From a588d46ff0cfd961ca75dfecac8acadd8a4242cf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 23:02:14 -0700 Subject: [PATCH 1/3] chat-workbench-loading: stop overlapping the header divider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-dot "Getting your workbench ready…" loader sat flush under .chat-workbench-header with no gap, so the dots rendered on top of the header's bottom rule instead of below it. Give the loader flex:1 so it claims the room's remaining space and centers within it, with its own padding and overflow: auto for containment at short viewport heights; apply the same overflow containment to .chat-workspace-frame, the pre-workbench loading screen's outer frame. --- packages/chat-ui/src/styles.css | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index a43e5f17..b3e2cf62 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -28,6 +28,11 @@ min-height: 0; align-items: center; justify-content: center; + /* Without this, a short viewport lets the centered loader overflow past + this frame's own box and render on top of the page header above it + (CL-6624) — `overflow` makes the frame clip/scroll its own content + instead of bleeding into chrome it doesn't own. */ + overflow: auto; } /* Stage top bar — mock `.top`: fixed 3rem row, tight title, trailing actions. */ @@ -718,13 +723,26 @@ } /* The async-mint setup state: three staggered orange squares (the - brand's zero-radius language) pulsing while the launches finish. */ + brand's zero-radius language) pulsing while the launches finish. + `flex: 1` claims the rest of `.chat-main`'s column instead of sitting + flush under `.chat-workbench-header` with no gap — without it the dots + render right on top of the header's bottom rule (CL-6624). Centering + in that claimed space, plus its own padding, keeps a real gap from the + header at any viewport height; `overflow: auto` is the same + containment `.chat-workspace-frame` needs for the pre-workbench + loading screen, so a very short viewport scrolls this in place rather + than bleeding past its box. */ .chat-workbench-loading { display: flex; + flex: 1; + min-height: 0; flex-direction: column; align-items: center; + justify-content: center; gap: 0.65rem; + padding: 1.5rem 1rem; text-align: center; + overflow: auto; } .chat-workbench-loading-mark { From b325009953de7cee685b13990f2de654ecc2395f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 23:02:22 -0700 Subject: [PATCH 2/3] Add tests for failed-turn Retry auto-resend Rewrites the old paste-into-composer assertion: Retry should hand the recovered text straight to the host's resend action, and the button should disable itself while that resend is in flight so a double-click can't fire two sends. --- .../chat-ui/test/failed-turn-strip.test.tsx | 59 ++++++++++++++++++- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/chat-ui/test/failed-turn-strip.test.tsx b/packages/chat-ui/test/failed-turn-strip.test.tsx index 3c140f00..1eea25d4 100644 --- a/packages/chat-ui/test/failed-turn-strip.test.tsx +++ b/packages/chat-ui/test/failed-turn-strip.test.tsx @@ -92,7 +92,9 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => { participants={[ { address: "ins_echo1@agents.example", handle: "echo" }, ]} - onRetryFailedTurn={(item) => retried.push(item.id)} + onRetryFailedTurn={(item) => { + retried.push(item.id); + }} onWhatHappenedFailedTurn={(item) => whatHappened.push(item.id)} />, ); @@ -185,7 +187,7 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => { ); }); - test("Retry hands back the original request text so it isn't lost", async () => { + test("Retry auto-resends the recovered request text — no composer round trip", async () => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -197,7 +199,9 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => { participants={[ { address: "ins_echo1@agents.example", handle: "echo" }, ]} - onRetryFailedTurn={(_item, retryText) => retried.push(retryText)} + onRetryFailedTurn={(_item, retryText) => { + retried.push(retryText); + }} />, ); }); @@ -208,6 +212,55 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => { ).click(); }); + // The strip hands the recovered text straight to the host's resend + // action — the host (chat-workspace.tsx) sends it through the normal + // send path itself; the strip never touches a composer. expect(retried).toEqual(["hi @echo"]); }); + + test("Retry disables itself while the resend is in flight, and re-enables once it settles", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const calls: (string | undefined)[] = []; + let resolveSend: (() => void) | undefined; + await act(async () => { + root?.render( + { + calls.push(retryText); + return new Promise((resolve) => { + resolveSend = resolve; + }); + }} + />, + ); + }); + + const retryButton = () => + container?.querySelector(".chat-turn-failed-retry") as HTMLButtonElement; + + act(() => { + retryButton().click(); + }); + // A second click while the first resend is still in flight must not + // fire a second send. + act(() => { + retryButton().click(); + }); + + expect(calls).toEqual(["hi @echo"]); + expect(retryButton().disabled).toBe(true); + + await act(async () => { + resolveSend?.(); + await Promise.resolve(); + }); + + expect(retryButton().disabled).toBe(false); + }); }); From 2c1907f2a3e769a5521d719d585d01821fa39483 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 23:02:31 -0700 Subject: [PATCH 3/3] Retry on a failed turn: auto-resend instead of pasting into composer Retry used to hand the recovered request text back into the composer for the person to send again by hand. It now sends that text straight through the normal send path itself, same as if they had typed it and hit Enter. The strip disables its own Retry button for the duration of that resend so a double-click can't fire it twice; the cause-aware failure text is unchanged. --- packages/chat-ui/src/chat-workspace.tsx | 24 ++++++++++++------------ packages/chat-ui/src/timeline.tsx | 18 ++++++++++++++---- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 5dc26c20..5ac17d37 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -552,18 +552,6 @@ function ChatWorkspaceInner({ const composerRef = useRef(null); - /** Retry on a failed-turn strip: the request text was already - * recovered (`findRetryText`) rather than resent silently — a person - * may have since fixed what broke, or may not want it re-sent - * verbatim, so this hands it back into the composer ready to send - * rather than re-sending on their behalf. */ - const handleRetryFailedTurn = useCallback( - (_item: TimelineMessageItem, retryText?: string) => { - if (retryText !== undefined) composerRef.current?.insertText(retryText); - }, - [], - ); - const feed = useWorkbenchFeed({ tenantId, activeWorkbenchId, @@ -888,6 +876,18 @@ function ChatWorkspaceInner({ restoreDraft: (text) => composerRef.current?.insertText(text), }); + /** Retry on a failed-turn strip: sends the recovered text + * (`findRetryText`) straight back through the normal send path — same + * as the person typing it and hitting Enter — rather than parking it + * in the composer for them to resend by hand. */ + const handleRetryFailedTurn = useCallback( + async (_item: TimelineMessageItem, retryText?: string) => { + if (retryText === undefined) return; + await handleSend({ text: retryText, attachments: [] }); + }, + [handleSend], + ); + // The mention popover's "Bring in…" group: only a `workbench` grows its // participants after creation (a chat's counterpart is fixed at // creation — see `workbench-service.ts`'s `joinHumanParticipant`/ diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index f1d30e55..9577f266 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -706,12 +706,15 @@ function FailedTurnStrip({ readonly onRetryFailedTurn?: ( item: TimelineMessageItem, retryText?: string, - ) => void; + ) => void | Promise; readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; }) { const display = senderDisplay(item.sender, participants, currentUser); const sender = display?.label ?? CHAT_STRINGS.senderFallbackMember; const [expanded, setExpanded] = useState(false); + // Guards the resend itself against a double-click firing two sends — + // not composer state, since Retry never touches the composer any more. + const [retrying, setRetrying] = useState(false); return (
@@ -722,7 +725,14 @@ function FailedTurnStrip({ variant="ghost" size="sm" className="chat-turn-failed-retry" - onClick={() => onRetryFailedTurn?.(item, retryText)} + disabled={retrying} + onClick={() => { + if (retrying) return; + setRetrying(true); + void Promise.resolve(onRetryFailedTurn?.(item, retryText)).finally( + () => setRetrying(false), + ); + }} > {CHAT_STRINGS.prThreadRetryAction} @@ -1280,7 +1290,7 @@ function MessageParts({ readonly onRetryFailedTurn?: ( item: TimelineMessageItem, retryText?: string, - ) => void; + ) => void | Promise; readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; }) { // A message this reader's own composer submitted and the server hasn't @@ -1690,7 +1700,7 @@ export function WorkbenchTimeline({ readonly onRetryFailedTurn?: ( item: TimelineMessageItem, retryText?: string, - ) => void; + ) => void | Promise; /** The failed-turn strip's "what happened" action — same undefined * contract as `onRetryFailedTurn`. */ readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void;