feat(tasks-ui): migrate the Tasks page to shadcn/ui (Tailwind v4 + radix, lazy chunk)
Visual base replacement for the Tasks page after the 'hard shadows make a dense graph unreadable' review. Behaviour, hooks, aria labels and i18n are unchanged; the base layer is not. - FOUNDATION: Tailwind v4 wired into tsdown's css-inline plugin (theme + utilities ONLY — preflight is excluded because the plugin's stylesheet is a global <style data-plugin> tag and would repaint the host page), shadcn tokens bridged to --dsw-* so the DSH theme keeps flipping light/dark with no dark: overrides, cn() util, components.json for the CLI, src/client/ui/. - COMPONENTS: shadcn's component source vendored into src/client/ui (button, card, badge, separator, input, textarea, tooltip, popover, dropdown-menu, scroll-area, collapsible, toggle-group, skeleton, spinner, empty) with a documented local diff: lucide-react replaced by host IconXxx (no new icon dep), shadow-* dropped from non-floating variants, unused variants pruned, and React.forwardRef restored on Button — the host runs React 18, where radix's Slot cannot hand a ref to a plain function component (dev warns, production silently mis-anchors the floating layer). - SHADOWS: static panels are now hairline + surface tiers with hover:bg-muted; shadows exist only on floating layers. Settled nodes recede by ink (text-muted-foreground/foreground-3) instead of opacity. - DENSITY: node cards widened 132 -> 150px with a two-line clamped title (13px text in a 132px card left ~7 CJK characters), reserved height 46 -> 60. - CHUNK: the whole page (graph, tree, task window, board, drawer, shadcn/radix layer and its stylesheet) moved into the lazy 'tasks' chunk. Core bundle 988.5 -> 835.6 KiB (-153 KiB); the 709.8 KiB UI payload loads only when the Tasks tab opens. - MODEL: workflow members whose childId is already a real node elsewhere are no longer synthesized into a duplicate id (React keys/edges), with a last-resort per-id dedupe. - GUARDS: tests/ui-foundation.spec.ts, tests/ui-bundle.spec.ts (core/chunk split + size budget + no preflight/oklch/lucide), tests/ui-shadows.spec.ts, extended tests/theme.spec.ts (no colour literal / palette class in src/client/ui/** and the migrated files). - REVIEW FIXES: portal popovers re-scoped under .dsw-tasks, graph loading state, 11px floor, error nodes no longer tint a whole border, TeamBoard's delete item is neutral (the window owns the two-step confirm). Verified: typecheck / lint / 136 files 1502 tests / build bundle assertions / mount e2e 7-7; browser harness 65-65 checks with 0 console errors.
This commit is contained in:
+213
-104
@@ -11,12 +11,21 @@
|
||||
* cursor), with a copy action, a follow-latest switch, the two-click kill,
|
||||
* and a terminal-style tail while the job runs.
|
||||
*
|
||||
* Every control is a host primitive (Button / Tag / Switch / StateDot).
|
||||
* Visual language: Tailwind utilities over the shadcn tokens
|
||||
* (src/client/ui/theme.css) with the vendored Collapsible / ScrollArea /
|
||||
* Button for the interactive shells. Hierarchy comes from a 1px `border-border`
|
||||
* hairline plus the surface ladder (`bg-background` → `bg-muted` on hover) —
|
||||
* the static drawer carries no shadow; the only float is the popover card,
|
||||
* whose surface/shadow belong to AnchoredPopover. Body copy is 13px, meta
|
||||
* 11–12px, identifiers/durations `font-mono tabular-nums`.
|
||||
*
|
||||
* Every non-shadcn control is a host primitive (Switch / StateDot), and every
|
||||
* icon is a host `IconXxx` glyph — the badges are the vendored shadcn `Badge`
|
||||
* on the page's shared outline + semantic-tone classes.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch, Tag,
|
||||
type TagTone,
|
||||
IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarJobView } from '../context-types.ts'
|
||||
import {
|
||||
@@ -28,7 +37,11 @@ import {
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
import { Badge } from './ui/badge.tsx'
|
||||
import { Button } from './ui/button.tsx'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.tsx'
|
||||
import { ScrollArea } from './ui/scroll-area.tsx'
|
||||
import { cn } from './ui/utils.ts'
|
||||
|
||||
/** Refresh cadence of an open job-output popover while its job runs. */
|
||||
const JOB_POLL_MS = 2000
|
||||
@@ -37,12 +50,16 @@ const JOB_KILL_ARM_MS = 3000
|
||||
/** The agent count at which the drawer starts collapsed. */
|
||||
export const JOBS_DRAWER_COLLAPSE_AT = 8
|
||||
|
||||
/** The Tag tone of one job status. */
|
||||
function jobTone(job: SidebarJobView): TagTone {
|
||||
if (job.status === 'running') return 'info'
|
||||
if (job.status === 'completed') return 'success'
|
||||
if (job.status === 'failed') return 'danger'
|
||||
return 'neutral'
|
||||
/**
|
||||
* The status badge's semantic ink (the page's shared `outline` + tone class
|
||||
* pattern): a running job wears the accent, success/failure their own family,
|
||||
* and an ending/killed job stays neutral.
|
||||
*/
|
||||
function statusTone(job: SidebarJobView): string {
|
||||
if (job.status === 'running') return 'border-primary/40 text-primary'
|
||||
if (job.status === 'completed') return 'border-success/40 text-success'
|
||||
if (job.status === 'failed') return 'border-destructive/40 text-destructive'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
export interface JobsDrawerProps {
|
||||
@@ -109,83 +126,147 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
: t('jobsCount', { count: rows.length })
|
||||
|
||||
return (
|
||||
<section className={css.jobsDrawer} aria-label={t('jobs')}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.jobsDrawerBar}
|
||||
aria-expanded={open}
|
||||
onClick={() => { setManualOpen(!open) }}
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(next: boolean) => { setManualOpen(next) }}
|
||||
role="region"
|
||||
aria-label={t('jobs')}
|
||||
className="z-[5] flex-none border-t border-border bg-background"
|
||||
>
|
||||
{/* The bar keeps the drawer's whole toggle affordance (title, running
|
||||
tally, count line) and Radix owns aria-expanded / aria-controls. */}
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-none border-0 bg-transparent px-3 py-[7px] text-left font-mono text-[11px] tracking-[0.12em] text-muted-foreground uppercase transition-colors outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<span>{t('jobs')}</span>
|
||||
<span className={css.jobsBigNum}>{liveCount > 0 ? liveCount : rows.length}</span>
|
||||
<span className={css.jobsDrawerCount}>{countLabel}</span>
|
||||
<span className={css.jobsDrawerChev} data-open={open ? 'true' : 'false'} aria-hidden="true">
|
||||
<span className="text-[13px] font-semibold text-primary tabular-nums">
|
||||
{liveCount > 0 ? liveCount : rows.length}
|
||||
</span>
|
||||
<span className="tracking-[0.02em] text-foreground-3 normal-case">{countLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto inline-flex text-foreground-3 transition-transform duration-150',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconChevronUpOutline14 size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{!autoOpen && <div className={css.jobsAutoNote}>{t('jobsAutoCollapsed')}</div>}
|
||||
{open && (
|
||||
<div className={css.jobsDrawerBody}>
|
||||
{rows.map((row) => {
|
||||
const { job } = row
|
||||
const live = isJobLive(job)
|
||||
const armed = armedId === job.id
|
||||
const killing = killingId === job.id
|
||||
const killFailed = killErrorId === job.id
|
||||
const elapsed = live
|
||||
? now - job.startedAt
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
formatJobDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className={css.jobsRow}
|
||||
data-settled={live ? 'false' : 'true'}
|
||||
data-open={openJobId === job.id ? 'true' : 'false'}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={css.jobsRowMain}
|
||||
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
|
||||
title={t('jobViewOutput')}
|
||||
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
|
||||
>
|
||||
<StateDot state={jobDotState(job.status)} size={6} />
|
||||
<Tag tone="quiet">{job.kind}</Tag>
|
||||
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
|
||||
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag>
|
||||
<span className={css.jobsMeta}>{secondary}</span>
|
||||
</Button>
|
||||
{job.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={css.jobsKill}
|
||||
data-armed={armed ? 'true' : 'false'}
|
||||
icon={<IconStopFill16 size={11} />}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('jobKillConfirm') : undefined}
|
||||
</Button>
|
||||
)}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
{!autoOpen && (
|
||||
<div className="px-3 pb-2 font-mono text-[11px] text-foreground-3">{t('jobsAutoCollapsed')}</div>
|
||||
)}
|
||||
</section>
|
||||
<CollapsibleContent>
|
||||
{/* Bounded log surface: one row per job, hairline-separated by the
|
||||
rows' own 4px rhythm rather than per-row borders. The height is
|
||||
definite (not `max-h`) because ScrollArea's viewport is `size-full`:
|
||||
a bare max-height leaves the viewport content-sized, so the rows
|
||||
would spill out of the drawer instead of scrolling. */}
|
||||
<ScrollArea className="h-[156px] w-full min-w-0 border-t border-dashed border-border">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 px-2 pt-1 pb-2">
|
||||
{rows.map((row) => {
|
||||
const { job } = row
|
||||
const live = isJobLive(job)
|
||||
const armed = armedId === job.id
|
||||
const killing = killingId === job.id
|
||||
const killFailed = killErrorId === job.id
|
||||
const elapsed = live
|
||||
? now - job.startedAt
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const duration = formatJobDuration(elapsed, t)
|
||||
// The owner/detail tail; the duration is its own tabular-nums
|
||||
// cell, while the accessible name keeps the exact
|
||||
// `owner · detail · duration` string it always had.
|
||||
const context = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
].join(' · ')
|
||||
const secondary = [context, duration].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className={cn(
|
||||
'flex items-center gap-[7px] rounded-md px-1.5 py-1 transition-colors',
|
||||
'hover:bg-muted',
|
||||
openJobId === job.id && 'bg-muted',
|
||||
// A settled job recedes by ink (the label line drops to the
|
||||
// secondary level), not by opacity.
|
||||
!live && 'text-muted-foreground',
|
||||
)}
|
||||
data-settled={live ? 'false' : 'true'}
|
||||
data-open={openJobId === job.id ? 'true' : 'false'}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto min-w-0 flex-1 justify-start gap-[7px] px-0 py-0 text-left text-[13px] font-normal tracking-normal normal-case"
|
||||
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
|
||||
title={t('jobViewOutput')}
|
||||
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
|
||||
>
|
||||
{/* `size-1.5` opts the dot out of the Button's
|
||||
`[&_svg:not([class*='size-'])]:size-4` rule (the
|
||||
running state draws an svg, the others a span), so the
|
||||
indicator keeps its 6px. */}
|
||||
<StateDot state={jobDotState(job.status)} size={6} className="size-1.5" />
|
||||
<Badge variant="outline" className="h-4 flex-none px-1.5 py-0 font-mono text-[11px] font-normal text-muted-foreground">
|
||||
{job.kind}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate font-mono text-[13px]',
|
||||
live ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
title={job.label}
|
||||
>
|
||||
{job.label}
|
||||
</span>
|
||||
<Badge variant="outline" className={cn('h-4 flex-none px-1.5 py-0 text-[11px] font-normal', statusTone(job))}>
|
||||
{jobStatusLabel(job.status, t)}
|
||||
</Badge>
|
||||
{context !== '' && (
|
||||
<span className="max-w-[45%] shrink-0 truncate text-xs text-muted-foreground">
|
||||
{context}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{duration}
|
||||
</span>
|
||||
</Button>
|
||||
{job.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// `border-border` is explicit: preflight is never loaded
|
||||
// (see ui/theme.css), so a bare `border` here would take
|
||||
// `currentColor` instead of the hairline token.
|
||||
className={cn(
|
||||
'h-auto shrink-0 border-border px-1.5 py-0.5 font-mono text-[11px] font-normal text-muted-foreground',
|
||||
armed && 'border-destructive text-destructive',
|
||||
)}
|
||||
data-armed={armed ? 'true' : 'false'}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
<IconStopFill16 size={11} className="size-[11px]" />
|
||||
{armed ? t('jobKillConfirm') : undefined}
|
||||
</Button>
|
||||
)}
|
||||
{killFailed && (
|
||||
<span className="shrink-0 font-mono text-[11px] text-destructive">{t('jobKillError')}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,6 +276,12 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
* {@link JOB_POLL_MS} while the job runs and the page is visible. The reader
|
||||
* can copy the output and turn the terminal-style tail off; the kill action
|
||||
* keeps its two-click confirm.
|
||||
*
|
||||
* The card fills the AnchoredPopover surface (rounded-lg + border + shadow
|
||||
* live there, since that host owns the float) and only places its content
|
||||
* inside it: header, title line, meta line, the terminal tail, and the action
|
||||
* footer. `data-popover-handle` / `data-popover-no-drag` keep the drag
|
||||
* contract unchanged — the body drags, the controls do not.
|
||||
*/
|
||||
export function JobOutputPopoverContent(props: {
|
||||
ownerSessionId: string
|
||||
@@ -273,14 +360,17 @@ export function JobOutputPopoverContent(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.popCard} data-popover-handle>
|
||||
<div className={css.popHead}>
|
||||
// `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-[13px] text-popover-foreground" data-popover-handle>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2 font-mono text-[11px] tracking-[0.14em] text-foreground-3 uppercase">
|
||||
<span>{t('jobs')}</span>
|
||||
<span className={css.popHeadActions} data-popover-no-drag>
|
||||
<span className="inline-flex items-center gap-0.5 tracking-normal normal-case" data-popover-no-drag>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<IconCopyOutline16 size={12} />}
|
||||
className="size-7 p-0"
|
||||
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
title={copied ? t('jobCopied') : t('jobCopyOutput')}
|
||||
disabled={text === ''}
|
||||
@@ -290,32 +380,51 @@ export function JobOutputPopoverContent(props: {
|
||||
window.setTimeout(() => { setCopied(false) }, 1500)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<IconCopyOutline16 size={12} className="size-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.jobPopTitle}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<StateDot state={jobDotState(job.status)} size={6} />
|
||||
<span className={css.popTitle} title={job.label}>{job.label}</span>
|
||||
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground" title={job.label}>
|
||||
{job.label}
|
||||
</span>
|
||||
<Badge variant="outline" className={cn('h-5 flex-none px-2 text-[11px]', statusTone(job))}>
|
||||
{jobStatusLabel(job.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className={css.jobPopMeta}>
|
||||
<Tag tone="quiet">{job.kind}</Tag>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="h-4 flex-none px-1.5 py-0 font-mono text-[11px] font-normal text-muted-foreground">
|
||||
{job.kind}
|
||||
</Badge>
|
||||
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
|
||||
<span className={css.popHint}>{t('jobDragHint')}</span>
|
||||
<span className="font-mono text-xs text-foreground-3">{t('jobDragHint')}</span>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.popHint}>{t('loading')}</div>}
|
||||
{state === 'error' && <div className={css.popError}>{t('jobOutputError')}</div>}
|
||||
{state === 'loading' && <div className="text-xs text-foreground-3">{t('loading')}</div>}
|
||||
{state === 'error' && <div className="text-xs text-destructive">{t('jobOutputError')}</div>}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.popPre} data-popover-no-drag>{state.text}</pre>
|
||||
? (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="mt-1.5 max-h-[168px] overflow-auto rounded-md border border-border bg-background p-2 font-mono text-xs leading-[1.55] break-words whitespace-pre-wrap text-muted-foreground"
|
||||
data-popover-no-drag
|
||||
>
|
||||
{state.text}
|
||||
</pre>
|
||||
)
|
||||
: state.read
|
||||
? <div className={css.popHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.popHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.popHint}>{t('jobOutputTruncated')}</div>}
|
||||
? <div className="text-xs text-foreground-3">{t('jobNoOutput')}</div>
|
||||
: <div className="text-xs text-foreground-3">{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className="text-xs text-foreground-3">{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
<div className={css.jobPopActions} data-popover-no-drag>
|
||||
<div
|
||||
className="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2"
|
||||
data-popover-no-drag
|
||||
>
|
||||
<Switch
|
||||
checked={follow}
|
||||
onChange={setFollow}
|
||||
@@ -324,21 +433,21 @@ export function JobOutputPopoverContent(props: {
|
||||
/>
|
||||
{live && (
|
||||
<Button
|
||||
variant={armed ? 'primary' : 'outline'}
|
||||
variant={armed ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className={armed ? css.jobPopKillArmed : undefined}
|
||||
icon={<IconStopFill16 size={11} />}
|
||||
className="border-border font-mono text-xs"
|
||||
disabled={killing}
|
||||
onClick={() => {
|
||||
if (armed) void kill()
|
||||
else setArmed(true)
|
||||
}}
|
||||
>
|
||||
<IconStopFill16 size={11} className="size-[11px]" />
|
||||
{armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>}
|
||||
{killFailed && <div className="text-xs text-destructive">{t('jobKillError')}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user