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
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/chat-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@corbits/api-query": "workspace:*",
"@corbits/chat": "workspace:*",
"@corbits/context-menu": "workspace:*",
"@corbits/error-sink": "workspace:*",
"@corbits/inference-settings": "workspace:*",
"@corbits/preferences": "workspace:*",
"@corbits/presence": "workspace:*",
Expand Down
62 changes: 43 additions & 19 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,17 +250,26 @@ export function composerPlaceholderFor(
* `POST`/`GET` routes with a different shape). */
const STREAMING_REPLY_ITEM_ID = "streaming_reply";

/** The workbench's first agent participant — the best available
* attribution for a synthetic timeline item that has no real sender of
* its own (`chat.agent` events carry none, and a client-side timeout
* notice never had a server-issued sender to begin with). Workbenches
* with more than one invited agent are a known approximation here, not a
* regression — today's non-streaming refetch has the same "which agent
* replied" gap until the persisted message's real sender lands. */
function firstAgentParticipant(
participants: readonly ParticipantRecord[],
): ParticipantRecord | undefined {
return participants.find((participant) =>
isAgentAddress(participant.address),
);
}

/**
* Folds the active turn's in-progress reply onto the end of the timeline,
* exactly the way `mergePendingSends` folds this reader's own optimistic
* sends — except this synthetic item is the *other* side's message, so it
* needs a sender to attribute it to. `chat.agent` events carry no sender
* (the raw `InferenceEvent` union has no such field, see
* `streaming-reply.ts`), so this picks the workbench's first agent
* participant as the best available attribution; workbenches with more than
* one invited agent are a known approximation here, not a regression —
* today's non-streaming refetch has the same "which agent replied" gap
* until the persisted message's real sender lands.
* needs a sender to attribute it to (see `firstAgentParticipant`).
*/
export function mergeStreamingReply(
items: readonly TimelineMessageItem[],
Expand All @@ -279,9 +288,7 @@ export function mergeStreamingReply(
) {
return items;
}
const agent = participants.find((participant) =>
isAgentAddress(participant.address),
);
const agent = firstAgentParticipant(participants);
if (agent === undefined) return items;
return [
...items,
Expand All @@ -303,22 +310,38 @@ const REPLY_TIMED_OUT_ITEM_ID = "reply_timed_out_notice";
* Appends an honest inline notice once `useStreamingReply`'s own backstop
* (`PENDING_REPLY_CLEAR_MS`) has fired — a turn that opened but never got a
* single token and never closed out either, so the reader was left staring
* at a typing indicator that just vanished with no explanation. Renders
* through the same event-line path `mergeStreamingReply`'s bubble and every
* other system line already use — no new CSS, no new item shape.
* at a typing indicator that just vanished with no explanation. This is
* the same class of failure `postUndeliveredNotice` (`@corbits/chat`)
* already gives an honest, actionable backstop to when the dispatch fails
* loud enough for the server to see it — a cold-waking agent that never
* streams a token back fails silently instead, so before CL-6677 this
* synthetic notice rendered as a bare quiet event line with no ref id and
* no Retry. It now carries a `turnFailed` text part exactly like the
* server's own notice (`replyTimedOutRefId` is minted by `reportError` at
* the moment `useStreamingReply`'s timer fires), so it renders through
* `FailedTurnStrip` — same ref-quotable copy, same Retry action — instead
* of a second, weaker backstop living beside the real one.
*/
export function appendReplyTimedOutNotice(
items: readonly TimelineMessageItem[],
replyTimedOut: boolean,
replyTimedOutRefId: string | null,
participants: readonly ParticipantRecord[],
): readonly TimelineMessageItem[] {
if (!replyTimedOut) return items;
if (replyTimedOutRefId === null) return items;
const agent = firstAgentParticipant(participants);
return [
...items,
{
id: REPLY_TIMED_OUT_ITEM_ID,
createdAt: new Date().toISOString(),
parts: [{ kind: "event", event: "chat.reply-timed-out", data: {} }],
sender: { name: null, address: "" },
parts: [
{
kind: "text",
text: `${CHAT_STRINGS.replyTimedOutNotice} (ref ${replyTimedOutRefId})`,
turnFailed: true,
},
],
sender: { name: null, address: agent?.address ?? "" },
},
];
}
Expand Down Expand Up @@ -667,7 +690,7 @@ function ChatWorkspaceInner({
useTypingIndicator(currentUser?.principalId, activeWorkbenchId);
const {
streamingReply,
replyTimedOut,
replyTimedOutRefId,
handleStreamEvent: handleStreamingReplyEvent,
noteAwaitingReply,
resumeFromTurn,
Expand Down Expand Up @@ -1282,7 +1305,8 @@ function ChatWorkspaceInner({
streamingReply,
activeWorkbench?.participants ?? [],
),
replyTimedOut,
replyTimedOutRefId,
activeWorkbench?.participants ?? [],
)}
participants={activeWorkbench?.participants ?? []}
{...(currentUser !== undefined ? { currentUser } : {})}
Expand Down
46 changes: 32 additions & 14 deletions packages/chat-ui/src/streaming-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { useEffect, useRef, useState } from "react";

import { isAgentAddress } from "@corbits/chat/mentions";
import { reportError } from "@corbits/error-sink";
import type { ParticipantRecord } from "./api";
import { displayNameFromHandle } from "./timeline";

Expand Down Expand Up @@ -262,12 +263,17 @@ export function useStreamingReply(
minVisibleMs: number = TYPING_INDICATOR_MIN_VISIBLE_MS,
): {
readonly streamingReply: StreamingReplyState;
/** True once the backstop above has fired for the turn just cleared —
* the host's cue to render `CHAT_STRINGS.replyTimedOutNotice` rather
* than silently dropping back to no indicator at all. Reset on the
* next workbench switch, stream event, or awaited reply, same lifecycle
* as `streamingReply` itself. */
readonly replyTimedOut: boolean;
/** Set once the backstop above has fired for the turn just cleared — a
* `reportError` refId (CL-6677) the host's honest notice quotes, the
* same "ref id + Retry" treatment `postUndeliveredNotice`
* (`@corbits/chat`) gives a dispatch failure that surfaces server-side.
* This is the same class of failure with no server signal at all (a
* cold-waking agent that never streams back a token), so it deserves
* the same backstop rather than the ref-less, action-less notice this
* used to render. `null` when idle. Reset on the next workbench switch,
* stream event, or awaited reply, same lifecycle as `streamingReply`
* itself. */
readonly replyTimedOutRefId: string | null;
readonly handleStreamEvent: (eventType: string, data: unknown) => void;
readonly noteAwaitingReply: () => void;
/** See `resumeFromTurn`'s own doc comment below. */
Expand All @@ -277,13 +283,15 @@ export function useStreamingReply(
} {
const [streamingReply, setStreamingReply] =
useState<StreamingReplyState>(null);
const [replyTimedOut, setReplyTimedOut] = useState(false);
const [replyTimedOutRefId, setReplyTimedOutRefId] = useState<string | null>(
null,
);
const pendingSinceRef = useRef<number | null>(null);
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
setStreamingReply(null);
setReplyTimedOut(false);
setReplyTimedOutRefId(null);
pendingSinceRef.current = null;
if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current);
Expand All @@ -307,12 +315,22 @@ export function useStreamingReply(
// timer) the instant `streamingReply` changes, so this callback only
// ever runs while it's still the same pending reply it was armed
// for — no need to re-check identity here.
const refId = reportError(
new Error(
`Reply timed out: no token received within ${clearMs}ms` +
(workbenchId !== null ? ` for workbench ${workbenchId}` : ""),
),
{
operation: "chat.replyTimedOut",
...(workbenchId !== null ? { roomId: workbenchId } : {}),
},
);
setStreamingReply(null);
setReplyTimedOut(true);
setReplyTimedOutRefId(refId);
pendingSinceRef.current = null;
}, clearMs);
return () => clearTimeout(timer);
}, [streamingReply, clearMs]);
}, [streamingReply, clearMs, workbenchId]);

