Skip to content

Commit d352efc

Browse files
authored
🤖 fix: keep init logs visible when returning to a workspace (#3139)
## Summary Keep SSH/Coder init logs visible when returning to a workspace by making running `workspace-init` replay visible immediately and by letting running init-only transcripts bypass the generic workspace-loading placeholder. ## Background The earlier fix only handled deferred transcript rendering. The more structural issue was that `WorkspaceStore` buffered init replay until `caught-up`, while `WorkspaceShell` still treated init-only workspaces as "loading" because there were no persisted chat messages yet. That meant switching away from and back to a workspace mid-init could still hide the running init hook and its SSH setup output behind a loading screen. ## Implementation - immediately apply replayed `init-start` / `init-output` / `init-end` events during pre-`caught-up` buffering, just like other reconnect-visible startup state - treat actively running `workspace-init` rows as authoritative transcript content when deriving `loading` / `isHydratingTranscript` - keep the deferred-render guard for running init rows so reconnects do not reuse stale snapshots - add regression coverage for switching away from and back to a workspace while SSH init output is still streaming ## Validation - `bun test src/browser/stores/WorkspaceStore.test.ts` - `bun test src/browser/utils/messages/messageUtils.test.ts` - `make static-check` ## Risks Low to moderate. The change is scoped to reconnect/catch-up behavior in the renderer, but it touches the buffering rules that decide which pre-stream state is visible before `caught-up`. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$5.06`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=5.06 -->
1 parent 99596c2 commit d352efc

5 files changed

Lines changed: 215 additions & 9 deletions

File tree

src/browser/stores/WorkspaceStore.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, beforeEach, afterEach, mock, type Mock } from "bun:test";
2+
import type { DisplayedMessage } from "@/common/types/message";
23
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
34
import type { StreamStartEvent, ToolCallStartEvent } from "@/common/types/stream";
45
import type { WorkspaceActivitySnapshot, WorkspaceChatMessage } from "@/common/orpc/types";
@@ -1662,6 +1663,133 @@ describe("WorkspaceStore", () => {
16621663
expect(stayedVisibleAfterCaughtUp).toBe(true);
16631664
});
16641665

