refactor(tasks-ui): align the page with the latest shadcn/ui defaults

The previous round kept a hand-trimmed, shadow-less, 11-13px variant of the
component set. This replaces it with the registry's own defaults and the
registry's own composition primitives, keeping only the adaptations this host
actually requires.

Registry refresh (npx shadcn@latest add --overwrite): the whole vendored set is
back to upstream sources, plus label, field, input-group, item and button-group.
Re-applied adaptations (documented in the design doc):
- cn from ./utils and explicit ./x.tsx specifiers (the registry writes virtual
  aliases this repo has no resolver for);
- host IconXxx instead of lucide (skin contract);
- no dark: variants (the DSH tokens already flip) and no palette literals;
- React.forwardRef on Button (the host is React 18, so radix Slot needs it);
- tw-animate-css animation utilities stripped: installing that dependency would
  inject generic keyframes into a page we do not own.
Restored to stock: the full Button variant/size set, shadow-xs on outline,
transition-all, badge ghost/link.

Recomposition on the newest primitives (per-file, verified here):
- board: full Card composition, Item rows, ToggleGroup filter, dropdown items
  grouped, Empty for the empty state;
- task window: Card composition, FieldGroup/Field with data-invalid validation,
  ToggleGroup owner (default variant: the outline variant's hover and pressed
  states measured identically, hiding the selection), destructive-armed delete,
  FieldError for conflicts;
- popovers: both portal cards use the same Card composition, rows are Item;
- drawer/header: Item rows with ItemActions, Separator bands, Spinner, Empty;
- graph/tree: stock node recipe, outline buttons, cn() for conditional classes.

Two no-preflight traps found by rendering, not by types:
- stock Empty ships border-dashed without a width, so the UA's 3px medium dashed
  frame wrapped the whole page: border-0 on that Empty;
- <Spinner size={12}/> stayed type-clean after the refresh (size is a valid span
  attribute) while the glyph silently rendered at size-4: className="size-3".