function commitReply(
next: StreamingReplyState,
Expand Down Expand Up @@ -348,7 +366,7 @@ export function useStreamingReply(
}

function handleStreamEvent(eventType: string, data: unknown) {
setReplyTimedOut(false);
setReplyTimedOutRefId(null);
setStreamingReply((current) =>
commitReply(
nextStreamingReplyState(current, { eventType, data }),
Expand All @@ -358,7 +376,7 @@ export function useStreamingReply(
}

function noteAwaitingReply() {
setReplyTimedOut(false);
setReplyTimedOutRefId(null);
setStreamingReply((current) =>
commitReply(openPendingReply(current), current),
);
Expand All @@ -378,15 +396,15 @@ export function useStreamingReply(
runningTurn: { readonly textSnapshot?: string | null } | null,
) {
if (runningTurn === null) return;
setReplyTimedOut(false);
setReplyTimedOutRefId(null);
setStreamingReply(
(current) => current ?? hydrateStreamingReplyFromTurn(runningTurn),
);
}

return {
streamingReply,
replyTimedOut,
replyTimedOutRefId,
handleStreamEvent,
noteAwaitingReply,
resumeFromTurn,
Expand Down
2 changes: 0 additions & 2 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -634,8 +634,6 @@ export function friendlyEventText(
}
return CHAT_STRINGS.eventSettingsChanged;
}
case "chat.reply-timed-out":
return CHAT_STRINGS.replyTimedOutNotice;
case "block.response": {
const kind = data !== undefined ? data.kind : undefined;
return kind === "poll"
Expand Down
52 changes: 41 additions & 11 deletions packages/chat-ui/test/message-grouping-and-failed-polish.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,29 +184,59 @@ describe("failed pending message's inline recovery affordance", () => {
});
});

// CL-6252 #6: the reply backstop's synthetic event item renders through
// the same event-line path every other system line uses, with an honest
// message rather than the strip just silently vanishing.
describe("the reply-timed-out notice renders as an event line", () => {
test("shows the honest 'no reply arrived' copy", async () => {
// CL-6677: the client-side reply-timeout backstop (a cold-waking room —
// PR #327's defer-to-wake path — that never streams a single token back)
// used to render as a bare quiet event line: no ref id, no Retry. It now
// carries a `turnFailed` text part exactly like the server's own
// undelivered-turn notice (`postUndeliveredNotice`, CL-6308/CL-6644), so
// it renders through the same `FailedTurnStrip` — ref id quotable, Retry
// wired — instead of a second, weaker backstop with no actions.
describe("the reply-timed-out notice gets the same ref+Retry treatment as the server-side backstop", () => {
test("shows the honest 'no reply arrived' copy with a quotable ref id, through FailedTurnStrip", async () => {
const items: MessageItem[] = [
{
id: "notice_1",
createdAt: "2026-01-01T00:00:00.000Z",
parts: [{ kind: "event", event: "chat.reply-timed-out", data: {} }],
sender: { name: null, address: "" },
parts: [
{
kind: "text",
text: "No reply arrived — the agent may be unavailable. (ref mt4ewrje-zvbmti)",
turnFailed: true,
},
],
sender: { name: null, address: "myra@agents.example" },
},
];
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<WorkbenchTimeline items={items} />);
root?.render(
<WorkbenchTimeline
items={items}
participants={[{ address: "myra@agents.example", handle: "myra" }]}
/>,
);
});

expect(container.querySelector(".chat-event-line")?.textContent).toContain(
"No reply arrived — the agent may be unavailable.",
);
const strip = container.querySelector(".chat-turn-failed");
expect(strip).not.toBeNull();
expect(strip?.textContent).toContain("didn't reply");

const retryButton = container.querySelector(".chat-turn-failed-retry");
expect(retryButton).not.toBeNull();

act(() => {
container
?.querySelector(".chat-turn-failed-disclosure")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(
container.querySelector(".chat-turn-failed-detail")?.textContent,
).toContain("(ref mt4ewrje-zvbmti)");

// Never the old, action-less plain event line for this failure.
expect(container.querySelector(".chat-event-line")).toBeNull();
});
});

Expand Down
Loading
Loading