diff --git a/apps/web/package.json b/apps/web/package.json index 8c94d02bed..e2bc94acb7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "test:server": "vitest run --project server", "test:client": "vitest run --project client", "automation:icons": "node ./scripts/generate-automation-icons.mjs", + "task-robot:icons": "node ./scripts/generate-task-robot-icons.mjs", "docs:assets": "node ./scripts/sync-docs-assets.mjs", "dev:prepare": "rimraf .next && pnpm docs:assets && pnpm automation:icons", "dev": "pnpm docs:assets && pnpm automation:icons && dotenvx run -f ../../.env.local -- next dev --turbopack", diff --git a/apps/web/public/task-robots/robot-001.png b/apps/web/public/task-robots/robot-001.png new file mode 100644 index 0000000000..5d32f19acc Binary files /dev/null and b/apps/web/public/task-robots/robot-001.png differ diff --git a/apps/web/public/task-robots/robot-002.png b/apps/web/public/task-robots/robot-002.png new file mode 100644 index 0000000000..accc81d2a8 Binary files /dev/null and b/apps/web/public/task-robots/robot-002.png differ diff --git a/apps/web/public/task-robots/robot-003.png b/apps/web/public/task-robots/robot-003.png new file mode 100644 index 0000000000..c0e95c9152 Binary files /dev/null and b/apps/web/public/task-robots/robot-003.png differ diff --git a/apps/web/public/task-robots/robot-004.png b/apps/web/public/task-robots/robot-004.png new file mode 100644 index 0000000000..f2e9241f64 Binary files /dev/null and b/apps/web/public/task-robots/robot-004.png differ diff --git a/apps/web/public/task-robots/robot-005.png b/apps/web/public/task-robots/robot-005.png new file mode 100644 index 0000000000..7f88cd8bbc Binary files /dev/null and b/apps/web/public/task-robots/robot-005.png differ diff --git a/apps/web/public/task-robots/robot-006.png b/apps/web/public/task-robots/robot-006.png new file mode 100644 index 0000000000..5b7741a4af Binary files /dev/null and b/apps/web/public/task-robots/robot-006.png differ diff --git a/apps/web/public/task-robots/robot-007.png b/apps/web/public/task-robots/robot-007.png new file mode 100644 index 0000000000..3b74d2609a Binary files /dev/null and b/apps/web/public/task-robots/robot-007.png differ diff --git a/apps/web/public/task-robots/robot-008.png b/apps/web/public/task-robots/robot-008.png new file mode 100644 index 0000000000..065e3c26e7 Binary files /dev/null and b/apps/web/public/task-robots/robot-008.png differ diff --git a/apps/web/public/task-robots/robot-009.png b/apps/web/public/task-robots/robot-009.png new file mode 100644 index 0000000000..ed1bde2675 Binary files /dev/null and b/apps/web/public/task-robots/robot-009.png differ diff --git a/apps/web/public/task-robots/robot-010.png b/apps/web/public/task-robots/robot-010.png new file mode 100644 index 0000000000..9eecabe798 Binary files /dev/null and b/apps/web/public/task-robots/robot-010.png differ diff --git a/apps/web/public/task-robots/robot-011.png b/apps/web/public/task-robots/robot-011.png new file mode 100644 index 0000000000..ab29ac2ce2 Binary files /dev/null and b/apps/web/public/task-robots/robot-011.png differ diff --git a/apps/web/public/task-robots/robot-012.png b/apps/web/public/task-robots/robot-012.png new file mode 100644 index 0000000000..3ddde33f8a Binary files /dev/null and b/apps/web/public/task-robots/robot-012.png differ diff --git a/apps/web/public/task-robots/robot-013.png b/apps/web/public/task-robots/robot-013.png new file mode 100644 index 0000000000..ea146522c1 Binary files /dev/null and b/apps/web/public/task-robots/robot-013.png differ diff --git a/apps/web/public/task-robots/robot-014.png b/apps/web/public/task-robots/robot-014.png new file mode 100644 index 0000000000..1d26c45587 Binary files /dev/null and b/apps/web/public/task-robots/robot-014.png differ diff --git a/apps/web/public/task-robots/robot-015.png b/apps/web/public/task-robots/robot-015.png new file mode 100644 index 0000000000..cccb236a2b Binary files /dev/null and b/apps/web/public/task-robots/robot-015.png differ diff --git a/apps/web/public/task-robots/robot-016.png b/apps/web/public/task-robots/robot-016.png new file mode 100644 index 0000000000..761c8a65dd Binary files /dev/null and b/apps/web/public/task-robots/robot-016.png differ diff --git a/apps/web/public/task-robots/robot-017.png b/apps/web/public/task-robots/robot-017.png new file mode 100644 index 0000000000..05c93d9784 Binary files /dev/null and b/apps/web/public/task-robots/robot-017.png differ diff --git a/apps/web/public/task-robots/robot-018.png b/apps/web/public/task-robots/robot-018.png new file mode 100644 index 0000000000..549211fbf2 Binary files /dev/null and b/apps/web/public/task-robots/robot-018.png differ diff --git a/apps/web/public/task-robots/robot-019.png b/apps/web/public/task-robots/robot-019.png new file mode 100644 index 0000000000..0f49a7fb48 Binary files /dev/null and b/apps/web/public/task-robots/robot-019.png differ diff --git a/apps/web/public/task-robots/robot-020.png b/apps/web/public/task-robots/robot-020.png new file mode 100644 index 0000000000..4b4769daff Binary files /dev/null and b/apps/web/public/task-robots/robot-020.png differ diff --git a/apps/web/public/task-robots/robot-021.png b/apps/web/public/task-robots/robot-021.png new file mode 100644 index 0000000000..6c186c4ddf Binary files /dev/null and b/apps/web/public/task-robots/robot-021.png differ diff --git a/apps/web/public/task-robots/robot-022.png b/apps/web/public/task-robots/robot-022.png new file mode 100644 index 0000000000..b1e57c3690 Binary files /dev/null and b/apps/web/public/task-robots/robot-022.png differ diff --git a/apps/web/public/task-robots/robot-023.png b/apps/web/public/task-robots/robot-023.png new file mode 100644 index 0000000000..4540b12876 Binary files /dev/null and b/apps/web/public/task-robots/robot-023.png differ diff --git a/apps/web/public/task-robots/robot-024.png b/apps/web/public/task-robots/robot-024.png new file mode 100644 index 0000000000..3ac067d17a Binary files /dev/null and b/apps/web/public/task-robots/robot-024.png differ diff --git a/apps/web/public/task-robots/robot-025.png b/apps/web/public/task-robots/robot-025.png new file mode 100644 index 0000000000..c8e5a89737 Binary files /dev/null and b/apps/web/public/task-robots/robot-025.png differ diff --git a/apps/web/scripts/generate-task-robot-icons.mjs b/apps/web/scripts/generate-task-robot-icons.mjs new file mode 100644 index 0000000000..1cdeb59678 --- /dev/null +++ b/apps/web/scripts/generate-task-robot-icons.mjs @@ -0,0 +1,161 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import sharp from 'sharp'; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const SOURCE_PATH = join(SCRIPT_DIRECTORY, 'task-robot-icons.source.png'); +const OUTPUT_DIRECTORY = join(SCRIPT_DIRECTORY, '..', 'public', 'task-robots'); +const SOURCE_GRID_SIZE = 5; +const ICON_COUNT = 25; +const OUTPUT_SIZE = 96; +const DRAWING_SIZE = 70; +const DRAWING_PADDING = 2; +const BACKGROUND = { r: 213, g: 237, b: 65 }; +const FOREGROUND_DISTANCE_SQUARED = 50 ** 2; + +async function findDrawingMetrics(image, name) { + const { data: pixels, info } = await sharp(image) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let left = info.width; + let top = info.height; + let right = -1; + let bottom = -1; + let horizontalMass = 0; + let verticalMass = 0; + let foregroundPixels = 0; + + for (let y = 0; y < info.height; y += 1) { + for (let x = 0; x < info.width; x += 1) { + const offset = (y * info.width + x) * info.channels; + const red = pixels[offset] - BACKGROUND.r; + const green = pixels[offset + 1] - BACKGROUND.g; + const blue = pixels[offset + 2] - BACKGROUND.b; + if ( + red * red + green * green + blue * blue <= + FOREGROUND_DISTANCE_SQUARED + ) { + continue; + } + + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + horizontalMass += x; + verticalMass += y; + foregroundPixels += 1; + } + } + + if (right < 0) throw new Error(`${name} contains no drawing.`); + return { + left, + top, + right, + bottom, + imageWidth: info.width, + imageHeight: info.height, + centerX: horizontalMass / foregroundPixels, + centerY: verticalMass / foregroundPixels, + }; +} + +function paddedRectangle(bounds, padding) { + const left = Math.max(0, bounds.left - padding); + const top = Math.max(0, bounds.top - padding); + const right = Math.min(bounds.imageWidth - 1, bounds.right + padding); + const bottom = Math.min(bounds.imageHeight - 1, bounds.bottom + padding); + return { left, top, width: right - left + 1, height: bottom - top + 1 }; +} + +const source = sharp(SOURCE_PATH); +const metadata = await source.metadata(); +if (!metadata.width || !metadata.height || metadata.width !== metadata.height) { + throw new Error('Task robot source must be a square image.'); +} + +await rm(OUTPUT_DIRECTORY, { recursive: true, force: true }); +await mkdir(OUTPUT_DIRECTORY, { recursive: true }); + +for (let index = 0; index < ICON_COUNT; index += 1) { + const row = Math.floor(index / SOURCE_GRID_SIZE); + const column = index % SOURCE_GRID_SIZE; + const left = Math.round((column * metadata.width) / SOURCE_GRID_SIZE); + const top = Math.round((row * metadata.height) / SOURCE_GRID_SIZE); + const right = Math.round(((column + 1) * metadata.width) / SOURCE_GRID_SIZE); + const bottom = Math.round(((row + 1) * metadata.height) / SOURCE_GRID_SIZE); + const name = `robot-${String(index + 1).padStart(3, '0')}.png`; + const cell = await sharp(SOURCE_PATH) + .extract({ left, top, width: right - left, height: bottom - top }) + .png() + .toBuffer(); + const sourceBounds = await findDrawingMetrics(cell, name); + + const { data: drawing, info } = await sharp(cell) + .extract(paddedRectangle(sourceBounds, DRAWING_PADDING)) + .resize(DRAWING_SIZE, DRAWING_SIZE, { fit: 'inside' }) + .png({ compressionLevel: 9 }) + .toBuffer({ resolveWithObject: true }); + const outputLeft = Math.floor((OUTPUT_SIZE - info.width) / 2); + const outputTop = Math.floor((OUTPUT_SIZE - info.height) / 2); + + const initialIcon = await sharp({ + create: { + width: OUTPUT_SIZE, + height: OUTPUT_SIZE, + channels: 3, + background: BACKGROUND, + }, + }) + .composite([{ input: drawing, left: outputLeft, top: outputTop }]) + .png({ compressionLevel: 9 }) + .toBuffer(); + const finalBounds = await findDrawingMetrics(initialIcon, name); + const finalRectangle = paddedRectangle(finalBounds, DRAWING_PADDING); + const finalDrawing = await sharp(initialIcon) + .extract(finalRectangle) + .png() + .toBuffer(); + + await sharp({ + create: { + width: OUTPUT_SIZE, + height: OUTPUT_SIZE, + channels: 3, + background: BACKGROUND, + }, + }) + .composite([ + { + input: finalDrawing, + left: Math.max( + 0, + Math.min( + OUTPUT_SIZE - finalRectangle.width, + Math.round( + (OUTPUT_SIZE - 1) / 2 - + (finalBounds.centerX - finalRectangle.left), + ), + ), + ), + top: Math.max( + 0, + Math.min( + OUTPUT_SIZE - finalRectangle.height, + Math.round( + (OUTPUT_SIZE - 1) / 2 - + (finalBounds.centerY - finalRectangle.top), + ), + ), + ), + }, + ]) + .png({ compressionLevel: 9, palette: true, colours: 256, dither: 0 }) + .toFile(join(OUTPUT_DIRECTORY, name)); +} + +console.log(`Generated ${ICON_COUNT} task robot icons in ${OUTPUT_DIRECTORY}`); diff --git a/apps/web/scripts/task-robot-icons.source.png b/apps/web/scripts/task-robot-icons.source.png new file mode 100644 index 0000000000..cb1b35a988 Binary files /dev/null and b/apps/web/scripts/task-robot-icons.source.png differ diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 245ee5907e..04e2f8962b 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -17,6 +17,7 @@ import { getTextFromContentBlocks, inferAcpMessageKind, parsePrReviewActionOffer, + getTaskModelDisplayName, type AcpMessage, type PrReviewActionChoice, type AcpEventType, @@ -68,6 +69,7 @@ import { AcpTranscriptBlockList, useAcpTranscriptBlocks, } from '../../task/[taskId]/messages/acp'; +import { ModelBadge } from '@/components/sandbox'; import { AcpProtocolService, toAcpUiMessage, @@ -308,6 +310,7 @@ export function FastSessionTranscript({ const taskStateRevision = useSessionTaskStateRevision(); const { enabled: narrationModeEnabled } = useNarrationMode(); const displayMode = narrationModeEnabled ? 'narration' : 'default'; + const effectiveSessionModel = sessionModel ?? defaultModelId; const slackMentionScope = useMemo( () => ({ kind: 'session', sessionId }), [sessionId], @@ -750,13 +753,25 @@ export function FastSessionTranscript({ >

{title ?? fallbackTitle}

- {headerExtras} + {(effectiveSessionModel || headerExtras) && ( +
+ {effectiveSessionModel ? ( + + ) : null} + {headerExtras} +
+ )}
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 index 9e0f9cbbd8..d42afeb2eb 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx @@ -214,6 +214,9 @@ describe('NestedTaskSidePanel', () => { expect(screen.getByText('Task:')).toHaveClass('font-semibold'); expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect( + document.querySelector('[data-task-robot-icon]'), + ).toBeInTheDocument(); expect(screen.getByText('Workspace env-1')).toBeInTheDocument(); expect(screen.getByTestId('live-provider')).toBeInTheDocument(); expect(screen.getByText('Child transcript')).toBeInTheDocument(); @@ -264,7 +267,7 @@ describe('NestedTaskSidePanel', () => { ); const trigger = screen.getByRole('button', { - name: /Task:.*Fix checkout/, + name: 'Fix checkout', }); fireEvent.keyDown(trigger, { key: 'Enter' }); expect( diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx index cfff5c8b87..46ad15871a 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx @@ -18,8 +18,12 @@ import { ExternalLink, Skeleton, } from '@/components/system'; -import { WorkspaceBadge } from '@/components/sandbox'; import { FramedSurface } from '@/components/layout'; +import { + TaskHeaderContent, + TaskHeaderMetadata, + TaskTitle, +} from '../../task/[taskId]/TaskHeader'; import { ArtifactLinkProvider } from '../../task/[taskId]/hooks/ArtifactLinkProvider'; import { HistoricalSandboxProvider } from '../../task/[taskId]/hooks/HistoricalSandboxProvider'; @@ -287,6 +291,10 @@ export function NestedTaskSidePanel({ const title = session.task?.title?.trim() || 'Task'; const environmentId = session.taskRun?.payload?.environmentId; const repo = session.taskRun?.payload?.repo; + const model = session.task?.model ?? null; + const pullRequests = session.taskRun?.pullRequests ?? []; + const prRepo = session.taskRun?.prRepo; + const prNumber = session.taskRun?.prNumber; return ( - {environmentId || repo ? ( - - ) : null} - - - Tasks in this session - {tasks.map((task) => ( - onSelectTask?.(task.taskId)} + + {tasks.length > 1 ? ( + + + + + + Tasks in this session + {tasks.map((task) => ( + onSelectTask?.(task.taskId)} + > + + {task.taskId === taskId ? ( + + • + + ) : null} + + ))} + + + ) : ( +

+ +

+ )} + +
} />
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index 52f10e8160..2d518d0661 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -88,6 +88,7 @@ import { type SessionArtifactViewerSelection, } from './session-task-panel-context'; import { DelegatedTaskCard } from '../../task/[taskId]/messages/acp/DelegatedTaskCard'; +import { TaskRobotIconProvider } from '@/components/tasks/TaskRobotIcon'; import { useArtifactByPath } from '../../task/[taskId]/hooks/use-artifact-by-path'; import { PreviewPaneProvider } from '../../task/[taskId]/hooks/use-preview-pane'; import { humanizePortName } from '../../task/[taskId]/preview-port-utils'; @@ -1051,39 +1052,45 @@ export function SessionWorkspace({ className="flex min-h-0 min-w-0 flex-1" onKeyDownCapture={handlePromptFocusNavigation} > - !taskArtifacts[taskId]) - .map((taskId) => `task:${taskId}`), - ]} - mainMinSize={mainMinSize} - panelMinSize={panelMinSize} - main={ - - + !taskArtifacts[taskId]) + .map((taskId) => `task:${taskId}`), + ]} + mainMinSize={mainMinSize} + panelMinSize={panelMinSize} + main={ + - - - {children} - - - - - } - panel={primaryPanel?.content ?? utilityPanelContent} - panelId={primaryPanel?.id} - additionalPanels={renderedPanels.slice(1)} - /> + + {children} + + + + + } + panel={primaryPanel?.content ?? utilityPanelContent} + panelId={primaryPanel?.id} + additionalPanels={renderedPanels.slice(1)} + /> +
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx index 4400180ed7..93c0db5455 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx @@ -117,6 +117,18 @@ describe('Header', () => { enabled: options?.enabled, }), }, + byId: { + queryOptions: ( + _input: { sessionId: string }, + options?: { enabled?: boolean }, + ) => ({ + queryKey: ['sessions.byId'], + queryFn: async () => ({ + tasks: [{ taskId: 'task-123' }, { taskId: 'task-456' }], + }), + enabled: options?.enabled, + }), + }, }, tasks: { updateTitle: { @@ -138,6 +150,9 @@ describe('Header', () => { renderHeader(); expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); + expect( + document.querySelector('[data-task-robot-icon]'), + ).toBeInTheDocument(); }); it('keeps the header clean when the task uses OpenCode', () => { diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 9029c83409..49497812ed 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -16,8 +16,8 @@ import { DialogTitle, Input, } from '@/components/system'; -import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; +import { TaskHeaderContent, TaskHeaderMetadata, TaskTitle } from './TaskHeader'; import { useTRPC } from '@/trpc/client'; import { useSandboxLayout } from '../../use-sandbox-layout'; @@ -38,49 +38,27 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { data: parentSession } = useQuery( trpc.sessions.forTask.queryOptions({ taskId }), ); + const iconSessionId = + parentSession?.sessionId ?? taskRun?.payload?.fastAgentSessionId ?? null; + const { data: iconSession } = useQuery( + trpc.sessions.byId.queryOptions( + { sessionId: iconSessionId ?? '' }, + { enabled: Boolean(iconSessionId) }, + ), + ); const environmentId = taskRun?.payload?.environmentId; const repo = taskRun?.payload?.repo; const prRepo = taskRun?.prRepo; const prNumber = taskRun?.prNumber; const pullRequests = taskRun?.pullRequests ?? []; + const model = task?.model ?? null; const sessionHref = parentSession ? `/sessions/${parentSession.sessionId}?task=${taskId}` : taskRun?.payload?.fastAgentSessionId ? `/sessions/${taskRun.payload.fastAgentSessionId}` : null; - const badges = [ - (environmentId || repo) && ( - - ), - ...(pullRequests.length > 0 - ? pullRequests.map((pullRequest) => ( - - )) - : prRepo && prNumber - ? [ - , - ] - : []), - ].filter(Boolean); - const updateTaskTitle = useMutation(trpc.tasks.updateTitle.mutationOptions()); useEffect(() => { @@ -167,15 +145,15 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { {parentSession ? ( ) : null} - -
+ +
{sessionHref ? ( ) : null} -

item.taskId)} + showIcon={false} > - {title} -

+

+ item.taskId)} + /> +

+ +
- {badges.length > 0 && ( -
- {badges.map((badge, index) => ( - - {badge} - - ))} -
- )} {!isSidebarVisible && ( + ) : ( + props.iconElement + )} + {props.action} +
+ ); }, ToolContent: ({ children }: { children?: ReactNode }) => (
{children}
@@ -260,6 +276,22 @@ describe('AcpToolMessage', () => { ); }); + it('renders requests for user input as human guidance with the List icon', () => { + render( + , + ); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'Asked for', + object: 'human guidance', + icon: List, + }), + ); + }); + it('renders subagent launches as compact expandable rows when a prompt is available', () => { render( { ); }); + it('shows the referenced task robot for task-message activity', () => { + const msg = buildResultMessage('task', { + toolName: 'send_task_message', + output: JSON.stringify({ success: true, taskId: 'child-42' }), + }); + ( + msg.data as AcpToolResultUiMessage['data'] & { + rawInput: Record; + } + ).rawInput = { + arguments: { taskId: 'child-42', message: 'Check the tests' }, + }; + + render(); + + expect( + document.querySelector('[data-task-robot-icon]'), + ).toBeInTheDocument(); + }); + + it('opens and focuses a task from its Roomote MCP activity icon', () => { + const openTask = vi.fn(); + const msg = buildMessage('mcp', { + isMcp: true, + mcpServerName: 'roomote', + mcpToolName: 'manage_tasks', + toolName: 'manage_tasks', + rawInput: { + arguments: { + action: 'get_messages', + taskId: 'child-42', + }, + }, + } as never); + + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Focus task prompt' })); + expect(openTask).toHaveBeenCalledWith('child-42'); + }); + + it('keeps the normal icon for Session-targeted Roomote MCP activity', () => { + const msg = buildMessage('mcp', { + isMcp: true, + mcpServerName: 'roomote', + mcpToolName: 'manage_tasks', + toolName: 'manage_tasks', + rawInput: { + arguments: { + action: 'send_message', + sessionId: 'session-1', + }, + }, + } as never); + + render( + + + , + ); + + expect( + document.querySelector('[data-task-robot-icon]'), + ).not.toBeInTheDocument(); + }); + + it('does not replace generic tool icons with task robots', () => { + render(); + + expect( + document.querySelector('[data-task-robot-icon]'), + ).not.toBeInTheDocument(); + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ icon: SquarePen, iconElement: undefined }), + ); + }); + it('hides expanded details for read tool calls', () => { render(); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts index d90891d9d0..689a36f508 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts @@ -74,6 +74,7 @@ describe('tool presentation resolver', () => { ['list_chat_channels', 'messages'], ['get_chat_channel_messages', 'messages'], ['get_chat_message_context', 'messages'], + ['request_user_input', 'list'], ] as const)('uses the %s icon for %s', (toolName, iconKey) => { expect(resolveToolPresentation(toolData({ toolName }))).toMatchObject({ iconKey, @@ -138,6 +139,31 @@ describe('tool presentation resolver', () => { ).toMatchObject({ verb: 'Sent', object: 'message to session' }); }); + it('humanizes summaries received from a child session as hearing back from a task', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + serverName: 'roomote', + toolName: 'manage_tasks', + rawInput: { + arguments: { action: 'get_summary', sessionId: 'session-1' }, + }, + } as never), + ), + ).toMatchObject({ verb: 'Heard back from', object: 'task' }); + }); + + it('humanizes requests for user input', () => { + expect( + resolveToolPresentation(toolData({ toolName: 'request_user_input' })), + ).toMatchObject({ + verb: 'Asked for', + object: 'human guidance', + iconKey: 'list', + }); + }); + it('describes saved memories with an optional subject', () => { expect( resolveToolPresentation( @@ -176,7 +202,7 @@ describe('tool presentation resolver', () => { it.each([ ['start', 'Started', 'session'], ['search', 'Searched', 'sessions'], - ['get_summary', 'Received', 'summary from task'], + ['get_summary', 'Heard back from', 'task'], ['get_messages', 'Received', 'message from task'], ['send_message', 'Sent', 'message to task'], ['search_tasks', 'Searched', 'tasks'], diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts index c688fa4ed5..35a7be4334 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts @@ -11,6 +11,7 @@ import { GalleryVerticalEnd, GitPullRequest, HardDriveUpload, + List, ListChecks, MessageSquareText, MessagesSquare, @@ -43,6 +44,7 @@ export function toolIconForKey(key: ToolIconKey): LucideIcon { if (key === 'roomote') return RoomoteR; if (key === 'video') return Video; if (key === 'target') return Target; + if (key === 'list') return List; if (key === 'list-checks') return ListChecks; if (key === 'pull-request') return GitPullRequest; if (key === 'environment') return VectorSquare; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts index 45b7fa216d..3a9aa9b00a 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -37,6 +37,7 @@ export type ToolIconKey = | 'roomote' | 'video' | 'target' + | 'list' | 'list-checks' | 'pull-request' | 'environment' @@ -109,6 +110,7 @@ const TOOL_ICON_OVERRIDES: Readonly>> = { manage_wakeups: 'task', get_about_me: 'roomote', describe_video: 'video', + request_user_input: 'list', manage_goal: 'target', manage_tasks: 'list-checks', manage_source_control: 'pull-request', @@ -346,6 +348,11 @@ function resolveReceiptLanguage( verb: byPhase('Sending', 'Sent', 'Failed to Send'), object: 'chat reply', }; + if (toolName === 'request_user_input') + return { + verb: byPhase('Asking for', 'Asked for', 'Failed to Ask for'), + object: 'human guidance', + }; if (toolName === 'report_to_parent_session') return { verb: byPhase('Sending', 'Sent', 'Failed to Send'), @@ -538,8 +545,12 @@ function manageTasksReceipt( object: 'sessions', }, get_summary: { - verb: byPhase('Getting', 'Received', 'Failed to Get'), - object: `summary from ${target}`, + verb: byPhase( + 'Waiting to hear from', + 'Heard back from', + 'Failed to hear from', + ), + object: 'task', }, get_messages: { verb: byPhase('Getting', 'Received', 'Failed to Get'), diff --git a/apps/web/src/components/ai-elements/reasoning.tsx b/apps/web/src/components/ai-elements/reasoning.tsx index 81ba61d4e8..0ad1484888 100644 --- a/apps/web/src/components/ai-elements/reasoning.tsx +++ b/apps/web/src/components/ai-elements/reasoning.tsx @@ -132,7 +132,7 @@ const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { return Thinking...; } if (duration === undefined) { - return Thought; + return Thought for a bit; } return Thought for {duration}s; }; diff --git a/apps/web/src/components/ai-elements/tool.client.test.tsx b/apps/web/src/components/ai-elements/tool.client.test.tsx index fb84b5c3d6..4a6ed541b1 100644 --- a/apps/web/src/components/ai-elements/tool.client.test.tsx +++ b/apps/web/src/components/ai-elements/tool.client.test.tsx @@ -14,6 +14,22 @@ vi.mock('@/components/system', async (importOriginal) => { }); describe('ToolHeader', () => { + it('keeps running feedback beside a custom task icon', () => { + render( + } + state="input-available" + collapsible={false} + />, + ); + + expect(screen.getByTestId('task-icon')).toBeInTheDocument(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + it('shows running and failed states textually while success stays implied', () => { const { rerender } = render( { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + it('keeps a custom icon action separate from the expansion trigger', () => { + const onIconClick = vi.fn(); + render( + + } + iconAction={{ label: 'Focus task prompt', onClick: onIconClick }} + state="output-available" + /> + , + ); + + const iconButton = screen.getByRole('button', { + name: 'Focus task prompt', + }); + const expansionTrigger = screen.getByRole('button', { + name: 'Sent message to task Completed', + }); + + fireEvent.click(iconButton); + expect(onIconClick).toHaveBeenCalledOnce(); + expect(expansionTrigger).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(expansionTrigger); + expect(expansionTrigger).toHaveAttribute('aria-expanded', 'true'); + }); + it('keeps long action-only labels truncatable inside the header row', () => { const action = 'Read /tmp/roomote-tool-header-regression-path-with-a-very-long-file-name.txt'; diff --git a/apps/web/src/components/ai-elements/tool.tsx b/apps/web/src/components/ai-elements/tool.tsx index 7cea5ed7f1..123e124c0a 100644 --- a/apps/web/src/components/ai-elements/tool.tsx +++ b/apps/web/src/components/ai-elements/tool.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ComponentProps } from 'react'; +import { type ComponentProps, type ReactNode } from 'react'; import type { AcpToolCallPayload, AcpToolResultPayload } from '@roomote/types'; @@ -48,6 +48,11 @@ type ToolHeaderProps = { suffix?: string; suffixPrefix?: string; icon: LucideIcon; + iconElement?: ReactNode; + iconAction?: { + label: string; + onClick: () => void; + }; state: ToolState; params?: AcpToolCallPayload | AcpToolResultPayload; additions?: number; @@ -62,6 +67,8 @@ export const ToolHeader = ({ suffix, suffixPrefix = 'from', icon: ActionIcon, + iconElement, + iconAction, state, params: _params, additions, @@ -81,21 +88,26 @@ export const ToolHeader = ({ state === 'output-error'; const isRunning = state === 'input-streaming' || state === 'input-available'; - const inner = ( -
- {isRunning ? ( - - ) : collapsible ? ( - + const customIcon = iconElement ? ( + + {iconAction ? ( + ) : ( - + iconElement )} + {isRunning ? : null} + + ) : null; + const details = ( + <> {action && ( {statusLabel} + + ); + const inner = ( +
+ {customIcon ?? + (isRunning ? ( + + ) : collapsible ? ( + + ) : ( + + ))} + {details}
); @@ -154,6 +184,27 @@ export const ToolHeader = ({ ); } + if (iconElement && iconAction) { + return ( +
+ {customIcon} + +
+ {details} +
+
+
+ ); + } + return ( - + + {showIcon ? ( + + ) : null} {label} ); diff --git a/apps/web/src/components/sandbox/PullRequestBadge.tsx b/apps/web/src/components/sandbox/PullRequestBadge.tsx index b39994a229..498298763f 100644 --- a/apps/web/src/components/sandbox/PullRequestBadge.tsx +++ b/apps/web/src/components/sandbox/PullRequestBadge.tsx @@ -10,14 +10,23 @@ interface PullRequestBadgeProps { url?: string; className?: string; iconClassName?: string; + showIcon?: boolean; + size?: 'xs' | 'sm'; } +const BADGE_SIZES = { + xs: { icon: 'size-3', gap: 'gap-1' }, + sm: { icon: 'size-3.5', gap: 'gap-1.5' }, +} as const; + export function PullRequestBadge({ repo, prNumber, url, className, iconClassName, + showIcon = true, + size = 'sm', }: PullRequestBadgeProps) { const pullRequestUrl = url ?? `https://github.com/${repo}/pull/${prNumber}`; const repoName = repo.split('/')[1] ?? repo; @@ -28,15 +37,18 @@ export function PullRequestBadge({ target="_blank" rel="noopener noreferrer" className={cn( - 'inline-flex items-center gap-1.5 cursor-pointer hover:underline', + 'inline-flex cursor-pointer items-center hover:underline', + BADGE_SIZES[size].gap, className, )} onClick={(e) => e.stopPropagation()} > - + {showIcon ? ( + + ) : null} {repoName}#{prNumber} diff --git a/apps/web/src/components/sandbox/WorkspaceBadge.tsx b/apps/web/src/components/sandbox/WorkspaceBadge.tsx index b15f7a19ec..19cf785b41 100644 --- a/apps/web/src/components/sandbox/WorkspaceBadge.tsx +++ b/apps/web/src/components/sandbox/WorkspaceBadge.tsx @@ -15,8 +15,15 @@ interface WorkspaceBadgeProps { fallbackLabel?: string; className?: string; iconClassName?: string; + showIcon?: boolean; + size?: 'xs' | 'sm'; } +const BADGE_SIZES = { + xs: { icon: 'size-3', gap: 'gap-1' }, + sm: { icon: 'size-3.5', gap: 'gap-1.5' }, +} as const; + function useWorkspaceSelection( environmentId?: string, repo?: string, @@ -49,6 +56,8 @@ export function WorkspaceBadge({ fallbackLabel, className, iconClassName, + showIcon = true, + size = 'sm', }: WorkspaceBadgeProps) { const selection = useWorkspaceSelection(environmentId, repo, fallbackLabel); @@ -63,8 +72,18 @@ export function WorkspaceBadge({ } return ( - - + + {showIcon ? ( + + ) : null} {label} ); diff --git a/apps/web/src/components/system/primitives/avatar.tsx b/apps/web/src/components/system/primitives/avatar.tsx index 182ad99902..6179b75692 100644 --- a/apps/web/src/components/system/primitives/avatar.tsx +++ b/apps/web/src/components/system/primitives/avatar.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; const AVATAR_SIZES = { + xs: 'size-5 text-[9px]', sm: 'size-6 text-[10px]', md: 'size-8 text-xs', lg: 'size-10 text-sm', diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts index f237256fdd..98d32fd2de 100644 --- a/apps/web/src/components/system/primitives/icons.ts +++ b/apps/web/src/components/system/primitives/icons.ts @@ -191,6 +191,7 @@ export { Stethoscope, Sun, Target, + Telescope, Terminal, TerminalIcon, ThumbsDown, diff --git a/apps/web/src/components/tasks/TaskRobotIcon.client.test.tsx b/apps/web/src/components/tasks/TaskRobotIcon.client.test.tsx new file mode 100644 index 0000000000..fd34359d66 --- /dev/null +++ b/apps/web/src/components/tasks/TaskRobotIcon.client.test.tsx @@ -0,0 +1,26 @@ +import { render } from '@testing-library/react'; + +const avatarSpy = vi.fn(); + +vi.mock('@/components/system', () => ({ + Avatar: (props: Record) => { + avatarSpy(props); + return
; + }, +})); + +import { TaskRobotIcon } from './TaskRobotIcon'; + +describe('TaskRobotIcon', () => { + it('renders its assigned asset through the shared extra-small Avatar', () => { + render(); + + expect(avatarSpy).toHaveBeenCalledWith( + expect.objectContaining({ + imageUrl: expect.stringMatching(/^\/task-robots\/robot-\d{3}\.png$/), + size: 'xs', + alt: '', + }), + ); + }); +}); diff --git a/apps/web/src/components/tasks/TaskRobotIcon.tsx b/apps/web/src/components/tasks/TaskRobotIcon.tsx new file mode 100644 index 0000000000..114cd12c7b --- /dev/null +++ b/apps/web/src/components/tasks/TaskRobotIcon.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { createContext, useContext, useMemo, type ReactNode } from 'react'; + +import { + getTaskRobotIconPath, + resolveTaskRobotIconId, +} from '@/lib/task-robot-icons'; +import { Avatar } from '@/components/system'; + +type TaskRobotIconContextValue = { + sessionId: string; + orderedTaskIds: readonly string[]; + onOpenTask?: (taskId: string) => void; +}; + +const TaskRobotIconContext = createContext( + null, +); + +export function TaskRobotIconProvider({ + sessionId, + orderedTaskIds, + onOpenTask, + children, +}: TaskRobotIconContextValue & { children: ReactNode }) { + const value = useMemo( + () => ({ sessionId, orderedTaskIds, onOpenTask }), + [onOpenTask, orderedTaskIds, sessionId], + ); + + return ( + + {children} + + ); +} + +export function useTaskRobotIconContext() { + return useContext(TaskRobotIconContext); +} + +export function TaskRobotIcon({ + taskId, + sessionId, + orderedTaskIds, + size = 'xs', +}: { + taskId: string; + sessionId?: string | null; + orderedTaskIds?: readonly string[]; + size?: 'xs' | 'sm'; +}) { + const context = useTaskRobotIconContext(); + const iconId = resolveTaskRobotIconId({ + taskId, + sessionId: sessionId ?? context?.sessionId, + orderedTaskIds: orderedTaskIds ?? context?.orderedTaskIds, + }); + + return ( + + ); +} diff --git a/apps/web/src/lib/task-robot-assets.test.js b/apps/web/src/lib/task-robot-assets.test.js new file mode 100644 index 0000000000..2ce8f5e72a --- /dev/null +++ b/apps/web/src/lib/task-robot-assets.test.js @@ -0,0 +1,76 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import process from 'node:process'; + +import sharp from 'sharp'; +import { describe, expect, it } from 'vitest'; + +const ASSET_DIRECTORY = join(process.cwd(), 'public', 'task-robots'); +const BACKGROUND = { r: 213, g: 237, b: 65 }; +const FOREGROUND_DISTANCE_SQUARED = 50 ** 2; + +describe('generated task robot assets', () => { + it('contains 25 centered 96px robot drawings', async () => { + const files = (await readdir(ASSET_DIRECTORY)) + .filter((file) => file.endsWith('.png')) + .sort(); + expect(files).toHaveLength(25); + expect(files[0]).toBe('robot-001.png'); + expect(files.at(-1)).toBe('robot-025.png'); + + for (const file of files) { + const { data, info } = await sharp(join(ASSET_DIRECTORY, file)) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + expect([info.width, info.height], file).toEqual([96, 96]); + + let left = info.width; + let top = info.height; + let right = -1; + let bottom = -1; + let horizontalMass = 0; + let verticalMass = 0; + let foregroundPixels = 0; + for (let y = 0; y < info.height; y += 1) { + for (let x = 0; x < info.width; x += 1) { + const offset = (y * info.width + x) * info.channels; + const red = data[offset] - BACKGROUND.r; + const green = data[offset + 1] - BACKGROUND.g; + const blue = data[offset + 2] - BACKGROUND.b; + if ( + red * red + green * green + blue * blue <= + FOREGROUND_DISTANCE_SQUARED + ) { + continue; + } + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + horizontalMass += x; + verticalMass += y; + foregroundPixels += 1; + } + } + + expect(right, `${file} has no drawing`).toBeGreaterThanOrEqual(0); + expect( + Math.min(left, top, info.width - 1 - right, info.height - 1 - bottom), + `${file} canvas clearance`, + ).toBeGreaterThanOrEqual(2); + const horizontalOffset = + horizontalMass / foregroundPixels - (info.width - 1) / 2; + const verticalOffset = + verticalMass / foregroundPixels - (info.height - 1) / 2; + expect( + Math.abs(horizontalOffset), + `${file} horizontal center`, + ).toBeLessThanOrEqual(4.5); + expect( + Math.abs(verticalOffset), + `${file} vertical center`, + ).toBeLessThanOrEqual(4.5); + } + }); +}); diff --git a/apps/web/src/lib/task-robot-icons.test.ts b/apps/web/src/lib/task-robot-icons.test.ts new file mode 100644 index 0000000000..c2e078ce85 --- /dev/null +++ b/apps/web/src/lib/task-robot-icons.test.ts @@ -0,0 +1,88 @@ +import { + TASK_ROBOT_ICON_COUNT, + resolveTaskRobotIconId, +} from './task-robot-icons'; + +const taskIds = Array.from({ length: 26 }, (_, index) => `task-${index + 1}`); + +describe('task robot icon assignment', () => { + it('is deterministic for the same task and session', () => { + const input = { + taskId: 'task-42', + sessionId: 'session-a', + orderedTaskIds: taskIds, + }; + + expect(resolveTaskRobotIconId(input)).toBe(resolveTaskRobotIconId(input)); + }); + + it('keeps all first 25 tasks in a session unique', () => { + const assignments = taskIds.slice(0, TASK_ROBOT_ICON_COUNT).map((taskId) => + resolveTaskRobotIconId({ + taskId, + sessionId: 'session-a', + orderedTaskIds: taskIds, + }), + ); + + expect(new Set(assignments).size).toBe(TASK_ROBOT_ICON_COUNT); + }); + + it('uses a different seeded order for another session', () => { + const assignments = (sessionId: string) => + taskIds.slice(0, 10).map((taskId) => + resolveTaskRobotIconId({ + taskId, + sessionId, + orderedTaskIds: taskIds, + }), + ); + + expect(assignments('session-a')).not.toEqual(assignments('session-b')); + }); + + it('preserves existing assignments as an ordered task list grows', () => { + const initialTaskIds = taskIds.slice(0, 3); + const expandedTaskIds = taskIds.slice(0, 8); + + expect( + initialTaskIds.map((taskId) => + resolveTaskRobotIconId({ + taskId, + sessionId: 'session-a', + orderedTaskIds: initialTaskIds, + }), + ), + ).toEqual( + initialTaskIds.map((taskId) => + resolveTaskRobotIconId({ + taskId, + sessionId: 'session-a', + orderedTaskIds: expandedTaskIds, + }), + ), + ); + }); + + it('reuses the seeded permutation after 25 tasks', () => { + const first = resolveTaskRobotIconId({ + taskId: taskIds[0]!, + sessionId: 'session-a', + orderedTaskIds: taskIds, + }); + const twentySixth = resolveTaskRobotIconId({ + taskId: taskIds[25]!, + sessionId: 'session-a', + orderedTaskIds: taskIds, + }); + + expect(twentySixth).toBe(first); + }); + + it('provides a stable hash fallback when the task list is partial', () => { + const input = { taskId: 'unknown-task', sessionId: 'session-a' }; + + expect(resolveTaskRobotIconId(input)).toBe(resolveTaskRobotIconId(input)); + expect(resolveTaskRobotIconId(input)).toMatch(/^robot-\d{3}$/); + }); +}); diff --git a/apps/web/src/lib/task-robot-icons.ts b/apps/web/src/lib/task-robot-icons.ts new file mode 100644 index 0000000000..56d90dd537 --- /dev/null +++ b/apps/web/src/lib/task-robot-icons.ts @@ -0,0 +1,75 @@ +export const TASK_ROBOT_ICON_COUNT = 25; + +type TaskRobotIconId = `robot-${string}`; + +function hashString(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function seededRandom(seed: number) { + let state = seed; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +function getSessionPermutation(sessionId: string): number[] { + const permutation = Array.from( + { length: TASK_ROBOT_ICON_COUNT }, + (_, index) => index, + ); + const random = seededRandom(hashString(sessionId)); + + for (let index = permutation.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [permutation[index], permutation[swapIndex]] = [ + permutation[swapIndex]!, + permutation[index]!, + ]; + } + + return permutation; +} + +function formatIconId(index: number): TaskRobotIconId { + return `robot-${String(index + 1).padStart(3, '0')}`; +} + +export function resolveTaskRobotIconId({ + taskId, + sessionId, + orderedTaskIds, +}: { + taskId: string; + sessionId?: string | null; + orderedTaskIds?: readonly string[]; +}): TaskRobotIconId { + const canonicalSessionId = sessionId?.trim() || 'standalone'; + const permutation = getSessionPermutation(canonicalSessionId); + const orderedIndex = orderedTaskIds + ? [...new Set(orderedTaskIds)].indexOf(taskId) + : -1; + // Session task queries are ordered by attachment time and grow as a stable + // prefix. The 26th task intentionally reuses the first seeded icon. + const slot = + orderedIndex >= 0 + ? orderedIndex % TASK_ROBOT_ICON_COUNT + : // Lazy surfaces may learn about a task before the canonical list does. + // This fallback is stable, but uniqueness begins once the list includes it. + hashString(`${canonicalSessionId}:${taskId}`) % TASK_ROBOT_ICON_COUNT; + + return formatIconId(permutation[slot]!); +} + +export function getTaskRobotIconPath(iconId: TaskRobotIconId): string { + return `/task-robots/${iconId}.png`; +}