fix: keep background tasks visible after turn completion

Preserve background task visibility after the foreground turn completes and move task progress into an official-style background task drawer.

Keep tabs/sidebar active while background tasks run, delay foreground completion rows until background work is settled, and avoid hiding resumed agents after clearing prior finished entries.

Tested: bun run check:desktop
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-28 10:50:33 +08:00
parent 84c5685b1a
commit 8d12fdbcff
18 changed files with 1754 additions and 51 deletions

View File

@ -0,0 +1,265 @@
import { useEffect, useMemo, useState } from 'react'
import { CheckCircle2, CircleStop, LoaderCircle, X, XCircle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { createBackgroundTaskDismissKey, formatDurationMs } from '../../lib/backgroundTasks'
import type { BackgroundAgentTask } from '../../types/chat'
type BackgroundTasksBarProps = {
tasks: BackgroundAgentTask[]
compact?: boolean
dismissedFinishedTaskKeys?: Set<string>
onClearFinished?: (taskKeys: string[]) => void
}
const EMPTY_DISMISSED_TASK_KEYS = new Set<string>()
export function BackgroundTasksBar({
tasks,
compact = false,
dismissedFinishedTaskKeys,
onClearFinished,
}: BackgroundTasksBarProps) {
const t = useTranslation()
const [open, setOpen] = useState(false)
const dismissedTaskKeys = dismissedFinishedTaskKeys ?? EMPTY_DISMISSED_TASK_KEYS
const { runningTasks, finishedTasks } = useMemo(() => {
const sorted = [...tasks].sort((a, b) => b.updatedAt - a.updatedAt)
return {
runningTasks: sorted.filter((task) => task.status === 'running'),
finishedTasks: sorted.filter((task) => task.status !== 'running'),
}
}, [tasks])
const visibleFinishedTasks = finishedTasks.filter((task) =>
!dismissedTaskKeys.has(createBackgroundTaskDismissKey(task))
)
const runningCount = runningTasks.length
const visibleFinishedCount = visibleFinishedTasks.length
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
setOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open])
if (tasks.length === 0 || (runningCount === 0 && visibleFinishedCount === 0 && !open)) return null
const taskButtonLabel = runningCount > 0
? t(
runningCount === 1
? 'chat.backgroundTasks.runningCountOne'
: 'chat.backgroundTasks.runningCountMany',
{ count: runningCount },
)
: t(
visibleFinishedCount === 1
? 'chat.backgroundTasks.finishedCountOne'
: 'chat.backgroundTasks.finishedCountMany',
{ count: visibleFinishedCount },
)
const drawerTitleId = 'background-tasks-drawer-title'
return (
<>
{runningCount > 0 || visibleFinishedCount > 0 ? (
<div className={['shrink-0', compact ? 'px-4' : 'px-8'].join(' ')}>
<div className={compact ? 'w-full py-2' : 'mx-auto w-full max-w-[860px] py-2'}>
<button
type="button"
data-testid="background-tasks-button"
aria-expanded={open}
aria-controls="background-tasks-drawer"
onClick={() => setOpen(true)}
className="inline-flex min-h-8 items-center gap-2 rounded-md px-1.5 py-1 text-[13px] font-medium text-[var(--color-accent)] transition-colors hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-accent-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{runningCount > 0 ? (
<LoaderCircle size={16} strokeWidth={2.2} className="animate-spin text-[var(--color-warning)]" aria-hidden="true" />
) : (
<CheckCircle2 size={16} strokeWidth={2.2} className="text-[var(--color-success)]" aria-hidden="true" />
)}
<span>{taskButtonLabel}</span>
</button>
</div>
</div>
) : null}
{open ? (
<aside
id="background-tasks-drawer"
data-testid="background-tasks-drawer"
role="dialog"
aria-modal="false"
aria-labelledby={drawerTitleId}
className="absolute inset-y-0 right-0 z-40 flex w-[360px] max-w-[calc(100vw-24px)] flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--color-border)] px-4">
<h2 id={drawerTitleId} className="text-[15px] font-semibold text-[var(--color-text-primary)]">
{t('chat.backgroundTasks.title')}
</h2>
<button
type="button"
aria-label={t('chat.backgroundTasks.close')}
onClick={() => setOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<X size={16} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<TaskSection title={t('chat.backgroundTasks.running')} tasks={runningTasks} />
<div className="mt-5 flex items-center justify-between">
<h3 className="text-[13px] font-semibold text-[var(--color-text-secondary)]">
{t('chat.backgroundTasks.finished')}
{visibleFinishedCount > 0 ? (
<span className="ml-1 font-normal text-[var(--color-text-tertiary)]">{visibleFinishedCount}</span>
) : null}
</h3>
{visibleFinishedCount > 0 ? (
<button
type="button"
onClick={() => {
onClearFinished?.(finishedTasks.map(createBackgroundTaskDismissKey))
if (runningTasks.length === 0) setOpen(false)
}}
className="rounded-md px-2 py-1 text-[12px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t('chat.backgroundTasks.clear')}
</button>
) : null}
</div>
<TaskList tasks={visibleFinishedTasks} />
</div>
</aside>
) : null}
</>
)
}
function TaskSection({ title, tasks }: { title: string; tasks: BackgroundAgentTask[] }) {
if (tasks.length === 0) return null
return (
<section>
<h3 className="mb-2 text-[13px] font-semibold text-[var(--color-text-secondary)]">{title}</h3>
<TaskList tasks={tasks} />
</section>
)
}
function TaskList({ tasks }: { tasks: BackgroundAgentTask[] }) {
if (tasks.length === 0) return null
return (
<div className="space-y-2">
{tasks.map((task) => (
<BackgroundTaskRow key={task.taskId} task={task} />
))}
</div>
)
}
function BackgroundTaskRow({ task }: { task: BackgroundAgentTask }) {
const t = useTranslation()
const title = task.description?.trim() ||
task.summary?.trim() ||
task.lastToolName?.trim() ||
task.outputFile?.trim() ||
task.taskId
const typeLabel = getTaskTypeLabel(task, t)
const duration = formatDurationMs(task.usage?.durationMs, t)
const tokenLabel = task.usage?.totalTokens
? t('chat.backgroundAgents.tokens', { count: formatCompactNumber(task.usage.totalTokens) })
: null
return (
<div
data-testid="background-task-row"
data-status={task.status}
className="rounded-[8px] bg-[var(--color-surface-container-low)] px-3 py-2.5"
>
<div className="flex min-w-0 items-start gap-2">
<span className="mt-1 flex h-2 w-2 shrink-0 items-center justify-center rounded-full bg-[var(--color-text-tertiary)]">
{task.status === 'running' ? (
<span className="h-2 w-2 rounded-full bg-[var(--color-accent)] animate-pulse-dot" aria-hidden="true" />
) : null}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-[var(--color-text-primary)]" title={title}>
{title}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-[var(--color-text-tertiary)]">
<span className="inline-flex items-center gap-1 text-[var(--color-text-secondary)]">
{getTaskStatusIcon(task.status)}
{typeLabel}
</span>
<span>{getTaskStatusLabel(task.status, t)}</span>
{duration ? <span>{duration}</span> : null}
{tokenLabel ? <span>{tokenLabel}</span> : null}
</div>
</div>
</div>
</div>
)
}
function getTaskStatusIcon(status: BackgroundAgentTask['status']) {
if (status === 'running') {
return <LoaderCircle size={13} strokeWidth={2.2} className="animate-spin" aria-hidden="true" />
}
if (status === 'failed') {
return <XCircle size={13} strokeWidth={2.2} className="text-[var(--color-error)]" aria-hidden="true" />
}
if (status === 'stopped') {
return <CircleStop size={13} strokeWidth={2.2} aria-hidden="true" />
}
return <CheckCircle2 size={13} strokeWidth={2.2} className="text-[var(--color-success)]" aria-hidden="true" />
}
function getTaskTypeLabel(
task: BackgroundAgentTask,
t: ReturnType<typeof useTranslation>,
): string {
if (task.taskType === 'local_agent' || task.taskType === 'remote_agent') {
return t('chat.backgroundTasks.type.agent')
}
if (task.taskType === 'local_bash' || task.taskType === 'shell' || task.taskType === 'bash') {
return t('chat.backgroundTasks.type.bash')
}
if (task.taskType === 'local_workflow' || task.workflowName) {
return t('chat.backgroundTasks.type.workflow')
}
return t('chat.backgroundTasks.type.task')
}
function getTaskStatusLabel(
status: BackgroundAgentTask['status'],
t: ReturnType<typeof useTranslation>,
): string {
switch (status) {
case 'running':
return t('chat.backgroundAgents.status.running')
case 'completed':
return t('chat.backgroundAgents.status.completed')
case 'failed':
return t('chat.backgroundAgents.status.failed')
case 'stopped':
return t('chat.backgroundAgents.status.stopped')
}
}
function formatCompactNumber(value: number): string {
if (value < 1000) return String(value)
if (value < 1000000) return `${Math.round(value / 100) / 10}k`
return `${Math.round(value / 100000) / 10}m`
}

