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
28 changes: 18 additions & 10 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import React, {
useDeferredValue,
useMemo,
} from "react";
import { Lightbulb } from "lucide-react";
import { Lightbulb, Loader2 } from "lucide-react";
import { MessageListProvider } from "@/browser/features/Messages/MessageListContext";
import { cn } from "@/common/lib/utils";
import { ChatInstructionsChatDecoration } from "@/browser/components/InstructionsTab/AdditionalSystemContextScratchpad";
Expand Down Expand Up @@ -1689,6 +1689,19 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
</span>
</button>
)}
{/* Read-only transcripts need replay feedback without an editable composer. */}
{isHydratingTranscript && !shouldMountStreamingBarrier && (
<ChatDockSurface>
<div
role={showTranscriptHydrationPlaceholder ? undefined : "status"}
data-testid="transcript-loading-status"
className="text-muted flex items-center gap-2 px-3 py-1 text-xs"
>
<Loader2 aria-hidden="true" className="size-3 shrink-0 animate-spin" />
<span>Loading messages...</span>
</div>
</ChatDockSurface>
)}
{transcriptOnly ? (
// Transcript-only workspaces keep their historical transcript, but the whole
// composer surface is replaced with a single read-only notice.
Expand Down Expand Up @@ -1773,8 +1786,8 @@ interface ChatInputPaneProps {
workspaceName: string;
/**
* False until the chat view's one-commit reveal (transcript + decorations
* together). The decoration lane stays empty before that so a decoration
* can never mount after paint and shift the transcript.
* together). Async decorations stay hidden before that so they cannot
* mount after paint and shift the transcript.
*/
revealDecorations: boolean;
runtimeConfig?: RuntimeConfig;
Expand Down Expand Up @@ -1934,13 +1947,8 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
),
});
}
// The decoration lane lives inside the in-flow sticky composer dock, so a
// decoration mounting/unmounting reflows the transcript clearance in the same
// layout pass; the bottom stays pinned via native anchoring plus the
// scrollport-children ResizeObserver in useAutoScroll. Until the one-commit
// reveal the lane renders empty: readiness is monotonic per mounted
// workspace, so this only ever delays the initial mount — it never unmounts
// visible decorations.
// Keep decorations in the in-flow composer dock so height changes reserve
// transcript clearance in the same layout pass.

