fix(scheduling): harden edit/reschedule after review

Address findings from 3 review agents on the edit/reschedule feature:

- Race (correctness): handleEdit now marks the old message as cancelling
  while its cancel is in flight, so its Edit/Cancel buttons are disabled.
  Previously the old row stayed live during the fire-and-forget cancel, so
  a fast second edit could orphan a still-scheduled event and send twice.

- Durability (correctness): on a failed cancel-old, re-insert the old
  message if auto-prune removed its row while the modal was open, so the
  still-live delayed event stays visible and retriable instead of failing
  silently. Also clear any stale cancel error when starting an edit.

- a11y: per-row Edit/Cancel buttons now carry distinct aria-labels that
  include the message preview and send-time, so screen-reader users can
  tell which of several scheduled messages each button targets.

- UX: modal gains a submitLabel prop; the edit flow shows "Reschedule"
  instead of "Schedule". Modal now focuses the message body on open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:54:57 -04:00
co-authored by Claude Opus 4.8
parent 5d5ae0ee70
commit cacefb1f30
2 changed files with 51 additions and 11 deletions
@@ -30,6 +30,8 @@ interface ScheduleMessageModalProps {
initialSendAt?: number;
/** Header title; defaults to "Schedule Message". */
title?: string;
/** Primary-button label; defaults to "Schedule" (e.g. "Reschedule" when editing). */
submitLabel?: string;
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
onClose: () => void;
}
@@ -94,6 +96,7 @@ export function ScheduleMessageModal({
initialBody,
initialSendAt,
title = 'Schedule Message',
submitLabel = 'Schedule',
onScheduled,
onClose,
}: ScheduleMessageModalProps) {
@@ -179,7 +182,7 @@ export function ScheduleMessageModal({
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
initialFocus: '#schedule-message-body',
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
@@ -341,7 +344,7 @@ export function ScheduleMessageModal({
disabled={submitting || !preview}
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
>
<Text size="B400">Schedule</Text>
<Text size="B400">{submitLabel}</Text>
</Button>
</Box>
</Dialog>
@@ -115,12 +115,25 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
// cancel leaves it visible (and retriable) instead of letting it silently fire.
const handleEdit = useCallback(
(oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => {
// Add the newly-scheduled message up front (nothing lost yet).
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId);
next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]);
return next;
});
// Mark the old message as cancelling so its row's Edit/Cancel buttons are
// disabled while we tear it down. Without this the old row stays live during
// the in-flight cancel and a second edit could orphan a still-scheduled event
// (both would fire). Also clear any stale error from a prior failed cancel.
setCancelling((prev) => new Set(prev).add(oldMsg.delayId));
setCancelErrors((prev) => {
if (!prev.has(oldMsg.delayId)) return prev;
const next = new Set(prev);
next.delete(oldMsg.delayId);
return next;
});
setEditing(null);
cancelScheduledMessage(mx, oldMsg.delayId)
.then(() => {
setScheduledMessages((prev) => {
@@ -131,8 +144,28 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
return next;
});
})
.catch(() => setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId)));
setEditing(null);
.catch(() => {
// Cancel failed — the old delayed event is still live server-side, so it
// must stay visible and retriable. Re-insert it if auto-prune removed the
// row while the modal was open, otherwise the failure (and the duplicate
// it will send) would be invisible.
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = next.get(roomId) ?? [];
if (!current.some((m) => m.delayId === oldMsg.delayId)) {
next.set(roomId, [...current, oldMsg]);
}
return next;
});
setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId));
})
.finally(() => {
setCancelling((prev) => {
const next = new Set(prev);
next.delete(oldMsg.delayId);
return next;
});
});
},
[mx, roomId, setScheduledMessages],
);
@@ -147,6 +180,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
initialSendAt={editing.sendAt}
title="Edit scheduled message"
submitLabel="Reschedule"
onScheduled={(newDelayId, sendAt, content) =>
handleEdit(editing, newDelayId, sendAt, content)
}
@@ -183,7 +217,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
{/* Tray items */}
{expanded && (
<Box direction="Column">
{messages.map((msg) => (
{messages.map((msg) => {
const bodyPreview =
typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)';
const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`;
return (
<Box
key={msg.delayId}
direction="Column"
@@ -203,9 +241,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
whiteSpace: 'nowrap',
}}
>
{typeof msg.content.body === 'string'
? (msg.content.body as string)
: '(message)'}
{bodyPreview}
</Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
@@ -214,7 +250,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
size="300"
radii="300"
variant="SurfaceVariant"
aria-label="Edit scheduled message"
aria-label={`Edit scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
@@ -227,7 +263,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
size="300"
radii="300"
variant="SurfaceVariant"
aria-label="Cancel scheduled message"
aria-label={`Cancel scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
@@ -246,7 +282,8 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
</Text>
)}
</Box>
))}
);
})}
</Box>
)}
</Box>