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} -