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
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/client/ui/theme.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/client/ui",
"ui": "src/client/ui",
"lib": "src/client/ui",
"utils": "src/client/ui/utils",
"hooks": "src/client/ui"
}
}
+26
View File
@@ -1004,6 +1004,32 @@ interface SettingsDescriptor {
- 类名是 CSS Modules 哈希,**不是契约**;精确命中用 `[data-dsh-better-sidebar]` + 子串类名(`[class*='panel']`)或 DOM 结构。 - 类名是 CSS Modules 哈希,**不是契约**;精确命中用 `[data-dsh-better-sidebar]` + 子串类名(`[class*='panel']`)或 DOM 结构。
- 改动本契约必须同步本文档、设计文档与 `tests/theme.spec.ts`。 - 改动本契约必须同步本文档、设计文档与 `tests/theme.spec.ts`。
### 12.3 在插件里用 Tailwind / shadcn/ui
本仓库自己的任务管理页(v0.20 起)就是这套姿势的参考实现:组件源码 vendoring 在 `src/client/ui/`,样式由 Tailwind v4 编译,颜色全部经 shadcn 语义令牌桥接到 `--dsw-*`。你的插件照做即可自动兼容全部皮肤;**接入的难点只有三条,其余都是常规 Tailwind**。
**① 只引 `theme` + `utilities`,绝不引 preflight。** 插件的 CSS 最终是全局 `<style data-plugin>`(没有 shadow DOM),`@import "tailwindcss"` 连带的 preflight 会重置**整张 DSH 宿主页面**(会话正文、原生右侧栏、设置页)。唯一合法的入口形状:
```css
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@source "../**/*.tsx"; /* 显式限定扫描面,否则 Tailwind 会连 lib/、node_modules/ 一起扫 */
```
| 现象 | 原因 |
|---|---|
| 自己的 `.rounded-md` 被宿主样式压过 | 宿主样式表是**无层级(unlayered)**的,无层级声明优先于任何 `@layer` 内的声明(与特异性无关)。补偿方式是把重置放进层里并抬到**类特异性**、挂在页面自己的作用域根类上(本仓库是 `.dsw-tasks`:`color` / `:where(...)` 的 `box-sizing` 与 `font: inherit` / `focus-visible` 环),而不是去动宿主 |
| 深浅主题下颜色不翻 | DSH 的主题翻转是给 `<body>` 打 `data-ds-dark-theme`,不是加 `.dark` 类。若确需 `dark:` 变体,先定义 `@custom-variant dark (&:where([data-ds-dark-theme], [data-ds-dark-theme] *))`——但**令牌本身就是翻转的**,绝大多数情况下写 `dark:` 是双重记账,不要写 |
**② 令牌桥接到 `--dsw-*`,颜色零字面量。** 在 `:root` 里把 shadcn 语义名接上 DSH 令牌(`--background: var(--dsw-alias-bg-base)`、`--card: var(--dsw-alias-bg-layer-1)`、`--popover: var(--dsw-alias-bg-layer-2)`、`--border: var(--dsw-alias-border-l4)`、`--primary: var(--dsw-alias-state-business-primary)`、`--muted-foreground: var(--dsw-alias-label-secondary)`、`--destructive: var(--dsw-alias-state-error-primary)` …),再用 `@theme inline { --color-background: var(--background); … }` 接成 Tailwind 工具类。`inline` 保证产物里留着这层间接,宿主皮肤改令牌能立刻生效。**禁止** `#hex` / `rgb()` / `oklch()` 字面量,也**禁止** Tailwind 默认调色板类(`bg-blue-500` / `text-white`)——后者的值是 Tailwind 自己的主题,不是 DSH 的。完整映射表见[任务页设计文档](./plans/2026-09-14-tasks-graph-workflow-teams-design.md)的「shadcn 令牌 → `--dsw-*` 映射表」。
**③ 静态面板零阴影。** 阴影是**层级**手段,不是装饰:卡片、节点、行、面板互相叠加的投影是「节点一多就辣眼睛」的直接原因。静态面板的层级改用 **1px hairline(`border-border`)+ 表面阶梯(`background` → `card`/layer-1 → `popover`/layer-2)+ 三档墨色**承载;`shadow-*` 只允许出现在真正浮动、由自己锚定几何定位的层(popover / dropdown-menu / tooltip / 可拖动窗口)。上游 shadcn 组件默认给 card / input / textarea / toggle / toggle-group 都带 `shadow-xs`/`shadow-sm`,vendoring 时要逐处删掉。
**另外两条与皮肤无关但同样会遇到**:
- **图标用宿主图标,不引 `lucide-react`**:`@deepseek-ai/dsh-client-ui-primitives` 的 `IconXxx` 是平台模块(`size` / `className` 两个 props,颜色靠 `currentColor`),宿主图标渲染的就是 `svg`,所以 shadcn 组件里的 `[&_svg]` / `[&_svg:not([class*='size-'])]:size-4` 选择器照旧成立。引 lucide 会把整套图标内联进你的 bundle。
- **vendoring 而不是新增重依赖**:`npx shadcn add <component>` 把源码落进你自己的目录(本仓库 `components.json` 的 aliases 全部指向 `src/client/ui`),依赖只增加 `radix-ui` / `class-variance-authority` / `clsx` / `tailwind-merge` 这四个可 tree-shake 的包;`tailwindcss` 与 `@tailwindcss/postcss` 只进 devDependencies(运行期产物是编译后的 CSS)。落盘后**逐文件复核**:CLI 默认会写 `import { cn } from "cn"`(npm 上真有一个同名包)、带回 `dark:` 颜色覆写、带回被删的 `shadow-xs` / `text-white` 与未使用的变体。升级上游组件用 `npx shadcn@latest add <component> --diff`,只取结构性更新再重新施加自己的删改,**不要**用 `--overwrite`。
--- ---
## 13. 完整最小示例 ## 13. 完整最小示例
@@ -146,3 +146,212 @@ PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `
- 复现步骤与陷阱(令牌提取、primitives 桩、shim 与 tarball 重装坑)已沉淀到 `.workspace-docs/notes/dsh/09-14-dsh-better-sidebar任务管理页重构(工作流图与Teams任务板).md`。 - 复现步骤与陷阱(令牌提取、primitives 桩、shim 与 tarball 重装坑)已沉淀到 `.workspace-docs/notes/dsh/09-14-dsh-better-sidebar任务管理页重构(工作流图与Teams任务板).md`。
未在本机自动化验证、留给用户实机确认的一项:3384 桌面应用**重启后**的 UI 级复看(新 bundle 已装进 `~/.dsh/profiles/web`,与仓库构建产物 SHA-256 一致)。 未在本机自动化验证、留给用户实机确认的一项:3384 桌面应用**重启后**的 UI 级复看(新 bundle 已装进 `~/.dsh/profiles/web`,与仓库构建产物 SHA-256 一致)。
## shadcn/ui 迁移(2026-09-17,视觉基座替换)
三轮返工把**行为**收敛到位(统一的详情窗口、常驻任务板、可拖动浮窗),但**视觉基座**仍是自建:
- 每张卡片 / 每个节点 / 每行都是一次手写样式,同一种「卡片」在四个文件里有四份近似声明;
- 卡片自带 `color-mix()` 硬投影与描边,节点一多**满屏阴影相互叠加**,正是「辣眼睛」的根因(阴影是层级手段,被当成了装饰);
- 控件形态靠逐处微调对齐,hover / focus-visible / disabled 三态在不同文件里覆盖程度不一。
处置不是再打磨一轮自建样式,而是**换基座**:把 shadcn/ui 的组件源码 vendoring 进 `src/client/ui/`,样式统一由 Tailwind v4 工具类产出,颜色统一走 shadcn 语义令牌 → `--dsw-*` 的桥接,**静态面板零阴影**(层级改由 1px hairline + 表面阶梯承载)。行为语义、稳定钩子、i18n 全部不变,因此这次迁移对上层是不可见的。
### 依赖与实际版本
| 包 | 版本 | 归属 | 作用 |
|---|---|---|---|
| `radix-ui` | `^1.6.7` | dependencies | vendored 组件的无样式原语(聚合包,不是 `@radix-ui/react-*` 一族) |
| `class-variance-authority` | `^0.7.1` | dependencies | `buttonVariants` / `badgeVariants` / `toggleVariants` 的变体表 |
| `clsx` | `^2.1.1` | dependencies | `cn()` 的条件拼装 |
| `tailwind-merge` | `^3.7.0` | dependencies | `cn()` 的同族工具类去重(调用方的 `px-4` 胜过组件默认的 `px-2`) |
| `tailwindcss` | `^4.3.3` | devDependencies | v4 引擎(只构建期用;运行期产物是编译后的 CSS) |
| `@tailwindcss/postcss` | `^4.3.3` | devDependencies | 构建管线,被 `tsdown.config.ts` 与 `scripts/ui-css.mjs` 共用 |
| `postcss` | `^8.5.26` | devDependencies | 上面的宿主管线 |
`components.json`(CLI 上下文,非运行期配置):`style: new-york` / `base: radix` / `rsc: false` / `css: src/client/ui/theme.css`,`aliases` 全部指向 `src/client/ui`——**所以 CLI 的默认 `@/lib/utils` 不会出现在这个仓库**(见下文「统一改动」第 2 条)。
CLI 曾顺手注入一个 `cn: ^0.3.0` 依赖(npm 上真实存在的同名包,仓库里无任何 import)——已删除并 `pnpm install` 重同步 `pnpm-lock.yaml` / `node_modules`。
### Tailwind 接入方式
入口是唯一的:`src/client/ui/theme.css`,编译有**两个**消费方但只有一份定义(`tsdown.config.ts` 导出 `compileTailwind()`,`scripts/ui-css.mjs` 复用它产出可视化 harness 的 `tailwind.css`):
```
src/client/ui/theme.css ──┬─ tsdown css-inline 插件(生产)→ 内联进 lib/client.js 的 <style data-plugin>
└─ scripts/ui-css.mjs(pnpm ui:css,仅本地 harness)
```
**为什么只引 `theme` + `utilities`**:插件注入的样式表是**全局 `<style data-plugin>`**(`injectTag()`,无 shadow DOM)。Tailwind 的完整入口 `@import "tailwindcss"` 会连带 preflight,而 preflight 的 `*,::before,::after { box-sizing; margin: 0; padding: 0; border: 0 solid }` 与 `html { -webkit-text-size-adjust }` 是对**整张 DSH 宿主页面**的重置——会话正文、原生右侧栏、设置页全部会被改版。所以只引:
```css
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@source "../**/*.tsx";
```
`@source` 是**显式**的:不给它,Tailwind 会以项目根为扫描面,把 `lib/`、`tests/`、`node_modules/` 里像类名的字符串也算进产物(既胖又不可控)。它只扫 `src/client/**/*.tsx`。
**深浅主题**:`@custom-variant dark (&:where([data-ds-dark-theme], [data-ds-dark-theme] *))` —— DSH 的主题翻转是给 `<body>` 打 `data-ds-dark-theme`,不是加 `.dark` 类(不给这条 `@custom-variant`,Tailwind 的 `dark:` 变体会去找 `.dark` 祖先,在本宿主里**永远不会命中**)。但**本次迁移把所有 `dark:` 覆写都删了**:`--dsw-*` 令牌本身就随主题翻转,再写一遍 `dark:` 是双重记账(上游 `dark:bg-destructive/60`、`dark:border-input`、`dark:aria-invalid:ring-destructive/40`、`dark:hover:bg-accent/50` 全属此类)。`@custom-variant` 保留是为了将来确需「令牌之外」的主题分支时有正确锚点。
**级联层与 `dsw-tasks` 作用域根类**(这一条是本仓库特有的坑):宿主的样式表是**无层级(unlayered)**的,而无层级声明**优先于任何 `@layer` 内的声明**——与特异性无关。也就是说 `layer(utilities)` 里的 `.rounded-md` 打不过宿主任何一条裸元素规则。补偿手段是把页面自己的补偿规则放进层里但抬到**类特异性**,并挂在 **`.dsw-tasks`**(任务页根类,`SubagentView` 的页面根 + `TaskWindow` 的内容根)上:
```css
@layer base {
.dsw-tasks { color: var(--dsw-alias-label-primary); }
.dsw-tasks :where(button, input, textarea, select) { font: inherit; }
.dsw-tasks :where(*, *::before, *::after) { box-sizing: border-box; }
.dsw-tasks :where(a, button, input, textarea, select, [tabindex]):focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 1px; }
}
```
`:where()` 把元素重置压回类特异性,所以这几条既到得了页面内的元素,又**不越出 `.dsw-tasks`**——用作用域根类换回了 preflight 里真正需要的三条(`box-sizing` / 控件继承字体 / 前景色),代价是不碰宿主页面。附带的两组:`@layer utilities` 里的细滚动条(走 `--dsw-alias-scrollbar-*`)与 `@media (prefers-reduced-motion: reduce)` 的动效全量关闭。
**`radix-ui` 的入口解析**:`tsdown.config.ts` 把 `module`/`browser` 放在 `mainFields` 首位(`react-remove-scroll` 家族的 `main` 是 `dist/es5`),并给 `tailwind-merge` 加显式 alias(它的 exports map 把 `require` 列在 `import` 之前,会解析到不可 tree-shake 的 `bundle-cjs.js`)。
### shadcn 令牌 → `--dsw-*` 映射表
`theme.css` 的 `:root` 是唯一的桥。每个值都是 `var(--dsw-…)` 引用,**没有颜色字面量**;`@theme inline` 把这些名字再接成 Tailwind 的 `--color-*`(`inline` 保证产物里留着 `var(--background)` 这层间接,宿主皮肤改令牌能立刻生效)。
| shadcn 令牌 | DSH 令牌 | 备注 |
|---|---|---|
| `--background` | `--dsw-alias-bg-base` | |
| `--foreground` | `--dsw-alias-label-primary` | 正文墨色第 1 档 |
| `--card` | `--dsw-alias-bg-layer-1` | 表面阶梯第 2 级(静态面板) |
| `--card-foreground` | `--dsw-alias-label-primary` | |
| `--popover` | `--dsw-alias-bg-layer-2` | 表面阶梯第 3 级(浮层) |
| `--popover-foreground` | `--dsw-alias-label-primary` | |
| `--primary` | `--dsw-alias-state-business-primary` | 只给主操作 / 焦点环 / 进行中 |
| `--primary-foreground` | `--dsw-alias-label-primary-foreground` | |
| `--secondary` | `--dsw-alias-interactive-bg-hover` | |
| `--secondary-foreground` | `--dsw-alias-label-primary` | |
| `--muted` | `--dsw-alias-interactive-bg-hover` | |
| `--muted-foreground` | `--dsw-alias-label-secondary` | 墨色第 2 档 |
| `--accent` | `--dsw-alias-interactive-bg-hover` | |
| `--accent-foreground` | `--dsw-alias-label-primary` | |
| `--destructive` | `--dsw-alias-state-error-primary` | 错误 / 删除 |
| `--destructive-foreground` | `--dsw-alias-label-primary-foreground` | 上游此处是 `text-white`(迁移时删掉的唯一颜色字面量) |
| `--border` | `--dsw-alias-border-l4` | 1px hairline 的来源 |
| `--input` | `--dsw-alias-border-l4` | |
| `--ring` | `--dsw-alias-state-business-primary` | |
| `--success` | `--dsw-alias-state-success-primary` | 非 stock shadcn,任务状态需要 |
| `--warning` | `--dsw-alias-state-warn-primary` | 非 stock shadcn,阻塞 / 审批需要 |
| `--foreground-3` | `--dsw-alias-label-tertiary` | 墨色第 3 档(靠重量分层,不靠色数) |
| `--border-strong` | `--dsw-alias-label-tertiary` | 需要更实的分隔时用 |
圆角是唯一不来自令牌的一组(`--radius` 6px / `sm` 4px / `md` 6px / `lg` 8px / `xl` 12px,纯几何,与皮肤无关);字体走 `--font-sans: var(--dsw-font-family-default)` / `--font-mono: var(--dsw-font-family-code)`,所以插件不可能与 DSH 的字阶漂移。
### vendored 组件清单与对上游的每处改动
安装命令(16 个组件,17 个文件——`toggle.tsx` 是 `toggle-group` 的 cva 依赖,随 `add` 一起到达):
```
npx -y shadcn@latest add button card badge separator input textarea tooltip popover \
dropdown-menu scroll-area collapsible toggle-group skeleton spinner empty
```
按**消费面**清点(`src/client/ui/` 下 18 个文件:16 个组件 + `toggle.tsx` + `utils.ts`):
> **两个当前无消费者的文件**:`popover.tsx` 与 `skeleton.tsx`。浮层全部走自建的 `AnchoredPopover`(它需要锚定 + 拖动 + 关闭契约,shadcn popover 是 portal 语义,两者不通用),加载态走 `Spinner`。二者按简报要求随 `add` 一起 vendoring 进来,**保留原样**——但「保留」不等于「发货」:实测 `lib/client.js` 里**这两个模块的代码与 `@radix-ui/react-popover` 全部缺席**(rollup 按 import 图 tree-shake;`toggle.tsx` 则相反,`toggle-group` 只取 `toggleVariants`,模块在包内而 `Toggle` 组件不在)。若下次复盘确认仍无消费者,删除这两个文件是纯收益(少两份要跟上游 diff 的源码)。
| 文件 | 实际消费者 | 上游改动摘要 |
|---|---|---|
| `button.tsx` | JobsDrawer / SubagentView(别名 `UiButton`)/ TaskWindow / TasksGraph / TasksPopovers | 删 `link` 变体与 `xs` / `lg` / `icon-xs` / `icon-sm` / `icon-lg` 尺寸;`outline` 去掉 `shadow-xs` |
| `card.tsx` | TaskWindow / TasksGraph / TasksPopovers / TeamBoard | 去掉卡片阴影(静态面板零阴影) |
| `badge.tsx` | JobsDrawer / TaskWindow / TasksGraph / TasksPopovers / TeamBoard | 删 `ghost` / `link` 变体;`text-white` → `--destructive-foreground` |
| `separator.tsx` | TaskWindow / TasksPopovers / TeamBoard | 无(保留 `data-slot` 与 orientation 数据属性) |
| `input.tsx` | TaskWindow | 删上游 7 条 `file:` 变体工具类 |
| `textarea.tsx` | TaskWindow | 无 |
| `tooltip.tsx` | SubagentView / TasksGraph | 删 `dark:` 覆写 |
| `popover.tsx` | **无消费者**(见下) | 只保留 `shadow-md`(真浮层)、删 `dark:` 覆写;删 `PopoverHeader` / `PopoverTitle` / `PopoverDescription` |
| `dropdown-menu.tsx` | TeamBoard(改派负责人子菜单) | 删 `DropdownMenuCheckboxItem` / `DropdownMenuRadioGroup` / `DropdownMenuRadioItem`(无消费者,顺带消灭唯一的 `CircleIcon` 用法)、删 `DropdownMenuShortcut`;保留 `Sub` 家族;子菜单箭头换宿主图标;`shadow-md` / `shadow-lg` 保留(真浮层) |
| `scroll-area.tsx` | JobsDrawer / TasksTree | 无 |
| `collapsible.tsx` | JobsDrawer / TasksTree | 无 |
| `toggle-group.tsx` | TaskWindow / TeamBoard | 删 `shadow-none`;`src/client/ui/toggle` → `./toggle` |
| `toggle.tsx` | 无直接消费者(`toggle-group` 的 cva 依赖) | 删 `lg` 尺寸;`toggleVariants` 保留 |
| `skeleton.tsx` | **无消费者**(见上) | 无 |
| `spinner.tsx` | TasksTree | **重建**在宿主 `IconLoadingOutline16` 上(上游用 `Loader2Icon`),可访问状态包一层 `role="status"` |
| `empty.tsx` | SubagentView / TeamBoard | 删 `dark:` 覆写 |
| `utils.ts` | 全部 vendored 组件 | CLI 重建后重写(`clsx` + `twMerge`) |
逐条列出六类**统一改动**(每处在文件里都有注释说明理由):
1. **图标一律换宿主 primitives**:删掉 `lucide-react` 后,vendored 文件里只剩两处图标 import——`spinner.tsx` 的 `IconLoadingOutline16`(并据此重建了组件)与 `dropdown-menu.tsx` 的 `IconChevronRightOutline14`(子菜单箭头)。**没有新写一个 SVG**。宿主图标渲染的就是 `svg`,所以组件里的 `[&_svg]` / `[&_svg:not([class*='size-'])]:size-4` 选择器照旧成立。
2. **`import { cn } from "cn"` → `./utils`**(全部 16 个文件):CLI 默认写的是 `cn` 这个**npm 上真实存在的包名**,本项目没有该依赖;同时 `toggle-group` 的 `src/client/ui/toggle` 改成相对路径 `./toggle`。全目录不再有 `@/lib/utils` 与裸包名 alias。
3. **删所有 `dark:` 颜色覆写**(理由见上)。
4. **非浮层变体删 `shadow-*`**:上游给 `card`(`shadow-sm`)/ `input`(`shadow-xs`)/ `textarea`(`shadow-xs`)/ `toggle`(outline 的 `shadow-xs`)/ `toggle-group`(`shadow-xs` + `data-[spacing=0]:shadow-none`)/ `button`(outline 的 `shadow-xs`)都带了投影,全部删除。迁移后全目录只剩三处 `shadow-*`:`popover.tsx` 一处 `shadow-md`、`dropdown-menu.tsx` 的 `shadow-md` 与 `shadow-lg`。**静态面板零阴影是设计语言,不是遗漏。**
5. **删无消费者的变体 / 尺寸 / 子组件**:`button` 的 `link` + 5 个尺寸、`badge` 的 `ghost` / `link`、`toggle` 的 `lg`、`popover` 的 `PopoverHeader` / `PopoverTitle` / `PopoverDescription`、`dropdown-menu` 的 `DropdownMenuShortcut`。它们都是**运行期对象里的类字符串**,留着就会进 bundle。
6. **`input.tsx` 的 `file:` 类删除是双重理由**:页面没有 `<input type="file">` 面,且它们编译出 `::file-selector-button` 选择器——那正是 Tailwind preflight 的特征串,构建产物守卫用它证明「preflight 从未入包」。保留这 7 条会让该守卫**永远误报**。守卫本身也据此收紧了识别方式:改判「chunk CSS 里的重置声明负载 + html 块」,而不是选择器拼写(`tests/ui-bundle.spec.ts` 的 docstring 记录了这次修正)。
外加上游 CLI 之外的两件事:`package.json` 删除 CLI 注入的 `cn` 依赖;`theme.css` 与 `utils.ts` 曾在迁移中误删,按同一定义重建并重新逐条复核(两者都由 `tests/ui-foundation.spec.ts` 完整守护,重建后全绿)。
### 阴影策略(静态面板零阴影,仅浮层)
层级由「重量」承担:1px `border-border` hairline + 表面阶梯(`background` → `card`/`layer-1` → `popover`/`layer-2`)+ 三档墨色。**阴影只允许出现在真正浮动、由自己的锚定几何定位的层**:
| 允许阴影 | 位置 |
|---|---|
| `AnchoredPopover`(`shadow-lg`) | 任务窗口 / 节点详情 / 输出浮窗的外壳(自建锚定 + 拖动) |
| `ui/popover.tsx`(`shadow-md`) | portal 渲染的 shadcn popover |
| `ui/dropdown-menu.tsx`(`shadow-md` / `shadow-lg`) | portal 渲染的菜单与子菜单 |
| `ui/tooltip.tsx`(**白名单成员但当前不带阴影**) | portal 渲染的 tooltip 本来就是实心 `bg-foreground` 反色块,不需要投影;列入 `FLOATING_FILES` 只是承认它是浮层 |
守护是**源码级**的(`tests/ui-shadows.spec.ts`):`STATIC_PANEL_FILES`(页面内布局的 10 个文件)不得出现任何 Tailwind 阴影工具类,`FLOATING_FILES` 是唯一白名单,且最后一条断言要求白名单**不空转**(`AnchoredPopover` 必须真的带阴影,否则白名单在替别处的回归打掩护)。`box-shadow` 声明式写法同样被禁。
### 实测体积与 mount 结果(含返工:把任务页下沉为懒加载 chunk)
第一次门禁的唯一未达成项是体积:迁移后 `lib/client.js` **1452623 bytes(1418.6 KiB)**,比基线 +457.6 KiB,超出计划里 +250 KiB 的预算。定位结论是**浮层栈的固有成本**(`radix-ui` 的 popover / dropdown-menu / tooltip / scroll-area / collapsible 一族 + `floating-ui` + `react-remove-scroll` + `tailwind-merge`,逐生成行归属:浮动层 263.9 KiB、tailwind-merge 59.4 KiB)。
处置不是放宽预算,而是**把这层负载移出启动路径**:任务页(工作流图 + 树 + 任务窗口 + 团队任务板 + 后台任务抽屉 + 整个 vendored shadcn 层与其样式表)整体下沉为既有的懒加载 chunk——
- `src/client/chunks/tasks.tsx` 只 re-export `SubagentView`,并 `import '../ui/theme.css'`(Tailwind 产物随 chunk 走,不占核心包);
- `CHUNK_NAMES`(`src/bundle-route.ts`)与 `CHUNKS`(`tsdown.config.ts`)与 `ChunkName`(`chunk-loader.ts`)各加一个 `tasks`,产出 `lib/client-tasks.js`,经 `/sidebar/bundle/tasks.js` 按需下发;
- 任务页的 tab 描述符改用 `lazyChunkComponent('tasks', mod => mod.SubagentView)`(与终端同款机制:加载中显示占位、失败显示重试),**核心包不再静态 import 任何 chunk 入口**;
- 从未打开任务页的用户完全不下载这 700 KiB;stub/树/浮窗的既有 hook 与行为全部不变。
返工后实测(同一次 `pnpm build`):
| 指标 | 值 |
|---|---|
| `lib/client.js`(迁移前基线,HEAD `12b4716` 同工具链实测) | 1012271 bytes = 988.5 KiB |
| `lib/client.js`(迁移后) | **855612 bytes = 835.6 KiB** |
| 核心包增量 | **−156659 bytes(−153.0 KiB,比迁移前更小)** |
| `lib/client-tasks.js`(打开任务页时才拉) | 723656 bytes = 706.7 KiB |
| 体积预算(核心包 ≤ 基线 +250 KiB) | ✅ 达成(且核心包净减) |
| 核心包含 Tailwind / radix / tailwind-merge / `oklch(` / `lucide-react` | 全部为 0(由 `tests/ui-bundle.spec.ts` 断言) |
| `lib/client-tasks.js` 含 Tailwind 产物 + radix + 令牌桥 | ✅(同一 spec 断言) |
因此 `tests/ui-bundle.spec.ts` 现在**双向断言**:核心包必须干净(无 utilities/radix/tailwind-merge、无 preflight、无调色板字面量)且 ≤ 基线 +250 KiB;chunk 必须携带 Tailwind 产物、radix 层、`var(--dsw-…)` 桥接与 `__dshChunks__["tasks"]` 槽位,同样不得带 preflight/oklch/lucide。
其余门禁(同一份构建产物):
- `pnpm typecheck` 0;`pnpm lint` 0;`pnpm vitest run` **136 文件 / 1499 用例通过 / 9 skipped**;
- `pnpm build` 的四条产物断言全绿:Tailwind 产物确在包内(`--tw-*` + `.flex{`)、**无 preflight**、**无 `oklch(`**、**无 `lucide-react`**;
- `pnpm pack` ✅;`pnpm test:mount`(真实挂载 + 无头 tab 全扫)**7/7**,`PERF_JSON`:`mountLatencyMs 437`、`longtaskCount 2`(≤ 8 预算)、并且资源清单里出现 **`/sidebar/bundle/tasks.js`** —— 懒加载路径在真实 DSH 外壳里被走到。
新增的守护文件:`tests/ui-foundation.spec.ts`(入口 import 清单 / 令牌桥全表 / 级联层 / 作用域根类 / `@source`)、`tests/ui-bundle.spec.ts`(核心包与 chunk 的双向产物断言 + 体积预算)、`tests/ui-shadows.spec.ts`(静态面板零阴影)。`tests/theme.spec.ts` 追加「迁移后皮肤契约」一节:`src/client/ui/**` 与迁移文件的**颜色字面量**、**Tailwind 默认调色板类**与**未解析 `var()`** 三类回归。
### 迁移返工记录(门禁与审查发现)
**1. 体积 → 任务页整体下沉为懒加载 chunk。** 见上一节的返工说明:核心包从 988.5 KiB 降到 835.6 KiB(净减 153.0 KiB),shadcn/radix/Tailwind 产物(706.7 KiB)随 `lib/client-tasks.js` 按需加载。这次返工同时把 Tailwind 样式表挪进 chunk——它只服务任务页,没有理由占启动路径。
**2. React 18 的 ref 兼容(vendored 组件的必要本地适配)。** DSH 宿主跑的是 **React 18**(`@deepseek-ai/dsh-client-ui-primitives` 的 peer 是 `react@^18.2.0`),而 shadcn registry 的组件是按 **React 19** 写的(19 起 `ref` 是普通 prop,所以上游 `Button` 刻意不写 `forwardRef`)。后果:`<TooltipTrigger asChild><Button/></TooltipTrigger>` 里 radix 的 `Slot` 把 ref 递给函数组件会被丢掉 —— **dev 构建打印 "Function components cannot be given refs",生产构建静默地量不到锚点**(浮层定位失效)。修法是在 `src/client/ui/button.tsx` 上做一处有注释的本地适配(`React.forwardRef`),并在文件里写明升级时会被 CLI `--diff` 抹掉、需要重新施加。**这条只在 dev/可视化 harness 里可见,生产 bundle 不报错,所以 mount e2e 抓不到它** —— 依赖 harness 的 console 断言。
**3. 密度回归 → 卡片两行标题。** 迁移把卡片文字从 11px 提到 shadcn 的 13px,但节点宽度仍是 132px:实测标题只剩约 7 个汉字("图形重布重写" 被截成 "图…"),密集场景下比迁移前更难读。修法:`GRAPH_NODE_W` 132 → 150、标题改 `text-xs` + `line-clamp-2` + `[overflow-wrap:anywhere]`、`GRAPH_NODE_H` 46 → 60 预留两行高度(`GRAPH_LIVE_H` 13 → 14)。**教训:换字号必须同步核对卡片宽度预算**。
**4. 模型去重(重复 React key 的根因)。** 可视化 harness 在压力 fixture 上抓到 `sub-live-011` 等三个重复 key:根因在 `tasks-model.ts` —— workflow member 的 `childId` 若**不在 run 发起者的 catalog 里**(但存在于树中别处),代码会走"合成节点"分支,生成一个与真实节点**同 id** 的第二张卡片。修法:先按整棵树收集 `knownAgentIds`,只对 catalog 完全不知道的 id 合成节点;并在返回前加一条"每个 session id 只出现一次"的兜底去重。新增两条单测(已知 member 不重复 / 未知 member 仍合成)。
### 升级路径(未来用 shadcn CLI `--diff` 跟进上游)
`components.json` 已把 CLI 上下文钉在这个仓库上,`npx -y shadcn@latest info` 能正确列出 16 个已装组件(`base: radix`、`tailwindVersion: v4`、`tailwindCss: src/client/ui/theme.css`),所以**不要手动从 GitHub 抄文件**,走 CLI:
```bash
npx -y shadcn@latest info # 当前上下文与已装组件清单
npx -y shadcn@latest add <component> --dry-run # 先看会动哪些文件
npx -y shadcn@latest add <component> --diff # 逐文件看上游 vs 本地差异
npx -y shadcn@latest add <component> # 确认后再落盘
```
**实测**(`npx -y shadcn@latest add button --diff`,本次迁移后执行):上游会把 `cn` 的 import 写回裸包名 `"cn"`、带回 `dark:bg-destructive/60` 与 `dark:focus-visible:ring-destructive/40`、把 `text-destructive-foreground` 换回 `text-white`、给 `outline` 加回 `shadow-xs`、并恢复被删的 `link` 变体与 `xs` / `lg` / `icon-xs` / `icon-sm` / `icon-lg` 五个尺寸——正是本文件记录的六类统一改动。所以升级的流程是**逐文件判断**:没有本地改动的文件可被覆盖,有本地改动的(几乎全部)读 diff 后**只取上游的结构性更新,重新施加本仓库的六类改动**——尤其 `input.tsx` 的 `file:` 类一旦回来,`tests/ui-bundle.spec.ts` 的 preflight 守卫会开始误报。落盘后必须复跑三个守卫 spec(`ui-foundation` / `ui-bundle` / `ui-shadows`)与 `pnpm build`(体积测量是 `ui-bundle` 的一部分),再跑 `pnpm vitest run tests/theme.spec.ts` 确认皮肤契约未被上游带回的颜色字面量破坏。
`--overwrite` 与本仓库不兼容:它会一次性抹掉上面全部改动。真要用,先 `git stash` 出可对比的基线。
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Compile the plugin's Tailwind entry (src/client/ui/theme.css) to a standalone
* stylesheet.
*
* The production path is tsdown's css-inline plugin (tsdown.config.ts), which
* inlines the compiled CSS into lib/client.js as a `<style data-plugin>` tag.
* The local visual harness (/tmp/dsh-visual) instead renders the REAL task-page
* components against a tokens stylesheet in a plain HTML page, so it needs the
* same utilities as a file it can `<link>` — this script is that file, built
* from the same pipeline (tsdown.config.ts exports `compileTailwind`) so the two
* cannot drift.
*
* Usage: node scripts/ui-css.mjs [outFile] (default /tmp/dsh-visual/tailwind.css)
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { compileTailwind } from '../tsdown.config.ts'
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const ENTRY = resolve(ROOT, 'src/client/ui/theme.css')
const DEFAULT_OUT = '/tmp/dsh-visual/tailwind.css'
const outFile = resolve(process.argv[2] ?? DEFAULT_OUT)
// `from`/`base` stay anchored to the real entry file, so `@source` and
// `@import` resolve against src/client/ui regardless of the cwd we run from.
const source = await compileTailwind(await readFile(ENTRY, 'utf8'), ENTRY, { minify: false })
await mkdir(dirname(outFile), { recursive: true })
await writeFile(outFile, source)
process.stdout.write(`ui:css -> ${outFile} (${source.length} bytes)\n`)
+5
View File
@@ -154,6 +154,11 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
<div <div
ref={cardRef} ref={cardRef}
role="dialog" role="dialog"
// Presentation only: geometry stays inline (the drag math owns
// `left`/`top`/`width`), the surface takes the shadcn popover tokens, and
// `box-border` keeps the declared width the measured width so the drag
// clamp above stays exact.
className="box-border rounded-lg border border-border bg-popover text-popover-foreground shadow-lg"
style={{ style={{
position: 'fixed', position: 'fixed',
left: pos?.left ?? -9999, left: pos?.left ?? -9999,
+213 -104
View File
@@ -11,12 +11,21 @@
* cursor), with a copy action, a follow-latest switch, the two-click kill, * cursor), with a copy action, a follow-latest switch, the two-click kill,
* and a terminal-style tail while the job runs. * and a terminal-style tail while the job runs.
* *
* Every control is a host primitive (Button / Tag / Switch / StateDot). * Visual language: Tailwind utilities over the shadcn tokens
* (src/client/ui/theme.css) with the vendored Collapsible / ScrollArea /
* Button for the interactive shells. Hierarchy comes from a 1px `border-border`
* hairline plus the surface ladder (`bg-background` → `bg-muted` on hover) —
* the static drawer carries no shadow; the only float is the popover card,
* whose surface/shadow belong to AnchoredPopover. Body copy is 13px, meta
* 11–12px, identifiers/durations `font-mono tabular-nums`.
*
* Every non-shadcn control is a host primitive (Switch / StateDot), and every
* icon is a host `IconXxx` glyph — the badges are the vendored shadcn `Badge`
* on the page's shared outline + semantic-tone classes.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { import {
Button, IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch, Tag, IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch,
type TagTone,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarJobView } from '../context-types.ts' import type { SidebarJobView } from '../context-types.ts'
import { import {
@@ -28,7 +37,11 @@ import {
} from './subagent-jobs.ts' } from './subagent-jobs.ts'
import { api, type JobOutputResult } from './api.ts' import { api, type JobOutputResult } from './api.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import css from './tasks-graph.module.css' import { Badge } from './ui/badge.tsx'
import { Button } from './ui/button.tsx'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.tsx'
import { ScrollArea } from './ui/scroll-area.tsx'
import { cn } from './ui/utils.ts'
/** Refresh cadence of an open job-output popover while its job runs. */ /** Refresh cadence of an open job-output popover while its job runs. */
const JOB_POLL_MS = 2000 const JOB_POLL_MS = 2000
@@ -37,12 +50,16 @@ const JOB_KILL_ARM_MS = 3000
/** The agent count at which the drawer starts collapsed. */ /** The agent count at which the drawer starts collapsed. */
export const JOBS_DRAWER_COLLAPSE_AT = 8 export const JOBS_DRAWER_COLLAPSE_AT = 8
/** The Tag tone of one job status. */ /**
function jobTone(job: SidebarJobView): TagTone { * The status badge's semantic ink (the page's shared `outline` + tone class
if (job.status === 'running') return 'info' * pattern): a running job wears the accent, success/failure their own family,
if (job.status === 'completed') return 'success' * and an ending/killed job stays neutral.
if (job.status === 'failed') return 'danger' */
return 'neutral' function statusTone(job: SidebarJobView): string {
if (job.status === 'running') return 'border-primary/40 text-primary'
if (job.status === 'completed') return 'border-success/40 text-success'
if (job.status === 'failed') return 'border-destructive/40 text-destructive'
return 'text-muted-foreground'
} }
export interface JobsDrawerProps { export interface JobsDrawerProps {
@@ -109,83 +126,147 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
: t('jobsCount', { count: rows.length }) : t('jobsCount', { count: rows.length })
return ( return (
<section className={css.jobsDrawer} aria-label={t('jobs')}> <Collapsible
<button open={open}
type="button" onOpenChange={(next: boolean) => { setManualOpen(next) }}
className={css.jobsDrawerBar} role="region"
aria-expanded={open} aria-label={t('jobs')}
onClick={() => { setManualOpen(!open) }} className="z-[5] flex-none border-t border-border bg-background"
>
{/* The bar keeps the drawer's whole toggle affordance (title, running
tally, count line) and Radix owns aria-expanded / aria-controls. */}
<CollapsibleTrigger
className="flex w-full cursor-pointer items-center gap-2 rounded-none border-0 bg-transparent px-3 py-[7px] text-left font-mono text-[11px] tracking-[0.12em] text-muted-foreground uppercase transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
> >
<span>{t('jobs')}</span> <span>{t('jobs')}</span>
<span className={css.jobsBigNum}>{liveCount > 0 ? liveCount : rows.length}</span> <span className="text-[13px] font-semibold text-primary tabular-nums">
<span className={css.jobsDrawerCount}>{countLabel}</span> {liveCount > 0 ? liveCount : rows.length}
<span className={css.jobsDrawerChev} data-open={open ? 'true' : 'false'} aria-hidden="true"> </span>
<span className="tracking-[0.02em] text-foreground-3 normal-case">{countLabel}</span>
<span
className={cn(
'ml-auto inline-flex text-foreground-3 transition-transform duration-150',
open && 'rotate-180',
)}
aria-hidden="true"
>
<IconChevronUpOutline14 size={12} /> <IconChevronUpOutline14 size={12} />
</span> </span>
</button> </CollapsibleTrigger>
{!autoOpen && <div className={css.jobsAutoNote}>{t('jobsAutoCollapsed')}</div>} {!autoOpen && (
{open && ( <div className="px-3 pb-2 font-mono text-[11px] text-foreground-3">{t('jobsAutoCollapsed')}</div>
<div className={css.jobsDrawerBody}>
{rows.map((row) => {
const { job } = row
const live = isJobLive(job)
const armed = armedId === job.id
const killing = killingId === job.id
const killFailed = killErrorId === job.id
const elapsed = live
? now - job.startedAt
: (job.finishedAt ?? job.startedAt) - job.startedAt
const secondary = [
...(multiOwner ? [row.ownerTitle] : []),
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
formatJobDuration(elapsed, t),
].filter(Boolean).join(' · ')
return (
<div
key={job.id}
className={css.jobsRow}
data-settled={live ? 'false' : 'true'}
data-open={openJobId === job.id ? 'true' : 'false'}
>
<Button
variant="ghost"
size="sm"
className={css.jobsRowMain}
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
title={t('jobViewOutput')}
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
>
<StateDot state={jobDotState(job.status)} size={6} />
<Tag tone="quiet">{job.kind}</Tag>
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag>
<span className={css.jobsMeta}>{secondary}</span>
</Button>
{job.status === 'running' && (
<Button
variant="outline"
size="sm"
className={css.jobsKill}
data-armed={armed ? 'true' : 'false'}
icon={<IconStopFill16 size={11} />}
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing}
onClick={() => {
if (armed) void kill(row)
else setArmedId(job.id)
}}
>
{armed ? t('jobKillConfirm') : undefined}
</Button>
)}
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
</div>
)
})}
</div>
)} )}
</section> <CollapsibleContent>
{/* Bounded log surface: one row per job, hairline-separated by the
rows' own 4px rhythm rather than per-row borders. The height is
definite (not `max-h`) because ScrollArea's viewport is `size-full`:
a bare max-height leaves the viewport content-sized, so the rows
would spill out of the drawer instead of scrolling. */}
<ScrollArea className="h-[156px] w-full min-w-0 border-t border-dashed border-border">
<div className="flex min-w-0 flex-col gap-0.5 px-2 pt-1 pb-2">
{rows.map((row) => {
const { job } = row
const live = isJobLive(job)
const armed = armedId === job.id
const killing = killingId === job.id
const killFailed = killErrorId === job.id
const elapsed = live
? now - job.startedAt
: (job.finishedAt ?? job.startedAt) - job.startedAt
const duration = formatJobDuration(elapsed, t)
// The owner/detail tail; the duration is its own tabular-nums
// cell, while the accessible name keeps the exact
// `owner · detail · duration` string it always had.
const context = [
...(multiOwner ? [row.ownerTitle] : []),
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
].join(' · ')
const secondary = [context, duration].filter(Boolean).join(' · ')
return (
<div
key={job.id}
className={cn(
'flex items-center gap-[7px] rounded-md px-1.5 py-1 transition-colors',
'hover:bg-muted',
openJobId === job.id && 'bg-muted',
// A settled job recedes by ink (the label line drops to the
// secondary level), not by opacity.
!live && 'text-muted-foreground',
)}
data-settled={live ? 'false' : 'true'}
data-open={openJobId === job.id ? 'true' : 'false'}
>
<Button
variant="ghost"
size="sm"
className="h-auto min-w-0 flex-1 justify-start gap-[7px] px-0 py-0 text-left text-[13px] font-normal tracking-normal normal-case"
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
title={t('jobViewOutput')}
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
>
{/* `size-1.5` opts the dot out of the Button's
`[&_svg:not([class*='size-'])]:size-4` rule (the
running state draws an svg, the others a span), so the
indicator keeps its 6px. */}
<StateDot state={jobDotState(job.status)} size={6} className="size-1.5" />
<Badge variant="outline" className="h-4 flex-none px-1.5 py-0 font-mono text-[11px] font-normal text-muted-foreground">
{job.kind}
</Badge>
<span
className={cn(
'min-w-0 flex-1 truncate font-mono text-[13px]',
live ? 'text-foreground' : 'text-muted-foreground',
)}
title={job.label}
>
{job.label}
</span>
<Badge variant="outline" className={cn('h-4 flex-none px-1.5 py-0 text-[11px] font-normal', statusTone(job))}>
{jobStatusLabel(job.status, t)}
</Badge>
{context !== '' && (
<span className="max-w-[45%] shrink-0 truncate text-xs text-muted-foreground">
{context}
</span>
)}
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{duration}
</span>
</Button>
{job.status === 'running' && (
<Button
variant="outline"
size="sm"
// `border-border` is explicit: preflight is never loaded
// (see ui/theme.css), so a bare `border` here would take
// `currentColor` instead of the hairline token.
className={cn(
'h-auto shrink-0 border-border px-1.5 py-0.5 font-mono text-[11px] font-normal text-muted-foreground',
armed && 'border-destructive text-destructive',
)}
data-armed={armed ? 'true' : 'false'}
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing}
onClick={() => {
if (armed) void kill(row)
else setArmedId(job.id)
}}
>
<IconStopFill16 size={11} className="size-[11px]" />
{armed ? t('jobKillConfirm') : undefined}
</Button>
)}
{killFailed && (
<span className="shrink-0 font-mono text-[11px] text-destructive">{t('jobKillError')}</span>
)}
</div>
)
})}
</div>
</ScrollArea>
</CollapsibleContent>
</Collapsible>
) )
} }
@@ -195,6 +276,12 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
* {@link JOB_POLL_MS} while the job runs and the page is visible. The reader * {@link JOB_POLL_MS} while the job runs and the page is visible. The reader
* can copy the output and turn the terminal-style tail off; the kill action * can copy the output and turn the terminal-style tail off; the kill action
* keeps its two-click confirm. * keeps its two-click confirm.
*
* The card fills the AnchoredPopover surface (rounded-lg + border + shadow
* live there, since that host owns the float) and only places its content
* inside it: header, title line, meta line, the terminal tail, and the action
* footer. `data-popover-handle` / `data-popover-no-drag` keep the drag
* contract unchanged — the body drags, the controls do not.
*/ */
export function JobOutputPopoverContent(props: { export function JobOutputPopoverContent(props: {
ownerSessionId: string ownerSessionId: string
@@ -273,14 +360,17 @@ export function JobOutputPopoverContent(props: {
} }
return ( return (
<div className={css.popCard} data-popover-handle> // `dsw-tasks`: this card renders inside AnchoredPopover's portal at
<div className={css.popHead}> // document.body — OUTSIDE the page root — so it re-declares the page root
// class for the scoped base reset (see TaskWindow.tsx).
<div className="dsw-tasks box-border flex flex-col gap-1 p-2.5 text-[13px] text-popover-foreground" data-popover-handle>
<div className="mb-1 flex items-baseline justify-between gap-2 font-mono text-[11px] tracking-[0.14em] text-foreground-3 uppercase">
<span>{t('jobs')}</span> <span>{t('jobs')}</span>
<span className={css.popHeadActions} data-popover-no-drag> <span className="inline-flex items-center gap-0.5 tracking-normal normal-case" data-popover-no-drag>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
icon={<IconCopyOutline16 size={12} />} className="size-7 p-0"
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')} aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
title={copied ? t('jobCopied') : t('jobCopyOutput')} title={copied ? t('jobCopied') : t('jobCopyOutput')}
disabled={text === ''} disabled={text === ''}
@@ -290,32 +380,51 @@ export function JobOutputPopoverContent(props: {
window.setTimeout(() => { setCopied(false) }, 1500) window.setTimeout(() => { setCopied(false) }, 1500)
}) })
}} }}
/> >
<IconCopyOutline16 size={12} className="size-3" />
</Button>
</span> </span>
</div> </div>
<div className={css.jobPopTitle}> <div className="flex min-w-0 items-center gap-1.5">
<StateDot state={jobDotState(job.status)} size={6} /> <StateDot state={jobDotState(job.status)} size={6} />
<span className={css.popTitle} title={job.label}>{job.label}</span> <span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground" title={job.label}>
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag> {job.label}
</span>
<Badge variant="outline" className={cn('h-5 flex-none px-2 text-[11px]', statusTone(job))}>
{jobStatusLabel(job.status, t)}
</Badge>
</div> </div>
<div className={css.jobPopMeta}> <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<Tag tone="quiet">{job.kind}</Tag> <Badge variant="outline" className="h-4 flex-none px-1.5 py-0 font-mono text-[11px] font-normal text-muted-foreground">
{job.kind}
</Badge>
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>} {job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
<span className={css.popHint}>{t('jobDragHint')}</span> <span className="font-mono text-xs text-foreground-3">{t('jobDragHint')}</span>
</div> </div>
{state === 'loading' && <div className={css.popHint}>{t('loading')}</div>} {state === 'loading' && <div className="text-xs text-foreground-3">{t('loading')}</div>}
{state === 'error' && <div className={css.popError}>{t('jobOutputError')}</div>} {state === 'error' && <div className="text-xs text-destructive">{t('jobOutputError')}</div>}
{typeof state === 'object' && ( {typeof state === 'object' && (
<> <>
{state.text.length > 0 {state.text.length > 0
? <pre ref={preRef} className={css.popPre} data-popover-no-drag>{state.text}</pre> ? (
<pre
ref={preRef}
className="mt-1.5 max-h-[168px] overflow-auto rounded-md border border-border bg-background p-2 font-mono text-xs leading-[1.55] break-words whitespace-pre-wrap text-muted-foreground"
data-popover-no-drag
>
{state.text}
</pre>
)
: state.read : state.read
? <div className={css.popHint}>{t('jobNoOutput')}</div> ? <div className="text-xs text-foreground-3">{t('jobNoOutput')}</div>
: <div className={css.popHint}>{t('jobNotReadYet')}</div>} : <div className="text-xs text-foreground-3">{t('jobNotReadYet')}</div>}
{state.truncated && <div className={css.popHint}>{t('jobOutputTruncated')}</div>} {state.truncated && <div className="text-xs text-foreground-3">{t('jobOutputTruncated')}</div>}
</> </>
)} )}
<div className={css.jobPopActions} data-popover-no-drag> <div
className="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2"
data-popover-no-drag
>
<Switch <Switch
checked={follow} checked={follow}
onChange={setFollow} onChange={setFollow}
@@ -324,21 +433,21 @@ export function JobOutputPopoverContent(props: {
/> />
{live && ( {live && (
<Button <Button
variant={armed ? 'primary' : 'outline'} variant={armed ? 'destructive' : 'outline'}
size="sm" size="sm"
className={armed ? css.jobPopKillArmed : undefined} className="border-border font-mono text-xs"
icon={<IconStopFill16 size={11} />}
disabled={killing} disabled={killing}
onClick={() => { onClick={() => {
if (armed) void kill() if (armed) void kill()
else setArmed(true) else setArmed(true)
}} }}
> >
<IconStopFill16 size={11} className="size-[11px]" />
{armed ? t('jobKillConfirm') : t('jobKill')} {armed ? t('jobKillConfirm') : t('jobKill')}
</Button> </Button>
)} )}
</div> </div>
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>} {killFailed && <div className="text-xs text-destructive">{t('jobKillError')}</div>}
</div> </div>
) )
} }
+15 -227
View File
@@ -1,233 +1,21 @@
/* Tasks page shell: header row, empty/error states, and the state dot. The /* The Tasks page shell's OWN container geometry.
graph canvas / tree rows / popovers / jobs drawer live in *
tasks-graph.module.css; the old indentation-tree and jobs-dock classes * Everything visual on this page (header row, empty state, error banner,
were pruned when the page moved to the unified model. */ * popovers) is expressed with Tailwind utilities over the shadcn tokens in
* src/client/ui/theme.css, so this module no longer carries any of it. What
* stays here is the one thing utilities cannot own: the page root is the child
* of the native tab host's column flex box, and the HOST's stylesheets are
* unlayered — an unlayered host rule outranks anything Tailwind puts in
* `layer(utilities)`, so `flex: 1 / min-height: 0` must not ride a utility
* class.
*
* `.subagent` travels with the `dsw-tasks` root class (SubagentView.tsx), which
* carries the scoped base reset (box-sizing, control typography, focus ring,
* thin scrollbars) for every utility-styled element inside the page.
*/
.subagent { .subagent {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* The page header keeps the mockup's rhythm: a 13px semibold title, a mono
count, and a hairline underline that separates chrome from content. */
.subagentHeader {
flex: none;
display: flex;
align-items: baseline;
gap: 8px;
padding: 10px 10px 9px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l4);
}
.subagentTitle {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.35;
font-weight: 600;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The descendant count badge ("3 · 1 运行中") beside the title. */
.subagentCount {
flex: none;
font-family: var(--ds-font-family-code);
font-size: 10px;
color: var(--dsw-alias-label-secondary);
}
.subagentRefresh {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 3px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.subagentRefresh:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.subagentRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Keyboard focus (mirror of the official catalog row): a visible fill only
when focused via keyboard; the selected state below still wins on top. */
.subagentRow:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
/* The currently-open session, highlighted in place (mirror of the workspace
`.sessionRow.selected` — declared after the hover/focus rules so it wins). */
.subagentRowActive,
.subagentRowActive:hover,
.subagentRowActive:focus-visible {
background: var(--dsw-alias-interactive-bg-active);
}
.subagentRowDisabled:hover {
background: transparent;
}
/* Loading placeholders are disabled but keep the default cursor (official
`.loadingRow` distinction from `.disabled`). */
/* The state dot aligns to the first text line (official catalog detail). */
.subagentDot {
margin-top: 4px;
}
.subagentLabel,
/* ── Live lines (running cards only) ──────────────────────────────────── */
/* ── Tree connector lines (official catalog recipe) ────────────────────── */
.subagentChildren::before {
content: '';
position: absolute;
left: 0;
top: -26px;
height: 26px;
border-left: 1px solid var(--dsw-alias-border-l2);
}
.subagentChildren[aria-busy='true']::before {
content: none;
}
.subagentChildren > .subagentNode::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -4px;
border-left: 1px solid var(--dsw-alias-border-l2);
}
.subagentChildren > .subagentNode:last-child::before {
bottom: auto;
height: 17px;
}
.subagentChildren > .subagentNode > .subagentRow::before {
content: '';
position: absolute;
top: 16px;
left: -4px;
width: 14px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.subagentEmpty {
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.subagentEmptyHint {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-dimmed);
}
.subagentError {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
}
.subagentErrorRetry {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-strong-11);
cursor: pointer;
}
.subagentErrorRetry:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Background-job section (below the topology tree) ────────────────── */
/* One job row; the whole row lights up on hover, the expandable target
stays transparent (the kill button rides on the row's right edge). */
.jobsRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* The row whose output is shown in the bottom dock. */
.jobsRowSelected,
.jobsRowSelected:hover {
background: var(--dsw-alias-interactive-bg-active);
}
.jobsRowMain:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
/* The command line: a kind badge followed by the command itself in the code
face, like the terminal's live lines. */
.jobsKill:hover {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
}
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
.jobsKillArmed,
.jobsKillArmed:hover {
width: auto;
height: 20px;
padding: 0 8px;
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
}
.jobsKill:disabled {
opacity: 0.5;
cursor: default;
}
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
edge while the job list scrolls above it; a solid background keeps the
tree from showing through. */
.jobsPaneClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
+56 -28
View File
@@ -25,7 +25,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import { import {
IconRefreshOutline14, IconAgentPresetOutline16, IconRefreshOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { import type {
Context, Context,
@@ -52,6 +52,9 @@ import { TeamBoard } from './TeamBoard.tsx'
import { TaskPopover } from './TaskWindow.tsx' import { TaskPopover } from './TaskWindow.tsx'
import type { SidebarStore } from './state.ts' import type { SidebarStore } from './state.ts'
import type { WorkflowRunView } from '../workflow-runs.ts' import type { WorkflowRunView } from '../workflow-runs.ts'
import { Button as UiButton } from './ui/button.tsx'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from './ui/empty.tsx'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip.tsx'
import legacy from './SubagentView.module.css' import legacy from './SubagentView.module.css'
/** Refresh cadence of the live "last text + tool call" lines while a child runs. */ /** Refresh cadence of the live "last text + tool call" lines while a child runs. */
@@ -411,28 +414,46 @@ export function SubagentView(props: {
})() })()
return ( return (
<div className={legacy.subagent} style={{ position: 'relative' }}> <div className={`dsw-tasks ${legacy.subagent} relative`}>
<div className={legacy.subagentHeader}> {/*
<span className={legacy.subagentTitle}> Page header: title + mono descendant count + refresh, closed by a 1px
hairline. The control cluster (view toggle / fold / zoom) stays on the
canvas itself, so it is visible in BOTH modes.
*/}
<div className="flex flex-none items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{t('subagent')} {t('subagent')}
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== '' {rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
? ` · ${rootSummary.displayTitle}` ? ` · ${rootSummary.displayTitle}`
: ''} : ''}
</span> </span>
{countLabel !== undefined && <span className={legacy.subagentCount}>{countLabel}</span>} {countLabel !== undefined && (
<button <span className="flex-none font-mono text-[11px] tabular-nums text-muted-foreground">
type="button" {countLabel}
className={legacy.subagentRefresh} </span>
aria-label={t('refresh')} )}
title={t('refresh')} {/* Delay so the label never flashes while the pointer crosses the row. */}
disabled={rootId === undefined} <TooltipProvider delayDuration={300}>
onClick={() => { <Tooltip>
if (rootId !== undefined) refresh(rootId) <TooltipTrigger asChild>
team.refresh() <UiButton
}} variant="ghost"
> size="icon"
<IconRefreshOutline14 /> className="size-7 flex-none"
</button> aria-label={t('refresh')}
title={t('refresh')}
disabled={rootId === undefined}
onClick={() => {
if (rootId !== undefined) refresh(rootId)
team.refresh()
}}
>
<IconRefreshOutline14 size={13} />
</UiButton>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-[11px]">{t('refresh')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
{rootId !== undefined && teamView?.available === true && teamView.team !== null && ( {rootId !== undefined && teamView?.available === true && teamView.team !== null && (
<TeamBoard <TeamBoard
@@ -445,23 +466,29 @@ export function SubagentView(props: {
/> />
)} )}
{failedParents.length > 0 && ( {failedParents.length > 0 && (
<div className={legacy.subagentError}> <div className="flex flex-none items-center justify-between gap-2 px-3 py-2 text-xs text-destructive">
<span>{t('catalogLoadFailed', { count: failedParents.length })}</span> <span>{t('catalogLoadFailed', { count: failedParents.length })}</span>
<button <UiButton
type="button" variant="ghost"
className={legacy.subagentErrorRetry} size="sm"
className="flex-none"
onClick={() => { for (const parent of failedParents) refresh(parent) }} onClick={() => { for (const parent of failedParents) refresh(parent) }}
> >
<IconRefreshOutline14 /> <IconRefreshOutline14 size={12} />
{t('retry')} {t('retry')}
</button> </UiButton>
</div> </div>
)} )}
{readyEmpty && ( {readyEmpty && (
<div className={legacy.subagentEmpty}> <Empty className="flex-1 gap-4 border-0 p-4">
<div>{t('subagentEmpty')}</div> <EmptyHeader className="gap-2">
<div className={legacy.subagentEmptyHint}>{t('subagentEmptyDesc')}</div> <EmptyMedia variant="icon" className="size-8 rounded-md">
</div> <IconAgentPresetOutline16 size={16} />
</EmptyMedia>
<EmptyTitle className="text-sm font-medium">{t('subagentEmpty')}</EmptyTitle>
<EmptyDescription className="text-xs">{t('subagentEmptyDesc')}</EmptyDescription>
</EmptyHeader>
</Empty>
)} )}
{!readyEmpty && rootId !== undefined && ( {!readyEmpty && rootId !== undefined && (
mode === 'graph' mode === 'graph'
@@ -476,6 +503,7 @@ export function SubagentView(props: {
onToggleFold={() => { setFolded(current => !current) }} onToggleFold={() => { setFolded(current => !current) }}
mode={mode} mode={mode}
onModeChange={setModeOverride} onModeChange={setModeOverride}
loading={summaryBackedLoading}
/> />
) )
: ( : (
+156 -92
View File
@@ -7,24 +7,40 @@
* Shape: a non-fullscreen, DRAGGABLE card (never a modal dialog). Details * Shape: a non-fullscreen, DRAGGABLE card (never a modal dialog). Details
* render as multi-line MARKDOWN by default; 编辑 switches the same card into * render as multi-line MARKDOWN by default; 编辑 switches the same card into
* multi-line editing, so reading and writing never jump between surfaces. * multi-line editing, so reading and writing never jump between surfaces.
* Actions: owner reassignment (Pills, CAS-immediate), edit/save, reopen or * Actions: owner reassignment (CAS-immediate), edit/save, reopen or complete,
* complete, and a two-step delete. * and a two-step delete.
* *
* Host primitives everywhere (Button / Input / Pill / Tag / StateDot) plus * Visual base: the vendored shadcn/ui set — `Card` shell, `Badge` status,
* the plugin's own `MultilineField` (the host set ships no multi-line input) * `Input` / `Textarea` fields, a `ToggleGroup` owner picker and `Button`
* and the shared `MarkdownText` renderer with the plugin's copy labels. * actions — over the token bridge in src/client/ui/theme.css, plus the shared
* `MarkdownText` renderer with the plugin's copy labels. The shell paints no
* surface of its own: the window is portaled to `document.body`, where
* `AnchoredPopover` owns the floating border/radius/shadow, so this card is
* layout and content only (the page's "static panels cast no shadow" rule).
*
* That portal also sits OUTSIDE the task page root, so the card re-declares
* the page root class (`dsw-tasks`) to pick up the scoped base reset the
* plugin ships instead of preflight — box-sizing and form-control font
* inheritance; without preflight a bare `<button>`/`<textarea>` would render
* in the UA's own font.
*/ */
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { import {
Button, IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16, IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16,
IconRefreshOutline14, IconTrashOutline16, Input, MarkdownText, Pill, StateDot, Tag, IconRefreshOutline14, IconTrashOutline16, MarkdownText, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts' import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { api } from './api.ts' import { api } from './api.ts'
import { markdownTextProps } from './markdown-labels.tsx' import { markdownTextProps } from './markdown-labels.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx' import { AnchoredPopover } from './AnchoredPopover.tsx'
import { t, type CopyKey } from './locales.ts' import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css' import { Badge } from './ui/badge.tsx'
import { Button } from './ui/button.tsx'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
import { Input } from './ui/input.tsx'
import { Separator } from './ui/separator.tsx'
import { Textarea } from './ui/textarea.tsx'
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
/** One mutation outcome (the host route's own union). */ /** One mutation outcome (the host route's own union). */
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } } type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
@@ -39,10 +55,22 @@ function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
} }
} }
/**
* The status badge's ink — hue carries meaning only: done = success, running =
* accent, blocked = warning, everything else stays on quiet meta ink.
*/
function statusTone(task: SidebarTeamTaskView): string {
if (task.status === 'completed') return 'border-success/40 text-success'
if (task.status === 'in_progress') return 'border-primary/40 text-primary'
return task.ready ? 'text-muted-foreground' : 'border-warning/40 text-warning'
}
/** /**
* The plugin's own multi-line input: the host primitive set ships no * The plugin's own multi-line input: the host primitive set ships no
* textarea, so this is OUR component (token-styled, drag-exempt) and every * textarea, so this is OUR component (token-styled, drag-exempt) and every
* multi-line edit in the page goes through it. * multi-line edit in the page goes through it. The surface is the vendored
* shadcn `Textarea`; `rows` keeps the caller's height contract and the
* content-driven growth is capped so a long body cannot outgrow the viewport.
*/ */
export function MultilineField(props: { export function MultilineField(props: {
value: string value: string
@@ -52,8 +80,8 @@ export function MultilineField(props: {
onChange?(next: string): void onChange?(next: string): void
}): ReactNode { }): ReactNode {
return ( return (
<textarea <Textarea
className={css.taskTextarea} className="max-h-[45vh] min-h-[120px] resize-y leading-relaxed"
value={props.value} value={props.value}
rows={props.rows ?? 7} rows={props.rows ?? 7}
placeholder={props.placeholder} placeholder={props.placeholder}
@@ -64,33 +92,54 @@ export function MultilineField(props: {
) )
} }
/** The owner picker (Pills; CAS-immediate, so it is never a draft field). */ /**
function OwnerPicker(props: { * The unowned option's sentinel value: Radix reads an EMPTY string as "nothing
* is selected" (`value ? [value] : []`), so `''` could never render pressed —
* and the unowned chip must be visibly the active one on an unowned task.
*/
const UNOWNED = '__unowned__'
/**
* The owner chips' ink: an unselected owner is meta ink, the picked one is
* full ink, and the vendored toggle adds its accent tint on top — the tint
* alone would read exactly like hover. (Weight cannot carry the selection
* here: the page's scoped base reset gives every form control `font: inherit`,
* which outranks a font-weight utility.)
*/
const OWNER_CHIP = 'text-muted-foreground data-[state=on]:text-foreground'
/** The owner picker (single-select toggles; CAS-immediate, so never a draft field). */
export function OwnerPicker(props: {
members: readonly SidebarTeamMemberView[] members: readonly SidebarTeamMemberView[]
owner: string | undefined owner: string | undefined
disabled: boolean disabled: boolean
onPick(owner: string): void onPick(owner: string): void
}): ReactNode { }): ReactNode {
return ( return (
<div className={css.taskOwnerRow}> <ToggleGroup
<Pill type="single"
active={props.owner === undefined} value={props.owner ?? UNOWNED}
disabled={props.disabled} disabled={props.disabled}
onClick={() => { props.onPick('') }} spacing={1}
> aria-label={t('teamTaskOwner')}
className="flex-wrap"
onValueChange={(next) => {
// '' is Radix's deselect of the pressed chip (the reader re-clicked the
// current owner): the old pill row re-sent the same owner, i.e. no
// mutation — the window never unassigns behind a re-click.
if (next === '') return
props.onPick(next === UNOWNED ? '' : next)
}}
>
<ToggleGroupItem value={UNOWNED} variant="outline" size="sm" className={OWNER_CHIP}>
{t('teamTaskUnowned')} {t('teamTaskUnowned')}
</Pill> </ToggleGroupItem>
{props.members.map(member => ( {props.members.map(member => (
<Pill <ToggleGroupItem key={member.id} value={member.name} variant="outline" size="sm" className={OWNER_CHIP}>
key={member.id}
active={props.owner === member.name}
disabled={props.disabled}
onClick={() => { props.onPick(member.name) }}
>
{member.name} {member.name}
</Pill> </ToggleGroupItem>
))} ))}
</div> </ToggleGroup>
) )
} }
@@ -104,38 +153,38 @@ function TaskViewBody(props: {
const { task } = props const { task } = props
const body = task.description.trim() const body = task.description.trim()
return ( return (
<> <div className="flex min-w-0 flex-col gap-2.5">
<div className={css.jobPopTitle}> <div className="flex min-w-0 items-center gap-1.5">
<StateDot <StateDot
size={6} size={6}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'} state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/> />
<span className={css.popTitle} title={task.subject}>{task.subject}</span> <span className="min-w-0 flex-1 truncate font-medium" title={task.subject}>{task.subject}</span>
<Tag tone={task.status === 'completed' ? 'success' : task.ready ? 'info' : 'warning'}> <Badge variant="outline" className={`h-5 px-2 text-[11px] ${statusTone(task)}`}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')} {t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</Tag> </Badge>
</div> </div>
<div className={css.jobPopMeta}> <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span className={css.popKey}>{t('teamTaskOwner')}</span> <span className="text-foreground-3">{t('teamTaskOwner')}</span>
<span>{task.ownerName ?? t('teamTaskUnowned')}</span> <span>{task.ownerName ?? t('teamTaskUnowned')}</span>
{task.blockedBy.length > 0 && ( {task.blockedBy.length > 0 && (
<> <>
<span className={css.popKey}>{t('teamTaskBlockedBy')}</span> <span className="text-foreground-3">{t('teamTaskBlockedBy')}</span>
<span>{task.blockedBy.join(' · ')}</span> <span className="font-mono tabular-nums">{task.blockedBy.join(' · ')}</span>
</> </>
)} )}
</div> </div>
<div className={css.taskMarkdown}> <div className="max-h-[260px] overflow-y-auto leading-[1.55] text-foreground">
{body === '' {body === ''
? <div className={css.popHint}>{t('teamTaskNoDescription')}</div> ? <div className="text-xs leading-snug text-muted-foreground">{t('teamTaskNoDescription')}</div>
: ( : (
<MarkdownText <MarkdownText
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })} {...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
/> />
)} )}
</div> </div>
<div className={css.taskField}> <div className="flex flex-col gap-1.5">
<span className={css.taskLabel}>{t('teamTaskOwner')}</span> <span className="font-mono text-[11px] text-foreground-3">{t('teamTaskOwner')}</span>
<OwnerPicker <OwnerPicker
members={props.teammates} members={props.teammates}
owner={task.ownerName} owner={task.ownerName}
@@ -143,11 +192,11 @@ function TaskViewBody(props: {
onPick={props.onReassign} onPick={props.onReassign}
/> />
</div> </div>
</> </div>
) )
} }
/** The editing body: subject (host Input) + description (our multi-line field). */ /** The editing body: subject (shadcn Input) + description (our multi-line field). */
function TaskEditBody(props: { function TaskEditBody(props: {
subject: string subject: string
description: string description: string
@@ -155,18 +204,19 @@ function TaskEditBody(props: {
onDescription(next: string): void onDescription(next: string): void
}): ReactNode { }): ReactNode {
return ( return (
<div className={css.taskForm} data-popover-no-drag> <div className="flex flex-col gap-2.5" data-popover-no-drag>
<label className={css.taskField}> <label className="flex flex-col gap-1">
<span className={css.taskLabel}>{t('teamTaskSubject')}</span> <span className="font-mono text-[11px] text-foreground-3">{t('teamTaskSubject')}</span>
<Input <Input
className="h-8"
value={props.subject} value={props.subject}
placeholder={t('teamTaskSubjectPlaceholder')} placeholder={t('teamTaskSubjectPlaceholder')}
aria-label={t('teamTaskSubject')} aria-label={t('teamTaskSubject')}
onChange={(event) => { props.onSubject(event.target.value) }} onChange={(event) => { props.onSubject(event.target.value) }}
/> />
</label> </label>
<label className={css.taskField}> <label className="flex flex-col gap-1">
<span className={css.taskLabel}>{t('teamTaskDescription')}</span> <span className="font-mono text-[11px] text-foreground-3">{t('teamTaskDescription')}</span>
<MultilineField <MultilineField
value={props.description} value={props.description}
label={t('teamTaskDescription')} label={t('teamTaskDescription')}
@@ -277,52 +327,67 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
} }
return ( return (
<div className={css.popCard} data-popover-handle> <Card
<div className={css.popHead}> className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 text-[13px]"
<span>{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}</span> data-popover-handle
<span className={css.popHeadActions} data-popover-no-drag> >
<CardHeader className="flex flex-row items-center justify-between gap-2 px-3 py-2">
<CardTitle className="min-w-0 flex-1 truncate font-mono text-[11px] font-medium text-foreground-3">
{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}
</CardTitle>
<div className="flex flex-none items-center gap-1" data-popover-no-drag>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
icon={<IconCloseOutline16 size={12} />} // Load-bearing reset: the plugin ships no preflight, so a control
// whose variant declares no paint keeps the UA's `buttonface` fill
// and 2px `outset` border. A ghost icon button must be chrome-free.
className="size-7 border-0 bg-transparent"
aria-label={t('teamTaskCancel')} aria-label={t('teamTaskCancel')}
title={t('teamTaskCancel')} title={t('teamTaskCancel')}
onClick={onClose} onClick={onClose}
/> >
</span> <IconCloseOutline16 />
</div> </Button>
</div>
</CardHeader>
<Separator />
{editing || task === undefined <CardContent className="px-3 py-2.5">
? ( {editing || task === undefined
<TaskEditBody ? (
subject={subject} <TaskEditBody
description={description} subject={subject}
onSubject={setSubject} description={description}
onDescription={setDescription} onSubject={setSubject}
/> onDescription={setDescription}
) />
: ( )
<TaskViewBody : (
task={task} <TaskViewBody
teammates={teammates} task={task}
busy={busy} teammates={teammates}
onReassign={reassign} busy={busy}
/> onReassign={reassign}
)} />
)}
{note !== undefined && <div className="mt-2 text-xs leading-snug text-warning">{note}</div>}
</CardContent>
{note !== undefined && <div className={css.teamNote}>{note}</div>} <Separator />
<CardFooter className="flex-wrap justify-end gap-1.5 px-3 py-2" data-popover-no-drag>
<div className={css.jobPopActions} data-popover-no-drag>
{editing {editing
? ( ? (
<> <>
<Button <Button
variant="primary"
size="sm" size="sm"
icon={<IconCheckOutline14 size={12} />} // Same preflight reset as the ghost button: `default` declares no
// border, so the UA's 2px `outset` edge would frame the fill.
className="border-0"
disabled={busy || subject.trim() === ''} disabled={busy || subject.trim() === ''}
onClick={save} onClick={save}
> >
<IconCheckOutline14 />
{creating ? t('teamTaskCreate') : t('teamTaskSave')} {creating ? t('teamTaskCreate') : t('teamTaskSave')}
</Button> </Button>
<Button <Button
@@ -344,12 +409,8 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
) )
: task === undefined ? null : ( : task === undefined ? null : (
<> <>
<Button <Button variant="outline" size="sm" onClick={() => { setEditing(true) }}>
variant="outline" <IconEditOutline16 />
size="sm"
icon={<IconEditOutline16 size={12} />}
onClick={() => { setEditing(true) }}
>
{t('teamTaskEdit')} {t('teamTaskEdit')}
</Button> </Button>
{task.status === 'completed' {task.status === 'completed'
@@ -357,12 +418,12 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
icon={<IconRefreshOutline14 size={12} />}
disabled={busy} disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, { onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen', taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))} }))}
> >
<IconRefreshOutline14 />
{t('teamTaskReopen')} {t('teamTaskReopen')}
</Button> </Button>
) )
@@ -370,20 +431,22 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
icon={<IconCheckOutline14 size={12} />}
disabled={busy} disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, { onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete', taskId: task.id, expectedRevision: task.revision, action: 'complete',
}))} }))}
> >
<IconCheckOutline14 />
{t('teamTaskComplete')} {t('teamTaskComplete')}
</Button> </Button>
)} )}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className={armedDelete ? css.jobPopKillArmed : undefined} // Two-step confirm, danger only on the armed click: outline +
icon={<IconTrashOutline16 size={12} />} // destructive ink beats a permanently red button (and keeps the
// variant's own 1px border).
className={armedDelete ? 'border-destructive/40 text-destructive' : undefined}
disabled={busy} disabled={busy}
onClick={() => { onClick={() => {
if (!armedDelete) { setArmedDelete(true); return } if (!armedDelete) { setArmedDelete(true); return }
@@ -393,12 +456,13 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
}), { close: true }) }), { close: true })
}} }}
> >
<IconTrashOutline16 />
{armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')} {armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
</Button> </Button>
</> </>
)} )}
</div> </CardFooter>
</div> </Card>
) )
} }
@@ -424,10 +488,10 @@ export function TaskCreateButton(props: {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
icon={<IconPlusOutline16 size={13} />}
disabled={props.disabled} disabled={props.disabled}
onClick={(event) => { props.onClick(event.currentTarget) }} onClick={(event) => { props.onClick(event.currentTarget) }}
> >
<IconPlusOutline16 />
{t('teamTaskCreate')} {t('teamTaskCreate')}
</Button> </Button>
) )
+257 -136
View File
@@ -12,6 +12,23 @@
* hidden at zero size, so a one-shot fit on mount is not enough), centering * hidden at zero size, so a one-shot fit on mount is not enough), centering
* on both axes and scaling UP to {@link FIT_MAX_SCALE} so a small tree fills * on both axes and scaling UP to {@link FIT_MAX_SCALE} so a small tree fills
* the narrow panel instead of hugging the top-left corner. * the narrow panel instead of hugging the top-left corner.
*
* Visual language: every node is a shadcn `Card` — a single 1px `border-border`
* hairline over the surface ladder, `hover:bg-muted` as the only hover change,
* no shadow (nothing here floats) and no card tint (the old double/dashed
* borders and the filled workflow cards are gone; "current" is a 2px accent
* bar, running is the host `StateDot` plus the live line). Statuses ride the
* mono meta line, team membership is a `Badge`, a truncated line carries its
* full text in a shadcn `Tooltip`, and the control cluster is the vendored
* `Button` recipe (`ghost` / `sm`) with a tooltip per control. The phase frames
* are 1px dashed `border-border` boxes and the relations are hairline strokes
* in the border ink (see tasks-canvas.module.css for the canvas-only pieces:
* the dotted grid, the transformed layer and the edge strokes).
*
* Behaviour is unchanged: the same pointer-down-on-background pan, the same
* wheel zoom to cursor, the same click tolerance, the same auto-fit, and the
* same stable hooks (`data-graph-node`, `data-graph-controls`, `role="group"`,
* the aria-label copy).
*/ */
import { import {
useCallback, useCallback,
@@ -22,9 +39,8 @@ import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import clsx from 'clsx'
import { import {
Button, IconChecklistOutline14, IconFullscreenOutline16, IconTreeCorner8x10, StateDot, IconChecklistOutline14, IconFullscreenOutline16, IconTreeCorner8x10, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts' import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import { tasksEdges } from './tasks-model.ts' import { tasksEdges } from './tasks-model.ts'
@@ -34,7 +50,13 @@ import {
WorkflowGlyph, workflowMeta, WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx' } from './tasks-shared.tsx'
import { t } from './locales.ts' import { t } from './locales.ts'
import css from './tasks-graph.module.css' import { Badge } from './ui/badge.tsx'
import { buttonVariants } from './ui/button.tsx'
import { Card } from './ui/card.tsx'
import { Spinner } from './ui/spinner.tsx'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip.tsx'
import { cn } from './ui/utils.ts'
import css from './tasks-canvas.module.css'
/** The zoom bounds of the canvas. */ /** The zoom bounds of the canvas. */
const ZOOM_MIN = 0.3 const ZOOM_MIN = 0.3
@@ -47,6 +69,25 @@ const FIT_MIN_SCALE = 0.78
/** Drag-vs-click separation: pointer travel below this stays a click. */ /** Drag-vs-click separation: pointer travel below this stays a click. */
const CLICK_TOLERANCE_PX = 4 const CLICK_TOLERANCE_PX = 4
/**
* The shared shell of every node card: absolute (the layout owns left/top),
* one 1px hairline, the card surface, and `hover:bg-muted` as the only hover
* change. No shadow — a node never floats — and no tint.
*/
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg border-border bg-card px-2 py-1.5 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
/** The on-screen session: a 2px accent bar, never a tinted or shadowed card. */
const NODE_ACCENT = 'border-l-2 border-l-primary'
/** Settled nodes recede by INK, not by opacity: the whole card drops to the
* secondary ink (the title inherits it) and the meta line goes one level
* further down (`text-foreground-3`, appended over NODE_META). */
const NODE_SETTLED = 'text-muted-foreground'
/** Card line 1: 12px medium, clamped to TWO lines — at 150px a single
* ellipsized line left ~7 CJK characters, which is what made a dense graph
* unreadable. The tooltip still carries the full name. */
const NODE_TITLE = 'min-w-0 flex-1 line-clamp-2 text-xs leading-[1.3] font-medium [overflow-wrap:anywhere]'
/** The mono meta line: 11px, tabular figures, secondary ink. */
const NODE_META = 'mt-0.5 truncate font-mono text-[11px] leading-[1.3] tabular-nums text-muted-foreground'
/** One phase frame of a run node (the dashed box behind its members). */ /** One phase frame of a run node (the dashed box behind its members). */
interface PhaseFrame { interface PhaseFrame {
key: string key: string
@@ -69,6 +110,67 @@ export interface TasksGraphProps {
onToggleFold(): void onToggleFold(): void
mode: 'graph' | 'tree' mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void onModeChange(mode: 'graph' | 'tree'): void
/** Fallback loading overlay while the root catalog hydrates (the same
* signal TasksTree renders its loading row for). */
loading?: boolean
}
/**
* One button of the control cluster: the vendored `Button`'s own `ghost` / `sm`
* recipe on a real `<button>`, with a shadcn tooltip carrying the same copy as
* the aria-label (`title` used to carry it; the bubble replaces it).
*
* The recipe comes from `buttonVariants` rather than the `Button` component for
* one concrete reason: Radix's `TooltipTrigger` has to attach its ref to the
* anchor, and the vendored `Button` is a plain function component (not a
* `forwardRef` one), so on React 18 the cloned ref is dropped with a warning
* and the tooltip would measure nothing. The rendered element is exactly what
* `<Button variant="ghost" size="sm">` renders.
*/
function ControlButton(props: {
label: string
pressed?: boolean
onClick(): void
children: ReactNode
}): ReactNode {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'flex-none px-2 text-[13px]',
props.pressed === true && 'bg-muted text-foreground',
)}
aria-label={props.label}
aria-pressed={props.pressed}
onClick={props.onClick}
>
{props.children}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[11px]">{props.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
/**
* One ellipsized card line plus the tooltip that carries its full text — the
* shadcn replacement for the native `title` these lines used to wear. The
* anchor is the line itself, so hovering the dot/glyph gutter stays quiet.
*/
function CardLine(props: { className: string; label: string; children?: ReactNode }): ReactNode {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={props.className}>{props.children ?? props.label}</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px] text-[11px]">{props.label}</TooltipContent>
</Tooltip>
)
} }
/** The shared view-mode toggle (glyph + label, per the mockup). */ /** The shared view-mode toggle (glyph + label, per the mockup). */
@@ -80,17 +182,13 @@ export function ViewModeToggle(props: {
const toTree = mode === 'graph' const toTree = mode === 'graph'
const label = toTree ? t('tasksViewTree') : t('tasksViewGraph') const label = toTree ? t('tasksViewTree') : t('tasksViewGraph')
return ( return (
<Button <ControlButton
variant="ghost" label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
size="sm"
className={css.controlMode}
icon={toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph size={13} />}
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }} onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
> >
{toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph />}
{label} {label}
</Button> </ControlButton>
) )
} }
@@ -98,21 +196,18 @@ export function ViewModeToggle(props: {
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode { export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
const { folded, onToggleFold } = props const { folded, onToggleFold } = props
return ( return (
<Button <ControlButton
variant="ghost" label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
size="sm" pressed={folded}
className={clsx(css.controlBtn, folded && css.controlBtnActive)}
icon={<IconChecklistOutline14 size={13} />}
aria-pressed={folded}
aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
onClick={onToggleFold} onClick={onToggleFold}
/> >
<IconChecklistOutline14 />
</ControlButton>
) )
} }
export function TasksGraph(props: TasksGraphProps): ReactNode { export function TasksGraph(props: TasksGraphProps): ReactNode {
const { nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange } = props const { nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange, loading } = props
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [tf, setTf] = useState({ x: 0, y: 0, k: 1 }) const [tf, setTf] = useState({ x: 0, y: 0, k: 1 })
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
@@ -282,95 +377,102 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
} }
return ( return (
<div className={css.graphView}> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div <div
ref={containerRef} ref={containerRef}
className={clsx(css.canvas, dragging && css.canvasDragging)} className={cn(css.canvas, dragging && css.canvasDragging)}
role="group" role="group"
aria-label={t('tasksViewGraph')} aria-label={t('tasksViewGraph')}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
> >
<div <TooltipProvider delayDuration={400}>
className={css.canvasInner} {/* The graph twin of TasksTree's loading row (same Spinner + the
style={{ same `loading` copy key). `pointer-events-none` keeps the
width: layout.width, canvas's pan/zoom/node clicks fully live underneath. */}
height: layout.height, {loading === true && nodes.length === 0 && (
transform: `translate(${tf.x}px, ${tf.y}px) scale(${tf.k})`, <div className="pointer-events-none absolute inset-0 z-[6] flex items-center justify-center">
}} <span className="flex items-center gap-1.5 px-6 py-3 text-xs text-muted-foreground">
> {/* The visible line carries the copy; the glyph is decoration. */}
<svg className={css.edges} width={layout.width} height={layout.height} aria-hidden="true"> <span aria-hidden="true" className="flex flex-none items-center">
{edges.map((edge) => { <Spinner size={12} />
const from = layout.boxes.get(edge.from) </span>
const to = layout.boxes.get(edge.to) {t('loading')}
if (from === undefined || to === undefined) return null </span>
const target = nodeById.get(edge.to)
return (
<path
key={`${edge.from}->${edge.to}`}
className={clsx(
css.edge,
edge.kind === 'team' && css.edgeTeam,
edge.kind === 'workflow' && css.edgeWorkflow,
target?.kind === 'fold' && css.edgeFold,
)}
d={edgePath(from, to)}
/>
)
})}
</svg>
{phaseFrames.map(frame => (
<div
key={frame.key}
className={css.phaseFrame}
style={{ left: frame.x, top: frame.y, width: frame.w, height: frame.h }}
>
<span className={css.phaseLabel}>{frame.title ?? t('workflowPhaseUnnamed')}</span>
</div> </div>
))} )}
{nodes.map((node) => { <div
const box = layout.boxes.get(node.id) className={css.canvasInner}
if (box === undefined) return null style={{
const style = { left: box.x, top: box.y, width: GRAPH_NODE_W, minHeight: box.h } width: layout.width,
if (node.kind === 'fold') return renderFoldNode(node, style, onToggleFold, clickAllowed) height: layout.height,
if (node.kind === 'workflow') { transform: `translate(${tf.x}px, ${tf.y}px) scale(${tf.k})`,
return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed) }}
} >
return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed) <svg className={css.edges} width={layout.width} height={layout.height} aria-hidden="true">
})} {edges.map((edge) => {
</div> const from = layout.boxes.get(edge.from)
<div className={css.controls} data-graph-controls> const to = layout.boxes.get(edge.to)
if (from === undefined || to === undefined) return null
const target = nodeById.get(edge.to)
return (
<path
key={`${edge.from}->${edge.to}`}
className={cn(
css.edge,
edge.kind === 'team' && css.edgeTeam,
edge.kind === 'workflow' && css.edgeWorkflow,
target?.kind === 'fold' && css.edgeFold,
)}
d={edgePath(from, to)}
/>
)
})}
</svg>
{phaseFrames.map(frame => (
<div
key={frame.key}
className="pointer-events-none absolute rounded-md border border-dashed border-border"
style={{ left: frame.x, top: frame.y, width: frame.w, height: frame.h }}
>
<span className="absolute -top-[7px] left-2 bg-background px-1 font-mono text-[11px] leading-[1.3] tracking-wide text-foreground-3 uppercase">
{frame.title ?? t('workflowPhaseUnnamed')}
</span>
</div>
))}
{/* KEYBOARD ACCESS (deliberate, F4): graph nodes render with
tabIndex={-1} on purpose — a graph with dozens of nodes would
add dozens of Tab stops and drown the page's real controls,
and the canvas itself supports pointer pan/zoom only. The
keyboard-equivalent path is the bottom-right control cluster
(`data-graph-controls`): its "切换为树状图" view toggle is a
NATIVE <button> (Tab-reachable, Enter/Space-activatable), and
tree mode provides the full ArrowUp/ArrowDown/Home/End/Enter/
Space navigation over the same nodes (see TasksTree). Do NOT
"fix" the nodes' tabIndex without fixing the tab-stop flood. */}
{nodes.map((node) => {
const box = layout.boxes.get(node.id)
if (box === undefined) return null
const style = { left: box.x, top: box.y, width: GRAPH_NODE_W, minHeight: box.h }
if (node.kind === 'fold') return renderFoldNode(node, style, onToggleFold, clickAllowed)
if (node.kind === 'workflow') {
return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed)
}
return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed)
})}
</div>
</TooltipProvider>
<div className="absolute right-2.5 bottom-2.5 z-[6] flex flex-row items-center gap-0.5" data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} /> <ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} /> <FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
<Button <ControlButton label={t('tasksZoomOut')} onClick={() => { zoomBy(1 / 1.2) }}>−</ControlButton>
variant="ghost" <span
size="sm" className="flex-none px-1.5 font-mono text-[11px] tabular-nums text-muted-foreground"
className={css.controlBtn} aria-hidden="true"
aria-label={t('tasksZoomOut')}
title={t('tasksZoomOut')}
onClick={() => { zoomBy(1 / 1.2) }}
> >
− {Math.round(tf.k * 100)}%
</Button> </span>
<span className={css.controlZoomLevel} aria-hidden="true">{Math.round(tf.k * 100)}%</span> <ControlButton label={t('tasksZoomIn')} onClick={() => { zoomBy(1.2) }}>+</ControlButton>
<Button <ControlButton label={t('tasksZoomFit')} onClick={refit}><IconFullscreenOutline16 /></ControlButton>
variant="ghost"
size="sm"
className={css.controlBtn}
aria-label={t('tasksZoomIn')}
title={t('tasksZoomIn')}
onClick={() => { zoomBy(1.2) }}
>
+
</Button>
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
icon={<IconFullscreenOutline16 size={13} />}
aria-label={t('tasksZoomFit')}
title={t('tasksZoomFit')}
onClick={refit}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -385,21 +487,22 @@ function renderAgentNode(
onOpenTask: (taskId: string, anchor: HTMLElement) => void, onOpenTask: (taskId: string, anchor: HTMLElement) => void,
clickAllowed: () => boolean, clickAllowed: () => boolean,
): ReactNode { ): ReactNode {
const meta = agentMeta(node)
const settled = (node.state === 'done' || node.state === 'error') && !node.current
return ( return (
<div <Card
key={node.id} key={node.id}
data-graph-node={node.id} data-graph-node={node.id}
role="button" role="button"
tabIndex={-1} tabIndex={-1}
aria-label={`${node.label} ${agentMeta(node)}`} aria-label={`${node.label} ${meta}`}
aria-current={node.current ? 'true' : undefined} aria-current={node.current ? 'true' : undefined}
className={clsx( className={cn(
css.node, NODE_CARD,
node.parentId === undefined && css.nodeLead, // The lead keeps the stronger hairline rung; the meta line names it.
node.team?.role === 'teammate' && css.nodeTeam, node.parentId === undefined && 'border-border-strong',
node.current && css.nodeCurrent, node.current && NODE_ACCENT,
(node.state === 'done' || node.state === 'error') && !node.current && css.nodeSettled, settled && NODE_SETTLED,
node.state === 'error' && css.nodeError,
)} )}
style={style} style={style}
onClick={(event) => { onClick={(event) => {
@@ -407,15 +510,32 @@ function renderAgentNode(
onNodeInfo(node, event.currentTarget) onNodeInfo(node, event.currentTarget)
}} }}
> >
<span className={css.nodeHeader}> <span className="flex min-w-0 items-center gap-1">
<StateDot state={nodeDotState(node.state)} size={6} className={css.nodeDot} /> <StateDot state={nodeDotState(node.state)} size={6} className="flex-none" />
<span className={css.nodeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span> <span className="flex flex-none text-foreground-3" aria-hidden="true"><AgentGlyph node={node} /></span>
<span className={css.nodeTitle} title={node.label}>{node.label}</span> <CardLine className={NODE_TITLE} label={node.label} />
{node.team?.role === 'teammate' && node.team.name !== '' && (
<Badge
variant="outline"
className="h-4 max-w-[48px] flex-none truncate px-1.5 py-0 text-[11px] font-normal text-muted-foreground"
>
{node.team.name}
</Badge>
)}
</span> </span>
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span> {/* Error is data, not chrome: the card keeps the shared hairline (F6)
and the state rides the StateDot plus the destructive meta ink —
the same family success/warning already use on their meta words. */}
<CardLine
className={cn(
NODE_META,
node.state === 'error' ? 'text-destructive' : settled && 'text-foreground-3',
)}
label={meta}
/>
{node.state === 'running' && <LiveLine live={node.live} />} {node.state === 'running' && <LiveLine live={node.live} />}
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} /> <TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
</div> </Card>
) )
} }
@@ -426,60 +546,61 @@ function renderWorkflowNode(
onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void, onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void,
clickAllowed: () => boolean, clickAllowed: () => boolean,
): ReactNode { ): ReactNode {
const meta = workflowMeta(node)
const settled = node.run.status !== 'running'
return ( return (
<div <Card
key={node.id} key={node.id}
data-graph-node={node.id} data-graph-node={node.id}
role="button" role="button"
tabIndex={-1} tabIndex={-1}
aria-label={`${node.run.name} ${workflowMeta(node)}`} aria-label={`${node.run.name} ${meta}`}
className={clsx(css.node, css.nodeWorkflow, node.run.status !== 'running' && css.nodeSettled)} className={cn(NODE_CARD, settled && NODE_SETTLED)}
style={style} style={style}
onClick={(event) => { onClick={(event) => {
if (!clickAllowed()) return if (!clickAllowed()) return
onWorkflowInfo(node, event.currentTarget) onWorkflowInfo(node, event.currentTarget)
}} }}
> >
<span className={css.nodeHeader}> <span className="flex min-w-0 items-center gap-1">
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} className={css.nodeDot} /> <StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} className="flex-none" />
<span className={css.nodeGlyph} aria-hidden="true"><WorkflowGlyph /></span> <span className="flex flex-none text-foreground-3" aria-hidden="true"><WorkflowGlyph /></span>
<span className={css.nodeTitle} title={node.run.name}>{node.run.name}</span> <CardLine className={NODE_TITLE} label={node.run.name} />
</span> </span>
<span className={css.nodeMeta}>{workflowMeta(node)}</span> <CardLine className={cn(NODE_META, settled && 'text-foreground-3')} label={meta} />
</div> </Card>
) )
} }
/** One fold aggregate node. */ /** One fold aggregate node: the settled leaves as a dashed card. */
function renderFoldNode( function renderFoldNode(
node: TasksFoldNode, node: TasksFoldNode,
style: { left: number; top: number; width: number; minHeight: number }, style: { left: number; top: number; width: number; minHeight: number },
onToggleFold: () => void, onToggleFold: () => void,
clickAllowed: () => boolean, clickAllowed: () => boolean,
): ReactNode { ): ReactNode {
const previews = foldPreviews(node.previews)
return ( return (
<div <Card
key={node.id} key={node.id}
data-graph-node={node.id} data-graph-node={node.id}
role="button" role="button"
tabIndex={-1} tabIndex={-1}
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`} aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
className={clsx(css.node, css.nodeFold)} className={cn(NODE_CARD, 'border-dashed text-muted-foreground')}
style={style} style={style}
onClick={() => { onClick={() => {
if (!clickAllowed()) return if (!clickAllowed()) return
onToggleFold() onToggleFold()
}} }}
> >
<span className={css.nodeHeader}> <span className="flex min-w-0 items-center gap-1">
<span className={css.nodeGlyph} aria-hidden="true"><FoldGlyph /></span> <span className="flex flex-none" aria-hidden="true"><FoldGlyph /></span>
<span className={clsx(css.nodeTitle, css.nodeTitlePlain)}> <span className={NODE_TITLE}>{t('tasksFoldCompleted', { count: node.count })}</span>
{t('tasksFoldCompleted', { count: node.count })}
</span>
</span> </span>
<span className={css.nodeMeta} title={foldPreviews(node.previews)}> <CardLine className={NODE_META} label={previews}>
{`${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`} {`${t('tasksFoldExpand')} · ${previews}`}
</span> </CardLine>
</div> </Card>
) )
} }
+189 -110
View File
@@ -1,21 +1,37 @@
/** /**
* The anchored-popover CONTENTS of the Tasks page (geometry and dismissal * The anchored-popover CONTENTS of the Tasks page (geometry and dismissal
* live in AnchoredPopover): the agent node detail with a transcript jump and * live in AnchoredPopover): the agent node detail with a transcript jump and
* the workflow run detail with clickable member rows. Both follow the mockup * the workflow run detail with clickable member rows.
* grammar — a letter-spaced uppercase head, a dt/dd key/value grid, and one *
* full-width jump action. The team board is NOT here: it is always visible as * Visual language: the vendored shadcn/ui pieces (Card + Separator + Badge +
* a strip above the canvas (TeamBoard.tsx). * Button) over Tailwind utilities bound to the `--dsw-*` tokens. The surface
* is flat — a 1px hairline plus the popover surface ladder carries the
* layering; the only shadow is the one AnchoredPopover itself puts on the
* floating card. Ink stays on three levels (foreground / muted-foreground /
* foreground-3), mono meta is `font-mono tabular-nums`, and hue is semantic
* only: success = settled, warning = blocked, destructive = failed, primary =
* the one main action per popover.
*
* The team board is NOT here: it is always visible as a strip above the
* canvas (TeamBoard.tsx).
*/ */
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import clsx from 'clsx' import {
import { Button, IconRightUpOutline14, StateDot, Tag } from '@deepseek-ai/dsh-client-ui-primitives' IconBranchOutline16, IconRightUpOutline14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarSubagentAddress } from '../context-types.ts' import type { SidebarSubagentAddress } from '../context-types.ts'
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts' import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
import { import {
flatten, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey, flatten, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
} from './tasks-shared.tsx' } from './tasks-shared.tsx'
import { t, type CopyKey } from './locales.ts' import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css' import { Badge } from './ui/badge.tsx'
import { Button as UiButton } from './ui/button.tsx'
import { Card } from './ui/card.tsx'
import { Separator } from './ui/separator.tsx'
/** The workflow run status (the badge's semantic ink selector). */
type WorkflowStatus = TasksWorkflowNode['run']['status']
/** The display-state label key. */ /** The display-state label key. */
function stateKey(state: TasksAgentNode['state']): CopyKey { function stateKey(state: TasksAgentNode['state']): CopyKey {
@@ -27,21 +43,58 @@ function stateKey(state: TasksAgentNode['state']): CopyKey {
} }
} }
/** The popover's key/value grid. */ /**
* The semantic ink of one status word: settled = success, in progress = the
* accent, blocked = warning, failed = destructive. The hue rides the text
* only — the badge keeps a neutral outline, so a popover never turns into a
* block of color.
*/
function statusTone(status: WorkflowStatus | 'pending' | 'in_progress' | 'completed'): string {
if (status === 'completed') return 'text-success'
if (status === 'running' || status === 'in_progress') return 'text-primary'
if (status === 'pending') return 'text-muted-foreground'
return 'text-destructive'
}
/** The popover's key/value grid (label left, value right, mono on request).
* The label column is a fixed 64px so every row's value starts on one axis —
* an `auto` column makes the labels ragged across rows. */
function PopRows(props: { children: ReactNode }): ReactNode { function PopRows(props: { children: ReactNode }): ReactNode {
return <dl className={css.popRows}>{props.children}</dl> return <dl className="grid grid-cols-[64px_1fr] items-baseline gap-x-3 gap-y-1">{props.children}</dl>
} }
/** One key/value row of a popover. */ /** One key/value row of a popover. */
function PopRow(props: { label: string; mono?: boolean; children: ReactNode }): ReactNode { function PopRow(props: { label: string; mono?: boolean; children: ReactNode }): ReactNode {
return ( return (
<> <>
<dt className={css.popKey}>{props.label}</dt> <dt className="truncate text-xs text-foreground-3" title={props.label}>{props.label}</dt>
<dd className={clsx(css.popValue, props.mono === true && css.popValueMono)}>{props.children}</dd> <dd
className={
props.mono === true
? 'truncate text-right font-mono text-xs tabular-nums text-muted-foreground'
: 'flex min-w-0 items-center justify-end gap-1.5 text-right text-xs'
}
>
{props.children}
</dd>
</> </>
) )
} }
/** The mono kicker above a popover list ("任务" / a phase title). */
function GroupLabel(props: { children: ReactNode }): ReactNode {
return (
<div className="px-1 pt-0.5 font-mono text-[11px] tracking-wide text-foreground-3 uppercase">
{props.children}
</div>
)
}
/** One outlined status badge (neutral chrome, semantic ink only). */
function StatusBadge(props: { tone: string; children: ReactNode }): ReactNode {
return <Badge variant="outline" className={`h-4 px-1.5 text-[11px] ${props.tone}`}>{props.children}</Badge>
}
/** The agent node detail popover. */ /** The agent node detail popover. */
export function AgentNodePopover(props: { export function AgentNodePopover(props: {
node: TasksAgentNode node: TasksAgentNode
@@ -54,71 +107,77 @@ export function AgentNodePopover(props: {
const liveTool = node.live?.tool !== undefined const liveTool = node.live?.tool !== undefined
? `${node.live.tool.name}${node.live.tool.args === '' ? '' : ` ${node.live.tool.args}`}` ? `${node.live.tool.name}${node.live.tool.args === '' ? '' : ` ${node.live.tool.args}`}`
: undefined : undefined
const tasks = node.tasks ?? []
return ( return (
<div className={css.popCard}> // `dsw-tasks`: this card renders inside AnchoredPopover's portal at
<div className={css.popHead}> // document.body — OUTSIDE the page root — so it must re-declare the page
<span>{t('tasksNodeDetail')}</span> // root class to pick up the scoped base reset the plugin ships instead of
// preflight (see TaskWindow.tsx for the same pattern).
<Card className="dsw-tasks gap-2 rounded-lg border-border bg-popover p-0 py-2.5">
<div className="px-3 font-mono text-[11px] text-foreground-3">{t('tasksNodeDetail')}</div>
<Separator />
<div className="flex flex-col gap-1.5 px-3">
<span className="truncate text-sm font-medium" title={node.label}>{node.label}</span>
<PopRows>
<PopRow label={t('tasksNodeState')}>
<StateDot state={nodeDotState(node.state)} size={6} />
<span className="text-muted-foreground">{t(stateKey(node.state))}</span>
</PopRow>
{node.mode !== undefined && (
<PopRow label={t('tasksNodeMode')}>
{node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
</PopRow>
)}
{node.team !== undefined && (
<PopRow label={t('tasksNodeTeamRole')}>
{node.team.role === 'lead' ? 'lead' : node.team.name}
</PopRow>
)}
{node.team?.model !== undefined && (
<PopRow label={t('tasksNodeModel')} mono>{node.team.model}</PopRow>
)}
{(liveTool !== undefined || liveText !== undefined) && (
<PopRow label={t('tasksNodeActivity')} mono>{liveTool ?? liveText}</PopRow>
)}
</PopRows>
{liveText !== undefined && liveTool !== undefined && (
<div className="line-clamp-2 text-xs text-muted-foreground" title={liveText}>{liveText}</div>
)}
</div> </div>
<span className={css.popTitle} title={node.label}>{node.label}</span> {tasks.length > 0 && (
<PopRows> <>
<PopRow label={t('tasksNodeState')}> <Separator />
<StateDot state={nodeDotState(node.state)} size={6} /> {t(stateKey(node.state))} <div className="flex flex-col gap-1 px-3">
</PopRow> <GroupLabel>{t('tasksNodeTasks')}</GroupLabel>
{node.mode !== undefined && ( <div className="-mx-1 flex max-h-[190px] flex-col gap-0.5 overflow-y-auto">
<PopRow label={t('tasksNodeMode')}> {tasks.map(task => (
{node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')} <UiButton
</PopRow> key={task.id}
)} variant="ghost"
{node.team !== undefined && ( size="sm"
<PopRow label={t('tasksNodeTeamRole')}> className="h-auto w-full justify-start gap-1.5 rounded-sm px-1 py-1 text-left text-xs font-normal"
{node.team.role === 'lead' ? 'lead' : node.team.name} title={task.subject}
</PopRow> onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
)} >
{node.team?.model !== undefined && ( <StateDot state={taskDotState(task)} size={6} />
<PopRow label={t('tasksNodeModel')} mono>{node.team.model}</PopRow> <span className="min-w-0 flex-1 truncate text-xs font-medium">{task.subject}</span>
)} <StatusBadge tone={statusTone(task.status)}>{t(taskStatusKey(task.status))}</StatusBadge>
{(liveTool !== undefined || liveText !== undefined) && ( </UiButton>
<PopRow label={t('tasksNodeActivity')} mono>{liveTool ?? liveText}</PopRow> ))}
)} </div>
</PopRows>
{liveText !== undefined && liveTool !== undefined && (
<div className={css.popHint}>{liveText}</div>
)}
{(node.tasks?.length ?? 0) > 0 && (
<div className={css.popSection}>
<div className={css.popGroup}>{t('tasksNodeTasks')}</div>
<div className={css.popList}>
{(node.tasks ?? []).map(task => (
<button
key={task.id}
type="button"
className={css.popListRow}
title={task.subject}
onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
>
<StateDot state={taskDotState(task)} size={6} />
<span className={css.popListLabel}>{task.subject}</span>
<Tag tone={task.status === 'completed' ? 'success' : task.ready ? 'info' : 'warning'}>
{t(taskStatusKey(task.status))}
</Tag>
</button>
))}
</div> </div>
</div> </>
)} )}
{(node.childAddress !== undefined || node.parentId === undefined) && ( {(node.childAddress !== undefined || node.parentId === undefined) && (
<div className={css.popFoot}> <>
<Button {tasks.length > 0 && <Separator />}
variant="primary" <UiButton size="sm" className="mx-3 w-auto" onClick={() => { onJump(node) }}>
size="sm" <IconRightUpOutline14 size={12} />
icon={<IconRightUpOutline14 size={12} />}
onClick={() => { onJump(node) }}
>
{t('tasksNodeJump')} {t('tasksNodeJump')}
</Button> </UiButton>
</div> </>
)} )}
</div> </Card>
) )
} }
@@ -129,49 +188,69 @@ export function WorkflowNodePopover(props: {
}): ReactNode { }): ReactNode {
const { node, onJumpMember } = props const { node, onJumpMember } = props
const { run } = node const { run } = node
/** A member with no outcome yet still runs; only completed/failed settle. */
const memberDot = (outcome: string | undefined): 'ongoing' | 'done' | 'error' | 'idle' => {
if (outcome === undefined) return 'ongoing'
if (outcome === 'completed') return 'done'
if (outcome === 'failed') return 'error'
return 'idle'
}
return ( return (
<div className={css.popCard}> // `dsw-tasks`: same portal-scope reset as AgentNodePopover above.
<div className={css.popHead}> <Card className="dsw-tasks gap-2 rounded-lg border-border bg-popover p-0 py-2.5">
<span>{t('workflowRun')}</span> <div className="px-3 font-mono text-[11px] text-foreground-3">{t('workflowRun')}</div>
</div> <Separator />
<span className={css.popTitle} title={run.name}>▶ {run.name}</span> <div className="flex flex-col gap-1.5 px-3">
<PopRows> <div className="flex min-w-0 items-center gap-1.5" title={run.name}>
<PopRow label={t('tasksNodeState')}>{t(workflowStatusKey(run.status))}</PopRow> <span className="flex-none text-muted-foreground" aria-hidden="true">
</PopRows> <IconBranchOutline16 size={12} />
<div className={css.popSection}> </span>
<div className={css.popList}> <span className="truncate text-sm font-medium">{run.name}</span>
{run.phases.map((phase, phaseIndex) => (
<div key={`${phase.title ?? 'phase'}-${phaseIndex}`}>
<div className={css.popGroup}>{phase.title ?? t('workflowPhaseUnnamed')}</div>
{phase.members.map(member => (
<div
key={member.seq}
role={member.childId === '' ? undefined : 'button'}
className={css.popListRow}
title={member.label}
onClick={() => {
if (member.childId === '') return
onJumpMember({
parentSessionId: run.originSessionId,
childSessionId: member.childId,
mode: 'one-shot',
})
}}
>
<StateDot
size={6}
state={member.outcome === undefined
? 'ongoing'
: member.outcome === 'completed' ? 'done'
: member.outcome === 'failed' ? 'error' : 'idle'}
/>
<span className={css.popListLabel}>{member.label}</span>
</div>
))}
</div>
))}
</div> </div>
<PopRows>
<PopRow label={t('tasksNodeState')}>
<StatusBadge tone={statusTone(run.status)}>{t(workflowStatusKey(run.status))}</StatusBadge>
</PopRow>
</PopRows>
</div> </div>
</div> {run.phases.length > 0 && (
<>
<Separator />
<div className="flex max-h-[220px] flex-col gap-1 overflow-y-auto px-3">
{run.phases.map((phase, phaseIndex) => (
<div key={`${phase.title ?? 'phase'}-${phaseIndex}`} className="flex flex-col gap-0.5">
<GroupLabel>{phase.title ?? t('workflowPhaseUnnamed')}</GroupLabel>
{phase.members.length === 0 && (
<div className="px-1 py-1 text-xs text-foreground-3">{t('subagentEmpty')}</div>
)}
{phase.members.map(member => (
<div
key={member.seq}
role={member.childId === '' ? undefined : 'button'}
className={
member.childId === ''
? 'flex items-center gap-1.5 rounded-sm px-1 py-1'
: 'flex cursor-pointer items-center gap-1.5 rounded-sm px-1 py-1 transition-colors hover:bg-muted'
}
title={member.label}
onClick={() => {
if (member.childId === '') return
onJumpMember({
parentSessionId: run.originSessionId,
childSessionId: member.childId,
mode: 'one-shot',
})
}}
>
<StateDot size={6} state={memberDot(member.outcome)} />
<span className="min-w-0 flex-1 truncate text-xs">{member.label}</span>
</div>
))}
</div>
))}
</div>
</>
)}
</Card>
) )
} }
+109 -50
View File
@@ -1,21 +1,63 @@
/** /**
* The tree mode of the Tasks page: the SAME unified model rendered as the * The tree mode of the Tasks page: the SAME unified model rendered as the
* classic indentation tree — nested children with a hairline connector, the * classic indentation tree — nested children with a hairline connector, rows on
* mockup's row metrics (12px title / 9.5px mono meta), left accent bar on the * the shadcn base (a 13px/500 title, an 11px mono meta line, a 2px accent bar
* current session, dashed fold rows, and the shared bottom-right control * on the current session, dashed fold cards), and the shared bottom-right
* cluster (view toggle + fold toggle) so switching back is always possible. * control cluster (view toggle + fold toggle) so switching back is always
* possible.
*
* Visual language: Tailwind utilities over the shadcn tokens
* (src/client/ui/theme.css). Hierarchy is one 1px `border-border` hairline plus
* the surface ladder and `hover:bg-muted` — the row carries no shadow (shadows
* belong to floating layers only). Every color is a semantic token: no palette
* class and no literal. Controls come from the vendored shadcn set
* (`ScrollArea`, `Collapsible`, `Spinner`) and glyphs from the host
* `IconXxx` primitives, so the page owns no artwork of its own.
*
* Behaviour is unchanged: the same `data-tasks-row` focus order, the same
* `role="tree"` / `role="treeitem"` + `aria-level` semantics, the same
* ArrowUp / ArrowDown / Home / End / Enter / Space handling, and the same
* global fold aggregate (a fold row toggles the whole page's fold state).
*
* `Collapsible` is structural only: no row gains a collapse toggle, because
* subtree visibility is owned by the page-wide fold state (`folded`). It is
* rendered open + `forceMount`, so a subtree container is always mounted and
* `[data-tasks-row]` focus order is exactly the pre-migration one.
*/ */
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react' import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx' import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts' import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import { import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine, AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
WorkflowGlyph, workflowMeta, WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx' } from './tasks-shared.tsx'
import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx' import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
import { Collapsible, CollapsibleContent } from './ui/collapsible.tsx'
import { ScrollArea } from './ui/scroll-area.tsx'
import { Spinner } from './ui/spinner.tsx'
import { t } from './locales.ts' import { t } from './locales.ts'
import css from './tasks-graph.module.css'
/** The row slab: one shared box, so every row kind lines up on the same grid. */
const ROW = 'flex w-full cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-left text-[13px] leading-[1.35] text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
/** Hover is a background change only, per the design language. */
const ROW_HOVER = 'hover:bg-muted'
/** The current session: the same muted surface plus the 2px accent bar. */
const ROW_CURRENT = 'bg-muted'
/** Settled (done / error) rows recede by INK — the row's 13px title drops
* from the foreground to the secondary ink — never by opacity or hue. */
const ROW_SETTLED = 'text-muted-foreground'
/** The fold aggregate is a dashed inline card, not a filled row. */
const ROW_FOLD = 'border border-dashed border-border text-muted-foreground'
/** One text line of a row body. */
const LINE = 'truncate'
/** The mono meta line: 11px, tabular figures, secondary ink. */
const META = 'truncate font-mono text-[11px] tabular-nums text-muted-foreground'
/** The glyph/state-dot gutter (row level 0). */
const GLYPH = 'mt-0.5 flex-none text-foreground-3'
/** The current-session accent bar; the slot is reserved on EVERY agent row so
* the dot and glyphs stay on one vertical line down the whole tree. */
const ACCENT_SLOT = 'mt-0.5 h-4 w-0.5 flex-none rounded-full'
const ACCENT_BAR = `${ACCENT_SLOT} bg-primary`
export interface TasksTreeProps { export interface TasksTreeProps {
nodes: readonly TasksNode[] nodes: readonly TasksNode[]
@@ -82,7 +124,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
} }
} }
/** One row plus its nested children (connector line per the mockup). */ /** One row plus its nested children (hairline connector per the mockup). */
const renderNode = (node: TasksNode, depth: number): ReactNode => { const renderNode = (node: TasksNode, depth: number): ReactNode => {
const children = childrenOf.get(node.id) ?? [] const children = childrenOf.get(node.id) ?? []
const row = node.kind === 'fold' const row = node.kind === 'fold'
@@ -94,19 +136,19 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
tabIndex={0} tabIndex={0}
aria-level={depth + 1} aria-level={depth + 1}
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`} aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
className={clsx(css.treeRow, css.treeFoldRow)} className={`${ROW} ${ROW_FOLD} ${ROW_HOVER}`}
onClick={onToggleFold} onClick={onToggleFold}
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }} onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
> >
<span className={css.treeGlyph} aria-hidden="true"><FoldGlyph /></span> <span className={GLYPH} aria-hidden="true"><FoldGlyph /></span>
<span className={css.treeContent}> <span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={css.treeTitle}> <span className={`flex items-center gap-1.5 font-medium ${LINE}`}>
{t('tasksFoldCompleted', { count: node.count })} {t('tasksFoldCompleted', { count: node.count })}
<span className={css.treeMeta}> <span className={META}>
{t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')} {t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
</span> </span>
</span> </span>
<span className={css.treeMeta}>{foldPreviews(node.previews)}</span> <span className={META}>{foldPreviews(node.previews)}</span>
</span> </span>
</div> </div>
) )
@@ -120,17 +162,19 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
aria-level={depth + 1} aria-level={depth + 1}
aria-expanded="true" aria-expanded="true"
aria-label={`${node.run.name} ${workflowMeta(node)}`} aria-label={`${node.run.name} ${workflowMeta(node)}`}
className={clsx(css.treeRow, node.run.status !== 'running' && css.treeRowSettled)} className={`${ROW} ${ROW_HOVER} ${node.run.status !== 'running' ? ROW_SETTLED : ''}`}
onClick={(event) => { onWorkflowInfo(node, event.currentTarget) }} onClick={(event) => { onWorkflowInfo(node, event.currentTarget) }}
onKeyDown={(event) => { onKeyDown={(event) => {
activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) }) activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
}} }}
> >
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} /> <span className="mt-0.5 flex flex-none items-center">
<span className={css.treeGlyph} aria-hidden="true"><WorkflowGlyph /></span> <StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} />
<span className={css.treeContent}> </span>
<span className={css.treeTitle}>{node.run.name}</span> <span className={GLYPH} aria-hidden="true"><WorkflowGlyph /></span>
<span className={css.treeMeta}>{workflowMeta(node)}</span> <span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={`font-medium ${LINE}`}>{node.run.name}</span>
<span className={META}>{workflowMeta(node)}</span>
</span> </span>
</div> </div>
) )
@@ -143,21 +187,28 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
aria-level={depth + 1} aria-level={depth + 1}
aria-label={`${node.label} ${agentMeta(node)}`} aria-label={`${node.label} ${agentMeta(node)}`}
aria-current={node.current ? 'true' : undefined} aria-current={node.current ? 'true' : undefined}
className={clsx( className={[
css.treeRow, ROW,
node.current && css.treeRowActive, ROW_HOVER,
(node.state === 'done' || node.state === 'error') && !node.current && css.treeRowSettled, node.current ? ROW_CURRENT : '',
)} (node.state === 'done' || node.state === 'error') && !node.current ? ROW_SETTLED : '',
].join(' ')}
onClick={(event) => { onNodeInfo(node, event.currentTarget) }} onClick={(event) => { onNodeInfo(node, event.currentTarget) }}
onKeyDown={(event) => { onKeyDown={(event) => {
activateOnKey(event, () => { onNodeInfo(node, event.currentTarget as HTMLElement) }) activateOnKey(event, () => { onNodeInfo(node, event.currentTarget as HTMLElement) })
}} }}
> >
<StateDot state={nodeDotState(node.state)} size={6} /> <span
<span className={css.treeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span> className={node.current ? ACCENT_BAR : ACCENT_SLOT}
<span className={css.treeContent}> aria-hidden="true"
<span className={css.treeTitle}>{node.label}</span> />
<span className={css.treeMeta}>{agentMeta(node)}</span> <span className="mt-0.5 flex flex-none items-center">
<StateDot state={nodeDotState(node.state)} size={6} />
</span>
<span className={GLYPH} aria-hidden="true"><AgentGlyph node={node} /></span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={`font-medium ${LINE}`}>{node.label}</span>
<span className={META}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />} {node.state === 'running' && <LiveLine live={node.live} />}
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} /> <TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
</span> </span>
@@ -165,35 +216,43 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
) )
if (children.length === 0) return <div key={node.id}>{row}</div> if (children.length === 0) return <div key={node.id}>{row}</div>
return ( return (
<div key={node.id}> <Collapsible key={node.id} open>
{row} {row}
<div className={css.treeKids}> <CollapsibleContent
forceMount
className="ml-3 border-l border-border pl-2.5"
>
{children.map(child => renderNode(child, depth + 1))} {children.map(child => renderNode(child, depth + 1))}
</div> </CollapsibleContent>
</div> </Collapsible>
) )
} }
const roots = childrenOf.get('') ?? [] const roots = childrenOf.get('') ?? []
return ( return (
<div className={css.graphView}> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div <ScrollArea className="min-h-0 flex-1">
ref={bodyRef} <div ref={bodyRef} className="px-2.5 pt-2 pb-11">
className={css.treeView} <div
role="tree" role="tree"
aria-label={t('subagent')} aria-label={t('subagent')}
onKeyDown={onTreeKeyDown} onKeyDown={onTreeKeyDown}
> >
{loading === true && roots.length === 0 && ( {loading === true && roots.length === 0 && (
<div className={css.viewEmptyHint}> <div className="flex items-center justify-center gap-1.5 px-6 py-3 text-xs text-muted-foreground">
<span className={css.treeGlyph} aria-hidden="true"><IconChevronUpOutline14 size={12} /></span> {/* The visible line carries the copy; the glyph is decoration. */}
{t('loading')} <span aria-hidden="true" className="flex flex-none items-center">
<Spinner size={12} />
</span>
{t('loading')}
</div>
)}
{roots.map(root => renderNode(root, 0))}
</div> </div>
)} </div>
{roots.map(root => renderNode(root, 0))} </ScrollArea>
</div> <div className="absolute right-2.5 bottom-2.5 z-[6] flex flex-row items-center" data-graph-controls>
<div className={css.controls} data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} /> <ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} /> <FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
</div> </div>
+231 -87
View File
@@ -1,25 +1,39 @@
/** /**
* The Agent Teams board — ALWAYS VISIBLE under the page header whenever the * The Agent Teams board — ALWAYS VISIBLE under the page header whenever the
* tree's root leads a team, plus the task editor dialog. Every control is a * tree's root leads a team. It is a full-width strip, not a side rail, because
* host primitive (Menu / Modal / Input / Button / Pill / Tag / StateDot); the * the native sidebar is narrow: members wrap as a segmented filter and the
* page ships no native form control of its own. * task list scrolls inside a bounded height, so the canvas keeps its room.
* *
* Layout: the strip is full width because the native sidebar is narrow — a * Visual language: shadcn/ui over the task page's Tailwind tokens
* member row of Pills (also the owner filter), then task rows (subject + * (src/client/ui/theme.css) — the strip is a flat Card (1px hairline, no
* owner + status Tag + ONE overflow Menu). Editing opens a Modal with real * shadow), members are a ToggleGroup whose selected chip carries the accent,
* Inputs — that is where the "too narrow to use" complaint is answered. * and every task row ends in one DropdownMenu. Ink stays on the three token
* levels (13px body / 11px mono meta, `font-mono tabular-nums`), and color is
* state only: in progress = primary, done = success, blocked = warning.
* *
* Mutations are CAS: each action sends the task's CURRENT revision; a * Behaviour is unchanged, and deliberately so: the row and EVERY menu entry
* conflict surfaces as a note and re-syncs through the parent's poller. * open the shared task window, which is the ONE surface owning view / edit /
* create and every CAS mutation. The menu is that window's affordance list
* hoisted onto the row, not a second mutation path — so a conflict still
* surfaces in the window and re-syncs through the parent's poller, exactly as
* before.
*/ */
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useRef, useState, type ReactNode } from 'react'
import { import {
Button, IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, Pill, StateDot, Tag, Button, IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, StateDot,
type TagTone,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts' import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { t, type CopyKey } from './locales.ts' import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css' import { cn } from './ui/utils.ts'
import { Badge } from './ui/badge.tsx'
import { Card } from './ui/card.tsx'
import { Empty } from './ui/empty.tsx'
import { Separator } from './ui/separator.tsx'
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
} from './ui/dropdown-menu.tsx'
/** The task status label key. */ /** The task status label key. */
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey { function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
@@ -31,11 +45,137 @@ function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
} }
} }
/** The status Tag tone of one task. */ /** The status word as shown: an unready task reads "blocked" whatever it is. */
function taskTone(task: SidebarTeamTaskView): TagTone { function taskStatusLabel(task: SidebarTeamTaskView): string {
if (task.status === 'completed') return 'success' return t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')
if (!task.ready) return 'warning' }
return task.status === 'in_progress' ? 'info' : 'neutral'
/**
* The status Badge's semantic tint. In progress takes the accent, done takes
* success, blocked takes warning; pending stays neutral (secondary) because a
* to-do list is not an alert.
*/
function taskBadgeClass(task: SidebarTeamTaskView): string | undefined {
if (task.status === 'completed') return 'text-success'
if (!task.ready) return 'text-warning'
return task.status === 'in_progress' ? 'text-primary' : undefined
}
/** The StateDot semantic of one task row. */
function taskDotState(task: SidebarTeamTaskView): 'done' | 'warning' | 'ongoing' {
if (task.status === 'completed') return 'done'
return task.ready ? 'ongoing' : 'warning'
}
/** The StateDot semantic of one member chip. */
function memberDotState(member: SidebarTeamMemberView): 'ongoing' | 'error' | 'idle' {
return member.status === 'running' || member.status === 'provisioning'
? 'ongoing'
: member.status === 'failed' ? 'error' : 'idle'
}
/**
* The "every member" sentinel of the owner filter. It must be a NON-empty
* string: Radix treats a falsy toggle value as "nothing selected", so an empty
* sentinel would leave the whole segmented control reading as unselected.
*/
const FILTER_ALL = '__all__'
/** The shared slab: a filled rounded row whose only hover change is its background. */
const ROW_CLASS =
'flex min-w-0 cursor-pointer items-center gap-2 rounded-md bg-transparent px-2 py-1 text-left transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50'
/** The shared filter chip: sm toggle geometry on the 4px rhythm, accent when picked. */
const CHIP_CLASS =
'h-6 w-auto min-w-0 gap-1 rounded-md px-2 text-[11px] font-normal text-muted-foreground data-[state=on]:bg-primary/10 data-[state=on]:text-primary'
/** The row menu's rows: body size, no icon column (the label is the affordance). */
const ITEM_CLASS = 'text-[13px]'
/**
* One board row: state dot, subject, owner, status Badge, and the row menu.
*
* The clickable surface is a real `<button>` (the whole row except the menu),
* and the menu is its SIBLING inside a grid row — a control may not nest
* inside another, and the row's own press target must stay one element. The
* node is held in a ref so the menu can hand a MOUNTED anchor to the task
* window: the menu content is portaled and unmounts the moment an entry is
* picked, while the window measures its anchor on mount.
*/
function TeamTaskRow(props: {
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
onOpenTask(task: SidebarTeamTaskView, anchor: HTMLElement): void
}): ReactNode {
const { task, teammates, onOpenTask } = props
const rowRef = useRef<HTMLDivElement>(null)
/** Every menu entry lands on the same surface (the window owns the actions). */
const openWindow = (): void => {
const row = rowRef.current
if (row !== null) onOpenTask(task, row)
}
return (
<div ref={rowRef} className="grid grid-cols-[1fr_auto] items-center gap-1">
<button
type="button"
className={ROW_CLASS}
aria-label={`${t('teamTaskDetail')} ${task.subject}`}
title={t('teamTaskDetail')}
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
>
<StateDot size={6} state={taskDotState(task)} />
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{task.subject}</span>
{task.ownerName !== undefined && (
<span className="max-w-[72px] flex-none truncate font-mono text-[11px] tabular-nums text-foreground-3">
{task.ownerName}
</span>
)}
<Badge
variant={taskBadgeClass(task) === undefined ? 'secondary' : 'outline'}
className={cn('flex-none px-1.5 py-0 text-[11px] font-normal', taskBadgeClass(task))}
>
{taskStatusLabel(task)}
</Badge>
</button>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t('teamTaskActions')}
title={t('teamTaskActions')}
className="flex size-6 flex-none cursor-pointer items-center justify-center rounded-md bg-transparent text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<IconChevronUpOutline14 size={12} className="rotate-90" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
{task.status === 'completed' ? t('teamTaskReopen') : t('teamTaskComplete')}
</DropdownMenuItem>
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
{t('teamTaskEdit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className={ITEM_CLASS}>{t('teamTaskOwner')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-40">
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
{t('teamTaskUnowned')}
</DropdownMenuItem>
{teammates.map(member => (
<DropdownMenuItem key={member.id} className={ITEM_CLASS} onClick={openWindow}>
{member.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Neutral on purpose: this entry only OPENS the shared task window
(the danger lives in the window's two-click delete confirm), so a
permanently red menu item would over-signal — see TaskWindow. */}
<DropdownMenuItem className={ITEM_CLASS} onClick={openWindow}>
{t('teamTaskDelete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
} }
export interface TeamBoardProps { export interface TeamBoardProps {
@@ -58,88 +198,92 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
() => live.filter(task => ownerFilter === undefined || task.ownerName === ownerFilter), () => live.filter(task => ownerFilter === undefined || task.ownerName === ownerFilter),
[live, ownerFilter], [live, ownerFilter],
) )
const teammates = useMemo(() => members.filter(member => member.role === 'teammate'), [members])
return ( return (
<section className={css.teamBoard} aria-label={t('teamBoard')}> <Card
<button // `gap-0` because the bands are separated by hairlines, not by the stock
type="button" // card rhythm; `py-0` because each band owns its own padding;
className={css.teamBoardBar} // `border-border` because the hairline takes the border token, never the
aria-expanded={!collapsed} // inherited ink (this plugin ships no preflight to default it); the
onClick={onToggleCollapsed} // landmark role keeps the labelled region the old `<section>` provided.
> className="mb-2 shrink-0 gap-0 overflow-hidden rounded-lg border-border py-0"
<span className={css.teamBoardIcon} aria-hidden="true"><IconChecklistOutline14 size={11} /></span> role="region"
<span>{t('teamBoard')}</span> aria-label={t('teamBoard')}
<span className={css.teamBoardCount}> >
{t('teamChip', { members: members.length, tasks: live.length })} {/* Header: the disclosure, and the only control that collapses the board. */}
</span> <div className="flex items-center py-0.5 pr-2 pl-3">
<span <button
className={css.teamBoardChev} type="button"
style={{ display: 'inline-flex', transform: collapsed ? undefined : 'rotate(180deg)' }} className={cn(ROW_CLASS, 'flex-1 py-1.5')}
aria-hidden="true" aria-expanded={!collapsed}
aria-label={t('teamBoard')}
onClick={onToggleCollapsed}
> >
<IconChevronUpOutline14 size={12} /> <span className="flex-none text-muted-foreground" aria-hidden="true">
</span> <IconChecklistOutline14 size={12} />
</button> </span>
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">{t('teamBoard')}</span>
<span className="min-w-0 truncate font-mono text-[11px] tabular-nums text-foreground-3">
{t('teamChip', { members: members.length, tasks: live.length })}
</span>
<span
className={cn('ml-auto flex-none text-muted-foreground', collapsed ? '' : 'rotate-180')}
aria-hidden="true"
>
<IconChevronUpOutline14 size={12} />
</span>
</button>
</div>
{!collapsed && ( {!collapsed && (
<> <>
<div className={css.teamMembers}> <Separator />
<Pill {/* The owner filter: one chip per member, plus "all". */}
active={ownerFilter === undefined} <ToggleGroup
className={css.teamMemberPill} type="single"
onClick={() => { setOwnerFilter(undefined) }} variant="default"
> size="sm"
{t('teamFilterAll')} value={ownerFilter ?? FILTER_ALL}
</Pill> aria-label={t('teamMembers')}
className="flex-wrap justify-start gap-1 px-2 py-1"
onValueChange={(next) => {
// "all" is a sentinel, never a real member name — and re-clicking
// the active member clears the filter (the toggle's own off state,
// which Radix reports as an empty value).
setOwnerFilter(next === '' ? undefined : next)
}}
>
<ToggleGroupItem value={FILTER_ALL} className={CHIP_CLASS}>{t('teamFilterAll')}</ToggleGroupItem>
{members.map(member => ( {members.map(member => (
<Pill <ToggleGroupItem
key={member.id} key={member.id}
active={ownerFilter === member.name} value={member.name}
className={css.teamMemberPill}
title={`${member.name} · ${member.role}`} title={`${member.name} · ${member.role}`}
onClick={() => { className={CHIP_CLASS}
setOwnerFilter(current => (current === member.name ? undefined : member.name))
}}
> >
<StateDot <StateDot size={6} state={memberDotState(member)} />
size={6} <span className="min-w-0 truncate">{member.name}</span>
state={member.status === 'running' || member.status === 'provisioning' </ToggleGroupItem>
? 'ongoing'
: member.status === 'failed' ? 'error' : 'idle'}
/>
<span className={css.teamMemberName}>{member.name}</span>
</Pill>
))} ))}
</ToggleGroup>
<Separator />
<div className="max-h-40 overflow-y-auto p-1">
{shown.length === 0
// `Empty` is the design language's empty state; the stock card
// rhythm is overridden so an empty board stays a thin band.
? <Empty className="gap-0 rounded-md p-2 text-[12px]">{t('teamTasksEmpty')}</Empty>
: shown.map(task => (
<TeamTaskRow key={task.id} task={task} teammates={teammates} onOpenTask={onOpenTask} />
))}
</div> </div>
<div className={css.teamTasks}> <Separator />
{shown.length === 0 && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>} <div className="px-2 py-1.5">
{shown.map(task => (
<Button
key={task.id}
variant="ghost"
size="sm"
className={css.teamTask}
aria-label={`${t('teamTaskDetail')} ${task.subject}`}
title={t('teamTaskDetail')}
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
>
<StateDot
size={6}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
<span className={css.teamTaskSubject}>{task.subject}</span>
{task.ownerName !== undefined && (
<span className={css.teamTaskOwner}>{task.ownerName}</span>
)}
<Tag tone={taskTone(task)}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</Tag>
</Button>
))}
</div>
<div className={css.teamActions}>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
// `bg-background`: the host's preflight is the only thing that
// would otherwise clear the UA's button face paint.
className="bg-background"
icon={<IconPlusOutline16 size={13} />} icon={<IconPlusOutline16 size={13} />}
onClick={(event) => { onOpenTask(undefined, event.currentTarget) }} onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
> >
@@ -148,6 +292,6 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
</div> </div>
</> </>
)} )}
</section> </Card>
) )
} }
+20
View File
@@ -0,0 +1,20 @@
/**
* Lazy chunk entry: the whole Tasks page (workflow graph + tree, task window,
* team board, jobs drawer) together with the vendored shadcn/ui component set
* it renders. Built as `lib/client-tasks.js` and registered under
* `dsh-better-sidebar/tasks` — fetched only when the Tasks tab is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
*
* Why it is a chunk: the shadcn/radix/tailwind-ui layer costs ~430 KiB raw
* (radix primitives + floating-ui + tailwind-merge + the vendored components),
* and README's "startup pulls only the core bundle" contract must hold for
* every user who never opens the Tasks tab. Never import this module (or the
* page modules it re-exports) from the core bundle.
*/
// The Tailwind sheet (theme bridge + compiled utilities) ships WITH the
// chunk: it styles this page only, so the core bundle stays free of the
// UI-layer payload and the host page never receives the stylesheet until a
// reader opens the Tasks tab.
import '../ui/theme.css'
export { SubagentView } from '../SubagentView.tsx'
+3
View File
@@ -31,6 +31,9 @@ import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locale
import { loadChunk } from './chunk-loader.ts' import { loadChunk } from './chunk-loader.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
import './layout.css' import './layout.css'
// Tailwind v4 entry for the vendored shadcn/ui components (theme + utilities
// only, never preflight — see the file header). tsdown compiles it through
// postcss/@tailwindcss/postcss and injects it as one more <style data-plugin>.
/** Services required before mounting (provided by the client runtime; the /** Services required before mounting (provided by the client runtime; the
* locale service backs the sidebar's copy — see locales.ts). `modules` * locale service backs the sidebar's copy — see locales.ts). `modules`
+69
View File
@@ -0,0 +1,69 @@
/**
* Canvas-only styles of the workflow graph (TasksGraph.tsx): the dotted grid,
* the transformed node layer, the SVG relation layer and the edge stroke
* recipe. These are the four pieces Tailwind utilities cannot express in the
* markup (a radial-gradient background image, `transform-origin: 0 0` on the
* scaled layer, an overflow-visible SVG stage, and SVG stroke/dash painting) —
* everything else in the graph is a utility class over the shadcn tokens.
*
* Skin contract: every color is a `--dsw-*` token (no literal, no palette
* class), so the canvas follows the host light/dark theme like the rest of the
* page. Relations are 1px hairlines in the border ink — the KIND of a relation
* is its dash pattern and opacity, never a second hue.
*/
.canvas {
position: absolute;
inset: 0;
cursor: grab;
/* Panning must never turn into a text selection or a native scroll. */
touch-action: none;
user-select: none;
/* Graph paper: the dotted grid is part of the language. */
background-image: radial-gradient(circle, var(--dsw-alias-border-l1) 0.6px, transparent 0.6px);
background-size: 16px 16px;
background-position: 0 0;
}
.canvasDragging {
cursor: grabbing;
}
.canvasInner {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.edges {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
}
.edge {
fill: none;
stroke: var(--dsw-alias-border-l4);
stroke-width: 1;
opacity: 0.5;
}
/* Team membership: dashed. */
.edgeTeam {
stroke-dasharray: 3 3;
opacity: 0.8;
}
/* Workflow member: solid, one notch stronger than a plain lineage edge. */
.edgeWorkflow {
opacity: 0.75;
}
/* Collapsed aggregate: dotted and faint — it stands for many rows. */
.edgeFold {
stroke-dasharray: 1 4;
opacity: 0.4;
}
+6 -3
View File
@@ -15,10 +15,13 @@ import { tasksEdges } from './tasks-model.ts'
/** Geometry constants of the canvas (px, pre-scale). Metrics follow the /** Geometry constants of the canvas (px, pre-scale). Metrics follow the
* approved Variant-D mockup, whose design target is the NARROW native right * approved Variant-D mockup, whose design target is the NARROW native right
* sidebar (~360px): a 132px card fits two per row plus the gutter. */ * sidebar (~360px): a 132px card fits two per row plus the gutter. */
export const GRAPH_NODE_W = 132 export const GRAPH_NODE_W = 150
export const GRAPH_NODE_H = 46 /** Two title lines plus the mono meta line: the card clamps long agent names
* to two lines instead of ellipsizing them to a couple of characters, so the
* reserved height covers that (see NODE_TITLE in TasksGraph.tsx). */
export const GRAPH_NODE_H = 60
/** Extra height of an agent node carrying a live line. */ /** Extra height of an agent node carrying a live line. */
export const GRAPH_LIVE_H = 13 export const GRAPH_LIVE_H = 14
export const GRAPH_GAP_X = 28 export const GRAPH_GAP_X = 28
export const GRAPH_GAP_Y = 53 export const GRAPH_GAP_Y = 53
export const GRAPH_PAD = 16 export const GRAPH_PAD = 16
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -185,6 +185,24 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const out: TasksNode[] = [] const out: TasksNode[] = []
/**
* Every session id the catalogs know about, anywhere in the tree. A
* workflow member whose `childId` is listed under a DIFFERENT parent (or
* whose catalog row is missing from the run's origin) must NOT be
* synthesized into a second node with the same id: duplicate ids collide in
* React keys and in the edge map. Known ids keep their single real node;
* unknown ones are synthesized below.
*/
const knownAgentIds = new Set<string>()
for (const catalog of Object.values(catalogs)) {
if (catalog?.state !== 'ready') continue
for (const entry of catalog.entries) {
if (entry.kind === 'child') knownAgentIds.add(entry.id)
}
}
// The local children lists of `appendChildren` are built lazily per parent,
// so this global set is what makes the guard below order-independent.
/** The agent children of one parent, in catalog order (side threads and /** The agent children of one parent, in catalog order (side threads and
* diagnostics excluded), with workflow runs appended in start order. */ * diagnostics excluded), with workflow runs appended in start order. */
const appendChildren = (parentId: string): void => { const appendChildren = (parentId: string): void => {
@@ -236,6 +254,9 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
existing.parentId = runNode.id existing.parentId = runNode.id
if (tasksByNode.get(existing.id) !== undefined) existing.tasks = tasksByNode.get(existing.id) if (tasksByNode.get(existing.id) !== undefined) existing.tasks = tasksByNode.get(existing.id)
members.push(existing) members.push(existing)
} else if (member.childId !== '' && knownAgentIds.has(member.childId)) {
// Its real node exists elsewhere in the tree: the run's popover
// already lists the member, so no card is duplicated here.
} else { } else {
const childKnown = member.childId !== '' const childKnown = member.childId !== ''
members.push({ members.push({
@@ -335,7 +356,16 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
...(tasksByNode.get(rootId) !== undefined ? { tasks: tasksByNode.get(rootId) } : {}), ...(tasksByNode.get(rootId) !== undefined ? { tasks: tasksByNode.get(rootId) } : {}),
}) })
appendChildren(rootId) appendChildren(rootId)
return out
// Last-resort guard: one node per session id, first occurrence wins. Real
// catalogs list a child under exactly one parent, so this only fires on
// pathological trees (and keeps React keys unique when it does).
const seen = new Set<string>()
return out.filter((node) => {
if (seen.has(node.id)) return false
seen.add(node.id)
return true
})
} }
/** The parent→child edges of a model (derived, kept out of the build). */ /** The parent→child edges of a model (derived, kept out of the build). */
+45 -13
View File
@@ -10,6 +10,11 @@
* line 3 live tool line (running nodes only) * line 3 live tool line (running nodes only)
* line 4 owned shared task (team members only) * line 4 owned shared task (team members only)
* Everything else lives in the popovers. * 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 type { ReactNode } from 'react'
import { import {
@@ -20,7 +25,6 @@ import type { LastActivity } from '../subagent-activity.ts'
import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts' import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts'
import { toolGlyph } from './tool-icons.tsx' import { toolGlyph } from './tool-icons.tsx'
import { t, type CopyKey } from './locales.ts' import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
/** Preview cap of one tool-call argument line. */ /** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 48 const ARGS_PREVIEW = 48
@@ -138,10 +142,25 @@ export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | un
?? tasks[tasks.length - 1] ?? 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 * 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 * status word, and a `+N` tail when the agent owns more. Renders nothing
* without tasks, so non-team nodes keep the three-line shape. * 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: { export function TaskLine(props: {
tasks: readonly TasksNodeTask[] | undefined tasks: readonly TasksNodeTask[] | undefined
@@ -154,7 +173,7 @@ export function TaskLine(props: {
<span <span
role="button" role="button"
tabIndex={0} 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')} title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -167,10 +186,12 @@ export function TaskLine(props: {
props.onOpenTask(primary.id, event.currentTarget) props.onOpenTask(primary.id, event.currentTarget)
}} }}
> >
<span className={css.taskGlyph} aria-hidden="true"><IconChecklistOutline14 size={9} /></span> <span className="flex-none" aria-hidden="true"><IconChecklistOutline14 size={11} /></span>
<span className={css.taskSubject}>{primary.subject}</span> <span className="min-w-0 flex-1 truncate group-hover:text-foreground">{primary.subject}</span>
<span className={css.taskState}>{t(taskStatusKey(primary.status))}</span> <span className={`flex-none font-mono tabular-nums ${taskTone(primary)}`}>{t(taskStatusKey(primary.status))}</span>
{tasks.length > 1 && <span className={css.taskMore}>{`+${tasks.length - 1}`}</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> </span>
) )
} }
@@ -178,24 +199,35 @@ export function TaskLine(props: {
/** /**
* The live activity row of a RUNNING agent: the tool's own icon + tool name + * 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. * 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 { export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
const { live } = props const { live } = props
if (live?.text === undefined && live?.tool === undefined) { 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 ( return (
<> <>
{live.tool !== undefined && ( {live.tool !== undefined && (
<span className={css.live}> <span className="mt-0.5 flex min-w-0 items-center gap-1 truncate font-mono text-xs tabular-nums text-primary">
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(9)}</span> <span className="flex size-3 flex-none items-center justify-center rounded-sm border border-current" aria-hidden="true">
<span className={css.liveTool}>{live.tool.name}</span> {toolGlyph(live.tool.name)(9)}
{live.tool.args !== '' && <span className={css.liveArgs}>{preview(live.tool.args)}</span>} </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> </span>
)} )}
{live.text !== undefined && ( {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>
)} )}
</> </>
) )
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { Slot } from "radix-ui"
/**
* Vendored shadcn/ui badge, trimmed to the task page's real inventory.
*
* Removed from upstream (`new-york`): the `ghost` and `link` variants (dead
* class strings in a runtime object, so they would ship). `text-white` became
* the `--destructive-foreground` token, which is what the theme bridge defines
* that role for — no color literal lives in this directory.
*/
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { Slot } from "radix-ui"
/**
* Vendored shadcn/ui button, trimmed to the task page's real inventory.
*
* Removed from upstream (`new-york`): the `link` variant and the `xs` / `lg` /
* `icon-xs` / `icon-sm` / `icon-lg` sizes. The page's control heights are 28 px
* (`sm`), 36 px (`default`) and the square 36 px icon button; every removed
* class still lived in this runtime object and would have shipped in the bundle.
*/
const buttonVariants = cva(
// LOCAL ADAPTATION: `transition-all` narrowed to the same property list the
// page's other vendored controls use (see ui/toggle.tsx) — `all` also
// animates layout/size changes, which makes resizes and mount transitions
// laggy on a dense task page.
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
/**
* LOCAL ADAPTATION (dsh-better-sidebar): `React.forwardRef`, because this
* plugin runs on the DSH host's React 18 (react@^18.2.0 — ref-as-prop is a
* React 19 feature). Without it, `<TooltipTrigger asChild><Button/></…>`
* cannot measure its anchor: radix's `Slot` hands the child a ref and a plain
* function component drops it (dev builds warn, production silently
* mis-positions the floating layer). Upstream ships no forwardRef because the
* registry targets React 19; the shadcn CLI `--diff` flow will try to remove
* it — re-apply this adaptation when updating the file.
*/
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}
>(function Button({ className, variant = "default", size = "default", asChild = false, ...props }, ref) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
})
export { Button, buttonVariants }
+91
View File
@@ -0,0 +1,91 @@
import * as React from "react"
import { cn } from "./utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+31
View File
@@ -0,0 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
/**
* Vendored shadcn/ui dropdown menu, trimmed to the task page's real inventory.
*
* Removed from upstream (`new-york`): `DropdownMenuCheckboxItem`,
* `DropdownMenuRadioGroup`, `DropdownMenuRadioItem` and `DropdownMenuShortcut`
* (no checkbox/radio/shortcut rows exist on the page). `DropdownMenuSub` and its
* trigger/content stay — the node menu's "reassign owner" is a submenu.
*
* The submenu arrow is the host's `IconChevronRightOutline14`, so the file
* imports no icon library of its own.
*/
import * as React from "react"
import { IconChevronRightOutline14 } from "@deepseek-ai/dsh-client-ui-primitives"
import { cn } from "./utils"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<IconChevronRightOutline14 size={16} className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+103
View File
@@ -0,0 +1,103 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react"
import { cn } from "./utils"
/**
* Vendored shadcn/ui input, minus upstream's `file:` variant utilities
* (`file:inline-flex` `file:h-7` `file:border-0` `file:bg-transparent`
* `file:text-sm` `file:font-medium` `file:text-foreground`).
*
* Two reasons, and both are load-bearing: the plugin has no `<input
* type="file">` surface, so they are dead class strings in a runtime object;
* and they compile to `::file-selector-button` selectors, which is also
* Tailwind preflight's signature — `tests/ui-foundation.spec.ts` uses that
* string to prove the reset never ships (plugin CSS is a global `<style>`, so a
* reset would repaint the whole DSH page), and the file-input variant would
* trip that guard forever. Nothing else on the element changes.
*/
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
className
)}
{...props}
/>
)
}
export { Input }
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import { cn } from "./utils"
import { Popover as PopoverPrimitive } from "radix-ui"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { cn } from "./utils"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "./utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "./utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
+29
View File
@@ -0,0 +1,29 @@
import { IconLoadingOutline16 } from "@deepseek-ai/dsh-client-ui-primitives"
import { cn } from "./utils"
/**
* Loading indicator. The host supplies the glyph (`IconLoadingOutline16`), and
* the rotation rides `animate-spin` on it — the host icons render an `svg`, so
* the utility applies to the glyph box the same way it did upstream.
*
* Upstream took `React.ComponentProps<"svg">`; host icons expose exactly
* `size` / `className`, so the accessible status wrapper moved out one level
* instead of being spread onto the glyph.
*/
function Spinner({
className,
size = 16,
label = "Loading",
}: {
className?: string
size?: number
label?: string
}) {
return (
<span role="status" aria-label={label} className="inline-flex">
<IconLoadingOutline16 size={size} className={cn("animate-spin", className)} />
</span>
)
}
export { Spinner }
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "./utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+181
View File
@@ -0,0 +1,181 @@
/*
* Tailwind v4 entry for the plugin's vendored shadcn/ui components.
*
* Two deliberate omissions:
*
* 1. PREFLIGHT IS NEVER IMPORTED. The plugin's CSS ships as a GLOBAL
* `<style data-plugin>` tag injected by the client bundle (no shadow DOM),
* so Tailwind's reset would repaint the whole DSH host page (headings,
* lists, buttons, borders — every element in the conversation and the
* native sidebar). Only `theme.css` (design tokens) and `utilities.css`
* (the classes we actually use) are pulled in.
* 2. NO COLOR LITERALS AND NO DARK PALETTE. Every value in `:root` below
* resolves to a DSH `--dsw-*` token, which already flips with the host
* light/dark theme (`body[data-ds-dark-theme]`). That is the skin contract
* (docs/external-plugin-guide.md §12, guarded by tests/theme.spec.ts).
*
* `@source` is EXPLICIT so the scanner covers only the client TSX tree —
* without it Tailwind would glob the project root and pick class-like strings
* out of `lib/`, `tests/`, `node_modules/` and the docs.
*/
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@source "../**/*.tsx";
/* The DSH theme flips by toggling `data-ds-dark-theme` on <body> (see
* @deepseek-ai/dsh-client-ui-theme), not by adding a `.dark` class. */
@custom-variant dark (&:where([data-ds-dark-theme], [data-ds-dark-theme] *));
/*
* Bridge: shadcn/ui's semantic names -> Tailwind color/radius/font utilities.
*
* `inline` keeps the generated utilities as `var(--background)` etc. instead of
* copying the (indirect) value, so exactly one indirection layer exists and a
* host skin that re-paints a token is picked up live.
*/
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Extra rungs the task page needs beyond stock shadcn: success/warning for
* task state and a third text weight (design language: three ink levels by
* weight, not by hue). */
--color-success: var(--success);
--color-warning: var(--warning);
--color-foreground-3: var(--foreground-3);
--color-border-strong: var(--border-strong);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
/* Text roles come from the host's own font tokens so the plugin cannot
* drift from the DSH type scale. */
--font-sans: var(--dsw-font-family-default, system-ui, sans-serif);
--font-mono: var(--dsw-font-family-code, ui-monospace, monospace);
}
/*
* shadcn tokens -> DSH alias tokens. Every value is a `var(--dsw-…)`
* reference; nothing here is a color literal, and no dark override is needed
* because the DSH tokens themselves flip with the host theme.
*/
:root {
--radius: 6px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--background: var(--dsw-alias-bg-base);
--foreground: var(--dsw-alias-label-primary);
--card: var(--dsw-alias-bg-layer-1);
--card-foreground: var(--dsw-alias-label-primary);
--popover: var(--dsw-alias-bg-layer-2);
--popover-foreground: var(--dsw-alias-label-primary);
--primary: var(--dsw-alias-state-business-primary);
--primary-foreground: var(--dsw-alias-label-primary-foreground);
--secondary: var(--dsw-alias-interactive-bg-hover);
--secondary-foreground: var(--dsw-alias-label-primary);
--muted: var(--dsw-alias-interactive-bg-hover);
--muted-foreground: var(--dsw-alias-label-secondary);
--accent: var(--dsw-alias-interactive-bg-hover);
--accent-foreground: var(--dsw-alias-label-primary);
--destructive: var(--dsw-alias-state-error-primary);
--destructive-foreground: var(--dsw-alias-label-primary-foreground);
--border: var(--dsw-alias-border-l4);
--input: var(--dsw-alias-border-l4);
--ring: var(--dsw-alias-state-business-primary);
--success: var(--dsw-alias-state-success-primary);
--warning: var(--dsw-alias-state-warn-primary);
--foreground-3: var(--dsw-alias-label-tertiary);
--border-strong: var(--dsw-alias-label-tertiary);
}
/*
* Scoped resets.
*
* The host's own stylesheets are UNLAYERED, so an unlayered host rule outranks
* anything Tailwind puts in `layer(utilities)` regardless of specificity — a
* layered `.rounded-md` cannot beat a bare host rule. The compensation is
* `.dsw-tasks` (the task page root class): its own rules live in the layer but
* carry class-level specificity, and `:where()` keeps the element resets at
* class specificity too, so `color`/`font`/`box-sizing` reach the page's
* elements without a preflight and without leaking to the rest of DSH.
*/
@layer base {
.dsw-tasks {
color: var(--dsw-alias-label-primary);
}
.dsw-tasks :where(button, input, textarea, select) {
font: inherit;
}
.dsw-tasks :where(*, *::before, *::after) {
box-sizing: border-box;
}
/* The host strips/overrides the UA focus ring in places; the task page owns
* a visible, token-colored one (design language: focus-visible must show). */
.dsw-tasks :where(a, button, input, textarea, select, [tabindex]):focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
}
/*
* Thin scrollbars for the shadcn ScrollArea / scrollable task surfaces, riding
* the host's scrollbar tokens (they already flip with the theme).
*/
@layer utilities {
.dsw-tasks {
scrollbar-width: thin;
scrollbar-color: var(--dsw-alias-scrollbar-hover-l2) transparent;
}
.dsw-tasks :where(*)::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dsw-tasks :where(*)::-webkit-scrollbar-track {
background: transparent;
}
.dsw-tasks :where(*)::-webkit-scrollbar-thumb {
background: var(--dsw-alias-scrollbar-bg-l2);
border-radius: 4px;
}
.dsw-tasks :where(*)::-webkit-scrollbar-thumb:hover {
background: var(--dsw-alias-scrollbar-hover-l2);
}
}
/* Motion is decorative only (opacity / background / transform); users who ask
* for less get none of it. */
@media (prefers-reduced-motion: reduce) {
.dsw-tasks :where(*, *::before, *::after) {
animation: none !important;
transition: none !important;
}
}
+81
View File
@@ -0,0 +1,81 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "./toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { Toggle as TogglePrimitive } from "radix-ui"
/**
* Vendored shadcn/ui toggle, trimmed to the task page's real inventory.
*
* Removed from upstream (`new-york`): the `lg` size (the page's toggle heights
* top out at 36 px). `Toggle` itself stays — the view switcher's segmented
* control is built from it via `ToggleGroup`.
*/
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "./utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+16
View File
@@ -0,0 +1,16 @@
/**
* The shadcn/ui class-name helper: `clsx` for conditional composition,
* `tailwind-merge` so a caller's later utility wins over a component's default
* of the same kind (`cn('px-2', 'px-4')` -> `px-4`) instead of both landing in
* the class list and letting stylesheet order decide.
*
* Vendored shadcn components import this file directly (relative path); the
* CLI's `@/lib/utils` alias is never used in this repo.
*/
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** Merge conditional class values, resolving conflicting Tailwind utilities. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
+41
View File
@@ -191,3 +191,44 @@ describe('tasksEdges', () => {
expect(kindOf('m1')).toBe('workflow') expect(kindOf('m1')).toBe('workflow')
}) })
}) })
describe('buildTasksModel: duplicate workflow members', () => {
it('never synthesizes a second node for a member another parent already lists', () => {
// `m1` is a real child of `a`, but a run started by `root` names it as a
// member — the node must stay single (React keys / edge map depend on it)
// and the run simply does not duplicate it on the canvas.
const audit = run({
originSessionId: 'root',
phases: [{ title: '侦察', members: [{ seq: 1, label: 'm1', childId: 'm1', outcome: 'completed' }] }],
})
const model = buildTasksModel(input({
catalogs: {
root: catalog([child('a')]),
a: catalog([child('m1', { activity: 'inactive' })]),
},
byId: { root: summary('root'), a: summary('a'), m1: summary('m1') },
runs: [audit],
folded: false,
}))
const ids = model.map(node => node.id)
expect(ids.filter(id => id === 'm1')).toHaveLength(1)
expect(new Set(ids).size).toBe(ids.length)
// The real node keeps its catalog parent.
expect(model.find(node => node.id === 'm1')?.parentId).toBe('a')
})
it('still synthesizes a member no catalog knows', () => {
const audit = run({
originSessionId: 'root',
phases: [{ title: '侦察', members: [{ seq: 1, label: 'ghost member', childId: 'ghost' }] }],
})
const model = buildTasksModel(input({
catalogs: { root: catalog([]) },
byId: { root: summary('root') },
runs: [audit],
folded: false,
}))
const ghost = model.find(node => node.id === 'ghost')
expect(ghost).toMatchObject({ parentId: 'run:run-1', synthesized: true })
})
})
+101
View File
@@ -157,3 +157,104 @@ describe('skin contract: the plugin owns no color of its own', () => {
} }
}) })
}) })
/**
* Task-page migration guard (the shadcn/ui rework).
*
* The vendored components under `src/client/ui/**` and every migrated
* task-page module must take their colors from a DSH token — either directly
* (`var(--dsw-…)`) or through one of the shadcn aliases bridged in
* `src/client/ui/theme.css` (`--background`, `--border`, …). Three ways that
* can rot silently:
*
* 1. A color literal (`#hex`, `rgb()`, `oklch()`, …) — the host skin can no
* longer repaint it, and the value stops flipping with dark mode.
* 2. A Tailwind default-palette class (`bg-blue-500`, `text-white`) — its
* value is defined by Tailwind's own theme, not by DSH.
* 3. A `var()` in a migrated stylesheet pointing at neither a `--dsw-*` token
* nor a bridged alias — a typo falls back to the initial color.
*
* Comments are stripped first: prose legitimately quotes the old values (the
* Badge docstring records that upstream's `text-white` became the
* `--destructive-foreground` token), and upstream issue numbers look like
* three-digit hex colors.
*/
const VENDORED_UI_DIR = 'src/client/ui'
const MIGRATED_TASK_FILES = [
'src/client/SubagentView.tsx',
'src/client/SubagentView.module.css',
'src/client/TasksGraph.tsx',
'src/client/TasksTree.tsx',
'src/client/TaskWindow.tsx',
'src/client/TeamBoard.tsx',
'src/client/JobsDrawer.tsx',
'src/client/TasksPopovers.tsx',
'src/client/AnchoredPopover.tsx',
'src/client/tasks-shared.tsx',
'src/client/tasks-canvas.module.css',
]
/** Drop block comments and whole-line `//` comments (URLs/strings stay intact). */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
}
/** Any spelled-out color value (the skin contract's forbidden set). */
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color-mix|light-dark)\(/
/** Tailwind's default palette families, which are not DSH tokens. */
const PALETTE_CLASS
= /\b(?:bg|text|border|ring|fill|stroke|from|via|to|outline|divide|shadow|accent|caret|decoration)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)(?:-\d{2,3})?\b/
/** Paint declarations whose value must resolve to a token. */
const PAINT_DECLARATION
= /(?:^|;)\s*(background(?:-color|-image)?|color|border(?:-(?:top|right|bottom|left))?-color|border|outline(?:-color)?|fill|stroke|box-shadow|text-decoration-color|caret-color|accent-color|scrollbar-color)\s*:\s*([^;}]+)/g
/** A paint declaration naming no token at all may only be inert. */
const INERT_PAINT = /^(?:none|transparent|currentcolor|inherit|initial|unset|revert)$/i
/** Every variable the theme bridge defines (or forwards) — the alias allowlist. */
const THEME_ALIASES = new Set(
[...readFileSync(resolve(ROOT, `${VENDORED_UI_DIR}/theme.css`), 'utf8').matchAll(/--([a-z0-9-]+)\s*:/g)]
.map(match => match[1]),
)
/** `--dsw-*` is the contract; the bridged aliases and runtime vars are the only indirection. */
function tokenAllows(name: string): boolean {
return name.startsWith('dsw-') || name.startsWith('tw-') || name.startsWith('radix-') || THEME_ALIASES.has(name)
}
describe('skin contract: the migrated task page and the vendored ui/ own no color', () => {
const uiFiles = readdirSync(resolve(ROOT, VENDORED_UI_DIR))
.filter(name => /\.(?:tsx|ts|css)$/.test(name))
.map(name => `${VENDORED_UI_DIR}/${name}`)
const files = [...uiFiles, ...MIGRATED_TASK_FILES]
it('covers the vendored components and the migrated task-page modules', () => {
expect(uiFiles.length).toBeGreaterThan(10)
for (const file of files) expect(readFileSync(resolve(ROOT, file), 'utf8').length, file).toBeGreaterThan(0)
})
it.each(files)('%s carries no color literal', (file) => {
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(COLOR_LITERAL)
})
it.each(files)('%s uses no Tailwind default-palette class', (file) => {
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(PALETTE_CLASS)
})
it('binds every paint declaration in the migrated stylesheets to a token', () => {
for (const file of files.filter(name => name.endsWith('.css'))) {
const styles = stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
for (const declaration of styles.matchAll(PAINT_DECLARATION)) {
const [, property, value] = declaration
const vars = [...(value ?? '').matchAll(/var\(--([a-z0-9-]+)/gi)].map(match => match[1] ?? '')
if (vars.length === 0) {
expect(INERT_PAINT.test((value ?? '').trim()), `${file}: ${property}: ${value}`).toBe(true)
continue
}
for (const name of vars) expect(tokenAllows(name), `${file}: ${property}: var(--${name})`).toBe(true)
}
}
})
})
+125
View File
@@ -0,0 +1,125 @@
/**
* Built-artifact gate for the Tailwind v4 + shadcn/ui migration.
*
* The Tasks page — the workflow graph, the tree, the task window, the team
* board, the jobs drawer AND the vendored shadcn/radix component layer — lives
* in the lazy `tasks` chunk (`lib/client-tasks.js`), so this gate checks BOTH
* artifacts:
*
* - **core** (`lib/client.js`): must stay free of the UI layer. No Tailwind
* utilities, no radix, no tailwind-merge — those are the chunk's payload.
* It also must never carry preflight (its CSS is injected into the host page
* globally, with no shadow root) nor a palette literal.
* - **chunk** (`lib/client-tasks.js`): carries the compiled Tailwind output
* (`--tw-*` plumbing plus the generated `.flex{}`), the bridged tokens and
* the radix components; preflight and oklch stay out of it for the same
* global-injection reason.
*
* Preflight is detected by its DECLARATION payload, not the selector spelling:
* the minifier rewrites `::before` to `:before`, Tailwind's own `@property`
* fallback shares the `*,:before,:after,::backdrop` selector list, and
* `::file-selector-button` is emitted legitimately by the `file:` variant
* (that false positive is why this check lives here rather than in a
* substring match).
*
* `lib/` is a build output, not a test prerequisite, so every spec SKIPS
* (loudly, via skipIf) when the artifact is absent instead of failing an
* unbuilt checkout or building behind the suite's back.
*/
// @vitest-environment node
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const CORE_BUNDLE = resolve(process.cwd(), 'lib/client.js')
const TASKS_CHUNK = resolve(process.cwd(), 'lib/client-tasks.js')
/** Tailwind's generated output: the utility class and its `--tw-*` plumbing. */
const TAILWIND_UTILITY = '.flex{'
const TAILWIND_CUSTOM_PROPERTY = '--tw-'
/** Preflight's universal reset, in the spellings Tailwind/minification emit. */
const PREFLIGHT_RESET_DECLARATIONS = /box-sizing:\s*border-box;\s*margin:\s*0/
const PREFLIGHT_SELECTORS = [/\*,::before,::after/, /\*, ::before, ::after/]
const PREFLIGHT_HTML_BLOCK = '-webkit-text-size-adjust'
/** The measured pre-migration core bundle (HEAD 12b4716, same toolchain). */
const PRE_MIGRATION_CORE_BYTES = 1_012_271
/** The migration's reviewed core allowance (chunk payload excluded). */
const CORE_BUDGET_BYTES = PRE_MIGRATION_CORE_BYTES + 250 * 1024
/** A bundle smaller than this lost the plugin entirely. */
const SANITY_FLOOR_BYTES = 500 * 1024
/** Every preflight signature, asserted absent from one artifact. */
function expectNoPreflight(source: string): void {
for (const selector of PREFLIGHT_SELECTORS) expect(source).not.toMatch(selector)
expect(source).not.toMatch(PREFLIGHT_RESET_DECLARATIONS)
expect(source).not.toContain(PREFLIGHT_HTML_BLOCK)
}
describe('lib/client.js: the core bundle stays free of the UI layer', () => {
const built = existsSync(CORE_BUNDLE)
const bundle = built ? readFileSync(CORE_BUNDLE, 'utf8') : ''
it.skipIf(!built)('carries no Tailwind utilities (they belong to the tasks chunk)', () => {
expect(bundle).not.toContain(TAILWIND_CUSTOM_PROPERTY)
expect(bundle).not.toContain(TAILWIND_UTILITY)
})
it.skipIf(!built)('carries neither radix nor tailwind-merge nor lucide', () => {
expect(bundle).not.toContain('radix-ui')
expect(bundle).not.toContain('tailwind-merge')
expect(bundle).not.toContain('lucide-react')
})
it.skipIf(!built)('carries no preflight reset and no palette literal', () => {
expectNoPreflight(bundle)
// Tailwind's own palette is oklch-based; any hit is a leaked value.
expect(bundle).not.toContain('oklch(')
})
it.skipIf(!built)('fits the reviewed core budget and reports its size', () => {
const bytes = Buffer.byteLength(bundle)
expect(bytes).toBeGreaterThan(SANITY_FLOOR_BYTES)
expect(bytes).toBeLessThanOrEqual(CORE_BUDGET_BYTES)
console.log(
`[ui-bundle] lib/client.js = ${bytes} bytes (${(bytes / 1024).toFixed(1)} KiB);`
+ ` pre-migration ${PRE_MIGRATION_CORE_BYTES} bytes; delta ${bytes - PRE_MIGRATION_CORE_BYTES}`,
)
})
})
describe('lib/client-tasks.js: the lazy chunk carries the shadcn layer', () => {
const built = existsSync(TASKS_CHUNK)
const chunk = built ? readFileSync(TASKS_CHUNK, 'utf8') : ''
it.skipIf(!built)('carries the compiled Tailwind output and the bridged tokens', () => {
expect(chunk).toContain(TAILWIND_CUSTOM_PROPERTY)
expect(chunk).toContain(TAILWIND_UTILITY)
// The bridge is what keeps the skin contract: shadcn variables point at
// DSH tokens, so no palette value is ever painted.
expect(chunk).toContain('var(--dsw-alias-state-business-primary)')
})
it.skipIf(!built)('carries the radix component layer', () => {
expect(chunk).toContain('radix-ui')
})
it.skipIf(!built)('carries no preflight reset and no palette literal', () => {
// The chunk's stylesheet is injected globally too, so the same rule holds.
expectNoPreflight(chunk)
expect(chunk).not.toContain('oklch(')
expect(chunk).not.toContain('lucide-react')
})
it.skipIf(!built)('assigns its global chunk registry slot', () => {
// chunk-loader.ts materializes the factory from this slot.
expect(chunk).toContain('__dshChunks__["tasks"]')
})
it.skipIf(!built)('reports its size for the gate', () => {
const bytes = Buffer.byteLength(chunk)
expect(bytes).toBeGreaterThan(SANITY_FLOOR_BYTES / 4)
console.log(`[ui-bundle] lib/client-tasks.js = ${bytes} bytes (${(bytes / 1024).toFixed(1)} KiB)`)
})
})
+205
View File
@@ -0,0 +1,205 @@
/**
* Tailwind v4 + shadcn/ui foundation guards.
*
* The plugin ships ONE global stylesheet (src/client/ui/theme.css, compiled by
* tsdown's css-inline plugin into `<style data-plugin>`), and the two ways that
* can go wrong are silent:
*
* 1. Someone adds `@import "tailwindcss"` (or preflight) — the reset then
* repaints the entire DSH host page, because plugin CSS is not scoped by a
* shadow root. The first test locks the import list and the layer names; the
* bundle test looks for preflight's own selectors in the built artifact.
* 2. Someone bridge a shadcn token to a literal instead of a `--dsw-*` token —
* a dark/light theme switch then leaves the plugin's controls unreadable.
* That is the skin contract (docs/external-plugin-guide.md §12).
*
* `@source` is also guarded: if it disappears, Tailwind falls back to scanning
* the project root (lib/, tests/, node_modules/), which both bloats the bundle
* and lets `lib/*.js` class-like strings feed the scanner.
*/
// @vitest-environment node
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { compileTailwind } from '../tsdown.config.ts'
const ROOT = process.cwd()
const THEME_CSS = resolve(ROOT, 'src/client/ui/theme.css')
const CLIENT_BUNDLE = resolve(ROOT, 'lib/client.js')
/** The lazy chunk that carries the Tasks page and the shadcn layer. */
const TASKS_CHUNK = resolve(ROOT, 'lib/client-tasks.js')
/** The shadcn token -> DSH token bridge, as specified by the design language. */
const TOKEN_BRIDGE: Array<[shadcn: string, dsw: string]> = [
['--background', '--dsw-alias-bg-base'],
['--foreground', '--dsw-alias-label-primary'],
['--card', '--dsw-alias-bg-layer-1'],
['--card-foreground', '--dsw-alias-label-primary'],
['--popover', '--dsw-alias-bg-layer-2'],
['--popover-foreground', '--dsw-alias-label-primary'],
['--primary', '--dsw-alias-state-business-primary'],
['--primary-foreground', '--dsw-alias-label-primary-foreground'],
['--secondary', '--dsw-alias-interactive-bg-hover'],
['--secondary-foreground', '--dsw-alias-label-primary'],
['--muted', '--dsw-alias-interactive-bg-hover'],
['--muted-foreground', '--dsw-alias-label-secondary'],
['--accent', '--dsw-alias-interactive-bg-hover'],
['--accent-foreground', '--dsw-alias-label-primary'],
['--destructive', '--dsw-alias-state-error-primary'],
['--destructive-foreground', '--dsw-alias-label-primary-foreground'],
['--border', '--dsw-alias-border-l4'],
['--input', '--dsw-alias-border-l4'],
['--ring', '--dsw-alias-state-business-primary'],
['--success', '--dsw-alias-state-success-primary'],
['--warning', '--dsw-alias-state-warn-primary'],
['--foreground-3', '--dsw-alias-label-tertiary'],
['--border-strong', '--dsw-alias-label-tertiary'],
]
describe('tailwind entry: theme + utilities only', () => {
const theme = readFileSync(THEME_CSS, 'utf8')
it('imports exactly the theme and utilities layers, and never preflight', () => {
const imports = [...theme.matchAll(/@import\s+"([^"]+)"\s+layer\(([^)]+)\);/g)]
.map(match => [match[1], match[2]])
expect(imports).toEqual([
['tailwindcss/theme.css', 'theme'],
['tailwindcss/utilities.css', 'utilities'],
])
// No bare `@import "tailwindcss"` and no stylesheet path that could be the
// reset (both forms pull preflight in; the file's own comment is prose).
expect(theme).not.toMatch(/@import\s+"tailwindcss"/)
expect(theme).not.toMatch(/@import\s+"[^"]*preflight/)
})
it('scans only the client TSX tree', () => {
expect(theme).toMatch(/@source\s+"\.\.\/\*\*\/\*\.tsx";/)
})
it('defines the dark variant from the host attribute, not a .dark class', () => {
expect(theme).toMatch(/@custom-variant\s+dark\s+\(&:where\(\[data-ds-dark-theme\],\s*\[data-ds-dark-theme\]\s*\*\)\);/)
expect(theme).not.toMatch(/@custom-variant\s+dark\s+\(&:where\(\.dark/)
})
it('bridges every shadcn token to a DSH token', () => {
for (const [name, dsw] of TOKEN_BRIDGE) {
expect(theme, name).toContain(`${name}: var(${dsw})`)
}
})
it('carries the radius scale and the scoped base reset', () => {
for (const [name, value] of [['--radius', '6px'], ['--radius-sm', '4px'], ['--radius-md', '6px'], ['--radius-lg', '8px'], ['--radius-xl', '12px']]) {
expect(theme, name).toContain(`${name}: ${value}`)
}
expect(theme).toMatch(/\.dsw-tasks\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary\)/)
expect(theme).toMatch(/\.dsw-tasks\s+:where\(button, input, textarea, select\)\s*\{[^}]*font:\s*inherit/)
expect(theme).toMatch(/\.dsw-tasks\s+:where\(\*, \*::before, \*::after\)\s*\{[^}]*box-sizing:\s*border-box/)
// Motion is opt-out-able.
expect(theme).toMatch(/@media \(prefers-reduced-motion: reduce\)/)
})
it('scopes every portal-rendered popover root back under .dsw-tasks', () => {
// The popovers are portaled to document.body, i.e. OUTSIDE the page root
// that carries `.dsw-tasks` — without the class on their own root the
// scoped base reset (font inheritance, box-sizing, focus-visible ring on
// bare controls) silently stops applying there. Regression: the first
// migration shipped TaskWindow with the class but not the two node
// popovers nor the job-output popover.
const portalContents = [
'src/client/TaskWindow.tsx',
'src/client/TasksPopovers.tsx',
'src/client/JobsDrawer.tsx',
]
for (const file of portalContents) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
expect(source, file).toContain('dsw-tasks')
}
// The shell itself portals — that is the reason the class is needed.
const shell = readFileSync(resolve(ROOT, 'src/client/AnchoredPopover.tsx'), 'utf8')
expect(shell).toContain('createPortal')
})
it('holds the skin contract: no color literal anywhere in the entry', () => {
// Comments carry prose, not values — strip them before looking.
const withoutComments = theme.replace(/\/\*[\s\S]*?\*\//g, '')
expect(withoutComments).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(withoutComments).not.toMatch(/\b(?:rgba?|hsla?|oklch|lab|lch|color-mix)\(/)
// Every custom-property value is either a DSH token, another token defined
// in this file (the @theme inline block), or a length from the radius
// scale — never a paint value of its own.
const definedElsewhere = new Set([
...TOKEN_BRIDGE.map(([name]) => name.slice(2)),
'radius', 'radius-sm', 'radius-md', 'radius-lg', 'radius-xl',
])
for (const declaration of withoutComments.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) {
const [, name, raw] = declaration
const value = (raw ?? '').trim()
const reference = value.match(/^var\(--([a-z0-9-]+)/)
const refName = reference?.[1]
const ok = (refName !== undefined && (refName.startsWith('dsw-') || definedElsewhere.has(refName)))
|| /^[0-9.]+(?:px|rem|em|%)$/.test(value)
expect(ok, `${name}: ${value}`).toBe(true)
}
})
})
describe('tailwind compile: the shared pipeline produces utilities bound to DSH tokens', () => {
it('emits utilities and the token indirection, and no preflight', async () => {
const css = await compileTailwind(readFileSync(THEME_CSS, 'utf8'), THEME_CSS, { minify: true })
expect(css).toMatch(/--tw-/)
expect(css).toMatch(/\.flex\{/)
expect(css).toContain('var(--dsw-alias-state-business-primary)')
expect(css).toContain('.dsw-tasks')
// Preflight's actual signature is its universal reset block. The selector
// spelling alone is not enough (minification rewrites `::before` to
// `:before`, and the utilities layer's own `*,:before,:after,::backdrop`
// @property fallback shares the selector list), and
// `::file-selector-button` is not a signature at all: Tailwind's `file:`
// variant emits it legitimately for the vendored Input's file button.
expect(css).not.toMatch(/box-sizing:\s*border-box;\s*margin:\s*0/)
expect(css).not.toContain('-webkit-text-size-adjust')
})
})
describe('built artifacts: tailwind inlined, no preflight, no new deps', () => {
const built = existsSync(CLIENT_BUNDLE)
// `pnpm build` is a gate command, not a test prerequisite; skip loudly rather
// than fail a checkout that has not been built yet.
//
// The Tasks page (and therefore the shadcn layer's stylesheet) lives in the
// lazy `tasks` chunk, so the compiled utilities are asserted against THAT
// artifact; tests/ui-bundle.spec.ts owns the core/chunk split contract.
const chunkBuilt = existsSync(TASKS_CHUNK)
it.skipIf(!chunkBuilt)('inlines the compiled utilities into the tasks chunk', () => {
const bundle = readFileSync(TASKS_CHUNK, 'utf8')
expect(bundle).toMatch(/--tw-/)
expect(bundle).toMatch(/\.flex\{/)
// The scoped base reset travels with the utilities.
expect(bundle).toContain('.dsw-tasks')
expect(bundle).toContain('--dsw-alias-state-business-primary')
})
it.skipIf(!built)('keeps the core bundle free of the tailwind layer', () => {
const bundle = readFileSync(CLIENT_BUNDLE, 'utf8')
expect(bundle).not.toMatch(/--tw-/)
expect(bundle).not.toContain('radix-ui')
})
it.skipIf(!built)('ships no preflight reset', () => {
const bundle = readFileSync(CLIENT_BUNDLE, 'utf8')
expect(bundle).not.toMatch(/\*,::before,::after/)
expect(bundle).not.toMatch(/\*, ::before, ::after/)
// The minifier rewrites `::before` to `:before`, so the selector spelling
// is not a complete signature — the reset's declaration payload is.
expect(bundle).not.toMatch(/box-sizing:\s*border-box;\s*margin:\s*0/)
expect(bundle).not.toContain('-webkit-text-size-adjust')
})
it.skipIf(!built)('ships no color literal from the tailwind palette', () => {
const bundle = readFileSync(CLIENT_BUNDLE, 'utf8')
// Tailwind's default palette is defined in oklch; any hit means a palette
// value leaked into the sheet instead of a --dsw-* token.
expect(bundle).not.toContain('oklch(')
expect(bundle).not.toContain('lucide-react')
})
})
+93
View File
@@ -0,0 +1,93 @@
/**
* "Static panels cast no shadow" — the elevation half of the task page's
* design language.
*
* The pre-migration page gave every card, node and row its own tinted,
* shadowed box, which is what made a graph with many nodes look garish; the
* rework replaced elevation with 1px hairlines and the surface ladder
* (background / card / popover). Shadows survive only on layers that really
* float above the page and are positioned by their own anchor: the
* `AnchoredPopover` shell (the task window / node popover chassis), and the
* vendored `popover` / `dropdown-menu` / `tooltip` primitives, which render
* into a portal.
*
* The guard is deliberately source-level: a `shadow-*` utility dropped into a
* static panel is exactly the regression, and the class name is the only place
* it is visible (Tailwind generates the CSS at build time).
*/
// @vitest-environment node
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const ROOT = process.cwd()
const VENDORED_UI_DIR = 'src/client/ui'
/** Panels that are laid out IN the page: they may never paint a shadow. */
const STATIC_PANEL_FILES = [
'src/client/SubagentView.tsx',
'src/client/SubagentView.module.css',
'src/client/TasksGraph.tsx',
'src/client/TasksTree.tsx',
'src/client/TaskWindow.tsx',
'src/client/TeamBoard.tsx',
'src/client/JobsDrawer.tsx',
'src/client/TasksPopovers.tsx',
'src/client/tasks-shared.tsx',
'src/client/tasks-canvas.module.css',
]
/** The only files allowed to paint an elevation shadow (floating layers). */
const FLOATING_FILES = [
'src/client/AnchoredPopover.tsx',
`${VENDORED_UI_DIR}/popover.tsx`,
`${VENDORED_UI_DIR}/dropdown-menu.tsx`,
`${VENDORED_UI_DIR}/tooltip.tsx`,
]
/**
* Tailwind's elevation utilities (`shadow`, `shadow-md`, `shadow-[…]`,
* `shadow-inner`, …) — but NOT `box-shadow`, which is how the components spell
* the focus-ring transition (`transition-[color,box-shadow]`) and the ring
* plumbing (`--tw-shadow`).
*/
const SHADOW_UTILITY = /(?<![-\w])shadow(?:-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)|\[|\()/
/** A literal `box-shadow` declaration is the same defect in stylesheet form. */
const BOX_SHADOW_DECLARATION = /(?:^|;)\s*box-shadow\s*:/
/** Comments are prose about shadows ("no shadow — a node never floats"). */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
}
const read = (file: string) => stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
describe('task page: static panels cast no shadow', () => {
it.each(STATIC_PANEL_FILES)('%s carries no shadow utility', (file) => {
expect(read(file), file).not.toMatch(SHADOW_UTILITY)
})
it('no migrated stylesheet declares box-shadow', () => {
for (const file of [...STATIC_PANEL_FILES, ...FLOATING_FILES].filter(name => name.endsWith('.css'))) {
expect(read(file), file).not.toMatch(BOX_SHADOW_DECLARATION)
}
})
it('keeps the shadow in the floating allowlist only', () => {
const uiFiles = readdirSync(resolve(ROOT, VENDORED_UI_DIR))
.filter(name => /\.(?:tsx|ts|css)$/.test(name))
.map(name => `${VENDORED_UI_DIR}/${name}`)
const surface = [...STATIC_PANEL_FILES, ...FLOATING_FILES, ...uiFiles]
for (const file of new Set(surface)) {
if (FLOATING_FILES.includes(file)) continue
expect(read(file), file).not.toMatch(SHADOW_UTILITY)
}
})
it('is not a dead allowlist: the popover shell really floats', () => {
// AnchoredPopover is the migration's own floating surface; if it stops
// carrying the shadow, this allowlist is hiding a regression elsewhere.
expect(read('src/client/AnchoredPopover.tsx')).toMatch(SHADOW_UTILITY)
})
})