Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(
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.
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -1456,6 +1483,12 @@ function ChatWorkspaceInner({
/>
<TurnActivityStrip activity={turnActivity} />
<div className="chat-composer-stack">
{resumeFailedRefId !== null ? (
<ResumeFailedBanner
refId={resumeFailedRefId}
onRetry={handleRetryResume}
/>
) : null}
{hasUsableModel === false && hasAgentParticipant ? (
<NoUsableModelBanner
onConnectModel={() => onConnectModel?.()}
Expand Down
27 changes: 27 additions & 0 deletions packages/chat-ui/src/resume-failed-banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="chat-resume-failed-banner" role="alert">
<WarningCircle aria-hidden="true" />
<span className="chat-resume-failed-banner-text">
{CHAT_STRINGS.resumeFailedNotice(refId)}
</span>
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
{CHAT_STRINGS.resumeFailedRetryAction}
</Button>
</div>
);
}
3 changes: 3 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
103 changes: 103 additions & 0 deletions packages/chat-ui/test/chat-workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/chat-ui/test/resume-failed-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ResumeFailedBanner
refId="mt4ewrje-zvbmti"
onRetry={() => 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]);
});
});
Loading