diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 7cb26e5bd..1c6f4d7e2 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -13,6 +13,7 @@ import { isAgentAddress } from "@corbits/chat/mentions"; import { Button, EmptyState, toast } from "@corbits/react-ui"; +import { reportError } from "@corbits/error-sink"; import { CaretDown, ChatCircle, @@ -56,6 +57,7 @@ import { useTurnActivity, TurnActivityStrip } from "./turn-activity"; import type { StreamingReplyState } from "./streaming-reply"; import { AgentBadge, WorkbenchTimeline, messageDomId } from "./timeline"; import { NoUsableModelBanner } from "./no-usable-model-banner"; +import { ResumeFailedBanner } from "./resume-failed-banner"; import type { CurrentUser, PinActions, @@ -625,6 +627,12 @@ function ChatWorkspaceInner({ onWorkbenchChange?.(id); }; const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + // CL-6833: catch-up `fetchRunningTurn` failure must surface a banner with + // Retry — never look idle. `resumeAttempt` re-arms the effect on Retry. + const [resumeFailedRefId, setResumeFailedRefId] = useState( + null, + ); + const [resumeAttempt, setResumeAttempt] = useState(0); // null = workbench root feed. A concrete id opens that thread in the same // geometry (timeline + composer). pendingParentMessageId is set when the // user opens a reply on a message that has no thread yet. @@ -932,20 +940,39 @@ function ChatWorkspaceInner({ // rather than showing nothing until the next live token arrives. Any // live event that beats this fetch back always wins — see // `resumeFromTurn`'s own guard. + // CL-6833: a failed catch-up must not swallow into idle — report a ref + // and keep `ResumeFailedBanner` visible until Retry succeeds (or the + // workbench changes). useEffect(() => { if (activeWorkbenchId === null || resumeAgentAddress === undefined) { + setResumeFailedRefId(null); return; } let cancelled = false; fetchRunningTurn(tenantId, activeWorkbenchId, resumeAgentAddress) .then((runningTurn) => { - if (!cancelled) resumeFromTurn(runningTurn); + if (cancelled) return; + setResumeFailedRefId(null); + resumeFromTurn(runningTurn); }) - .catch(() => undefined); + .catch((cause) => { + if (cancelled) return; + const refId = reportError(cause, { + operation: "chat.resumeRunningTurn", + tenantId, + roomId: activeWorkbenchId, + agentId: resumeAgentAddress, + }); + setResumeFailedRefId(refId); + }); return () => { cancelled = true; }; - }, [tenantId, activeWorkbenchId, resumeAgentAddress]); + }, [tenantId, activeWorkbenchId, resumeAgentAddress, resumeAttempt]); + + const handleRetryResume = useCallback(() => { + setResumeAttempt((attempt) => attempt + 1); + }, []); const { pendingSends, handleSend, retryPendingSend, discardPendingSend } = useOptimisticSends({ @@ -1456,6 +1483,12 @@ function ChatWorkspaceInner({ />
+ {resumeFailedRefId !== null ? ( + + ) : null} {hasUsableModel === false && hasAgentParticipant ? ( onConnectModel?.()} diff --git a/packages/chat-ui/src/resume-failed-banner.tsx b/packages/chat-ui/src/resume-failed-banner.tsx new file mode 100644 index 000000000..f028a9c9e --- /dev/null +++ b/packages/chat-ui/src/resume-failed-banner.tsx @@ -0,0 +1,27 @@ +// CL-6833: mid-turn reopen whose catch-up fetch (`fetchRunningTurn`) fails +// must not leave the room looking idle. This soft banner names the gap, +// quotes a `reportError` ref id, and offers Retry — never a silent +// `.catch(() => undefined)`. +import { Button } from "@corbits/react-ui"; +import { WarningCircle } from "@corbits/icons"; +import { CHAT_STRINGS } from "./strings"; + +export function ResumeFailedBanner({ + refId, + onRetry, +}: { + readonly refId: string; + readonly onRetry: () => void; +}) { + return ( +
+
+ ); +} diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 4b6c2f845..46dfb84fb 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -277,6 +277,9 @@ export const CHAT_STRINGS = { turnActivityThinking: "Thinking…", turnActivityRetry: (attempt: number) => `Retrying (attempt ${attempt})…`, replyTimedOutNotice: "No reply arrived — the agent may be unavailable.", + resumeFailedNotice: (refId: string) => + `Couldn't resume the running reply — try again. (ref ${refId})`, + resumeFailedRetryAction: "Retry", turnFailedTitle: (sender: string) => `${sender} didn't reply`, turnFailedSub: "No reply arrived — the agent may be unavailable.", noUsableModelBannerText: diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index c27f4b5d9..4662247a8 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -1847,6 +1847,31 @@ flex: 1; } +/* CL-6833: catch-up fetch for a still-running turn failed — soft alert + above the activity strip so a mid-turn reopen never looks idle. Same + visual weight as the no-model banner; role=alert is set in markup. */ +.chat-resume-failed-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.5rem; + padding: 0.4rem 0.6rem; + border-radius: var(--ui-radius-md, 0.375rem); + background: color-mix(in srgb, var(--destructive) 8%, transparent); + color: color-mix(in srgb, var(--destructive) 78%, var(--muted-foreground)); + font-size: 0.8125rem; +} + +.chat-resume-failed-banner svg { + flex-shrink: 0; + width: 1rem; + height: 1rem; +} + +.chat-resume-failed-banner-text { + flex: 1; +} + .chat-composer { position: relative; flex-shrink: 0; diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index c8e6daa70..1b1e3e618 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -53,6 +53,7 @@ const WORKBENCH_WIRE = { function stubFetch( sentMessages?: unknown[], workbench: typeof WORKBENCH_WIRE = WORKBENCH_WIRE, + options: { readonly turnsFail?: boolean } = {}, ) { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -81,6 +82,18 @@ function stubFetch( if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { return json({ items: [] }); } + // CL-6380 catch-up: empty list = nothing running. Returning this by + // default keeps agent-participant mounts from treating an unstubbed + // turns path as a resume failure (CL-6833). + if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) { + if (options.turnsFail === true) { + return new Response(JSON.stringify({ error: "turns unavailable" }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + return json({ items: [] }); + } if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { return json({ ...workbench, @@ -1729,6 +1742,96 @@ describe("Workbench header polish (CL-6106)", () => { }); }); +describe("CL-6833: running-turn resume failure is visible, never silent idle", () => { + test("a failed catch-up fetch shows the resume-failed banner with Retry", async () => { + stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE, { turnsFail: true }); + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + }); + await harness.settle(); + await harness.settle(); + + const banner = harness.container.querySelector( + ".chat-resume-failed-banner", + ); + expect(banner).not.toBeNull(); + expect(banner?.getAttribute("role")).toBe("alert"); + expect(harness.container.textContent).toContain( + "Couldn't resume the running reply", + ); + expect( + [...harness.container.querySelectorAll("button")].some( + (button) => button.textContent?.trim() === "Retry", + ), + ).toBe(true); + harness.unmount(); + }); + + test("a successful empty catch-up leaves the room without a resume banner", async () => { + stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE); + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + }); + await harness.settle(); + await harness.settle(); + + expect( + harness.container.querySelector(".chat-resume-failed-banner"), + ).toBeNull(); + harness.unmount(); + }); + + test("Retry re-asks the turns endpoint after a resume failure", async () => { + let turnsCalls = 0; + stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE, { turnsFail: true }); + const failingFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const path = typeof input === "string" ? input : String(input); + if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) { + turnsCalls += 1; + if (turnsCalls >= 2) { + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + } + return failingFetch(input, init); + }) as typeof fetch; + + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + }); + await harness.settle(); + await harness.settle(); + + expect( + harness.container.querySelector(".chat-resume-failed-banner"), + ).not.toBeNull(); + + const retry = [...harness.container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Retry", + ) as HTMLButtonElement; + await act(async () => { + retry.click(); + await sleep(30); + }); + await harness.settle(); + + expect(turnsCalls).toBeGreaterThanOrEqual(2); + expect( + harness.container.querySelector(".chat-resume-failed-banner"), + ).toBeNull(); + harness.unmount(); + }); +}); + describe("switching workbenches never carries a stale root-thread id across", () => { // CL-6067/6069 regression, still guarded after CL-6313 made it // structurally impossible: the timeline no longer fetches by thread id diff --git a/packages/chat-ui/test/resume-failed-banner.test.tsx b/packages/chat-ui/test/resume-failed-banner.test.tsx new file mode 100644 index 000000000..3523d7bc2 --- /dev/null +++ b/packages/chat-ui/test/resume-failed-banner.test.tsx @@ -0,0 +1,49 @@ +// CL-6833: mid-turn reopen whose catch-up fetch fails must not leave the +// room looking idle — a visible soft banner with Retry, never a silent +// swallow of `fetchRunningTurn(...).catch(() => undefined)`. +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { ResumeFailedBanner } from "../src/resume-failed-banner"; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; +}); + +describe("ResumeFailedBanner (CL-6833)", () => { + test("names the gap with a quotable ref and offers Retry, never a dead end", async () => { + const retries: number[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + retries.push(1)} + />, + ); + }); + + const banner = container.querySelector(".chat-resume-failed-banner"); + expect(banner).not.toBeNull(); + expect(banner?.getAttribute("role")).toBe("alert"); + expect(container.textContent).toContain( + "Couldn't resume the running reply", + ); + expect(container.textContent).toContain("ref mt4ewrje-zvbmti"); + + act(() => { + (container?.querySelector("button") as HTMLButtonElement).click(); + }); + expect(retries).toEqual([1]); + }); +});