fix(desktop): render the workspace pane from its own session slice

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 02:58:52 -05:00
parent dacd8d5416
commit c489480cbb
2 changed files with 141 additions and 18 deletions

View file

@ -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)
})
})

View file

@ -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<string>
}
/** 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<T>(
select: (state: ClientSessionState) => T,
$draft: ReadableAtom<T>
): ReadableAtom<T> {
const $field: ReadableAtom<T> = computed([$primaryState, $draft], (state, draft: T) =>
state ? select(state) : draft
)
return $field
}
const $primaryMessages = primaryField<ChatMessage[]>(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<boolean>(state => state.awaitingResponse, $awaitingResponse),
$busy: primaryField<boolean>(state => state.busy, $busy),
$cwd: primaryField<string>(state => state.cwd, $currentCwd),
$fast: primaryField<boolean>(state => state.fast, $currentFastMode),
$lastVisibleIsUser: computed($primaryMessages, lastVisibleMessageIsUser),
$messages: $primaryMessages,
$messagesEmpty: computed($primaryMessages, messages => messages.length === 0),
$model: primaryField<string>(state => state.model, $currentModel),
$provider: primaryField<string>(state => state.provider, $currentProvider),
$reasoningEffort: primaryField<string>(state => state.reasoningEffort, $currentReasoningEffort),
$runtimeId: $activeSessionId,
$storedId: $selectedStoredSessionId
}