mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Desktop sessions can receive task tools, background-agent lifecycle events, and child tool stream events in the same turn. The UI now keeps root Agent cards separate from task-management tools, preserves parent links when later stream events omit them, and surfaces completed background-agent result text from task notifications. Constraint: Existing desktop session history can contain task-notification XML and live WS events with incomplete parent metadata. Rejected: Render local_agent lifecycle messages as standalone cards | duplicates the Agent tool card and makes ownership unclear. Confidence: high Scope-risk: moderate Directive: Do not collapse Agent and TaskCreate/TaskUpdate root tools into one generic group without checking multi-agent screenshots. Tested: SKIP_INSTALL=1 PRESERVE_TAURI_TARGET=1 ./desktop/scripts/build-macos-arm64.sh Tested: Real gpt-5.5 desktop E2E sessions under /tmp/cc-haha-subagent-e2e-20260519-025454 for 3 subagents, child tools, result dialog, and background Bash completion. Not-tested: Full bun run verify gate.
953 lines
34 KiB
TypeScript
953 lines
34 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react'
|
|
import { ToolCallBlock } from './ToolCallBlock'
|
|
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
|
import { Modal } from '../shared/Modal'
|
|
import { useTranslation } from '../../i18n'
|
|
import type { TranslationKey } from '../../i18n'
|
|
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
|
import { useUIStore } from '../../stores/uiStore'
|
|
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
|
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
|
|
|
|
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
|
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
|
type MemoryToolAction = 'saved' | 'referenced'
|
|
|
|
type MemoryToolFile = {
|
|
path: string
|
|
label: string
|
|
action: MemoryToolAction
|
|
lineHint?: string
|
|
preview?: string
|
|
}
|
|
|
|
type MemoryToolActivity = {
|
|
action: MemoryToolAction
|
|
files: MemoryToolFile[]
|
|
}
|
|
|
|
type Props = {
|
|
toolCalls: ToolCall[]
|
|
resultMap: Map<string, ToolResult>
|
|
childToolCallsByParent: Map<string, ToolCall[]>
|
|
agentTaskNotifications: Record<string, AgentTaskNotification>
|
|
/** When true, the last tool is still executing — show expanded */
|
|
isStreaming?: boolean
|
|
}
|
|
|
|
const TOOL_VERBS: Record<string, (count: number, t: (key: TranslationKey, params?: Record<string, string | number>) => string) => string> = {
|
|
Read: (n, t) => n === 1 ? t('toolGroup.readOne') : t('toolGroup.readMany', { count: n }),
|
|
Write: (n, t) => n === 1 ? t('toolGroup.createdOne') : t('toolGroup.createdMany', { count: n }),
|
|
Edit: (n, t) => n === 1 ? t('toolGroup.editedOne') : t('toolGroup.editedMany', { count: n }),
|
|
Bash: (n, t) => n === 1 ? t('toolGroup.ranOne') : t('toolGroup.ranMany', { count: n }),
|
|
Glob: (_n, t) => t('toolGroup.foundFiles'),
|
|
Grep: (n, t) => n === 1 ? t('toolGroup.searchedOne') : t('toolGroup.searchedMany', { count: n }),
|
|
Agent: (n, t) => n === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: n }),
|
|
WebSearch: (_n, t) => t('toolGroup.searchedWeb'),
|
|
WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }),
|
|
}
|
|
|
|
function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
|
const counts = new Map<string, number>()
|
|
for (const tc of toolCalls) {
|
|
counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1)
|
|
}
|
|
|
|
const parts: string[] = []
|
|
for (const [name, count] of counts) {
|
|
const verbFn = TOOL_VERBS[name]
|
|
parts.push(verbFn ? verbFn(count, t) : `${name} (${count})`)
|
|
}
|
|
|
|
return parts.join(', ')
|
|
}
|
|
|
|
function toolCallHasError(
|
|
toolCall: ToolCall,
|
|
resultMap: Map<string, ToolResult>,
|
|
childToolCallsByParent: Map<string, ToolCall[]>,
|
|
): boolean {
|
|
const result = resultMap.get(toolCall.toolUseId)
|
|
if (result?.isError) return true
|
|
|
|
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).some((childToolCall) =>
|
|
toolCallHasError(childToolCall, resultMap, childToolCallsByParent),
|
|
)
|
|
}
|
|
|
|
function groupHasErrors(
|
|
toolCalls: ToolCall[],
|
|
resultMap: Map<string, ToolResult>,
|
|
childToolCallsByParent: Map<string, ToolCall[]>,
|
|
): boolean {
|
|
return toolCalls.some((tc) => {
|
|
return toolCallHasError(tc, resultMap, childToolCallsByParent)
|
|
})
|
|
}
|
|
|
|
function isToolCallResolved(
|
|
toolCall: ToolCall,
|
|
resultMap: Map<string, ToolResult>,
|
|
childToolCallsByParent: Map<string, ToolCall[]>,
|
|
): boolean {
|
|
if (!resultMap.has(toolCall.toolUseId)) return false
|
|
|
|
return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).every((childToolCall) =>
|
|
isToolCallResolved(childToolCall, resultMap, childToolCallsByParent),
|
|
)
|
|
}
|
|
|
|
function hasUnresolvedToolCalls(
|
|
toolCalls: ToolCall[],
|
|
resultMap: Map<string, ToolResult>,
|
|
childToolCallsByParent: Map<string, ToolCall[]>,
|
|
): boolean {
|
|
return toolCalls.some((toolCall) =>
|
|
!isToolCallResolved(toolCall, resultMap, childToolCallsByParent),
|
|
)
|
|
}
|
|
|
|
export function ToolCallGroup({
|
|
toolCalls,
|
|
resultMap,
|
|
childToolCallsByParent,
|
|
agentTaskNotifications,
|
|
isStreaming,
|
|
}: Props) {
|
|
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
|
|
if (memoryActivity) {
|
|
const memoryToolCalls = toolCalls.filter(isMemoryToolCall)
|
|
const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall))
|
|
if (regularToolCalls.length > 0) {
|
|
return (
|
|
<div className="mb-2 space-y-2">
|
|
<MemoryToolActivityGroup
|
|
activity={memoryActivity}
|
|
toolCalls={memoryToolCalls}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
<ToolCallGroupContent
|
|
toolCalls={regularToolCalls}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<MemoryToolActivityGroup
|
|
activity={memoryActivity}
|
|
toolCalls={memoryToolCalls}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ToolCallGroupContent
|
|
toolCalls={toolCalls}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ToolCallGroupContent({
|
|
toolCalls,
|
|
resultMap,
|
|
childToolCallsByParent,
|
|
agentTaskNotifications,
|
|
isStreaming,
|
|
}: Props) {
|
|
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
|
|
|
|
if (allAgents) {
|
|
return (
|
|
<AgentToolGroup
|
|
toolCalls={toolCalls}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Single tool call — render directly without group wrapper
|
|
if (toolCalls.length === 1) {
|
|
const tc = toolCalls[0]!
|
|
return (
|
|
<ToolCallTree
|
|
toolCall={tc}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ToolCallGroupMulti
|
|
toolCalls={toolCalls}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function MemoryToolActivityGroup({
|
|
activity,
|
|
toolCalls,
|
|
resultMap,
|
|
childToolCallsByParent,
|
|
isStreaming,
|
|
}: {
|
|
activity: MemoryToolActivity
|
|
toolCalls: ToolCall[]
|
|
resultMap: Map<string, ToolResult>
|
|
childToolCallsByParent: Map<string, ToolCall[]>
|
|
isStreaming?: boolean
|
|
}) {
|
|
const [expanded, setExpanded] = useState(activity.action === 'saved')
|
|
const [detailsExpanded, setDetailsExpanded] = useState(false)
|
|
const t = useTranslation()
|
|
const titleKey = activity.action === 'saved'
|
|
? 'chat.memorySavedFromToolsTitle'
|
|
: 'chat.memoryReferencedTitle'
|
|
const visibleFiles = activity.files.slice(0, 4)
|
|
const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length)
|
|
|
|
useEffect(() => {
|
|
if (isStreaming) setExpanded(true)
|
|
}, [isStreaming])
|
|
|
|
return (
|
|
<div className="mb-2">
|
|
<div
|
|
data-testid="memory-tool-activity-card"
|
|
className="overflow-hidden rounded-lg border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
) : (
|
|
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
)}
|
|
<BookMarked size={15} className="shrink-0 text-[var(--color-memory-accent)]" aria-hidden="true" />
|
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
{t(titleKey, { count: activity.files.length })}
|
|
</span>
|
|
{isStreaming ? (
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-memory-accent)] animate-pulse-dot" />
|
|
) : null}
|
|
</button>
|
|
|
|
{expanded ? (
|
|
<div className="border-t border-[var(--color-border)]/55 px-3 py-2.5">
|
|
<div className="space-y-1.5">
|
|
{visibleFiles.map((file) => (
|
|
<button
|
|
key={file.path}
|
|
type="button"
|
|
title={file.path}
|
|
onClick={() => openMemorySettings(file.path)}
|
|
className="group flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
|
>
|
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border border-[var(--color-memory-border)] bg-[var(--color-memory-icon-bg)] text-[var(--color-text-tertiary)] group-hover:text-[var(--color-memory-accent)]">
|
|
<Settings size={12} aria-hidden="true" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
{file.label}
|
|
</span>
|
|
{file.lineHint ? (
|
|
<span className="shrink-0 text-[12px] text-[var(--color-text-tertiary)]">
|
|
{file.lineHint}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
{file.preview ? (
|
|
<span className="mt-0.5 line-clamp-2 text-[12px] leading-5 text-[var(--color-text-secondary)]">
|
|
{file.preview}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
))}
|
|
{hiddenCount > 0 ? (
|
|
<div className="px-2 py-1 text-[12px] text-[var(--color-text-tertiary)]">
|
|
{t('chat.memoryMoreFiles', { count: hiddenCount })}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setDetailsExpanded((value) => !value)}
|
|
className="mt-2 inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
|
>
|
|
{detailsExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
{t('chat.memoryTechnicalDetails')}
|
|
</button>
|
|
|
|
{detailsExpanded ? (
|
|
<div className="mt-2 space-y-1">
|
|
{toolCalls.map((toolCall) => (
|
|
<ToolCallTree
|
|
key={toolCall.id}
|
|
toolCall={toolCall}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentToolGroup({
|
|
toolCalls,
|
|
resultMap,
|
|
childToolCallsByParent,
|
|
agentTaskNotifications,
|
|
isStreaming,
|
|
}: Props) {
|
|
const [expanded, setExpanded] = useState(true)
|
|
const t = useTranslation()
|
|
const statuses = toolCalls.map((toolCall) =>
|
|
getAgentStatus({
|
|
hasResult: resultMap.has(toolCall.toolUseId),
|
|
isError: !!resultMap.get(toolCall.toolUseId)?.isError,
|
|
isLaunchResult: isAgentLaunchResult(resultMap.get(toolCall.toolUseId)?.content),
|
|
isStreaming: !!isStreaming && !resultMap.has(toolCall.toolUseId),
|
|
childCount: (childToolCallsByParent.get(toolCall.toolUseId) ?? []).length,
|
|
taskStatus: agentTaskNotifications[toolCall.toolUseId]?.status,
|
|
}),
|
|
)
|
|
const isAnyRunning = statuses.some((status) => status === 'running' || status === 'starting')
|
|
const errorPresent = statuses.some((status) => status === 'failed')
|
|
const allComplete = statuses.every((status) => status === 'done')
|
|
const anyStopped = statuses.some((status) => status === 'stopped')
|
|
|
|
useEffect(() => {
|
|
if (isStreaming) {
|
|
setExpanded(true)
|
|
}
|
|
}, [isStreaming])
|
|
|
|
return (
|
|
<div className="mb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
className="flex w-full items-center gap-2 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container-high)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
|
|
{expanded ? 'expand_less' : 'expand_more'}
|
|
</span>
|
|
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
|
|
{toolCalls.length === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: toolCalls.length })}
|
|
</span>
|
|
{isAnyRunning && (
|
|
<span className="rounded-full bg-[var(--color-warning)]/12 px-2 py-0.5 text-[10px] font-semibold text-[var(--color-warning)]">
|
|
{t('agentStatus.running')}
|
|
</span>
|
|
)}
|
|
{!isAnyRunning && errorPresent && (
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)]">error</span>
|
|
)}
|
|
{!isAnyRunning && !errorPresent && allComplete && (
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
|
|
)}
|
|
{!isAnyRunning && !errorPresent && !allComplete && !anyStopped && (
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">pending</span>
|
|
)}
|
|
{!isAnyRunning && !errorPresent && !allComplete && anyStopped && (
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">stop_circle</span>
|
|
)}
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="relative mt-3 pl-5">
|
|
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]/45" />
|
|
<div className="space-y-2">
|
|
{toolCalls.map((toolCall) => (
|
|
<div key={toolCall.id} className="relative pl-7">
|
|
<div className="absolute left-0 top-1/2 -translate-y-1/2">
|
|
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]/45" />
|
|
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
|
|
</div>
|
|
<AgentCallCard
|
|
toolCall={toolCall}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
|
|
isStreaming={isStreaming && !resultMap.has(toolCall.toolUseId)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Separated so the useState hook is never called conditionally. */
|
|
function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const t = useTranslation()
|
|
const summary = generateSummary(toolCalls, t)
|
|
const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent)
|
|
const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
|
|
const isRunning = !!isStreaming || hasUnresolvedTools
|
|
const hasNestedToolCalls = toolCalls.some((tc) => (childToolCallsByParent.get(tc.toolUseId)?.length ?? 0) > 0)
|
|
|
|
useEffect(() => {
|
|
if (isRunning || hasNestedToolCalls) {
|
|
setExpanded(true)
|
|
}
|
|
}, [hasNestedToolCalls, isRunning])
|
|
|
|
return (
|
|
<div className="mb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="flex w-full items-center gap-2 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container-high)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
|
|
{expanded ? 'expand_less' : 'expand_more'}
|
|
</span>
|
|
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
|
|
{summary}
|
|
</span>
|
|
{!isRunning && !errorPresent && (
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
|
|
)}
|
|
{!isRunning && errorPresent && (
|
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)]">error</span>
|
|
)}
|
|
{isRunning && (
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
|
|
)}
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="mt-1.5 space-y-1">
|
|
{toolCalls.map((tc) => {
|
|
return (
|
|
<ToolCallTree
|
|
key={tc.id}
|
|
toolCall={tc}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
compact
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgentCallCard({
|
|
toolCall,
|
|
resultMap,
|
|
childToolCallsByParent,
|
|
agentTaskNotification,
|
|
isStreaming = false,
|
|
}: {
|
|
toolCall: ToolCall
|
|
resultMap: Map<string, ToolResult>
|
|
childToolCallsByParent: Map<string, ToolCall[]>
|
|
agentTaskNotification?: AgentTaskNotification
|
|
isStreaming?: boolean
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [previewOpen, setPreviewOpen] = useState(false)
|
|
const t = useTranslation()
|
|
const input = toolCall.input && typeof toolCall.input === 'object'
|
|
? toolCall.input as Record<string, unknown>
|
|
: {}
|
|
const result = resultMap.get(toolCall.toolUseId)
|
|
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
|
|
const isLaunchResult = isAgentLaunchResult(result?.content)
|
|
const recentToolCalls = childToolCalls.slice(-2)
|
|
const status = getAgentStatus({
|
|
hasResult: !!result,
|
|
isError: !!result?.isError,
|
|
isLaunchResult,
|
|
isStreaming,
|
|
childCount: childToolCalls.length,
|
|
taskStatus: agentTaskNotification?.status,
|
|
})
|
|
const statusClassName = getAgentStatusClassName(status)
|
|
const statusLabel = getAgentStatusLabel(status, t)
|
|
const taskSummary = agentTaskNotification?.summary?.trim() || ''
|
|
const taskResult = agentTaskNotification?.result?.trim() || ''
|
|
const errorText =
|
|
status === 'failed'
|
|
? taskSummary || (result?.isError ? getAgentErrorSummary(result.content) : '')
|
|
: result?.isError
|
|
? getAgentErrorSummary(result.content)
|
|
: ''
|
|
const fullOutputText =
|
|
result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content)
|
|
? extractAgentDisplayText(result.content).trim()
|
|
: ''
|
|
const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskResult || taskSummary : '')
|
|
const outputSummary = previewText ? getAgentOutputSummary(previewText) : ''
|
|
const description = typeof input.description === 'string' ? input.description : ''
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)]">
|
|
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50">
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">Agent</span>
|
|
{description && (
|
|
<span className="truncate text-[12px] text-[var(--color-text-secondary)]">
|
|
{description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!expanded && outputSummary && (
|
|
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{outputSummary}
|
|
</div>
|
|
)}
|
|
{!expanded && !outputSummary && recentToolCalls.length > 0 && (
|
|
<div className="mt-1 space-y-1">
|
|
{recentToolCalls.map((recentToolCall) => (
|
|
<div
|
|
key={recentToolCall.id}
|
|
className="truncate text-[11px] text-[var(--color-text-tertiary)]"
|
|
>
|
|
{formatRecentToolUseSummary(recentToolCall, resultMap)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!expanded && !outputSummary && !recentToolCalls.length && errorText && (
|
|
<div className="mt-1 truncate text-[11px] text-[var(--color-error)]">
|
|
{errorText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{outputSummary && (
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
setPreviewOpen(true)
|
|
}}
|
|
className="shrink-0 rounded-md border border-[var(--color-border)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
|
>
|
|
{t('agentStatus.viewResult')}
|
|
</button>
|
|
)}
|
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusClassName}`}>
|
|
{statusLabel}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[var(--color-outline)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
|
aria-label={expanded ? 'Collapse agent' : 'Expand agent'}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">
|
|
{expanded ? 'expand_less' : 'expand_more'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="border-t border-[var(--color-border)]/60 px-3 py-3">
|
|
{errorText && (
|
|
<div className="mb-3 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/60 px-3 py-2 text-[11px] text-[var(--color-error)]">
|
|
{errorText}
|
|
</div>
|
|
)}
|
|
{childToolCalls.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{childToolCalls.map((childToolCall) => (
|
|
<ToolCallTree
|
|
key={childToolCall.id}
|
|
toolCall={childToolCall}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
) : outputSummary ? (
|
|
<div className="text-[11px] text-[var(--color-text-tertiary)]">
|
|
{t('agentStatus.noActivity')}
|
|
</div>
|
|
) : (
|
|
<div className="text-[11px] text-[var(--color-text-tertiary)]">
|
|
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<Modal
|
|
open={previewOpen}
|
|
onClose={() => setPreviewOpen(false)}
|
|
title={description || t('agentStatus.resultTitle')}
|
|
width={900}
|
|
>
|
|
<div className="max-h-[70vh] overflow-y-auto">
|
|
<MarkdownRenderer content={previewText || errorText} />
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ToolCallTree({
|
|
toolCall,
|
|
resultMap,
|
|
childToolCallsByParent,
|
|
compact = false,
|
|
}: {
|
|
toolCall: ToolCall
|
|
resultMap: Map<string, ToolResult>
|
|
childToolCallsByParent: Map<string, ToolCall[]>
|
|
compact?: boolean
|
|
}) {
|
|
const result = resultMap.get(toolCall.toolUseId)
|
|
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
|
|
|
|
return (
|
|
<div className={compact ? 'space-y-1' : ''}>
|
|
<ToolCallBlock
|
|
toolName={toolCall.toolName}
|
|
input={toolCall.input}
|
|
result={result ? { content: result.content, isError: result.isError } : null}
|
|
compact={compact}
|
|
/>
|
|
{childToolCalls.length > 0 && (
|
|
<div className={compact ? 'ml-4 border-l border-[var(--color-border)]/60 pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)]/60 pl-3'}>
|
|
<div className="space-y-1">
|
|
{childToolCalls.map((childToolCall) => (
|
|
<ToolCallTree
|
|
key={childToolCall.id}
|
|
toolCall={childToolCall}
|
|
resultMap={resultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function openMemorySettings(path?: string) {
|
|
const ui = useUIStore.getState()
|
|
if (path) ui.setPendingMemoryPath(path)
|
|
ui.setPendingSettingsTab('memory')
|
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
|
}
|
|
|
|
function getMemoryToolActivity(
|
|
toolCalls: ToolCall[],
|
|
resultMap: Map<string, ToolResult>,
|
|
): MemoryToolActivity | null {
|
|
const filesByPath = new Map<string, MemoryToolFile>()
|
|
let sawSave = false
|
|
|
|
for (const toolCall of toolCalls) {
|
|
const path = getToolFilePath(toolCall.input)
|
|
if (!path || !isMemoryMarkdownPath(path)) continue
|
|
|
|
const isSave = isMemoryWriteTool(toolCall.toolName)
|
|
const isReference = toolCall.toolName === 'Read'
|
|
if (!isSave && !isReference) continue
|
|
sawSave ||= isSave
|
|
|
|
const result = resultMap.get(toolCall.toolUseId)
|
|
const preview = extractMemoryPreview(result?.content)
|
|
const current = filesByPath.get(path)
|
|
filesByPath.set(path, {
|
|
path,
|
|
label: memoryFileLabel(path),
|
|
action: isSave ? 'saved' : (current?.action ?? 'referenced'),
|
|
lineHint: preview.lineHint || current?.lineHint,
|
|
preview: preview.text || current?.preview,
|
|
})
|
|
}
|
|
|
|
if (filesByPath.size === 0) return null
|
|
return {
|
|
action: sawSave ? 'saved' : 'referenced',
|
|
files: [...filesByPath.values()],
|
|
}
|
|
}
|
|
|
|
function isMemoryToolCall(toolCall: ToolCall): boolean {
|
|
const path = getToolFilePath(toolCall.input)
|
|
if (!path || !isMemoryMarkdownPath(path)) return false
|
|
return toolCall.toolName === 'Read' || isMemoryWriteTool(toolCall.toolName)
|
|
}
|
|
|
|
function isMemoryWriteTool(toolName: string): boolean {
|
|
return toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit'
|
|
}
|
|
|
|
function getToolFilePath(input: unknown): string | null {
|
|
if (!input || typeof input !== 'object') return null
|
|
const record = input as Record<string, unknown>
|
|
const filePath = record.file_path ?? record.path
|
|
return typeof filePath === 'string' ? filePath : null
|
|
}
|
|
|
|
function isMemoryMarkdownPath(path: string): boolean {
|
|
const normalized = path.replace(/\\/g, '/')
|
|
return normalized.endsWith('.md') && normalized.includes('/memory/')
|
|
}
|
|
|
|
function memoryFileLabel(path: string): string {
|
|
const normalized = path.replace(/\\/g, '/')
|
|
return normalized.split('/').pop() || normalized
|
|
}
|
|
|
|
function extractMemoryPreview(content: unknown): { text?: string; lineHint?: string } {
|
|
const raw = extractTextContent(content)
|
|
if (!raw) return {}
|
|
const lineHint = extractLineHint(raw)
|
|
const lines = raw
|
|
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.replace(/^\s*\d+\s*/, '').trim())
|
|
.filter(Boolean)
|
|
|
|
let inFrontmatter = false
|
|
for (const line of lines) {
|
|
if (line === '---') {
|
|
inFrontmatter = !inFrontmatter
|
|
continue
|
|
}
|
|
if (inFrontmatter) continue
|
|
const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim()
|
|
if (!normalized || normalized === '---') continue
|
|
if (/^(file|lines?|total)\b/i.test(normalized)) continue
|
|
return {
|
|
text: normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized,
|
|
lineHint,
|
|
}
|
|
}
|
|
return { lineHint }
|
|
}
|
|
|
|
function extractLineHint(text: string): string | undefined {
|
|
const match = text.match(/(\d+)\s+lines?\b/i) ?? text.match(/(\d+)\s+行/)
|
|
return match?.[1] ? `${match[1]} lines` : undefined
|
|
}
|
|
|
|
type AgentStatus = 'starting' | 'running' | 'done' | 'failed' | 'stopped'
|
|
type AgentTaskStatus = AgentTaskNotification['status']
|
|
|
|
function getAgentStatus({
|
|
hasResult,
|
|
isError,
|
|
isLaunchResult,
|
|
isStreaming,
|
|
childCount,
|
|
taskStatus,
|
|
}: {
|
|
hasResult: boolean
|
|
isError: boolean
|
|
isLaunchResult: boolean
|
|
isStreaming: boolean
|
|
childCount: number
|
|
taskStatus?: AgentTaskStatus
|
|
}): AgentStatus {
|
|
if (taskStatus === 'failed') return 'failed'
|
|
if (taskStatus === 'stopped') return 'stopped'
|
|
if (taskStatus === 'completed') return 'done'
|
|
if (hasResult && isError && !isLaunchResult) return 'failed'
|
|
if (hasResult && !isLaunchResult) return 'done'
|
|
if (isStreaming || childCount > 0 || isLaunchResult) return 'running'
|
|
return 'starting'
|
|
}
|
|
|
|
function getAgentStatusLabel(
|
|
status: AgentStatus,
|
|
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
|
): string {
|
|
switch (status) {
|
|
case 'failed':
|
|
return t('agentStatus.failed')
|
|
case 'stopped':
|
|
return t('agentStatus.stopped')
|
|
case 'done':
|
|
return t('agentStatus.done')
|
|
case 'running':
|
|
return t('agentStatus.running')
|
|
case 'starting':
|
|
default:
|
|
return t('agentStatus.starting')
|
|
}
|
|
}
|
|
|
|
function getAgentStatusClassName(status: AgentStatus): string {
|
|
switch (status) {
|
|
case 'failed':
|
|
return 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
|
|
case 'stopped':
|
|
return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)]'
|
|
case 'done':
|
|
return 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
|
case 'running':
|
|
return 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
|
|
case 'starting':
|
|
default:
|
|
return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)]'
|
|
}
|
|
}
|
|
|
|
function formatRecentToolUseSummary(
|
|
toolCall: ToolCall,
|
|
resultMap: Map<string, ToolResult>,
|
|
): string {
|
|
const input = toolCall.input && typeof toolCall.input === 'object'
|
|
? toolCall.input as Record<string, unknown>
|
|
: {}
|
|
const result = resultMap.get(toolCall.toolUseId)
|
|
const suffix = result?.isError ? ' • failed' : result ? ' • done' : ' • running'
|
|
|
|
switch (toolCall.toolName) {
|
|
case 'Bash':
|
|
return `Bash · ${typeof input.command === 'string' ? input.command : ''}${suffix}`
|
|
case 'Read':
|
|
return `Read · ${typeof input.file_path === 'string' ? input.file_path.split('/').pop() : 'file'}${suffix}`
|
|
case 'Glob':
|
|
return `Glob · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}`
|
|
case 'Grep':
|
|
return `Grep · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}`
|
|
case 'Agent':
|
|
return `Agent · ${typeof input.description === 'string' ? input.description : ''}${suffix}`
|
|
default:
|
|
return `${toolCall.toolName}${suffix}`
|
|
}
|
|
}
|
|
|
|
function getAgentErrorSummary(content: unknown): string {
|
|
const text = extractTextContent(content).replace(/\s+/g, ' ').trim()
|
|
if (!text) return ''
|
|
if (text.includes(`Agent type 'Explore' not found`)) {
|
|
return 'Explore agent unavailable in this session'
|
|
}
|
|
return text.length > 120 ? `${text.slice(0, 120)}...` : text
|
|
}
|
|
|
|
function getAgentOutputSummary(content: string): string {
|
|
const text = content.replace(/\s+\n/g, '\n').trim()
|
|
if (!text) return ''
|
|
return text.length > 220 ? `${text.slice(0, 220)}...` : text
|
|
}
|
|
|
|
function extractAgentDisplayText(content: unknown): string {
|
|
return stripAgentResultMetadata(extractTextContent(content))
|
|
}
|
|
|
|
function stripAgentResultMetadata(text: string): string {
|
|
return text
|
|
.replace(/^\s*agentId:.*(?:\r?\n)?/gm, '')
|
|
.replace(/<usage>[\s\S]*?<\/usage>/g, '')
|
|
.replace(/^\s*(?:total_tokens|tool_uses|duration_ms):\s*\d+\s*$/gm, '')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim()
|
|
}
|
|
|
|
function isAgentLaunchResult(content: unknown): boolean {
|
|
const text = extractTextContent(content).trim()
|
|
if (!text) return false
|
|
|
|
return (
|
|
text.startsWith('Async agent launched successfully.') ||
|
|
text.startsWith('Remote agent launched in CCR.') ||
|
|
(text.startsWith('Spawned successfully.') &&
|
|
text.includes('The agent is now running and will receive instructions via mailbox.')) ||
|
|
text.includes('The agent is working in the background. You will be notified automatically when it completes.') ||
|
|
text.includes('The agent is running remotely. You will be notified automatically when it completes.')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Check if agent result content is a lifecycle notification (shutdown, terminated, etc.)
|
|
* rather than actual agent output. These should not be shown to the user as results.
|
|
*/
|
|
function isAgentLifecycleResult(content: unknown): boolean {
|
|
const text = extractTextContent(content).trim()
|
|
if (!text) return false
|
|
// Detect JSON lifecycle messages: shutdown_approved, shutdown_rejected, teammate_terminated
|
|
if (text.startsWith('{') && text.endsWith('}')) {
|
|
try {
|
|
const parsed = JSON.parse(text) as Record<string, unknown>
|
|
if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) {
|
|
return true
|
|
}
|
|
} catch {
|
|
// Not valid JSON, not a lifecycle message
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function extractTextContent(content: unknown): string {
|
|
if (typeof content === 'string') return content
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((chunk) => {
|
|
if (typeof chunk === 'string') return chunk
|
|
if (chunk && typeof chunk === 'object' && 'text' in chunk) {
|
|
return typeof chunk.text === 'string' ? chunk.text : ''
|
|
}
|
|
return ''
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
}
|
|
if (content && typeof content === 'object') {
|
|
if (
|
|
'status' in content &&
|
|
(content as Record<string, unknown>).status === 'completed' &&
|
|
Array.isArray((content as Record<string, unknown>).content)
|
|
) {
|
|
return extractTextContent((content as Record<string, unknown>).content)
|
|
}
|
|
}
|
|
if (content && typeof content === 'object') {
|
|
return JSON.stringify(content)
|
|
}
|
|
return ''
|
|
}
|