mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
84c5685b1a
commit
8d12fdbcff
265
desktop/src/components/chat/BackgroundTasksBar.tsx
Normal file
265
desktop/src/components/chat/BackgroundTasksBar.tsx
Normal 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`
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}, [])
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '閉じる',
|
||||
|
||||
@ -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': '닫기',
|
||||
|
||||
@ -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': '關閉',
|
||||
|
||||
@ -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': '关闭',
|
||||
|
||||
31
desktop/src/lib/backgroundTasks.ts
Normal file
31
desktop/src/lib/backgroundTasks.ts
Normal 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)
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -228,6 +228,7 @@ export type BackgroundAgentTask = {
|
||||
taskType?: string
|
||||
workflowName?: string
|
||||
prompt?: string
|
||||
result?: string
|
||||
summary?: string
|
||||
lastToolName?: string
|
||||
outputFile?: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user