fix(desktop): scope composer and transcript state to their own session

`$activeSessionId` only ever holds the primary chat's session, but surfaces
that render once per transcript were reading it as if it meant "the session
on screen." A preview produced inside a session tile was recorded under the
main chat's key and surfaced in the main chat's composer, which is what
prompted this.

The tool row now records under its own `SessionView`, and the same fix
applies to the other readers of that atom that render per surface:
attachment pills and inline preview links resolve relative paths against
their session's cwd, composer voice and auto-speak read and subscribe to
their own transcript, and the thread's compaction label, prompt-wait gate
and turn timer follow the session that mounted them. `ComposerScope` now
carries a `$messages` atom rather than a read closure so both the
imperative read and the subscription come from one place.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 17:55:17 -05:00
parent 96999b116b
commit 003ff53fb4
12 changed files with 165 additions and 86 deletions

View file

@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
@ -8,8 +9,7 @@ import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { openPreview } from '@/store/preview'
export function AttachmentList({
attachments,
@ -31,13 +31,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const { t } = useI18n()
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
// The tile's cwd when this pill lives in a tile composer, not the primary's:
// a relative attachment path has to resolve against its own session's root.
const cwd = useStore(useSessionView().$cwd)
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
async function openAttachmentPreview() {
if (!canPreview) {
return
}
@ -70,7 +72,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
: preview
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
openPreview(withBytes, 'manual')
} catch (error) {
notifyError(error, c.previewUnavailable)
}
@ -89,7 +91,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
disabled={!canPreview}
onClick={() => void openPreview()}
onClick={() => void openAttachmentPreview()}
type="button"
>
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">

View file

@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react'
import { playSpeechText } from '@/lib/voice-playback'
import { ownsAmbientCue } from '@/store/ambient'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useComposerScope } from '../scope'
interface AutoSpeakReply {
id: string
pending: boolean
@ -40,6 +41,9 @@ export function useAutoSpeakReplies({
sessionId
}: UseAutoSpeakReplies) {
const enabled = useStore($autoSpeakReplies)
// Wake on THIS composer's transcript: a tile subscribed to the primary's
// would never fire on its own replies (and would fire on someone else's).
const { $messages } = useComposerScope()
const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply })
latest.current = { conversationActive, failureLabel, markSpoken, pendingReply }
@ -83,5 +87,5 @@ export function useAutoSpeakReplies({
const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
return () => stops.forEach(f => f())
}, [enabled, sessionId])
}, [$messages, enabled, sessionId])
}

View file

@ -5,11 +5,11 @@ import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { resetBrowseState } from '@/store/composer-input-history'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
import { useAutoSpeakReplies } from './use-auto-speak-replies'
@ -50,6 +50,8 @@ export function useComposerVoice({
target
}: UseComposerVoiceArgs) {
const { t } = useI18n()
// A tile's composer speaks ITS transcript, not the primary chat's.
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)

View file

