cc-haha/desktop/src/pages/ActiveSession.test.tsx
程序员阿江(Relakkes) a94e5b641a fix: unify token usage display across desktop and CLI (#757)
Token counts were rendered with five inconsistent formats across the
chat UI (header "181,117 t", in-progress bare "↓ 2514", background
agents "12,345 tokens", compact summary "1.5k", trace "1.2k"), and the
in-progress count was actually stale: the server never set the status
event's tokens field, so the indicator showed the previous turn's value
(hence "first message shows nothing", "sometimes appears, never moves").

- Add shared desktop lib/formatTokenCount; route header, streaming
  indicator, background agents, compact summary, and trace through it.
- Header shows compact "124.3k tokens" with the exact count on hover.
- Add common.tokens i18n key (en/zh/zh-TW/jp/kr).
- Estimate the in-progress count from streamed chars (÷4, mirroring the
  CLI spinner) via a new streamingResponseChars per-session field, reset
  on each send; drop the dead status.tokens/elapsed fields.
- CLI spinner rows use formatTokens to drop the "1.0k" artifact.
2026-06-12 15:25:56 +08:00

1018 lines
33 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { act } from 'react'
const viewportMocks = vi.hoisted(() => ({
isMobile: false,
}))
vi.mock('../hooks/useMobileViewport', () => ({
useMobileViewport: () => viewportMocks.isMobile,
}))
vi.mock('../components/chat/MessageList', () => ({
MessageList: ({ compact }: { compact?: boolean }) => (
<div data-testid="message-list" data-compact={compact ? 'true' : 'false'} />
),
}))
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} />
),
}))
vi.mock('../components/teams/TeamStatusBar', () => ({
TeamStatusBar: () => <div data-testid="team-status-bar" />,
}))
vi.mock('../components/chat/SessionTaskBar', () => ({
SessionTaskBar: () => <div data-testid="session-task-bar" />,
}))
vi.mock('../components/workbench/WorkbenchPanel', () => ({
WorkbenchPanel: ({ sessionId }: { sessionId: string }) => (
<div data-testid="workspace-panel">workspace:{sessionId}</div>
),
}))
vi.mock('./TerminalSettings', () => ({
TerminalSettings: ({
active,
cwd,
onOpenInTab,
onClose,
runtimeId,
preserveOnUnmount,
testId,
}: {
active?: boolean
cwd?: string
onOpenInTab?: () => void
onClose?: () => void
runtimeId?: string
preserveOnUnmount?: boolean
testId: string
}) => (
<div
data-testid={testId}
data-active={active ? 'true' : 'false'}
data-cwd={cwd ?? ''}
data-preserve-on-unmount={preserveOnUnmount ? 'true' : 'false'}
data-runtime-id={runtimeId ?? ''}
>
<button type="button" onClick={onOpenInTab}>Open in Tab</button>
<button type="button" onClick={onClose}>Close terminal panel</button>
</div>
),
}))
import { ActiveSession } from './ActiveSession'
import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useSessionStore } from '../stores/sessionStore'
import { useTabStore } from '../stores/tabStore'
import { useTeamStore } from '../stores/teamStore'
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
import { WORKSPACE_PANEL_DEFAULT_WIDTH } from '../stores/workspacePanelStore'
import { useTerminalPanelStore } from '../stores/terminalPanelStore'
import {
TERMINAL_PANEL_DEFAULT_HEIGHT,
TERMINAL_PANEL_MAX_HEIGHT,
TERMINAL_PANEL_MIN_HEIGHT,
} from '../stores/terminalPanelStore'
afterEach(() => {
cleanup()
vi.useRealTimers()
viewportMocks.isMobile = false
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null })
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true)
})
describe('ActiveSession task polling', () => {
it('treats a persisted historical session as non-empty before messages finish loading', () => {
const sessionId = 'history-loading-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'History Loading Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 2,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'History Loading Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.getByTestId('message-list')).toBeInTheDocument()
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
})
it('shows a loading state for historical sessions while messages are loading', () => {
const sessionId = 'history-visible-loading-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'History Loading Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 2,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'History Loading Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
...useChatStore.getState().getSession(sessionId),
connectionState: 'connected',
historyStatus: 'loading',
},
},
})
render(<ActiveSession />)
expect(screen.getByRole('status')).toHaveTextContent(/Loading|加载中/)
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument()
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
})
it('renders the current goal as a lightweight header strip without a page-level panel', () => {
const sessionId = 'goal-visible-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Goal Visible 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,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Goal Visible Session', type: 'session', status: 'running' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{
id: 'goal-event',
type: 'goal_event',
action: 'created',
status: 'active',
objective: 'ship the smoke test',
budget: '0 / 2,000 tokens',
continuations: '0',
timestamp: 1,
}],
activeGoal: {
action: 'created',
status: 'active',
objective: 'ship the smoke test',
budget: '0 / 2,000 tokens',
continuations: '0',
updatedAt: 1,
},
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.queryByTestId('active-goal-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('active-goal-strip')).toBeInTheDocument()
expect(screen.getByTestId('active-goal-strip')).toHaveTextContent('ship the smoke test')
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
it('does not keep a completed goal pinned in the header', () => {
const sessionId = 'goal-completed-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Goal Completed Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 3,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Goal Completed Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{
id: 'goal-completed-event',
type: 'goal_event',
action: 'completed',
status: 'complete',
message: 'Goal marked complete.',
timestamp: 3,
}],
activeGoal: {
action: 'completed',
status: 'complete',
message: 'Goal marked complete.',
updatedAt: 3,
},
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.queryByTestId('active-goal-strip')).not.toBeInTheDocument()
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
it('does not render background agent progress as a page-level panel', () => {
const sessionId = 'background-agent-visible-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Background Agent 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,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Background Agent Session', type: 'session', status: 'running' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [],
activeGoal: {
action: 'created',
status: 'active',
objective: 'ship the smoke test',
updatedAt: 1,
},
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Verify the todo app',
summary: 'Running Playwright checks',
usage: {
totalTokens: 1200,
toolUses: 4,
durationMs: 45000,
},
startedAt: 1,
updatedAt: 2,
},
},
chatState: 'tool_executing',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.queryByTestId('background-agent-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
it('keeps the session header active while a background task is still running after the turn completes', () => {
const sessionId = 'background-shell-running-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Background Shell Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: new Date().toISOString(),
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Background Shell Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'task started', timestamp: 1 }],
backgroundAgentTasks: {
'bash-task-1': {
taskId: 'bash-task-1',
toolUseId: 'bash-tool-1',
status: 'running',
taskType: 'local_bash',
description: 'Run page integration checks',
startedAt: 1,
updatedAt: 2,
},
},
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.getByText(/session active|会话活跃中/)).toBeInTheDocument()
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
})
it('refreshes CLI tasks repeatedly while a turn is active', async () => {
vi.useFakeTimers()
const sessionId = 'polling-session'
const originalCliTaskState = useCLITaskStore.getState()
const fetchSessionTasks = vi.fn().mockResolvedValue(undefined)
useCLITaskStore.setState({
sessionId,
tasks: [],
fetchSessionTasks,
})
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Polling Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '',
workDir: null,
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Polling Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [],
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
const { unmount } = render(<ActiveSession />)
expect(fetchSessionTasks).toHaveBeenCalledWith(sessionId)
await act(async () => {
await vi.advanceTimersByTimeAsync(2200)
})
expect(
fetchSessionTasks.mock.calls.filter(([currentSessionId]) => currentSessionId === sessionId),
).toHaveLength(4)
unmount()
useCLITaskStore.setState(originalCliTaskState)
})
it('keeps member sessions interactive and skips leader task polling', () => {
const memberSessionId = 'team-member:security-reviewer@test-team'
const originalCliTaskState = useCLITaskStore.getState()
const fetchSessionTasks = vi.fn().mockResolvedValue(undefined)
useCLITaskStore.setState({
sessionId: null,
tasks: [],
fetchSessionTasks,
})
useTeamStore.setState({
teams: [],
activeTeam: {
name: 'test-team',
leadAgentId: 'team-lead@test-team',
leadSessionId: 'leader-session',
members: [
{
agentId: 'team-lead@test-team',
role: 'team-lead',
status: 'running',
sessionId: 'leader-session',
},
{
agentId: 'security-reviewer@test-team',
role: 'security-reviewer',
status: 'running',
},
],
},
memberColors: new Map(),
error: null,
})
useTabStore.setState({
tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }],
activeTabId: memberSessionId,
})
useChatStore.setState({
sessions: {
[memberSessionId]: {
messages: [],
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
const { queryByTestId, unmount } = render(<ActiveSession />)
expect(queryByTestId('chat-input')).toBeInTheDocument()
expect(queryByTestId('session-task-bar')).not.toBeInTheDocument()
expect(fetchSessionTasks).not.toHaveBeenCalled()
unmount()
useCLITaskStore.setState(originalCliTaskState)
})
it('renders the workspace panel to the right of chat and supports resizing', () => {
const sessionId = 'workspace-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Workspace Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '',
workDir: '/tmp/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Workspace Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useWorkspacePanelStore.getState().openPanel(sessionId)
render(<ActiveSession />)
const contentRow = screen.getByTestId('active-session-content-row')
const chatColumn = screen.getByTestId('active-session-chat-column')
const resizeHandle = screen.getByTestId('workspace-resize-handle')
const workbenchPanel = screen.getByTestId('workbench-panel')
expect(within(contentRow).getByTestId('message-list')).toBeInTheDocument()
expect(within(contentRow).getByTestId('message-list')).toHaveAttribute('data-compact', 'true')
expect(within(workbenchPanel).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument()
expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true')
expect(chatColumn).toHaveClass('flex-1')
expect(chatColumn).not.toHaveClass('shrink-0')
expect(contentRow.children[0]).toBe(chatColumn)
expect(contentRow.children[1]).toBe(resizeHandle)
expect(contentRow.children[2]).toBe(workbenchPanel)
act(() => {
fireEvent.keyDown(resizeHandle, { key: 'ArrowLeft' })
})
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH + 32)
})
it('does not render the workspace panel when closed or for member sessions', () => {
const regularSessionId = 'regular-session'
useSessionStore.setState({
sessions: [{
id: regularSessionId,
title: 'Regular Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 0,
projectPath: '',
workDir: '/tmp/project',
workDirExists: true,
}],
activeSessionId: regularSessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId: regularSessionId, title: 'Regular Session', type: 'session', status: 'idle' }],
activeTabId: regularSessionId,
})
useChatStore.setState({
sessions: {
[regularSessionId]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
const { rerender } = render(<ActiveSession />)
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
const memberSessionId = 'team-member:security-reviewer@test-team'
act(() => {
useTeamStore.setState({
teams: [],
activeTeam: {
name: 'test-team',
leadAgentId: 'team-lead@test-team',
leadSessionId: 'leader-session',
members: [
{
agentId: 'team-lead@test-team',
role: 'team-lead',
status: 'running',
sessionId: 'leader-session',
},
{
agentId: 'security-reviewer@test-team',
role: 'security-reviewer',
status: 'running',
},
],
},
memberColors: new Map(),
error: null,
})
useTabStore.setState({
tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }],
activeTabId: memberSessionId,
})
useChatStore.setState({
sessions: {
[memberSessionId]: {
messages: [{ id: 'msg-2', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useWorkspacePanelStore.getState().openPanel(memberSessionId)
rerender(<ActiveSession />)
})
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
it('keeps chat as the primary surface on mobile by hiding workspace and terminal panels', () => {
const sessionId = 'mobile-session'
viewportMocks.isMobile = true
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Mobile Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '/tmp/project-root',
workDir: '/tmp/project-root',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Mobile Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useWorkspacePanelStore.getState().openPanel(sessionId)
useTerminalPanelStore.getState().openPanel(sessionId)
render(<ActiveSession />)
expect(screen.getByTestId('active-session-chat-column')).toHaveClass('min-w-0')
expect(screen.getByTestId('message-list')).toHaveAttribute('data-compact', 'false')
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-compact', 'false')
expect(screen.queryByRole('heading', { name: 'Mobile Session' })).not.toBeInTheDocument()
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('workspace-resize-handle')).not.toBeInTheDocument()
expect(screen.queryByTestId('session-terminal-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('terminal-resize-handle')).not.toBeInTheDocument()
})
it('renders a bottom terminal panel in the current session cwd and can promote it to a tab', async () => {
const sessionId = 'terminal-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Terminal Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '/tmp/project-root',
workDir: '/tmp/project-root/packages/app',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Terminal Session', status: 'idle' } as ReturnType<typeof useTabStore.getState>['tabs'][number]],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useTerminalPanelStore.getState().openPanel(sessionId)
render(<ActiveSession />)
const panel = screen.getByTestId('session-terminal-panel')
const resizeHandle = screen.getByTestId('terminal-resize-handle')
const host = screen.getByTestId(`session-terminal-host-${sessionId}`)
expect(panel).toHaveStyle({ height: `${TERMINAL_PANEL_DEFAULT_HEIGHT}px` })
expect(host).toHaveAttribute('data-cwd', '/tmp/project-root/packages/app')
expect(host).toHaveAttribute('data-active', 'true')
expect(host).toHaveAttribute('data-preserve-on-unmount', 'true')
expect(resizeHandle).toHaveAttribute('aria-valuemin', `${TERMINAL_PANEL_MIN_HEIGHT}`)
expect(resizeHandle).toHaveAttribute('aria-valuemax', `${TERMINAL_PANEL_MAX_HEIGHT}`)
act(() => {
fireEvent.keyDown(resizeHandle, { key: 'ArrowUp' })
})
expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_DEFAULT_HEIGHT + 24)
await act(async () => {
const pointerDown = createEvent.pointerDown(resizeHandle)
Object.defineProperty(pointerDown, 'button', { value: 0 })
Object.defineProperty(pointerDown, 'clientY', { value: 300 })
fireEvent(resizeHandle, pointerDown)
})
await act(async () => {
const pointerMove = new Event('pointermove')
Object.defineProperty(pointerMove, 'clientY', { value: 260 })
window.dispatchEvent(pointerMove)
window.dispatchEvent(new Event('pointerup'))
})
expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_DEFAULT_HEIGHT + 64)
act(() => {
fireEvent.keyDown(resizeHandle, { key: 'End' })
})
expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_MAX_HEIGHT)
act(() => {
fireEvent.keyDown(resizeHandle, { key: 'Home' })
})
expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_MIN_HEIGHT)
act(() => {
fireEvent.doubleClick(resizeHandle)
})
expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_DEFAULT_HEIGHT)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open in Tab' }))
await Promise.resolve()
})
const terminalTab = useTabStore.getState().tabs.find((tab) => tab.type === 'terminal')
expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false)
expect(useTerminalPanelStore.getState().getPanelRuntimeId(sessionId)).toBeUndefined()
expect(terminalTab?.terminalCwd).toBe('/tmp/project-root/packages/app')
expect(terminalTab?.terminalRuntimeId).toBe(`__session_terminal__${sessionId}`)
expect(useTabStore.getState().activeTabId).toBe(terminalTab?.sessionId)
})
it('keeps the docked terminal mounted when the panel is hidden', async () => {
const sessionId = 'terminal-hide-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Terminal Hide Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '/tmp/project-root',
workDir: '/tmp/project-root',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Terminal Hide Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useTerminalPanelStore.getState().openPanel(sessionId)
render(<ActiveSession />)
fireEvent.click(screen.getByRole('button', { name: 'Close terminal panel' }))
expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false)
expect(screen.getByTestId('session-terminal-panel')).toHaveClass('hidden')
expect(screen.getByTestId(`session-terminal-host-${sessionId}`)).toHaveAttribute('data-active', 'false')
expect(screen.getByTestId(`session-terminal-host-${sessionId}`)).toHaveAttribute('data-runtime-id', `__session_terminal__${sessionId}`)
})
})