From 74c16bf31f3f908eb97b37b34d99045ffe743201 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 13:17:25 +0000 Subject: [PATCH 1/2] feat: add live nested task panels to web sessions --- .../FastSessionTranscript.client.test.tsx | 82 ++++++++++++ .../[sessionId]/FastSessionTranscript.tsx | 4 + .../NestedTaskSidePanel.client.test.tsx | 93 +++++++++++++ .../[sessionId]/NestedTaskSidePanel.tsx | 124 ++++++++++++++++++ .../SessionWorkspace.client.test.tsx | 93 +++++++++++++ .../sessions/[sessionId]/SessionWorkspace.tsx | 118 +++++++++-------- .../[sessionId]/session-task-panel-context.ts | 11 ++ .../[taskId]/messages/acp/AcpMessageItem.tsx | 12 ++ .../messages/acp/AcpTranscriptBlocks.tsx | 8 ++ .../acp/DelegatedTaskCard.client.test.tsx | 98 ++++++++++++++ .../messages/acp/DelegatedTaskCard.tsx | 60 +++++++++ .../tool-call-grouping.client.test.ts | 23 ++++ .../[taskId]/messages/acp/activity-groups.ts | 23 +++- .../[taskId]/messages/acp/delegated-task.ts | 50 +++++++ .../[taskId]/messages/acp/render-blocks.ts | 7 +- .../sidebar-panels/SidePanelHeader.tsx | 1 + 16 files changed, 748 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 776fac7e5..be2d3ad78 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -10,6 +10,7 @@ import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; import { FastSessionTranscript } from './FastSessionTranscript'; const replyMutate = vi.fn(); +const openTaskPanel = vi.fn(); vi.mock('@/trpc/client', () => ({ useTRPCClient: () => ({ @@ -24,6 +25,24 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ }), })); +vi.mock('./session-task-panel-context', () => ({ + useOpenSessionTaskPanel: () => openTaskPanel, +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + onOpen, + }: { + taskId: string; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + class FakeEventSource { static instances: FakeEventSource[] = []; listeners = new Map void>>(); @@ -54,6 +73,7 @@ class FakeEventSource { beforeEach(() => { FakeEventSource.instances = []; replyMutate.mockReset(); + openTaskPanel.mockReset(); vi.stubGlobal('EventSource', FakeEventSource); }); @@ -205,6 +225,68 @@ describe('FastSessionTranscript', () => { expect(screen.getAllByText('launch_task')).toHaveLength(1); }); + it('opens a launched child task in the session side panel', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Delegated task/ })); + + expect(openTaskPanel).toHaveBeenCalledWith('child-1'); + }); + it('cold-loads one completed tool row before an intervening kickoff', () => { render( >( @@ -159,6 +161,7 @@ export function FastSessionTranscript({ shouldHideFirstMessage: false, showInternalMessages: false, hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); @@ -243,6 +246,7 @@ export function FastSessionTranscript({ blocks={renderBlocks} showInternalMessages={false} onSuppress={suppressMessage} + onOpenDelegatedTask={openTaskPanel ?? undefined} /> diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx new file mode 100644 index 000000000..6e663264b --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { RunStatus } from '@roomote/types'; + +const useTaskSessionMock = vi.fn(); + +vi.mock('../../task/[taskId]/hooks/use-task-session', () => ({ + useTaskSession: (...args: unknown[]) => useTaskSessionMock(...args), +})); + +vi.mock('../../task/[taskId]/hooks/use-task-message-envelopes', () => ({ + useTaskMessageEnvelopes: () => ({ + data: [], + isPending: false, + isSuccess: true, + isError: false, + }), +})); + +vi.mock('../../task/[taskId]/hooks/ArtifactLinkProvider', () => ({ + ArtifactLinkProvider: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock('../../task/[taskId]/hooks/HistoricalSandboxProvider', () => ({ + HistoricalSandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/hooks/SandboxProvider', () => ({ + SandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/Messages', () => ({ + Messages: () =>
Child transcript
, +})); + +vi.mock('../../task/[taskId]/sidebar-panels/SidePanelHeader', () => ({ + SidePanelHeader: ({ + title, + actions, + }: { + title: string; + actions: ReactNode; + }) => ( +
+ {title} + {actions} +
+ ), +})); + +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; + +describe('NestedTaskSidePanel', () => { + beforeEach(() => { + useTaskSessionMock.mockReturnValue({ + taskId: 'child-1', + task: { title: 'Fix checkout' }, + taskRun: { + id: 42, + harness: 'opencode-server', + status: RunStatus.Running, + taskPhase: 'running', + sandboxServerUrl: 'http://sandbox.test', + }, + artifacts: [], + prompt: null, + token: 'token', + refreshConnection: vi.fn(), + sessionState: 'interactive', + isSessionLoading: false, + }); + }); + + it('renders the focused live transcript and full-task navigation without task chrome', () => { + render(); + + expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByTestId('live-provider')).toBeInTheDocument(); + expect(screen.getByText('Child transcript')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Go to task/ })).toHaveAttribute( + 'href', + '/task/child-1', + ); + expect(screen.queryByText('Task actions')).not.toBeInTheDocument(); + expect(useTaskSessionMock).toHaveBeenCalledWith('child-1', { + refetchInterval: 2_000, + }); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx new file mode 100644 index 000000000..0ec29566d --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx @@ -0,0 +1,124 @@ +'use client'; + +import Link from 'next/link'; + +import { DEFAULT_CODING_HARNESS, type TaskPhase } from '@roomote/types'; + +import { + Button, + ErrorState, + ExternalLink, + Skeleton, +} from '@/components/system'; + +import { ArtifactLinkProvider } from '../../task/[taskId]/hooks/ArtifactLinkProvider'; +import { HistoricalSandboxProvider } from '../../task/[taskId]/hooks/HistoricalSandboxProvider'; +import { SandboxProvider } from '../../task/[taskId]/hooks/SandboxProvider'; +import { useTaskMessageEnvelopes } from '../../task/[taskId]/hooks/use-task-message-envelopes'; +import { + useTaskSession, + type TaskSession, +} from '../../task/[taskId]/hooks/use-task-session'; +import { Messages } from '../../task/[taskId]/Messages'; +import { SidePanelHeader } from '../../task/[taskId]/sidebar-panels/SidePanelHeader'; + +function NestedTaskTranscript({ session }: { session: TaskSession }) { + const history = useTaskMessageEnvelopes(session.taskId); + + if (session.isSessionLoading) { + return ( +
+ + + +
+ ); + } + + if ( + session.sessionState === 'error' || + session.sessionState === 'not-found' + ) { + return ; + } + + if (!session.taskRun) { + return ; + } + + const transcript = ( + + + + ); + + if ( + session.sessionState === 'historical' || + session.sessionState === 'resuming' || + session.sessionState === 'boot-failed' + ) { + return ( + + {transcript} + + ); + } + + return ( + + {transcript} + + ); +} + +export function NestedTaskSidePanel({ + taskId, + onClose, +}: { + taskId: string; + onClose: () => void; +}) { + const session = useTaskSession(taskId, { refetchInterval: 2_000 }); + const title = session.task?.title?.trim() || 'Task'; + + return ( +
+ + + Go to task + + + + } + /> +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx new file mode 100644 index 000000000..154c43308 --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +vi.mock('@/components/layout', () => ({ + WorkspaceSurface: ({ + children, + sideActions, + }: { + children: ReactNode; + sideActions: ReactNode; + }) => ( +
+ {sideActions} + {children} +
+ ), +})); + +vi.mock('@/components/layout/side-nav/SideNavItem', () => ({ + SideNavItem: ({ onClick }: { onClick: () => void }) => ( + + ), +})); + +vi.mock('@/components/system', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + Avatar: () => null, + BasicTooltip: ({ children }: { children: ReactNode }) => children, + ResizableDivider: () =>
, + ResizablePanel: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + ResizablePanelGroup: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + }; +}); + +vi.mock('../../task/[taskId]/sidebar-panels/SidePanelHeader', () => ({ + SidePanelHeader: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock('./NestedTaskSidePanel', () => ({ + NestedTaskSidePanel: ({ taskId }: { taskId: string }) => ( +
Nested panel {taskId}
+ ), +})); + +vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ + useLaunchTaskModels: () => ({ data: { models: [] } }), +})); + +import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { useOpenSessionTaskPanel } from './session-task-panel-context'; + +const session: SessionInfo = { + id: 'session-1', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + surface: 'web', + model: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +function OpenNestedTask() { + const openTaskPanel = useOpenSessionTaskPanel(); + return ( + + ); +} + +describe('SessionWorkspace', () => { + it('opens delegated tasks in the existing session side-panel slot', () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open child' })); + + expect(screen.getByText('Nested panel child-1')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index ade5d409c..8ecaf3162 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, type ReactNode } from 'react'; +import { useCallback, useState, type ReactNode } from 'react'; import { formatDistanceToNow } from 'date-fns'; import { formatInferenceCost, getUserDisplayName } from '@/lib'; @@ -10,14 +10,15 @@ import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { Avatar, BasicTooltip, - Button, DollarSign, Info, ResizableDivider, ResizablePanel, ResizablePanelGroup, - X, } from '@/components/system'; +import { SidePanelHeader } from '../../task/[taskId]/sidebar-panels/SidePanelHeader'; +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; +import { OpenSessionTaskPanelContext } from './session-task-panel-context'; export type SessionInfo = { id: string; @@ -68,19 +69,7 @@ function SessionInfoPanel({ return ( <> -
-

Session info

- - - -
+
@@ -120,6 +109,8 @@ function SessionInfoPanel({ ); } +type SessionPanel = { type: 'info' } | { type: 'task'; taskId: string }; + export function SessionWorkspace({ session, children, @@ -127,47 +118,62 @@ export function SessionWorkspace({ session: SessionInfo; children: ReactNode; }) { - const [isInfoOpen, setIsInfoOpen] = useState(false); + const [activePanel, setActivePanel] = useState(null); + const openTaskPanel = useCallback((taskId: string) => { + setActivePanel({ type: 'task', taskId }); + }, []); + const isInfoOpen = activePanel?.type === 'info'; + const closePanel = useCallback(() => setActivePanel(null), []); return ( - - setIsInfoOpen((previous) => !previous)} - /> - - } - > - - - {children} - - {isInfoOpen && ( - <> - - - setIsInfoOpen(false)} - /> - - - )} - - + + + + setActivePanel((previous) => + previous?.type === 'info' ? null : { type: 'info' }, + ) + } + /> + + } + > + + + {children} + + {activePanel ? ( + <> + + + {activePanel.type === 'info' ? ( + + ) : ( + + )} + + + ) : null} + + + ); } diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts new file mode 100644 index 000000000..8fd19b8cf --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts @@ -0,0 +1,11 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export const OpenSessionTaskPanelContext = createContext< + ((taskId: string) => void) | null +>(null); + +export function useOpenSessionTaskPanel() { + return useContext(OpenSessionTaskPanelContext); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx index ddc9bdcf1..d2f0ebc86 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx @@ -8,11 +8,14 @@ import { AcpTodoSectionMessage } from './AcpTodoSectionMessage'; import { AcpTextMessage } from './AcpTextMessage'; import { AcpToolMessage } from './AcpToolMessage'; import { AcpUnknownMessage } from './AcpUnknownMessage'; +import { DelegatedTaskCard } from './DelegatedTaskCard'; +import { getDelegatedTaskDetails } from './delegated-task'; interface AcpMessageItemProps { msg: AcpUiMessage; onSuppress?: (messageId: string) => void; showSubagentPayload?: boolean; + onOpenDelegatedTask?: (taskId: string) => void; children?: ReactNode; } @@ -20,6 +23,7 @@ function AcpMessageItemBase({ msg, onSuppress, showSubagentPayload = false, + onOpenDelegatedTask, children, }: AcpMessageItemProps) { switch (msg.kind) { @@ -31,6 +35,14 @@ function AcpMessageItemBase({ return ; case 'tool_call': case 'tool_result': { + const delegatedTask = getDelegatedTaskDetails(msg); + + if (delegatedTask && onOpenDelegatedTask) { + return ( + + ); + } + return msg.data.kind === 'execute' ? ( >( @@ -63,6 +65,7 @@ export function useAcpTranscriptBlocks({ initialPrompt, shouldHideFirstMessage, showInternalMessages, + keepDelegatedTasksVisible, suppressedMessageIds, }); @@ -70,12 +73,14 @@ export function useAcpTranscriptBlocks({ artifacts, displayMode, hasLeadingTextBoundary, + keepDelegatedTasksVisible, }); }, [ artifacts, displayMode, hasLeadingTextBoundary, initialPrompt, + keepDelegatedTasksVisible, messages, shouldHideFirstMessage, showInternalMessages, @@ -106,10 +111,12 @@ export function AcpTranscriptBlockList({ blocks, showInternalMessages, onSuppress, + onOpenDelegatedTask, }: { blocks: AcpConversationRenderBlock[]; showInternalMessages: boolean; onSuppress: (messageId: string) => void; + onOpenDelegatedTask?: (taskId: string) => void; }) { function renderNestedBlocks(nestedBlocks: AcpRenderBlock[]) { return nestedBlocks.map((block) => ( @@ -196,6 +203,7 @@ export function AcpTranscriptBlockList({ msg={block.msg} onSuppress={onSuppress} showSubagentPayload={showInternalMessages} + onOpenDelegatedTask={onOpenDelegatedTask} > {block.childBlocks?.length ? renderNestedBlocks(block.childBlocks) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx new file mode 100644 index 000000000..0205c52ca --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { RunStatus } from '@roomote/types'; + +const useQueryMock = vi.fn(); +const queryOptionsMock = vi.fn((input, options) => ({ input, ...options })); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sandboxSession: { byTaskId: { queryOptions: queryOptionsMock } }, + }), +})); + +import { DelegatedTaskCard } from './DelegatedTaskCard'; + +describe('DelegatedTaskCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + useQueryMock.mockReturnValue({ + isPending: false, + data: { + task: { title: 'Fix checkout' }, + taskRun: { + status: RunStatus.Running, + taskPhase: 'running', + error: null, + }, + }, + }); + }); + + it('renders live task state and opens the selected child', () => { + const onOpen = vi.fn(); + render( + , + ); + + expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByText('Working')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'View delegated task: Fix checkout' }), + ); + expect(onOpen).toHaveBeenCalledWith('child-1'); + + const queryOptions = queryOptionsMock.mock.calls[0]![1]; + expect(queryOptions.refetchInterval({ state: { data: undefined } })).toBe( + 2_000, + ); + expect(queryOptionsMock).toHaveBeenCalledWith( + { taskId: 'child-1' }, + expect.any(Object), + ); + }); + + it('updates when the child transitions to a terminal state', () => { + let queryResult = { + isPending: false, + data: { + task: { title: 'Fix checkout' }, + taskRun: { + status: RunStatus.Running, + taskPhase: 'running', + error: null as string | null, + }, + }, + }; + useQueryMock.mockImplementation(() => queryResult); + + const { rerender } = render( + , + ); + expect(screen.getByText('Working')).toBeInTheDocument(); + + queryResult = { + ...queryResult, + data: { + ...queryResult.data, + taskRun: { + status: RunStatus.Failed, + taskPhase: 'shutting_down', + error: 'Task failed', + }, + }, + }; + rerender( + , + ); + + expect(screen.getByText('Error')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx new file mode 100644 index 000000000..dc2834459 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { Bot, ChevronRight, Skeleton } from '@/components/system'; +import { TaskStatusIndicator } from '@/components/sandbox'; +import { useTRPC } from '@/trpc/client'; + +export function DelegatedTaskCard({ + taskId, + prompt, + onOpen, +}: { + taskId: string; + prompt: string | null; + onOpen: (taskId: string) => void; +}) { + const trpc = useTRPC(); + const { data, isPending } = useQuery( + trpc.sandboxSession.byTaskId.queryOptions( + { taskId }, + { + refetchInterval: (query) => query.state.data?.refetchInterval ?? 2_000, + }, + ), + ); + const title = data?.task?.title?.trim() || prompt || 'Delegated task'; + + return ( + + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts index 29b7fd169..0ea0c5827 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts @@ -2014,4 +2014,27 @@ describe('buildAcpRenderBlocks', () => { }, }); }); + + it('keeps multiple delegated tasks as standalone cards when requested', () => { + const delegatedTask = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'launch_task', + kind: 'tool', + mcp: false, + payload: { + toolName: 'launch_task', + output: JSON.stringify({ success: true, taskId: id }), + }, + }); + + const entries = buildAcpRenderBlocks( + [delegatedTask('child-1', 1), delegatedTask('child-2', 2)], + { keepDelegatedTasksVisible: true }, + ); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts index c899e451e..f1391c2f6 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts @@ -10,6 +10,7 @@ import type { import type { AcpRenderBlock } from './render-blocks'; import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; +import { getDelegatedTaskDetails } from './delegated-task'; const COLLAPSIBLE_ACP_MESSAGE_KINDS = [ 'reasoning', @@ -42,6 +43,7 @@ interface BuildAcpActivityRenderBlocksOptions { displayMode?: 'default' | 'narration'; hasLeadingTextBoundary?: boolean; collapseLeadingActivity?: boolean; + keepDelegatedTasksVisible?: boolean; } function isToolMessage( @@ -152,6 +154,7 @@ function isLivePartialBlock(block: AcpRenderBlock): boolean { export function isActivityCollapsibleBlock( block: AcpRenderBlock, artifacts?: readonly TaskArtifact[] | null, + keepDelegatedTasksVisible = false, ): boolean { // Keep in-flight reasoning/tool rows outside default-closed groups so current // activity stays visible without a manual expand. @@ -179,6 +182,14 @@ export function isActivityCollapsibleBlock( return false; } + if ( + keepDelegatedTasksVisible && + isToolMessage(msg) && + getDelegatedTaskDetails(msg) + ) { + return false; + } + return true; } @@ -215,7 +226,11 @@ export function buildAcpActivityRenderBlocks( if ( !hasLeftTextBoundary || - !isActivityCollapsibleBlock(current, options.artifacts) + !isActivityCollapsibleBlock( + current, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { groupedBlocks.push(current); hasLeftTextBoundary = false; @@ -228,7 +243,11 @@ export function buildAcpActivityRenderBlocks( while ( activityEnd < blocks.length && - isActivityCollapsibleBlock(blocks[activityEnd]!, options.artifacts) + isActivityCollapsibleBlock( + blocks[activityEnd]!, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { activityEnd += 1; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts new file mode 100644 index 000000000..65148e00d --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts @@ -0,0 +1,50 @@ +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +export interface DelegatedTaskDetails { + taskId: string; + prompt: string | null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function getDelegatedTaskDetails( + msg: ToolMessage, +): DelegatedTaskDetails | null { + const toolName = (msg.data.toolName ?? msg.data.mcpToolName) + ?.trim() + .toLowerCase(); + + if (msg.kind !== 'tool_result' || toolName !== 'launch_task') { + return null; + } + + try { + const parsed = asRecord(JSON.parse(msg.data.output)); + const result = asRecord(parsed?.result) ?? asRecord(parsed?.data) ?? parsed; + const taskId = result?.taskId; + + if (typeof taskId !== 'string' || taskId.length === 0) { + return null; + } + + const rawInput = asRecord( + (msg.data as unknown as Record).rawInput, + ); + const args = asRecord(rawInput?.arguments); + const prompt = args?.prompt; + + return { + taskId, + prompt: + typeof prompt === 'string' && prompt.trim() ? prompt.trim() : null, + }; + } catch { + return null; + } +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts index 3cedf9ef0..b82defe01 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts @@ -20,6 +20,7 @@ import { isSubagentToolPayload, } from './subagent-tool'; import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; +import { getDelegatedTaskDetails } from './delegated-task'; export type ExplorationStepKind = 'list' | 'read' | 'search'; @@ -113,6 +114,7 @@ interface BuildAcpRenderBlocksOptions { initialPrompt?: Pick | null; shouldHideFirstMessage?: boolean; showInternalMessages?: boolean; + keepDelegatedTasksVisible?: boolean; suppressedMessageIds?: ReadonlySet; } @@ -759,7 +761,10 @@ function resolveMessageRenderState( return { visibility: 'render', - groupKey: resolveToolGroupKey(msg), + groupKey: + options.keepDelegatedTasksVisible && getDelegatedTaskDetails(msg) + ? null + : resolveToolGroupKey(msg), }; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/SidePanelHeader.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/SidePanelHeader.tsx index c536926b4..b6929d1bd 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/SidePanelHeader.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/SidePanelHeader.tsx @@ -68,6 +68,7 @@ export function SidePanelHeader({ variant="ghost" size="icon" className="size-8" + aria-label="Close panel" onClick={onClose} > From 242816a969706f18432118f41f9b3d8573f16cb6 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 26 Aug 2026 13:19:04 +0000 Subject: [PATCH 2/2] chore: keep delegated task details private --- .../app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts index 65148e00d..488e97bc8 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts @@ -2,7 +2,7 @@ import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; -export interface DelegatedTaskDetails { +interface DelegatedTaskDetails { taskId: string; prompt: string | null; }