>(
@@ -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;
+ }) => (
+
+ ),
+}));
+
+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 }) => ,
+}));
+
+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..488e97bc8
--- /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;
+
+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}
>