Skip to content
Open
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
610 changes: 610 additions & 0 deletions scripts/reproWorkspaceSwitchTearWeb.ts

Large diffs are not rendered by default.

21 changes: 17 additions & 4 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1163,15 +1163,28 @@ function AppInner() {
setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata));

if (options?.autoNavigate !== false) {
// Only switch to new workspace if user hasn't selected another one
// during the creation process (selectedWorkspace was null when creation started)
let createdSelection: WorkspaceSelection | null = null;
setSelectedWorkspace((current) => {
if (current !== null) {
// User has already selected another workspace - don't override
// If the user picked another workspace before create/send resolved,
// keep their explicit selection and skip the optimistic starting barrier.
return current;
}
return toWorkspaceSelection(metadata);

createdSelection = toWorkspaceSelection(metadata);
return createdSelection;
});

// WorkspaceContext resolves functional selection updates synchronously
// against its latest ref, so by the time setSelectedWorkspace() returns we
// know whether this creation actually won and can safely mark the
// optimistic starting barrier outside the updater callback.
if (createdSelection) {
workspaceStore.markPendingInitialSend(
metadata.id,
options?.pendingStreamModel ?? null
);
}
}

// Track telemetry
Expand Down
60 changes: 43 additions & 17 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,29 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
// during rapid updates (streaming), keeping the UI responsive.
// Must be defined before any early returns to satisfy React Hooks rules.
const transformedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]);
const deferredTransformedMessages = useDeferredValue(transformedMessages);
const immediateMessageSnapshot = useMemo(
() => ({ workspaceId, messages: transformedMessages }),
[workspaceId, transformedMessages]
);
const deferredMessageSnapshot = useDeferredValue(immediateMessageSnapshot);

// CRITICAL: Show immediate messages when streaming or when message count changes.
// useDeferredValue can defer indefinitely if React keeps getting new work (rapid deltas).
// During active streaming (reasoning, text), we MUST show immediate updates or the UI
// appears frozen while only the token counter updates (reads aggregator directly).
// Also bypass the deferred snapshot when it still belongs to the previous workspace so
// chat switches cannot briefly render stale transcript rows from the old workspace.
const shouldBypassDeferral = shouldBypassDeferredMessages(
transformedMessages,
deferredTransformedMessages
immediateMessageSnapshot.messages,
deferredMessageSnapshot.messages,
{
immediateWorkspaceId: workspaceId,
deferredWorkspaceId: deferredMessageSnapshot.workspaceId,
}
);
const deferredMessages = shouldBypassDeferral ? transformedMessages : deferredTransformedMessages;
const deferredMessages = shouldBypassDeferral
? immediateMessageSnapshot.messages
: deferredMessageSnapshot.messages;

const latestMessageId = getLastNonDecorativeMessage(deferredMessages)?.id ?? null;
const messageListContextValue = useMemo(
Expand Down Expand Up @@ -424,11 +436,15 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
hasInputTarget: !transcriptOnly,
});

// ChatPane is keyed by workspaceId (WorkspaceShell), so per-workspace UI state naturally
// resets on workspace switches. Clear background errors so they don't leak across workspaces.
// Workspace switches should not leak background bash errors into the newly selected chat.
useEffect(() => {
clearBackgroundBashError();
}, [clearBackgroundBashError]);
}, [clearBackgroundBashError, workspaceId]);

useEffect(() => {
setEditingState({ workspaceId, message: undefined });
setExpandedBashGroups(new Set());
}, [workspaceId]);

const handleChatInputReady = useCallback((api: ChatInputAPI) => {
chatInputAPI.current = api;
Expand Down Expand Up @@ -607,15 +623,19 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
}
}, [workspaceState?.messages, workspaceState?.todos, autoScroll, performAutoScroll]);

// Scroll to bottom when workspace loads or changes
// useLayoutEffect ensures scroll happens synchronously after DOM mutations
// but before browser paint - critical for Chromatic snapshot consistency
const hasLoadedTranscriptRows = !workspaceState.loading && workspaceState.messages.length > 0;

// Reset transcript scroll ownership when switching workspaces. If the target workspace already
// has cached rows, pin to the bottom before paint; otherwise just re-arm auto-scroll so the
// next hydrated/streaming updates own the tail instead of showing the prior workspace's state.
useLayoutEffect(() => {
if (workspaceState && !workspaceState.loading && workspaceState.messages.length > 0) {
if (hasLoadedTranscriptRows) {
jumpToBottom();
return;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, workspaceState?.loading]);

setAutoScroll(true);
}, [hasLoadedTranscriptRows, jumpToBottom, setAutoScroll, workspaceId]);

