mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): improve activity panel interactions
Tested: cd desktop && bun run test -- src/components/activity/SessionActivityPanel.test.tsx src/pages/ActiveSession.test.tsx src/pages/SubagentRunPage.test.tsx --run Tested: bun run check:desktop Tested: browser smoke for activity panel auto-open and SubAgent run rendering Not-tested: full bun run verify was not run for this scoped desktop handoff Confidence: high Scope-risk: narrow
This commit is contained in:
parent
c68d42f6a1
commit
75ddd158b4
@ -160,6 +160,7 @@ describe('SessionActivityPanel', () => {
|
||||
expect(screen.getByLabelText('Task completed')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Task in progress')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Task pending')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-3')
|
||||
expect(screen.queryByText('Completed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Pending')).not.toBeInTheDocument()
|
||||
})
|
||||
@ -392,17 +393,17 @@ describe('SessionActivityPanel', () => {
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveAttribute('data-placement', 'rail')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('my-4')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('mr-4')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('w-[340px]')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('w-[360px]')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('rounded-[24px]')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('self-start')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('max-h-[min(480px,calc(100vh-96px))]')
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveClass('max-h-[min(620px,calc(100vh-72px))]')
|
||||
expect(screen.getByTestId('session-activity-panel')).not.toHaveClass('h-[calc(100%-24px)]')
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps each populated activity section independently', () => {
|
||||
it('uses one polished scroll owner instead of nested section scrollbars', () => {
|
||||
const taskRows = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `task-${index + 1}`,
|
||||
section: 'tasks' as const,
|
||||
@ -455,15 +456,19 @@ describe('SessionActivityPanel', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const scrollOwner = screen.getByTestId('session-activity-scroll')
|
||||
const tasksSection = document.querySelector('section[aria-label="Tasks"]')
|
||||
const teamSection = document.querySelector('section[aria-label="Team"]')
|
||||
const backgroundSection = document.querySelector('section[aria-label="Background Tasks"]')
|
||||
const subagentsSection = document.querySelector('section[aria-label="SubAgents"]')
|
||||
|
||||
expect(tasksSection?.querySelector('.max-h-44.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(teamSection?.querySelector('.max-h-36.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(backgroundSection?.querySelector('.max-h-40.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(subagentsSection?.querySelector('.max-h-40.overflow-y-auto')).toBeInTheDocument()
|
||||
expect(scrollOwner).toHaveClass('overflow-y-auto')
|
||||
expect(scrollOwner).toHaveClass('[scrollbar-width:auto]')
|
||||
expect(scrollOwner).toHaveClass('[&::-webkit-scrollbar]:w-2.5')
|
||||
expect(tasksSection?.querySelector('.overflow-y-auto')).not.toBeInTheDocument()
|
||||
expect(teamSection?.querySelector('.overflow-y-auto')).not.toBeInTheDocument()
|
||||
expect(backgroundSection?.querySelector('.overflow-y-auto')).not.toBeInTheDocument()
|
||||
expect(subagentsSection?.querySelector('.overflow-y-auto')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a SubAgent row when the row is openable', () => {
|
||||
|
||||
@ -16,6 +16,20 @@ type SessionActivityPanelPlacement = 'overlay' | 'rail'
|
||||
|
||||
type TranslationFn = ReturnType<typeof useTranslation>
|
||||
|
||||
const ACTIVITY_SCROLLBAR_CLASS = [
|
||||
'[scrollbar-width:auto]',
|
||||
'[scrollbar-color:color-mix(in_srgb,var(--color-outline)_62%,transparent)_transparent]',
|
||||
'[&::-webkit-scrollbar]:w-2.5',
|
||||
'[&::-webkit-scrollbar-track]:bg-transparent',
|
||||
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
||||
'[&::-webkit-scrollbar-thumb]:border-[3px]',
|
||||
'[&::-webkit-scrollbar-thumb]:border-transparent',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--color-outline)_68%,transparent)]',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-clip-content',
|
||||
'[&::-webkit-scrollbar-thumb:hover]:border-2',
|
||||
'[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_84%,transparent)]',
|
||||
].join(' ')
|
||||
|
||||
function fallbackStatusLabel(status: ActivityRow['status']): string {
|
||||
const label = String(status).replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (!label) return ''
|
||||
@ -63,22 +77,22 @@ function getSectionTitle(sectionId: ActivitySectionId, t: TranslationFn): string
|
||||
}
|
||||
|
||||
function getSectionRowsClassName(sectionId: ActivitySectionId, rowCount: number): string {
|
||||
const base = 'space-y-1'
|
||||
const base = 'space-y-1.5'
|
||||
if (rowCount === 0) return base
|
||||
|
||||
switch (sectionId) {
|
||||
case 'tasks':
|
||||
return `${base} max-h-44 overflow-y-auto overscroll-contain pr-1`
|
||||
return base
|
||||
case 'team':
|
||||
return `${base} max-h-36 overflow-y-auto overscroll-contain pr-1`
|
||||
return base
|
||||
case 'backgroundTasks':
|
||||
return `${base} max-h-40 overflow-y-auto overscroll-contain pr-1`
|
||||
return base
|
||||
case 'subagents':
|
||||
return `${base} max-h-40 overflow-y-auto overscroll-contain pr-1`
|
||||
return base
|
||||
case 'sources':
|
||||
return `${base} max-h-28 overflow-y-auto overscroll-contain pr-1`
|
||||
return base
|
||||
case 'output':
|
||||
return `${base} max-h-28 overflow-y-auto overscroll-contain pr-1`
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,7 +277,7 @@ function ActivityRowView({
|
||||
</>
|
||||
)
|
||||
const interactiveRowClassName =
|
||||
'flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
|
||||
'flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
|
||||
|
||||
if (row.section === 'team' && row.member && onOpenMember) {
|
||||
return (
|
||||
@ -306,7 +320,7 @@ function ActivityRowView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-xl px-3 py-2.5">
|
||||
<div className="flex items-center gap-3 rounded-xl px-3 py-3">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
@ -427,8 +441,8 @@ export function SessionActivityPanel({
|
||||
|
||||
if (!open) return null
|
||||
const className = placement === 'rail'
|
||||
? 'my-4 ml-3 mr-4 flex max-h-[min(480px,calc(100vh-96px))] w-[340px] shrink-0 self-start flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
|
||||
: 'absolute right-4 top-4 z-40 flex max-h-[calc(100%-112px)] w-[min(340px,calc(100%-32px))] flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
|
||||
? 'my-4 ml-3 mr-4 flex max-h-[min(620px,calc(100vh-72px))] w-[360px] shrink-0 self-start flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
|
||||
: 'absolute right-4 top-4 z-40 flex max-h-[calc(100%-80px)] w-[min(360px,calc(100%-32px))] flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -451,7 +465,10 @@ export function SessionActivityPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-5 pt-1">
|
||||
<div
|
||||
data-testid="session-activity-scroll"
|
||||
className={`min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-5 pb-5 pt-1 ${ACTIVITY_SCROLLBAR_CLASS}`}
|
||||
>
|
||||
{visibleSections.map((section, index) => {
|
||||
const sectionTitle = getSectionTitle(section.id, t)
|
||||
|
||||
|
||||
@ -635,6 +635,83 @@ describe('ActiveSession task polling', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-opens the activity panel when the current session first produces activity', async () => {
|
||||
const sessionId = 'activity-auto-open-session'
|
||||
const fetchSessionTasks = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
useCLITaskStore.setState({ fetchSessionTasks })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Auto Open Activity Session',
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Auto Open Activity Session', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
backgroundAgentTasks: {},
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument()
|
||||
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false)
|
||||
|
||||
act(() => {
|
||||
useCLITaskStore.setState({
|
||||
sessionId,
|
||||
tasks: [{
|
||||
id: 'task-1',
|
||||
subject: 'Draft implementation plan',
|
||||
description: 'Create the first activity row',
|
||||
status: 'in_progress',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
taskListId: sessionId,
|
||||
}],
|
||||
completedAndDismissed: false,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(true)
|
||||
})
|
||||
expect(screen.getByTestId('session-activity-panel')).toHaveAttribute('data-placement', 'rail')
|
||||
expect(screen.getByText('Draft implementation plan')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders completed historical TodoWrite activity in the rail', () => {
|
||||
const sessionId = 'activity-todowrite-history-session'
|
||||
|
||||
|
||||
@ -302,6 +302,7 @@ export function ActiveSession() {
|
||||
const hasIncompleteTasks = cliTasks.some((task) => task.status !== 'completed')
|
||||
const hasRunningTasks = cliTasks.some((task) => task.status === 'in_progress')
|
||||
const isActivityPanelOpen = useActivityPanelStore((state) => activeTabId ? state.isOpen(activeTabId) : false)
|
||||
const openActivityPanel = useActivityPanelStore((state) => state.open)
|
||||
const closeActivityPanel = useActivityPanelStore((state) => state.close)
|
||||
const dismissBackgroundTaskKeys = useActivityPanelStore((state) => state.dismissBackgroundTaskKeys)
|
||||
const pruneDismissedBackgroundTaskKeys = useActivityPanelStore((state) => state.pruneDismissedBackgroundTaskKeys)
|
||||
@ -336,6 +337,7 @@ export function ActiveSession() {
|
||||
: undefined,
|
||||
)
|
||||
const terminalPanelHeight = useTerminalPanelStore((state) => state.height)
|
||||
const activityVisibilityBySessionRef = useRef<Record<string, { hadAutoOpenActivity: boolean }>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabId && !isMemberSession) {
|
||||
@ -442,6 +444,31 @@ export function ActiveSession() {
|
||||
trackedTaskSessionId,
|
||||
])
|
||||
const hasVisibleActivity = activityModel ? hasVisibleSessionActivity(activityModel) : false
|
||||
const hasAutoOpenActivity = activityModel ? activityModel.badgeCount > 0 : false
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || isMemberSession || !isSessionTabState(activeTabId, activeTabType)) return
|
||||
|
||||
const state = activityVisibilityBySessionRef.current[activeTabId]
|
||||
if (!state) {
|
||||
activityVisibilityBySessionRef.current[activeTabId] = {
|
||||
hadAutoOpenActivity: hasAutoOpenActivity,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.hadAutoOpenActivity && hasAutoOpenActivity && !isActivityPanelOpen) {
|
||||
openActivityPanel(activeTabId)
|
||||
}
|
||||
state.hadAutoOpenActivity = hasAutoOpenActivity
|
||||
}, [
|
||||
activeTabId,
|
||||
activeTabType,
|
||||
hasAutoOpenActivity,
|
||||
isActivityPanelOpen,
|
||||
isMemberSession,
|
||||
openActivityPanel,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !isActivityPanelOpen || hasVisibleActivity) return
|
||||
|
||||
@ -61,6 +61,7 @@ describe('SubagentRunPage', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.mocked(subagentsApi.getRunByTool).mockReset()
|
||||
})
|
||||
|
||||
@ -72,16 +73,15 @@ describe('SubagentRunPage', () => {
|
||||
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="Kuhn" />)
|
||||
|
||||
expect(await screen.findByText('Kuhn')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Agent').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Agent: abc123')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Explore repo').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Found layout seam').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Output: /tmp/result.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('Parent Agent Tool Call')).toBeInTheDocument()
|
||||
expect(document.body).toHaveTextContent('"prompt": "Read files"')
|
||||
expect(screen.queryByText('Parent Agent Tool Call')).not.toBeInTheDocument()
|
||||
expect(document.body).not.toHaveTextContent('"prompt": "Read files"')
|
||||
expect(screen.queryByText(/Dispatched an agent|派遣了一个代理/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Open run/ })).not.toBeInTheDocument()
|
||||
|
||||
const transcript = screen.getByTestId('subagent-transcript')
|
||||
const transcript = screen.getByTestId('subagent-conversation')
|
||||
expect(transcript).toHaveTextContent('Read files')
|
||||
expect(transcript).toHaveTextContent('Finding')
|
||||
expect(transcript).not.toHaveTextContent('assistant_text')
|
||||
@ -107,8 +107,33 @@ describe('SubagentRunPage', () => {
|
||||
|
||||
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
|
||||
|
||||
expect(await screen.findByText('No local transcript messages captured for this SubAgent.')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Only summary available').length).toBeGreaterThan(0)
|
||||
const conversation = await screen.findByTestId('subagent-conversation')
|
||||
expect(conversation).toHaveTextContent('Only summary available')
|
||||
expect(screen.queryByText('No local transcript messages captured for this SubAgent.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes running SubAgent runs while the detail tab is open', async () => {
|
||||
vi.mocked(subagentsApi.getRunByTool)
|
||||
.mockResolvedValueOnce(subagentRun({
|
||||
status: 'running',
|
||||
messages: [],
|
||||
prompt: 'Review streaming changes',
|
||||
}))
|
||||
.mockResolvedValueOnce(subagentRun({
|
||||
status: 'completed',
|
||||
messages: [],
|
||||
prompt: 'Review streaming changes',
|
||||
result: 'Streaming review complete',
|
||||
}))
|
||||
|
||||
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
|
||||
|
||||
expect(await screen.findByText('Running')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('subagent-conversation')).toHaveTextContent('Review streaming changes')
|
||||
|
||||
await waitFor(() => expect(subagentsApi.getRunByTool).toHaveBeenCalledTimes(2), { timeout: 2500 })
|
||||
expect(await screen.findByText('Completed')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('subagent-conversation')).toHaveTextContent('Streaming review complete')
|
||||
})
|
||||
|
||||
it('keeps the tab open on API errors', async () => {
|
||||
@ -145,7 +170,6 @@ describe('SubagentRunPage', () => {
|
||||
await second.promise
|
||||
})
|
||||
|
||||
expect((await screen.findAllByText('Second result')).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/Second finding/)).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
@ -163,14 +187,14 @@ describe('SubagentRunPage', () => {
|
||||
await first.promise
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('Second result').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/Second finding/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Stale first result')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Stale finding/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps existing details visible when refresh fails', async () => {
|
||||
vi.mocked(subagentsApi.getRunByTool)
|
||||
.mockResolvedValueOnce(subagentRun({ summary: 'Initial result' }))
|
||||
.mockResolvedValueOnce(subagentRun({ messages: [], summary: 'Initial result' }))
|
||||
.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
|
||||
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
|
||||
|
||||
@ -6,15 +6,13 @@ import {
|
||||
type SubagentRunStatus,
|
||||
} from '../api/subagents'
|
||||
import { buildRenderModel, MessageBlock } from '../components/chat/MessageList'
|
||||
import { ToolCallBlock } from '../components/chat/ToolCallBlock'
|
||||
import { ToolCallGroup } from '../components/chat/ToolCallGroup'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { mapHistoryMessagesToUiMessages } from '../stores/chatStore'
|
||||
import type { AgentTaskNotification, UIMessage } from '../types/chat'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
type TranslationFn = ReturnType<typeof useTranslation>
|
||||
const LIVE_RUN_REFRESH_MS = 2000
|
||||
|
||||
export function SubagentRunPage({
|
||||
sourceSessionId,
|
||||
@ -54,6 +52,16 @@ export function SubagentRunPage({
|
||||
void load({ resetData: true })
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.status !== 'running' || loading) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void load()
|
||||
}, LIVE_RUN_REFRESH_MS)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [data?.status, load, loading])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)] text-[var(--color-text-primary)]">
|
||||
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-3">
|
||||
@ -96,8 +104,6 @@ export function SubagentRunPage({
|
||||
|
||||
function SubagentRunDetails({ data }: { data: SubagentRunResponse }) {
|
||||
const t = useTranslation()
|
||||
const agentToolCall = useMemo(() => buildAgentToolCall(data), [data])
|
||||
const agentToolResult = useMemo(() => buildAgentToolResult(data, t), [data, t])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-5">
|
||||
@ -105,6 +111,12 @@ function SubagentRunDetails({ data }: { data: SubagentRunResponse }) {
|
||||
<span>{t('subagentRun.source')}: {sourceLabel(data.source, t)}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{t('subagentRun.agent')}: {data.agentId ?? t('subagentRun.unknown')}</span>
|
||||
{data.description ? (
|
||||
<>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{data.description}</span>
|
||||
</>
|
||||
) : null}
|
||||
{data.taskId ? (
|
||||
<>
|
||||
<span aria-hidden="true">/</span>
|
||||
@ -127,30 +139,17 @@ function SubagentRunDetails({ data }: { data: SubagentRunResponse }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section aria-label={t('subagentRun.parentToolInput')}>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">{t('subagentRun.parentToolCall')}</h2>
|
||||
<ToolCallBlock
|
||||
toolName="Agent"
|
||||
input={agentToolCall.input}
|
||||
result={agentToolResult}
|
||||
isPending={data.status === 'running' && !agentToolResult}
|
||||
defaultExpanded
|
||||
/>
|
||||
</section>
|
||||
|
||||
<TranscriptSection data={data} />
|
||||
<ConversationSection data={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_AGENT_TASK_NOTIFICATIONS: Record<string, AgentTaskNotification> = {}
|
||||
|
||||
function TranscriptSection({ data }: { data: SubagentRunResponse }) {
|
||||
function ConversationSection({ data }: { data: SubagentRunResponse }) {
|
||||
const t = useTranslation()
|
||||
const transcriptMessages = useMemo(() => (
|
||||
mapHistoryMessagesToUiMessages(data.messages, { includeTeammateMessages: true })
|
||||
), [data.messages])
|
||||
const renderModel = useMemo(() => buildRenderModel(transcriptMessages), [transcriptMessages])
|
||||
const conversationMessages = useMemo(() => buildSubagentConversationMessages(data), [data])
|
||||
const renderModel = useMemo(() => buildRenderModel(conversationMessages), [conversationMessages])
|
||||
|
||||
if (renderModel.renderItems.length === 0) {
|
||||
return (
|
||||
@ -171,7 +170,7 @@ function TranscriptSection({ data }: { data: SubagentRunResponse }) {
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">{t('subagentRun.truncated')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-testid="subagent-transcript" className="space-y-3">
|
||||
<div data-testid="subagent-conversation" className="space-y-3">
|
||||
{renderModel.renderItems.map((item) => {
|
||||
if (item.kind === 'tool_group') {
|
||||
return (
|
||||
@ -265,38 +264,44 @@ function timestampMs(value: string | undefined) {
|
||||
return Number.isFinite(time) ? time : Date.now()
|
||||
}
|
||||
|
||||
function buildAgentToolCall(data: SubagentRunResponse): ToolCall {
|
||||
return {
|
||||
id: `subagent-tool-${data.toolUseId}`,
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: data.toolUseId,
|
||||
input: {
|
||||
...(data.description ? { description: data.description } : {}),
|
||||
...(data.prompt ? { prompt: data.prompt } : {}),
|
||||
},
|
||||
timestamp: timestampMs(data.updatedAt),
|
||||
function normalizedText(value: string | undefined) {
|
||||
return (value ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function hasPromptMessage(messages: UIMessage[], prompt: string) {
|
||||
const normalizedPrompt = normalizedText(prompt)
|
||||
if (!normalizedPrompt) return false
|
||||
|
||||
return messages.some((message) => (
|
||||
message.type === 'user_text' &&
|
||||
normalizedText(message.content) === normalizedPrompt
|
||||
))
|
||||
}
|
||||
|
||||
function buildSubagentConversationMessages(data: SubagentRunResponse): UIMessage[] {
|
||||
const transcriptMessages = mapHistoryMessagesToUiMessages(data.messages, { includeTeammateMessages: true })
|
||||
const messages = [...transcriptMessages]
|
||||
const prompt = data.prompt?.trim()
|
||||
const baseTimestamp = timestampMs(data.updatedAt)
|
||||
|
||||
if (prompt && !hasPromptMessage(transcriptMessages, prompt)) {
|
||||
messages.unshift({
|
||||
id: `subagent-prompt-${data.toolUseId}`,
|
||||
type: 'user_text',
|
||||
content: prompt,
|
||||
timestamp: baseTimestamp - 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentToolResult(data: SubagentRunResponse, t: TranslationFn): ToolResult | null {
|
||||
const resultText = data.result || data.summary
|
||||
if (!resultText && data.status === 'running') return null
|
||||
if (!resultText && data.status === 'unknown') return null
|
||||
|
||||
return {
|
||||
id: `subagent-result-${data.toolUseId}`,
|
||||
type: 'tool_result',
|
||||
toolUseId: data.toolUseId,
|
||||
content: resultText || statusFallbackResult(data.status, t),
|
||||
isError: data.status === 'failed' || data.status === 'stopped',
|
||||
timestamp: timestampMs(data.updatedAt),
|
||||
const resultText = (data.result || data.summary)?.trim()
|
||||
if (transcriptMessages.length === 0 && resultText) {
|
||||
messages.push({
|
||||
id: `subagent-result-message-${data.toolUseId}`,
|
||||
type: 'assistant_text',
|
||||
content: resultText,
|
||||
timestamp: baseTimestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function statusFallbackResult(status: SubagentRunStatus, t: TranslationFn) {
|
||||
if (status === 'completed') return t('subagentRun.result.completedFallback')
|
||||
if (status === 'failed') return t('subagentRun.result.failedFallback')
|
||||
if (status === 'stopped') return t('subagentRun.result.stoppedFallback')
|
||||
return t('subagentRun.result.noneFallback')
|
||||
return messages
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user