return (
<>
Expand Down
4 changes: 2 additions & 2 deletions src/browser/components/ChatPane/layoutStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export interface LayoutStackItem<
export type TranscriptTailStackItem = LayoutStackItem<"transcript-tail">;
export type ChatInputDecorationStackItem = LayoutStackItem<"composer-decoration"> & {
/**
* Render even before async decoration data is ready. Reserve this for synchronous,
* user-authored state that must stay visible while an active stream bypasses hydration.
* Render even before async decoration data is ready, for synchronous chat state
* that must stay visible during hydration.
*/
readonly revealBeforeReady?: boolean;
};
Expand Down
136 changes: 125 additions & 11 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,7 @@ describe("WorkspaceStore", () => {
const createdAt = new Date().toISOString();

// Setup mock stream
mockChatScript([{ type: "caught-up" }, tick(10)]);
mockChatScript([{ type: "caught-up" }, tick(10)], { keepOpen: true });

createAndAddWorkspace(store, workspaceId, { name: "test-branch-2", createdAt });

Expand Down Expand Up @@ -1272,6 +1272,117 @@ describe("WorkspaceStore", () => {
expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(true);
});

it.each([
["end", false],
["end", true],
["error", false],
["error", true],
["client reconnect", false],
["client reconnect", true],
] as const)(
"keeps hydration active through %s backoff with cached history %s",
async (termination, cached) => {
const workspaceId = "workspace-retry-hydration";
const client = getInternal<{ client: Parameters<WorkspaceStore["setClient"]>[0] }>(
store
).client;
const attempts = Array.from({ length: 3 }, () =>
createControllableAsyncIterable<WorkspaceChatMessage>()
);
let subscriptions = 0;
mockOnChat.mockImplementation(async function* (_input, options) {
const events = attempts[subscriptions++];
options?.signal?.addEventListener("abort", () => events.close(), { once: true });
yield* events.iterable;
if (termination === "error" && !options?.signal?.aborted) {
throw new Error("onChat transport failed");
}
});
createAndAddWorkspace(store, workspaceId);
expect(await waitUntil(() => subscriptions === 1)).toBe(true);
if (cached) {
attempts[0].push(createHistoryMessageEvent("history-1", 1));
attempts[0].push(fullCaughtUpEvent());
expect(
await waitUntil(() => store.getWorkspaceState(workspaceId).isTranscriptCaughtUp)
).toBe(true);
}
const cachedMessages = store.getWorkspaceState(workspaceId).messages;
expect(cachedMessages).toHaveLength(cached ? 1 : 0);
const observed: Array<{ isHydratingTranscript: boolean; canInterrupt: boolean }> = [];
const unsubscribe = store.subscribeKey(workspaceId, () => {
const { isHydratingTranscript, canInterrupt } = store.getWorkspaceState(workspaceId);
observed.push({ isHydratingTranscript, canInterrupt });
});

for (let attempt = 0; attempt < 2; attempt++) {
if (attempt > 0) {
attempts[attempt].push({
type: "stream-start",
workspaceId,
messageId: "buffered-stream",
model: "openai:gpt-4o-mini",
historySequence: 2,
startTime: 1_000,
});
expect(await waitUntil(() => store.getWorkspaceState(workspaceId).canInterrupt)).toBe(
true
);
}
const previousUpdates = observed.length;
if (termination === "client reconnect") store.setClient(null);
else attempts[attempt].close();
expect(await waitUntil(() => observed.length > previousUpdates)).toBe(true);
expect(subscriptions).toBe(attempt + 1);
expect(observed.every((state) => state.isHydratingTranscript)).toBe(true);
expect(observed.at(-1)?.canInterrupt).toBe(false);
if (termination === "client reconnect") store.setClient(client);
expect(await waitUntil(() => subscriptions === attempt + 2)).toBe(true);
const replayState = store.getWorkspaceState(workspaceId);
expect(replayState.isHydratingTranscript).toBe(true);
expect(replayState.isTranscriptCaughtUp).toBe(false);
expect(replayState.messages).toEqual(cachedMessages);
}
unsubscribe();

if (cached) attempts[2].push(createHistoryMessageEvent("history-1", 1));
attempts[2].push(cached ? sinceCaughtUpEvent() : fullCaughtUpEvent());
expect(
await waitUntil(() => store.getWorkspaceState(workspaceId).isTranscriptCaughtUp)
).toBe(true);
expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false);
expect(store.getWorkspaceState(workspaceId).messages).toEqual(cachedMessages);
mockChatScript([], { keepOpen: true });
}
);

it("waits for a client before hydration and clears it on deactivation during backoff", async () => {
const client = getInternal<{ client: Parameters<WorkspaceStore["setClient"]>[0] }>(
store
).client;
store.setClient(null);
const workspaceId = "workspace-pending-client";
createAndAddWorkspace(store, workspaceId);
expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false);
const events = createControllableAsyncIterable<WorkspaceChatMessage>();
mockOnChat.mockImplementation(async function* () {
yield* events.iterable;
});
store.setClient(client);
expect(await waitUntil(() => mockOnChat.mock.calls.length > 0)).toBe(true);
const updates: boolean[] = [];
const unsubscribe = store.subscribeKey(workspaceId, () => {
updates.push(store.getWorkspaceState(workspaceId).isHydratingTranscript);
});
events.close();
expect(await waitUntil(() => updates.length > 0)).toBe(true);
expect(updates.every(Boolean)).toBe(true);
store.setActiveWorkspaceId(null);
expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false);
unsubscribe();
mockChatScript([], { keepOpen: true });
});