@ -1,6 +1,14 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import {
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef
} from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
@ -11,7 +19,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $compactionActive } from '@/store/compaction'
import { sessionCompacting } from '@/store/compaction'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
@ -108,7 +116,7 @@ export function ChatBar({
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const compacting = useStore($compactionActive)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId]))
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
@ -637,7 +645,7 @@ export function ChatBar({
// $messages is read imperatively (not subscribed) so the composer
// doesn't re-render on every streaming delta flush.
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const history = deriveUserHistory(scope.$messages.get(), chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
@ -662,7 +670,7 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const history = deriveUserHistory(scope.$messages.get(), chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {

View file

@ -25,18 +25,19 @@ export interface ComposerScope {
attachments: ComposerAttachmentScope
/** Only the main scope may pop out (the floating composer is a singleton). */
popoutAllowed: boolean
/** Imperative read of this scope's transcript (input-history browse)
* never subscribed, so streaming stays out of the composer's renders. */
readMessages: () => ChatMessage[]
/** This scope's transcript. Read it imperatively (input-history browse) to
* keep streaming out of the composer's renders; subscribe only off-render
* (auto-speak) where the reply edge is the whole point. */
$messages: ReadableAtom<ChatMessage[]>
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
target: ComposerTarget
}
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
$awaitingInput: $activeSessionAwaitingInput,
$messages,
attachments: mainComposerScope,
popoutAllowed: true,
readMessages: () => $messages.get(),
target: 'main'
}

View file

@ -126,9 +126,9 @@ function TileChat({
const scope = useMemo<ComposerScope>(
() => ({
$awaitingInput: sessionAwaitingInput(runtimeId),
$messages: view.$messages,
attachments,
popoutAllowed: false,
readMessages: () => view.$messages.get(),
target: `tile:${storedSessionId}`
}),
[attachments, runtimeId, storedSessionId, view.$messages]

View file

@ -1,7 +1,8 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useEffect, useState } from 'react'
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { Codicon } from '@/components/ui/codicon'
@ -9,9 +10,9 @@ import { Loader } from '@/components/ui/loader'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $backgroundResume } from '@/store/background-delegation'
import { $compactionActive } from '@/store/compaction'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
import { sessionCompacting } from '@/store/compaction'
import { sessionAwaitingInput } from '@/store/prompts'
import { $turnStartedAt } from '@/store/session'
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
@ -37,11 +38,23 @@ const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
)
function useActiveTurnTimerKey(): string | undefined {
const activeSessionId = useStore($activeSessionId)
/** These indicators render inside whichever transcript mounted them, so every
* session-scoped signal comes from that surface's view a tile must never
* show the primary chat's compaction, prompt-wait, or turn timer. */
function useThreadSessionStatus() {
const sessionId = useStore(useSessionView().$runtimeId)
const turnStartedAt = useStore($turnStartedAt)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
// A pending clarify / approval / sudo / secret means the turn is paused on the
// user, not working — so don't resurrect the "thinking" timer while they
// decide (matches the pet's awaitingInput pose taking priority over busy).
const awaitingInput = useStore(useMemo(() => sessionAwaitingInput(sessionId), [sessionId]))
return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined
return {
awaitingInput,
compacting,
turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined
}
}
export const CenteredThreadSpinner: FC = () => {
@ -67,9 +80,8 @@ export const CenteredThreadSpinner: FC = () => {
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const timerKey = useActiveTurnTimerKey()
const elapsed = useElapsedSeconds(true, timerKey)
const compacting = useStore($compactionActive)
const { compacting, turnTimerKey } = useThreadSessionStatus()
const elapsed = useElapsedSeconds(true, turnTimerKey)
return (
<StatusRow
@ -147,12 +159,7 @@ export const StreamStallIndicator: FC = () => {
// what lets the timer read "quiet for 12s" rather than the age of this
// component, which is the whole turn so far.
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
const compacting = useStore($compactionActive)
const turnTimerKey = useActiveTurnTimerKey()
// A pending clarify / approval / sudo / secret means the turn is paused on the
// user, not working — so don't resurrect the "thinking" timer while they
// decide (matches the pet's awaitingInput pose taking priority over busy).
const awaitingInput = useStore($activeSessionAwaitingInput)
const { awaitingInput, compacting, turnTimerKey } = useThreadSessionStatus()
useEffect(() => {
setQuietSince(undefined)

View file

@ -0,0 +1,76 @@
import { cleanup, render } from '@testing-library/react'
import { atom } from 'nanostores'
import type { ComponentProps, ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { $previewStatusBySession } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
vi.mock('@assistant-ui/react', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
useAuiState: (select: (state: unknown) => unknown) =>
select({ message: { id: 'msg-1', status: { type: 'complete' } }, thread: { isRunning: false } })
}))
const { ToolFallback } = await import('./fallback')
const PRIMARY_ID = 'primary-session'
const TILE_ID = 'tile-session'
/** Minimal tile view: only the fields the tool row reads. */
function tileView(): SessionView {
return {
...({} as SessionView),
$cwd: atom('/tile/work'),
$messages: atom([]),
$runtimeId: atom<null | string>(TILE_ID),
kind: 'tile'
}
}
function renderToolRow(wrap: (node: ReactNode) => ReactNode) {
const props = {
args: { path: '/tile/work/report.html' },
result: { path: '/tile/work/report.html' },
toolCallId: 'call-1',
toolName: 'write_file'
} as unknown as ComponentProps<typeof ToolFallback>
render(<>{wrap(<ToolFallback {...props} />)}</>)
}
afterEach(() => {
cleanup()
$previewStatusBySession.set({})
$activeSessionId.set(null)
$currentCwd.set('')
})
describe('tool row preview recording', () => {
// The row used to record under the global (primary-only) $activeSessionId, so
// a preview produced inside a session TILE surfaced in the main chat's
// composer instead of the tile's own.
it('records into the session whose transcript the row is in, not the primary', () => {
$activeSessionId.set(PRIMARY_ID)
$currentCwd.set('/primary/work')
const view = tileView()
renderToolRow(node => <SessionViewProvider value={view}>{node}</SessionViewProvider>)
const recorded = $previewStatusBySession.get()
expect(Object.keys(recorded)).toEqual([TILE_ID])
expect(recorded[TILE_ID]?.[0]?.cwd).toBe('/tile/work')
})
it('still records into the primary session for the main chat', () => {
$activeSessionId.set(PRIMARY_ID)
$currentCwd.set('/primary/work')
renderToolRow(node => node)
expect(Object.keys($previewStatusBySession.get())).toEqual([PRIMARY_ID])
})
})

View file

@ -16,6 +16,7 @@ import {
useState
} from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@ -39,7 +40,6 @@ import { normalize } from '@/lib/text'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
import { $toolInlineDiff } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
@ -364,9 +364,12 @@ function ToolEntry({ part }: ToolEntryProps) {
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
// in the composer status stack rather than a bulky inline card. Uses the same
// detected target the old inline card did, keyed to the active session the
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
// detected target the old inline card did. Idempotent + dedup'd, so re-renders
// don't churn.
const previewTarget = view.previewTarget
// The session whose transcript this row is IN, which is not necessarily the
// primary one: a tool row inside a session tile must feed that tile's composer.
const { $cwd: $sessionCwd, $runtimeId: $sessionRuntimeId } = useSessionView()
useEffect(() => {
if (isPending || !previewTarget || !isPreviewableTarget(previewTarget)) {
@ -376,12 +379,12 @@ function ToolEntry({ part }: ToolEntryProps) {
// Read (don't subscribe) session/cwd: this only fires when a previewable
// target appears, and subscribing re-rendered every tool row on any session
// or cwd change.
const activeSessionId = $activeSessionId.get()
const sessionId = $sessionRuntimeId.get()
if (activeSessionId) {
recordPreviewArtifact(activeSessionId, previewTarget, $currentCwd.get() || '')
if (sessionId) {
recordPreviewArtifact(sessionId, previewTarget, $sessionCwd.get() || '')
}
}, [isPending, previewTarget])
}, [$sessionCwd, $sessionRuntimeId, isPending, previewTarget])
const detailSections = useMemo(() => {
if (!view.detail) {

View file

@ -1,33 +1,28 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useI18n } from '@/i18n'
import { MonitorPlay } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { previewName } from '@/lib/preview-targets'
import { notifyError } from '@/store/notifications'
import {
$previewTarget,
dismissPreviewTarget,
type PreviewRecordSource,
setCurrentSessionPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { $previewTabSources, closePreviewForSource, openPreview, type PreviewRecordSource } from '@/store/preview'
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
const { t } = useI18n()
const cwd = useStore($currentCwd)
const activePreview = useStore($previewTarget)
// This link lives in one session's transcript; resolve it against THAT
// session's cwd, not the primary chat's.
const cwd = useStore(useSessionView().$cwd)
const openSources = useStore($previewTabSources)
const [opening, setOpening] = useState(false)
const activePreviewRef = useRef(activePreview)
const cwdRef = useRef(cwd)
const mountedRef = useRef(false)
const requestTokenRef = useRef(0)
const targetRef = useRef(target)
const name = previewName(target)
const isActive = activePreview?.source === target
const isActive = openSources.includes(target)
activePreviewRef.current = activePreview
cwdRef.current = cwd
targetRef.current = target
@ -53,7 +48,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
}
if (isActive) {
dismissPreviewTarget()
closePreviewForSource(target)
return
}
@ -80,13 +75,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
throw new Error(`Could not open preview target: ${requestTarget}`)
}
const currentPreview = activePreviewRef.current
if (currentPreview?.source === preview.source && currentPreview.url === preview.url) {
return
}
setCurrentSessionPreviewTarget(preview, source, requestTarget)
openPreview(preview, source)
} catch (error) {
if (
!mountedRef.current ||

View file

@ -1,18 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction'
import { $activeSessionId } from './session'
import { $compactingSessions, sessionCompacting, setSessionCompacting } from './compaction'
describe('compaction store', () => {
beforeEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
beforeEach(() => $compactingSessions.set({}))
afterEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
afterEach(() => $compactingSessions.set({}))
it('tracks compaction per session independently', () => {
setSessionCompacting('session-a', true)
@ -21,16 +14,11 @@ describe('compaction store', () => {
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
})
it('exposes only the active session via the focus-scoped view', () => {
it('scopes the view to the session asked for, not whichever is active', () => {
setSessionCompacting('session-a', true)
expect($compactionActive.get()).toBe(false)
$activeSessionId.set('session-a')
expect($compactionActive.get()).toBe(true)
$activeSessionId.set('session-b')
expect($compactionActive.get()).toBe(false)
expect(sessionCompacting('session-a').get()).toBe(true)
expect(sessionCompacting('session-b').get()).toBe(false)
})
it('clears a session without disturbing the others', () => {

View file

@ -1,7 +1,5 @@
import { atom, computed } from 'nanostores'
import { $activeSessionId } from './session'
// Per-session flag while auto-compaction runs mid-turn. Without it the
// transcript looks like it reset; per-session so a background chat can't
// clobber the foreground view.
@ -9,10 +7,11 @@ const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $compactingSessions = atom<Record<string, true>>({})
export const $compactionActive = computed(
[$compactingSessions, $activeSessionId],
(sessions, activeId) => keyFor(activeId) in sessions
)
/** Is `sessionId` compacting? Per-session because a transcript may be a tile,
* and a tile must never wear the primary chat's compaction state. */
export function sessionCompacting(sessionId: null | string) {
return computed($compactingSessions, sessions => keyFor(sessionId) in sessions)
}
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
const key = keyFor(sessionId)