1666+
it("shows replayed init output before caught-up when switching back to a workspace", async () => {
1667+
const workspaceId = "workspace-init-replay";
1668+
const otherWorkspaceId = "workspace-init-other";
1669+
const firstLine = "Preparing workspace...";
1670+
const replayedLine = "Syncing repository over SSH...";
1671+
let subscriptionCount = 0;
1672+
let releaseSecondInitOutput: (() => void) | undefined;
1673+
let releaseSecondCaughtUp: (() => void) | undefined;
1674+
1675+
const getInitMessage = (): {
1676+
state: ReturnType<WorkspaceStore["getWorkspaceState"]>;
1677+
initMessage: Extract<DisplayedMessage, { type: "workspace-init" }> | undefined;
1678+
} => {
1679+
const state = store.getWorkspaceState(workspaceId);
1680+
const initMessage = state.messages.find(
1681+
(message): message is Extract<DisplayedMessage, { type: "workspace-init" }> =>
1682+
message.type === "workspace-init"
1683+
);
1684+
return { state, initMessage };
1685+
};
1686+
1687+
mockOnChat.mockImplementation(async function* (
1688+
input?: { workspaceId: string; mode?: unknown },
1689+
options?: { signal?: AbortSignal }
1690+
): AsyncGenerator<WorkspaceChatMessage, void, unknown> {
1691+
if (input?.workspaceId !== workspaceId) {
1692+
await waitForAbortSignal(options?.signal);
1693+
return;
1694+
}
1695+
1696+
subscriptionCount += 1;
1697+
1698+
if (subscriptionCount === 1) {
1699+
yield { type: "caught-up" };
1700+
await Promise.resolve();
1701+
yield {
1702+
type: "init-start",
1703+
hookPath: "/tmp/project/.mux/init",
1704+
timestamp: 1_000,
1705+
};
1706+
await Promise.resolve();
1707+
yield {
1708+
type: "init-output",
1709+
line: firstLine,
1710+
isError: false,
1711+
timestamp: 1_001,
1712+
};
1713+
await waitForAbortSignal(options?.signal);
1714+
return;
1715+
}
1716+
1717+
yield {
1718+
type: "init-start",
1719+
hookPath: "/tmp/project/.mux/init",
1720+
timestamp: 2_000,
1721+
};
1722+
await new Promise<void>((resolve) => {
1723+
releaseSecondInitOutput = resolve;
1724+
});
1725+
yield {
1726+
type: "init-output",
1727+
line: replayedLine,
1728+
isError: false,
1729+
timestamp: 2_001,
1730+
};
1731+
await new Promise<void>((resolve) => {
1732+
releaseSecondCaughtUp = resolve;
1733+
});
1734+
yield { type: "caught-up", replay: "full" };
1735+
await waitForAbortSignal(options?.signal);
1736+
});
1737+
1738+
createAndAddWorkspace(store, workspaceId);
1739+
1740+
const sawInitialInit = await waitUntil(() => {
1741+
const { state, initMessage } = getInitMessage();
1742+
return (
1743+
state.loading === false &&
1744+
initMessage?.status === "running" &&
1745+
initMessage.lines[0]?.line === firstLine
1746+
);
1747+
});
1748+
expect(sawInitialInit).toBe(true);
1749+
1750+
createAndAddWorkspace(store, otherWorkspaceId);
1751+
store.setActiveWorkspaceId(workspaceId);
1752+
1753+
const sawEmptyReconnectInit = await waitUntil(() => {
1754+
const { state, initMessage } = getInitMessage();
1755+
return (
1756+
subscriptionCount >= 2 &&
1757+
state.loading === false &&
1758+
state.isHydratingTranscript === false &&
1759+
initMessage?.status === "running" &&
1760+
initMessage.lines.length === 0
1761+
);
1762+
});
1763+
expect(sawEmptyReconnectInit).toBe(true);
1764+
1765+
releaseSecondInitOutput?.();
1766+
1767+
const replayedInitBeforeCaughtUp = await waitUntil(() => {
1768+
const { state, initMessage } = getInitMessage();
1769+
return (
1770+
subscriptionCount >= 2 &&
1771+
state.loading === false &&
1772+
state.isHydratingTranscript === false &&
1773+
initMessage?.status === "running" &&
1774+
initMessage.lines.length === 1 &&
1775+
initMessage.lines[0]?.line === replayedLine
1776+
);
1777+
});
1778+
expect(replayedInitBeforeCaughtUp).toBe(true);
1779+
1780+
releaseSecondCaughtUp?.();
1781+
1782+
const stayedVisibleAfterCaughtUp = await waitUntil(() => {
1783+
const { state, initMessage } = getInitMessage();
1784+
return (
1785+
!state.loading &&
1786+
initMessage?.status === "running" &&
1787+
initMessage.lines[0]?.line === replayedLine
1788+
);
1789+
});
1790+
expect(stayedVisibleAfterCaughtUp).toBe(true);
1791+
});
1792+
16651793
it("active workspace still shows starting during legitimate startup gap", async () => {
16661794
const workspaceId = "stream-starting-active-workspace";
16671795

src/browser/stores/WorkspaceStore.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import {
3232
isStreamError,
3333
isStreamLifecycle,
3434
isDeleteMessage,
35+
isInitEnd,
36+
isInitOutput,
37+
isInitStart,
3538
isBashOutputEvent,
3639
isTaskCreatedEvent,
3740
isMuxMessage,
@@ -1525,6 +1528,10 @@ export class WorkspaceStore {
15251528
const aggregator = this.assertGet(workspaceId);
15261529

15271530
const hasMessages = aggregator.hasMessages();
1531+
const displayedMessages = aggregator.getDisplayedMessages();
1532+
const hasRunningInitMessage = displayedMessages.some(
1533+
(message) => message.type === "workspace-init" && message.status === "running"
1534+
);
15281535
const transient = this.assertChatTransientState(workspaceId);
15291536
const historyPagination =
15301537
this.historyPagination.get(workspaceId) ?? createInitialHistoryPaginationState();
@@ -1574,8 +1581,14 @@ export class WorkspaceStore {
15741581
(streamLifecycle?.phase === "preparing" ||
15751582
(!hasAuthoritativeStreamLifecycle && pendingStreamStartTime !== null)) &&
15761583
!canInterrupt;
1584+
// Only actively running init output should bypass transcript hydration. Completed init
1585+
// rows are still replayed, but they should not suppress the normal catch-up placeholder
1586+
// for stale cached transcript content on reconnect.
15771587
const isHydratingTranscript =
1578-
isActiveWorkspace && transient.isHydratingTranscript && !transient.caughtUp;
1588+
isActiveWorkspace &&
1589+
transient.isHydratingTranscript &&
1590+
!transient.caughtUp &&
1591+
!hasRunningInitMessage;
15791592
const aggregatorTodos = aggregator.getCurrentTodos();
15801593
const displayStatus = useAggregatorState ? undefined : (activity?.displayStatus ?? undefined);
15811594
const todoStatus = useAggregatorState
@@ -1596,13 +1609,13 @@ export class WorkspaceStore {
15961609

15971610
return {
15981611
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
1599-
messages: aggregator.getDisplayedMessages(),
1612+
messages: displayedMessages,
16001613
queuedMessage: transient.queuedMessage,
16011614
canInterrupt,
16021615
isCompacting: aggregator.isCompacting(),
16031616
isStreamStarting,
16041617
awaitingUserQuestion: aggregator.hasAwaitingUserQuestion(),
1605-
loading: !hasMessages && !transient.caughtUp,
1618+
loading: !hasMessages && !hasRunningInitMessage && !transient.caughtUp,
16061619
isHydratingTranscript,
16071620
hasOlderHistory: historyPagination.hasOlder,
16081621
loadingOlderHistory: historyPagination.loading,
@@ -3577,9 +3590,24 @@ export class WorkspaceStore {
35773590
}
35783591

35793592
if (!transient.caughtUp && this.isBufferedEvent(data)) {
3580-
if (isStreamLifecycle(data) || isStreamAbort(data) || isRuntimeStatus(data)) {
3593+
if (
3594+
isStreamLifecycle(data) ||
3595+
isStreamAbort(data) ||
3596+
isRuntimeStatus(data) ||
3597+
isInitStart(data) ||
3598+
isInitOutput(data) ||
3599+
isInitEnd(data)
3600+
) {
3601+
// SSH/Coder init replay can be the only transcript content for a new workspace.
3602+
// Apply it immediately so switching back mid-init shows the latest backend output
3603+
// instead of only a generic loading placeholder until caught-up lands.
35813604
applyWorkspaceChatEventToAggregator(aggregator, data, { allowSideEffects: false });
3582-
this.states.bump(workspaceId);
3605+
if (isInitOutput(data)) {
3606+
aggregator.flushPendingInitOutput();
3607+
this.scheduleIdleStateBump(workspaceId);
3608+
} else {
3609+
this.states.bump(workspaceId);
3610+
}
35833611
}
35843612

35853613
transient.pendingStreamEvents.push(data);

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,6 +1595,20 @@ export class StreamingMessageAggregator {
15951595
}
15961596
}
15971597

1598+
/**
1599+
* Replay-visible init output needs a synchronous cache flush. WorkspaceStore can
1600+
* bump subscribers before the normal 100ms init-output throttle fires, and in
1601+
* reconnects that would otherwise leave the newest line hidden until caught-up.
1602+
*/
1603+
flushPendingInitOutput(): void {
1604+
if (this.initOutputThrottleTimer) {
1605+
clearTimeout(this.initOutputThrottleTimer);
1606+
this.initOutputThrottleTimer = null;
1607+
}
1608+
1609+
this.invalidateCache();
1610+
}
1611+
15981612
clearPendingStreamStart(): void {
15991613
this.setPendingStreamStartTime(null);
16001614
}

src/browser/utils/messages/messageUtils.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,25 @@ describe("shouldBypassDeferredMessages", () => {
113113
result: { success: true, output: "hi", exitCode: 0, wall_duration_ms: 5 },
114114
};
115115

116+
const runningInit: DisplayedMessage = {
117+
type: "workspace-init",
118+
id: "workspace-init",
119+
historySequence: -1,
120+
status: "running",
121+
hookPath: "/tmp/project/.mux/init",
122+
lines: [{ line: "Installing dependencies...", isError: false }],
123+
exitCode: null,
124+
timestamp: 1,
125+
durationMs: null,
126+
};
127+
128+
const completedInit: DisplayedMessage = {
129+
...runningInit,
130+
status: "success",
131+
exitCode: 0,
132+
durationMs: 2_000,
133+
};
134+
116135
it("returns true when immediate snapshot has active rows", () => {
117136
expect(shouldBypassDeferredMessages([executingBash], [executingBash])).toBe(true);
118137
});
@@ -141,6 +160,16 @@ describe("shouldBypassDeferredMessages", () => {
141160
);
142161
});
143162

163+
it("returns true when init output is still running", () => {
164+
expect(shouldBypassDeferredMessages([runningInit], [runningInit])).toBe(true);
165+
});
166+
167+
it("returns true when the deferred snapshot still shows a running init hook", () => {
168+
// Regression scenario: reconnect replay completed the init hook, but the deferred
169+
// snapshot is still holding on to the older running row from before catch-up.
170+
expect(shouldBypassDeferredMessages([completedInit], [runningInit])).toBe(true);
171+
});
172+
144173
it("returns false when both snapshots are settled and in sync", () => {
145174
expect(shouldBypassDeferredMessages([completedBash], [completedBash])).toBe(false);
146175
});

src/browser/utils/messages/messageUtils.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,15 @@ export function shouldShowInterruptedBarrier(
115115

116116
/**
117117
* Returns whether ChatPane should bypass useDeferredValue and render the immediate
118-
* message list. We bypass deferral while assistant content is streaming OR while
119-
* any tool call is still executing (e.g. live bash output).
118+
* message list. We bypass deferral while assistant content is streaming, while the
119+
* init hook is still running, OR while any tool call is still executing (for example,
120+
* live bash output).
120121
*
121122
* We also bypass when the deferred snapshot appears stale (it still has active
122123
* streaming/executing rows after the immediate snapshot is idle), or when both
123124
* snapshots have diverged in row identity/order. Showing stale deferred rows can
124-
* cause hidden-marker placement and tool-state flash at stream completion.
125+
* cause hidden-marker placement, hide live init logs after a workspace switch, and
126+
* flash tool state at stream completion.
125127
*/
126128
export function shouldBypassDeferredMessages(
127129
messages: DisplayedMessage[],
@@ -130,7 +132,12 @@ export function shouldBypassDeferredMessages(
130132
const hasActiveRows = (rows: DisplayedMessage[]) =>
131133
rows.some(
132134
(m) =>
133-
("isStreaming" in m && m.isStreaming) || (m.type === "tool" && m.status === "executing")
135+
("isStreaming" in m && m.isStreaming) ||
136+
(m.type === "tool" && m.status === "executing") ||
137+
// Keep SSH/Coder init output on the immediate path when a user returns to a
138+
// workspace mid-setup; otherwise the deferred snapshot can keep showing an
139+
// older empty/stale init row because workspace-init uses a stable display ID.
140+
(m.type === "workspace-init" && m.status === "running")
134141
);
135142

136143
if (messages.length !== deferredMessages.length) {

0 commit comments

Comments
 (0)