Files
apes-sidebar/src/client/TaskWindow.tsx
T
Menghuan1918 f6cf73c4cf 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.
2026-09-27 22:18:19 +08:00

499 lines
18 KiB
TypeScript

/**
* The ONE task window: view, edit and create all live here, and every task
* surface (the board's rows, an agent node's task line, the node detail
* popover) opens THIS component — pointer targets stay thin, the window owns
* the behaviour.
*
* Shape: a non-fullscreen, DRAGGABLE card (never a modal dialog). Details
* render as multi-line MARKDOWN by default; 编辑 switches the same card into
* multi-line editing, so reading and writing never jump between surfaces.
* 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).
*
* That portal also sits OUTSIDE the task page root, so the card re-declares
* the page root class (`dsw-tasks`) to pick up the scoped base reset the
* plugin ships instead of preflight — box-sizing and form-control font
* inheritance; without preflight a bare `<button>`/`<textarea>` would render
* in the UA's own font.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16,
IconRefreshOutline14, IconTrashOutline16, MarkdownText, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { api } from './api.ts'
import { markdownTextProps } from './markdown-labels.tsx'
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 { Input } from './ui/input.tsx'
import { Separator } from './ui/separator.tsx'
import { Textarea } from './ui/textarea.tsx'
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx'
/** One mutation outcome (the host route's own union). */
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
/** The task status label key. */
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
case 'deleted': return 'teamTaskDeleted'
}
}
/**
* The status badge's ink — hue carries meaning only: done = success, running =
* accent, blocked = warning, everything else stays on quiet meta ink.
*/
function statusTone(task: SidebarTeamTaskView): string {
if (task.status === 'completed') return 'border-success/40 text-success'
if (task.status === 'in_progress') return 'border-primary/40 text-primary'
return task.ready ? 'text-muted-foreground' : 'border-warning/40 text-warning'
}
/**
* The plugin's own multi-line input: the host primitive set ships no
* textarea, so this is OUR component (token-styled, drag-exempt) and every
* multi-line edit in the page goes through it. The surface is the vendored
* shadcn `Textarea`; `rows` keeps the caller's height contract and the
* content-driven growth is capped so a long body cannot outgrow the viewport.
*/
export function MultilineField(props: {
value: string
label: string
placeholder?: string
rows?: number
onChange?(next: string): void
}): ReactNode {
return (
<Textarea
className="max-h-[45vh] min-h-[120px] resize-y leading-relaxed"
value={props.value}
rows={props.rows ?? 7}
placeholder={props.placeholder}
aria-label={props.label}
data-popover-no-drag
onChange={(event) => { props.onChange?.(event.target.value) }}
/>
)
}
/**
* The unowned option's sentinel value: Radix reads an EMPTY string as "nothing
* is selected" (`value ? [value] : []`), so `''` could never render pressed —
* and the unowned chip must be visibly the active one on an unowned task.
*/
const UNOWNED = '__unowned__'
/**
* The owner chips' ink: an unselected owner is meta ink, the picked one is
* full ink, and the vendored toggle adds its accent tint on top — the tint
* alone would read exactly like hover. (Weight cannot carry the selection
* here: the page's scoped base reset gives every form control `font: inherit`,
* which outranks a font-weight utility.)
*/
const OWNER_CHIP = 'text-muted-foreground data-[state=on]:text-foreground'
/** The owner picker (single-select toggles; CAS-immediate, so never a draft field). */
export function OwnerPicker(props: {
members: readonly SidebarTeamMemberView[]
owner: string | undefined
disabled: boolean
onPick(owner: string): void
}): ReactNode {
return (
<ToggleGroup
type="single"
value={props.owner ?? UNOWNED}
disabled={props.disabled}
spacing={1}
aria-label={t('teamTaskOwner')}
className="flex-wrap"
onValueChange={(next) => {
// '' is Radix's deselect of the pressed chip (the reader re-clicked the
// current owner): the old pill row re-sent the same owner, i.e. no
// mutation — the window never unassigns behind a re-click.
if (next === '') return
props.onPick(next === UNOWNED ? '' : next)
}}
>
<ToggleGroupItem value={UNOWNED} variant="outline" size="sm" className={OWNER_CHIP}>
{t('teamTaskUnowned')}
</ToggleGroupItem>
{props.members.map(member => (
<ToggleGroupItem key={member.id} value={member.name} variant="outline" size="sm" className={OWNER_CHIP}>
{member.name}
</ToggleGroupItem>
))}
</ToggleGroup>
)
}
/** The read-only body: markdown description, meta, owner picker. */
function TaskViewBody(props: {
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
busy: boolean
onReassign(owner: string): void
}): ReactNode {
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="outline" className={`h-5 px-2 text-[11px] ${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">
{body === ''
? <div className="text-xs leading-snug text-muted-foreground">{t('teamTaskNoDescription')}</div>
: (
<MarkdownText
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
/>
)}
</div>
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[11px] text-foreground-3">{t('teamTaskOwner')}</span>
<OwnerPicker
members={props.teammates}
owner={task.ownerName}
disabled={props.busy}
onPick={props.onReassign}
/>
</div>
</div>
)
}
/** The editing body: subject (shadcn Input) + description (our multi-line field). */
function TaskEditBody(props: {
subject: string
description: string
onSubject(next: string): void
onDescription(next: string): void
}): ReactNode {
return (
<div className="flex flex-col gap-2.5" data-popover-no-drag>
<label className="flex flex-col gap-1">
<span className="font-mono text-[11px] text-foreground-3">{t('teamTaskSubject')}</span>
<Input
className="h-8"
value={props.subject}
placeholder={t('teamTaskSubjectPlaceholder')}
aria-label={t('teamTaskSubject')}
onChange={(event) => { props.onSubject(event.target.value) }}
/>
</label>
<label className="flex flex-col gap-1">
<span className="font-mono text-[11px] text-foreground-3">{t('teamTaskDescription')}</span>
<MultilineField
value={props.description}
label={t('teamTaskDescription')}
placeholder={t('teamTaskDescriptionPlaceholder')}
onChange={props.onDescription}
/>
</label>
</div>
)
}
export interface TaskWindowProps {
rootId: string
/** The task under view/edit; undefined = create mode. */
task: SidebarTeamTaskView | undefined
members: readonly SidebarTeamMemberView[]
/** Re-pull `teams.view` after a mutation (the parent owns the poller). */
onChanged(): void
/** Close the window (the caller's popover state). */
onClose(): void
}
/** The task window content (the caller supplies the popover shell). */
export function TaskWindow(props: TaskWindowProps): ReactNode {
const { rootId, task, members, onChanged, onClose } = props
const creating = task === undefined
const [editing, setEditing] = useState(creating)
const [subject, setSubject] = useState(task?.subject ?? '')
const [description, setDescription] = useState(task?.description ?? '')
const [busy, setBusy] = useState(false)
const [note, setNote] = useState<string | undefined>(undefined)
const [armedDelete, setArmedDelete] = useState(false)
// Re-seed the draft when the caller swaps the subject task: the poller
// hands us fresh revisions, and unsaved text must not leak across tasks.
// Keyed by id on purpose — a same-id revision bump must NOT clobber the
// reader's in-flight edit (the seed rides a render-time ref so the effect
// depends on the id alone).
const seedRef = useRef({
subject: task?.subject ?? '',
description: task?.description ?? '',
creating: task === undefined,
})
seedRef.current = {
subject: task?.subject ?? '',
description: task?.description ?? '',
creating: task === undefined,
}
useEffect(() => {
const seed = seedRef.current
setSubject(seed.subject)
setDescription(seed.description)
setEditing(seed.creating)
}, [task?.id])
const teammates = members.filter(member => member.role === 'teammate')
/** Run one CAS mutation with the shared busy/conflict handling. */
const mutate = async (
action: () => Promise<MutationResult>,
options: { close?: boolean } = {},
): Promise<void> => {
if (busy) return
setBusy(true)
setNote(undefined)
try {
const result = await action()
if (!result.ok) {
setNote(result.error.code === 'team-task-conflict'
? t('teamTaskConflict')
: t('teamTaskError', { message: result.error.message }))
} else {
if (options.close === true) onClose()
else setEditing(false)
}
onChanged()
} catch (error) {
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
} finally {
setBusy(false)
}
}
/** Save the draft (create closes the window; edit returns to the view). */
const save = (): void => {
const nextSubject = subject.trim()
if (nextSubject === '') return
void mutate(() => (creating
? api.teamsTaskCreate(rootId, { subject: nextSubject, description: description.trim() })
: api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'edit',
subject: nextSubject,
description: description.trim(),
})), { close: creating })
}
/** Reassign the task (or return it to the unowned pool). */
const reassign = (owner: string): void => {
if (task === undefined || busy) return
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'reassign',
...(owner === '' ? {} : { owner }),
}))
}
return (
<Card
className="dsw-tasks box-border gap-0 rounded-lg border-0 bg-transparent py-0 text-[13px]"
data-popover-handle
>
<CardHeader className="flex flex-row items-center justify-between gap-2 px-3 py-2">
<CardTitle className="min-w-0 flex-1 truncate font-mono text-[11px] font-medium text-foreground-3">
{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}
</CardTitle>
<div className="flex flex-none items-center gap-1" data-popover-no-drag>
<Button
variant="ghost"
size="icon"
// Load-bearing reset: the plugin ships no preflight, so a control
// whose variant declares no paint keeps the UA's `buttonface` fill
// and 2px `outset` border. A ghost icon button must be chrome-free.
className="size-7 border-0 bg-transparent"
aria-label={t('teamTaskCancel')}
title={t('teamTaskCancel')}
onClick={onClose}
>
<IconCloseOutline16 />
</Button>
</div>
</CardHeader>
<Separator />
<CardContent className="px-3 py-2.5">
{editing || task === undefined
? (
<TaskEditBody
subject={subject}
description={description}
onSubject={setSubject}
onDescription={setDescription}
/>
)
: (
<TaskViewBody
task={task}
teammates={teammates}
busy={busy}
onReassign={reassign}
/>
)}
{note !== undefined && <div className="mt-2 text-xs leading-snug text-warning">{note}</div>}
</CardContent>
<Separator />
<CardFooter className="flex-wrap justify-end gap-1.5 px-3 py-2" data-popover-no-drag>
{editing
? (
<>
<Button
size="sm"
// Same preflight reset as the ghost button: `default` declares no
// border, so the UA's 2px `outset` edge would frame the fill.
className="border-0"
disabled={busy || subject.trim() === ''}
onClick={save}
>
<IconCheckOutline14 />
{creating ? t('teamTaskCreate') : t('teamTaskSave')}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
if (creating) onClose()
else if (task !== undefined) {
setSubject(task.subject)
setDescription(task.description)
setEditing(false)
}
}}
>
{t('teamTaskCancel')}
</Button>
</>
)
: task === undefined ? null : (
<>
<Button variant="outline" size="sm" onClick={() => { setEditing(true) }}>
<IconEditOutline16 />
{t('teamTaskEdit')}
</Button>
{task.status === 'completed'
? (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))}
>
<IconRefreshOutline14 />
{t('teamTaskReopen')}
</Button>
)
: (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete',
}))}
>
<IconCheckOutline14 />
{t('teamTaskComplete')}
</Button>
)}
<Button
variant="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 }
setArmedDelete(false)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'delete',
}), { close: true })
}}
>
<IconTrashOutline16 />
{armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
</Button>
</>
)}
</CardFooter>
</Card>
)
}
/**
* The task window inside its draggable popover shell — the single entry point
* every task surface uses (board row, node task line, node detail list).
*/
export function TaskPopover(props: TaskWindowProps & { anchor: HTMLElement | null }): ReactNode {
const { anchor, onClose, ...windowProps } = props
return (
<AnchoredPopover anchor={anchor} onClose={onClose} draggable width={430}>
{anchor === null ? null : <TaskWindow {...windowProps} onClose={onClose} />}
</AnchoredPopover>
)
}
/** The create affordance every surface shares. */
export function TaskCreateButton(props: {
disabled?: boolean
onClick(anchor: HTMLElement): void
}): ReactNode {
return (
<Button
variant="outline"
size="sm"
disabled={props.disabled}
onClick={(event) => { props.onClick(event.currentTarget) }}
>
<IconPlusOutline16 />
{t('teamTaskCreate')}
</Button>
)
}