From c489480cbba1fc3f60d109c7e32fdaaaae4f7ec3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 02:58:52 -0500 Subject: [PATCH] fix(desktop): render the workspace pane from its own session slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace pane read the global $messages/$busy atoms — a mirror of whichever session was active — while every ⌘T tile rendered from its own $sessionStates slice. With two turns in flight, navigating away from a still-streaming session left it painting into the surface now showing a different conversation: the wrong transcript under the right route. Point the primary view at the active session's own slice, keeping the global atoms as the draft surface for a chat that has no runtime id yet. --- .../desktop/src/app/chat/session-view.test.ts | 88 +++++++++++++++++++ apps/desktop/src/app/chat/session-view.tsx | 71 +++++++++++---- 2 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-view.test.ts diff --git a/apps/desktop/src/app/chat/session-view.test.ts b/apps/desktop/src/app/chat/session-view.test.ts new file mode 100644 index 000000000000..366af2327bcb --- /dev/null +++ b/apps/desktop/src/app/chat/session-view.test.ts @@ -0,0 +1,88 @@ +import { cleanup } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { createClientSessionState } from '@/lib/chat-runtime' +import { $activeSessionId, $busy, $messages } from '@/store/session' +import { $sessionStates, dropSessionState, publishSessionState } from '@/store/session-states' + +import { PRIMARY_SESSION_VIEW } from './session-view' + +const message = (id: string, text: string) => ({ + id, + parts: [{ type: 'text' as const, text }], + role: 'assistant' as const +}) + +const stateWith = (runtimeId: string, text: string, busy: boolean) => ({ + ...createClientSessionState(`stored-${runtimeId}`), + messages: [message(`${runtimeId}-msg`, text)], + busy +}) + +/** + * The workspace pane is just the first tab: it renders from the active + * session's own `$sessionStates` slice, exactly like a ⌘T tile. + * + * The regression this guards: the pane used to render straight off the global + * `$messages`/`$busy` atoms — a mirror of whichever session was active. With + * two turns in flight, navigating away from a still-streaming session left it + * painting into the surface now showing a different conversation. + */ +describe('primary session view reads its own session slice', () => { + beforeEach(() => { + $sessionStates.set({}) + $activeSessionId.set(null) + $messages.set([]) + $busy.set(false) + }) + + afterEach(cleanup) + + it('shows the active session transcript, not a background session still streaming', () => { + publishSessionState('runtime-background', stateWith('runtime-background', 'background turn', true)) + publishSessionState('runtime-foreground', stateWith('runtime-foreground', 'foreground turn', false)) + + $activeSessionId.set('runtime-foreground') + + expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('runtime-foreground-msg', 'foreground turn')]) + expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(false) + }) + + it('ignores a background session that keeps streaming after the user switches away', () => { + publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', true)) + $activeSessionId.set('runtime-b') + publishSessionState('runtime-b', stateWith('runtime-b', 'session B turn', false)) + + // Session A streams on: another delta lands for the session the user left. + publishSessionState('runtime-a', { + ...stateWith('runtime-a', 'session A turn', true), + messages: [message('runtime-a-msg', 'session A turn'), message('runtime-a-late', 'late delta')] + }) + + expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('runtime-b-msg', 'session B turn')]) + expect(PRIMARY_SESSION_VIEW.$lastVisibleIsUser.get()).toBe(false) + expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(false) + }) + + it('falls back to the draft atoms while the chat has no runtime session yet', () => { + $messages.set([message('draft-msg', 'unsent draft')]) + $busy.set(true) + + expect(PRIMARY_SESSION_VIEW.$runtimeId.get()).toBeNull() + expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('draft-msg', 'unsent draft')]) + expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(true) + expect(PRIMARY_SESSION_VIEW.$messagesEmpty.get()).toBe(false) + }) + + it('returns to the draft atoms when the active session state is dropped', () => { + publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', true)) + $activeSessionId.set('runtime-a') + + expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('runtime-a-msg', 'session A turn')]) + + dropSessionState('runtime-a') + + expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([]) + expect(PRIMARY_SESSION_VIEW.$messagesEmpty.get()).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/chat/session-view.tsx b/apps/desktop/src/app/chat/session-view.tsx index 9ca39ace67e8..27cef73ecea6 100644 --- a/apps/desktop/src/app/chat/session-view.tsx +++ b/apps/desktop/src/app/chat/session-view.tsx @@ -1,6 +1,7 @@ -import type { ReadableAtom } from 'nanostores' +import { computed, type ReadableAtom } from 'nanostores' import { createContext, useContext } from 'react' +import type { ClientSessionState } from '@/app/types' import type { ChatMessage } from '@/lib/chat-messages' import { $activeSessionId, @@ -11,18 +12,28 @@ import { $currentModel, $currentProvider, $currentReasoningEffort, - $lastVisibleMessageIsUser, $messages, - $messagesEmpty, $selectedStoredSessionId } from '@/store/session' +import { $sessionStates } from '@/store/session-states' + +import { lastVisibleMessageIsUser } from './thread-loading' /** - * SESSION VIEW — the store surface a ChatView renders from. The PRIMARY view - * is the app's classic global atoms (route-driven active session, untouched - * fast path). A session TILE provides the same shape computed from its - * session's slice of `$sessionStates`, so the identical ChatView tree renders - * either — one chat surface, N sessions on screen. + * SESSION VIEW — the store surface a ChatView renders from. Every session, + * including the one in the workspace pane, renders from ITS OWN slice of + * `$sessionStates`. The workspace pane is just the first tab: a session + * surface with no privileged state of its own. + * + * That symmetry is load-bearing. The pane used to render off the global + * `$messages`/`$busy` atoms — a mirror of whichever session was active — so + * with two turns in flight (⌘T tabs made that routine), navigating away from + * a still-streaming session left it painting into the surface now showing a + * different conversation. Reading the per-session slice makes that + * structurally impossible rather than merely guarded. + * + * The global atoms stay the DRAFT surface: a new chat has no runtime id, and + * therefore no slice, until its first turn creates one. * * Everything is atoms (not values) so subscription granularity survives: * ChatView subscribes only to the coarse edges; `$messages` stays boundary- @@ -44,18 +55,42 @@ export interface SessionView { $reasoningEffort: ReadableAtom } +/** The active session's own slice, or `undefined` while it's a draft. */ +const $primaryState = computed([$activeSessionId, $sessionStates], (runtimeId, states) => + runtimeId ? states[runtimeId] : undefined +) + +/** + * Read one field from the active session's slice, falling back to the global + * draft atom while no runtime exists yet. Once a session HAS a slice, that + * slice is authoritative — a background session publishing its own state can + * never reach this view. + */ +function primaryField( + select: (state: ClientSessionState) => T, + $draft: ReadableAtom +): ReadableAtom { + const $field: ReadableAtom = computed([$primaryState, $draft], (state, draft: T) => + state ? select(state) : draft + ) + + return $field +} + +const $primaryMessages = primaryField(state => state.messages, $messages) + export const PRIMARY_SESSION_VIEW: SessionView = { kind: 'primary', - $awaitingResponse, - $busy, - $cwd: $currentCwd, - $fast: $currentFastMode, - $lastVisibleIsUser: $lastVisibleMessageIsUser, - $messages, - $messagesEmpty, - $model: $currentModel, - $provider: $currentProvider, - $reasoningEffort: $currentReasoningEffort, + $awaitingResponse: primaryField(state => state.awaitingResponse, $awaitingResponse), + $busy: primaryField(state => state.busy, $busy), + $cwd: primaryField(state => state.cwd, $currentCwd), + $fast: primaryField(state => state.fast, $currentFastMode), + $lastVisibleIsUser: computed($primaryMessages, lastVisibleMessageIsUser), + $messages: $primaryMessages, + $messagesEmpty: computed($primaryMessages, messages => messages.length === 0), + $model: primaryField(state => state.model, $currentModel), + $provider: primaryField(state => state.provider, $currentProvider), + $reasoningEffort: primaryField(state => state.reasoningEffort, $currentReasoningEffort), $runtimeId: $activeSessionId, $storedId: $selectedStoredSessionId }