feat(tasks-ui): tasks on their owner nodes, host-primitive controls, icon glyphs, draggable output

Round-two review feedback, all three points:

- TASKS ON NODES: the model maps each shared task (ownerName -> member name
  -> member session id) onto its owner's node; graph cards and tree rows
  render a fourth line (icon + subject + status + '+N'), and the node detail
  popover lists every owned task.
- TEAM BOARD: member Pills double as the owner filter; each task row is a
  status dot + subject + owner + status Tag + ONE overflow Menu (complete /
  reopen / edit / reassign submenu / delete with a two-step arm). Creating
  and editing moved into a real Modal with labeled Inputs and owner Pills —
  the narrow strip is no longer where you type.
- HOST PRIMITIVES ONLY: the board and the jobs drawer now use Menu / Modal /
  Input / Button / Pill / Tag / Switch / StateDot; no native select or input
  remains (guarded by a spec).
- ICONS, NOT HAND-DRAWN GLYPHS: every text glyph (circle/diamond/triangle/
  check/chevrons/i/house) is replaced by a host icon — agent preset, user
  (teammate), branch (workflow run), checklist (fold + tasks), tree corner,
  fullscreen (fit), ellipsis (node details), copy/stop (jobs). The live line
  now shows the tool's own icon + name + args without the drawn box.
- JOBS UX: the output popover is DRAGGABLE (offset clamped to the viewport,
  double click re-anchors, controls do not start a drag), defaults to 380px,
  and gains copy-output, a follow-latest switch, kind/status Tags and a drag
  hint; the drawer highlights the open row.
- 19 new copy keys across zh/en/ja and the 18 third-language dictionaries.

Tests: 4 new specs (task rendered on its node, no native select/input +
menu-driven board, create-through-dialog posts the CAS-free create, job
popover drag moves the card). Full suite 1404 passed / 133 files.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 85a19cecc3
commit e41f10afc9
32 changed files with 1257 additions and 382 deletions
+80 -17
View File
@@ -1,27 +1,29 @@
/**
* Shared presentation bits of the Tasks page's two modes (graph + tree): the
* state-dot mapping, the SHORT mono meta line (one or two tokens — the card
* is 132px wide in the narrow native sidebar, so anything longer moves into
* the ⓘ popover), the iconified live activity row ("glyph + tool + args",
* the approved format), and the fold aggregate caption.
* 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 dot + name (11px, semibold, one line)
* line 2 mode/model · activity (9px mono, one line)
* line 3 live line (running nodes only)
* Everything else — display title, full model id, team role, phase names,
* latest output text, jump action — lives in the anchored popover.
* 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 type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconAgentPresetOutline16, IconBranchOutline16, IconChecklistOutline14, IconUserOutline16,
type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { LastActivity } from '../subagent-activity.ts'
import type { TasksAgentNode, TasksNodeState, TasksWorkflowNode } from './tasks-model.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 = 42
const ARGS_PREVIEW = 48
/** Preview cap of one text line. */
const TEXT_PREVIEW = 72
@@ -45,6 +47,21 @@ export function stateLabel(state: TasksNodeState): string {
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
@@ -55,6 +72,23 @@ 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 <IconUserOutline16 size={size} />
return <IconAgentPresetOutline16 size={size} />
}
/** The workflow-run glyph. */
export function WorkflowGlyph(props: { size?: number }): ReactNode {
return <IconBranchOutline16 size={props.size ?? 11} />
}
/** The fold aggregate glyph. */
export function FoldGlyph(props: { size?: number }): ReactNode {
return <IconChecklistOutline14 size={props.size ?? 11} />
}
/**
* The mono meta line of an agent card — at most two short tokens:
* - the topology root: 主代理 · 状态
@@ -97,10 +131,39 @@ export function workflowStatusKey(status: TasksWorkflowNode['run']['status']): C
}
}
/** 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 live activity row of a RUNNING agent: "glyph + tool + args" (the
* approved format), plus the flattened last text line underneath. A running
* node with neither reads as thinking.
* 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 }): ReactNode {
const tasks = props.tasks ?? []
const primary = primaryTask(tasks)
if (primary === undefined) return null
return (
<span
className={css.taskLine}
title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
>
<span className={css.taskGlyph} aria-hidden="true"><IconChecklistOutline14 size={9} /></span>
<span className={css.taskSubject}>{primary.subject}</span>
<span className={css.taskState}>{t(taskStatusKey(primary.status))}</span>
{tasks.length > 1 && <span className={css.taskMore}>{`+${tasks.length - 1}`}</span>}
</span>
)
}
/**
* The live activity row of a RUNNING agent: the tool's own icon + tool name +
* args (the approved format), plus the flattened last text line underneath.
* A running node with neither reads as thinking.
*/
export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
const { live } = props
@@ -111,7 +174,7 @@ export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
<>
{live.tool !== undefined && (
<span className={css.live}>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(8)}</span>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(9)}</span>
<span className={css.liveTool}>{live.tool.name}</span>
{live.tool.args !== '' && <span className={css.liveArgs}>{preview(live.tool.args)}</span>}
</span>