it("preserves optimistic startup across full replay resets", () => {
const workspaceId = "workspace-full-replay-pending-start";
const requestedModel = "openai:gpt-4o-mini";
Expand Down Expand Up @@ -2008,7 +2119,10 @@ describe("WorkspaceStore", () => {
const workspaceId = "fine-grained-deltas";
const flushMicrotasks = () => new Promise<void>((resolve) => queueMicrotask(resolve));
const messageId = "stream-message";
const subscribed = Promise.withResolvers<void>();
mockChatScript([() => subscribed.resolve()], { keepOpen: true });
createAndAddWorkspace(store, workspaceId);
await subscribed.promise;
const rawStore = getInternal<{
states: { bump: (key: string) => void };
streamingStatsStore: { bump: (key: string) => void };
Expand All @@ -2020,9 +2134,6 @@ describe("WorkspaceStore", () => {
event: WorkspaceChatMessage
) => void;
}>(store);
// Dispatch below the caught-up buffering gate: the mock onChat retry loop
// resets transient.caughtUp whenever an await lets it advance, which would
// silently buffer later events. Hydration gating is covered elsewhere.
const dispatch = (event: WorkspaceChatMessage) =>
rawStore.processStreamEvent(workspaceId, store.getAggregator(workspaceId), event);

Expand Down Expand Up @@ -4629,13 +4740,16 @@ describe("WorkspaceStore", () => {
releaseDuplicate = resolve;
});

mockChatScript([
caughtUpEvent(),
Promise.resolve(),
advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 1),
waitForDuplicate,
advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 2),
]);
mockChatScript(
[
caughtUpEvent(),
Promise.resolve(),
advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 1),
waitForDuplicate,
advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 2),
],
{ keepOpen: true }
);

createAndAddWorkspace(store, workspaceId);

Expand Down
35 changes: 12 additions & 23 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3846,33 +3846,21 @@ export class WorkspaceStore {
* Retries on unexpected iterator termination to avoid requiring a full app restart.
*/
private async runOnChatSubscription(workspaceId: string, signal: AbortSignal): Promise<void> {
// Loop-scoped so the observation survives the generation retry: attaching a
// client aborts the change signal the waiting attempt captured, and the
// retried attempt would otherwise see the client as always-present.
let clientWasMissing = false;
await runSubscriptionLoop({
name: "onChat(" + workspaceId + ")",
signal,
getClient: async (attemptSignal) => {
if (this.client === null) clientWasMissing = true;
return this.client ?? (await this.waitForClient(attemptSignal));
},
getClient: async (attemptSignal) => this.client ?? (await this.waitForClient(attemptSignal)),
getClientChangeSignal: () => this.clientChangeController.signal,
subscribe: async (client, attemptSignal, abortAttempt) => {
const hadClientAtLoopStart = !clientWasMissing;
clientWasMissing = false;
const initialTransient = this.chatTransientState.get(workspaceId);
if (
!hadClientAtLoopStart &&
initialTransient &&
!initialTransient.caughtUp &&
!initialTransient.isHydratingTranscript
) {
initialTransient.isHydratingTranscript = true;
this.states.bump(workspaceId);
}
const transient = this.chatTransientState.get(workspaceId);
if (transient) transient.caughtUp = false;
if (transient) {
transient.caughtUp = false;
// Every attempt replays, including retries that keep the same client and cached rows.
if (!transient.isHydratingTranscript) {
transient.isHydratingTranscript = true;
this.states.bump(workspaceId);
Comment thread
ibetitsmike marked this conversation as resolved.
}
}
const aggregator = this.aggregators.get(workspaceId);
let mode: OnChatMode | undefined;
const attemptContext: OnChatAttemptContext = { abort: abortAttempt };
Expand Down Expand Up @@ -3940,8 +3928,9 @@ export class WorkspaceStore {
if (!this.isWorkspaceRegistered(workspaceId)) return;
this.clearReplayBuffers(workspaceId);
const transient = this.chatTransientState.get(workspaceId);
if (transient?.isHydratingTranscript && !transient.caughtUp) {
transient.isHydratingTranscript = false;
if (transient) {
// Backoff is still catch-up; cleared stream buffers must also invalidate cached barriers.
transient.isHydratingTranscript = true;
this.states.bump(workspaceId);
}
if (transient && !transient.caughtUp && this.preReplayUsageSnapshot.delete(workspaceId))
Expand Down
Loading
Loading