fix(desktop): preserve chat view across tab switches

This commit is contained in:
Relakkes Yang
2026-09-23 22:11:20 +08:00
parent 0fdceb4c6e
commit 2bdbbcc99d
8 changed files with 215 additions and 23 deletions
@@ -522,6 +522,27 @@ describe('ChatInput file mentions', () => {
})
})
it('keeps a retained conversation draft when settings becomes the selected tab', () => {
useTabStore.setState((state) => ({
tabs: [...state.tabs, { sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }],
}))
const { rerender } = render(<ChatInput sessionId={sessionId} />)
setComposerText('keep this draft', 15)
act(() => getComposerElement().focus())
expect(getComposerElement()).toHaveFocus()
act(() => useTabStore.getState().setActiveTab('__settings__'))
rerender(<ChatInput sessionId={sessionId} visible={false} />)
expect(getComposerText()).toBe('keep this draft')
expect(getComposerElement()).not.toHaveFocus()
expect(useChatStore.getState().sessions['__settings__']).toBeUndefined()
act(() => useTabStore.getState().setActiveTab(sessionId))
rerender(<ChatInput sessionId={sessionId} visible />)
expect(getComposerText()).toBe('keep this draft')
})
it('keeps the unsent draft when switching project on an empty active session', async () => {
installElectronFileHost()
mocks.dialogOpen.mockResolvedValueOnce('/other')
+18 -7
View File
@@ -79,6 +79,8 @@ type Attachment = ComposerAttachment
type ChatInputProps = {
variant?: 'default' | 'hero'
compact?: boolean
sessionId?: string
visible?: boolean
}
const EMPTY_COMPOSER_REFERENCES: ComposerReferenceCandidate[] = []
@@ -136,7 +138,7 @@ function insertComposerTokenAtRange(value: string, start: number, end: number, t
}
}
export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) {
export function ChatInput({ variant = 'default', compact = false, sessionId, visible = true }: ChatInputProps) {
const t = useTranslation()
const isMobileComposer = useMobileViewport() && !isDesktopRuntime()
// The shell, not the panel inside it: the panel's own `max-w` changes with
@@ -206,9 +208,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
sendQueuedUserMessage,
setPreparingTurn,
} = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const selectedTabId = useTabStore((s) => s.activeTabId)
const activeTabId = sessionId ?? selectedTabId
const activeTabType = useTabStore((s) =>
s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type,
s.tabs.find((tab) => tab.sessionId === (sessionId ?? s.activeTabId))?.type,
)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const repositoryLaunchDraft = sessionState?.repositoryLaunchDraft
@@ -408,8 +411,14 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}, [mentions])
useEffect(() => {
composerRef.current?.focus()
}, [isActive])
if (visible) composerRef.current?.focus()
}, [isActive, visible])
useEffect(() => {
if (visible) return
const focused = document.activeElement
if (focused instanceof HTMLElement && panelRef.current?.contains(focused)) focused.blur()
}, [visible])
useEffect(() => {
if (!composerPrefill || !activeTabId) return
@@ -439,7 +448,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setAtCursorPos(-1)
requestAnimationFrame(() => {
composerRef.current?.focus()
if (visible) composerRef.current?.focus()
if (composerPrefill.mode !== 'append') {
composerRef.current?.setSelectionOffsets(composerPrefill.text.length)
}
@@ -451,6 +460,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
composerPrefill,
setComposerAttachments,
setComposerInput,
visible,
])
useEffect(() => {
@@ -473,7 +483,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
clearComposerInsertion(activeTabId, composerInsertion.nonce)
requestAnimationFrame(() => {
composerRef.current?.focus()
if (visible) composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(next.cursorPos)
})
}, [
@@ -481,6 +491,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
addWorkspaceReference,
clearComposerInsertion,
composerInsertion,
visible,
isMemberSession,
setComposerInput,
])
@@ -300,6 +300,29 @@ describe('ContentRouter tab surfaces', () => {
expect(screen.getByTestId('settings-page')).toBeInTheDocument()
expect(useTabStore.getState().tabs.find(tab => tab.sessionId === 'session-1')).toMatchObject({ type: 'session' })
})
it('keeps the current conversation mounted while settings is open', () => {
useTabStore.setState({
tabs: [
{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' },
{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' },
],
activeTabId: 'session-1',
})
render(<ContentRouter />)
const conversation = screen.getByTestId('active-session')
act(() => useTabStore.getState().setActiveTab(SETTINGS_TAB_ID))
expect(screen.getByTestId('settings-page')).toBeInTheDocument()
expect(conversation).toBeInTheDocument()
expect(conversation.closest('[aria-hidden]')).toHaveAttribute('aria-hidden', 'true')
expect(conversation.closest('[aria-hidden]')).toHaveAttribute('inert')
act(() => useTabStore.getState().setActiveTab('session-1'))
expect(screen.getByTestId('active-session')).toBe(conversation)
expect(screen.queryByTestId('settings-page')).not.toBeInTheDocument()
})
})
it('routes the independent connectors tab', () => {
@@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { EmptySession } from '../../pages/EmptySession'
import { ActiveSession } from '../../pages/ActiveSession'
@@ -17,6 +17,18 @@ export function ContentRouter() {
const tabs = useTabStore((s) => s.tabs)
const activeTabType = tabs.find((t) => t.sessionId === activeTabId)?.type
const terminalTabs = tabs.filter((tab) => tab.type === 'terminal')
const activeSessionId = activeTabType === 'session' ? activeTabId : null
const [lastSessionId, setLastSessionId] = useState<string | null>(activeSessionId)
const retainedSessionId = activeSessionId ?? (
tabs.some((tab) => tab.sessionId === lastSessionId && tab.type === 'session')
? lastSessionId
: null
)
useEffect(() => {
if (activeSessionId) setLastSessionId(activeSessionId)
else if (lastSessionId && !retainedSessionId) setLastSessionId(null)
}, [activeSessionId, lastSessionId, retainedSessionId])
useEffect(() => {
if (activeTabType !== 'workbench') return
@@ -79,12 +91,24 @@ export function ContentRouter() {
page = teamTab?.teamLeadSessionId
? <AgentTeamsWorkbenchTab tabId={activeTabId} leadSessionId={teamTab.teamLeadSessionId} />
: <EmptySession />
} else if (activeTabType !== 'terminal') {
page = <ActiveSession />
} else if (activeTabType !== 'terminal' && activeTabType !== 'session') {
page = <EmptySession />
}
return (
<div className="relative min-h-0 flex-1 overflow-hidden">
{retainedSessionId && (
<div
aria-hidden={!activeSessionId}
{...(activeSessionId ? {} : { inert: '' })}
data-testid="session-tab-panel"
className={`absolute inset-0 flex min-h-0 flex-col overflow-hidden ${
activeSessionId ? 'z-10 opacity-100' : 'pointer-events-none z-0 opacity-0'
}`}
>
<ActiveSession sessionId={retainedSessionId} active={Boolean(activeSessionId)} />
</div>
)}
{page && (
<div className="absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden">
{page}
+53 -4
View File
@@ -37,14 +37,14 @@ vi.mock('../hooks/useMobileViewport', () => ({
}))
vi.mock('../components/chat/MessageList', () => ({
MessageList: ({ compact }: { compact?: boolean }) => (
<div data-testid="message-list" data-compact={compact ? 'true' : 'false'} />
MessageList: ({ compact, sessionId }: { compact?: boolean; sessionId?: string }) => (
<div data-testid="message-list" data-compact={compact ? 'true' : 'false'} data-session-id={sessionId} />
),
}))
vi.mock('../components/chat/ChatInput', () => ({
ChatInput: ({ compact, variant }: { compact?: boolean; variant?: string }) => (
<div data-testid="chat-input" data-compact={compact ? 'true' : 'false'} data-variant={variant} />
ChatInput: ({ compact, variant, sessionId, visible }: { compact?: boolean; variant?: string; sessionId?: string; visible?: boolean }) => (
<div data-testid="chat-input" data-compact={compact ? 'true' : 'false'} data-variant={variant} data-session-id={sessionId} data-visible={visible ? 'true' : 'false'} />
),
}))
@@ -141,6 +141,55 @@ afterEach(() => {
})
describe('ActiveSession task polling', () => {
it('keeps the same message list bound to its session while settings is selected', () => {
const sessionId = 'retained-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Retained Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
})
useTabStore.setState({
tabs: [
{ sessionId, title: 'Retained Session', type: 'session', status: 'idle' },
{ sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' },
],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
...createDefaultSessionState(),
connectionState: 'connected',
messages: [{ id: 'existing', type: 'assistant_text', content: 'ready', timestamp: 1 }],
historyStatus: 'ready',
historyHydrated: true,
},
},
})
const { rerender } = render(<ActiveSession sessionId={sessionId} active />)
const messageList = screen.getByTestId('message-list')
const chatInput = screen.getByTestId('chat-input')
act(() => useTabStore.getState().setActiveTab('__settings__'))
rerender(<ActiveSession sessionId={sessionId} active={false} />)
expect(screen.getByTestId('message-list')).toBe(messageList)
expect(screen.getByTestId('chat-input')).toBe(chatInput)
expect(messageList).toHaveAttribute('data-session-id', sessionId)
expect(chatInput).toHaveAttribute('data-session-id', sessionId)
expect(chatInput).toHaveAttribute('data-visible', 'false')
expect(useChatStore.getState().sessions['__settings__']).toBeUndefined()
})
it('shows cleaned worktrees as retained history and uses the source project for tools', () => {
const sessionId = 'cleaned-worktree-session'
useSettingsStore.setState({ locale: 'en' })
+10 -7
View File
@@ -316,11 +316,12 @@ function TerminalResizeHandle() {
)
}
export function ActiveSession() {
export function ActiveSession({ sessionId, active = true }: { sessionId?: string; active?: boolean } = {}) {
const isMobileLayout = useMobileViewport() && !isDesktopRuntime()
const workbenchPanelRef = useRef<HTMLElement>(null)
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
const selectedTabId = useTabStore((s) => s.activeTabId)
const activeTabId = sessionId ?? selectedTabId
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === (sessionId ?? s.activeTabId))?.type ?? null)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const stopBackgroundTask = useChatStore((s) => s.stopBackgroundTask)
@@ -408,12 +409,12 @@ export function ActiveSession() {
// Subscribed once for the app, not per task: the owner of each event is
// resolved from the page id, so a background task's pages keep reporting.
useWorkspaceBrowserEventBridge(!isMobileLayout)
useWorkspaceFocusReturn(workspaceEnabled ? activeTabId : null)
useWorkspaceBrowserEventBridge(active && !isMobileLayout)
useWorkspaceFocusReturn(active && workspaceEnabled ? activeTabId : null)
useWorkspaceShortcuts({
sessionId: activeTabId,
cwd: getSessionTerminalCwd(session) ?? '',
enabled: workspaceEnabled,
enabled: active && workspaceEnabled,
})
useEffect(() => {
@@ -877,7 +878,7 @@ export function ActiveSession() {
{historyError}
</div>
) : (
<MessageList compact={showRightPanel} mobileLayout={isMobileLayout} />
<MessageList sessionId={activeTabId ?? undefined} compact={showRightPanel} mobileLayout={isMobileLayout} />
)}
</>
)}
@@ -897,6 +898,8 @@ export function ActiveSession() {
) : null}
<ChatInput
sessionId={activeTabId ?? undefined}
visible={active}
variant={isEmpty && !showRightPanel ? 'hero' : 'default'}
compact={showRightPanel}
/>
+52 -2
View File
@@ -3,6 +3,7 @@ import type { AgentTaskNotification, UIMessage } from '../types/chat'
import type { MessageEntry, SessionListItem } from '../types/session'
import type { SessionHistoryPage } from '../api/sessions'
import type { SavedProvider } from '../types/provider'
import type { Tab } from './tabStore'
import {
buildMainSessionActivityModel,
buildSessionActivityModel,
@@ -36,6 +37,7 @@ const {
connectionStateHandlers,
sendSubagentMessageMock,
tabStoreSnapshot,
tabStoreListeners,
providerStoreSnapshot,
} = vi.hoisted(() => ({
sendMock: vi.fn(),
@@ -76,7 +78,8 @@ const {
},
connectionStateHandlers: new Map<string, (state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected') => void>(),
sendSubagentMessageMock: vi.fn(async () => ({ ok: true })),
tabStoreSnapshot: { tabs: [] as Array<Record<string, unknown>> },
tabStoreSnapshot: { tabs: [] as Tab[], activeTabId: null as string | null },
tabStoreListeners: new Set<(state: any, previous: any) => void>(),
providerStoreSnapshot: { providers: [] as SavedProvider[], activeId: null as string | null },
}))
@@ -136,9 +139,14 @@ vi.mock('./tabStore', () => ({
useTabStore: {
getState: () => ({
tabs: tabStoreSnapshot.tabs,
activeTabId: tabStoreSnapshot.activeTabId,
updateTabStatus: updateTabStatusMock,
updateTabTitle: updateTabTitleMock,
}),
subscribe: (listener: (state: any, previous: any) => void) => {
tabStoreListeners.add(listener)
return () => tabStoreListeners.delete(listener)
},
},
}))
@@ -14792,7 +14800,9 @@ describe('chatStore history mapping', () => {
const subagentSessionId = '__subagent__parent-session__tool-agent-1'
tabStoreSnapshot.tabs = [{
sessionId: subagentSessionId,
title: 'Subagent',
type: 'subagent',
status: 'idle',
sourceSessionId: 'parent-session',
subagentToolUseId: 'tool-agent-1',
subagentTaskId: 'agent-1',
@@ -15873,9 +15883,11 @@ describe('chatStore inactive complete-page retention', () => {
const { readFileSync } = await import('node:fs')
imageData = `data:image/png;base64,${readFileSync('src/assets/pets/action-sheet-guide.zh.png').toString('base64')}`
const { useTabStore } = await import('./tabStore')
tabStoreSnapshot.tabs = []
tabStoreSnapshot.activeTabId = null
const tabState = useTabStore.getState()
activeSessionId = 'image-cache-0'
const spy = vi.spyOn(useTabStore, 'getState').mockImplementation(() => ({ ...tabState, activeTabId: activeSessionId }))
const spy = vi.spyOn(useTabStore, 'getState').mockImplementation(() => ({ ...tabState, tabs: tabStoreSnapshot.tabs, activeTabId: activeSessionId }))
restoreTabState = () => spy.mockRestore()
getMemberBySessionIdMock.mockReturnValue(null)
vi.mocked(sessionsApi.getFullHistory).mockReset()
@@ -15885,9 +15897,47 @@ describe('chatStore inactive complete-page retention', () => {
afterEach(() => {
restoreTabState()
tabStoreSnapshot.tabs = []
tabStoreSnapshot.activeTabId = null
useChatStore.setState({ ...initialState, sessions: {} })
})
it('keeps the last open chat history when a settings or other page takes focus', () => {
const sessions = imageSessions()
const recentId = 'image-cache-4'
for (const [id, session] of Object.entries(sessions)) {
useChatStore.getState().markHistoryRowsDurable(id, session.messages)
}
const sessionTab: Tab = { sessionId: recentId, type: 'session', title: 'Recent', status: 'idle' }
const settingsTab: Tab = { sessionId: '__settings__', type: 'settings', title: 'Settings', status: 'idle' }
const marketTab: Tab = { sessionId: '__market__', type: 'market', title: 'Market', status: 'idle' }
const previous = { tabs: [sessionTab], activeTabId: recentId }
const settings = { tabs: [sessionTab, settingsTab], activeTabId: settingsTab.sessionId }
tabStoreSnapshot.tabs = settings.tabs
activeSessionId = settings.activeTabId
for (const listener of tabStoreListeners) listener(settings, previous)
useChatStore.getState().applyBoundedUpdate(() => ({ sessions }))
expect(useChatStore.getState().sessions[recentId]!.messages).toHaveLength(1)
expect(useChatStore.getState().sessions['image-cache-0']!.messages).toHaveLength(0)
const market = { tabs: [...settings.tabs, marketTab], activeTabId: marketTab.sessionId }
tabStoreSnapshot.tabs = market.tabs
activeSessionId = market.activeTabId
for (const listener of tabStoreListeners) listener(market, settings)
useChatStore.getState().applyBoundedUpdate(state => ({ sessions: { ...state.sessions } }))
expect(useChatStore.getState().sessions[recentId]!.messages).toHaveLength(1)
const closed = { tabs: [settingsTab, marketTab], activeTabId: marketTab.sessionId }
tabStoreSnapshot.tabs = closed.tabs
for (const listener of tabStoreListeners) listener(closed, market)
for (const [id, session] of Object.entries(sessions)) {
useChatStore.getState().markHistoryRowsDurable(id, session.messages)
}
useChatStore.getState().applyBoundedUpdate(() => ({ sessions }))
expect(useChatStore.getState().sessions[recentId]!.messages).toHaveLength(0)
})
it('keeps live rows intact while an idle-tab eviction would drop durable pages', () => {
// Live rows that have not yet been persisted are not eligible for the
// durable eviction set, so a tab holding them stays mounted whole.
+11
View File
@@ -2838,6 +2838,12 @@ function shouldPrewarmSession(sessionId: string): boolean {
}
export const useChatStore = create<ChatStore>((setState, get) => {
let lastViewedSessionId: string | null = null
useTabStore.subscribe((tabState, previousTabState) => {
if (tabState.activeTabId === previousTabState.activeTabId) return
const previousTab = previousTabState.tabs.find(tab => tab.sessionId === previousTabState.activeTabId)
if (previousTab?.type === 'session') lastViewedSessionId = previousTab.sessionId
})
const set = (update: Partial<ChatStore> | ((state: ChatStore) => Partial<ChatStore>)) => {
setState((previous) => {
const patch = typeof update === 'function' ? update(previous) : update
@@ -2863,6 +2869,11 @@ export const useChatStore = create<ChatStore>((setState, get) => {
const tabState = useTabStore.getState()
const activeTab = tabState.tabs.find(tab => tab.sessionId === tabState.activeTabId)
const activeIds = new Set([tabState.activeTabId, activeTab?.sourceSessionId, activeTab?.workbenchSessionId, activeTab?.teamLeadSessionId])
// Keep the chat the user just left warm while a non-chat page is open.
// Other idle tabs remain eligible, and closing this tab releases it.
if (activeTab?.type !== 'session' && tabState.tabs.some(tab => tab.type === 'session' && tab.sessionId === lastViewedSessionId)) {
activeIds.add(lastViewedSessionId)
}
const cacheSizes = new Map(Object.entries(sessions).map(([id, session]) => [id, historyCacheBytes(session)]))
let retainedBytes = [...cacheSizes.values()].reduce((total, bytes) => total + bytes, 0)
if (retainedBytes > CHAT_HISTORY_CACHE_BYTES) {