From b7bd6268e8bf11f6dab0c1cf91c6284827ec8a94 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:01:20 +0000 Subject: [PATCH 1/2] feat(web): show task summaries in session execution details --- .../SessionWorkspace.client.test.tsx | 239 +++++++++++++++++- .../sessions/[sessionId]/SessionWorkspace.tsx | 75 ++++++ 2 files changed, 306 insertions(+), 8 deletions(-) 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 index df1b51ca5..5db8c6851 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -12,13 +12,19 @@ import { SandboxLayoutContext } from '../../use-sandbox-layout'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; import { useOpenSessionTaskPanel } from './session-task-panel-context'; -const { useMediaQueryMock, sessionQueryState, fastTaskQueryState } = vi.hoisted( - () => ({ - useMediaQueryMock: vi.fn(), - sessionQueryState: { data: null as unknown }, - fastTaskQueryState: { data: null as unknown }, - }), -); +const { + useMediaQueryMock, + sessionQueryState, + fastTaskQueryState, + searchParamsRef, + useTaskSummaryMock, +} = vi.hoisted(() => ({ + useMediaQueryMock: vi.fn(), + sessionQueryState: { data: null as unknown }, + fastTaskQueryState: { data: null as unknown }, + searchParamsRef: { current: new URLSearchParams() }, + useTaskSummaryMock: vi.fn(), +})); vi.mock('usehooks-ts', () => ({ useMediaQuery: useMediaQueryMock, @@ -26,7 +32,19 @@ vi.mock('usehooks-ts', () => ({ vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn() }), - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => searchParamsRef.current, +})); + +vi.mock('streamdown', () => ({ + Streamdown: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('@streamdown/code', () => ({ code: () => null })); +vi.mock('@streamdown/mermaid', () => ({ mermaid: () => null })); +vi.mock('@streamdown/cjk', () => ({ cjk: () => null })); + +vi.mock('../../task/[taskId]/hooks/use-task-summary', () => ({ + useTaskSummary: useTaskSummaryMock, })); vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ @@ -126,6 +144,7 @@ function renderWorkspace({ sessionOverride, queriedTasks, queriedFastTasks, + selectedTaskId, }: { isMobile: boolean; children?: ReactNode; @@ -134,6 +153,7 @@ function renderWorkspace({ queriedFastTasks?: Array< Pick >; + selectedTaskId?: string; }) { useMediaQueryMock.mockReturnValue(!isMobile); let viewportChangeListener: ((event: MediaQueryListEvent) => void) | null = @@ -153,6 +173,9 @@ function renderWorkspace({ writable: true, value: vi.fn().mockReturnValue(mediaQuery), }); + searchParamsRef.current = new URLSearchParams( + selectedTaskId ? { task: selectedTaskId } : undefined, + ); const initialSession = { ...session, ...sessionOverride }; sessionQueryState.data = { @@ -194,6 +217,18 @@ function OpenNestedTask() { } describe('SessionWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + useTaskSummaryMock.mockReturnValue({ + enabled: true, + summary: null, + isLoadingSummary: false, + errorMessage: null, + isSummaryStale: false, + regenerateSummary: vi.fn(), + }); + }); + it('matches the task sidebar replacement behavior and controls on mobile', () => { renderWorkspace({ isMobile: true }); @@ -336,4 +371,192 @@ describe('SessionWorkspace', () => { expect(screen.queryByRole('button', { name: 'Session info' })).toBeNull(); expect(screen.getByRole('button', { name: 'Show sidebar' })).toBeVisible(); }); + + it('shows a generated summary in execution details', () => { + useTaskSummaryMock.mockReturnValue({ + enabled: true, + summary: 'The task updated the session sidebar and verified the result.', + isLoadingSummary: false, + errorMessage: null, + isSummaryStale: false, + regenerateSummary: vi.fn(), + }); + + renderWorkspace({ + isMobile: false, + selectedTaskId: 'task-1', + sessionOverride: { tasks: [createSessionTask()] }, + }); + + expect( + screen.getByRole('heading', { name: 'Summary' }), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'The task updated the session sidebar and verified the result.', + ), + ).toBeInTheDocument(); + expect(useTaskSummaryMock).toHaveBeenCalledWith('task-1'); + }); + + it('shows loading and retryable error states for task summaries', () => { + useTaskSummaryMock.mockReturnValue({ + enabled: true, + summary: null, + isLoadingSummary: true, + errorMessage: null, + isSummaryStale: false, + regenerateSummary: vi.fn(), + }); + const task = createSessionTask(); + const { rerender } = renderWorkspace({ + isMobile: false, + selectedTaskId: task.taskId, + sessionOverride: { tasks: [task] }, + }); + + expect( + screen.getByLabelText('Generating task summary'), + ).toBeInTheDocument(); + + const regenerateSummary = vi.fn(); + useTaskSummaryMock.mockReturnValue({ + enabled: true, + summary: null, + isLoadingSummary: false, + errorMessage: + 'Summary is temporarily unavailable. Try again in a moment.', + isSummaryStale: false, + regenerateSummary, + }); + rerender( + + + +
Session transcript
+
+
+
, + ); + + expect( + screen.getByText( + 'Summary is temporarily unavailable. Try again in a moment.', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Try again' }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(regenerateSummary).toHaveBeenCalledOnce(); + }); + + it('allows a stale generated summary to be refreshed', () => { + const regenerateSummary = vi.fn(); + useTaskSummaryMock.mockReturnValue({ + enabled: true, + summary: 'The execution completed its original scope.', + isLoadingSummary: false, + errorMessage: null, + isSummaryStale: true, + regenerateSummary, + }); + + renderWorkspace({ + isMobile: false, + selectedTaskId: 'task-1', + sessionOverride: { tasks: [createSessionTask()] }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Refresh summary' })); + expect(regenerateSummary).toHaveBeenCalledOnce(); + }); + + it('falls back gracefully while an execution has too little activity', () => { + renderWorkspace({ + isMobile: false, + selectedTaskId: 'task-1', + sessionOverride: { tasks: [createSessionTask()] }, + }); + + expect( + screen.getByText('A summary will appear as this execution progresses.'), + ).toBeInTheDocument(); + }); + + it('uses the latest execution output when a generated summary is unavailable', () => { + renderWorkspace({ + isMobile: false, + selectedTaskId: 'task-1', + sessionOverride: { + tasks: [ + createSessionTask({ + latestOutput: 'Implemented the sidebar summary and ran the tests.', + }), + ], + }, + }); + + expect( + screen.getByText('Implemented the sidebar summary and ran the tests.'), + ).toBeInTheDocument(); + }); + + it('shows a terminal empty state when a completed execution has no summary', () => { + useTaskSummaryMock.mockReturnValue({ + enabled: false, + summary: null, + isLoadingSummary: false, + errorMessage: null, + isSummaryStale: false, + regenerateSummary: vi.fn(), + }); + + renderWorkspace({ + isMobile: false, + selectedTaskId: 'task-1', + sessionOverride: { + tasks: [createSessionTask({ state: 'completed' })], + }, + }); + + expect( + screen.getByText('No summary is available for this execution yet.'), + ).toBeInTheDocument(); + }); + + it('does not request a summary for an inaccessible execution', () => { + renderWorkspace({ + isMobile: false, + selectedTaskId: 'task-1', + sessionOverride: { + tasks: [createSessionTask({ canAccessDetails: false })], + }, + }); + + expect( + screen.getByText('Execution details require task access.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Summary' })).toBeNull(); + expect(useTaskSummaryMock).not.toHaveBeenCalled(); + }); }); + +function createSessionTask( + overrides?: Partial, +): SessionInfo['tasks'][number] { + return { + taskId: 'task-1', + title: 'Summarize session execution', + workflow: 'standard', + state: 'active', + repositoryName: 'RooCodeInc/Roomote', + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + ...overrides, + }; +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 3b96ed536..6ad66d519 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { Streamdown } from 'streamdown'; import { useCallback, useEffect, @@ -45,7 +46,9 @@ import { SelectItem, SelectTrigger, SelectValue, + Skeleton, } from '@/components/system'; +import { streamdownCodeMermaidCjkPlugins } from '@/components/ai-elements/streamdown-plugins'; import type { SessionTaskSummary } from './SessionTaskCards'; import { SandboxSidePanelHeader } from '../../SandboxSidePanelHeader'; @@ -65,6 +68,7 @@ import { import { NestedTaskSidePanel } from './NestedTaskSidePanel'; import { OpenSessionTaskPanelContext } from './session-task-panel-context'; import { DelegatedTaskCard } from '../../task/[taskId]/messages/acp/DelegatedTaskCard'; +import { useTaskSummary } from '../../task/[taskId]/hooks/use-task-summary'; export type SessionInfo = { id: string; @@ -83,6 +87,74 @@ export type SessionInfo = { taskCards?: Array>; }; +function SessionTaskSummarySection({ task }: { task: SessionTaskSummary }) { + const { + enabled, + summary, + isLoadingSummary, + errorMessage, + isSummaryStale, + regenerateSummary, + } = useTaskSummary(task.taskId); + const fallbackSummary = task.latestOutput?.trim(); + + return ( +
+

Summary

+ {isLoadingSummary ? ( +
+ + + +
+ ) : summary ? ( +
+ {isSummaryStale ? ( +

+ New activity is not included yet.{' '} + +

+ ) : null} +
+ + {summary} + +
+
+ ) : errorMessage ? ( +
+

{errorMessage}

+ +
+ ) : fallbackSummary ? ( +

+ {fallbackSummary} +

+ ) : ( +

+ {enabled || task.state === 'active' + ? 'A summary will appear as this execution progresses.' + : 'No summary is available for this execution yet.'} +

+ )} +
+ ); +} + function SessionTaskPanel({ sessionId, task, @@ -135,6 +207,9 @@ function SessionTaskPanel({

) : null} + {task.canAccessDetails === false ? null : ( + + )} {task.canAccessDetails === false ? (

Execution details require task access. From 0750fe30a47db737cef6d91025d6a58f675c0b72 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:23:17 +0000 Subject: [PATCH 2/2] fix(web): show summaries for Fast-only session tasks --- .../SessionWorkspace.client.test.tsx | 61 ++++++++ .../sessions/[sessionId]/SessionWorkspace.tsx | 140 +++++++++++++++++- 2 files changed, 196 insertions(+), 5 deletions(-) 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 index 5db8c6851..ce8dfba10 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -18,12 +18,14 @@ const { fastTaskQueryState, searchParamsRef, useTaskSummaryMock, + useTaskMock, } = vi.hoisted(() => ({ useMediaQueryMock: vi.fn(), sessionQueryState: { data: null as unknown }, fastTaskQueryState: { data: null as unknown }, searchParamsRef: { current: new URLSearchParams() }, useTaskSummaryMock: vi.fn(), + useTaskMock: vi.fn(), })); vi.mock('usehooks-ts', () => ({ @@ -47,6 +49,10 @@ vi.mock('../../task/[taskId]/hooks/use-task-summary', () => ({ useTaskSummary: useTaskSummaryMock, })); +vi.mock('@/hooks/tasks/useTask', () => ({ + useTask: useTaskMock, +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { models: [{ id: 'model-1', displayName: 'Model One' }] }, @@ -227,6 +233,11 @@ describe('SessionWorkspace', () => { isSummaryStale: false, regenerateSummary: vi.fn(), }); + useTaskMock.mockReturnValue({ + data: null, + isPending: false, + isError: false, + }); }); it('matches the task sidebar replacement behavior and controls on mobile', () => { @@ -399,6 +410,56 @@ describe('SessionWorkspace', () => { expect(useTaskSummaryMock).toHaveBeenCalledWith('task-1'); }); + it('shows execution details for a selected Fast-only session task', () => { + useTaskSummaryMock.mockReturnValue({ + enabled: true, + summary: 'The Fast task completed its delegated work.', + isLoadingSummary: false, + errorMessage: null, + isSummaryStale: false, + regenerateSummary: vi.fn(), + }); + useTaskMock.mockReturnValue({ + data: { + id: 'task-1', + title: 'Fast delegated task', + workflow: 'standard', + state: 'completed', + repositoryName: 'RooCodeInc/Roomote', + taskRun: { + id: 1, + status: 'completed', + taskPhase: null, + error: null, + result: null, + pullRequests: [], + }, + artifacts: [], + inferenceUsage: { eventCount: 1, costMicroUsd: 100 }, + }, + isPending: false, + isError: false, + }); + + renderWorkspace({ + isMobile: false, + selectedTaskId: 'task-1', + sessionOverride: { + taskSource: 'fast', + tasks: [], + taskCards: [{ taskId: 'task-1', title: 'Fast delegated task' }], + }, + }); + + expect( + screen.getByText('The Fast task completed its delegated work.'), + ).toBeInTheDocument(); + expect(useTaskSummaryMock).toHaveBeenCalledWith('task-1'); + expect(useTaskMock).toHaveBeenCalledWith('task-1', true, { + refetchInterval: 2_000, + }); + }); + it('shows loading and retryable error states for task summaries', () => { useTaskSummaryMock.mockReturnValue({ enabled: true, diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 6ad66d519..6c4ebc4ee 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -24,6 +24,7 @@ import { getSessionSurfaceLabel, } from '@/components/sessions/session-surfaces'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; +import { useTask } from '@/hooks/tasks/useTask'; import { useTRPC } from '@/trpc/client'; import { FramedSurface, WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; @@ -36,6 +37,7 @@ import { Button, Calendar, DollarSign, + ErrorState, Globe, Info, Slack, @@ -164,7 +166,7 @@ function SessionTaskPanel({ }: { sessionId: string; task: SessionTaskSummary; - tasks: SessionTaskSummary[]; + tasks: Array>; onSelect: (taskId: string) => void; onClose: () => void; }) { @@ -264,6 +266,118 @@ function SessionTaskPanel({ ); } +function getLatestTaskOutput(result: unknown) { + if (!result || typeof result !== 'object') return null; + + return ( + String( + (result as Record).summary ?? + (result as Record).message ?? + '', + ) + .trim() + .slice(0, 240) || null + ); +} + +function FastSessionTaskPanel({ + sessionId, + taskId, + tasks, + onSelect, + onClose, +}: { + sessionId: string; + taskId: string; + tasks: Array>; + onSelect: (taskId: string) => void; + onClose: () => void; +}) { + const taskQuery = useTask(taskId, true, { refetchInterval: 2_000 }); + const task = taskQuery.data; + + if (taskQuery.isPending) { + return ( + <> + +

+ + + +
+ + ); + } + + if (taskQuery.isError || !task) { + return ( + <> + + + + ); + } + + const taskRun = task.taskRun; + const pullRequests = (taskRun?.pullRequests ?? []).flatMap((pullRequest) => + pullRequest.prUrl + ? [ + { + id: pullRequest.prUrl, + url: pullRequest.prUrl, + number: pullRequest.prNumber, + title: null, + repository: pullRequest.repository, + status: null, + }, + ] + : [], + ); + const taskSummary: SessionTaskSummary = { + taskId: task.id, + title: task.title, + workflow: task.workflow ?? 'standard', + state: task.state, + repositoryName: task.repositoryName ?? null, + latestOutput: getLatestTaskOutput(taskRun?.result), + inferenceCostMicroUsd: task.inferenceUsage?.costMicroUsd ?? 0, + canAccessDetails: true, + latestRun: taskRun + ? { + id: taskRun.id, + status: taskRun.status, + taskPhase: taskRun.taskPhase, + error: taskRun.error, + result: taskRun.result, + } + : null, + artifacts: (task.artifacts ?? []).map(({ id, path, artifactType }) => ({ + id, + path, + artifactType, + })), + pullRequests, + }; + + return ( + + ); +} + function SessionTasksPanel({ tasks, onOpenTask, @@ -451,7 +565,11 @@ export function SessionWorkspace({ const selectedTask = sessionTasks.find( (task) => task.taskId === selectedTaskId, ); - const panelOpen = panel !== null || Boolean(selectedTask); + const selectedFastTask = isFastTaskSource + ? taskCards.find((task) => task.taskId === selectedTaskId) + : undefined; + const panelOpen = + panel !== null || Boolean(selectedTask) || Boolean(selectedFastTask); const selectTask = useCallback( (taskId: string | null) => { @@ -498,12 +616,20 @@ export function SessionWorkspace({ onSelect={selectTask} onClose={closePanel} /> + ) : selectedFastTask ? ( + ) : panel?.kind === 'nested' ? ( ) : panel?.kind === 'tasks' ? ( ) : ( @@ -523,7 +649,9 @@ export function SessionWorkspace({ side="right" label="Session info" tooltip="Session info" - active={panel?.kind === 'info' && !selectedTask} + active={ + panel?.kind === 'info' && !selectedTask && !selectedFastTask + } icon={Info} onClick={() => togglePanel('info')} /> @@ -531,7 +659,9 @@ export function SessionWorkspace({ side="right" label="Tasks" tooltip="Tasks" - active={panel?.kind === 'tasks' && !selectedTask} + active={ + panel?.kind === 'tasks' && !selectedTask && !selectedFastTask + } disabled={taskCards.length === 0} icon={Rows4} onClick={() => togglePanel('tasks')}