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:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` 出可对比的基线。
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user