Verified: typecheck/lint clean; 136 files / 1515 tests; build with the core and
chunk bundle guards (core 836.3 KiB, chunk 734.7 KiB); mount e2e 7/7; and the
real-host-stylesheet mirror page re-shot for graph, tree, board, task window and
the 77-node stress fixture at 360 and 720.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent d1a9d520ae
commit 5deb5285da
30 changed files with 2107 additions and 1097 deletions
@@ -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 <component> --dry-run # 先看会动哪些文件
npx -y shadcn@latest add <component> --diff # 逐文件看上游 vs 本地差异
npx -y shadcn@latest add <component> # 确认后再落盘
```
| # | 本地适配 | 为什么必须 |
|---|---|---|
| 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 <component> --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 size={12} />` 类型合法但静默失效**:刷新后的 Spinner 变成 `React.ComponentProps<"span">`,`size` 是合法 HTML 属性 → typecheck 全绿而图标按默认 `size-4` 渲染。改用 `className="size-3"`。**教训:组件 API 变更中"仍然类型合法但语义丢失"的那类最危险,只有看渲染结果才能发现。**
**已知未修(记录在案)**:抽屉行在 ~420px 以下的窄宽度里会横向溢出(Radix ScrollArea 的 table 视口按 min-content 计算 + 行内 `nowrap` 的 mono 标题),修复需要重新决定 ScrollArea/行的最小宽度策略;宽面板(用户实机)不受影响。
+113 -77
View File
@@ -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 (
<Badge variant="outline" className="flex-none font-mono font-normal text-muted-foreground">
{props.kind}
</Badge>
)
}
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). */}
<Separator />
{/* The bar keeps the drawer's whole toggle affordance (title, running
tally, count line) and Radix owns aria-expanded / aria-controls. */}
<CollapsibleTrigger
@@ -155,13 +174,14 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
<div className="px-3 pb-2 font-mono text-xs text-foreground-3">{t('jobsAutoCollapsed')}</div>
)}
<CollapsibleContent>
{/* Bounded log surface: one row per job, hairline-separated by the
<Separator />
{/* 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. */}
<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">
<ScrollArea className="h-[156px] w-full min-w-0">
<ItemGroup className="min-w-0 gap-0.5 px-2 pt-1 pb-2">
{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 (
<div
<Item
key={job.id}
size="sm"
// The row whose output is open wears the muted surface (the
// stock Item variant) instead of a one-off background class.
variant={openJobId === job.id ? 'muted' : 'default'}
data-settled={live ? 'false' : 'true'}
data-open={openJobId === job.id ? 'true' : 'false'}
className={cn(
'group flex items-center gap-[7px] rounded-md px-1.5 py-1 transition-colors',
'hover:bg-muted',
openJobId === job.id && 'bg-muted',
'min-w-0 flex-nowrap gap-2 rounded-md px-1.5 py-1 transition-colors hover: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-sm font-normal tracking-normal normal-case"
className="h-auto min-w-0 flex-1 justify-start gap-2 px-0 py-0 text-left"
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
title={t('jobViewOutput')}
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
@@ -207,9 +229,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
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="flex-none font-mono font-normal text-muted-foreground">
{job.kind}
</Badge>
<KindBadge kind={job.kind} />
<span
className={cn(
'min-w-0 flex-1 truncate font-mono text-sm',
@@ -232,36 +252,36 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
</span>
</Button>
{job.status === 'running' && (
<Button
variant="outline"
size="sm"
// Inline row action: quiet until the row is hovered, but
// keyboard focus brings it back (focus-visible below).
className={cn(
'h-auto shrink-0 font-mono text-xs font-normal text-muted-foreground',
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
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>
<ItemActions className="gap-1">
<Button
// The armed confirm is the stock `destructive` variant;
// unarmed stays a quiet outline chip.
variant={armed ? 'destructive' : 'outline'}
size="xs"
// Inline row action: quiet until the row is hovered, but
// keyboard focus brings it back (focus-visible below).
className="opacity-0 transition-opacity group-hover/item:opacity-100 focus-visible:opacity-100"
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 />
{armed ? t('jobKillConfirm') : undefined}
</Button>
</ItemActions>
)}
{killFailed && (
<span className="shrink-0 font-mono text-xs text-destructive">{t('jobKillError')}</span>
)}
</div>
</Item>
)
})}
</div>
</ItemGroup>
</ScrollArea>
</CollapsibleContent>
</Collapsible>
@@ -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).
<div className="dsw-tasks box-border flex flex-col gap-1 p-2.5 text-sm text-popover-foreground" data-popover-handle>
<div className="mb-1 flex items-baseline justify-between gap-2 font-mono text-xs tracking-[0.14em] text-foreground-3 uppercase">
<span>{t('jobs')}</span>
<span className="inline-flex items-center gap-0.5 tracking-normal normal-case" data-popover-no-drag>
<div className="dsw-tasks box-border flex flex-col gap-2 p-2.5 text-sm text-popover-foreground" data-popover-handle>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs tracking-[0.14em] text-foreground-3 uppercase">{t('jobs')}</span>
<span className="inline-flex items-center" data-popover-no-drag>
<Button
variant="ghost"
size="icon"
size="icon-xs"
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
title={copied ? t('jobCopied') : t('jobCopyOutput')}
disabled={text === ''}
@@ -378,27 +398,41 @@ export function JobOutputPopoverContent(props: {
})
}}
>
<IconCopyOutline16 size={12} className="size-3" />
<IconCopyOutline16 />
</Button>
</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<StateDot state={jobDotState(job.status)} size={6} />
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground" title={job.label}>
{job.label}
</span>
<Badge variant={statusVariant(job)} className="flex-none">
{jobStatusLabel(job.status, t)}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<Badge variant="outline" className="flex-none font-mono font-normal text-muted-foreground">
{job.kind}
</Badge>
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
<span className="font-mono text-xs text-foreground-3">{t('jobDragHint')}</span>
</div>
{state === 'loading' && <div className="text-xs text-foreground-3">{t('loading')}</div>}
{/* The job's identity: the stock `Item` composition (media / content /
actions) rather than a hand-rolled row. */}
<Item size="sm" variant="muted" className="min-w-0 flex-nowrap gap-2 rounded-md px-2 py-1.5">
<ItemMedia>
<StateDot state={jobDotState(job.status)} size={8} />
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<ItemTitle className="w-full min-w-0">
<span className="min-w-0 flex-1 truncate" title={job.label}>{job.label}</span>
</ItemTitle>
<span className="flex min-w-0 items-center gap-1.5 font-mono text-xs text-muted-foreground">
<KindBadge kind={job.kind} />
{job.detail !== undefined && job.detail !== '' && (
<span className="min-w-0 truncate">{job.detail}</span>
)}
<span className="flex-none text-foreground-3">{t('jobDragHint')}</span>
</span>
</ItemContent>
<ItemActions>
<Badge variant={statusVariant(job)}>{jobStatusLabel(job.status, t)}</Badge>
</ItemActions>
</Item>
{state === 'loading' && (
<div className="flex items-center gap-1.5 text-xs text-foreground-3">
{/* The visible line carries the copy; the glyph is decoration. */}
<span className="flex flex-none items-center" aria-hidden="true">
<Spinner className="size-3" />
</span>
{t('loading')}
</div>
)}
{state === 'error' && <div className="text-xs text-destructive">{t('jobOutputError')}</div>}
{typeof state === 'object' && (
<>
@@ -406,7 +440,7 @@ export function JobOutputPopoverContent(props: {
? (
<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"
className="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}
@@ -418,8 +452,11 @@ export function JobOutputPopoverContent(props: {
{state.truncated && <div className="text-xs text-foreground-3">{t('jobOutputTruncated')}</div>}
</>
)}
<div
className="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2"
<Separator />
{/* The action footer: the follow-latest switch and the kill confirm. */}
<Item
size="sm"
className="min-w-0 flex-nowrap justify-between gap-2 rounded-md px-0 py-0"
data-popover-no-drag
>
<Switch
@@ -432,18 +469,17 @@ export function JobOutputPopoverContent(props: {
<Button
variant={armed ? 'destructive' : 'outline'}
size="sm"
className="font-mono text-xs"
disabled={killing}
onClick={() => {
if (armed) void kill()
else setArmed(true)
}}
>
<IconStopFill16 size={11} className="size-[11px]" />
<IconStopFill16 />
{armed ? t('jobKillConfirm') : t('jobKill')}
</Button>
)}
</div>
</Item>
{killFailed && <div className="text-xs text-destructive">{t('jobKillError')}</div>}
</div>
)
+50 -24
View File
@@ -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 (
<div className={`dsw-tasks ${legacy.subagent} relative`}>
{/*
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.
*/}
<div className="flex flex-none items-center gap-2 border-b border-border px-3 py-2">
<div className="flex flex-none items-center gap-2 px-3 py-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{t('subagent')}
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
@@ -448,13 +456,14 @@ export function SubagentView(props: {
team.refresh()
}}
>
<IconRefreshOutline14 size={13} />
<IconRefreshOutline14 />
</UiButton>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('refresh')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator />
{rootId !== undefined && teamView?.available === true && teamView.team !== null && (
<TeamBoard
rootId={rootId}
@@ -466,27 +475,44 @@ export function SubagentView(props: {
/>
)}
{failedParents.length > 0 && (
<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>
<UiButton
variant="ghost"
size="sm"
className="flex-none"
onClick={() => { for (const parent of failedParents) refresh(parent) }}
>
<IconRefreshOutline14 size={12} />
{t('retry')}
</UiButton>
</div>
<Item
variant="outline"
size="sm"
className="mx-3 my-2 min-w-0 flex-nowrap gap-2 rounded-md px-2 py-1.5"
>
<ItemMedia variant="icon" className="text-destructive">
<IconWarningOutline16 />
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<ItemTitle className="text-destructive">
{t('catalogLoadFailed', { count: failedParents.length })}
</ItemTitle>
</ItemContent>
<ItemActions>
<UiButton
variant="outline"
size="xs"
className="flex-none"
onClick={() => { for (const parent of failedParents) refresh(parent) }}
>
<IconRefreshOutline14 />
{t('retry')}
</UiButton>
</ItemActions>
</Item>
)}
{readyEmpty && (
<Empty className="flex-1 gap-4 border-0 p-4">
<EmptyHeader className="gap-2">
<EmptyMedia variant="icon" className="size-8 rounded-md">
<IconAgentPresetOutline16 size={16} />
// `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.
<Empty className="flex-1 border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconAgentPresetOutline16 />
</EmptyMedia>
<EmptyTitle className="text-sm font-medium">{t('subagentEmpty')}</EmptyTitle>
<EmptyDescription className="text-xs">{t('subagentEmptyDesc')}</EmptyDescription>
<EmptyTitle>{t('subagentEmpty')}</EmptyTitle>
<EmptyDescription>{t('subagentEmptyDesc')}</EmptyDescription>
</EmptyHeader>
</Empty>
)}
+130 -83
View File
@@ -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 `<button>`/`<textarea>` would render
* in the UA's own font.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16,
IconRefreshOutline14, IconTrashOutline16, MarkdownText, StateDot,
@@ -36,12 +43,14 @@ import { AnchoredPopover } from './AnchoredPopover.tsx'
import { t, type CopyKey } from './locales.ts'
import { Badge } from './ui/badge.tsx'
import { Button } from './ui/button.tsx'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldTitle } from './ui/field.tsx'
import { Input } from './ui/input.tsx'
import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from './ui/item.tsx'
import { Separator } from './ui/separator.tsx'
import { cn } from './ui/utils.ts'
import { Textarea } from './ui/textarea.tsx'
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
import { cn } from './ui/utils.ts'
/** One mutation outcome (the host route's own union). */
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
@@ -59,12 +68,13 @@ function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
/**
* The status badge's stock variant: in progress takes `default` (the accent),
* everything else settles on `secondary`; a BLOCKED task wears `outline` with
* warning ink — hue carries meaning only.
* warning ink — hue carries meaning only, and readiness outranks the status
* word (the board row maps it the same way).
*/
function statusVariant(task: SidebarTeamTaskView): 'default' | 'secondary' | 'outline' {
if (task.status === 'in_progress') return 'default'
if (task.ready) return 'secondary'
return 'outline'
if (task.status === 'in_progress' && task.ready) return 'default'
if (!task.ready) return 'outline'
return 'secondary'
}
/** The blocked badge's warning ink (the `outline` variant is otherwise neutral). */
@@ -88,7 +98,7 @@ export function MultilineField(props: {
}): ReactNode {
return (
<Textarea
className="max-h-[45vh] min-h-[120px] resize-y leading-relaxed"
className="max-h-[45vh] min-h-[120px] resize-y"
value={props.value}
rows={props.rows ?? 7}
placeholder={props.placeholder}
@@ -107,15 +117,13 @@ export function MultilineField(props: {
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.)
* The owner picker (single-select toggles; CAS-immediate, so never a draft
* field). The chips are the toggle's DEFAULT variant, the same one the board's
* owner filter uses: its own state pair separates hover (accent tint, meta ink)
* from pressed (accent tint, full ink) without help. That is not true of the
* `outline` variant — its hover rules outrank the base ones, so hover and
* pressed would look exactly alike and the picked owner would be invisible.
*/
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[]
owner: string | undefined
@@ -138,11 +146,11 @@ export function OwnerPicker(props: {
props.onPick(next === UNOWNED ? '' : next)
}}
>
<ToggleGroupItem value={UNOWNED} variant="outline" size="sm" className={OWNER_CHIP}>
<ToggleGroupItem value={UNOWNED} size="sm">
{t('teamTaskUnowned')}
</ToggleGroupItem>
{props.members.map(member => (
<ToggleGroupItem key={member.id} value={member.name} variant="outline" size="sm" className={OWNER_CHIP}>
<ToggleGroupItem key={member.id} value={member.name} size="sm">
{member.name}
</ToggleGroupItem>
))}
@@ -150,7 +158,7 @@ export function OwnerPicker(props: {
)
}
/** The read-only body: markdown description, meta, owner picker. */
/** The read-only body: the task slab, the markdown description, the owner picker. */
function TaskViewBody(props: {
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
@@ -160,81 +168,113 @@ function TaskViewBody(props: {
const { task } = props
const body = task.description.trim()
return (
<div className="flex min-w-0 flex-col gap-2.5">
<div className="flex min-w-0 items-center gap-1.5">
<StateDot
size={6}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
<span className="min-w-0 flex-1 truncate font-medium" title={task.subject}>{task.subject}</span>
<Badge
variant={statusVariant(task)}
className={cn('flex-none', statusTone(task))}
>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span className="text-foreground-3">{t('teamTaskOwner')}</span>
<span>{task.ownerName ?? t('teamTaskUnowned')}</span>
{task.blockedBy.length > 0 && (
<>
<span className="text-foreground-3">{t('teamTaskBlockedBy')}</span>
<span className="font-mono tabular-nums">{task.blockedBy.join(' · ')}</span>
</>
)}
</div>
<div className="max-h-[260px] overflow-y-auto leading-[1.55] text-foreground">
<FieldGroup className="gap-4">
{/* The task's own slab: an `Item` is the registry's row primitive, so the
subject leads, the owner / blocker meta follows as its description and
the status rides the trailing action slot. */}
<Item variant="muted" size="sm" className="min-w-0 flex-nowrap gap-2 px-2 py-1.5">
<ItemMedia>
<StateDot
size={6}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<ItemTitle className="w-full min-w-0">
<span className="min-w-0 flex-1 truncate" title={task.subject}>{task.subject}</span>
</ItemTitle>
{/* The meta line keeps the page's three ink levels: the labels are a
step quieter than the values, and ids stay mono. */}
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span className="text-foreground-3">{t('teamTaskOwner')}</span>
<span>{task.ownerName ?? t('teamTaskUnowned')}</span>
{task.blockedBy.length > 0 && (
<>
<span className="text-foreground-3">{t('teamTaskBlockedBy')}</span>
<span className="font-mono tabular-nums">{task.blockedBy.join(' · ')}</span>
</>
)}
</span>
</ItemContent>
<ItemActions>
<Badge variant={statusVariant(task)} className={cn('flex-none', statusTone(task))}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</Badge>
</ItemActions>
</Item>
{/* The description is READ-ONLY here (the markdown preview is the point of
the view mode); 编辑 is what turns it into the multi-line editor. */}
<Field>
<FieldTitle>{t('teamTaskDescription')}</FieldTitle>
{body === ''
? <div className="text-xs leading-snug text-muted-foreground">{t('teamTaskNoDescription')}</div>
? <FieldDescription>{t('teamTaskNoDescription')}</FieldDescription>
: (
<MarkdownText
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
/>
<div className="max-h-[260px] overflow-y-auto">
<MarkdownText
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
/>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">{t('teamTaskOwner')}</span>
</Field>
<Field data-disabled={props.busy ? true : undefined}>
<FieldLabel>{t('teamTaskOwner')}</FieldLabel>
<OwnerPicker
members={props.teammates}
owner={task.ownerName}
disabled={props.busy}
onPick={props.onReassign}
/>
</div>
</div>
</Field>
</FieldGroup>
)
}
/** The editing body: subject (shadcn Input) + description (our multi-line field). */
/**
* The editing body: subject (a stock `Input`) and description (our multi-line
* field) as two `Field` rows. A BLANK subject is the form's only invalid state
* — it is reported on the `Field` (`data-invalid`) and the control
* (`aria-invalid`) once the reader has been in the field, and Save stays
* disabled until it is filled.
*
* The one-line input carries its hint as a `FieldDescription` (a placeholder
* would vanish the moment the reader focuses it); the tall textarea keeps its
* hint inside the field, where there is room for it.
*/
function TaskEditBody(props: {
subject: string
description: string
invalid: boolean
onSubject(next: string): void
onDescription(next: string): void
}): ReactNode {
const subjectId = useId()
const subjectHintId = useId()
return (
<div className="flex flex-col gap-2.5" data-popover-no-drag>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t('teamTaskSubject')}</span>
<FieldGroup className="gap-4" data-popover-no-drag>
<Field data-invalid={props.invalid ? true : undefined}>
<FieldLabel htmlFor={subjectId}>{t('teamTaskSubject')}</FieldLabel>
<Input
className="h-8"
id={subjectId}
value={props.subject}
placeholder={t('teamTaskSubjectPlaceholder')}
aria-label={t('teamTaskSubject')}
aria-invalid={props.invalid ? true : undefined}
aria-describedby={subjectHintId}
onChange={(event) => { props.onSubject(event.target.value) }}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t('teamTaskDescription')}</span>
<FieldDescription id={subjectHintId}>{t('teamTaskSubjectPlaceholder')}</FieldDescription>
</Field>
<Field>
<FieldLabel>{t('teamTaskDescription')}</FieldLabel>
<MultilineField
value={props.description}
label={t('teamTaskDescription')}
placeholder={t('teamTaskDescriptionPlaceholder')}
onChange={props.onDescription}
/>
</label>
</div>
</Field>
</FieldGroup>
)
}
@@ -256,6 +296,8 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
const [editing, setEditing] = useState(creating)
const [subject, setSubject] = useState(task?.subject ?? '')
const [description, setDescription] = useState(task?.description ?? '')
/** The reader has been in the subject field (see TaskEditBody's contract). */
const [subjectTouched, setSubjectTouched] = useState(false)
const [busy, setBusy] = useState(false)
const [note, setNote] = useState<string | undefined>(undefined)
const [armedDelete, setArmedDelete] = useState(false)
@@ -279,6 +321,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
const seed = seedRef.current
setSubject(seed.subject)
setDescription(seed.description)
setSubjectTouched(false)
setEditing(seed.creating)
}, [task?.id])
@@ -338,14 +381,14 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
return (
<Card
className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 text-sm"
className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 shadow-none"
data-popover-handle
>
<CardHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3">
<CardTitle className="min-w-0 flex-1 truncate text-sm font-medium text-muted-foreground">
<CardHeader className="items-center px-4 py-3">
<CardTitle className="min-w-0 truncate">
{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}
</CardTitle>
<div className="flex flex-none items-center gap-1" data-popover-no-drag>
<CardAction data-popover-no-drag>
<Button
variant="ghost"
size="icon"
@@ -355,7 +398,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
>
<IconCloseOutline16 />
</Button>
</div>
</CardAction>
</CardHeader>
<Separator />
@@ -365,7 +408,8 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
<TaskEditBody
subject={subject}
description={description}
onSubject={setSubject}
invalid={subjectTouched && subject.trim() === ''}
onSubject={(next) => { setSubjectTouched(true); setSubject(next) }}
onDescription={setDescription}
/>
)
@@ -377,7 +421,9 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
onReassign={reassign}
/>
)}
{note !== undefined && <div className="mt-2 text-xs leading-snug text-warning">{note}</div>}
{/* The mutation outcome (CAS conflict / failure) is the form's own
alert: FieldError carries the role and the destructive ink. */}
{note !== undefined && <FieldError className="mt-2">{note}</FieldError>}
</CardContent>
<Separator />
@@ -402,6 +448,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
else if (task !== undefined) {
setSubject(task.subject)
setDescription(task.description)
setSubjectTouched(false)
setEditing(false)
}
}}
@@ -444,12 +491,12 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
</Button>
)}
<Button
variant="outline"
// Two-step confirm: the ARMED click takes the `destructive`
// variant (its copy already says 确认删除) — danger is a
// component variant, never a permanently red button and never a
// hand-painted outline.
variant={armedDelete ? 'destructive' : 'outline'}
size="sm"
// Two-step confirm, danger only on the armed click: outline +
// 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}
onClick={() => {
if (!armedDelete) { setArmedDelete(true); return }
+10 -4
View File
@@ -78,8 +78,14 @@ const CLICK_TOLERANCE_PX = 4
* The shared shell of every node card: absolute (the layout owns left/top),
* the compact stock node recipe — `rounded-lg border bg-card p-2.5 shadow-xs`
* — with `hover:bg-muted` as the only hover change. No tint.
*
* Every class here is stock: `Card` already carries `border` (hairline in
* `--border`) and `text-card-foreground`, so this only tightens the padding,
* drops the stock `gap-6` (the graph's three lines have their own `mt-0.5`)
* and sets the canvas elevation. The border ink is never re-declared — the
* lead node's stronger rung is the one exception, below.
*/
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg border border-border bg-card p-2.5 shadow-xs transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg bg-card p-2.5 shadow-xs 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 card. */
const NODE_ACCENT = 'border-l-2 border-l-primary'
/** Settled nodes recede by INK, not by opacity: the whole card drops to the
@@ -387,11 +393,11 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
same `loading` copy key). `pointer-events-none` keeps the
canvas's pan/zoom/node clicks fully live underneath. */}
{loading === true && nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 z-[6] flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 z-10 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. */}
<span aria-hidden="true" className="flex flex-none items-center">
<Spinner size={12} />
<Spinner className="size-3" />
</span>
{t('loading')}
</span>
@@ -458,7 +464,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
})}
</div>
</TooltipProvider>
<div className="absolute right-2.5 bottom-2.5 z-[6] flex flex-row items-center gap-0.5" data-graph-controls>
<div className="absolute right-2.5 bottom-2.5 z-10 flex flex-row items-center gap-0.5" data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
<ControlButton label={t('tasksZoomOut')} onClick={() => { zoomBy(1 / 1.2) }}>−</ControlButton>
+151 -82
View File
@@ -3,14 +3,20 @@
* live in AnchoredPopover): the agent node detail with a transcript jump and
* the workflow run detail with clickable member rows.
*
* Visual language: the vendored shadcn/ui pieces (Card + Separator + Badge +
* 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.
* Visual base: the vendored shadcn/ui set composed the registry's way — the
* full `Card` (header + title + description + action slot + content + footer),
* the node / run and every list row as an `Item` (`ItemMedia` + `ItemTitle` +
* `ItemActions`) inside an `ItemGroup`, status as a stock `Badge` variant
* (running = `default`, settled = `secondary`, failed = `destructive`) and the
* jump as a stock `Button` — so colours and type come from those components
* and the semantic tokens. The surface is flat and owned by the shell: like
* the task window, the card paints no surface of its own (`border-0
* bg-transparent shadow-none`) because AnchoredPopover already draws the
* floating border/radius/shadow — nesting a second bordered card inside it
* would double the hairline. The only shadow is the shell's; ink stays on
* three levels (foreground / muted-foreground / foreground-3) and mono meta is
* `font-mono tabular-nums`; 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).
@@ -22,12 +28,13 @@ import {
import type { SidebarSubagentAddress } from '../context-types.ts'
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
import {
flatten, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
flatten, taskDotState, taskStatusKey, workflowStatusKey,
} from './tasks-shared.tsx'
import { t, type CopyKey } from './locales.ts'
import { Badge } from './ui/badge.tsx'
import { Button as UiButton } from './ui/button.tsx'
import { Card } from './ui/card.tsx'
import { Button } from './ui/button.tsx'
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from './ui/item.tsx'
import { Separator } from './ui/separator.tsx'
/** The workflow run status (the badge's semantic ink selector). */
@@ -54,9 +61,21 @@ function statusVariant(status: WorkflowStatus | 'pending' | 'in_progress' | 'com
return 'secondary'
}
/** 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. */
/** The node display state on the same stock variant set. */
function stateVariant(state: TasksAgentNode['state']): 'default' | 'secondary' | 'destructive' {
if (state === 'running') return 'default'
if (state === 'error') return 'destructive'
return 'secondary'
}
/**
* The popover's key/value grid (label left, value right, mono on request).
* The read-only meta stays a definition list rather than a `FieldGroup`: the
* three ink levels ARE the design language here, and a `FieldLabel` would have
* to be repainted (or would shout) to say the same thing. 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 {
return <dl className="grid grid-cols-[64px_1fr] items-baseline gap-x-3 gap-y-1">{props.children}</dl>
}
@@ -79,7 +98,7 @@ function PopRow(props: { label: string; mono?: boolean; children: ReactNode }):
)
}
/** The mono kicker above a popover list ("任务" / a phase title). */
/** 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-xs tracking-wide text-foreground-3 uppercase">
@@ -111,16 +130,21 @@ export function AgentNodePopover(props: {
// document.body — OUTSIDE the page root — so it must re-declare the page
// 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-xs text-foreground-3">{t('tasksNodeDetail')}</div>
<Card className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 shadow-none">
{/* The node leads (title) over the card's kind (description), and its
display state rides the header's action slot as a stock badge. */}
<CardHeader className="items-center px-3 py-2">
<CardTitle className="min-w-0">
<span className="min-w-0 truncate" title={node.label}>{node.label}</span>
</CardTitle>
<CardDescription>{t('tasksNodeDetail')}</CardDescription>
<CardAction>
<StatusBadge variant={stateVariant(node.state)}>{t(stateKey(node.state))}</StatusBadge>
</CardAction>
</CardHeader>
<Separator />
<div className="flex flex-col gap-1.5 px-3">
<span className="truncate text-sm font-medium" title={node.label}>{node.label}</span>
<CardContent className="flex min-w-0 flex-col gap-2 px-3 py-2.5">
<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')}
@@ -141,38 +165,59 @@ export function AgentNodePopover(props: {
{liveText !== undefined && liveTool !== undefined && (
<div className="line-clamp-2 text-xs text-muted-foreground" title={liveText}>{liveText}</div>
)}
</div>
</CardContent>
{tasks.length > 0 && (
<>
<Separator />
<div className="flex flex-col gap-1 px-3">
<CardContent className="flex flex-col gap-1 px-3 py-2.5">
<GroupLabel>{t('tasksNodeTasks')}</GroupLabel>
<div className="-mx-1 flex max-h-[190px] flex-col gap-0.5 overflow-y-auto">
{/* Each row opens the shared task window, so the whole row is the
button (`Item asChild` puts the stock row geometry on it, and
the trailing status badge on the actions slot). */}
<ItemGroup className="max-h-[190px] overflow-y-auto">
{tasks.map(task => (
<UiButton
<Item
key={task.id}
variant="ghost"
asChild
variant="outline"
size="sm"
className="h-auto w-full justify-start gap-1.5 rounded-sm px-1 py-1 text-left text-xs font-normal"
title={task.subject}
onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
className="min-w-0 flex-nowrap gap-2 px-2 py-1.5 hover:bg-muted"
>
<StateDot state={taskDotState(task)} size={6} />
<span className="min-w-0 flex-1 truncate text-xs font-medium">{task.subject}</span>
<StatusBadge variant={statusVariant(task.status)}>{t(taskStatusKey(task.status))}</StatusBadge>
</UiButton>
<button
type="button"
className="text-left"
title={task.subject}
onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
>
<ItemMedia>
<StateDot state={taskDotState(task)} size={6} />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0">
<span className="min-w-0 flex-1 truncate">{task.subject}</span>
</ItemTitle>
</ItemContent>
<ItemActions>
<StatusBadge variant={statusVariant(task.status)}>
{t(taskStatusKey(task.status))}
</StatusBadge>
</ItemActions>
</button>
</Item>
))}
</div>
</div>
</ItemGroup>
</CardContent>
</>
)}
{(node.childAddress !== undefined || node.parentId === undefined) && (
<>
{tasks.length > 0 && <Separator />}
<UiButton size="sm" className="mx-3 w-auto" onClick={() => { onJump(node) }}>
<IconRightUpOutline14 size={12} />
{t('tasksNodeJump')}
</UiButton>
<Separator />
<CardFooter className="justify-end px-3 py-2">
<Button size="sm" onClick={() => { onJump(node) }}>
<IconRightUpOutline14 />
{t('tasksNodeJump')}
</Button>
</CardFooter>
</>
)}
</Card>
@@ -195,58 +240,82 @@ export function WorkflowNodePopover(props: {
}
return (
// `dsw-tasks`: same portal-scope reset as AgentNodePopover above.
<Card className="dsw-tasks gap-2 rounded-lg border-border bg-popover p-0 py-2.5">
<div className="px-3 font-mono text-xs text-foreground-3">{t('workflowRun')}</div>
<Separator />
<div className="flex flex-col gap-1.5 px-3">
<div className="flex min-w-0 items-center gap-1.5" title={run.name}>
<span className="flex-none text-muted-foreground" aria-hidden="true">
<IconBranchOutline16 size={12} />
<Card className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 shadow-none">
{/* The run's identity leads and keeps its branch glyph; the run status
rides the header's action slot as a stock badge. */}
<CardHeader className="items-center px-3 py-2">
<CardTitle className="flex min-w-0 items-center gap-1.5">
{/* The visible line carries the copy; the glyph is decoration. */}
<span className="flex flex-none items-center" aria-hidden="true">
<IconBranchOutline16 />
</span>
<span className="truncate text-sm font-medium">{run.name}</span>
</div>
<PopRows>
<PopRow label={t('tasksNodeState')}>
<StatusBadge variant={statusVariant(run.status)}>{t(workflowStatusKey(run.status))}</StatusBadge>
</PopRow>
</PopRows>
</div>
<span className="min-w-0 truncate" title={run.name}>{run.name}</span>
</CardTitle>
<CardDescription>{t('workflowRun')}</CardDescription>
<CardAction>
<StatusBadge variant={statusVariant(run.status)}>{t(workflowStatusKey(run.status))}</StatusBadge>
</CardAction>
</CardHeader>
{run.phases.length > 0 && (
<>
<Separator />
<div className="flex max-h-[220px] flex-col gap-1 overflow-y-auto px-3">
<CardContent className="flex max-h-[220px] flex-col gap-2 overflow-y-auto px-3 py-2.5">
{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>
))}
<ItemGroup>
{phase.members.map(member => {
const body = (
<>
<ItemMedia>
<StateDot size={6} state={memberDot(member.outcome)} />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0">
<span className="min-w-0 flex-1 truncate" title={member.label}>{member.label}</span>
</ItemTitle>
</ItemContent>
</>
)
// A member row IS a jump affordance when it has a child
// session; without one it stays a plain tally line.
return member.childId === ''
? (
<Item key={member.seq} size="sm" className="min-w-0 flex-nowrap gap-2 px-1.5 py-1">
{body}
</Item>
)
: (
<Item
key={member.seq}
asChild
size="sm"
className="min-w-0 flex-nowrap gap-2 px-1.5 py-1 hover:bg-muted"
>
<button
type="button"
className="text-left"
title={member.label}
onClick={() => {
onJumpMember({
parentSessionId: run.originSessionId,
childSessionId: member.childId,
mode: 'one-shot',
})
}}
>
{body}
</button>
</Item>
)
})}
</ItemGroup>
</div>
))}
</div>
</CardContent>
</>
)}
</Card>
+12 -11
View File
@@ -35,6 +35,7 @@ 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 { cn } from './ui/utils.ts'
import { t } from './locales.ts'
/** The row slab: one shared box, so every row kind lines up on the same grid. */
@@ -136,13 +137,13 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
tabIndex={0}
aria-level={depth + 1}
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
className={`${ROW} ${ROW_FOLD} ${ROW_HOVER}`}
className={cn(ROW, ROW_FOLD, ROW_HOVER)}
onClick={onToggleFold}
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
>
<span className={GLYPH} aria-hidden="true"><FoldGlyph /></span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={`flex items-center gap-1.5 font-medium ${LINE}`}>
<span className={cn('flex items-center gap-1.5 font-medium', LINE)}>
{t('tasksFoldCompleted', { count: node.count })}
<span className={META}>
{t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
@@ -162,7 +163,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
aria-level={depth + 1}
aria-expanded="true"
aria-label={`${node.run.name} ${workflowMeta(node)}`}
className={`${ROW} ${ROW_HOVER} ${node.run.status !== 'running' ? ROW_SETTLED : ''}`}
className={cn(ROW, ROW_HOVER, node.run.status !== 'running' && ROW_SETTLED)}
onClick={(event) => { onWorkflowInfo(node, event.currentTarget) }}
onKeyDown={(event) => {
activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
@@ -173,7 +174,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
</span>
<span className={GLYPH} aria-hidden="true"><WorkflowGlyph /></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={cn('font-medium', LINE)}>{node.run.name}</span>
<span className={META}>{workflowMeta(node)}</span>
</span>
</div>
@@ -187,12 +188,12 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
aria-level={depth + 1}
aria-label={`${node.label} ${agentMeta(node)}`}
aria-current={node.current ? 'true' : undefined}
className={[
className={cn(
ROW,
ROW_HOVER,
node.current ? ROW_CURRENT : '',
(node.state === 'done' || node.state === 'error') && !node.current ? ROW_SETTLED : '',
].join(' ')}
node.current && ROW_CURRENT,
(node.state === 'done' || node.state === 'error') && !node.current && ROW_SETTLED,
)}
onClick={(event) => { onNodeInfo(node, event.currentTarget) }}
onKeyDown={(event) => {
activateOnKey(event, () => { onNodeInfo(node, event.currentTarget as HTMLElement) })
@@ -207,7 +208,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
</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={cn('font-medium', LINE)}>{node.label}</span>
<span className={META}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
@@ -243,7 +244,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
<div className="flex items-center justify-center gap-1.5 px-6 py-3 text-xs text-muted-foreground">
{/* The visible line carries the copy; the glyph is decoration. */}
<span aria-hidden="true" className="flex flex-none items-center">
<Spinner size={12} />
<Spinner className="size-3" />
</span>
{t('loading')}
</div>
@@ -252,7 +253,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
</div>
</div>
</ScrollArea>
<div className="absolute right-2.5 bottom-2.5 z-[6] flex flex-row items-center" data-graph-controls>
<div className="absolute right-2.5 bottom-2.5 z-10 flex flex-row items-center" data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
</div>
+163 -138
View File
@@ -4,12 +4,16 @@
* the native sidebar is narrow: members wrap as a segmented filter and the
* task list scrolls inside a bounded height, so the canvas keeps its room.
*
* Visual language: shadcn/ui over the task page's Tailwind tokens
* (src/client/ui/theme.css) — the strip is a stock Card (`rounded-xl border
* bg-card shadow-sm`), members are a ToggleGroup whose selected chip carries
* the accent, and every task row ends in one DropdownMenu. Ink stays on the
* three token levels (text-sm body / text-xs mono meta, `font-mono
* tabular-nums`), and status color is the stock Badge variant set only.
* Visual language: the vendored shadcn/ui set over the task page's Tailwind
* tokens (src/client/ui/theme.css) — one full `Card` whose bands (header,
* filter, `CardContent` list, `CardFooter`) are separated by hairlines, task
* rows are `Item`s, members are a `ToggleGroup` whose picked chip carries the
* component's own accent, and every task row ends in one `DropdownMenu`.
* Type and color come from the components' variants and the semantic tokens
* (the count keeps the page's mono-meta convention); status color is the stock
* `Badge` variant set only. The strip's compact band padding is its one
* deliberate departure — it is a permanently visible strip, so it stays thin
* and lets the canvas below it keep its height.
*
* Behaviour is unchanged, and deliberately so: the row and EVERY menu entry
* open the shared task window, which is the ONE surface owning view / edit /
@@ -19,21 +23,21 @@
* before.
*/
import { useMemo, useRef, useState, type ReactNode } from 'react'
import {
Button, IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { t, type CopyKey } from './locales.ts'
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 { Button } from './ui/button.tsx'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card.tsx'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
} from './ui/dropdown-menu.tsx'
import { Empty, EmptyDescription } from './ui/empty.tsx'
import { Item, ItemContent, ItemGroup, ItemMedia, ItemTitle } from './ui/item.tsx'
import { Separator } from './ui/separator.tsx'
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
import { cn } from './ui/utils.ts'
/** The task status label key. */
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
@@ -81,22 +85,20 @@ function memberDotState(member: SidebarTeamMemberView): 'ongoing' | 'error' | 'i
*/
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-2 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, accent when picked (stock). */
const CHIP_CLASS =
'h-6 w-auto min-w-0 gap-1 px-2 text-xs font-normal text-muted-foreground data-[state=on]:text-foreground'
/** The row menu's rows: body size, no icon column (the label is the affordance). */
const ITEM_CLASS = 'text-sm'
/**
* The row menu's trigger: revealed on hover / focus (the board stays quiet
* until the reader reaches for it) but never removed from the tab order.
*/
const TRIGGER_CLASS = 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100'
/**
* 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
* and the menu is its SIBLING — a control may not nest inside another, and the
* row's own press target must stay one element. `Item asChild` puts the stock
* row geometry on that button itself instead of wrapping it in a second box.
* 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.
*/
@@ -112,68 +114,79 @@ function TeamTaskRow(props: {
const row = rowRef.current
if (row !== null) onOpenTask(task, row)
}
const badge = taskBadge(task)
return (
<div ref={rowRef} className="group 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-sm text-foreground">{task.subject}</span>
{task.ownerName !== undefined && (
<span className="max-w-[72px] flex-none truncate font-mono text-xs tabular-nums text-foreground-3">
{task.ownerName}
</span>
)}
{(() => {
const badge = taskBadge(task)
return (
<Badge variant={badge.variant} className={cn('flex-none', badge.className)}>
{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 opacity-0 outline-none transition-colors group-hover:opacity-100 hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50"
<div ref={rowRef} className="group flex min-w-0 items-center gap-1">
<Item asChild size="sm" className="min-w-0 flex-1 flex-nowrap gap-2 px-2 py-1.5 hover:bg-muted">
<button
type="button"
aria-label={`${t('teamTaskDetail')} ${task.subject}`}
title={t('teamTaskDetail')}
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
>
<IconChevronUpOutline14 size={12} className="rotate-90" />
<ItemMedia><StateDot size={6} state={taskDotState(task)} /></ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0">
<span className="min-w-0 truncate">{task.subject}</span>
</ItemTitle>
</ItemContent>
{task.ownerName !== undefined && (
<span className="max-w-18 flex-none truncate font-mono text-xs tabular-nums text-foreground-3">
{task.ownerName}
</span>
)}
<Badge variant={badge.variant} className={cn('flex-none', badge.className)}>
{taskStatusLabel(task)}
</Badge>
</button>
</Item>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={TRIGGER_CLASS}
aria-label={t('teamTaskActions')}
title={t('teamTaskActions')}
>
<IconChevronUpOutline14 className="rotate-90" />
</Button>
</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>
<DropdownMenuGroup>
<DropdownMenuItem onClick={openWindow}>
{task.status === 'completed' ? t('teamTaskReopen') : t('teamTaskComplete')}
</DropdownMenuItem>
<DropdownMenuItem onClick={openWindow}>
{t('teamTaskEdit')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className={ITEM_CLASS}>{t('teamTaskOwner')}</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger>{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}
<DropdownMenuGroup>
<DropdownMenuItem onClick={openWindow}>
{t('teamTaskUnowned')}
</DropdownMenuItem>
))}
{teammates.map(member => (
<DropdownMenuItem key={member.id} onClick={openWindow}>
{member.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</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>
<DropdownMenuGroup>
{/* 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 onClick={openWindow}>
{t('teamTaskDelete')}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -208,86 +221,98 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
// card rhythm; `py-0` because each band owns its own padding. The rest is
// the stock card surface (`rounded-xl border bg-card shadow-sm`); the
// landmark role keeps the labelled region the old `<section>` provided.
className="mb-2 shrink-0 gap-0 overflow-hidden rounded-xl py-0 shadow-sm"
className="mb-2 shrink-0 gap-0 overflow-hidden py-0"
role="region"
aria-label={t('teamBoard')}
>
{/* Header: the disclosure, and the only control that collapses the board. */}
<div className="flex items-center py-0.5 pr-2 pl-3">
<button
type="button"
className={cn(ROW_CLASS, 'flex-1 py-1.5')}
aria-expanded={!collapsed}
aria-label={t('teamBoard')}
onClick={onToggleCollapsed}
>
<span className="flex-none text-muted-foreground" aria-hidden="true">
<IconChecklistOutline14 size={12} />
</span>
<span className="min-w-0 truncate text-sm font-medium text-foreground">{t('teamBoard')}</span>
<span className="min-w-0 truncate font-mono text-xs 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"
{/* Header: the disclosure, and the only control that collapses the board.
It owns the WHOLE band (one press target, as before) and carries the
board's accessible name and expanded state. */}
<CardHeader className="flex flex-row items-center gap-1 p-1">
<CardTitle className="min-w-0 flex-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
aria-expanded={!collapsed}
aria-label={t('teamBoard')}
onClick={onToggleCollapsed}
>
<IconChevronUpOutline14 size={12} />
</span>
</button>
</div>
<IconChecklistOutline14 />
<span className="min-w-0 truncate">{t('teamBoard')}</span>
<span className="ml-auto min-w-0 truncate font-mono text-xs tabular-nums text-foreground-3">
{t('teamChip', { members: members.length, tasks: live.length })}
</span>
<span className={cn('flex-none', collapsed ? '' : 'rotate-180')} aria-hidden="true">
<IconChevronUpOutline14 />
</span>
</Button>
</CardTitle>
</CardHeader>
{!collapsed && (
<>
<Separator />
{/* The owner filter: one chip per member, plus "all". */}
<ToggleGroup
type="single"
variant="default"
size="sm"
value={ownerFilter ?? FILTER_ALL}
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 => (
<ToggleGroupItem
key={member.id}
value={member.name}
title={`${member.name} · ${member.role}`}
className={CHIP_CLASS}
>
<StateDot size={6} state={memberDotState(member)} />
<span className="min-w-0 truncate">{member.name}</span>
</ToggleGroupItem>
))}
</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-xs">{t('teamTasksEmpty')}</Empty>
: shown.map(task => (
<TeamTaskRow key={task.id} task={task} teammates={teammates} onOpenTask={onOpenTask} />
<div className="px-2 py-1.5">
<ToggleGroup
type="single"
variant="default"
size="sm"
spacing={1}
value={ownerFilter ?? FILTER_ALL}
aria-label={t('teamMembers')}
className="flex-wrap justify-start"
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}>{t('teamFilterAll')}</ToggleGroupItem>
{members.map(member => (
<ToggleGroupItem
key={member.id}
value={member.name}
title={`${member.name} · ${member.role}`}
>
<StateDot size={6} state={memberDotState(member)} />
<span className="min-w-0 truncate">{member.name}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<Separator />
<div className="px-2 py-1.5">
<CardContent className="p-1">
{shown.length === 0
// `Empty` is the design language's empty state; its stock card
// rhythm is overridden — including the `md:` step, which would
// otherwise re-inflate the band to 48px of padding above 768px —
// so an empty board stays a thin band.
? (
<Empty className="gap-0 p-3 md:p-3">
<EmptyDescription>{t('teamTasksEmpty')}</EmptyDescription>
</Empty>
)
: (
<ItemGroup className="max-h-40 overflow-y-auto">
{shown.map(task => (
<TeamTaskRow key={task.id} task={task} teammates={teammates} onOpenTask={onOpenTask} />
))}
</ItemGroup>
)}
</CardContent>
<Separator />
<CardFooter className="px-2 py-1.5">
<Button
variant="outline"
size="sm"
icon={<IconPlusOutline16 size={13} />}
onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
>
<IconPlusOutline16 />
{t('teamTaskCreate')}
</Button>
</div>
</CardFooter>
</>
)}
</Card>
+33 -39
View File
@@ -3,51 +3,45 @@ 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",
},
}
"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",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "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}
/>
)
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+83
View File
@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { Slot } from "radix-ui"
import { Separator } from "./separator.tsx"
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+54 -61
View File
@@ -3,74 +3,67 @@ 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",
},
}
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all 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 shadow-xs 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 ",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
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.
* LOCAL ADAPTATION: `React.forwardRef`, because this plugin runs on the DSH
* host's React 18 (ref-as-prop is a React 19 feature). Without it
* `<TooltipTrigger asChild><Button/></TooltipTrigger>` 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). The registry targets React 19 and ships no forwardRef, so a
* `shadcn add --overwrite` refresh must re-apply this.
*/
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}
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"
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}
/>
)
return (
<Comp
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
})
export { Button, buttonVariants }
+65 -65
View File
@@ -2,90 +2,90 @@ 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}
/>
)
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
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}
/>
)
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}
/>
)
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}
/>
)
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}
/>
)
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}
/>
)
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}
/>
)
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,
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+16 -16
View File
@@ -1,31 +1,31 @@
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+201 -131
View File
@@ -1,184 +1,254 @@
/**
* 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"
import { IconCheckOutline16, IconChevronRightOutline14 } from "@deepseek-ai/dsh-client-ui-primitives"
function DropdownMenu({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
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>
)
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]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]: data-[state=open]: data-[state=open]: data-[state=open]:",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
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}
/>
)
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 DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<IconCheckOutline16 className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<span className="size-2 rounded-full bg-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
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}
/>
)
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
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}
/>
)
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
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>
)
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 className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
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}
/>
)
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]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]: data-[state=open]: data-[state=open]: data-[state=open]:",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+76 -76
View File
@@ -2,102 +2,102 @@ 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}
/>
)
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}
/>
)
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",
},
}
"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
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}
/>
)
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}
/>
)
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}
/>
)
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}
/>
)
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,
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+246
View File
@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { Label } from "./label.tsx"
import { Separator } from "./separator.tsx"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 ",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+168
View File
@@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { Button } from "./button.tsx"
import { Input } from "./input.tsx"
import { Textarea } from "./textarea.tsx"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none ",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 ",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 ",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 ",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+13 -26
View File
@@ -1,33 +1,20 @@
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}
/>
)
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 shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-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 }
+193
View File
@@ -0,0 +1,193 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { Slot } from "radix-ui"
import { Separator } from "./separator.tsx"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "gap-4 p-4",
sm: "gap-2.5 px-4 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-sm leading-normal font-normal text-balance text-muted-foreground",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "./utils"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+66 -25
View File
@@ -3,43 +3,84 @@ import { cn } from "./utils"
import { Popover as PopoverPrimitive } from "radix-ui"
function Popover({
...props
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
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>
)
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]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]: data-[state=open]: data-[state=open]: data-[state=open]:",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}
+42 -42
View File
@@ -3,53 +3,53 @@ import { cn } from "./utils"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
function ScrollArea({
className,
children,
...props
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>
)
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
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>
)
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 }
+16 -16
View File
@@ -3,23 +3,23 @@ import { cn } from "./utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
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}
/>
)
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 }
+7 -7
View File
@@ -1,13 +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}
/>
)
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
+9 -21
View File
@@ -2,28 +2,16 @@ 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.
* Vendored shadcn `Spinner` with the icon swapped to the host's primitive set
* (the plugin never ships lucide). The host icons render their own `<svg>`, so
* the props land on a wrapping span instead of the svg element.
*/
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>
)
function Spinner({ className, ...props }: React.ComponentProps<"span">) {
return (
<span role="status" aria-label="Loading" className={cn("inline-flex", className)} {...props}>
<IconLoadingOutline16 className="size-4 animate-spin" />
</span>
)
}
export { Spinner }
+10 -10
View File
@@ -2,16 +2,16 @@ 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}
/>
)
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 shadow-xs 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 }
+61 -61
View File
@@ -3,79 +3,79 @@ import { type VariantProps } from "class-variance-authority"
import { cn } from "./utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "./toggle"
import { toggleVariants } from "./toggle.tsx"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
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>
)
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 data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
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>
)
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]:shadow-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 }
+31 -37
View File
@@ -3,48 +3,42 @@ 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",
},
}
"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 shadow-xs 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",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
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}
/>
)
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+33 -33
View File
@@ -3,52 +3,52 @@ import { cn } from "./utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
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>
)
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background data-[side=bottom]: data-[side=left]: data-[side=right]: data-[side=top]: data-[state=closed]: data-[state=closed]: data-[state=closed]:",
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 }
+5 -2
View File
@@ -3,7 +3,7 @@
*
* Static panels (cards, rows, the drawer) may carry the stock LIGHT shadows
* only — `shadow-xs` (button-like compact surfaces such as graph node cards)
* and `shadow-sm` (cards / panels). Anything heavier (`shadow-md|lg|xl|2xl`,
* and `shadow-sm` (cards / panels); `shadow-none` (a reset) is fine. Anything heavier (`shadow-md|lg|xl|2xl`,
* bare `shadow`, `shadow-inner`, arbitrary `shadow-[…]`) on a static panel is
* the regression: elevation beyond the stock pair reads as a floating layer
* that nothing anchors.
@@ -54,7 +54,10 @@ const FLOATING_FILES = [
const SHADOW_UTILITY = /(?<![-\w])shadow(?:-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)|\[|\()?/g
/** The stock pair a static panel may paint. */
const STATIC_ALLOWED = new Set(['shadow-xs', 'shadow-sm'])
// `shadow-none` is not an elevation at all (it clears the ring plumbing), so it
// is allowed anywhere a light shadow is; upstream's input-group uses it to keep
// nested controls flat inside the group's own surface.
const STATIC_ALLOWED = new Set(['shadow-xs', 'shadow-sm', 'shadow-none'])
/** Every non-allowed shadow utility in the source, for a readable failure. */
function offendingShadows(source: string): string[] {