Skip to content

Commit 6055c58

Browse files
committed
🤖 fix: add stream startup breadcrumbs
Add lightweight info-level breadcrumbs around pre-stream startup so hangs can be narrowed to init, runtime checks, tool loading, request preparation, or stream start. Reuse the existing runtime-status event channel so the current startup phase is also visible in the StreamingBarrier UI instead of a generic starting message. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$11.73`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=11.73 -->
1 parent 9ef71ed commit 6055c58

File tree

12 files changed

+292
-19
lines changed

12 files changed

+292
-19
lines changed

src/browser/features/Messages/ChatBarrier/StreamingBarrier.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,19 @@ describe("StreamingBarrier", () => {
158158
expect(interruptStream).toHaveBeenCalledWith({ workspaceId: "ws-1" });
159159
});
160160

161+
test("shows backend startup breadcrumb text while the stream is starting", () => {
162+
currentWorkspaceState = createWorkspaceState({
163+
canInterrupt: false,
164+
pendingStreamStartTime: Date.now(),
165+
pendingStreamModel: "openai:gpt-4o-mini",
166+
runtimeStatus: { phase: "starting", detail: "Loading tools..." },
167+
});
168+
169+
const view = render(<StreamingBarrier workspaceId="ws-1" />);
170+
171+
expect(view.getByText("Loading tools...")).toBeTruthy();
172+
});
173+
161174
test("shows vim interrupt shortcut when vim mode is enabled", () => {
162175
currentWorkspaceState = createWorkspaceState({
163176
canInterrupt: false,

src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
105105
const statusText = (() => {
106106
switch (phase) {
107107
case "starting":
108-
// Show a runtime-specific message if the workspace is still booting (e.g., Coder/devcontainers).
108+
// Prefer any backend-provided startup breadcrumb so users can see whether
109+
// we're still booting the runtime or doing later prep like loading tools.
109110
if (runtimeStatus?.phase === "starting" || runtimeStatus?.phase === "waiting") {
110111
return runtimeStatus.detail ?? "Starting workspace...";
111112
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { fn } from "@storybook/test";
3+
import { lightweightMeta } from "@/browser/stories/meta.js";
4+
import { StreamingBarrierView } from "./StreamingBarrierView";
5+
6+
const meta = {
7+
...lightweightMeta,
8+
title: "App/Chat/Barriers/Streaming",
9+
component: StreamingBarrierView,
10+
render: (args) => (
11+
<div className="bg-background flex min-h-screen items-start p-6">
12+
<div className="w-full max-w-3xl rounded-md border border-[var(--color-border-medium)] bg-[var(--color-card)] p-4">
13+
<StreamingBarrierView {...args} />
14+
</div>
15+
</div>
16+
),
17+
} satisfies Meta<typeof StreamingBarrierView>;
18+
19+
export default meta;
20+
21+
type Story = StoryObj<typeof meta>;
22+
23+
/**
24+
* Frontend display for the first-init SSH diagnostic state while the workspace
25+
* is still waiting for initialization to complete.
26+
*/
27+
export const WaitingForWorkspaceInitialization: Story = {
28+
args: {
29+
statusText: "Waiting for workspace initialization...",
30+
cancelText: "hit Esc to cancel",
31+
cancelShortcutText: "Esc",
32+
onCancel: fn(),
33+
},
34+
parameters: {
35+
docs: {
36+
description: {
37+
story:
38+
"Shows the startup diagnostic users see when an SSH workspace is still in first-init setup and the frontend is waiting for workspace initialization.",
39+
},
40+
},
41+
},
42+
};
43+
44+
/**
45+
* Frontend display for the later startup diagnostic state after runtime
46+
* readiness, when the request is still assembling tools.
47+
*/
48+
export const LoadingToolsDiagnostic: Story = {
49+
args: {
50+
statusText: "Loading tools...",
51+
cancelText: "hit Esc to cancel",
52+
cancelShortcutText: "Esc",
53+
onCancel: fn(),
54+
},
55+
parameters: {
56+
docs: {
57+
description: {
58+
story:
59+
"Shows the more specific startup diagnostic text after runtime readiness succeeds but request startup is still blocked on tool assembly.",
60+
},
61+
},
62+
},
63+
};

src/browser/stores/WorkspaceStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export interface WorkspaceState {
102102
pendingStreamStartTime: number | null;
103103
// Model used for the pending send (used during "starting" phase)
104104
pendingStreamModel: string | null;
105-
// Runtime status from ensureReady (for Coder workspace starting UX)
105+
// Current pre-stream startup breadcrumb (runtime readiness, tool loading, etc.)
106106
runtimeStatus: RuntimeStatusEvent | null;
107107
autoRetryStatus: AutoRetryStatus | null;
108108
// Live streaming stats (updated on each stream-delta)

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,9 @@ export class StreamingMessageAggregator {
433433
// Last observed stream-abort reason (used to gate auto-retry).
434434
private lastAbortReason: StreamAbortReasonSnapshot | null = null;
435435

436-
// Current runtime status (set during ensureReady for Coder workspaces)
437-
// Used to show "Starting Coder workspace..." in StreamingBarrier
436+
// Current pre-stream startup status.
437+
// This begins with runtime readiness for Coder, but also carries generic
438+
// startup breadcrumbs like "Loading tools..." while the request is preparing.
438439
private runtimeStatus: RuntimeStatusEvent | null = null;
439440

440441
// Pending compaction request metadata for the next stream (set when user message arrives).
@@ -1178,8 +1179,8 @@ export class StreamingMessageAggregator {
11781179
}
11791180

11801181
/**
1181-
* Get the current runtime status (for Coder workspace starting UX).
1182-
* Returns null if no runtime status is active.
1182+
* Get the current pre-stream startup status.
1183+
* Returns null if no startup breadcrumb is active.
11831184
*/
11841185
getRuntimeStatus(): RuntimeStatusEvent | null {
11851186
return this.runtimeStatus;
@@ -1210,8 +1211,8 @@ export class StreamingMessageAggregator {
12101211
}
12111212

12121213
/**
1213-
* Handle runtime-status event (emitted during ensureReady for Coder workspaces).
1214-
* Used to show "Starting Coder workspace..." in StreamingBarrier.
1214+
* Handle runtime-status event.
1215+
* Used to show both runtime readiness and generic startup breadcrumbs in StreamingBarrier.
12151216
*/
12161217
handleRuntimeStatus(status: RuntimeStatusEvent): void {
12171218
// Keep stream lifecycle code focused on when runtime status becomes irrelevant.

src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export function applyWorkspaceChatEventToAggregator(
229229
return "immediate";
230230
}
231231

232-
// runtime-status events are used for Coder workspace starting UX
232+
// runtime-status events drive pre-stream startup breadcrumbs in the barrier UI
233233
if (isRuntimeStatus(event)) {
234234
aggregator.handleRuntimeStatus(event);
235235
return "immediate";

src/common/orpc/schemas/stream.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,18 @@ export const CaughtUpMessageSchema = z.object({
7575
});
7676

7777
/**
78-
* Progress event for runtime readiness checks.
79-
* Used by Coder workspaces to show "Starting Coder workspace..." while ensureReady() blocks.
80-
* Not used by Docker (start is near-instant) or local runtimes.
78+
* Progress event for pre-stream startup work.
79+
*
80+
* Initially introduced for runtime readiness checks, but now also carries generic
81+
* pre-stream breadcrumbs (for example tool loading or request preparation) so the
82+
* user can see where startup is currently blocked.
8183
*/
8284
export const RuntimeStatusEventSchema = z.object({
8385
type: z.literal("runtime-status"),
8486
workspaceId: z.string(),
8587
phase: z.enum(["checking", "starting", "waiting", "ready", "error"]),
8688
runtimeType: RuntimeModeSchema,
89+
source: z.enum(["runtime", "startup"]).optional(),
8790
detail: z.string().optional(), // Human-readable status like "Starting Coder workspace..."
8891
});
8992

src/common/types/stream.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export type AutoRetryStartingEvent = z.infer<typeof AutoRetryStartingEventSchema
8585
export type AutoRetryAbandonedEvent = z.infer<typeof AutoRetryAbandonedEventSchema>;
8686

8787
/**
88-
* Progress event for runtime readiness checks.
89-
* Used by Coder workspaces to show "Starting Coder workspace..." while ensureReady() blocks.
88+
* Progress event for pre-stream startup work.
89+
* Used for both runtime readiness and generic startup breadcrumbs in the barrier UI.
9090
*/
9191
export type RuntimeStatusEvent = z.infer<typeof RuntimeStatusEventSchema>;

src/common/utils/messages/retryEligibility.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,54 @@ describe("hasInterruptedStream", () => {
279279
expect(hasInterruptedStream(messages, null)).toBe(true);
280280
});
281281

282+
it("suppresses retry while runtime startup is still in progress", () => {
283+
const messages: DisplayedMessage[] = [
284+
{
285+
type: "user",
286+
id: "user-1",
287+
historyId: "user-1",
288+
content: "Hello",
289+
historySequence: 1,
290+
},
291+
];
292+
293+
const runtimeStatus = {
294+
type: "runtime-status" as const,
295+
workspaceId: "ws-1",
296+
phase: "starting" as const,
297+
runtimeType: "ssh" as const,
298+
source: "runtime" as const,
299+
detail: "Starting workspace...",
300+
};
301+
302+
expect(hasInterruptedStream(messages, null, runtimeStatus)).toBe(false);
303+
expect(isEligibleForAutoRetry(messages, null, runtimeStatus)).toBe(false);
304+
});
305+
306+
it("keeps retry eligible for non-runtime startup breadcrumbs", () => {
307+
const messages: DisplayedMessage[] = [
308+
{
309+
type: "user",
310+
id: "user-1",
311+
historyId: "user-1",
312+
content: "Hello",
313+
historySequence: 1,
314+
},
315+
];
316+
317+
const runtimeStatus = {
318+
type: "runtime-status" as const,
319+
workspaceId: "ws-1",
320+
phase: "starting" as const,
321+
runtimeType: "ssh" as const,
322+
source: "startup" as const,
323+
detail: "Loading tools...",
324+
};
325+
326+
expect(hasInterruptedStream(messages, null, runtimeStatus)).toBe(true);
327+
expect(isEligibleForAutoRetry(messages, null, runtimeStatus)).toBe(true);
328+
});
329+
282330
it("returns false when message was sent very recently (within grace period)", () => {
283331
const messages: DisplayedMessage[] = [
284332
{

src/common/utils/messages/retryEligibility.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,14 @@ function computeHasInterruptedStream(
159159
return false;
160160
}
161161

162-
// Don't show retry barrier if runtime is still starting (e.g., Coder workspace waiting for startup scripts).
163-
// The backend's ensureReady() is still running - this happens when reconnecting to a stopped workspace.
164-
// runtimeStatus is set during ensureReady() and cleared when ready/error.
165-
if (runtimeStatus !== null && lastMessage.type !== "stream-error") {
162+
// Don't show retry barrier if runtime bring-up is still in progress (for example a stopped
163+
// Coder workspace that is still starting). Generic post-runtime startup breadcrumbs must not
164+
// suppress retry UI, or a later stall in tool loading/request prep would hide the interruption.
165+
if (
166+
runtimeStatus?.source !== "startup" &&
167+
runtimeStatus !== null &&
168+
lastMessage.type !== "stream-error"
169+
) {
166170
return false;
167171
}
168172

0 commit comments

Comments
 (0)