View File

@ -624,6 +624,41 @@ describe('MessageList nested tool calls', () => {
expect(card.textContent).toContain('45s')
})
it('localizes non-agent background task duration units', () => {
useSettingsStore.setState({ locale: 'zh' })
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'background-task-shell-1',
type: 'background_task',
timestamp: 2,
task: {
taskId: 'shell-task-1',
toolUseId: 'shell-tool-1',
status: 'completed',
taskType: 'local_bash',
summary: 'Running Playwright checks',
usage: {
totalTokens: 1200,
toolUses: 4,
durationMs: 65000,
},
startedAt: 2,
updatedAt: 2,
},
},
],
}),
},
})
render(<MessageList />)
expect(screen.getByTestId('background-task-event-card').textContent).toContain('1 分 5 秒')
})
it('renders stopped non-agent background tasks as neutral transcript events', () => {
useChatStore.setState({
sessions: {
@ -3963,12 +3998,47 @@ describe('MessageList nested tool calls', () => {
expect(await screen.findByText('first.ts')).toBeTruthy()
act(() => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages,
chatState: 'idle',
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
status: 'running',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 2,
},
},
}),
},
})
})
await waitFor(() => {
expect(screen.queryByText('first.ts')).toBeNull()
})
act(() => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages,
chatState: 'thinking',
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
status: 'completed',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 3,
},
},
}),
},
})
@ -3979,6 +4049,67 @@ describe('MessageList nested tool calls', () => {
})
})
it('does not load turn change cards while background tasks are still running', async () => {
const getTurnCheckpoints = vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
{
target: {
targetUserMessageId: 'user-1',
userMessageIndex: 0,
userMessageCount: 1,
},
code: {
available: true,
filesChanged: ['src/first.ts'],
insertions: 1,
deletions: 0,
},
},
],
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '第一轮',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: 'done',
timestamp: 2,
},
],
chatState: 'idle',
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
status: 'running',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 2,
},
},
}),
},
})
render(<MessageList />)
await act(async () => {
await Promise.resolve()
})
expect(getTurnCheckpoints).not.toHaveBeenCalled()
expect(screen.queryByText('first.ts')).toBeNull()
})
it('confirms before rewinding to an earlier turn from a historical change card', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [

View File

@ -24,6 +24,7 @@ import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { formatTokenCount } from '../../lib/formatTokenCount'
import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { isTouchH5Document } from '../../lib/touchH5'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
@ -70,6 +71,8 @@ type TurnChangeCardModel = {
isLatest: boolean
}
const EMPTY_TURN_CHANGE_CARDS: TurnChangeCardModel[] = []
type ChatMessageRole = 'user' | 'assistant'
type ChatSelectionState = {
@ -325,21 +328,13 @@ function GoalContinuationDivider({ message }: { message: GoalEvent }) {
)
}
function formatBackgroundTaskDuration(durationMs?: number) {
if (typeof durationMs !== 'number' || durationMs < 0) return null
const seconds = Math.round(durationMs / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
return `${minutes}m ${seconds % 60}s`
}
function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent }) {
const t = useTranslation()
const { task } = message
const isRunning = task.status === 'running'
const isFailed = task.status === 'failed'
const isStopped = task.status === 'stopped'
const duration = formatBackgroundTaskDuration(task.usage?.durationMs)
const duration = formatDurationMs(task.usage?.durationMs, t)
const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId
const label = getBackgroundTaskLabel(task.taskType, t)
@ -1377,6 +1372,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const streamingToolInput = sessionState?.streamingToolInput ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
const activeAskUserQuestionToolUseId =
sessionState?.pendingPermission?.toolName === 'AskUserQuestion'
? sessionState.pendingPermission.toolUseId
@ -1422,6 +1418,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const branchActionsDisabled =
isMemberSession ||
chatState !== 'idle' ||
hasRunningBackgroundTasks ||
streamingText.trim().length > 0 ||
Boolean(activeThinkingId) ||
Boolean(sessionState?.activeToolUseId) ||
@ -1706,13 +1703,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
completedTurnTargets.length > 0
? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null
: null
const visibleTurnChangeCards = hasRunningBackgroundTasks ? EMPTY_TURN_CHANGE_CARDS : turnChangeCards
const turnCardsByRenderIndex = useMemo(
() => buildTurnCardInsertionMap(renderItems, turnChangeCards),
[renderItems, turnChangeCards],
() => buildTurnCardInsertionMap(renderItems, visibleTurnChangeCards),
[renderItems, visibleTurnChangeCards],
)
const changedFilesByRenderIndex = useMemo(
() => buildChangedFilesByRenderIndex(renderItems, turnChangeCards),
[renderItems, turnChangeCards],
() => buildChangedFilesByRenderIndex(renderItems, visibleTurnChangeCards),
[renderItems, visibleTurnChangeCards],
)
const renderItemKeys = useMemo(
() => renderItems.map(getRenderItemKey),
@ -1747,8 +1745,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport],
)
const confirmTurnCard = useMemo(
() => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
[turnChangeCards, turnUndoConfirmTargetId],
() => visibleTurnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
[turnUndoConfirmTargetId, visibleTurnChangeCards],
)
useEffect(() => {
@ -1776,6 +1774,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
return
}
if (hasRunningBackgroundTasks) {
setTurnChangeLoadError(null)
setIsLoadingTurnChangeCards(false)
return
}
if (chatState !== 'idle') {
setTurnChangeLoadError(null)
setIsLoadingTurnChangeCards(false)
@ -1830,10 +1834,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
return () => {
cancelled = true
}
}, [chatState, completedTurnTargets, isMemberSession, latestCompletedTurnId, resolvedSessionId])
}, [chatState, completedTurnTargets, hasRunningBackgroundTasks, isMemberSession, latestCompletedTurnId, resolvedSessionId])
const handleUndoCurrentTurn = useCallback(async () => {
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId) return
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId || hasRunningBackgroundTasks) return
const target = confirmTurnCard.target
setRewindingTurnId(target.messageId)
@ -1886,6 +1890,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
addToast,
chatState,
confirmTurnCard,
hasRunningBackgroundTasks,
queueComposerPrefill,
reloadHistory,
resolvedSessionId,
@ -2048,7 +2053,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
<StreamingIndicator />
)}
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
{!isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{turnChangeLoadError}
</div>

View File

@ -113,6 +113,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import type { SessionListItem } from '../../types/session'
import type { PerSessionState } from '../../stores/chatStore'
const PROJECT_ORDER_STORAGE_KEY = 'cc-haha-sidebar-project-order'
const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects'
@ -139,6 +140,33 @@ function makeSession(
}
}
function makeChatSessionState(overrides: Partial<PerSessionState> = {}): PerSessionState {
return {
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: {},
backgroundAgentTasks: {},
activeGoal: null,
elapsedTimer: null,
composerPrefill: null,
composerDraft: null,
...overrides,
}
}
function makeDataTransfer() {
const data = new Map<string, string>()
return {
@ -884,16 +912,35 @@ describe('Sidebar', () => {
...makeSession('running-worktree', 'Running Worktree', '/workspace/repo/.claude/worktrees/desktop-main-12345678', '2026-05-19T07:00:00.000Z'),
projectRoot: '/workspace/repo',
},
makeSession('background-running', 'Background Running', '/workspace/repo', '2026-05-19T10:30:00.000Z'),
makeSession('idle-source', 'Idle Source', '/workspace/repo', '2026-05-19T11:40:00.000Z'),
],
})
useTabStore.setState({
tabs: [
{ sessionId: 'running-worktree', title: 'Running Worktree', type: 'session', status: 'running' },
{ sessionId: 'background-running', title: 'Background Running', type: 'session', status: 'idle' },
{ sessionId: 'idle-source', title: 'Idle Source', type: 'session', status: 'idle' },
],
activeTabId: 'running-worktree',
})
useChatStore.setState({
sessions: {
'background-running': makeChatSessionState({
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 2,
},
},
}),
},
})
render(<Sidebar />)
@ -902,6 +949,9 @@ describe('Sidebar', () => {
expect(within(runningRow).getByText('worktree')).toHaveClass('sr-only')
expect(within(runningRow).getByText('5h ago')).toBeInTheDocument()
const backgroundRunningRow = screen.getByRole('button', { name: /Background Running/ })
expect(within(backgroundRunningRow).getByLabelText('Session running')).toBeInTheDocument()
const idleRow = screen.getByRole('button', { name: /Idle Source/ })
expect(within(idleRow).queryByLabelText('Session running')).not.toBeInTheDocument()
expect(within(idleRow).getByText('20m ago')).toBeInTheDocument()

View File

@ -12,6 +12,7 @@ import { useOpenTargetStore } from '../../stores/openTargetStore'
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
import { getDesktopHost } from '../../lib/desktopHost'
import { publicAssetPath } from '../../lib/publicAsset'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
const desktopHost = getDesktopHost()
const isDesktopRuntime = desktopHost.isDesktop
@ -140,7 +141,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
if (tab.type === 'session' && tab.status === 'running') ids.add(tab.sessionId)
}
for (const [sessionId, sessionState] of Object.entries(chatSessions)) {
if (sessionState.chatState !== 'idle') ids.add(sessionId)
if (sessionState.chatState !== 'idle' || hasRunningBackgroundTasks(sessionState.backgroundAgentTasks)) {
ids.add(sessionId)
}
}
return ids
}, [chatSessions, tabs])

