Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { I18nProvider } from '@/i18n'
import { $previewStatusBySession } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'

import { ToolFallback } from './fallback'

vi.mock('@assistant-ui/react', async importOriginal => {
const actual = await importOriginal<Record<string, unknown>>()

return {
...actual,
useAuiState: vi.fn((selector: (state: unknown) => unknown) =>
selector({
message: {
id: 'assistant-b',
status: { type: 'complete', reason: 'stop' }
},
thread: { isRunning: false }
})
)
}
})

Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}

function tileView(runtimeId: string, cwd: string): SessionView {
return {
kind: 'tile',
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(cwd),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(true),
$model: atom(''),
$provider: atom(''),
$reasoningEffort: atom(''),
$runtimeId: atom(runtimeId),
$storedId: atom(`stored-${runtimeId}`)
}
}

function previewToolProps(): ToolCallMessagePartProps {
return {
addResult: vi.fn(),
args: { path: '/work/b/creator.html' },
argsText: JSON.stringify({ path: '/work/b/creator.html' }),
isError: false,
respondToApproval: vi.fn(),
result: { ok: true },
resume: vi.fn(),
status: { type: 'complete' },
toolCallId: 'preview-b-1',
toolName: 'search_files',
type: 'tool-call'
}
}

beforeEach(() => {
$previewStatusBySession.set({})
$activeSessionId.set('session-a')
$currentCwd.set('/work/a')
})

afterEach(() => {
cleanup()
$previewStatusBySession.set({})
$activeSessionId.set(null)
$currentCwd.set('')
})

describe('ToolFallback preview session isolation (#66411)', () => {
it('records preview artifacts under the owning session view, not the global active session', async () => {
const sessionB = tileView('session-b', '/work/b')

render(
<I18nProvider configClient={null} initialLocale="en">
<SessionViewProvider value={sessionB}>
<ToolFallback {...previewToolProps()} />
</SessionViewProvider>
</I18nProvider>
)

await waitFor(() => {
expect($previewStatusBySession.get()['session-b']?.map(item => item.id)).toEqual(['/work/b/creator.html'])
})

expect($previewStatusBySession.get()['session-a']).toBeUndefined()

// Global focus flips to A while B's mounted tool row stays mounted — must
// not re-key the artifact into A's bucket.
$activeSessionId.set('session-a')
$currentCwd.set('/work/a')

await waitFor(() => {
expect($previewStatusBySession.get()['session-b']?.map(item => item.id)).toEqual(['/work/b/creator.html'])
})
expect($previewStatusBySession.get()['session-a']).toBeUndefined()
})
})
25 changes: 11 additions & 14 deletions apps/desktop/src/components/assistant-ui/tool/fallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -38,7 +39,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'
Expand Down Expand Up @@ -309,25 +309,22 @@ function ToolEntry({ part }: ToolEntryProps) {
}, [inlineDiff, isPending, result, stablePart])

// 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.
// in the composer status stack rather than a bulky inline card. Keyed to the
// owning session view (primary or tile), not the global active session — in
// multi-session split both panes stay mounted and focus can flip freely.
// Idempotent + dedup'd, so re-renders don't churn.
const sessionView = useSessionView()
const owningSessionId = useStore(sessionView.$runtimeId)
const owningCwd = useStore(sessionView.$cwd)
const previewTarget = view.previewTarget

useEffect(() => {
if (isPending || !previewTarget || !isPreviewableTarget(previewTarget)) {
if (isPending || !owningSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) {
return
}

// 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()

if (activeSessionId) {
recordPreviewArtifact(activeSessionId, previewTarget, $currentCwd.get() || '')
}
}, [isPending, previewTarget])
recordPreviewArtifact(owningSessionId, previewTarget, owningCwd || '')
}, [isPending, owningCwd, owningSessionId, previewTarget])

const detailSections = useMemo(() => {
if (!view.detail) {
Expand Down
Loading