/** * Shared presentation bits of the Tasks page's two modes (graph + tree): * the state dot, the agent/workflow/task GLYPHS (host primitive icons — the * page draws no glyphs of its own), the short mono meta line, the node task * line, and the iconified live activity row ("tool icon + tool + args"). * * Card content contract (the page's answer to "everything is ellipsized"): * line 1 state dot + agent icon + name (11px semibold, one line) * line 2 mode/model · activity (9px mono, one line) * line 3 live tool line (running nodes only) * line 4 owned shared task (team members only) * Everything else lives in the popovers. */ import type { ReactNode } from 'react' import { IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconUserOutlineRegular, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarChildLiveView } from '../context-types.ts' import { liveActivityLabel } from './process-labels.ts' import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts' import { toolGlyph } from './tool-icons.tsx' import { t, type CopyKey } from './locales.ts' import css from './tasks-graph.module.css' /** Preview cap of one tool-call argument line. */ const ARGS_PREVIEW = 48 /** Preview cap of one text line. */ const TEXT_PREVIEW = 72 /** The host StateDot semantic of a node display state. */ export function nodeDotState(state: TasksNodeState): StateDotState { switch (state) { case 'running': return 'ongoing' case 'idle': return 'idle' case 'done': return 'done' case 'error': return 'error' } } /** The short state word of a node (the meta line's second token). */ export function stateLabel(state: TasksNodeState): string { const key: CopyKey = state === 'running' ? 'tasksStateRunning' : state === 'idle' ? 'subagentInactive' : state === 'error' ? 'tasksStateError' : 'tasksStateDone' return t(key) } /** The task status label key. */ export function taskStatusKey(status: TasksNodeTask['status']): CopyKey { switch (status) { case 'pending': return 'teamTaskPending' case 'in_progress': return 'teamTaskInProgress' case 'completed': return 'teamTaskCompleted' } } /** The host StateDot semantic of a task row/line. */ export function taskDotState(task: TasksNodeTask): StateDotState { if (task.status === 'completed') return 'done' return task.ready ? 'ongoing' : 'warning' } /** First `limit` characters with an ellipsis when truncated. */ export function preview(text: string, limit: number = ARGS_PREVIEW): string { return text.length > limit ? `${text.slice(0, limit)}…` : text } /** Collapse whitespace for single-line previews. */ export function flatten(text: string): string { return text.replace(/\s+/g, ' ').trim() } /** The host icon of one agent node (the lead and plain children share one). */ export function AgentGlyph(props: { node: TasksAgentNode; size?: number }): ReactNode { const size = props.size ?? 11 if (props.node.team?.role === 'teammate') return return } /** The workflow-run glyph. */ export function WorkflowGlyph(props: { size?: number }): ReactNode { return } /** The fold aggregate glyph. */ export function FoldGlyph(props: { size?: number }): ReactNode { return } /** * The mode word of one catalog row, or undefined when it names no mode. DSH * 0.1.7 added `unknown` (a child the host's catalog fold kept without a * readable descriptor): it claims NEITHER mode, so the segment is omitted * rather than mislabelled. */ export function modeLabel(mode: TasksAgentNode['mode']): string | undefined { switch (mode) { case 'one-shot': return t('subagentModeOneShot') case 'continuable': return t('subagentModeContinuable') case 'unknown': case undefined: return undefined } } /** * The mono meta line of an agent card — at most two short tokens: * - the topology root: 主代理 · 状态 * - a teammate with a known model: 模型 · 状态 * - any other agent: 模式 · 状态 */ export function agentMeta(node: TasksAgentNode): string { const state = stateLabel(node.state) if (node.parentId === undefined) return `${t('subagentMainAgent')} · ${state}` if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') { return `${node.team.model} · ${state}` } const mode = modeLabel(node.mode) return mode === undefined ? state : `${mode} · ${state}` } /** The mono meta line of a workflow run card: status · member tally. */ export function workflowMeta(node: TasksWorkflowNode): string { const { run } = node let done = 0 let total = 0 for (const phase of run.phases) { for (const member of phase.members) { total += 1 if (member.outcome !== undefined) done += 1 } } return `${t(workflowStatusKey(run.status))} · ${t('workflowMembers', { done, total })}` } /** The workflow run status label key. */ export function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey { switch (status) { case 'running': return 'workflowRunning' case 'completed': return 'workflowCompleted' case 'cancelled': return 'workflowCancelled' case 'failed': return 'workflowFailed' } } /** The task a node surfaces first: in-progress, else pending, else the last. */ export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | undefined { return tasks.find(task => task.status === 'in_progress') ?? tasks.find(task => task.status === 'pending') ?? tasks[tasks.length - 1] } /** * The node's shared-task line: an icon, the primary task's subject, its * status word, and a `+N` tail when the agent owns more. Renders nothing * without tasks, so non-team nodes keep the three-line shape. */ export function TaskLine(props: { tasks: readonly TasksNodeTask[] | undefined onOpenTask(taskId: string, anchor: HTMLElement): void }): ReactNode { const tasks = props.tasks ?? [] const primary = primaryTask(tasks) if (primary === undefined) return null return ( `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')} onClick={(event) => { event.stopPropagation() props.onOpenTask(primary.id, event.currentTarget) }} onKeyDown={(event) => { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() event.stopPropagation() props.onOpenTask(primary.id, event.currentTarget) }} > {primary.subject} {t(taskStatusKey(primary.status))} {tasks.length > 1 && {`+${tasks.length - 1}`}} ) } /** * The live activity row of a RUNNING agent: the merged process summary the * main conversation would show for the child's newest range (category wording * + the running call's detail), plus the flattened last text line underneath. * A running node with neither reads as thinking. */ export function LiveLine(props: { live: SidebarChildLiveView | undefined }): ReactNode { const { live } = props const activity = liveActivityLabel(live?.summary) if (activity === undefined && live?.text === undefined) { return {t('subagentThinking')} } return ( <> {activity !== undefined && ( {activity} )} {live?.text !== undefined && ( {preview(flatten(live.text), TEXT_PREVIEW)} )} ) } /** The fold aggregate's subtitle: up to two label previews joined by ·. */ export function foldPreviews(previews: readonly string[]): string { return previews.join(' / ') }