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/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 { 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; 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); + }); });