View File

@ -959,15 +959,28 @@ describe('TabBar', () => {
expect(useTabStore.getState().tabs).toEqual([])
})
it('shows a running marker on tabs from tab status or live chat state', async () => {
it('shows a running marker on tabs from tab status, live chat state, or background tasks', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const backgroundRunningSession = makeChatSession('idle')
backgroundRunningSession.backgroundAgentTasks = {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 2,
},
}
useTabStore.setState({
tabs: [
{ sessionId: 'tab-status-running', title: 'Status Running', type: 'session', status: 'running' },
{ sessionId: 'tab-chat-running', title: 'Chat Running', type: 'session', status: 'idle' },
{ sessionId: 'tab-background-running', title: 'Background Running', type: 'session', status: 'idle' },
{ sessionId: 'tab-idle', title: 'Idle', type: 'session', status: 'idle' },
],
activeTabId: 'tab-status-running',
@ -976,6 +989,7 @@ describe('TabBar', () => {
sessions: {
'tab-status-running': makeChatSession('idle'),
'tab-chat-running': makeChatSession('thinking'),
'tab-background-running': backgroundRunningSession,
'tab-idle': makeChatSession('idle'),
},
disconnectSession: vi.fn(),
@ -985,7 +999,7 @@ describe('TabBar', () => {
render(<TabBar />)
})
expect(screen.getAllByLabelText('Session running')).toHaveLength(2)
expect(screen.getAllByLabelText('Session running')).toHaveLength(3)
expect(screen.getByText('Idle').closest('[data-dragging]')?.querySelector('[aria-label="Session running"]')).toBeNull()
})
})

View File

@ -16,6 +16,7 @@ import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
import { useTranslation } from '../../i18n'
import { getDesktopHost } from '../../lib/desktopHost'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { WindowControls, showWindowControls } from './WindowControls'
import { OpenProjectMenu } from './OpenProjectMenu'
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
@ -59,7 +60,11 @@ export function TabBar() {
[tabs],
)
const activeChatSessionIds = useChatStore(useShallow((s) =>
sessionTabIds.filter((sessionId) => s.sessions[sessionId]?.chatState !== 'idle')
sessionTabIds.filter((sessionId) => {
const sessionState = s.sessions[sessionId]
return !!sessionState &&
(sessionState.chatState !== 'idle' || hasRunningBackgroundTasks(sessionState.backgroundAgentTasks))
})
))
const disconnectSession = useChatStore((s) => s.disconnectSession)
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
@ -171,7 +176,8 @@ export function TabBar() {
.filter((tab) => isSessionTab(tab))
.filter((tab) => {
const sessionState = chatSessions[tab.sessionId]
return !!sessionState && sessionState.chatState !== 'idle'
return !!sessionState &&
(sessionState.chatState !== 'idle' || hasRunningBackgroundTasks(sessionState.backgroundAgentTasks))
})
.map((tab) => tab.sessionId)
}, [])

View File

@ -1215,14 +1215,29 @@ export const en = {
'chat.backgroundAgents.title': 'Background agents',
'chat.backgroundAgents.count': '{count} active or recent',
'chat.backgroundAgents.agent': 'agent',
'chat.backgroundTasks.title': 'Background tasks',
'chat.backgroundTasks.runningCountOne': '{count} running task',
'chat.backgroundTasks.runningCountMany': '{count} running tasks',
'chat.backgroundTasks.finishedCountOne': '{count} finished task',
'chat.backgroundTasks.finishedCountMany': '{count} finished tasks',
'chat.backgroundTasks.running': 'Running',
'chat.backgroundTasks.finished': 'Finished',
'chat.backgroundTasks.clear': 'Clear',
'chat.backgroundTasks.close': 'Close background tasks',
'chat.backgroundTasks.command': 'Background command',
'chat.backgroundTasks.workflow': 'Background workflow',
'chat.backgroundTasks.task': 'Background task',
'chat.backgroundTasks.type.agent': 'Agent',
'chat.backgroundTasks.type.bash': 'Bash',
'chat.backgroundTasks.type.workflow': 'Workflow',
'chat.backgroundTasks.type.task': 'Task',
'chat.backgroundAgents.tokens': '{count} tokens',
'chat.backgroundAgents.status.running': 'running',
'chat.backgroundAgents.status.completed': 'completed',
'chat.backgroundAgents.status.failed': 'failed',
'chat.backgroundAgents.status.stopped': 'stopped',
'chat.duration.seconds': '{seconds}s',
'chat.duration.minutesSeconds': '{minutes}m {seconds}s',
'slash.mcp.title': 'Available MCP tools',
'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.',
'slash.mcp.subtitleWithProject': 'Showing global plus project MCP for: {path}',
@ -1899,6 +1914,7 @@ export const en = {
'chat.retry.retrying': 'retrying now…',
'chat.fallback.title': 'Network hiccup — switched to non-streaming mode',
'chat.fallback.detail': 'response arrives in one piece, this can take a while',
'chat.turnCompleted': 'Completed in {duration}',
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',

View File

@ -1217,14 +1217,29 @@ export const jp: Record<TranslationKey, string> = {
'chat.backgroundAgents.title': 'バックグラウンドエージェント',
'chat.backgroundAgents.count': '{count} 件がアクティブまたは最近',
'chat.backgroundAgents.agent': 'エージェント',
'chat.backgroundTasks.title': 'バックグラウンドタスク',
'chat.backgroundTasks.runningCountOne': '{count} 件の実行中タスク',
'chat.backgroundTasks.runningCountMany': '{count} 件の実行中タスク',
'chat.backgroundTasks.finishedCountOne': '{count} 件の完了タスク',
'chat.backgroundTasks.finishedCountMany': '{count} 件の完了タスク',
'chat.backgroundTasks.running': '実行中',
'chat.backgroundTasks.finished': '完了',
'chat.backgroundTasks.clear': 'クリア',
'chat.backgroundTasks.close': 'バックグラウンドタスクを閉じる',
'chat.backgroundTasks.command': 'バックグラウンドコマンド',
'chat.backgroundTasks.workflow': 'バックグラウンドワークフロー',
'chat.backgroundTasks.task': 'バックグラウンドタスク',
'chat.backgroundTasks.type.agent': 'エージェント',
'chat.backgroundTasks.type.bash': 'Bash',
'chat.backgroundTasks.type.workflow': 'ワークフロー',
'chat.backgroundTasks.type.task': 'タスク',
'chat.backgroundAgents.tokens': '{count} トークン',
'chat.backgroundAgents.status.running': '実行中',
'chat.backgroundAgents.status.completed': '完了',
'chat.backgroundAgents.status.failed': '失敗',
'chat.backgroundAgents.status.stopped': '停止',
'chat.duration.seconds': '{seconds}秒',
'chat.duration.minutesSeconds': '{minutes}分 {seconds}秒',
'slash.mcp.title': '利用可能な MCP ツール',
'slash.mcp.subtitle': 'このチャットコンテキストで利用できる、グローバルおよび現在のプロジェクトの MCP サーバー。',
'slash.mcp.subtitleWithProject': '次のグローバルおよびプロジェクトの MCP を表示中: {path}',
@ -1901,6 +1916,7 @@ export const jp: Record<TranslationKey, string> = {
'chat.retry.retrying': '再試行しています…',
'chat.fallback.title': 'ネットワーク不調のため非ストリーミングに切替',
'chat.fallback.detail': '応答は一括で届くため時間がかかることがあります',
'chat.turnCompleted': '完了しました({duration}',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '閉じる',

View File

@ -1217,14 +1217,29 @@ export const kr: Record<TranslationKey, string> = {
'chat.backgroundAgents.title': '백그라운드 에이전트',
'chat.backgroundAgents.count': '{count}개 활성 또는 최근',
'chat.backgroundAgents.agent': '에이전트',
'chat.backgroundTasks.title': '백그라운드 작업',
'chat.backgroundTasks.runningCountOne': '실행 중인 작업 {count}개',
'chat.backgroundTasks.runningCountMany': '실행 중인 작업 {count}개',
'chat.backgroundTasks.finishedCountOne': '완료된 작업 {count}개',
'chat.backgroundTasks.finishedCountMany': '완료된 작업 {count}개',
'chat.backgroundTasks.running': '실행 중',
'chat.backgroundTasks.finished': '완료됨',
'chat.backgroundTasks.clear': '지우기',
'chat.backgroundTasks.close': '백그라운드 작업 닫기',
'chat.backgroundTasks.command': '백그라운드 명령',
'chat.backgroundTasks.workflow': '백그라운드 워크플로',
'chat.backgroundTasks.task': '백그라운드 작업',
'chat.backgroundTasks.type.agent': '에이전트',
'chat.backgroundTasks.type.bash': 'Bash',
'chat.backgroundTasks.type.workflow': '워크플로',
'chat.backgroundTasks.type.task': '작업',
'chat.backgroundAgents.tokens': '{count} 토큰',
'chat.backgroundAgents.status.running': '실행 중',
'chat.backgroundAgents.status.completed': '완료',
'chat.backgroundAgents.status.failed': '실패',
'chat.backgroundAgents.status.stopped': '중지됨',
'chat.duration.seconds': '{seconds}초',
'chat.duration.minutesSeconds': '{minutes}분 {seconds}초',
'slash.mcp.title': '사용 가능한 MCP 도구',
'slash.mcp.subtitle': '이 채팅 컨텍스트에서 사용 가능한 전역 및 현재 프로젝트 MCP 서버입니다.',
'slash.mcp.subtitleWithProject': '다음의 전역 및 프로젝트 MCP를 표시 중: {path}',
@ -1901,6 +1916,7 @@ export const kr: Record<TranslationKey, string> = {
'chat.retry.retrying': '지금 다시 시도 중…',
'chat.fallback.title': '네트워크 불안정으로 비스트리밍 모드로 전환됨',
'chat.fallback.detail': '응답이 한 번에 도착하므로 시간이 걸릴 수 있습니다',
'chat.turnCompleted': '완료됨 ({duration})',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '닫기',

View File

@ -1217,14 +1217,29 @@ export const zh: Record<TranslationKey, string> = {
'chat.backgroundAgents.title': '後臺 Agent',
'chat.backgroundAgents.count': '{count} 個執行中或最近任務',
'chat.backgroundAgents.agent': 'agent',
'chat.backgroundTasks.title': '後臺任務',
'chat.backgroundTasks.runningCountOne': '{count} 個執行中任務',
'chat.backgroundTasks.runningCountMany': '{count} 個執行中任務',
'chat.backgroundTasks.finishedCountOne': '{count} 個已完成任務',
'chat.backgroundTasks.finishedCountMany': '{count} 個已完成任務',
'chat.backgroundTasks.running': '執行中',
'chat.backgroundTasks.finished': '已完成',
'chat.backgroundTasks.clear': '清除',
'chat.backgroundTasks.close': '關閉後臺任務',
'chat.backgroundTasks.command': '後臺命令',
'chat.backgroundTasks.workflow': '後臺工作流',
'chat.backgroundTasks.task': '後臺任務',
'chat.backgroundTasks.type.agent': 'Agent',
'chat.backgroundTasks.type.bash': 'Bash',
'chat.backgroundTasks.type.workflow': '工作流',
'chat.backgroundTasks.type.task': '任務',
'chat.backgroundAgents.tokens': '{count} tokens',
'chat.backgroundAgents.status.running': '執行中',
'chat.backgroundAgents.status.completed': '已完成',
'chat.backgroundAgents.status.failed': '失敗',
'chat.backgroundAgents.status.stopped': '已停止',
'chat.duration.seconds': '{seconds} 秒',
'chat.duration.minutesSeconds': '{minutes} 分 {seconds} 秒',
'slash.mcp.title': '可用 MCP 工具',
'slash.mcp.subtitle': '展示當前聊天上下文裡的全域性 MCP 和當前專案 MCP。',
'slash.mcp.subtitleWithProject': '當前展示全域性 MCP 以及這個專案的 MCP{path}',
@ -1901,6 +1916,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.retry.retrying': '正在重試…',
'chat.fallback.title': '網路波動,已切換為非串流請求',
'chat.fallback.detail': '回應將一次性返回,可能需要較長時間',
'chat.turnCompleted': '已完成,用時 {duration}',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '關閉',

View File

@ -1217,14 +1217,29 @@ export const zh: Record<TranslationKey, string> = {
'chat.backgroundAgents.title': '后台 Agent',
'chat.backgroundAgents.count': '{count} 个运行中或最近任务',
'chat.backgroundAgents.agent': 'agent',
'chat.backgroundTasks.title': '后台任务',
'chat.backgroundTasks.runningCountOne': '{count} 个运行中任务',
'chat.backgroundTasks.runningCountMany': '{count} 个运行中任务',
'chat.backgroundTasks.finishedCountOne': '{count} 个已完成任务',
'chat.backgroundTasks.finishedCountMany': '{count} 个已完成任务',
'chat.backgroundTasks.running': '运行中',
'chat.backgroundTasks.finished': '已完成',
'chat.backgroundTasks.clear': '清除',
'chat.backgroundTasks.close': '关闭后台任务',
'chat.backgroundTasks.command': '后台命令',
'chat.backgroundTasks.workflow': '后台工作流',
'chat.backgroundTasks.task': '后台任务',
'chat.backgroundTasks.type.agent': 'Agent',
'chat.backgroundTasks.type.bash': 'Bash',
'chat.backgroundTasks.type.workflow': '工作流',
'chat.backgroundTasks.type.task': '任务',
'chat.backgroundAgents.tokens': '{count} tokens',
'chat.backgroundAgents.status.running': '运行中',
'chat.backgroundAgents.status.completed': '已完成',
'chat.backgroundAgents.status.failed': '失败',
'chat.backgroundAgents.status.stopped': '已停止',
'chat.duration.seconds': '{seconds} 秒',
'chat.duration.minutesSeconds': '{minutes} 分 {seconds} 秒',
'slash.mcp.title': '可用 MCP 工具',
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
'slash.mcp.subtitleWithProject': '当前展示全局 MCP 以及这个项目的 MCP{path}',
@ -1901,6 +1916,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.retry.retrying': '正在重试…',
'chat.fallback.title': '网络波动,已切换为非流式请求',
'chat.fallback.detail': '响应将一次性返回,可能需要较长时间',
'chat.turnCompleted': '已完成,用时 {duration}',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',

View File

@ -0,0 +1,31 @@
import type { BackgroundAgentTask } from '../types/chat'
import type { TranslationKey } from '../i18n'
type Translator = (key: TranslationKey, params?: Record<string, string | number>) => string
export function hasRunningBackgroundTasks(tasks?: Record<string, BackgroundAgentTask>): boolean {
return Object.values(tasks ?? {}).some((task) => task.status === 'running')
}
export function createBackgroundTaskDismissKey(task: BackgroundAgentTask): string {
return `${task.taskId}:${task.status}:${task.startedAt}`
}
export function formatDurationSeconds(
seconds: number,
t: Translator,
minimumSeconds = 0,
): string {
const totalSeconds = Math.max(minimumSeconds, Math.round(seconds))
if (totalSeconds < 60) {
return t('chat.duration.seconds', { seconds: totalSeconds })
}
const minutes = Math.floor(totalSeconds / 60)
const remainingSeconds = totalSeconds % 60
return t('chat.duration.minutesSeconds', { minutes, seconds: remainingSeconds })
}
export function formatDurationMs(durationMs: number | undefined, t: Translator): string | null {
if (typeof durationMs !== 'number' || durationMs < 0) return null
return formatDurationSeconds(durationMs / 1000, t)
}

View File

@ -72,6 +72,7 @@ import { ActiveSession } from './ActiveSession'
import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore } from '../stores/tabStore'
import { useTeamStore } from '../stores/teamStore'
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
@ -90,11 +91,91 @@ afterEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
useSettingsStore.setState({ locale: 'en' })
useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null })
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true)
})
function renderBackgroundTaskDrawerForLocale(locale: 'jp' | 'kr', sessionId: string) {
useSettingsStore.setState({ locale })
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Localized Background 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: 'Localized Background Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'tasks finished', timestamp: 1 }],
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
taskType: 'local_agent',
description: 'Review agent output',
startedAt: 1,
updatedAt: 2,
},
'workflow-task-1': {
taskId: 'workflow-task-1',
toolUseId: 'workflow-tool-1',
status: 'completed',
taskType: 'local_workflow',
description: 'Run workflow',
startedAt: 1,
updatedAt: 3,
},
'task-1': {
taskId: 'task-1',
toolUseId: 'task-tool-1',
status: 'completed',
taskType: 'other',
description: 'Generic task',
startedAt: 1,
updatedAt: 4,
},
},
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 />)
fireEvent.click(screen.getByTestId('background-tasks-button'))
return screen.getByTestId('background-tasks-drawer')
}
describe('ActiveSession task polling', () => {
it('treats a persisted historical session as non-empty before messages finish loading', () => {
const sessionId = 'history-loading-session'
@ -380,8 +461,9 @@ describe('ActiveSession task polling', () => {
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
it('does not render background agent progress as a page-level panel', () => {
it('renders an official-style background task entry point and drawer', () => {
const sessionId = 'background-agent-visible-session'
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [{
@ -428,6 +510,19 @@ describe('ActiveSession task polling', () => {
startedAt: 1,
updatedAt: 2,
},
'bash-task-1': {
taskId: 'bash-task-1',
toolUseId: 'bash-tool-1',
status: 'completed',
taskType: 'local_bash',
description: 'Capture final screenshots',
summary: 'Captured 36 screenshots',
usage: {
durationMs: 120000,
},
startedAt: 1,
updatedAt: 3,
},
},
chatState: 'tool_executing',
connectionState: 'connected',
@ -451,8 +546,619 @@ describe('ActiveSession task polling', () => {
render(<ActiveSession />)
expect(screen.queryByTestId('background-agent-panel')).not.toBeInTheDocument()
expect(screen.queryByText(/Completed in/i)).not.toBeInTheDocument()
const taskButton = screen.getByRole('button', { name: '1 running task' })
expect(taskButton).toBeInTheDocument()
expect(taskButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByTestId('message-list')).toBeInTheDocument()
fireEvent.click(taskButton)
expect(taskButton).toHaveAttribute('aria-expanded', 'true')
const drawer = screen.getByTestId('background-tasks-drawer')
expect(drawer).toHaveAttribute('role', 'dialog')
expect(within(drawer).getByRole('heading', { name: 'Background tasks' })).toBeInTheDocument()
expect(within(drawer).getByText('Running')).toBeInTheDocument()
expect(within(drawer).getByText('Verify the todo app')).toBeInTheDocument()
expect(within(drawer).getByText('Agent')).toBeInTheDocument()
expect(within(drawer).getByText('Finished')).toBeInTheDocument()
expect(within(drawer).getByText('Capture final screenshots')).toBeInTheDocument()
expect(within(drawer).getByText('Bash')).toBeInTheDocument()
})
it('localizes the background task entry point', () => {
const sessionId = 'background-agent-zh-session'
useSettingsStore.setState({ locale: 'zh' })
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: [{ id: 'msg-1', type: 'assistant_text', content: 'task started', timestamp: 1 }],
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Verify the todo app',
startedAt: 1,
updatedAt: 2,
},
'agent-task-2': {
taskId: 'agent-task-2',
toolUseId: 'agent-tool-2',
status: 'running',
taskType: 'remote_agent',
description: 'Review screenshots',
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.getByRole('button', { name: '2 个运行中任务' })).toBeInTheDocument()
})
it('localizes background task duration units', () => {
const sessionId = 'background-agent-duration-zh-session'
useSettingsStore.setState({ locale: 'zh' })
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Background Agent Duration 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 Duration Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'task finished', timestamp: 1 }],
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
taskType: 'local_agent',
description: 'Review screenshots',
usage: {
totalTokens: 94300,
toolUses: 76,
durationMs: 671000,
},
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 />)
fireEvent.click(screen.getByRole('button', { name: '1 个已完成任务' }))
expect(within(screen.getByTestId('background-tasks-drawer')).getByText('11 分 11 秒')).toBeInTheDocument()
})
it('renders Japanese background task type labels in the drawer', () => {
const drawer = renderBackgroundTaskDrawerForLocale('jp', 'background-agent-jp-label-session')
expect(within(drawer).getByText('エージェント')).toBeInTheDocument()
expect(within(drawer).getByText('ワークフロー')).toBeInTheDocument()
expect(within(drawer).getByText('タスク')).toBeInTheDocument()
})
it('renders Korean background task type labels in the drawer', () => {
const drawer = renderBackgroundTaskDrawerForLocale('kr', 'background-agent-kr-label-session')
expect(within(drawer).getByText('에이전트')).toBeInTheDocument()
expect(within(drawer).getByText('워크플로')).toBeInTheDocument()
expect(within(drawer).getByText('작업')).toBeInTheDocument()
})
it('keeps finished background tasks reachable until cleared', () => {
const sessionId = 'background-agent-finished-session'
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Finished Background 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: 'Finished Background Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'task started', timestamp: 1 }],
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
taskType: 'local_agent',
description: 'Review screenshots',
usage: {
totalTokens: 94300,
toolUses: 76,
durationMs: 671000,
},
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 />)
const taskButton = screen.getByRole('button', { name: '1 finished task' })
fireEvent.click(taskButton)
const drawer = screen.getByTestId('background-tasks-drawer')
expect(within(drawer).getByText('Finished')).toBeInTheDocument()
expect(within(drawer).getByText('1')).toBeInTheDocument()
expect(within(drawer).getByText('Review screenshots')).toBeInTheDocument()
fireEvent.click(within(drawer).getByRole('button', { name: 'Clear' }))
expect(screen.queryByTestId('background-tasks-drawer')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument()
})
it('does not carry an open background task drawer across sessions', () => {
const firstSessionId = 'background-agent-first-session'
const secondSessionId = 'background-agent-second-session'
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [
{
id: firstSessionId,
title: 'First Background 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,
},
{
id: secondSessionId,
title: 'Second Background 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: firstSessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [
{ sessionId: firstSessionId, title: 'First Background Session', type: 'session', status: 'running' },
{ sessionId: secondSessionId, title: 'Second Background Session', type: 'session', status: 'running' },
],
activeTabId: firstSessionId,
})
useChatStore.setState({
sessions: {
[firstSessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'first', timestamp: 1 }],
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
status: 'running',
taskType: 'local_agent',
description: 'First session task',
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,
},
[secondSessionId]: {
messages: [{ id: 'msg-2', type: 'assistant_text', content: 'second', timestamp: 1 }],
backgroundAgentTasks: {
'agent-task-2': {
taskId: 'agent-task-2',
status: 'running',
taskType: 'local_agent',
description: 'Second session task',
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 />)
fireEvent.click(screen.getByRole('button', { name: '1 running task' }))
expect(screen.getByTestId('background-tasks-drawer')).toBeInTheDocument()
act(() => {
useTabStore.setState({ activeTabId: secondSessionId })
})
expect(screen.queryByTestId('background-tasks-drawer')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '1 running task' })).toBeInTheDocument()
})
it('keeps cleared finished background tasks dismissed when returning to the same session', () => {
const firstSessionId = 'background-finished-first-session'
const secondSessionId = 'background-finished-second-session'
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [
{
id: firstSessionId,
title: 'Finished First 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,
},
{
id: secondSessionId,
title: 'Second 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: firstSessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [
{ sessionId: firstSessionId, title: 'Finished First Session', type: 'session', status: 'idle' },
{ sessionId: secondSessionId, title: 'Second Session', type: 'session', status: 'idle' },
],
activeTabId: firstSessionId,
})
useChatStore.setState({
sessions: {
[firstSessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'first', timestamp: 1 }],
backgroundAgentTasks: {
'finished-agent-task': {
taskId: 'finished-agent-task',
status: 'completed',
taskType: 'local_agent',
description: 'Finished review',
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,
},
[secondSessionId]: {
messages: [{ id: 'msg-2', type: 'assistant_text', content: 'second', timestamp: 1 }],
backgroundAgentTasks: {},
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 />)
fireEvent.click(screen.getByRole('button', { name: '1 finished task' }))
fireEvent.click(within(screen.getByTestId('background-tasks-drawer')).getByRole('button', { name: 'Clear' }))
act(() => {
useTabStore.setState({ activeTabId: secondSessionId })
})
act(() => {
useTabStore.setState({ activeTabId: firstSessionId })
})
expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument()
})
it('shows a resumed background task after clearing an earlier finish for the same task id', () => {
const sessionId = 'background-finished-resume-session'
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Finished Resume 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: 'Finished Resume Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'first', timestamp: 1 }],
backgroundAgentTasks: {
'reused-agent-task': {
taskId: 'reused-agent-task',
status: 'completed',
taskType: 'local_agent',
description: 'First finished review',
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 />)
fireEvent.click(screen.getByRole('button', { name: '1 finished task' }))
fireEvent.click(within(screen.getByTestId('background-tasks-drawer')).getByRole('button', { name: 'Clear' }))
expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[sessionId]: {
...state.sessions[sessionId]!,
backgroundAgentTasks: {
'reused-agent-task': {
taskId: 'reused-agent-task',
status: 'completed',
taskType: 'local_agent',
description: 'Duplicate finished review',
startedAt: 1,
updatedAt: 3,
},
},
},
},
}))
})
expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[sessionId]: {
...state.sessions[sessionId]!,
backgroundAgentTasks: {
'reused-agent-task': {
taskId: 'reused-agent-task',
status: 'running',
taskType: 'local_agent',
description: 'Resumed review',
startedAt: 4,
updatedAt: 4,
},
},
},
},
}))
})
expect(screen.getByRole('button', { name: '1 running task' })).toBeInTheDocument()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[sessionId]: {
...state.sessions[sessionId]!,
backgroundAgentTasks: {
'reused-agent-task': {
taskId: 'reused-agent-task',
status: 'completed',
taskType: 'local_agent',
description: 'Second finished review',
startedAt: 4,
updatedAt: 5,
},
},
},
},
}))
})
expect(screen.getByRole('button', { name: '1 finished task' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '1 finished task' }))
expect(within(screen.getByTestId('background-tasks-drawer')).getByText('Second finished review')).toBeInTheDocument()
})
it('keeps the session header active while a background task is still running after the turn completes', () => {

View File

@ -25,6 +25,7 @@ import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
import { BackgroundTasksBar } from '../components/chat/BackgroundTasksBar'
import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { TerminalSettings } from './TerminalSettings'
@ -34,12 +35,17 @@ import { useMobileViewport } from '../hooks/useMobileViewport'
import { isDesktopRuntime } from '../lib/desktopRuntime'
import { formatTokenCount } from '../lib/formatTokenCount'
import { publicAssetPath } from '../lib/publicAsset'
import {
createBackgroundTaskDismissKey,
hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks,
} from '../lib/backgroundTasks'
const TASK_POLL_INTERVAL_MS = 1000
const WORKSPACE_RESIZE_STEP = 32
const TERMINAL_RESIZE_STEP = 24
const CHAT_COLUMN_WITH_WORKSPACE_CLASS =
'min-w-[320px] flex-1 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
const EMPTY_DISMISSED_BACKGROUND_TASK_KEYS = new Set<string>()
function isSessionTabState(activeTabId: string | null, activeTabType: TabType | null | undefined) {
if (!activeTabId) return false
@ -273,6 +279,7 @@ function TerminalResizeHandle() {
export function ActiveSession() {
const isMobileLayout = useMobileViewport() && !isDesktopRuntime()
const activeTabId = useTabStore((s) => s.activeTabId)
const [dismissedBackgroundTaskKeysBySession, setDismissedBackgroundTaskKeysBySession] = useState<Record<string, Set<string>>>({})
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
@ -284,8 +291,7 @@ export function ActiveSession() {
const hasRunningTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status === 'in_progress'))
const chatState = sessionState?.chatState ?? 'idle'
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const hasRunningBackgroundTasks = Object.values(sessionState?.backgroundAgentTasks ?? {})
.some((task) => task.status === 'running')
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
const session = sessions.find((s) => s.id === activeTabId)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
@ -344,6 +350,13 @@ export function ActiveSession() {
const t = useTranslation()
const messages = sessionState?.messages ?? []
const streamingText = sessionState?.streamingText ?? ''
const backgroundTasks = useMemo(
() => Object.values(sessionState?.backgroundAgentTasks ?? {}),
[sessionState?.backgroundAgentTasks],
)
const dismissedBackgroundTaskKeys = activeTabId
? dismissedBackgroundTaskKeysBySession[activeTabId] ?? EMPTY_DISMISSED_BACKGROUND_TASK_KEYS
: EMPTY_DISMISSED_BACKGROUND_TASK_KEYS
const activeGoal = sessionState?.activeGoal ?? null
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
const compactEmptyHero = isEmpty && showTerminalPanel
@ -375,6 +388,23 @@ export function ActiveSession() {
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
}, [session?.modifiedAt, t])
useEffect(() => {
if (!activeTabId || dismissedBackgroundTaskKeys.size === 0) return
const currentTaskKeys = new Set(backgroundTasks.map(createBackgroundTaskDismissKey))
const nextDismissed = new Set([...dismissedBackgroundTaskKeys].filter((taskKey) => currentTaskKeys.has(taskKey)))
if (nextDismissed.size === dismissedBackgroundTaskKeys.size) return
setDismissedBackgroundTaskKeysBySession((current) => {
const next = { ...current }
if (nextDismissed.size === 0) {
delete next[activeTabId]
} else {
next[activeTabId] = nextDismissed
}
return next
})
}, [activeTabId, backgroundTasks, dismissedBackgroundTaskKeys])
if (!activeTabId) return null
return (
@ -556,6 +586,25 @@ export function ActiveSession() {
<TeamStatusBar />
{!isMemberSession && (
<BackgroundTasksBar
key={activeTabId}
tasks={backgroundTasks}
compact={showRightPanel}
dismissedFinishedTaskKeys={dismissedBackgroundTaskKeys}
onClearFinished={(taskKeys) => {
if (!activeTabId || taskKeys.length === 0) return
setDismissedBackgroundTaskKeysBySession((current) => ({
...current,
[activeTabId]: new Set([
...(current[activeTabId] ?? EMPTY_DISMISSED_BACKGROUND_TASK_KEYS),
...taskKeys,
]),
}))
}}
/>
)}
<ChatInput
variant={isEmpty && !isMemberSession && !showRightPanel ? 'hero' : 'default'}
compact={showRightPanel}

View File

@ -126,6 +126,7 @@ vi.mock('./cliTaskStore', () => ({
}))
import { sessionsApi } from '../api/sessions'
import { useSettingsStore } from './settingsStore'
import {
mapHistoryMessagesToUiMessages,
reconstructAgentNotifications,
@ -192,6 +193,7 @@ describe('chatStore tool settlement', () => {
updateTabStatusMock.mockReset()
notifyDesktopMock.mockReset()
localStorage.clear()
useSettingsStore.setState({ locale: 'en' })
useChatStore.setState({
...initialState,
sessions: {
@ -281,6 +283,7 @@ describe('chatStore history mapping', () => {
cliTaskStoreSnapshot.sessionId = null
useSessionRuntimeStore.setState({ selections: {} })
localStorage.clear()
useSettingsStore.setState({ locale: 'en' })
useChatStore.setState({
...initialState,
sessions: {},
@ -2590,9 +2593,107 @@ describe('chatStore history mapping', () => {
outputFile: '/tmp/agent-output.txt',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
vi.setSystemTime(new Date('2026-04-06T00:00:05.000Z'))
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_progress',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'running',
summary: 'Resumed review',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
status: 'running',
startedAt: new Date('2026-04-06T00:00:05.000Z').getTime(),
summary: 'Resumed review',
})
vi.setSystemTime(new Date('2026-04-06T00:00:06.000Z'))
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Second lifecycle complete.',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
status: 'completed',
startedAt: new Date('2026-04-06T00:00:05.000Z').getTime(),
summary: 'Second lifecycle complete.',
})
vi.setSystemTime(new Date('2026-04-06T00:00:07.000Z'))
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Third lifecycle complete without progress.',
result: 'The resumed agent finished without using another tool.',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
status: 'completed',
startedAt: new Date('2026-04-06T00:00:07.000Z').getTime(),
summary: 'Third lifecycle complete without progress.',
result: 'The resumed agent finished without using another tool.',
})
vi.useRealTimers()
})
it('keeps idle chat state while marking the tab running for background task start and progress', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'idle',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_started',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
description: 'Verify the todo app',
task_type: 'local_agent',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('idle')
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
updateTabStatusMock.mockClear()
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_progress',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
summary: 'Still reviewing',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('idle')
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
})
it('keeps non-agent background tasks visible and updates the existing transcript card', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z'))
@ -3218,6 +3319,175 @@ describe('chatStore history mapping', () => {
])
})
it('localizes completed turn duration units', () => {
useSettingsStore.setState({ locale: 'zh' })
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
elapsedSeconds: 65,
streamingText: 'Finished answer',
elapsedTimer: null,
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 12, output_tokens: 34 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content: 'Finished answer',
},
{
type: 'system',
content: '已完成,用时 1 分 5 秒',
},
])
})
it('keeps background agent sessions visibly running when the foreground turn completes', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
elapsedSeconds: 65,
streamingText: 'Finished answer',
elapsedTimer: null,
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 2,
},
},
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 12, output_tokens: 34 },
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.messages).toMatchObject([
{
type: 'assistant_text',
content: 'Finished answer',
},
])
expect(session?.messages).not.toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'system', content: expect.stringContaining('Completed') }),
]))
expect(session?.chatState).toBe('idle')
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
})
it('marks the tab idle and appends the delayed completion when the last background agent task finishes after the foreground turn', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
elapsedSeconds: 65,
streamingText: 'Finished answer',
elapsedTimer: null,
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 2,
},
},
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 12, output_tokens: 34 },
})
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Review complete.',
},
})
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'system', content: 'Completed in 1m 5s' }),
]))
})
it('flushes a delayed completion before a new user turn while background tasks keep running', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
elapsedSeconds: 65,
streamingText: 'Finished answer',
elapsedTimer: null,
backgroundAgentTasks: {
'agent-task-1': {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Review screenshots',
startedAt: 1,
updatedAt: 2,
},
},
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 12, output_tokens: 34 },
})
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'Continue with next step')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{ type: 'assistant_text', content: 'Finished answer' },
{ type: 'system', content: 'Completed in 1m 5s' },
{ type: 'user_text', content: 'Continue with next step' },
])
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Review complete.',
},
})
const completedRows = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages
.filter((message) => message.type === 'system' && message.content === 'Completed in 1m 5s')
expect(completedRows).toHaveLength(1)
})
it('tracks API retry status until the request finishes', () => {
useChatStore.setState({
sessions: {

View File

@ -9,7 +9,9 @@ import { useTabStore } from './tabStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import { notifyDesktop } from '../lib/desktopNotifications'
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
import { formatDurationSeconds, hasRunningBackgroundTasks } from '../lib/backgroundTasks'
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import { t } from '../i18n'
import type { ComposerAttachment } from '../lib/composerAttachments'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
@ -110,6 +112,7 @@ export type PerSessionState = {
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
agentTaskNotifications: Record<string, AgentTaskNotification>
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
pendingCompletedTurnElapsedSeconds?: number | null
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | null
composerPrefill?: {
@ -146,6 +149,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
slashCommands: [],
agentTaskNotifications: {},
backgroundAgentTasks: {},
pendingCompletedTurnElapsedSeconds: null,
activeGoal: null,
elapsedTimer: null,
composerPrefill: null,
@ -495,26 +499,19 @@ function appendAssistantTextMessage(
]
}
function formatTurnDuration(seconds: number): string {
const totalSeconds = Math.max(1, Math.round(seconds))
if (totalSeconds < 60) return `${totalSeconds}s`
const minutes = Math.floor(totalSeconds / 60)
const remainingSeconds = totalSeconds % 60
return `${minutes}m ${remainingSeconds}s`
}
function appendCompletedTurnDurationMessage(
messages: UIMessage[],
elapsedSeconds: number,
timestamp: number,
): UIMessage[] {
if (!Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) return messages
const duration = formatDurationSeconds(elapsedSeconds, t, 1)
return [
...messages,
{
id: nextId(),
type: 'system',
content: `Completed in ${formatTurnDuration(elapsedSeconds)}`,
content: t('chat.turnCompleted', { duration }),
timestamp,
},
]
@ -632,6 +629,34 @@ function upsertBackgroundTaskMessage(
: message)
}
function buildBackgroundTaskSessionUpdate(
session: PerSessionState,
backgroundAgentTasks: Record<string, BackgroundAgentTask>,
task: BackgroundAgentTask | undefined,
timestamp: number,
): Partial<PerSessionState> {
let messages = task
? upsertBackgroundTaskMessage(session.messages, task, timestamp)
: session.messages
const shouldAppendDelayedCompletion =
session.pendingCompletedTurnElapsedSeconds != null &&
!hasRunningBackgroundTasks(backgroundAgentTasks)
if (shouldAppendDelayedCompletion) {
messages = appendCompletedTurnDurationMessage(
messages,
session.pendingCompletedTurnElapsedSeconds ?? 0,
timestamp,
)
}
return {
backgroundAgentTasks,
...(messages !== session.messages ? { messages } : {}),
...(shouldAppendDelayedCompletion ? { pendingCompletedTurnElapsedSeconds: null } : {}),
}
}
function mergeBackgroundTaskMessages(
messages: UIMessage[],
tasks: Record<string, BackgroundAgentTask>,
@ -1060,16 +1085,24 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const session = s.sessions[sessionId] ?? createDefaultSessionState()
const bufferedDelta = consumePendingDelta(sessionId)
const pendingAssistantText = `${session.streamingText}${bufferedDelta}`
const now = Date.now()
const newMessages = pendingAssistantText.trim()
? appendAssistantTextMessage(session.messages, pendingAssistantText, Date.now())
let newMessages = pendingAssistantText.trim()
? appendAssistantTextMessage(session.messages, pendingAssistantText, now)
: [...session.messages]
if (session.pendingCompletedTurnElapsedSeconds != null) {
newMessages = appendCompletedTurnDurationMessage(
newMessages,
session.pendingCompletedTurnElapsedSeconds,
now,
)
}
if (!isMemberSession && allTasksDone) {
newMessages.push({
id: nextId(),
type: 'task_summary',
tasks: completedTaskSummary,
timestamp: Date.now(),
timestamp: now,
})
}
newMessages.push({
@ -1078,7 +1111,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
content: userFacingContent,
...(userFacingContent !== modelFacingContent ? { modelContent: modelFacingContent } : {}),
attachments: isMemberSession ? undefined : uiAttachments,
timestamp: Date.now(),
timestamp: now,
...(isMemberSession ? { pending: true } : {}),
})
@ -1098,6 +1131,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
messages: newMessages,
chatState: 'thinking',
elapsedSeconds: 0,
pendingCompletedTurnElapsedSeconds: null,
streamingText: '',
streamingResponseChars: 0,
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
@ -1181,9 +1215,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
clearPendingToolInputDelta(sessionId)
clearPendingTaskToolUseIds(sessionId)
clearPendingToolParentUseIds(sessionId)
let hasRunningBackgroundAgents = false
set((s) => {
const session = s.sessions[sessionId]
if (!session) return s
hasRunningBackgroundAgents = hasRunningBackgroundTasks(session.backgroundAgentTasks)
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
const pendingAssistantText = `${session.streamingText}${bufferedText}`
const messagesWithFlushedText = pendingAssistantText.trim()
@ -1206,12 +1242,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
pendingComputerUsePermission: null,
apiRetry: null,
streamingFallback: null,
pendingCompletedTurnElapsedSeconds: hasRunningBackgroundAgents
? session.pendingCompletedTurnElapsedSeconds ?? null
: null,
elapsedTimer: null,
},
},
}
})
useTabStore.getState().updateTabStatus(sessionId, 'idle')
useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle')
},
loadHistory: async (sessionId) => {
@ -1342,6 +1381,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
statusVerb: '',
apiRetry: null,
streamingFallback: null,
pendingCompletedTurnElapsedSeconds: null,
})),
}
})
@ -1526,6 +1566,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
chatState: 'idle',
apiRetry: null,
streamingFallback: null,
pendingCompletedTurnElapsedSeconds: null,
queuedUserMessages: [],
})) }))
},
@ -1604,7 +1645,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
clearElapsedTimer()
}
// Sync tab status
useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
useTabStore.getState().updateTabStatus(
sessionId,
msg.state === 'idle' && !hasRunningBackgroundTasks(get().sessions[sessionId]?.backgroundAgentTasks)
? 'idle'
: 'running',
)
break
case 'permission_mode_changed': {
@ -1951,7 +1997,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
const appendedCompletionMessage = completionMessages !== session.messages
const stoppedMessages = markPendingToolUseMessagesStopped(completionMessages)
const finalMessages = wasAgentRunning && !hasQueuedUserMessages
const hasRunningBackgroundAgents = hasRunningBackgroundTasks(session.backgroundAgentTasks)
const finalMessages = wasAgentRunning && !hasQueuedUserMessages && !hasRunningBackgroundAgents
? appendCompletedTurnDurationMessage(stoppedMessages, session.elapsedSeconds, completedAt)
: stoppedMessages
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
@ -1965,8 +2012,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
elapsedTimer: null,
apiRetry: null,
streamingFallback: null,
pendingCompletedTurnElapsedSeconds: wasAgentRunning && !hasQueuedUserMessages && hasRunningBackgroundAgents
? session.elapsedSeconds
: null,
}))
useTabStore.getState().updateTabStatus(sessionId, 'idle')
useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle')
const notification = wasAgentRunning && appendedCompletionMessage
? buildAgentCompletionNotification(sessionId, finalMessages, text)
: null
@ -2099,6 +2149,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
slashCommands: [],
activeGoal: null,
backgroundAgentTasks: {},
pendingCompletedTurnElapsedSeconds: null,
agentTaskNotifications: {},
}))
clearPendingDelta(sessionId)
@ -2185,18 +2236,25 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const taskEvent = normalizeBackgroundAgentTaskEvent(msg.data, msg.subtype)
if (taskEvent) {
const now = Date.now()
let shouldUpdateIdleTabStatus = false
let hasRunningBackgroundAgentsAfterUpdate = false
update((session) => {
const backgroundAgentTasks = upsertBackgroundAgentTask(
session.backgroundAgentTasks ?? {},
taskEvent,
now,
)
shouldUpdateIdleTabStatus = session.chatState === 'idle'
hasRunningBackgroundAgentsAfterUpdate = hasRunningBackgroundTasks(backgroundAgentTasks)
const task = backgroundAgentTasks[taskEvent.taskId]
return {
backgroundAgentTasks,
...(task ? { messages: upsertBackgroundTaskMessage(session.messages, task, now) } : {}),
}
return buildBackgroundTaskSessionUpdate(session, backgroundAgentTasks, task, now)
})
if (shouldUpdateIdleTabStatus) {
useTabStore.getState().updateTabStatus(
sessionId,
hasRunningBackgroundAgentsAfterUpdate ? 'running' : 'idle',
)
}
}
}
if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') {
@ -2210,16 +2268,19 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const taskStatus = data.status
if (taskEvent) {
const now = Date.now()
let shouldUpdateIdleTabStatus = false
let hasRunningBackgroundAgentsAfterUpdate = false
update((session) => {
const backgroundAgentTasks = upsertBackgroundAgentTask(
session.backgroundAgentTasks ?? {},
taskEvent,
now,
)
shouldUpdateIdleTabStatus = session.chatState === 'idle'
hasRunningBackgroundAgentsAfterUpdate = hasRunningBackgroundTasks(backgroundAgentTasks)
const task = backgroundAgentTasks[taskEvent.taskId]
return {
backgroundAgentTasks,
...(task ? { messages: upsertBackgroundTaskMessage(session.messages, task, now) } : {}),
...buildBackgroundTaskSessionUpdate(session, backgroundAgentTasks, task, now),
agentTaskNotifications: {
...session.agentTaskNotifications,
...(toolUseId &&
@ -2241,6 +2302,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
}
})
if (shouldUpdateIdleTabStatus) {
useTabStore.getState().updateTabStatus(
sessionId,
hasRunningBackgroundAgentsAfterUpdate ? 'running' : 'idle',
)
}
}
}
break
@ -2570,6 +2637,7 @@ function normalizeBackgroundAgentTaskEvent(
taskType: readNonEmptyString(record, 'task_type', 'taskType'),
workflowName: readNonEmptyString(record, 'workflow_name', 'workflowName'),
prompt: readNonEmptyString(record, 'prompt'),
result: readNonEmptyString(record, 'result'),
summary: readNonEmptyString(record, 'summary'),
lastToolName: readNonEmptyString(record, 'last_tool_name', 'lastToolName'),
outputFile: readNonEmptyString(record, 'output_file', 'outputFile'),
@ -2593,6 +2661,10 @@ function upsertBackgroundAgentTask(
if (existingKey && existingKey !== event.taskId) {
delete next[existingKey]
}
const startsNewLifecycle = Boolean(existing && (
(existing.status !== 'running' && event.status === 'running') ||
(existing.status !== 'running' && event.status !== 'running' && hasTerminalTaskPayloadChanged(existing, event))
))
return {
...next,
[event.taskId]: {
@ -2603,16 +2675,36 @@ function upsertBackgroundAgentTask(
taskType: event.taskType ?? existing?.taskType,
workflowName: event.workflowName ?? existing?.workflowName,
prompt: event.prompt ?? existing?.prompt,
result: event.result ?? existing?.result,
summary: event.summary ?? existing?.summary,
lastToolName: event.lastToolName ?? existing?.lastToolName,
outputFile: event.outputFile ?? existing?.outputFile,
usage: event.usage ?? existing?.usage,
startedAt: existing?.startedAt ?? now,
startedAt: startsNewLifecycle ? now : existing?.startedAt ?? now,
updatedAt: now,
},
}
}
function hasTerminalTaskPayloadChanged(
existing: BackgroundAgentTask,
event: Partial<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>,
): boolean {
return event.summary != null && event.summary !== existing.summary ||
event.result != null && event.result !== existing.result ||
event.outputFile != null && event.outputFile !== existing.outputFile ||
event.usage != null && !areBackgroundTaskUsageEqual(event.usage, existing.usage)
}
function areBackgroundTaskUsageEqual(
a: BackgroundAgentTaskUsage | undefined,
b: BackgroundAgentTaskUsage | undefined,
): boolean {
return a?.totalTokens === b?.totalTokens &&
a?.toolUses === b?.toolUses &&
a?.durationMs === b?.durationMs
}
function normalizeGoalEventData(
data: unknown,
fallbackMessage?: string,

View File

@ -228,6 +228,7 @@ export type BackgroundAgentTask = {
taskType?: string
workflowName?: string
prompt?: string
result?: string
summary?: string
lastToolName?: string
outputFile?: string