// Compute showRetryBarrier once for both keybinds and UI.
// Track if last message was interrupted or errored (for RetryBarrier).
Expand All @@ -630,8 +650,14 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {

const hasInterruptedStream = interruption?.hasInterruptedStream ?? false;
// Keep rendering cached transcript rows during incremental catch-up so workspace switches
// feel stable; only show the full placeholder when there's no transcript content yet.
const showTranscriptHydrationPlaceholder = isHydratingTranscript && deferredMessages.length === 0;
// feel stable, but a brand-new chat should keep its starting barrier visible instead of
// flashing transcript placeholders before the first send reaches the workspace history.
const showTranscriptHydrationPlaceholder =
isHydratingTranscript && deferredMessages.length === 0 && !workspaceState.isStreamStarting;
const showEmptyTranscriptPlaceholder =
deferredMessages.length === 0 &&
!showTranscriptHydrationPlaceholder &&
!workspaceState.isStreamStarting;
const showRetryBarrier =
!isHydratingTranscript &&
!workspaceState.canInterrupt &&
Expand Down Expand Up @@ -805,7 +831,7 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
ref={innerRef}
className={cn(
"max-w-4xl mx-auto",
(showTranscriptHydrationPlaceholder || deferredMessages.length === 0) && "h-full"
(showTranscriptHydrationPlaceholder || showEmptyTranscriptPlaceholder) && "h-full"
)}
>
{showTranscriptHydrationPlaceholder ? (
Expand All @@ -816,7 +842,7 @@ export const ChatPane: React.FC<ChatPaneProps> = (props) => {
<h3>Loading transcript...</h3>
<p>Syncing recent messages for this workspace</p>
</div>
) : deferredMessages.length === 0 ? (
) : showEmptyTranscriptPlaceholder ? (
<div className="text-placeholder flex h-full flex-1 flex-col items-center justify-center text-center [&_h3]:m-0 [&_h3]:mb-2.5 [&_h3]:text-base [&_h3]:font-medium [&_p]:m-0 [&_p]:text-[13px]">
<h3>No Messages Yet</h3>
<p>Send a message below to begin</p>
Expand Down
84 changes: 79 additions & 5 deletions src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { installDom } from "../../../../tests/ui/dom";
interface MockWorkspaceState {
loading?: boolean;
isHydratingTranscript?: boolean;
isStreamStarting?: boolean;
messages?: Array<{ id: string }>;
queuedMessage?: { id: string } | null;
}

let cleanupDom: (() => void) | null = null;
Expand All @@ -22,7 +25,24 @@ void mock.module("lottie-react", () => ({
}));

void mock.module("@/browser/stores/WorkspaceStore", () => ({
useWorkspaceState: () => workspaceState,
useWorkspaceState: () =>
workspaceState
? {
messages: [],
queuedMessage: null,
...workspaceState,
}
: workspaceState,
}));

void mock.module("../ChatPane/ChatPane", () => ({
ChatPane: (props: { workspaceId: string }) => (
<div data-testid="chat-pane">Chat pane for {props.workspaceId}</div>
),
}));

void mock.module("@/browser/features/RightSidebar/RightSidebar", () => ({
RightSidebar: () => <div data-testid="right-sidebar" />,
}));

void mock.module("@/browser/contexts/ThemeContext", () => ({
Expand Down Expand Up @@ -125,19 +145,73 @@ describe("WorkspaceShell loading placeholders", () => {
addReviewMock.mockClear();
});

it("renders loading animation during hydration in web mode", () => {
it("keeps the chat pane mounted during hydration in web mode", () => {
workspaceState = {
isHydratingTranscript: true,
loading: false,
};

const view = render(<WorkspaceShell {...defaultProps} />);

expect(view.getByText("Catching up with the agent...")).toBeTruthy();
expect(view.getByTestId("lottie-animation")).toBeTruthy();
expect(view.queryByText("Catching up with the agent...")).toBeNull();
expect(view.getByTestId("chat-pane")).toBeTruthy();
});

it("keeps the chat pane mounted during initial web workspace loading", () => {
workspaceState = {
loading: true,
isHydratingTranscript: true,
isStreamStarting: false,
};

const view = render(<WorkspaceShell {...defaultProps} />);

expect(view.queryByText("Loading workspace...")).toBeNull();
expect(view.getByTestId("chat-pane")).toBeTruthy();
});

it("keeps cached transcript content visible during web hydration", () => {
workspaceState = {
isHydratingTranscript: true,
isStreamStarting: false,
loading: false,
messages: [{ id: "message-1" }],
queuedMessage: null,
};

const view = render(<WorkspaceShell {...defaultProps} />);

expect(view.queryByText("Catching up with the agent...")).toBeNull();
expect(view.getByTestId("chat-pane")).toBeTruthy();
});

it("keeps the same chat pane DOM node across workspace switches", () => {
workspaceState = {
isHydratingTranscript: false,
isStreamStarting: false,
loading: false,
messages: [{ id: "message-1" }],
queuedMessage: null,
};

const view = render(<WorkspaceShell {...defaultProps} />);
const firstChatPane = view.getByTestId("chat-pane");

view.rerender(
<WorkspaceShell
{...defaultProps}
workspaceId="workspace-2"
workspaceName="feature-two"
namedWorkspacePath="/projects/demo/workspaces/feature-two"
/>
);

const secondChatPane = view.getByTestId("chat-pane");
expect(secondChatPane).toBe(firstChatPane);
expect(secondChatPane.textContent).toContain("workspace-2");
});

it("renders loading animation during workspace loading", () => {
it("renders loading animation during non-hydrating workspace loading", () => {
workspaceState = {
loading: true,
isHydratingTranscript: false,
Expand Down
27 changes: 19 additions & 8 deletions src/browser/components/WorkspaceShell/WorkspaceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export const WorkspaceShell: React.FC<WorkspaceShellProps> = (props) => {
});
const backgroundBashError = useBackgroundBashError();

if (!workspaceState || workspaceState.loading) {
if (!workspaceState) {
return (
<WorkspacePlaceholder
title="Loading workspace..."
Expand All @@ -192,13 +192,23 @@ export const WorkspaceShell: React.FC<WorkspaceShellProps> = (props) => {
);
}

// Web-only: during workspace switches, the WebSocket subscription needs time to
// catch up. Show a splash instead of flashing stale cached messages.
// Electron's MessageChannel is near-instant so this gate is unnecessary there.
if (workspaceState.isHydratingTranscript && !window.api) {
const shouldKeepChatPaneMountedDuringHydration =
!window.api && workspaceState.isHydratingTranscript && !workspaceState.isStreamStarting;

// User rationale: a just-created chat should keep showing its startup barrier instead of
// flashing generic loading/catch-up placeholders before the first send reaches onChat.
// Web-only: keep the chat pane mounted during transcript hydration so the composer does not
// disappear while a workspace is opening. ChatPane already owns the transcript-level loading
// placeholder, so swapping the whole shell here causes the vertical tear reproduced by
// scripts/reproWorkspaceSwitchTearWeb.ts.
if (
workspaceState.loading &&
!workspaceState.isStreamStarting &&
!shouldKeepChatPaneMountedDuringHydration
) {
return (
<WorkspacePlaceholder
title="Catching up with the agent..."
title="Loading workspace..."
showAnimation
className={props.className}
/>
Expand All @@ -224,9 +234,10 @@ export const WorkspaceShell: React.FC<WorkspaceShellProps> = (props) => {
)}
style={{ containerType: "inline-size" }}
>
{/* Keyed by workspaceId to prevent cross-workspace message-list flashes. */}
{/* Keep the transcript viewport mounted across workspace switches so the browser doesn't
visually tear the pane while the new workspace content hydrates. ChatPane resets its
per-workspace local UI state internally, and the composer remains keyed by workspaceId. */}
<ChatPane
key={`chat-${props.workspaceId}`}
workspaceId={props.workspaceId}
workspaceState={workspaceState}
projectPath={props.projectPath}
Expand Down
2 changes: 2 additions & 0 deletions src/browser/features/ChatInput/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface ChatInputAPI {
export interface WorkspaceCreatedOptions {
/** When false, register metadata without navigating to the new workspace. */
autoNavigate?: boolean;
/** Pending model for the optimistic startup barrier when navigation actually occurs. */
pendingStreamModel?: string | null;
}

// Workspace variant: full functionality for existing workspaces
Expand Down
Loading
Loading