From e41f10afc93630b43323cedc1dcbd8aa70bf4c9d Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Wed, 16 Sep 2026 15:13:43 +0800 Subject: [PATCH] feat(tasks-ui): tasks on their owner nodes, host-primitive controls, icon glyphs, draggable output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-two review feedback, all three points: - TASKS ON NODES: the model maps each shared task (ownerName -> member name -> member session id) onto its owner's node; graph cards and tree rows render a fourth line (icon + subject + status + '+N'), and the node detail popover lists every owned task. - TEAM BOARD: member Pills double as the owner filter; each task row is a status dot + subject + owner + status Tag + ONE overflow Menu (complete / reopen / edit / reassign submenu / delete with a two-step arm). Creating and editing moved into a real Modal with labeled Inputs and owner Pills — the narrow strip is no longer where you type. - HOST PRIMITIVES ONLY: the board and the jobs drawer now use Menu / Modal / Input / Button / Pill / Tag / Switch / StateDot; no native select or input remains (guarded by a spec). - ICONS, NOT HAND-DRAWN GLYPHS: every text glyph (circle/diamond/triangle/ check/chevrons/i/house) is replaced by a host icon — agent preset, user (teammate), branch (workflow run), checklist (fold + tasks), tree corner, fullscreen (fit), ellipsis (node details), copy/stop (jobs). The live line now shows the tool's own icon + name + args without the drawn box. - JOBS UX: the output popover is DRAGGABLE (offset clamped to the viewport, double click re-anchors, controls do not start a drag), defaults to 380px, and gains copy-output, a follow-latest switch, kind/status Tags and a drag hint; the drawer highlights the open row. - 19 new copy keys across zh/en/ja and the 18 third-language dictionaries. Tests: 4 new specs (task rendered on its node, no native select/input + menu-driven board, create-through-dialog posts the CAS-free create, job popover drag moves the card). Full suite 1404 passed / 133 files. --- ...09-14-tasks-graph-workflow-teams-design.md | 13 + src/client/AnchoredPopover.tsx | 103 +++- src/client/JobsDrawer.tsx | 203 +++++-- src/client/SubagentView.tsx | 11 +- src/client/TasksGraph.tsx | 88 +-- src/client/TasksPopovers.tsx | 35 +- src/client/TasksTree.tsx | 39 +- src/client/TeamBoard.tsx | 524 ++++++++++-------- src/client/locales-ar.ts | 9 + src/client/locales-de.ts | 9 + src/client/locales-fr.ts | 9 + src/client/locales-hi.ts | 9 + src/client/locales-id.ts | 9 + src/client/locales-it.ts | 9 + src/client/locales-ja.ts | 9 + src/client/locales-ko.ts | 9 + src/client/locales-nl.ts | 9 + src/client/locales-pl.ts | 9 + src/client/locales-pt.ts | 9 + src/client/locales-ru.ts | 9 + src/client/locales-sv.ts | 9 + src/client/locales-th.ts | 9 + src/client/locales-tr.ts | 9 + src/client/locales-vi.ts | 9 + src/client/locales-zh-HK.ts | 9 + src/client/locales-zh-MO.ts | 9 + src/client/locales-zh-TW.ts | 9 + src/client/locales.ts | 18 + src/client/tasks-graph.module.css | 166 ++++++ src/client/tasks-model.ts | 35 ++ src/client/tasks-shared.tsx | 97 +++- tests/tasks-page.spec.tsx | 136 ++++- 32 files changed, 1257 insertions(+), 382 deletions(-) diff --git a/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md b/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md index 438441d..7600fd2 100644 --- a/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md +++ b/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md @@ -102,6 +102,19 @@ client 半(src/client/) 真实浏览器里的组件级回归无法靠 jsdom 覆盖(点击捕获、fit/居中、主题令牌解析都是浏览器行为)。harness 用 esbuild 把**真实组件** + mockup 形状的 fixture(走 `buildTasksModel`,因此折叠/重挂/富化都真实生效)打包进一个页面,注入从 `dsh-client-ui-theme` 抽出的令牌 CSS,`@deepseek-ai/dsh-client-ui-primitives` 别名到轻量桩(避免把 katex/shiki 资源拖进截图包),再由 Playwright 在 360px / 720px 两档宽度截图并断言:节点点击回调、ⓘ 回调、背景拖拽平移量。本次返工的四条反馈里有三条正是它先复现、修完再确认的。 +## 第二轮返工(2026-09-14 深夜,13 条反馈) + +用户第二轮反馈三条,逐条落地: + +| 反馈 | 做法 | +|---|---| +| 任务板「太窄太小」,且任务应显示到**对应节点**上 | 任务按 `ownerName → 成员名 → 成员会话 id` 映射进模型(`TasksNodeTask`),节点第 4 行渲染「☑ 主题 · 状态 +N」;任务板改为:成员 Pill 行**兼当负责人筛选**、任务行 = 状态点 + 主题 + 负责人 + 状态 Tag + **一个**溢出菜单;新建/编辑改为 `Modal` + `Input`(真正的宽表单),负责人用 Pill 选择 | +| 所有组件用自建、不用原生 HTML | 任务板全量换成宿主 primitives:`Menu`(含子菜单的改派)/`Modal`/`Input`/`Button`/`Pill`/`Tag`/`Switch`/`StateDot`;后台任务行的 kind/状态用 `Tag`、终止用 `Button`、输出浮窗的复制/跟随/终止同理。页面仅剩结构性 div(卡片、树行)不是表单控件 | +| Agent 用图标而非自绘 SVG;优化工具展示 | `◉/◇/▶/✓/⇪/⇩/i/☰/⌗/⌂` 等字形全部删除,改用宿主图标:`IconAgentPresetOutline16`(代理)、`IconUserOutline16`(teammate)、`IconBranchOutline16`(workflow run)、`IconChecklistOutline14`(折叠聚合 / 任务行 / 折叠按钮)、`IconTreeCorner8x10`(树视图)、`IconFullscreenOutline16`(适应)、`IconEllipsisOutline16`(节点详情)、`IconCopyOutline16`/`IconStopFill16`(作业)。live 行改为工具自身图标 + 工具名 + 参数(去掉手绘方框) | +| 后台任务弹窗要能拖动 + 体验 | `AnchoredPopover` 增加 `draggable`(拖动整体、按钮/输入/`pre` 不拦截、双击复位、视口内钳制)与 `width` 参数;输出浮窗宽 380 且可拖,新增「复制输出」「跟随最新」开关(关闭后不再尾钉)、kind/状态 Tag、拖动提示;抽屉行高亮当前打开的作业 | + +i18n:本轮新增 19 条文案,zh/en/ja + 18 份第三语言词典同步(`tests/locales.spec.ts` 16/16)。 + ## 验证结果(2026-09-14) PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `feat/tasks-graph-workflow-teams`)。 diff --git a/src/client/AnchoredPopover.tsx b/src/client/AnchoredPopover.tsx index 9dc9eae..be9e09a 100644 --- a/src/client/AnchoredPopover.tsx +++ b/src/client/AnchoredPopover.tsx @@ -1,38 +1,62 @@ /** * The anchored floating popover of the Tasks page (node details, job output, - * workflow run detail, team task editor): a viewport-anchored card portaled - * to `document.body`, positioned below its anchor and flipped above when the - * viewport bottom would clip it. Dismissal follows the proven - * selection-popup contract (fixes upstream issue #425): outside mousedown, - * Escape, document hidden, window blur, and the ANCHOR leaving the viewport - * (tab switches flip the pane to display:none, which has no DOM event — the - * IntersectionObserver geometry signal is the only reliable one). + * workflow run detail): a viewport-anchored card portaled to `document.body`, + * positioned below its anchor and flipped above when the viewport bottom + * would clip it. Dismissal follows the proven selection-popup contract + * (fixes upstream issue #425): outside mousedown, Escape, document hidden, + * window blur, and the ANCHOR leaving the viewport (tab switches flip the + * pane to display:none, which has no DOM event — the IntersectionObserver + * geometry signal is the only reliable one). + * + * DRAGGABLE mode: the job-output popover must be movable (a long log needs to + * sit wherever the reader puts it). While `draggable`, dragging the card (or + * its `data-popover-handle` area) moves it; the offset is clamped to the + * viewport and a double click re-anchors it. The card stays portaled and + * viewport-positioned — DSH 0.1.5 has no host free-window API, so the drag + * is implemented here rather than reaching for a platform window. */ -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { + useCallback, useEffect, useLayoutEffect, useRef, useState, + type PointerEvent as ReactPointerEvent, type ReactNode, +} from 'react' import { createPortal } from 'react-dom' -/** The popover's visual width (viewport-clamped). */ +/** The popover's default visual width (viewport-clamped). */ const POP_WIDTH = 264 +/** Free space kept at the viewport edges. */ +const VIEWPORT_MARGIN = 8 export interface AnchoredPopoverProps { /** The anchor element (null = closed). */ anchor: HTMLElement | null /** Dismiss (outside click / Escape / anchor off-screen). */ onClose(): void + /** Allow dragging the card (default false = pinned to the anchor). */ + draggable?: boolean + /** Card width in px before viewport clamping (default 264). */ + width?: number children: ReactNode } /** * Render a viewport-anchored popover. The caller owns WHAT is shown inside; - * this component owns geometry + dismissal. Re-anchoring while open (a new - * anchor) re-measures and keeps the card on screen. + * this component owns geometry, dragging and dismissal. Changing the anchor + * re-measures and resets any drag offset. */ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode { - const { anchor, onClose, children } = props + const { anchor, onClose, draggable = false, width = POP_WIDTH, children } = props const cardRef = useRef(null) const onCloseRef = useRef(onClose) onCloseRef.current = onClose const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + /** Reader-applied drag offset (added to the anchored position). */ + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const offsetRef = useRef(offset) + offsetRef.current = offset + /** The anchored baseline: a drag always starts from the measured position. */ + const baseRef = useRef({ left: 0, top: 0 }) + + const cardWidth = Math.min(width, window.innerWidth - VIEWPORT_MARGIN * 2) // Measure + position once the card is in the DOM (and whenever the anchor // or content changes size). layout effect: no visible jump. @@ -41,15 +65,54 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode { const card = cardRef.current if (card === null) return const rect = anchor.getBoundingClientRect() - const width = Math.min(POP_WIDTH, window.innerWidth - 16) const height = card.offsetHeight let top = rect.bottom + 6 - if (top + height > window.innerHeight - 8) { - top = Math.max(8, rect.top - height - 6) + if (top + height > window.innerHeight - VIEWPORT_MARGIN) { + top = Math.max(VIEWPORT_MARGIN, rect.top - height - 6) } - const left = Math.min(Math.max(8, rect.left), window.innerWidth - width - 8) - setPos({ left, top }) - }, [anchor, children]) + const left = Math.min( + Math.max(VIEWPORT_MARGIN, rect.left), + Math.max(VIEWPORT_MARGIN, window.innerWidth - cardWidth - VIEWPORT_MARGIN), + ) + baseRef.current = { left, top } + setPos({ left: left + offsetRef.current.x, top: top + offsetRef.current.y }) + }, [anchor, children, cardWidth]) + + // A new anchor starts a fresh placement (never inherit another node's drag). + useEffect(() => { setOffset({ x: 0, y: 0 }) }, [anchor]) + + /** Drag the card (buttons inside it keep working). */ + const onPointerDown = useCallback((event: ReactPointerEvent): void => { + if (!draggable || event.button !== 0) return + const target = event.target as HTMLElement + // A control inside the card (copy, kill, follow…) must not start a drag. + if (target.closest('button, input, select, a, [data-popover-no-drag]') !== null) return + event.preventDefault() + const startX = event.clientX + const startY = event.clientY + const start = offsetRef.current + const base = baseRef.current + const onMove = (move: PointerEvent): void => { + const nextX = Math.min( + Math.max(start.x + (move.clientX - startX), VIEWPORT_MARGIN - base.left), + Math.max(VIEWPORT_MARGIN - base.left, window.innerWidth - cardWidth - VIEWPORT_MARGIN - base.left), + ) + const nextY = Math.min( + Math.max(start.y + (move.clientY - startY), VIEWPORT_MARGIN - base.top), + Math.max(VIEWPORT_MARGIN - base.top, window.innerHeight - 48 - base.top), + ) + setOffset({ x: nextX, y: nextY }) + setPos({ left: base.left + nextX, top: base.top + nextY }) + } + const onUp = (): void => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + }, [draggable, cardWidth]) // Global dismissal (see the file header for the contract). useEffect(() => { @@ -95,10 +158,12 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode { position: 'fixed', left: pos?.left ?? -9999, top: pos?.top ?? -9999, - width: Math.min(POP_WIDTH, window.innerWidth - 16), + width: cardWidth, zIndex: 90, visibility: pos === null ? 'hidden' : 'visible', }} + onPointerDown={onPointerDown} + onDoubleClick={draggable ? () => { setOffset({ x: 0, y: 0 }) } : undefined} > {children} , diff --git a/src/client/JobsDrawer.tsx b/src/client/JobsDrawer.tsx index 002a814..738c9c7 100644 --- a/src/client/JobsDrawer.tsx +++ b/src/client/JobsDrawer.tsx @@ -1,15 +1,23 @@ /** - * The background-jobs bottom drawer of the Tasks page: the tree's jobs - * (owner-labeled, fed by the `session/jobs` push mirror) collapse into a - * bottom bar that AUTO-COLLAPSES when the agent count crosses the page's - * threshold — the manual toggle always wins afterwards. Job output no - * longer docks inside the page: clicking a row opens an anchored popover - * (event replay — never the model's job_output cursor). Live rows keep the - * two-click-confirm kill. + * The background-jobs bottom drawer of the Tasks page and its output popover. + * + * Drawer: the tree's jobs (owner-labeled, fed by the `session/jobs` push + * mirror) collapse into a bottom bar that AUTO-COLLAPSES once the tree has + * many agents — the manual toggle always wins afterwards. + * + * Popover: clicking a row opens a DRAGGABLE, portalled output card (see + * AnchoredPopover) instead of docking a pane inside the page. It shows the + * output the MODEL has read (event replay — never the model's job_output + * 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). */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import clsx from 'clsx' -import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch, Tag, + type TagTone, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarJobView } from '../context-types.ts' import { formatJobDuration, @@ -19,7 +27,6 @@ import { type TreeJob, } from './subagent-jobs.ts' import { api, type JobOutputResult } from './api.ts' -import { IconStopOutline16 } from './icons.tsx' import { t } from './locales.ts' import css from './tasks-graph.module.css' @@ -27,6 +34,16 @@ import css from './tasks-graph.module.css' const JOB_POLL_MS = 2000 /** How long the kill button stays armed before it needs re-confirming. */ 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' +} export interface JobsDrawerProps { rows: readonly TreeJob[] @@ -34,13 +51,12 @@ export interface JobsDrawerProps { agentCount: number /** Open the output popover of one row (anchor = the row's main button). */ onOpenOutput(row: TreeJob, anchor: HTMLElement): void + /** The job whose output popover is currently open (row highlight). */ + openJobId?: string } -/** The agent count at which the drawer starts collapsed. */ -export const JOBS_DRAWER_COLLAPSE_AT = 8 - export function JobsDrawer(props: JobsDrawerProps): ReactNode { - const { rows, agentCount, onOpenOutput } = props + const { rows, agentCount, onOpenOutput, openJobId } = props const autoOpen = agentCount < JOBS_DRAWER_COLLAPSE_AT /** Manual override; undefined = follow the auto rule. */ const [manualOpen, setManualOpen] = useState(undefined) @@ -103,15 +119,11 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode { {t('jobs')} {liveCount > 0 ? liveCount : rows.length} {countLabel} -