Skip to content

Commit bf08d8a

Browse files
Show resume failure when a running turn cannot be restored (#396)
* CL-6833: Show resume failure when a running turn cannot be restored Surface catch-up fetchRunningTurn failures with a Retry banner instead of leaving the room looking idle after a mid-turn reopen. * Format files changed in this PR
1 parent 2a8a36d commit bf08d8a

6 files changed

Lines changed: 243 additions & 3 deletions

File tree

‎packages/chat-ui/src/chat-workspace.tsx‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import { isAgentAddress } from "@corbits/chat/mentions";
1515
import { Button, EmptyState, toast } from "@corbits/react-ui";
16+
import { reportError } from "@corbits/error-sink";
1617
import {
1718
CaretDown,
1819
ChatCircle,
@@ -56,6 +57,7 @@ import { useTurnActivity, TurnActivityStrip } from "./turn-activity";
5657
import type { StreamingReplyState } from "./streaming-reply";
5758
import { AgentBadge, WorkbenchTimeline, messageDomId } from "./timeline";
5859
import { NoUsableModelBanner } from "./no-usable-model-banner";
60+
import { ResumeFailedBanner } from "./resume-failed-banner";
5961
import type {
6062
CurrentUser,
6163
PinActions,
@@ -669,6 +671,12 @@ function ChatWorkspaceInner({
669671
onWorkbenchChange?.(id);
670672
};
671673
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
674+
// CL-6833: catch-up `fetchRunningTurn` failure must surface a banner with
675+
// Retry — never look idle. `resumeAttempt` re-arms the effect on Retry.
676+
const [resumeFailedRefId, setResumeFailedRefId] = useState<string | null>(
677+
null,
678+
);
679+
const [resumeAttempt, setResumeAttempt] = useState(0);
672680
// null = workbench root feed. A concrete id opens that thread in the same
673681
// geometry (timeline + composer). pendingParentMessageId is set when the
674682
// user opens a reply on a message that has no thread yet.
@@ -976,20 +984,39 @@ function ChatWorkspaceInner({
976984
// rather than showing nothing until the next live token arrives. Any
977985
// live event that beats this fetch back always wins — see
978986
// `resumeFromTurn`'s own guard.
987+
// CL-6833: a failed catch-up must not swallow into idle — report a ref
988+
// and keep `ResumeFailedBanner` visible until Retry succeeds (or the
989+
// workbench changes).
979990
useEffect(() => {
980991
if (activeWorkbenchId === null || resumeAgentAddress === undefined) {
992+
setResumeFailedRefId(null);
981993
return;
982994
}
983995
let cancelled = false;
984996
fetchRunningTurn(tenantId, activeWorkbenchId, resumeAgentAddress)
985997
.then((runningTurn) => {
986-
if (!cancelled) resumeFromTurn(runningTurn);
998+
if (cancelled) return;
999+
setResumeFailedRefId(null);
1000+
resumeFromTurn(runningTurn);
9871001
})
988-
.catch(() => undefined);
1002+
.catch((cause) => {
1003+
if (cancelled) return;
1004+
const refId = reportError(cause, {
1005+
operation: "chat.resumeRunningTurn",
1006+
tenantId,
1007+
roomId: activeWorkbenchId,
1008+
agentId: resumeAgentAddress,
1009+
});
1010+
setResumeFailedRefId(refId);
1011+
});
9891012
return () => {
9901013
cancelled = true;
9911014
};
992-
}, [tenantId, activeWorkbenchId, resumeAgentAddress]);
1015+
}, [tenantId, activeWorkbenchId, resumeAgentAddress, resumeAttempt]);
1016+
1017+
const handleRetryResume = useCallback(() => {
1018+
setResumeAttempt((attempt) => attempt + 1);
1019+
}, []);
9931020

9941021
const { pendingSends, handleSend, retryPendingSend, discardPendingSend } =
9951022
useOptimisticSends({
@@ -1502,6 +1529,12 @@ function ChatWorkspaceInner({
15021529
/>
15031530
<TurnActivityStrip activity={turnActivity} />
15041531
<div className="chat-composer-stack">
1532+
{resumeFailedRefId !== null ? (
1533+
<ResumeFailedBanner
1534+
refId={resumeFailedRefId}
1535+
onRetry={handleRetryResume}
1536+
/>
1537+
) : null}
15051538
{hasUsableModel === false && hasAgentParticipant ? (
15061539
<NoUsableModelBanner
15071540
onConnectModel={() => onConnectModel?.()}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// CL-6833: mid-turn reopen whose catch-up fetch (`fetchRunningTurn`) fails
2+
// must not leave the room looking idle. This soft banner names the gap,
3+
// quotes a `reportError` ref id, and offers Retry — never a silent
4+
// `.catch(() => undefined)`.
5+
import { Button } from "@corbits/react-ui";
6+
import { WarningCircle } from "@corbits/icons";
7+
import { CHAT_STRINGS } from "./strings";
8+
9+
export function ResumeFailedBanner({
10+
refId,
11+
onRetry,
12+
}: {
13+
readonly refId: string;
14+
readonly onRetry: () => void;
15+
}) {
16+
return (
17+
<div className="chat-resume-failed-banner" role="alert">
18+
<WarningCircle aria-hidden="true" />
19+
<span className="chat-resume-failed-banner-text">
20+
{CHAT_STRINGS.resumeFailedNotice(refId)}
21+
</span>
22+
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
23+
{CHAT_STRINGS.resumeFailedRetryAction}
24+
</Button>
25+
</div>
26+
);
27+
}

‎packages/chat-ui/src/strings.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ export const CHAT_STRINGS = {
289289
turnActivityThinking: "Thinking…",
290290
turnActivityRetry: (attempt: number) => `Retrying (attempt ${attempt})…`,
291291
replyTimedOutNotice: "No reply arrived — the agent may be unavailable.",
292+
resumeFailedNotice: (refId: string) =>
293+
`Couldn't resume the running reply — try again. (ref ${refId})`,
294+
resumeFailedRetryAction: "Retry",
292295
turnFailedTitle: (sender: string) => `${sender} didn't reply`,
293296
turnFailedSub: "No reply arrived — the agent may be unavailable.",
294297
noUsableModelBannerText:

‎packages/chat-ui/src/styles.css‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1853,6 +1853,31 @@
18531853
flex: 1;
18541854
}
18551855

1856+
/* CL-6833: catch-up fetch for a still-running turn failed — soft alert
1857+
above the activity strip so a mid-turn reopen never looks idle. Same
1858+
visual weight as the no-model banner; role=alert is set in markup. */
1859+
.chat-resume-failed-banner {
1860+
display: flex;
1861+
align-items: center;
1862+
gap: 0.5rem;
1863+
margin: 0 0 0.5rem;
1864+
padding: 0.4rem 0.6rem;
1865+
border-radius: var(--ui-radius-md, 0.375rem);
1866+
background: color-mix(in srgb, var(--destructive) 8%, transparent);
1867+
color: color-mix(in srgb, var(--destructive) 78%, var(--muted-foreground));
1868+
font-size: 0.8125rem;
1869+
}
1870+
1871+
.chat-resume-failed-banner svg {
1872+
flex-shrink: 0;
1873+
width: 1rem;
1874+
height: 1rem;
1875+
}
1876+
1877+
.chat-resume-failed-banner-text {
1878+
flex: 1;
1879+
}
1880+
18561881
.chat-composer {
18571882
position: relative;
18581883
flex-shrink: 0;

‎packages/chat-ui/test/chat-workspace.test.tsx‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const WORKBENCH_WIRE = {
5353
function stubFetch(
5454
sentMessages?: unknown[],
5555
workbench: typeof WORKBENCH_WIRE = WORKBENCH_WIRE,
56+
options: { readonly turnsFail?: boolean } = {},
5657
) {
5758
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
5859
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -81,6 +82,18 @@ function stubFetch(
8182
if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) {
8283
return json({ items: [] });
8384
}
85+
// CL-6380 catch-up: empty list = nothing running. Returning this by
86+
// default keeps agent-participant mounts from treating an unstubbed
87+
// turns path as a resume failure (CL-6833).
88+
if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) {
89+
if (options.turnsFail === true) {
90+
return new Response(JSON.stringify({ error: "turns unavailable" }), {
91+
status: 500,
92+
headers: { "content-type": "application/json" },
93+
});
94+
}
95+
return json({ items: [] });
96+
}
8497
if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) {
8598
return json({
8699
...workbench,
@@ -1729,6 +1742,96 @@ describe("Workbench header polish (CL-6106)", () => {
17291742
});
17301743
});
17311744

1745+
describe("CL-6833: running-turn resume failure is visible, never silent idle", () => {
1746+
test("a failed catch-up fetch shows the resume-failed banner with Retry", async () => {
1747+
stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE, { turnsFail: true });
1748+
const harness = await mount({
1749+
tenant: { kind: "ready", tenantId: "tnt_1" },
1750+
workbenchId: "ch_1",
1751+
});
1752+
await harness.settle();
1753+
await harness.settle();
1754+
1755+
const banner = harness.container.querySelector(
1756+
".chat-resume-failed-banner",
1757+
);
1758+
expect(banner).not.toBeNull();
1759+
expect(banner?.getAttribute("role")).toBe("alert");
1760+
expect(harness.container.textContent).toContain(
1761+
"Couldn't resume the running reply",
1762+
);
1763+
expect(
1764+
[...harness.container.querySelectorAll("button")].some(
1765+
(button) => button.textContent?.trim() === "Retry",
1766+
),
1767+
).toBe(true);
1768+
harness.unmount();
1769+
});
1770+
1771+
test("a successful empty catch-up leaves the room without a resume banner", async () => {
1772+
stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE);
1773+
const harness = await mount({
1774+
tenant: { kind: "ready", tenantId: "tnt_1" },
1775+
workbenchId: "ch_1",
1776+
});
1777+
await harness.settle();
1778+
await harness.settle();
1779+
1780+
expect(
1781+
harness.container.querySelector(".chat-resume-failed-banner"),
1782+
).toBeNull();
1783+
harness.unmount();
1784+
});
1785+
1786+
test("Retry re-asks the turns endpoint after a resume failure", async () => {
1787+
let turnsCalls = 0;
1788+
stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE, { turnsFail: true });
1789+
const failingFetch = globalThis.fetch;
1790+
globalThis.fetch = (async (
1791+
input: RequestInfo | URL,
1792+
init?: RequestInit,
1793+
) => {
1794+
const path = typeof input === "string" ? input : String(input);
1795+
if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) {
1796+
turnsCalls += 1;
1797+
if (turnsCalls >= 2) {
1798+
return new Response(JSON.stringify({ items: [] }), {
1799+
status: 200,
1800+
headers: { "content-type": "application/json" },
1801+
});
1802+
}
1803+
}
1804+
return failingFetch(input, init);
1805+
}) as typeof fetch;
1806+
1807+
const harness = await mount({
1808+
tenant: { kind: "ready", tenantId: "tnt_1" },
1809+
workbenchId: "ch_1",
1810+
});
1811+
await harness.settle();
1812+
await harness.settle();
1813+
1814+
expect(
1815+
harness.container.querySelector(".chat-resume-failed-banner"),
1816+
).not.toBeNull();
1817+
1818+
const retry = [...harness.container.querySelectorAll("button")].find(
1819+
(button) => button.textContent?.trim() === "Retry",
1820+
) as HTMLButtonElement;
1821+
await act(async () => {
1822+
retry.click();
1823+
await sleep(30);
1824+
});
1825+
await harness.settle();
1826+
1827+
expect(turnsCalls).toBeGreaterThanOrEqual(2);
1828+
expect(
1829+
harness.container.querySelector(".chat-resume-failed-banner"),
1830+
).toBeNull();
1831+
harness.unmount();
1832+
});
1833+
});
1834+
17321835
describe("switching workbenches never carries a stale root-thread id across", () => {
17331836
// CL-6067/6069 regression, still guarded after CL-6313 made it
17341837
// structurally impossible: the timeline no longer fetches by thread id
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// CL-6833: mid-turn reopen whose catch-up fetch fails must not leave the
2+
// room looking idle — a visible soft banner with Retry, never a silent
3+
// swallow of `fetchRunningTurn(...).catch(() => undefined)`.
4+
import { afterEach, describe, expect, test } from "bun:test";
5+
import { act } from "react";
6+
import { createRoot } from "react-dom/client";
7+
import type { Root } from "react-dom/client";
8+
9+
import { ResumeFailedBanner } from "../src/resume-failed-banner";
10+
11+
let container: HTMLDivElement | null = null;
12+
let root: Root | null = null;
13+
14+
afterEach(() => {
15+
if (root !== null) act(() => root?.unmount());
16+
container?.remove();
17+
container = null;
18+
root = null;
19+
});
20+
21+
describe("ResumeFailedBanner (CL-6833)", () => {
22+
test("names the gap with a quotable ref and offers Retry, never a dead end", async () => {
23+
const retries: number[] = [];
24+
container = document.createElement("div");
25+
document.body.appendChild(container);
26+
root = createRoot(container);
27+
await act(async () => {
28+
root?.render(
29+
<ResumeFailedBanner
30+
refId="mt4ewrje-zvbmti"
31+
onRetry={() => retries.push(1)}
32+
/>,
33+
);
34+
});
35+
36+
const banner = container.querySelector(".chat-resume-failed-banner");
37+
expect(banner).not.toBeNull();
38+
expect(banner?.getAttribute("role")).toBe("alert");
39+
expect(container.textContent).toContain(
40+
"Couldn't resume the running reply",
41+
);
42+
expect(container.textContent).toContain("ref mt4ewrje-zvbmti");
43+
44+
act(() => {
45+
(container?.querySelector("button") as HTMLButtonElement).click();
46+
});
47+
expect(retries).toEqual([1]);
48+
});
49+
});

0 commit comments

Comments
 (0)