feat(tasks-ui): migrate the Tasks page to shadcn/ui (Tailwind v4 + radix, lazy chunk)

Visual base replacement for the Tasks page after the 'hard shadows make a
dense graph unreadable' review. Behaviour, hooks, aria labels and i18n are
unchanged; the base layer is not.

- FOUNDATION: Tailwind v4 wired into tsdown's css-inline plugin (theme +
  utilities ONLY — preflight is excluded because the plugin's stylesheet is a
  global <style data-plugin> tag and would repaint the host page), shadcn
  tokens bridged to --dsw-* so the DSH theme keeps flipping light/dark with no
  dark: overrides, cn() util, components.json for the CLI, src/client/ui/.
- COMPONENTS: shadcn's component source vendored into src/client/ui (button,
  card, badge, separator, input, textarea, tooltip, popover, dropdown-menu,
  scroll-area, collapsible, toggle-group, skeleton, spinner, empty) with a
  documented local diff: lucide-react replaced by host IconXxx (no new icon
  dep), shadow-* dropped from non-floating variants, unused variants pruned,
  and React.forwardRef restored on Button — the host runs React 18, where
  radix's Slot cannot hand a ref to a plain function component (dev warns,
  production silently mis-anchors the floating layer).
- SHADOWS: static panels are now hairline + surface tiers with hover:bg-muted;
  shadows exist only on floating layers. Settled nodes recede by ink
  (text-muted-foreground/foreground-3) instead of opacity.
- DENSITY: node cards widened 132 -> 150px with a two-line clamped title
  (13px text in a 132px card left ~7 CJK characters), reserved height 46 -> 60.
- CHUNK: the whole page (graph, tree, task window, board, drawer, shadcn/radix
  layer and its stylesheet) moved into the lazy 'tasks' chunk. Core bundle
  988.5 -> 835.6 KiB (-153 KiB); the 709.8 KiB UI payload loads only when the
  Tasks tab opens.
- MODEL: workflow members whose childId is already a real node elsewhere are
  no longer synthesized into a duplicate id (React keys/edges), with a
  last-resort per-id dedupe.
- GUARDS: tests/ui-foundation.spec.ts, tests/ui-bundle.spec.ts (core/chunk
  split + size budget + no preflight/oklch/lucide), tests/ui-shadows.spec.ts,
  extended tests/theme.spec.ts (no colour literal / palette class in
  src/client/ui/** and the migrated files).
- REVIEW FIXES: portal popovers re-scoped under .dsw-tasks, graph loading
  state, 11px floor, error nodes no longer tint a whole border, TeamBoard's
  delete item is neutral (the window owns the two-step confirm).

Verified: typecheck / lint / 136 files 1502 tests / build bundle assertions /
mount e2e 7-7; browser harness 65-65 checks with 0 console errors.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 7cbfc766a1
commit f6cf73c4cf
43 changed files with 3394 additions and 2048 deletions
+45 -13
View File
@@ -10,6 +10,11 @@
* line 3 live tool line (running nodes only)
* line 4 owned shared task (team members only)
* Everything else lives in the popovers.
*
* Visual language: Tailwind utilities over the shadcn tokens
* (src/client/ui/theme.css) — mono meta is `font-mono tabular-nums`, ink stays
* on the three token levels, and color is semantic only (running = primary,
* done = success, blocked = warning; errors only exist on nodes).
*/
import type { ReactNode } from 'react'
import {
@@ -20,7 +25,6 @@ import type { LastActivity } from '../subagent-activity.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
@@ -138,10 +142,25 @@ export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | un
?? tasks[tasks.length - 1]
}
/**
* The status word's semantic ink: done = success, in progress = primary,
* blocked = warning, anything else stays a plain meta token. No hue is used
* as decoration — the card stays quiet when many nodes are on screen.
*/
function taskTone(task: TasksNodeTask): string {
if (task.status === 'completed') return 'text-success'
if (task.status === 'in_progress') return 'text-primary'
return task.ready ? 'text-muted-foreground' : 'text-warning'
}
/**
* 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.
*
* The whole line is one inline hover slab (`hover:bg-muted`) that opens the
* task window; it stops the click from reaching the node behind it, which is
* the node's own detail affordance, not this one.
*/
export function TaskLine(props: {
tasks: readonly TasksNodeTask[] | undefined
@@ -154,7 +173,7 @@ export function TaskLine(props: {
<span
role="button"
tabIndex={0}
className={css.taskLine}
className="group -mx-1 mt-1 flex min-w-0 cursor-pointer items-center gap-1 rounded-sm px-1 text-xs text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring"
title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
onClick={(event) => {
event.stopPropagation()
@@ -167,10 +186,12 @@ export function TaskLine(props: {
props.onOpenTask(primary.id, event.currentTarget)
}}
>
<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 className="flex-none" aria-hidden="true"><IconChecklistOutline14 size={11} /></span>
<span className="min-w-0 flex-1 truncate group-hover:text-foreground">{primary.subject}</span>
<span className={`flex-none font-mono tabular-nums ${taskTone(primary)}`}>{t(taskStatusKey(primary.status))}</span>
{tasks.length > 1 && (
<span className="flex-none rounded-sm border border-border px-1 font-mono tabular-nums text-foreground-3">{`+${tasks.length - 1}`}</span>
)}
</span>
)
}
@@ -178,24 +199,35 @@ export function TaskLine(props: {
/**
* 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.
* A running node with neither reads as thinking. Running ink is the accent
* (primary); the args/text tails drop to the secondary token.
*/
export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
const { live } = props
if (live?.text === undefined && live?.tool === undefined) {
return <span className={css.nodeMeta}>{t('subagentThinking')}</span>
return (
<span className="mt-0.5 truncate font-mono text-xs tabular-nums text-muted-foreground">
{t('subagentThinking')}
</span>
)
}
return (
<>
{live.tool !== undefined && (
<span className={css.live}>
<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 className="mt-0.5 flex min-w-0 items-center gap-1 truncate font-mono text-xs tabular-nums text-primary">
<span className="flex size-3 flex-none items-center justify-center rounded-sm border border-current" aria-hidden="true">
{toolGlyph(live.tool.name)(9)}
</span>
<span className="flex-none">{live.tool.name}</span>
{live.tool.args !== '' && (
<span className="min-w-0 truncate text-muted-foreground">{preview(live.tool.args)}</span>
)}
</span>
)}
{live.text !== undefined && (
<span className={css.liveText}>{preview(flatten(live.text), TEXT_PREVIEW)}</span>
<span className="mt-0.5 truncate font-mono text-xs tabular-nums text-muted-foreground">
{preview(flatten(live.text), TEXT_PREVIEW)}
</span>
)}
</>
)