diff --git a/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md b/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md index 13e131b..33b103b 100644 --- a/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md +++ b/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md @@ -364,17 +364,36 @@ npx -y shadcn@latest add button card badge separator input textarea tooltip popo **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` 跟进上游) +### 升级路径与本地适配清单(stock 优先) -`components.json` 已把 CLI 上下文钉在这个仓库上,`npx -y shadcn@latest info` 能正确列出 16 个已装组件(`base: radix`、`tailwindVersion: v4`、`tailwindCss: src/client/ui/theme.css`),所以**不要手动从 GitHub 抄文件**,走 CLI: +**政策(2026-09-20 起):与上游 stock 保持一致优先,本地改动只保留"不这样就得坏"的那几条。** 早先那版"裁剪变体、去阴影、压字号"的本地取向已被真机反馈否掉(用户要求"改为 shadcn/ui 最新的默认样式对齐"),因此 `src/client/ui/**` 用 `npx shadcn@latest add … --overwrite` 整批刷成了最新 registry(new-york v4 / radix base),并**重新施加**下面这份最小适配清单: -```bash -npx -y shadcn@latest info # 当前上下文与已装组件清单 -npx -y shadcn@latest add --dry-run # 先看会动哪些文件 -npx -y shadcn@latest add --diff # 逐文件看上游 vs 本地差异 -npx -y shadcn@latest add # 确认后再落盘 -``` +| # | 本地适配 | 为什么必须 | +|---|---|---| +| 1 | `cn` 从 `./utils` 导入;跨组件 import 写显式 `./x.tsx` | registry 写的是虚拟别名 `cn` 与裸 `src/client/ui/...` 说明符,本项目没有那些解析规则 | +| 2 | 图标一律用宿主 `@deepseek-ai/dsh-client-ui-primitives` 的 `IconXxx` | 皮肤契约禁 lucide;宿主图标随主题变色。`spinner.tsx` 的 `Loader2Icon` → `IconLoadingOutline16`(外层 span 承接 props),`dropdown-menu.tsx` 的勾/箭头 → `IconCheckOutline16`/`IconChevronRightOutline14`,单选圆点用 `span.bg-current` | +| 3 | 删除所有 `dark:` 变体 | 令牌本身随宿主深浅主题翻转,`dark:` 是多余且会改写宿主主题语义(技能规则也禁手写 dark 覆盖) | +| 4 | `text-white` → `text-destructive-foreground` | 无颜色字面量是皮肤契约的硬线 | +| 5 | `Button` 保留 `React.forwardRef` | 宿主是 **React 18**,`asChild` 触发器要 ref 才能量到锚点(registry 面向 React 19,不写 forwardRef) | +| 6 | 删除 `tw-animate-css` 的动画工具类(`animate-in`/`fade-in-*`/`zoom-in-*`/`slide-in-from-*`) | 该依赖未安装;装了它会向**我们不拥有的宿主页面**注入通用 `@keyframes` 名字,有与宿主动画撞名的风险。缺省即"立即出现",与 reduced-motion 行为一致 | -**实测**(`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` 确认皮肤契约未被上游带回的颜色字面量破坏。 +**已恢复为 stock(此前被本地裁掉的)**:Button 的完整变体/尺寸集(`link`/`xs`/`lg`/`icon-xs|sm|lg`)、`outline` 的 `shadow-xs`、`transition-all`、Badge 的 `ghost`/`link` 变体。它们只是运行时字符串,未使用的工具类不会进 CSS。 -`--overwrite` 与本仓库不兼容:它会一次性抹掉上面全部改动。真要用,先 `git stash` 出可对比的基线。 +**升级流程**:`npx shadcn@latest add --dry-run` → `--diff` 逐文件看上游改动 → 有本地改动的按上表重新施加(`--overwrite` 后必须重跑下面四条)。落盘后必跑:`pnpm typecheck`、`pnpm vitest run tests/theme.spec.ts tests/ui-shadows.spec.ts tests/ui-foundation.spec.ts tests/ui-bundle.spec.ts`、`pnpm build`(体积与产物断言),以及**镜像页视觉复核**(真实宿主样式表 + 真实组件,见上一节)。 + +### 页面重组:用最新 stock 组件,而不是手搓 div(2026-09-20) + +按 shadcn 技能的硬规则把任务页的组成方式整体换成最新 registry 的构件(4 个并行代理按文件分区改,主线程统一验收): + +- **团队任务板**:`Card` 全组合(`CardHeader`/`CardTitle` + `Separator` 分带 + `CardContent` + `CardFooter`),任务行 = `Item asChild`(`ItemMedia` StateDot / `ItemContent`/`ItemTitle`/`ItemActions`),筛选 = `ToggleGroup`(radix radiogroup + roving tabindex),行内菜单 = `DropdownMenuGroup` 包住每个 `DropdownMenuItem`,空态 = `Empty`。 +- **任务窗口**:`Card` 全组合 + `FieldGroup`/`Field`/`FieldLabel`/`FieldDescription`/`FieldError`;标题空值走 `Field data-invalid` + `Input aria-invalid`(不新增 i18n key);负责人 = `ToggleGroup`(**default 变体**:实测 `outline` 变体的 hover 与 pressed 同色,选中态不可辨);删除 armed 用 `Button variant="destructive"`,冲突提示用 `FieldError(role=alert)`。 +- **浮窗**:两张 portal 卡同样 `Card` 全组合(`CardTitle` 放身份、`CardDescription` 放种类、`CardAction` 放状态 Badge、`CardFooter` 放跳转),行 = `Item`(可点击行 `asChild` 真按钮)。 +- **抽屉/页头**:行 = `Item`(终止按钮在 `ItemActions`),分隔全用 `Separator`,加载 = `Spinner`,目录失败横幅 = `Item variant="outline"`,空态 = `Empty`。 +- **图/树**:节点卡配方 `rounded-lg border bg-card p-2.5 shadow-xs`(不再重复声明 stock `Card` 已有的墨色),控制条全部 stock `Button variant="outline" size="sm|icon"`,`z-[6]` → `z-10`;树行的条件类名改走 `cn()`。 + +**本轮在真实浏览器里抓到的两个"无 preflight"陷阱**(都已修,且在测试/文档里留痕): + +1. **stock `Empty` 只有 `border-dashed` 没有宽度** → 没有 preflight 时露出 UA 的 `3px medium` 虚线框,整页像套了个粗虚线相框;`Empty` 上加 `border-0` 修掉(截图证实)。 +2. **`` 类型合法但静默失效**:刷新后的 Spinner 变成 `React.ComponentProps<"span">`,`size` 是合法 HTML 属性 → typecheck 全绿而图标按默认 `size-4` 渲染。改用 `className="size-3"`。**教训:组件 API 变更中"仍然类型合法但语义丢失"的那类最危险,只有看渲染结果才能发现。** + +**已知未修(记录在案)**:抽屉行在 ~420px 以下的窄宽度里会横向溢出(Radix ScrollArea 的 table 视口按 min-content 计算 + 行内 `nowrap` 的 mono 标题),修复需要重新决定 ScrollArea/行的最小宽度策略;宽面板(用户实机)不受影响。 diff --git a/src/client/JobsDrawer.tsx b/src/client/JobsDrawer.tsx index 46f1aa6..f2ab857 100644 --- a/src/client/JobsDrawer.tsx +++ b/src/client/JobsDrawer.tsx @@ -11,17 +11,21 @@ * cursor), with a copy action, a follow-latest switch, the two-click kill, * and a terminal-style tail while the job runs. * - * 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) — + * Composition: the vendored shadcn set carries every surface. The drawer is a + * `Collapsible` whose rows are `Item`s (`ItemGroup` → `Item` + `ItemActions`, + * the row whose output is open taking the `muted` variant); the popover's + * identity block is an `Item` (media / content / actions) and its footer an + * `Item` holding the host `Switch` and the kill `Button`; hairlines are + * `Separator`s, status ink rides the stock `Badge` variants, and the loading + * line is the vendored `Spinner`. Hierarchy comes from the 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 text-sm, meta * text-xs, 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 its stock variants. + * `className` is used for layout only (the components own their color and + * type), every icon is a host `IconXxx` glyph, and every other control is a + * host primitive (`Switch` / `StateDot`). */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { @@ -40,7 +44,10 @@ import { t } from './locales.ts' import { Badge } from './ui/badge.tsx' import { Button } from './ui/button.tsx' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.tsx' +import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from './ui/item.tsx' import { ScrollArea } from './ui/scroll-area.tsx' +import { Separator } from './ui/separator.tsx' +import { Spinner } from './ui/spinner.tsx' import { cn } from './ui/utils.ts' /** Refresh cadence of an open job-output popover while its job runs. */ @@ -60,6 +67,15 @@ function statusVariant(job: SidebarJobView): 'default' | 'secondary' | 'destruct return 'secondary' } +/** The meta chip of one job kind: the stock `outline` variant, mono figures. */ +function KindBadge(props: { kind: string }): ReactNode { + return ( + + {props.kind} + + ) +} + export interface JobsDrawerProps { rows: readonly TreeJob[] /** Agent count of the tree (root included) — the auto-collapse signal. */ @@ -129,8 +145,11 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode { onOpenChange={(next: boolean) => { setManualOpen(next) }} role="region" aria-label={t('jobs')} - className="z-[5] flex-none border-t border-border bg-background" + className="z-[5] flex-none bg-background" > + {/* The drawer's top edge: the stock hairline (the bar itself carries the + surface, the rows own their own rhythm). */} + {/* The bar keeps the drawer's whole toggle affordance (title, running tally, count line) and Radix owns aria-expanded / aria-controls. */} {t('jobsAutoCollapsed')} )} - {/* Bounded log surface: one row per job, hairline-separated by the + + {/* Bounded log surface: one `Item` row per job, 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. */} - -
+ + {rows.map((row) => { const { job } = row const live = isJobLive(job) @@ -181,23 +201,25 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode { ].join(' · ') const secondary = [context, duration].filter(Boolean).join(' · ') return ( -
{job.status === 'running' && ( - + + + )} {killFailed && ( {t('jobKillError')} )} -
+ ) })} -
+
@@ -277,9 +297,9 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode { * * 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. + * inside it: header, the `Item` identity block, the terminal tail, and the + * `Item` 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: { ownerSessionId: string @@ -361,13 +381,13 @@ export function JobOutputPopoverContent(props: { // `dsw-tasks`: this card renders inside AnchoredPopover's portal at // document.body — OUTSIDE the page root — so it re-declares the page root // class for the scoped base reset (see TaskWindow.tsx). -
-
- {t('jobs')} - +
+
+ {t('jobs')} +
-
- - - {job.label} - - - {jobStatusLabel(job.status, t)} - -
-
- - {job.kind} - - {job.detail !== undefined && job.detail !== '' && {job.detail}} - {t('jobDragHint')} -
- {state === 'loading' &&
{t('loading')}
} + {/* The job's identity: the stock `Item` composition (media / content / + actions) rather than a hand-rolled row. */} + + + + + + + {job.label} + + + + {job.detail !== undefined && job.detail !== '' && ( + {job.detail} + )} + {t('jobDragHint')} + + + + {jobStatusLabel(job.status, t)} + + + {state === 'loading' && ( +
+ {/* The visible line carries the copy; the glyph is decoration. */} + + {t('loading')} +
+ )} {state === 'error' &&
{t('jobOutputError')}
} {typeof state === 'object' && ( <> @@ -406,7 +440,7 @@ export function JobOutputPopoverContent(props: { ? (
                 {state.text}
@@ -418,8 +452,11 @@ export function JobOutputPopoverContent(props: {
           {state.truncated && 
{t('jobOutputTruncated')}
} )} -
+ {/* The action footer: the follow-latest switch and the kill confirm. */} + { if (armed) void kill() else setArmed(true) }} > - + {armed ? t('jobKillConfirm') : t('jobKill')} )} -
+ {killFailed &&
{t('jobKillError')}
}
) diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index c63dec5..8867a0d 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -21,11 +21,17 @@ * ⓘ button opens the detail popover. Completed leaf agents fold into one * aggregate node per parent (click it or the control-cluster toggle to * expand/collapse). + * + * The shell itself is the vendored shadcn set: the header is a toolbar closed + * by a `Separator`, the catalog-failure banner is an `Item` (media / content / + * actions), the empty state is the stock `Empty` composition, and the page's + * only controls are the vendored `Button` + `Tooltip` — `className` carries + * layout only, and every glyph is a host `IconXxx` primitive. */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useSyncExternalStore } from 'react' import { - IconAgentPresetOutline16, IconRefreshOutline14, + IconAgentPresetOutline16, IconRefreshOutline14, IconWarningOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, @@ -54,6 +60,8 @@ import type { SidebarStore } from './state.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 { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from './ui/item.tsx' +import { Separator } from './ui/separator.tsx' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip.tsx' import legacy from './SubagentView.module.css' @@ -416,11 +424,11 @@ export function SubagentView(props: { return (
{/* - 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. + Page header: title + mono descendant count + refresh, closed by the + stock hairline `Separator`. The control cluster (view toggle / fold / + zoom) stays on the canvas itself, so it is visible in BOTH modes. */} -
+
{t('subagent')} {rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== '' @@ -448,13 +456,14 @@ export function SubagentView(props: { team.refresh() }} > - + {t('refresh')}
+ {rootId !== undefined && teamView?.available === true && teamView.team !== null && ( )} {failedParents.length > 0 && ( -
- {t('catalogLoadFailed', { count: failedParents.length })} - { for (const parent of failedParents) refresh(parent) }} - > - - {t('retry')} - -
+ + + + + + + {t('catalogLoadFailed', { count: failedParents.length })} + + + + { for (const parent of failedParents) refresh(parent) }} + > + + {t('retry')} + + + )} {readyEmpty && ( - - - - + // `border-0` is the no-preflight fix, not a style override: the stock + // `Empty` declares only `border-dashed`, and with preflight never + // shipped (see ui/theme.css) a bare `border-style: dashed` keeps the + // UA's 3px medium width — a thick dashed frame around the page. + + + + - {t('subagentEmpty')} - {t('subagentEmptyDesc')} + {t('subagentEmpty')} + {t('subagentEmptyDesc')} )} diff --git a/src/client/TaskWindow.tsx b/src/client/TaskWindow.tsx index b35ab67..ce43526 100644 --- a/src/client/TaskWindow.tsx +++ b/src/client/TaskWindow.tsx @@ -10,13 +10,20 @@ * Actions: owner reassignment (CAS-immediate), edit/save, reopen or complete, * and a two-step delete. * - * Visual base: the vendored shadcn/ui set — `Card` shell, `Badge` status, - * `Input` / `Textarea` fields, a `ToggleGroup` owner picker and `Button` - * 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). + * Visual base: the vendored shadcn/ui set, composed the way the registry + * intends — the full `Card` (header + title + action slot + content + footer), + * the task itself as an `Item` slab, the editable half as a `FieldGroup` of + * `Field` rows (`FieldLabel` + control + `FieldDescription`), the owner picker + * as a `ToggleGroup`, status as a stock `Badge` variant and every action a + * stock `Button` variant (the armed delete takes the `destructive` variant + * instead of a hand-painted red outline). Colours and type therefore come from + * the components and the semantic tokens; `className` stays layout. + * + * Three resets ride the card root on purpose: the window paints NO surface of + * its own. It is portaled to `document.body`, where `AnchoredPopover` owns the + * floating border/radius/shadow, so `border-0 bg-transparent shadow-none` let + * that shell's surface show through instead of nesting a second, shadowed card + * inside it (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 @@ -24,7 +31,7 @@ * inheritance; without preflight a bare `