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,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 ||