From 6448d8bad9102886ecadcfdace166e4d796e87be Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 14 Sep 2026 23:06:22 +0800 Subject: [PATCH] feat(client): workflow-graph Tasks page with teams board and jobs drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Subagent page becomes the Tasks page per the approved Variant-D design: - tasks-model.ts feeds BOTH modes from one pre-order node list: graph canvas (TasksGraph: layered nodes over bezier edges, drag-pan, wheel-zoom-to-cursor, phase frames, bottom-right control cluster) and the classic tree (TasksTree: keyboard navigable) — the view toggle and fold toggle stay reachable in both modes. - Workflow runs (workflows.list) hang under their origin agent; member agents re-parent under the run node, missing ones synthesize from run data; completed leaf agents fold into per-parent aggregate nodes (click or cluster toggle to expand/re-collapse). - Agent Teams (experimental): roster enriches nodes; a header chip opens the shared task board popover with basic CAS operations (complete / reopen / delete / reassign / create / edit); no experimental layer → the whole block hides (structural degradation). - Background jobs move into a bottom drawer that auto-collapses at 8+ agents (manual toggle wins); job output opens as an anchored popover (event replay, never the model's cursor); two-click kill preserved. - Live activity lines render as glyph + tool + args (tool-icons map onto host primitive icons). - Node click jumps to the transcript; ⓘ opens the detail popover; popovers follow the selection-popup dismissal contract (Escape / outside click / anchor off-screen via IntersectionObserver). - tasksViewMode pref (graph default) with a select row in settings; 58 new copy keys across zh/en/ja + 18 third-language dictionaries; dead CSS from the old tree/dock pruned. - Tests: 6 new page-interaction specs (auto-collapse, both-mode toggle, jump, fold, popover); jobs-view suite re-pointed at the popover; prefs/builtins/smoke lock tests updated for the new pref. --- src/client/AnchoredPopover.tsx | 107 +++ src/client/JobsDrawer.tsx | 248 ++++++ src/client/SubagentView.module.css | 329 +------- src/client/SubagentView.tsx | 1121 +++++++++------------------- src/client/TasksGraph.tsx | 451 +++++++++++ src/client/TasksPopovers.tsx | 417 +++++++++++ src/client/TasksTree.tsx | 216 ++++++ src/client/builtins/tabs.tsx | 20 +- src/client/locales-ar.ts | 58 ++ src/client/locales-de.ts | 58 ++ src/client/locales-fr.ts | 58 ++ src/client/locales-hi.ts | 58 ++ src/client/locales-id.ts | 58 ++ src/client/locales-it.ts | 58 ++ src/client/locales-ja.ts | 58 ++ src/client/locales-ko.ts | 58 ++ src/client/locales-nl.ts | 58 ++ src/client/locales-pl.ts | 58 ++ src/client/locales-pt.ts | 58 ++ src/client/locales-ru.ts | 58 ++ src/client/locales-sv.ts | 58 ++ src/client/locales-th.ts | 58 ++ src/client/locales-tr.ts | 58 ++ src/client/locales-vi.ts | 58 ++ src/client/locales-zh-HK.ts | 58 ++ src/client/locales-zh-MO.ts | 58 ++ src/client/locales-zh-TW.ts | 58 ++ src/client/locales.ts | 116 +++ src/client/prefs.ts | 3 + src/client/tasks-graph.module.css | 684 +++++++++++++++++ src/client/tasks-shared.tsx | 88 +++ src/client/tool-icons.tsx | 54 ++ src/config.ts | 1 + src/prefs-shared.ts | 6 + tests/builtins.spec.ts | 6 +- tests/prefs.spec.ts | 3 + tests/subagent-jobs-view.spec.tsx | 420 +++-------- tests/tasks-page.spec.tsx | 185 +++++ 38 files changed, 4190 insertions(+), 1387 deletions(-) create mode 100644 src/client/AnchoredPopover.tsx create mode 100644 src/client/JobsDrawer.tsx create mode 100644 src/client/TasksGraph.tsx create mode 100644 src/client/TasksPopovers.tsx create mode 100644 src/client/TasksTree.tsx create mode 100644 src/client/tasks-graph.module.css create mode 100644 src/client/tasks-shared.tsx create mode 100644 src/client/tool-icons.tsx create mode 100644 tests/tasks-page.spec.tsx diff --git a/src/client/AnchoredPopover.tsx b/src/client/AnchoredPopover.tsx new file mode 100644 index 0000000..9dc9eae --- /dev/null +++ b/src/client/AnchoredPopover.tsx @@ -0,0 +1,107 @@ +/** + * 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). + */ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' + +/** The popover's visual width (viewport-clamped). */ +const POP_WIDTH = 264 + +export interface AnchoredPopoverProps { + /** The anchor element (null = closed). */ + anchor: HTMLElement | null + /** Dismiss (outside click / Escape / anchor off-screen). */ + onClose(): void + 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. + */ +export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode { + const { anchor, onClose, children } = props + const cardRef = useRef(null) + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + + // Measure + position once the card is in the DOM (and whenever the anchor + // or content changes size). layout effect: no visible jump. + useLayoutEffect(() => { + if (anchor === null) { setPos(null); return } + 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) + } + const left = Math.min(Math.max(8, rect.left), window.innerWidth - width - 8) + setPos({ left, top }) + }, [anchor, children]) + + // Global dismissal (see the file header for the contract). + useEffect(() => { + if (anchor === null) return + const onMouseDown = (event: MouseEvent): void => { + const card = cardRef.current + if (card !== null && (card === event.target || card.contains(event.target as Node))) return + if (anchor === event.target || anchor.contains(event.target as Node)) return + onCloseRef.current() + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') onCloseRef.current() + } + const onHidden = (): void => { if (document.hidden) onCloseRef.current() } + const onBlur = (): void => { onCloseRef.current() } + document.addEventListener('mousedown', onMouseDown, true) + document.addEventListener('keydown', onKeyDown, true) + document.addEventListener('visibilitychange', onHidden) + window.addEventListener('blur', onBlur) + // The anchor leaving the viewport (tab switch / panel collapse) closes. + let observer: IntersectionObserver | undefined + if (typeof IntersectionObserver !== 'undefined') { + observer = new IntersectionObserver((entries) => { + for (const entry of entries) if (!entry.isIntersecting) onCloseRef.current() + }, { threshold: 0 }) + observer.observe(anchor) + } + return () => { + document.removeEventListener('mousedown', onMouseDown, true) + document.removeEventListener('keydown', onKeyDown, true) + document.removeEventListener('visibilitychange', onHidden) + window.removeEventListener('blur', onBlur) + observer?.disconnect() + } + }, [anchor]) + + if (anchor === null) return null + return createPortal( +
+ {children} +
, + document.body, + ) +} diff --git a/src/client/JobsDrawer.tsx b/src/client/JobsDrawer.tsx new file mode 100644 index 0000000..ec65c68 --- /dev/null +++ b/src/client/JobsDrawer.tsx @@ -0,0 +1,248 @@ +/** + * 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. + */ +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 type { SidebarJobView } from '../context-types.ts' +import { + formatJobDuration, + isJobLive, + jobDotState, + jobStatusLabel, + 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' + +/** Refresh cadence of an open job-output popover while its job runs. */ +const JOB_POLL_MS = 2000 +/** How long the kill button stays armed before it needs re-confirming. */ +const JOB_KILL_ARM_MS = 3000 + +export interface JobsDrawerProps { + rows: readonly TreeJob[] + /** Agent count of the tree (root included) — the auto-collapse signal. */ + agentCount: number + /** The page is visible (active tab + open panel): skip polling otherwise. */ + active: boolean + /** Open the output popover of one row (anchor = the row's main button). */ + onOpenOutput(row: TreeJob, anchor: HTMLElement): void +} + +/** 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, active, onOpenOutput } = props + const autoOpen = agentCount < JOBS_DRAWER_COLLAPSE_AT + /** Manual override; undefined = follow the auto rule. */ + const [manualOpen, setManualOpen] = useState(undefined) + const open = manualOpen ?? autoOpen + const [armedId, setArmedId] = useState(undefined) + const [killingId, setKillingId] = useState(undefined) + const [killErrorId, setKillErrorId] = useState(undefined) + const [now, setNow] = useState(() => Date.now()) + + const liveCount = useMemo( + () => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0), + [rows], + ) + const multiOwner = useMemo( + () => new Set(rows.map(row => row.ownerSessionId)).size > 1, + [rows], + ) + + // The kill button stays armed only briefly; a stray click must never kill. + useEffect(() => { + if (armedId === undefined) return + const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS) + return () => { window.clearTimeout(timer) } + }, [armedId]) + + useEffect(() => { + if (liveCount === 0) return + setNow(Date.now()) + const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { window.clearInterval(timer) } + }, [liveCount]) + + const kill = useCallback(async (row: TreeJob): Promise => { + setKillingId(row.job.id) + setKillErrorId(undefined) + try { + await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id) + } catch { + setKillErrorId(row.job.id) + } finally { + setKillingId(undefined) + setArmedId(undefined) + } + }, []) + + if (rows.length === 0) return null + + const countLabel = liveCount > 0 + ? t('jobsCountRunning', { count: rows.length, running: liveCount }) + : t('jobsCount', { count: rows.length }) + + return ( +
+ + {open && ( +
+ {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] : []), + jobStatusLabel(job.status, t), + ...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []), + formatJobDuration(elapsed, t), + ].filter(Boolean).join(' · ') + return ( +
+ + {job.status === 'running' && ( + + )} + {killFailed && {t('jobKillError')}} +
+ ) + })} +
+ )} +
+ ) +} + +/** + * The output popover content of one job: the text the MODEL has read so far + * (replayed from the owner session's event log), refreshed every + * {@link JOB_POLL_MS} while the job runs and the page is visible, pinned to + * the newest output like a terminal tail while live. + */ +export function JobOutputPopoverContent(props: { + ownerSessionId: string + job: SidebarJobView + active: boolean +}): ReactNode { + const { ownerSessionId, job, active } = props + const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading') + const controllerRef = useRef(undefined) + const preRef = useRef(null) + + const load = useCallback(async (): Promise => { + controllerRef.current?.abort() + const controller = new AbortController() + controllerRef.current = controller + try { + const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal) + setState(result) + } catch { + // A newer pull aborted this one, or the wire failed: keep the last + // known output; only a popover that never loaded anything shows an error. + setState(current => (current === 'loading' ? 'error' : current)) + } + }, [ownerSessionId, job.id]) + + useEffect(() => { + void load() + if (!active || !isJobLive(job)) return + const timer = window.setInterval(() => { void load() }, JOB_POLL_MS) + return () => { window.clearInterval(timer) } + // isJobLive reads only job.status; whole-job identity churns on every + // catalog refresh and must not restart the poll interval. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [load, active, job.status]) + + useEffect(() => () => { controllerRef.current?.abort() }, []) + + // Terminal-tail behavior: while the job runs, each refresh pins the view + // to the newest output; a settled popover leaves scrolling to the reader. + useEffect(() => { + if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return + const pre = preRef.current + if (pre !== null) pre.scrollTop = pre.scrollHeight + // Same as the poll effect above: only the status transition matters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state, job.status]) + + return ( +
+ + {job.label} + + + {jobStatusLabel(job.status, t)} + {job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''} + + {state === 'loading' &&
{t('loading')}
} + {state === 'error' &&
{t('jobOutputError')}
} + {typeof state === 'object' && ( + <> + {state.text.length > 0 + ?
{state.text}
+ : state.read + ?
{t('jobNoOutput')}
+ :
{t('jobNotReadYet')}
} + {state.truncated &&
{t('jobOutputTruncated')}
} + + )} +
+ ) +} diff --git a/src/client/SubagentView.module.css b/src/client/SubagentView.module.css index 14ddd9b..ba2a09a 100644 --- a/src/client/SubagentView.module.css +++ b/src/client/SubagentView.module.css @@ -1,6 +1,7 @@ -/* Subagent page: main-agent topology tree. Row/card chrome mirrors the - official ui-subagent catalog row (min-height 50, 7/8/11 padding, radius 8, - flex-start alignment, tree connector lines via border-left hairlines). */ +/* Tasks page shell: header row, empty/error states, and the state dot. The + graph canvas / tree rows / popovers / jobs drawer live in + tasks-graph.module.css; the old indentation-tree and jobs-dock classes + were pruned when the page moved to the unified model. */ .subagent { flex: 1; min-height: 0; @@ -52,33 +53,6 @@ background: var(--dsw-alias-interactive-bg-hover); } -.subagentBody { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 2px 6px 8px; -} - -/* One node card: state dot + label + secondary + (running) live lines. */ -.subagentRow { - position: relative; - display: flex; - align-items: flex-start; - gap: 8px; - box-sizing: border-box; - width: 100%; - min-height: 50px; - padding: 7px 8px 7px 11px; - border: none; - border-radius: 8px; - background: transparent; - font: var(--dsw-font-s-14); - color: var(--dsw-alias-label-primary); - text-align: left; - cursor: pointer; - outline: none; -} - .subagentRow:hover { background: var(--dsw-alias-interactive-bg-hover); } @@ -97,102 +71,24 @@ background: var(--dsw-alias-interactive-bg-active); } -.subagentRowDisabled { - color: var(--dsw-alias-label-dimmed); - cursor: not-allowed; -} - .subagentRowDisabled:hover { background: transparent; } /* Loading placeholders are disabled but keep the default cursor (official `.loadingRow` distinction from `.disabled`). */ -.subagentRowLoading { - cursor: default; -} /* The state dot aligns to the first text line (official catalog detail). */ .subagentDot { margin-top: 4px; } -.subagentContent { - display: flex; - flex: 1; - flex-direction: column; - min-width: 0; - gap: 2px; -} - .subagentLabel, -.subagentSecondary { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.subagentLabel { - color: inherit; - font-weight: 400; -} - -.subagentSecondary { - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-label-tertiary); -} /* ── Live lines (running cards only) ──────────────────────────────────── */ -.subagentLive { - display: flex; - align-items: baseline; - gap: 4px; - min-width: 0; - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-label-tertiary); - overflow: hidden; -} - -.subagentLiveTool { - flex: none; - font: var(--dsw-font-xxxs-strong-11); - color: var(--dsw-alias-label-secondary); -} - -.subagentLiveArgs { - min-width: 0; - font-family: var(--ds-font-family-code); - font-size: var(--dsw-font-xxxs-11-font-size); - line-height: var(--dsw-font-xxxs-11-line-height); - color: var(--dsw-alias-label-tertiary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.subagentLiveText { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-label-secondary); -} - /* ── Tree connector lines (official catalog recipe) ────────────────────── */ -.subagentNode { - position: relative; - min-width: 0; -} - -.subagentChildren { - position: relative; - margin-left: 18px; - padding-left: 4px; -} - .subagentChildren::before { content: ''; position: absolute; @@ -276,157 +172,25 @@ /* ── Background-job section (below the topology tree) ────────────────── */ -.jobs { - margin-top: 10px; - border-top: 1px solid var(--dsw-alias-border-l2); - padding-top: 8px; -} - -.jobsHeader { - display: flex; - align-items: center; - gap: 8px; - height: 26px; - padding: 0 2px; -} - -.jobsTitle { - flex: 1; - min-width: 0; - font: var(--dsw-font-xxxs-strong-11); - color: var(--dsw-alias-label-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.jobsCount { - flex: none; - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-label-tertiary); -} - -.jobsList { - display: flex; - flex-direction: column; - gap: 2px; - margin: 0; - padding: 0; - list-style: none; -} - /* One job row; the whole row lights up on hover, the expandable target stays transparent (the kill button rides on the row's right edge). */ -.jobsRow { - display: flex; - align-items: center; - gap: 4px; - border-radius: 8px; -} .jobsRow:hover { background: var(--dsw-alias-interactive-bg-hover); } -.jobsRowSettled { - opacity: 0.8; -} - /* The row whose output is shown in the bottom dock. */ .jobsRowSelected, .jobsRowSelected:hover { background: var(--dsw-alias-interactive-bg-active); } -.jobsRowMain { - flex: 1; - display: flex; - align-items: flex-start; - gap: 8px; - min-width: 0; - padding: 6px 8px 6px 11px; - border: none; - border-radius: 8px; - background: transparent; - font: var(--dsw-font-s-14); - color: var(--dsw-alias-label-primary); - text-align: left; - cursor: pointer; - outline: none; -} - .jobsRowMain:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } -.jobsDot { - margin-top: 5px; -} - -.jobsContent { - display: flex; - flex-direction: column; - min-width: 0; - gap: 1px; -} - /* The command line: a kind badge followed by the command itself in the code face, like the terminal's live lines. */ -.jobsLabelLine { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.jobsKind { - flex: none; - max-width: 90px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding: 0 5px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 4px; - font: var(--dsw-font-xxxs-strong-11); - line-height: 14px; - color: var(--dsw-alias-label-tertiary); -} - -.jobsLabel { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--ds-font-family-code); - font-size: var(--dsw-font-xxxs-11-font-size); - line-height: var(--dsw-font-xxxs-11-line-height); - color: var(--dsw-alias-label-primary); -} - -.jobsSecondary { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-label-tertiary); -} - -.jobsKill { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - margin-right: 4px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--dsw-alias-label-secondary); - cursor: pointer; -} .jobsKill:hover { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); @@ -450,98 +214,13 @@ cursor: default; } -.jobsKillError { - flex: none; - margin-right: 4px; - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-state-error-primary); -} - /* ── The shared output dock (bottom of the page, sticky like a terminal) ── */ /* Direct child of the scrolling body, so it docks at the scrollport's bottom edge while the job list scrolls above it; a solid background keeps the tree from showing through. */ -.jobsPane { - position: sticky; - bottom: 0; - z-index: 1; - margin-top: 4px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; - background: var(--dsw-alias-bg-base); - box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35); - overflow: hidden; -} - -.jobsPaneHeader { - display: flex; - align-items: center; - gap: 6px; - height: 28px; - padding: 0 4px 0 10px; - border-bottom: 1px solid var(--dsw-alias-border-l1); -} - -.jobsPaneDot { - flex: none; -} - -.jobsPaneLabel { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--ds-font-family-code); - font-size: var(--dsw-font-xxxs-11-font-size); - line-height: var(--dsw-font-xxxs-11-line-height); - color: var(--dsw-alias-label-primary); -} - -.jobsPaneStatus { - flex: none; - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-label-tertiary); -} - -.jobsPaneClose { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border: none; - border-radius: 5px; - background: transparent; - color: var(--dsw-alias-label-secondary); - cursor: pointer; -} .jobsPaneClose:hover { background: var(--dsw-alias-interactive-bg-hover); } -.jobsPanePre { - margin: 0; - max-height: 200px; - overflow: auto; - padding: 6px 10px; - font-family: var(--ds-font-family-code); - font-size: var(--dsw-font-xxxs-11-font-size); - line-height: 1.5; - color: var(--dsw-alias-label-primary); - white-space: pre-wrap; - word-break: break-word; -} - -.jobsPaneHint { - padding: 8px 10px; - font: var(--dsw-font-xxxs-11); - color: var(--dsw-alias-label-tertiary); -} - -.jobsPaneError { - color: var(--dsw-alias-state-error-primary); -} diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index a92092a..e10c68f 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -1,215 +1,67 @@ /** - * Subagent page: the FULL agent topology of the current tree's main session. + * Tasks page (task management): the FULL agent topology of the current + * tree's main session, rendered in the user-approved postmodern style as a + * workflow GRAPH canvas (default) or the classic indentation TREE — one + * unified model (tasks-model.ts) feeds both, so the fold state and the + * bottom-right view toggle never diverge. * - * The root is resolved by walking the durable parent chain upward from the - * current session to the first non-subagent session — the MAIN session — and - * every subagent under it shares this one topology view, no matter how deep - * the current selection is (including a subagent transcript opened in the - * main view). The main agent renders as the root node card (click it to jump - * back to the main session), with its subagents hanging below it in clearly - * LAYERED levels: tree connector lines (first level included) and per-level - * indentation show the hierarchy, and the currently-open session is - * highlighted in place. Every branch is expanded automatically. + * Beyond the topology this page now folds in: + * - WORKFLOW RUNS: the host folds `tool-workflow/*` session events of the + * whole tree (workflows.list); a run hangs under its origin agent with its + * member agents re-parented below it and phase frames behind them; + * - AGENT TEAMS (experimental host layer): when the root leads a team, the + * roster enriches matching nodes and a header chip opens the shared task + * board popover (CAS operations); without the layer the UI hides itself + * entirely; + * - BACKGROUND JOBS: a bottom drawer replaces the old in-page section and + * auto-collapses once the tree has many agents; job output opens as an + * anchored popover (event replay — never the model's cursor). * - * The child rows come from DSH 0.1.7's host-computed `subagentCatalog` - * projection (folded per parent by ./subagent-catalog.ts), which the client - * loads for every session with the connection — there is no per-parent - * observe/refresh handshake to run any more. What a row no longer carries is - * derived: a branch stays open until the child's own catalog is known-empty, - * and live status comes from the `subagents.live` batch channel. - * - * Each node card carries live status (state dot, durable label, mode and - * activity); while a child RUNS, its card additionally shows the LAST text - * output and LAST tool call pulled from its history tail, auto-refreshing - * every few seconds while the page is visible. Clicking a card jumps - * straight into the child transcript (`openChild`); the page stays open - * and the topology remains rooted at the main session. - * - * The background-jobs section below the tree is fed by the plugin's - * `jobs.list` route, one polled read per tree session (the registry's fence - * admits a job only to its owner, and 0.1.7 stopped mirroring jobs into the - * client snapshot). + * Node click jumps straight into the transcript (root → main session); the + * ⓘ 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). */ -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useSyncExternalStore } from 'react' -import clsx from 'clsx' import { - IconRefreshOutlineRegular, StateDot, + IconRefreshOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, - SidebarSessionList, - SidebarSessionSummary, SidebarSubagentAddress, - SidebarSubagentCatalogEntry, - SidebarJobView, } from '../context-types.ts' import { + collectBranchIds, countSubagentDescendants, isSideThreadSummary, rootAncestor, } from './subagent-detect.ts' -import { - childActivity, - isKnownLeaf, - subagentCatalogs, - type SubagentCatalogView, -} from './subagent-catalog.ts' import { type LastActivity } from '../subagent-activity.ts' -import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' -import { - collectTreeJobs, - formatJobDuration, - isJobLive, - orderJobs, - jobDotState, - jobStatusLabel, - treeSessionIds, - type TreeJob, -} from './subagent-jobs.ts' -import { api, type JobOutputResult } from './api.ts' +import { collectTreeJobs, orderJobs, type TreeJob } from './subagent-jobs.ts' +import { api, type TeamsViewResult } from './api.ts' import { usePolling } from './use-polling.ts' -import { IconStopOutline16 } from './icons.tsx' import { t } from './locales.ts' -import css from './SubagentView.module.css' +import { buildTasksModel, type TasksAgentNode, type TasksWorkflowNode } from './tasks-model.ts' +import { TasksGraph } from './TasksGraph.tsx' +import { TasksTree } from './TasksTree.tsx' +import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx' +import { AnchoredPopover } from './AnchoredPopover.tsx' +import { AgentNodePopover, TeamBoardPopover, WorkflowNodePopover } from './TasksPopovers.tsx' +import type { SidebarStore } from './state.ts' +import type { WorkflowRunView } from '../workflow-runs.ts' +import legacy from './SubagentView.module.css' +import css from './tasks-graph.module.css' /** Refresh cadence of the live "last text + tool call" lines while a child runs. */ const POLL_MS = 3000 -/** Refresh cadence of the tree's background-job lists (one read per tree session). */ -const JOBS_POLL_MS = 3000 -/** Preview cap of one tool-call argument line. */ -const ARGS_PREVIEW = 60 -/** Refresh cadence of an expanded job-output panel while its job runs. */ -const JOB_POLL_MS = 2000 -/** How long the kill button stays armed before it needs re-confirming. */ -const JOB_KILL_ARM_MS = 3000 - -/** The direct subagent children of one parent (durable `origin` rows; - * Side Chat threads ride the same origin but are tab-strip conversations, - * never topology). */ -function directChildren( - byId: Readonly>, - parentSessionId: string, -): SidebarSessionSummary[] { - return Object.values(byId).filter( - summary => summary.origin === 'subagent' && summary.parentId === parentSessionId - && !isSideThreadSummary(summary), - ) -} - -/** Human label of one catalog child: durable label, then summary title, then id. */ -function childLabel( - entry: SidebarSubagentCatalogEntry, - summary: SidebarSessionSummary | undefined, -): string { - return entry.label ?? summary?.displayTitle ?? entry.id -} +/** Poll cadence of the workflow-run and team views while the page is visible. */ +const TELEMETRY_POLL_MS = 5000 /** - * The mode word of one catalog row. DSH 0.1.7 added `unknown` (a child the - * host's catalog fold kept without a readable descriptor): it claims NEITHER - * mode, so the segment is omitted rather than mislabelled — printing - * "Continuable" would be a lie, and the shipped dictionaries have no key for - * it yet (adding one would have to touch every locale chunk). - */ -function modeLabel(mode: SidebarSubagentCatalogEntry['mode']): string | undefined { - switch (mode) { - case 'one-shot': return t('subagentModeOneShot') - case 'continuable': return t('subagentModeContinuable') - case 'unknown': return undefined - } -} - -/** The secondary line of one card: title · mode · activity (skips empty parts). */ -function cardSecondary( - summary: SidebarSessionSummary | undefined, - entry: SidebarSubagentCatalogEntry, - activity: 'running' | 'inactive', -): string { - return [ - summary?.displayTitle, - modeLabel(entry.mode), - activity === 'running' ? t('subagentRunning') : t('subagentInactive'), - ].filter(Boolean).join(' · ') -} - -/** First `limit` characters with an ellipsis when truncated. */ -function preview(text: string, limit: number): string { - return text.length > limit ? `${text.slice(0, limit)}…` : text -} - -/** Collapse whitespace for the single-paragraph live-text preview. */ -function flatten(text: string): string { - return text.replace(/\s+/g, ' ').trim() -} - -/** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */ -function CatalogLoadingRows(props: { - parentSessionId: string - byId: Readonly> - level: number -}) { - const { parentSessionId, byId, level } = props - const children = directChildren(byId, parentSessionId) - if (children.length === 0) { - return
{t('loading')}
- } - return ( - <> - {children.map(summary => ( -
- - - {t('loading')} - -
- ))} - - ) -} - -/** - * The live lines of one RUNNING subagent card: a pure presentation of the - * batch `subagents.live` activity. The polling lives in one place (the - * SubagentView hook), not per card. A running child with neither output yet - * reads "thinking…". - */ -function SubagentLiveLines(props: { live: LastActivity | undefined }) { - const { live } = props - if (live?.text === undefined && live?.tool === undefined) { - return {t('subagentThinking')} - } - return ( - <> - {live.tool !== undefined && ( - - {live.tool.name} - {live.tool.args !== '' && ( - {preview(live.tool.args, ARGS_PREVIEW)} - )} - - )} - {live.text !== undefined && ( - {flatten(live.text)} - )} - - ) -} - -/** - * One shared live-preview poller for the whole Subagent tree. Unlike the old - * per-card `subagents.history` timers, this sends at most ONE `subagents.live` - * request at a time (the shared poller's self-scheduling mode arms the next - * tick only after the previous request settles, so a slow host never sees - * abort/restart storms); a response settling after the poller stopped (page - * hidden, tree re-rooted) is dropped via the aborted signal. + * One shared live-preview poller for the whole tree. At most ONE + * `subagents.live` request in flight (self-scheduling); a response settling + * after the poller stopped is dropped via the aborted signal. */ function useSubagentLive( rootId: string | undefined, @@ -234,661 +86,404 @@ function useSubagentLive( return live } -interface RowsProps { - parentSessionId: string - catalog: SubagentCatalogView | undefined - catalogs: Readonly> - byId: Readonly> - level: number - /** The currently-open session id (highlighted in the topology). */ - currentSessionId: string - /** The batch live-preview map (child id → latest activity). */ - live: Readonly> - openChild: (address: SidebarSubagentAddress) => void - refresh: (parentSessionId: string) => void -} - -/** Render one topology level; branches are always expanded. */ -function CatalogRows({ - parentSessionId, catalog, catalogs, byId, level, currentSessionId, live, - openChild, refresh, -}: RowsProps) { - const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0 - // Side Chat threads are honest catalog citizens (durable descriptor, 'Side: ' - // label) but they are NOT subagent topology — filter them out here (the tab - // strip owns them). The label lives on the entry for a continuable child and - // on the summary for a one-shot whose descriptor carried none, so both are - // checked. - const visibleEntries = (catalog?.entries ?? []).filter(entry => - !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false) - && !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)) - return ( - <> - {emptyLoading && ( - - )} - {catalog?.state === 'error' && ( -
- {catalog.error?.message ?? t('error')} - -
- )} - {visibleEntries.map((entry) => { - const childCatalog = catalogs[entry.id] - // A branch stays open until the child's OWN catalog loaded empty; a - // catalog that is missing, still loading, or failed keeps its level so - // the rows appear the moment the projection lands. - const knownLeaf = isKnownLeaf(catalogs, entry.id) - const summary = byId[entry.id] - const label = childLabel(entry, summary) - const activity = childActivity(live, entry.id) - const secondary = cardSecondary(summary, entry, activity) - const childLoading = childCatalog === undefined - || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) - const address: SidebarSubagentAddress = { - parentSessionId, - childSessionId: entry.id, - // DSH 0.1.7 widened the runtime address to `mode: 'unknown'` (the - // row's own mode). This plugin's structural mirror of the address - // still spells the 0.1.6 pair — the cast is that pending widening, - // not a claim about a shape the host rejects. - mode: entry.mode as SidebarSubagentAddress['mode'], - } - const current = entry.id === currentSessionId - - return ( -
-
{ openChild(address) }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - event.stopPropagation() - openChild(address) - } - }} - > - - - {label} - {secondary} - {activity === 'running' && ( - - )} - -
- {!knownLeaf && ( -
- {childCatalog === undefined - ? ( - - ) - : ( - - )} -
- )} -
- ) - })} - - ) -} - -/** - * The shared output dock of the jobs section: ONE pane at the bottom of the - * sidebar body (sticky, terminal-like) shows the SELECTED job's output as - * the MODEL has read it so far (replayed from the owner session's event - * log), refreshed every {@link JOB_POLL_MS} while the job runs and the - * page is visible. The model's `job_output` cursor is never touched — the - * pane can never steal the agent's bytes, and it stays empty until the - * agent reads the job. A single dock — not a panel per row — keeps the - * job list compact and stable when many jobs are running. - */ -function JobOutputPane(props: { - ownerSessionId: string - job: SidebarJobView - /** The page is visible (active tab + open panel): skip polling otherwise. */ - active: boolean - onClose: () => void -}) { - const { ownerSessionId, job, active, onClose } = props - const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading') - const controllerRef = useRef(undefined) - const preRef = useRef(null) - - const load = useCallback(async (): Promise => { - controllerRef.current?.abort() - const controller = new AbortController() - controllerRef.current = controller - try { - const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal) - setState(result) - } catch { - // A newer pull aborted this one, or the wire failed: keep the last - // known output; only a dock that never loaded anything shows an error. - setState(current => (current === 'loading' ? 'error' : current)) - } - }, [ownerSessionId, job.id]) - - useEffect(() => { - void load() - if (!active || !isJobLive(job)) return - const timer = window.setInterval(() => { void load() }, JOB_POLL_MS) - return () => { window.clearInterval(timer) } - // isJobLive reads only job.status; whole-job identity churns on every - // catalog refresh and must not restart the poll interval. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [load, active, job.status]) - - useEffect(() => () => { controllerRef.current?.abort() }, []) - - // Terminal-tail behavior: while the job runs, each refresh pins the view - // to the newest output; a settled dock leaves scrolling to the reader. - useEffect(() => { - if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return - const pre = preRef.current - if (pre !== null) pre.scrollTop = pre.scrollHeight - // Same as the poll effect above: only the status transition matters. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state, job.status]) - - return ( -
-
- - {job.label} - - {jobStatusLabel(job.status, t)} - {job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''} - - -
- {state === 'loading' &&
{t('loading')}
} - {state === 'error' && ( -
{t('jobOutputError')}
- )} - {typeof state === 'object' && ( - <> - {state.text.length > 0 - ?
{state.text}
- : state.read - ?
{t('jobNoOutput')}
- :
{t('jobNotReadYet')}
} - {state.truncated &&
{t('jobOutputTruncated')}
} - - )} -
- ) -} - -/** - * The background-job section of the Subagent page: every job of the whole - * current tree (main agent + subagents, owner-labeled), read through the - * plugin's `jobs.list` route while the page is visible. Clicking a row feeds - * its model-read output to the shared bottom dock (event replay — never the - * model's cursor); live rows carry a two-click-confirm kill button. Renders - * nothing while the tree has no jobs. - */ -function JobsSection(props: { - byId: SidebarSessionList['byId'] - rootId: string | undefined - /** The page is visible (active tab + open panel): skip polling otherwise. */ - active: boolean -}) { - const { byId, rootId, active } = props - // The registry's access fence admits a job to its OWNER session only, so the - // tree's jobs are one read per tree session — fanned out on each tick and - // keyed by owner for the (pure) collection below. - const treeIds = useMemo(() => [...treeSessionIds(byId, rootId)], [byId, rootId]) - const [jobsBySession, setJobsBySession] = useState>>({}) +/** The folded workflow runs of the tree (poll-driven; empty when none). */ +function useWorkflowRuns(rootId: string | undefined, active: boolean): readonly WorkflowRunView[] { + const [runs, setRuns] = useState([]) + useEffect(() => { setRuns([]) }, [rootId]) const poll = useCallback(async (signal: AbortSignal): Promise => { - const entries = await Promise.all(treeIds.map(async (sessionId): Promise<[string, readonly SidebarJobView[]]> => { - try { - const result = await api.jobsList(sessionId, signal) - return [sessionId, result.jobs] - } catch { - // A host without the jobs service (503) or one dropped read degrades - // to an empty set for THIS session; the next tick retries. - return [sessionId, []] - } - })) - if (!signal.aborted) setJobsBySession(Object.fromEntries(entries)) - }, [treeIds]) - usePolling(active && treeIds.length > 0, poll, { - intervalMs: JOBS_POLL_MS, + if (rootId === undefined) return + const result = await api.workflowsList(rootId, signal) + if (!signal.aborted) setRuns(result.runs) + }, [rootId]) + usePolling(rootId !== undefined && active, poll, { + intervalMs: TELEMETRY_POLL_MS, mode: 'self-scheduling', immediate: true, }) - const rows = useMemo( - () => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)), - [byId, jobsBySession, rootId], - ) - const [selectedId, setSelectedId] = useState(undefined) - const [armedId, setArmedId] = useState(undefined) - const [killingId, setKillingId] = useState(undefined) - const [killErrorId, setKillErrorId] = useState(undefined) - // The duration clock only runs while a live row is on screen. - const [now, setNow] = useState(() => Date.now()) - - const selectedRow = useMemo( - () => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)), - [rows, selectedId], - ) - - const liveCount = useMemo( - () => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0), - [rows], - ) - const multiOwner = useMemo( - () => new Set(rows.map(row => row.ownerSessionId)).size > 1, - [rows], - ) - - // The kill button stays armed only briefly; a stray click must never kill. - useEffect(() => { - if (armedId === undefined) return - const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS) - return () => { window.clearTimeout(timer) } - }, [armedId]) - - useEffect(() => { - if (liveCount === 0) return - setNow(Date.now()) - const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000) - return () => { window.clearInterval(timer) } - }, [liveCount]) - - // The docked output pane follows its job: when the selected job leaves - // the mirror (settled and dropped, or the tree switched), close the dock. - useEffect(() => { - if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined) - }, [selectedId, selectedRow]) - - // NOTE: every hook must live ABOVE the empty-state return — a hook below it - // would flip this component's hook count when the mirror empties and crash - // React with "Rendered fewer hooks than expected" (the #300 regression). - const kill = useCallback(async (row: TreeJob): Promise => { - setKillingId(row.job.id) - setKillErrorId(undefined) - try { - await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id) - } catch { - setKillErrorId(row.job.id) - } finally { - setKillingId(undefined) - setArmedId(undefined) - } - }, []) - - if (rows.length === 0) return null - - const countLabel = liveCount > 0 - ? t('jobsCountRunning', { count: rows.length, running: liveCount }) - : t('jobsCount', { count: rows.length }) - - return ( - <> -
-
- {t('jobs')} - {countLabel} -
-
    - {rows.map((row) => { - const { job } = row - const live = isJobLive(job) - const selected = selectedId === job.id - 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] : []), - jobStatusLabel(job.status, t), - ...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []), - formatJobDuration(elapsed, t), - ].filter(Boolean).join(' · ') - return ( -
  • - - {job.status === 'running' && ( - - )} - {killFailed && {t('jobKillError')}} -
  • - ) - })} -
-
- {selectedRow !== undefined && ( - { setSelectedId(undefined) }} - /> - )} - - ) + return runs } +/** The team view of the tree's root (poll-driven, structurally degraded). */ +function useTeamView( + rootId: string | undefined, + active: boolean, +): { view: TeamsViewResult | undefined; refresh(): void } { + const [view, setView] = useState(undefined) + const [epoch, setEpoch] = useState(0) + useEffect(() => { setView(undefined) }, [rootId]) + const poll = useCallback(async (signal: AbortSignal): Promise => { + if (rootId === undefined) return + const result = await api.teamsView(rootId, signal) + if (!signal.aborted) setView(result) + }, [rootId, epoch]) + usePolling(rootId !== undefined && active, poll, { + intervalMs: TELEMETRY_POLL_MS, + mode: 'self-scheduling', + immediate: true, + }) + const refresh = useCallback((): void => { setEpoch(current => current + 1) }, []) + return { view, refresh } +} + +/** The open popover of the page (one at a time, anchored). */ +type PagePopover = + | { kind: 'node'; nodeId: string; anchor: HTMLElement } + | { kind: 'workflow'; nodeId: string; anchor: HTMLElement } + | { kind: 'job'; jobId: string; anchor: HTMLElement } + | { kind: 'team'; anchor: HTMLElement } + /** - * The client's own "show this conversation" verb. DSH 0.1.6 moved child and - * session navigation OFF `ISessions` (0.1.5's `open` / `openSubagent`, neither - * of which exists any more — the optional calls this page used were silently - * dead) onto the workspace face, which owns the main-view selection. The - * pre-0.1.6 faces stay as the fallback for a host that predates the move. - */ -interface ConversationNavigation { - openSession?(target: SidebarSubagentAddress | string): void -} - -/** The per-parent catalog refresh across the two DSH generations. */ -interface CatalogRefreshFace { - refreshProjections?(sessionId: string): Promise - refreshSubagents?(sessionId: string): Promise -} - -/** - * The sidebar's Subagent topology page. - * @param props - current session id, whether the page is actually visible - * (active tab + open panel), the client context, and an optional - * jump-notify hook fired right before the child is opened (lets the sidebar - * shell re-open the Subagent page after the conversation switch lands on - * the child session). - * @returns the main agent's topology tree, or the empty/error/loading states. + * The sidebar's Tasks page. + * @param props - current session id, visibility, the client context, the + * shared store (the prefs drive the default view mode), and the optional + * jump-notify hook fired right before `openSubagent`. */ export function SubagentView(props: { sessionId: string active: boolean ctx: Context + store?: SidebarStore onOpenChild?: (address: SidebarSubagentAddress) => void -}) { - const { sessionId, active, ctx, onOpenChild } = props +}): ReactNode { + const { sessionId, active, ctx, store, onOpenChild } = props const sessions = ctx.sessions - // The same list feed the official catalog consumes (`byId` lineage + the - // host-computed projection values). A snapshot without the projection seam - // (a pre-0.1.7 runtime) leaves every catalog absent, so the topology shows - // the root card alone — no rows, and no empty-state copy either. + // The same list feed the official catalog consumes (byId lineage + the + // lazy per-parent catalogs). Older DSH snapshots without the subagent seam + // simply leave these surfaces empty — the page degrades to the empty state. const list = useSyncExternalStore( useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]), useCallback(() => sessions.list.getSnapshot(), [sessions]), ) const byId = list.byId - // One fold per snapshot: the views are what every row, the branch test and - // the loading/error states read, and a fresh object per render would - // invalidate each of their memos. - const catalogs = useMemo(() => subagentCatalogs(list.projectionsBySession), [list.projectionsBySession]) + // Memoized so the empty-catalog fallback keeps a stable identity — a fresh + // `{}` per render would invalidate every catalog-dependent memo/effect. + const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent]) // The topology root: the main agent of the current session's tree. const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId]) - const rootCatalog = rootId === undefined ? undefined : catalogs[rootId] const rootSummary = rootId === undefined ? undefined : byId[rootId] const live = useSubagentLive(rootId, active) + const runs = useWorkflowRuns(rootId, active) + const team = useTeamView(rootId, active) + const teamView = team.view + const teamMembers = useMemo( + () => (teamView?.available === true ? teamView.team?.members ?? [] : []), + [teamView], + ) - const openConversation = useCallback((target: SidebarSubagentAddress | string): void => { - const workspace = ctx.get('uiWorkspace') as unknown as ConversationNavigation | undefined - if (typeof workspace?.openSession === 'function') { - workspace.openSession(target) - return + // The default view mode comes from the side card prefs (settings select); + // the in-page toggle overrides it ephemerally. + const prefsMode = useSyncExternalStore( + useMemo(() => (callback: () => void) => store?.subscribe(callback) ?? (() => {}), [store]), + useCallback(() => store?.getPrefs().tasksViewMode ?? 'graph', [store]), + ) + const [modeOverride, setModeOverride] = useState<'graph' | 'tree' | undefined>(undefined) + const mode = modeOverride ?? prefsMode + + const [folded, setFolded] = useState(true) + const [popover, setPopover] = useState(null) + + const model = useMemo( + () => (rootId === undefined + ? [] + : buildTasksModel({ + byId, + catalogs, + rootId, + currentSessionId: sessionId, + live, + runs, + teamMembers, + folded, + })), + [byId, catalogs, rootId, sessionId, live, runs, teamMembers, folded], + ) + + /** Catalog owners currently consuming live membership updates. */ + const observedRef = useRef(new Set()) + + const observe = useCallback((parentSessionId: string, open: boolean): void => { + sessions.setSubagentCatalogOpen?.(parentSessionId, open) + if (open) observedRef.current.add(parentSessionId) + else observedRef.current.delete(parentSessionId) + }, [sessions]) + + // While the page is visible the topology root consumes live membership; a + // root change (switching to another main agent's tree) or the page hiding + // (tab switched away / panel collapsed) releases everything observed. + useEffect(() => { + if (rootId === undefined || !active) return + observe(rootId, true) + return () => { + // The cleanup must release everything observed AT cleanup time (the set + // mutates as branches open), so reading the ref here is the point. + // eslint-disable-next-line react-hooks/exhaustive-deps + for (const parentSessionId of observedRef.current) { + sessions.setSubagentCatalogOpen?.(parentSessionId, false) + } + observedRef.current.clear() } - if (typeof target === 'string') sessions.open?.(target) - else sessions.openSubagent?.(target) - }, [ctx, sessions]) + }, [rootId, active, observe, sessions]) + + // Every branch of the always-expanded topology consumes live membership. + const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId]) + useEffect(() => { + if (!active) return + for (const id of branches) { + if (!observedRef.current.has(id)) observe(id, true) + } + }, [branches, active, observe]) + + // Unobserve everything on unmount (the host stops refreshing unused catalogs). + useEffect(() => () => { + for (const parentSessionId of observedRef.current) { + sessions.setSubagentCatalogOpen?.(parentSessionId, false) + } + observedRef.current.clear() + }, [sessions]) const openChild = useCallback((address: SidebarSubagentAddress): void => { // Notify the shell first: the jump switches the sidebar to the child - // session's own layout, and the shell re-opens the Subagent page on top + // session's own layout, and the shell re-opens the Tasks page on top // of it (the topology stays rooted at the main agent with the child // highlighted) — the README "page stays open" contract. onOpenChild?.(address) try { - openConversation(address) + sessions.openSubagent?.(address) } catch (error) { - console.error('[dsh-better-sidebar] open subagent failed:', error) + console.error('[dsh-better-sidebar] openSubagent failed:', error) } - }, [openConversation, onOpenChild]) + }, [sessions, onOpenChild]) /** Jump back to the main agent (the topology root) from its node. */ const openMain = useCallback((): void => { if (rootId === undefined) return try { - openConversation(rootId) + sessions.open?.(rootId) } catch (error) { console.error('[dsh-better-sidebar] open session failed:', error) } - }, [openConversation, rootId]) + }, [sessions, rootId]) const refresh = useCallback((parentSessionId: string): void => { - // 0.1.7 renamed the per-parent catalog refresh to `refreshProjections` - // (the projection store owns every session's values now); calling the - // removed 0.1.6 name alone would make this button a silent no-op. - const face = sessions as CatalogRefreshFace - void (face.refreshProjections ?? face.refreshSubagents)?.(parentSessionId) + void sessions.refreshSubagents?.(parentSessionId) }, [sessions]) + /** Node click = jump to the transcript (synthesized members without a + * session address open their detail popover instead). */ + const activateNode = useCallback((node: TasksAgentNode): void => { + if (node.childAddress !== undefined) { + openChild(node.childAddress) + setPopover(null) + return + } + if (node.parentId === undefined) { + openMain() + setPopover(null) + } + // Synthesized workflow members without a childId have no transcript to + // jump to: their ⓘ popover is the only detail surface. + }, [openChild, openMain]) + const totals = useMemo( () => rootId === undefined ? { count: 0, runningCount: 0 } : countSubagentDescendants(byId, rootId), [byId, rootId], ) + const agentCount = totals.count + 1 + + /** The tree's ordered job rows (owner-labeled). */ + const jobRows = useMemo( + () => orderJobs(collectTreeJobs(byId, list.jobsBySession, rootId)), + [byId, list.jobsBySession, rootId], + ) + + /** Catalogs that failed to load (surfaced as one banner in both modes). */ + const failedParents = useMemo( + () => Object.entries(catalogs) + .filter(([, catalog]) => catalog?.state === 'error') + .map(([parent]) => parent), + [catalogs], + ) + // Session summaries can announce membership before the descriptor-backed // catalog catches up (or a catalog that just went ready is still empty). const summaryBackedLoading = rootId !== undefined - && (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0)) - && directChildren(byId, rootId).length > 0 - const readyEmpty = rootCatalog?.state === 'ready' - && rootCatalog.entries.length === 0 - && directChildren(byId, rootId ?? '').length === 0 + && (catalogs[rootId] === undefined + || (catalogs[rootId]?.state === 'ready' && catalogs[rootId]?.entries.length === 0)) + && Object.values(byId).some( + summary => summary.origin === 'subagent' && summary.parentId === rootId + && !isSideThreadSummary(summary), + ) + const readyEmpty = rootId !== undefined + && catalogs[rootId]?.state === 'ready' + && catalogs[rootId]?.entries.length === 0 + && runs.length === 0 + && teamMembers.length === 0 + && !Object.values(byId).some( + summary => summary.origin === 'subagent' && summary.parentId === rootId + && !isSideThreadSummary(summary), + ) + const countLabel = totals.count === 0 ? undefined : totals.runningCount > 0 ? t('subagentCountRunning', { count: totals.count, running: totals.runningCount }) : t('subagentCount', { count: totals.count }) - /** Arrow-key tree navigation over the visible rows (official catalog recipe). */ - const bodyRef = useRef(null) - const focusAt = useCallback((index: number): void => { - const items = bodyRef.current?.querySelectorAll( - '[role="treeitem"]:not([aria-disabled="true"])', - ) ?? [] - if (items.length === 0) return - items[(index + items.length) % items.length]?.focus() - }, []) - const onTreeKeyDown = useCallback((event: KeyboardEvent): void => { - const items = bodyRef.current?.querySelectorAll( - '[role="treeitem"]:not([aria-disabled="true"])', - ) ?? [] - const index = Array.prototype.indexOf.call(items, document.activeElement) - if (event.key === 'ArrowDown') { - event.preventDefault() - focusAt(index + 1) - } else if (event.key === 'ArrowUp') { - event.preventDefault() - focusAt(index < 0 ? items.length - 1 : index - 1) - } else if (event.key === 'Home') { - event.preventDefault() - focusAt(0) - } else if (event.key === 'End') { - event.preventDefault() - focusAt(items.length - 1) + const closePopover = useCallback((): void => { setPopover(null) }, []) + + // A popover whose subject left the model (a settled job dropped from the + // mirror, a tree switch) closes itself. + useEffect(() => { + if (popover === null) return + if (popover.kind === 'job' && !jobRows.some(row => row.job.id === popover.jobId)) setPopover(null) + if (popover.kind === 'node' && !model.some(node => node.id === popover.nodeId)) setPopover(null) + if (popover.kind === 'workflow' && !model.some(node => node.id === popover.nodeId)) setPopover(null) + }, [popover, jobRows, model]) + + /** The current popover's resolved content (subjects re-resolve live). */ + const popoverContent = ((): ReactNode => { + if (popover === null) return null + if (popover.kind === 'team' && teamView?.available === true && teamView.team !== null && rootId !== undefined) { + return ( + + ) } - }, [focusAt]) + if (popover.kind === 'job') { + const row = jobRows.find(candidate => candidate.job.id === popover.jobId) + if (row === undefined) return null + return + } + if (popover.kind === 'team') return null + const node = model.find(candidate => candidate.id === popover.nodeId) + if (node === undefined) return null + if (popover.kind === 'workflow' && node.kind === 'workflow') { + return ( + { openChild(address); setPopover(null) }} + /> + ) + } + if (popover.kind === 'node' && node.kind === 'agent') { + return ( + { + if (target.childAddress !== undefined) openChild(target.childAddress) + else openMain() + setPopover(null) + }} + /> + ) + } + return null + })() return ( -
-
- +
+
+ {t('subagent')} {rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== '' ? ` · ${rootSummary.displayTitle}` : ''} - {countLabel !== undefined && {countLabel}} + {countLabel !== undefined && {countLabel}} + {teamView?.available === true && teamView.team !== null && ( + + )}
-
-
- {rootId !== undefined && rootSummary !== undefined && ( -
{ - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - event.stopPropagation() - openMain() - } - }} - > - - - - {rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} - - - {`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`} - - -
- )} - {rootId !== undefined && ( -
- {summaryBackedLoading && ( - - )} - {!summaryBackedLoading && ( - - )} -
- )} - {readyEmpty && ( -
-
{t('subagentEmpty')}
-
{t('subagentEmptyDesc')}
-
- )} + {failedParents.length > 0 && ( +
+ {t('catalogLoadFailed', { count: failedParents.length })} +
- -
+ )} + {readyEmpty && ( +
+
{t('subagentEmpty')}
+
{t('subagentEmptyDesc')}
+
+ )} + {!readyEmpty && rootId !== undefined && ( + mode === 'graph' + ? ( + { setPopover({ kind: 'node', nodeId: node.id, anchor }) }} + onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }} + onToggleFold={() => { setFolded(current => !current) }} + mode={mode} + onModeChange={setModeOverride} + /> + ) + : ( + { setPopover({ kind: 'node', nodeId: node.id, anchor }) }} + onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }} + onToggleFold={() => { setFolded(current => !current) }} + mode={mode} + onModeChange={setModeOverride} + /> + ) + )} + { + setPopover(popover?.kind === 'job' && popover.jobId === row.job.id + ? null + : { kind: 'job', jobId: row.job.id, anchor }) + }} + /> + + {popoverContent} +
) } diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx new file mode 100644 index 0000000..79b318d --- /dev/null +++ b/src/client/TasksGraph.tsx @@ -0,0 +1,451 @@ +/** + * The workflow-graph mode of the Tasks page: the approved Variant D canvas — + * layered agent/workflow nodes over bezier edges, pointer-drag pan, + * wheel-zoom-to-cursor, per-phase dashed frames, fold aggregate nodes, and a + * bottom-right control cluster (view toggle / zoom out / percentage / zoom + * in / fit / fold toggle). The cluster lives OUTSIDE the scroll surface so + * the view toggle stays reachable in both modes (a lesson of the mockup). + * + * The layout comes from the pure `layoutTasksGraph`; this component owns + * only the transform state and DOM wiring. + */ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from 'react' +import clsx from 'clsx' +import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts' +import { tasksEdges } from './tasks-model.ts' +import { GRAPH_NODE_H, GRAPH_NODE_W, layoutTasksGraph, type GraphBox } from './tasks-graph-layout.ts' +import { agentSecondary, foldPreviews, LiveLine, nodeDotState } from './tasks-shared.tsx' +import { t, type CopyKey } from './locales.ts' +import css from './tasks-graph.module.css' + +/** The zoom bounds of the canvas. */ +const ZOOM_MIN = 0.15 +const ZOOM_MAX = 2.5 +/** Drag-vs-click separation: pointer travel below this stays a click. */ +const CLICK_TOLERANCE_PX = 4 + +/** The workflow run status label key. */ +function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey { + switch (status) { + case 'running': return 'workflowRunning' + case 'completed': return 'workflowCompleted' + case 'cancelled': return 'workflowCancelled' + case 'failed': return 'workflowFailed' + } +} + +/** The member tally of a run (`done` = every member with an outcome). */ +function memberTally(run: TasksWorkflowNode['run']): { done: number; total: number } { + let done = 0 + let total = 0 + for (const phase of run.phases) { + for (const member of phase.members) { + total += 1 + if (member.outcome !== undefined) done += 1 + } + } + return { done, total } +} + +/** One phase frame of a run node (the dashed box behind its members). */ +interface PhaseFrame { + key: string + title: string | undefined + x: number + y: number + w: number + h: number +} + +export interface TasksGraphProps { + nodes: readonly TasksNode[] + folded: boolean + /** Re-fit trigger: the topology root id (a tree switch re-centers). */ + rootId: string | undefined + onActivate(node: TasksAgentNode): void + onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void + onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void + onToggleFold(): void + mode: 'graph' | 'tree' + onModeChange(mode: 'graph' | 'tree'): void +} + +/** The shared view-mode toggle button (also used by the tree mode). */ +export function ViewModeToggle(props: { + mode: 'graph' | 'tree' + onModeChange(mode: 'graph' | 'tree'): void +}): ReactNode { + const { mode, onModeChange } = props + const toTree = mode === 'graph' + return ( + + ) +} + +/** The fold toggle button (shared by both modes). */ +export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode { + const { folded, onToggleFold } = props + return ( + + ) +} + +export function TasksGraph(props: TasksGraphProps): ReactNode { + const { nodes, folded, rootId, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange } = props + const containerRef = useRef(null) + const [tf, setTf] = useState({ x: 0, y: 0, k: 1 }) + const [dragging, setDragging] = useState(false) + /** Pointer travel of the in-flight gesture (click suppression). */ + const travelRef = useRef(0) + const tfRef = useRef(tf) + tfRef.current = tf + + const layout = useMemo(() => layoutTasksGraph(nodes), [nodes]) + const edges = useMemo(() => tasksEdges(nodes), [nodes]) + const nodeById = useMemo(() => new Map(nodes.map(node => [node.id, node])), [nodes]) + + /** Fit the whole canvas into the container (initial mount + ⌂ + reroot). */ + const fit = useCallback((): void => { + const container = containerRef.current + if (container === null) return + const cw = container.clientWidth + const ch = container.clientHeight + if (cw === 0 || ch === 0) return + const k = Math.min((cw - 40) / layout.width, (ch - 40) / layout.height, 1) + setTf({ x: (cw - layout.width * k) / 2, y: 12, k }) + }, [layout.width, layout.height]) + + useLayoutEffect(() => { fit() }, [fit, rootId]) + + // Wheel-zoom to cursor (non-passive: the page must not scroll). + useEffect(() => { + const container = containerRef.current + if (container === null) return + const onWheel = (event: WheelEvent): void => { + event.preventDefault() + const rect = container.getBoundingClientRect() + const mx = event.clientX - rect.left + const my = event.clientY - rect.top + const { x, y, k } = tfRef.current + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * 0.0014))) + if (next === k) return + setTf({ + x: mx - (mx - x) * (next / k), + y: my - (my - y) * (next / k), + k: next, + }) + } + container.addEventListener('wheel', onWheel, { passive: false }) + return () => { container.removeEventListener('wheel', onWheel) } + }, []) + + const zoomBy = useCallback((factor: number): void => { + const container = containerRef.current + const { x, y, k } = tfRef.current + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * factor)) + if (next === k || container === null) return + const mx = container.clientWidth / 2 + const my = container.clientHeight / 2 + setTf({ x: mx - (mx - x) * (next / k), y: my - (my - y) * (next / k), k: next }) + }, []) + + // Drag-pan: pointer capture on the container, any start point pans. + const onPointerDown = useCallback((event: ReactPointerEvent): void => { + if (event.button !== 0) return + travelRef.current = 0 + const startX = event.clientX + const startY = event.clientY + const { x, y } = tfRef.current + const target = event.currentTarget + target.setPointerCapture(event.pointerId) + const onMove = (move: globalThis.PointerEvent): void => { + const dx = move.clientX - startX + const dy = move.clientY - startY + travelRef.current = Math.max(travelRef.current, Math.abs(dx) + Math.abs(dy)) + setTf(current => ({ ...current, x: x + dx, y: y + dy })) + if (travelRef.current > CLICK_TOLERANCE_PX) setDragging(true) + } + const onUp = (): void => { + target.removeEventListener('pointermove', onMove) + target.removeEventListener('pointerup', onUp) + target.removeEventListener('pointercancel', onUp) + setDragging(false) + } + target.addEventListener('pointermove', onMove) + target.addEventListener('pointerup', onUp) + target.addEventListener('pointercancel', onUp) + }, []) + + /** Suppress node clicks that ended a drag gesture. */ + const clickAllowed = useCallback((): boolean => travelRef.current <= CLICK_TOLERANCE_PX, []) + + // Phase frames: group each run's members by phase and box them. + const phaseFrames = useMemo((): PhaseFrame[] => { + const frames: PhaseFrame[] = [] + for (const node of nodes) { + if (node.kind !== 'workflow') continue + const runNode = node + runNode.run.phases.forEach((phase, phaseIndex) => { + const boxes: GraphBox[] = [] + for (const member of phase.members) { + const id = member.childId !== '' ? member.childId : `wfmember:${runNode.run.runId}:${member.seq}` + const box = layout.boxes.get(id) + if (box !== undefined) boxes.push(box) + } + if (boxes.length === 0) return + const x1 = Math.min(...boxes.map(box => box.x)) - 12 + const y1 = Math.min(...boxes.map(box => box.y)) - 22 + const x2 = Math.max(...boxes.map(box => box.x + box.w)) + 12 + const y2 = Math.max(...boxes.map(box => box.y + box.h)) + 12 + frames.push({ + key: `${runNode.id}:${phaseIndex}`, + title: phase.title, + x: x1, y: y1, w: x2 - x1, h: y2 - y1, + }) + }) + } + return frames + }, [nodes, layout]) + + /** The bezier edge path between two boxes (top-down). */ + const edgePath = (from: GraphBox, to: GraphBox): string => { + const x1 = from.x + from.w / 2 + const y1 = from.y + from.h + const x2 = to.x + to.w / 2 + const y2 = to.y + const midY = (y1 + y2) / 2 + return `M ${x1} ${y1} C ${x1} ${midY} ${x2} ${midY} ${x2} ${y2}` + } + + return ( +
+
+
+ + {phaseFrames.map(frame => ( +
+ {frame.title ?? t('workflowPhaseUnnamed')} +
+ ))} + {nodes.map((node) => { + const box = layout.boxes.get(node.id) + if (box === undefined) return null + const style = { left: box.x, top: box.y, width: GRAPH_NODE_W, minHeight: box.h } + if (node.kind === 'fold') return renderFoldNode(node, style, onToggleFold, clickAllowed) + if (node.kind === 'workflow') { + return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed) + } + return renderAgentNode(node, style, onActivate, onNodeInfo, clickAllowed) + })} +
+
+
+ + + + + +
+
+ ) +} + +/** One agent node card. */ +function renderAgentNode( + node: TasksAgentNode, + style: { left: number; top: number; width: number; minHeight: number }, + onActivate: (node: TasksAgentNode) => void, + onNodeInfo: (node: TasksAgentNode, anchor: HTMLElement) => void, + clickAllowed: () => boolean, +): ReactNode { + return ( +
{ + if (!clickAllowed()) return + onActivate(node) + void event + }} + > + + + {node.label} + + {agentSecondary(node)} + {node.state === 'running' && } + +
+ ) +} + +/** One workflow run node card. */ +function renderWorkflowNode( + node: TasksWorkflowNode, + style: { left: number; top: number; width: number; minHeight: number }, + onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void, + clickAllowed: () => boolean, +): ReactNode { + const tally = memberTally(node.run) + return ( +
{ + if (!clickAllowed()) return + onWorkflowInfo(node, event.currentTarget) + }} + > + + + {node.run.name} + + + {`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done: tally.done, total: tally.total })}`} + +
+ ) +} + +/** One fold aggregate node. */ +function renderFoldNode( + node: TasksFoldNode, + style: { left: number; top: number; width: number; minHeight: number }, + onToggleFold: () => void, + clickAllowed: () => boolean, +): ReactNode { + return ( +
{ + if (!clickAllowed()) return + onToggleFold() + }} + > + {t('tasksFoldCompleted', { count: node.count })} + {foldPreviews(node.previews)} +
+ ) +} diff --git a/src/client/TasksPopovers.tsx b/src/client/TasksPopovers.tsx new file mode 100644 index 0000000..1a25600 --- /dev/null +++ b/src/client/TasksPopovers.tsx @@ -0,0 +1,417 @@ +/** + * The anchored-popover CONTENTS of the Tasks page (geometry and dismissal + * live in AnchoredPopover): agent node details with a transcript jump, the + * workflow run detail with clickable member rows, and the Agent Teams task + * board (roster + basic CAS operations: complete / reopen / delete / + * reassign / create / edit). Every mutation passes the task's CURRENT + * revision; a conflict shows the note and re-syncs via the parent poller. + */ +import { useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { + SidebarSubagentAddress, + SidebarTeamMemberView, + SidebarTeamTaskView, +} from '../context-types.ts' +import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts' +import { agentSecondary, flatten, nodeDotState } from './tasks-shared.tsx' +import { api } from './api.ts' +import { t, type CopyKey } from './locales.ts' +import css from './tasks-graph.module.css' + +/** The display-state label key. */ +function stateKey(state: TasksAgentNode['state']): CopyKey { + switch (state) { + case 'running': return 'tasksStateRunning' + case 'idle': return 'tasksStateIdle' + case 'done': return 'tasksStateDone' + case 'error': return 'tasksStateError' + } +} + +/** One key/value row of a popover. */ +function PopRow(props: { label: string; children: ReactNode }): ReactNode { + return ( +
+ {props.label} + {props.children} +
+ ) +} + +/** The agent node detail popover. */ +export function AgentNodePopover(props: { + node: TasksAgentNode + onJump(node: TasksAgentNode): void +}): ReactNode { + const { node, onJump } = props + const liveText = node.live?.text !== undefined ? flatten(node.live.text) : undefined + const liveTool = node.live?.tool !== undefined + ? `${node.live.tool.name}${node.live.tool.args === '' ? '' : ` ${node.live.tool.args}`}` + : undefined + return ( +
+ {node.label} + + {t(stateKey(node.state))} + + {node.mode !== undefined && ( + + {node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')} + + )} + {node.team !== undefined && ( + <> + + {node.team.role === 'lead' ? 'lead' : node.team.name} + + {node.team.model !== undefined && ( + {node.team.model} + )} + + )} + {(liveTool !== undefined || liveText !== undefined) && ( + {liveTool ?? liveText} + )} + {node.childAddress !== undefined || node.parentId === undefined ? ( + + ) : null} +
+ ) +} + +/** The workflow run detail popover (phases with clickable member rows). */ +export function WorkflowNodePopover(props: { + node: TasksWorkflowNode + onJumpMember(address: SidebarSubagentAddress): void +}): ReactNode { + const { node, onJumpMember } = props + const { run } = node + return ( +
+ ▶ {run.name} + + {t(run.status === 'running' + ? 'workflowRunning' + : run.status === 'completed' + ? 'workflowCompleted' + : run.status === 'cancelled' ? 'workflowCancelled' : 'workflowFailed')} + +
+
+ {run.phases.map((phase, phaseIndex) => ( +
+
+ {phase.title ?? t('workflowPhaseUnnamed')} +
+ {phase.members.map(member => ( +
{ + if (member.childId === '') return + onJumpMember({ + parentSessionId: run.originSessionId, + childSessionId: member.childId, + mode: 'one-shot', + }) + }} + > + + {member.label} +
+ ))} +
+ ))} +
+
+
+ ) +} + +/** 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 Agent Teams board popover: roster + shared task list with the basic + * operations. Poll-driven refresh: a successful mutation (or a conflict) + * asks the parent to re-pull `teams.view` immediately. + */ +export function TeamBoardPopover(props: { + rootId: string + members: readonly SidebarTeamMemberView[] + tasks: readonly SidebarTeamTaskView[] + onChanged(): void +}): ReactNode { + const { rootId, members, tasks, onChanged } = props + const [busy, setBusy] = useState(false) + const [note, setNote] = useState(undefined) + const [editingId, setEditingId] = useState(undefined) + const [creating, setCreating] = useState(false) + const [armedDeleteId, setArmedDeleteId] = useState(undefined) + + /** Run one mutation with the shared busy/conflict handling. */ + const mutate = async ( + action: () => Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }>, + ): Promise => { + 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 })) + } + onChanged() + } catch (error) { + setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) })) + } finally { + setBusy(false) + } + } + + return ( +
+ {t('teamBoard')} + + {members.map(member => member.name).join(' · ')} + +
+
+ {tasks.length === 0 &&
{t('teamTasksEmpty')}
} + {tasks.filter(task => task.status !== 'deleted').map(task => ( + { setEditingId(open ? task.id : undefined) }} + onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }} + mutate={mutate} + /> + ))} +
+ {note !== undefined &&
{note}
} + {creating + ? ( + { setCreating(false) }} + onSubmit={(subject, description) => void mutate(async () => { + const result = await api.teamsTaskCreate(rootId, { subject, description }) + if (result.ok) setCreating(false) + return result + })} + /> + ) + : ( + + )} +
+
+ ) +} + +/** One task row of the board (status action + owner select + edit/delete). */ +function TeamTaskRow(props: { + rootId: string + task: SidebarTeamTaskView + members: readonly SidebarTeamMemberView[] + busy: boolean + editing: boolean + armedDelete: boolean + onEdit(open: boolean): void + onArmDelete(armed: boolean): void + mutate(action: () => Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }>): Promise +}): ReactNode { + const { rootId, task, members, busy, editing, armedDelete, onEdit, onArmDelete, mutate } = props + if (editing) { + return ( + { onEdit(false) }} + onSubmit={(subject, description) => void mutate(async () => { + const result = await api.teamsTaskUpdate(rootId, { + taskId: task.id, + expectedRevision: task.revision, + action: 'edit', + subject, + description, + }) + if (result.ok) onEdit(false) + return result + })} + /> + ) + } + return ( +
+ + + {task.subject} + {!task.ready && ` · ${t('teamTaskBlocked')}`} + + + + {task.status !== 'completed' && ( + + )} + {task.status === 'completed' && ( + + )} + + + + {t(taskStatusKey(task.status))} +
+ ) +} + +/** The create/edit task form (subject + description). */ +function TeamTaskForm(props: { + busy: boolean + initialSubject?: string + initialDescription?: string + submitLabel: string + onCancel(): void + onSubmit(subject: string, description: string): void +}): ReactNode { + const [subject, setSubject] = useState(props.initialSubject ?? '') + const [description, setDescription] = useState(props.initialDescription ?? '') + return ( +
+ { setSubject(event.target.value) }} + /> + { setDescription(event.target.value) }} + /> +
+ + +
+
+ ) +} diff --git a/src/client/TasksTree.tsx b/src/client/TasksTree.tsx new file mode 100644 index 0000000..2b30092 --- /dev/null +++ b/src/client/TasksTree.tsx @@ -0,0 +1,216 @@ +/** + * The tree mode of the Tasks page: the SAME unified model rendered as a + * classic indentation tree (keyboard navigable, the official catalog's + * arrow-key recipe). Fold aggregates are rows; workflow runs are group rows + * whose members indent one level deeper. The control cluster (view toggle + + * fold toggle) stays visible bottom-right — the mockup's lesson. + */ +import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react' +import clsx from 'clsx' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts' +import { agentSecondary, foldPreviews, LiveLine, nodeDotState } from './tasks-shared.tsx' +import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx' +import { t, type CopyKey } from './locales.ts' +import css from './tasks-graph.module.css' +import legacy from './SubagentView.module.css' + +/** Pixels of indentation per depth level (the classic tree's rhythm). */ +const INDENT_PX = 18 + +/** The workflow run status label key. */ +function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey { + switch (status) { + case 'running': return 'workflowRunning' + case 'completed': return 'workflowCompleted' + case 'cancelled': return 'workflowCancelled' + case 'failed': return 'workflowFailed' + } +} + +export interface TasksTreeProps { + nodes: readonly TasksNode[] + folded: boolean + onActivate(node: TasksAgentNode): void + onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void + onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void + onToggleFold(): void + mode: 'graph' | 'tree' + onModeChange(mode: 'graph' | 'tree'): void + /** Fallback loading rows while the root catalog hydrates. */ + loading?: boolean +} + +export function TasksTree(props: TasksTreeProps): ReactNode { + const { + nodes, folded, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange, loading, + } = props + const bodyRef = useRef(null) + + /** Depth of every node (roots at 0), derived from the parent chain. */ + const depthById = useMemo(() => { + const byId = new Map(nodes.map(node => [node.id, node])) + const depths = new Map() + const depthOf = (node: TasksNode, guard: number): number => { + const known = depths.get(node.id) + if (known !== undefined) return known + if (node.parentId === undefined || guard > 64) { + depths.set(node.id, 0) + return 0 + } + const parent = byId.get(node.parentId) + const depth = parent === undefined ? 0 : depthOf(parent, guard + 1) + 1 + depths.set(node.id, depth) + return depth + } + for (const node of nodes) depthOf(node, 0) + return depths + }, [nodes]) + + /** Arrow-key navigation over the visible rows (official catalog recipe). */ + const focusAt = useCallback((index: number): void => { + const items = bodyRef.current?.querySelectorAll('[data-tasks-row]') ?? [] + if (items.length === 0) return + items[(index + items.length) % items.length]?.focus() + }, []) + const onTreeKeyDown = useCallback((event: KeyboardEvent): void => { + const items = bodyRef.current?.querySelectorAll('[data-tasks-row]') ?? [] + const index = Array.prototype.indexOf.call(items, document.activeElement) + if (event.key === 'ArrowDown') { + event.preventDefault() + focusAt(index + 1) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + focusAt(index < 0 ? items.length - 1 : index - 1) + } else if (event.key === 'Home') { + event.preventDefault() + focusAt(0) + } else if (event.key === 'End') { + event.preventDefault() + focusAt(items.length - 1) + } + }, [focusAt]) + + const activateOnKey = (event: KeyboardEvent, action: () => void): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + action() + } + } + + return ( +
+
+ {loading === true && ( +
{t('loading')}
+ )} + {nodes.map((node) => { + const depth = depthById.get(node.id) ?? 0 + const indent = { marginLeft: depth * INDENT_PX } + if (node.kind === 'fold') { + return ( +
{ activateOnKey(event, onToggleFold) }} + > + + {t('tasksFoldCompleted', { count: node.count })} + {foldPreviews(node.previews)} + +
+ ) + } + if (node.kind === 'workflow') { + const done = node.run.phases.reduce( + (sum, phase) => sum + phase.members.filter(member => member.outcome !== undefined).length, + 0, + ) + const total = node.run.phases.reduce((sum, phase) => sum + phase.members.length, 0) + return ( +
{ onWorkflowInfo(node, event.currentTarget) }} + onKeyDown={(event) => { + activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) }) + }} + > + + + {node.run.name} + + {`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done, total })}`} + + +
+ ) + } + return ( +
{ onActivate(node) }} + onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }} + > + + + {node.label} + {agentSecondary(node)} + {node.state === 'running' && } + + +
+ ) + })} +
+
+ + +
+
+ ) +} diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index fcbd93e..0222c34 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -137,12 +137,30 @@ export function builtinTabs(): readonly TabDescriptor[] { key: 'autoOpenJobs', title: () => t('settingsJobsTitle'), desc: () => t('settingsJobsDesc'), + }, { + key: 'tasksViewMode', + type: 'select', + title: () => t('settingsViewModeTitle'), + desc: () => t('settingsViewModeDesc'), + options: [ + { + value: 'graph', + title: () => t('settingsViewModeGraph'), + desc: () => t('settingsViewModeGraphDesc'), + }, + { + value: 'tree', + title: () => t('settingsViewModeTree'), + desc: () => t('settingsViewModeTreeDesc'), + }, + ], }], }, - component: ({ ctx, scope, visible, onSubagentJump }) => ( + component: ({ ctx, store, scope, visible, onSubagentJump }) => ( { onSubagentJump?.(address.childSessionId) }} /> diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 37364e7..0af2806 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -391,6 +391,64 @@ export const ar: Record = { jobKill: 'إنهاء', jobKillConfirm: 'انقر مجدداً للتأكيد على الإنهاء', jobKillError: 'فشل الإنهاء', + tasksViewGraph: 'مخطط سير العمل', + tasksViewTree: 'شجرة', + tasksViewSwitchToGraph: 'التبديل إلى مخطط سير العمل', + tasksViewSwitchToTree: 'التبديل إلى الشجرة', + settingsViewModeTitle: 'العرض الافتراضي لصفحة المهام', + settingsViewModeDesc: 'كيف تُعرض صفحة المهام عند الفتح (زر التبديل داخل الصفحة يظل يبدّلها مؤقتًا)', + settingsViewModeGraph: 'مخطط سير العمل', + settingsViewModeGraphDesc: 'مخطط عُقد متدرج مع تكبير وتحريك', + settingsViewModeTree: 'شجرة', + settingsViewModeTreeDesc: 'شجرة كلاسيكية بمسافات بادئة، قابلة للتنقل بلوحة المفاتيح', + tasksWorkflowMember: 'عضو سير العمل', + tasksFoldCompleted: '✓ {count} مكتمل', + tasksFoldExpand: 'توسيع العُقد المكتملة', + tasksFoldCollapse: 'طي العُقد المكتملة', + tasksZoomIn: 'تكبير', + tasksZoomOut: 'تصغير', + tasksZoomFit: 'ملاءمة اللوحة', + tasksNodeJump: 'فتح النسخة', + tasksNodeState: 'الحالة', + tasksNodeMode: 'الوضع', + tasksNodeModel: 'الطراز', + tasksNodeTeamRole: 'دور الفريق', + tasksNodeActivity: 'آخر نشاط', + tasksStateRunning: 'قيد التشغيل', + tasksStateIdle: 'خامل', + tasksStateDone: 'مكتمل', + tasksStateError: 'خطأ', + teamBoard: 'لوحة مهام الفريق', + teamChip: 'فريق {members} أعضاء · {tasks} مهام', + teamMembers: 'الأعضاء', + teamTasksEmpty: 'لا مهام مشتركة بعد', + teamTaskCreate: 'مهمة جديدة', + teamTaskSubject: 'الموضوع', + teamTaskDescription: 'الوصف', + teamTaskOwner: 'المسؤول', + teamTaskUnowned: 'غير مُسندة', + teamTaskPending: 'معلّقة', + teamTaskInProgress: 'قيد التنفيذ', + teamTaskCompleted: 'مكتملة', + teamTaskBlocked: 'محجوبة', + teamTaskComplete: 'إكمال', + teamTaskReopen: 'إعادة فتح', + teamTaskDelete: 'حذف', + teamTaskDeleteConfirm: 'تأكيد الحذف', + teamTaskEdit: 'تحرير', + teamTaskSave: 'حفظ', + teamTaskCancel: 'إلغاء', + teamTaskConflict: 'تم تعديل المهمة من جهة أخرى، تم التحديث', + teamTaskError: 'فشلت العملية: {message}', + teamTaskDeleted: 'محذوفة', + workflowRunning: 'قيد التشغيل', + workflowCompleted: 'مكتمل', + workflowCancelled: 'أُلغي', + workflowFailed: 'فشل', + workflowPhaseUnnamed: 'مرحلة بدون اسم', + workflowMembers: '{done}/{total} أعضاء', + jobsAutoCollapsed: 'تم الطي تلقائيًا (وكلاء كثيرون)', + catalogLoadFailed: 'فشل تحميل {count} من الفروع', addPluginsTabCard: 'إضافة إضافات تبويب', addPluginsTabCardDesc: 'تسجيل صفحة شريط جانبي جديدة', addPluginsViewerCard: 'إضافة إضافات معاينة', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 420597c..dd2a83f 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -376,6 +376,64 @@ export const de: Record = { jobKill: 'Beenden', jobKillConfirm: 'Zum Bestätigen erneut klicken', jobKillError: 'Beenden fehlgeschlagen', + tasksViewGraph: 'Workflow-Diagramm', + tasksViewTree: 'Baum', + tasksViewSwitchToGraph: 'Zum Workflow-Diagramm wechseln', + tasksViewSwitchToTree: 'Zum Baum wechseln', + settingsViewModeTitle: 'Standardansicht der Aufgabenseite', + settingsViewModeDesc: 'Wie die Aufgabenseite beim Öffnen dargestellt wird (der Umschalter auf der Seite ändert es weiterhin ad hoc)', + settingsViewModeGraph: 'Workflow-Diagramm', + settingsViewModeGraphDesc: 'Geschichtetes Knotendiagramm mit Zoom und Schwenken', + settingsViewModeTree: 'Baum', + settingsViewModeTreeDesc: 'Klassischer Einrückungsbaum, per Tastatur navigierbar', + tasksWorkflowMember: 'Workflow-Mitglied', + tasksFoldCompleted: '✓ {count} abgeschlossen', + tasksFoldExpand: 'Abgeschlossene Knoten ausklappen', + tasksFoldCollapse: 'Abgeschlossene Knoten einklappen', + tasksZoomIn: 'Vergrößern', + tasksZoomOut: 'Verkleinern', + tasksZoomFit: 'An die Canvas anpassen', + tasksNodeJump: 'Transkript öffnen', + tasksNodeState: 'Status', + tasksNodeMode: 'Modus', + tasksNodeModel: 'Modell', + tasksNodeTeamRole: 'Teamrolle', + tasksNodeActivity: 'Letzte Aktivität', + tasksStateRunning: 'Läuft', + tasksStateIdle: 'Bereit', + tasksStateDone: 'Abgeschlossen', + tasksStateError: 'Fehler', + teamBoard: 'Team-Aufgabenboard', + teamChip: 'Team {members} Mitglieder · {tasks} Aufgaben', + teamMembers: 'Mitglieder', + teamTasksEmpty: 'Noch keine geteilten Aufgaben', + teamTaskCreate: 'Neue Aufgabe', + teamTaskSubject: 'Titel', + teamTaskDescription: 'Beschreibung', + teamTaskOwner: 'Verantwortlich', + teamTaskUnowned: 'Nicht übernommen', + teamTaskPending: 'Offen', + teamTaskInProgress: 'In Arbeit', + teamTaskCompleted: 'Abgeschlossen', + teamTaskBlocked: 'Blockiert', + teamTaskComplete: 'Abschließen', + teamTaskReopen: 'Wieder öffnen', + teamTaskDelete: 'Löschen', + teamTaskDeleteConfirm: 'Löschen bestätigen', + teamTaskEdit: 'Bearbeiten', + teamTaskSave: 'Speichern', + teamTaskCancel: 'Abbrechen', + teamTaskConflict: 'Die Aufgabe wurde anderweitig geändert; aktualisiert', + teamTaskError: 'Vorgang fehlgeschlagen: {message}', + teamTaskDeleted: 'Gelöscht', + workflowRunning: 'Läuft', + workflowCompleted: 'Abgeschlossen', + workflowCancelled: 'Abgebrochen', + workflowFailed: 'Fehlgeschlagen', + workflowPhaseUnnamed: 'Unbenannte Phase', + workflowMembers: '{done}/{total} Mitglieder', + jobsAutoCollapsed: 'Automatisch eingeklappt (viele Agenten)', + catalogLoadFailed: '{count} Branches konnten nicht geladen werden', addPluginsTabCard: 'Tab-Plugins hinzufügen', addPluginsTabCardDesc: 'Eine neue Seitenleisten-Seite registrieren', addPluginsViewerCard: 'Vorschau-Plugins hinzufügen', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 54a5aa3..801c11c 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -383,6 +383,64 @@ export const fr: Record = { jobKill: 'Interrompre', jobKillConfirm: 'Cliquez à nouveau pour confirmer l’interruption', jobKillError: 'Échec de l’interruption', + tasksViewGraph: 'Diagramme de workflow', + tasksViewTree: 'Arbre', + tasksViewSwitchToGraph: 'Passer au diagramme de workflow', + tasksViewSwitchToTree: 'Passer à l’arbre', + settingsViewModeTitle: 'Vue par défaut de la page Tâches', + settingsViewModeDesc: 'Comment la page Tâches s’affiche à l’ouverture (le bouton dans la page permet toujours de basculer à la volée)', + settingsViewModeGraph: 'Diagramme de workflow', + settingsViewModeGraphDesc: 'Graphe de nœuds en couches, avec zoom et panoramique', + settingsViewModeTree: 'Arbre', + settingsViewModeTreeDesc: 'Arbre à indentation classique, navigable au clavier', + tasksWorkflowMember: 'Membre du workflow', + tasksFoldCompleted: '✓ {count} terminés', + tasksFoldExpand: 'Déplier les nœuds terminés', + tasksFoldCollapse: 'Replier les nœuds terminés', + tasksZoomIn: 'Zoom avant', + tasksZoomOut: 'Zoom arrière', + tasksZoomFit: 'Ajuster au canevas', + tasksNodeJump: 'Ouvrir la transcription', + tasksNodeState: 'État', + tasksNodeMode: 'Mode', + tasksNodeModel: 'Modèle', + tasksNodeTeamRole: 'Rôle dans l’équipe', + tasksNodeActivity: 'Dernière activité', + tasksStateRunning: 'En cours', + tasksStateIdle: 'Inactif', + tasksStateDone: 'Terminé', + tasksStateError: 'Erreur', + teamBoard: 'Tableau des tâches d’équipe', + teamChip: 'Équipe {members} membres · {tasks} tâches', + teamMembers: 'Membres', + teamTasksEmpty: 'Aucune tâche partagée pour l’instant', + teamTaskCreate: 'Nouvelle tâche', + teamTaskSubject: 'Sujet', + teamTaskDescription: 'Description', + teamTaskOwner: 'Responsable', + teamTaskUnowned: 'Non réclamée', + teamTaskPending: 'À faire', + teamTaskInProgress: 'En cours', + teamTaskCompleted: 'Terminée', + teamTaskBlocked: 'Bloquée', + teamTaskComplete: 'Terminer', + teamTaskReopen: 'Rouvrir', + teamTaskDelete: 'Supprimer', + teamTaskDeleteConfirm: 'Confirmer la suppression', + teamTaskEdit: 'Modifier', + teamTaskSave: 'Enregistrer', + teamTaskCancel: 'Annuler', + teamTaskConflict: 'La tâche a été modifiée ailleurs ; actualisée', + teamTaskError: 'Échec de l’opération : {message}', + teamTaskDeleted: 'Supprimée', + workflowRunning: 'En cours', + workflowCompleted: 'Terminé', + workflowCancelled: 'Annulé', + workflowFailed: 'Échoué', + workflowPhaseUnnamed: 'Phase sans nom', + workflowMembers: '{done}/{total} membres', + jobsAutoCollapsed: 'Replié automatiquement (nombreux agents)', + catalogLoadFailed: '{count} branches n’ont pas pu être chargées', addPluginsTabCard: 'Ajouter un plugin d’onglet', addPluginsTabCardDesc: 'Enregistrer une nouvelle page de la barre latérale', addPluginsViewerCard: 'Ajouter un plugin d’aperçu', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 80081b2..a859a16 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -390,6 +390,64 @@ export const hi: Record = { jobKill: 'समाप्त', jobKillConfirm: 'समाप्त की पुष्टि के लिए फिर क्लिक करें', jobKillError: 'समाप्त विफल', + tasksViewGraph: 'वर्कफ़्लो ग्राफ़', + tasksViewTree: 'ट्री', + tasksViewSwitchToGraph: 'वर्कफ़्लो ग्राफ़ पर स्विच करें', + tasksViewSwitchToTree: 'ट्री पर स्विच करें', + settingsViewModeTitle: 'कार्य पृष्ठ का डिफ़ॉल्ट दृश्य', + settingsViewModeDesc: 'कार्य पृष्ठ खुलने पर डिफ़ॉल्ट रूप से कैसे दिखता है (पृष्ठ के अंदर अभी भी अस्थायी रूप से बदला जा सकता है)', + settingsViewModeGraph: 'वर्कफ़्लो ग्राफ़', + settingsViewModeGraphDesc: 'स्तरित नोड ग्राफ़, ज़ूम और पैन के साथ', + settingsViewModeTree: 'ट्री', + settingsViewModeTreeDesc: 'क्लासिक इंडेंटेड ट्री, कीबोर्ड से नेविगेट करने योग्य', + tasksWorkflowMember: 'वर्कफ़्लो सदस्य', + tasksFoldCompleted: '✓ {count} पूर्ण', + tasksFoldExpand: 'पूर्ण नोड्स विस्तारित करें', + tasksFoldCollapse: 'पूर्ण नोड्स संकुचित करें', + tasksZoomIn: 'ज़ूम इन', + tasksZoomOut: 'ज़ूम आउट', + tasksZoomFit: 'कैनवास में फ़िट करें', + tasksNodeJump: 'ट्रांसक्रिप्ट खोलें', + tasksNodeState: 'स्थिति', + tasksNodeMode: 'मोड', + tasksNodeModel: 'मॉडल', + tasksNodeTeamRole: 'टीम भूमिका', + tasksNodeActivity: 'नवीनतम गतिविधि', + tasksStateRunning: 'चल रहा है', + tasksStateIdle: 'निष्क्रिय', + tasksStateDone: 'पूर्ण', + tasksStateError: 'त्रुटि', + teamBoard: 'टीम कार्य बोर्ड', + teamChip: 'टीम {members} सदस्य · {tasks} कार्य', + teamMembers: 'सदस्य', + teamTasksEmpty: 'अभी कोई साझा कार्य नहीं', + teamTaskCreate: 'नया कार्य', + teamTaskSubject: 'विषय', + teamTaskDescription: 'विवरण', + teamTaskOwner: 'स्वामी', + teamTaskUnowned: 'बिना स्वामी', + teamTaskPending: 'लंबित', + teamTaskInProgress: 'प्रगति पर', + teamTaskCompleted: 'पूर्ण', + teamTaskBlocked: 'अवरुद्ध', + teamTaskComplete: 'पूर्ण करें', + teamTaskReopen: 'पुनः खोलें', + teamTaskDelete: 'हटाएँ', + teamTaskDeleteConfirm: 'हटाने की पुष्टि करें', + teamTaskEdit: 'संपादित करें', + teamTaskSave: 'सहेजें', + teamTaskCancel: 'रद्द करें', + teamTaskConflict: 'कार्य दूसरी जगह बदल गया; ताज़ा कर दिया गया', + teamTaskError: 'ऑपरेशन विफल: {message}', + teamTaskDeleted: 'हटाया गया', + workflowRunning: 'चल रहा है', + workflowCompleted: 'पूर्ण', + workflowCancelled: 'रद्द किया गया', + workflowFailed: 'विफल', + workflowPhaseUnnamed: 'अनामित चरण', + workflowMembers: '{done}/{total} सदस्य', + jobsAutoCollapsed: 'एजेंट बहुत सारे, स्वतः संकुचित किया गया', + catalogLoadFailed: '{count} ब्रांच लोड होने में विफल', addPluginsTabCard: 'टैब प्लगइन जोड़ें', addPluginsTabCardDesc: 'नया साइडबार पृष्ठ पंजीकृत करें', addPluginsViewerCard: 'पूर्वावलोकन प्लगइन जोड़ें', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index f1d63db..d25d2a4 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -388,6 +388,64 @@ export const id: Record = { jobKill: 'Hentikan', jobKillConfirm: 'Klik lagi untuk mengonfirmasi penghentian', jobKillError: 'Penghentian gagal', + tasksViewGraph: 'Graf alur kerja', + tasksViewTree: 'Pohon', + tasksViewSwitchToGraph: 'Beralih ke graf alur kerja', + tasksViewSwitchToTree: 'Beralih ke pohon', + settingsViewModeTitle: 'Tampilan default halaman Tasks', + settingsViewModeDesc: 'Bagaimana halaman Tasks dirender saat dibuka (tombol di halaman tetap bisa mengalihkannya sementara)', + settingsViewModeGraph: 'Graf alur kerja', + settingsViewModeGraphDesc: 'Graf node berlapis dengan zoom dan pan', + settingsViewModeTree: 'Pohon', + settingsViewModeTreeDesc: 'Pohon indentasi klasik, dapat dinavigasi dengan keyboard', + tasksWorkflowMember: 'Anggota alur kerja', + tasksFoldCompleted: '✓ {count} selesai', + tasksFoldExpand: 'Perluas node yang selesai', + tasksFoldCollapse: 'Lipat node yang selesai', + tasksZoomIn: 'Perbesar', + tasksZoomOut: 'Perkecil', + tasksZoomFit: 'Sesuaikan ke kanvas', + tasksNodeJump: 'Buka transkrip', + tasksNodeState: 'Status', + tasksNodeMode: 'Mode', + tasksNodeModel: 'Model', + tasksNodeTeamRole: 'Peran tim', + tasksNodeActivity: 'Aktivitas terbaru', + tasksStateRunning: 'Berjalan', + tasksStateIdle: 'Siaga', + tasksStateDone: 'Selesai', + tasksStateError: 'Error', + teamBoard: 'Papan tugas tim', + teamChip: 'Tim {members} anggota · {tasks} tugas', + teamMembers: 'Anggota', + teamTasksEmpty: 'Belum ada tugas bersama', + teamTaskCreate: 'Tugas baru', + teamTaskSubject: 'Judul', + teamTaskDescription: 'Deskripsi', + teamTaskOwner: 'Penanggung jawab', + teamTaskUnowned: 'Belum diklaim', + teamTaskPending: 'Tertunda', + teamTaskInProgress: 'Sedang berjalan', + teamTaskCompleted: 'Selesai', + teamTaskBlocked: 'Terblokir', + teamTaskComplete: 'Selesaikan', + teamTaskReopen: 'Buka kembali', + teamTaskDelete: 'Hapus', + teamTaskDeleteConfirm: 'Konfirmasi hapus', + teamTaskEdit: 'Edit', + teamTaskSave: 'Simpan', + teamTaskCancel: 'Batal', + teamTaskConflict: 'Tugas berubah di tempat lain; sudah disegarkan', + teamTaskError: 'Operasi gagal: {message}', + teamTaskDeleted: 'Terhapus', + workflowRunning: 'Berjalan', + workflowCompleted: 'Selesai', + workflowCancelled: 'Dibatalkan', + workflowFailed: 'Gagal', + workflowPhaseUnnamed: 'Fase tanpa nama', + workflowMembers: '{done}/{total} anggota', + jobsAutoCollapsed: 'Dilipat otomatis (agen banyak)', + catalogLoadFailed: '{count} cabang gagal dimuat', addPluginsTabCard: 'Tambah plugin tab', addPluginsTabCardDesc: 'Daftarkan halaman sidebar baru', addPluginsViewerCard: 'Tambah plugin pratinjau', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 9259933..dbd1c2a 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -381,6 +381,64 @@ export const it: Record = { jobKill: 'Termina', jobKillConfirm: 'Faccia di nuovo clic per confermare la terminazione', jobKillError: 'Terminazione non riuscita', + tasksViewGraph: 'Grafo del workflow', + tasksViewTree: 'Albero', + tasksViewSwitchToGraph: 'Passa al grafo del workflow', + tasksViewSwitchToTree: 'Passa all’albero', + settingsViewModeTitle: 'Vista predefinita della pagina Attività', + settingsViewModeDesc: 'Come si apre la pagina Attività (l’interruttore in pagina permette comunque di cambiare al volo)', + settingsViewModeGraph: 'Grafo del workflow', + settingsViewModeGraphDesc: 'Grafo di nodi a livelli, con zoom e pan', + settingsViewModeTree: 'Albero', + settingsViewModeTreeDesc: 'Albero a rientri classico, navigabile da tastiera', + tasksWorkflowMember: 'Membro del workflow', + tasksFoldCompleted: '✓ {count} completati', + tasksFoldExpand: 'Espandi i nodi completati', + tasksFoldCollapse: 'Comprimi i nodi completati', + tasksZoomIn: 'Ingrandisci', + tasksZoomOut: 'Riduci', + tasksZoomFit: 'Adatta all’area di disegno', + tasksNodeJump: 'Apri trascrizione', + tasksNodeState: 'Stato', + tasksNodeMode: 'Modalità', + tasksNodeModel: 'Modello', + tasksNodeTeamRole: 'Ruolo nel team', + tasksNodeActivity: 'Ultima attività', + tasksStateRunning: 'In esecuzione', + tasksStateIdle: 'In attesa', + tasksStateDone: 'Completato', + tasksStateError: 'Errore', + teamBoard: 'Bacheca delle attività del team', + teamChip: 'Team {members} membri · {tasks} attività', + teamMembers: 'Membri', + teamTasksEmpty: 'Ancora nessuna attività condivisa', + teamTaskCreate: 'Nuova attività', + teamTaskSubject: 'Titolo', + teamTaskDescription: 'Descrizione', + teamTaskOwner: 'Responsabile', + teamTaskUnowned: 'Non assegnata', + teamTaskPending: 'Da fare', + teamTaskInProgress: 'In corso', + teamTaskCompleted: 'Completata', + teamTaskBlocked: 'Bloccata', + teamTaskComplete: 'Completa', + teamTaskReopen: 'Riapri', + teamTaskDelete: 'Elimina', + teamTaskDeleteConfirm: 'Conferma eliminazione', + teamTaskEdit: 'Modifica', + teamTaskSave: 'Salva', + teamTaskCancel: 'Annulla', + teamTaskConflict: 'L’attività è stata modificata da altri; aggiornata', + teamTaskError: 'Operazione non riuscita: {message}', + teamTaskDeleted: 'Eliminata', + workflowRunning: 'In corso', + workflowCompleted: 'Completato', + workflowCancelled: 'Annullato', + workflowFailed: 'Non riuscito', + workflowPhaseUnnamed: 'Fase senza nome', + workflowMembers: '{done}/{total} membri', + jobsAutoCollapsed: 'Compresso automaticamente (molti agenti)', + catalogLoadFailed: 'Impossibile caricare {count} branch', addPluginsTabCard: 'Aggiungi plugin scheda', addPluginsTabCardDesc: 'Registri una nuova pagina della barra laterale', addPluginsViewerCard: 'Aggiungi plugin di anteprima', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index df67d20..e1bf0be 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -390,6 +390,64 @@ export const ja: Record = { jobKill: '終了', jobKillConfirm: 'もう一度クリックして終了を確認', jobKillError: '終了に失敗', + tasksViewGraph: 'ワークフロー図', + tasksViewTree: 'ツリー', + tasksViewSwitchToGraph: 'ワークフロー図に切替', + tasksViewSwitchToTree: 'ツリーに切替', + settingsViewModeTitle: 'タスク管理の既定ビュー', + settingsViewModeDesc: 'タスク管理ページを開いたときの表示方法(ページ内で一時切替可能)', + settingsViewModeGraph: 'ワークフロー図', + settingsViewModeGraphDesc: '階層ノードグラフ(ズーム・パン対応)', + settingsViewModeTree: 'ツリー', + settingsViewModeTreeDesc: '従来のインデントツリー(キーボード操作可)', + tasksWorkflowMember: 'ワークフローメンバー', + tasksFoldCompleted: '✓ {count} 件完了', + tasksFoldExpand: '完了ノードを展開', + tasksFoldCollapse: '完了ノードを折りたたむ', + tasksZoomIn: '拡大', + tasksZoomOut: '縮小', + tasksZoomFit: 'キャンバスに合わせる', + tasksNodeJump: 'トランスクリプトを開く', + tasksNodeState: '状態', + tasksNodeMode: 'モード', + tasksNodeModel: 'モデル', + tasksNodeTeamRole: 'チーム役割', + tasksNodeActivity: '最新アクティビティ', + tasksStateRunning: '実行中', + tasksStateIdle: '待機', + tasksStateDone: '完了', + tasksStateError: 'エラー', + teamBoard: 'チームタスクボード', + teamChip: 'チーム {members} 名 · {tasks} タスク', + teamMembers: 'メンバー', + teamTasksEmpty: '共有タスクはまだありません', + teamTaskCreate: 'タスクを作成', + teamTaskSubject: '件名', + teamTaskDescription: '説明', + teamTaskOwner: '担当', + teamTaskUnowned: '未割当', + teamTaskPending: '未着手', + teamTaskInProgress: '進行中', + teamTaskCompleted: '完了', + teamTaskBlocked: 'ブロック', + teamTaskComplete: '完了', + teamTaskReopen: '再オープン', + teamTaskDelete: '削除', + teamTaskDeleteConfirm: '削除を確認', + teamTaskEdit: '編集', + teamTaskSave: '保存', + teamTaskCancel: 'キャンセル', + teamTaskConflict: '他の場所で変更されました。最新に更新しました', + teamTaskError: '操作に失敗: {message}', + teamTaskDeleted: '削除済み', + workflowRunning: '進行中', + workflowCompleted: '完了', + workflowCancelled: 'キャンセル', + workflowFailed: '失敗', + workflowPhaseUnnamed: '無名フェーズ', + workflowMembers: '{done}/{total} メンバー', + jobsAutoCollapsed: 'エージェント多数のため自動折りたたみ', + catalogLoadFailed: '{count} 個のブランチの読み込みに失敗', addPluginsTabCard: 'Tab プラグインを追加', addPluginsTabCardDesc: '新しいサイドバーページを登録', addPluginsViewerCard: 'プレビュープラグインを追加', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 0ca7ccc..dd9b30a 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -382,6 +382,64 @@ export const ko: Record = { jobKill: '종료', jobKillConfirm: '다시 클릭하여 종료 확인', jobKillError: '종료 실패', + tasksViewGraph: '워크플로 그래프', + tasksViewTree: '트리', + tasksViewSwitchToGraph: '워크플로 그래프로 전환', + tasksViewSwitchToTree: '트리로 전환', + settingsViewModeTitle: '작업 관리 기본 보기', + settingsViewModeDesc: '작업 관리 페이지를 열 때의 기본 표시 방식(페이지 안에서 언제든 임시 전환 가능)', + settingsViewModeGraph: '워크플로 그래프', + settingsViewModeGraphDesc: '계층형 노드 그래프, 확대/이동 가능', + settingsViewModeTree: '트리', + settingsViewModeTreeDesc: '클래식 들여쓰기 트리, 키보드 탐색 지원', + tasksWorkflowMember: '워크플로 멤버', + tasksFoldCompleted: '✓ {count} 완료됨', + tasksFoldExpand: '완료된 노드 펼치기', + tasksFoldCollapse: '완료된 노드 접기', + tasksZoomIn: '확대', + tasksZoomOut: '축소', + tasksZoomFit: '캔버스에 맞추기', + tasksNodeJump: '대화 기록 보기', + tasksNodeState: '상태', + tasksNodeMode: '모드', + tasksNodeModel: '모델', + tasksNodeTeamRole: '팀 역할', + tasksNodeActivity: '최근 활동', + tasksStateRunning: '실행 중', + tasksStateIdle: '대기 중', + tasksStateDone: '완료됨', + tasksStateError: '오류', + teamBoard: '팀 작업 보드', + teamChip: '팀 멤버 {members}명 · 작업 {tasks}개', + teamMembers: '멤버', + teamTasksEmpty: '공유 작업이 없습니다', + teamTaskCreate: '새 작업', + teamTaskSubject: '제목', + teamTaskDescription: '설명', + teamTaskOwner: '담당자', + teamTaskUnowned: '미배정', + teamTaskPending: '할 일', + teamTaskInProgress: '진행 중', + teamTaskCompleted: '완료됨', + teamTaskBlocked: '차단됨', + teamTaskComplete: '완료', + teamTaskReopen: '다시 열기', + teamTaskDelete: '삭제', + teamTaskDeleteConfirm: '삭제 확인', + teamTaskEdit: '편집', + teamTaskSave: '저장', + teamTaskCancel: '취소', + teamTaskConflict: '다른 사람이 작업을 수정해 새로고침했습니다', + teamTaskError: '작업 실패: {message}', + teamTaskDeleted: '삭제됨', + workflowRunning: '진행 중', + workflowCompleted: '완료됨', + workflowCancelled: '취소됨', + workflowFailed: '실패', + workflowPhaseUnnamed: '이름 없는 단계', + workflowMembers: '{done}/{total} 멤버', + jobsAutoCollapsed: '에이전트가 많아 자동으로 접었습니다', + catalogLoadFailed: '{count}개 브랜치 로드 실패', addPluginsTabCard: 'Tab 플러그인 추가', addPluginsTabCardDesc: '새 사이드바 페이지 등록', addPluginsViewerCard: '미리보기 플러그인 추가', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index a476d49..644ba0d 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -388,6 +388,64 @@ export const nl: Record = { jobKill: 'Beëindigen', jobKillConfirm: 'Klik nogmaals om beëindigen te bevestigen', jobKillError: 'Beëindigen mislukt', + tasksViewGraph: 'Workflowgrafiek', + tasksViewTree: 'Boom', + tasksViewSwitchToGraph: 'Omschakelen naar de workflowgrafiek', + tasksViewSwitchToTree: 'Omschakelen naar de boom', + settingsViewModeTitle: 'Standaardweergave van de takenpagina', + settingsViewModeDesc: 'Hoe de takenpagina bij het openen wordt weergegeven (de schakelaar op de pagina wisselt nog steeds ad hoc)', + settingsViewModeGraph: 'Workflowgrafiek', + settingsViewModeGraphDesc: 'Gelaagde knoppengrafiek met in- en uitzoomen en pannen', + settingsViewModeTree: 'Boom', + settingsViewModeTreeDesc: 'Klassieke inspringboom, navigeerbaar met het toetsenbord', + tasksWorkflowMember: 'Workflowlid', + tasksFoldCompleted: '✓ {count} voltooid', + tasksFoldExpand: 'Voltooide knopen uitklappen', + tasksFoldCollapse: 'Voltooide knopen inklappen', + tasksZoomIn: 'Inzoomen', + tasksZoomOut: 'Uitzoomen', + tasksZoomFit: 'Passend maken', + tasksNodeJump: 'Transcript openen', + tasksNodeState: 'Status', + tasksNodeMode: 'Modus', + tasksNodeModel: 'Model', + tasksNodeTeamRole: 'Teamrol', + tasksNodeActivity: 'Laatste activiteit', + tasksStateRunning: 'Bezig', + tasksStateIdle: 'Inactief', + tasksStateDone: 'Voltooid', + tasksStateError: 'Fout', + teamBoard: 'Teamtakenbord', + teamChip: 'Team {members} leden · {tasks} taken', + teamMembers: 'Leden', + teamTasksEmpty: 'Nog geen gedeelde taken', + teamTaskCreate: 'Nieuwe taak', + teamTaskSubject: 'Onderwerp', + teamTaskDescription: 'Beschrijving', + teamTaskOwner: 'Verantwoordelijke', + teamTaskUnowned: 'Niet opgeëist', + teamTaskPending: 'Te doen', + teamTaskInProgress: 'Bezig', + teamTaskCompleted: 'Voltooid', + teamTaskBlocked: 'Geblokkeerd', + teamTaskComplete: 'Voltooien', + teamTaskReopen: 'Heropenen', + teamTaskDelete: 'Verwijderen', + teamTaskDeleteConfirm: 'Verwijderen bevestigen', + teamTaskEdit: 'Bewerken', + teamTaskSave: 'Opslaan', + teamTaskCancel: 'Annuleren', + teamTaskConflict: 'De taak is elders gewijzigd; vernieuwd', + teamTaskError: 'Bewerking mislukt: {message}', + teamTaskDeleted: 'Verwijderd', + workflowRunning: 'Bezig', + workflowCompleted: 'Voltooid', + workflowCancelled: 'Geannuleerd', + workflowFailed: 'Mislukt', + workflowPhaseUnnamed: 'Naamloze fase', + workflowMembers: '{done}/{total} leden', + jobsAutoCollapsed: 'Automatisch ingeklapt (veel agents)', + catalogLoadFailed: '{count} branches konden niet worden geladen', addPluginsTabCard: 'Tabblad-plugins toevoegen', addPluginsTabCardDesc: 'Een nieuwe zijbalkpagina registreren', addPluginsViewerCard: 'Preview-plugins toevoegen', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 7e96af2..ec7c63c 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -392,6 +392,64 @@ export const pl: Record = { jobKill: 'Zabij', jobKillConfirm: 'Kliknij ponownie, aby potwierdzić zabicie', jobKillError: 'Zabicie nie powiodło się', + tasksViewGraph: 'Graf przepływu pracy', + tasksViewTree: 'Drzewo', + tasksViewSwitchToGraph: 'Przełącz na graf przepływu pracy', + tasksViewSwitchToTree: 'Przełącz na drzewo', + settingsViewModeTitle: 'Domyślny widok strony zadań', + settingsViewModeDesc: 'Jak strona zadań renderuje się po otwarciu (przełącznik na stronie nadal działa doraźnie)', + settingsViewModeGraph: 'Graf przepływu pracy', + settingsViewModeGraphDesc: 'Warstwowy graf węzłów ze zoomowaniem i przesuwaniem', + settingsViewModeTree: 'Drzewo', + settingsViewModeTreeDesc: 'Klasyczne drzewo wcięć, nawigowalne z klawiatury', + tasksWorkflowMember: 'Członek przepływu pracy', + tasksFoldCompleted: '✓ {count} ukończonych', + tasksFoldExpand: 'Rozwiń ukończone węzły', + tasksFoldCollapse: 'Zwiń ukończone węzły', + tasksZoomIn: 'Przybliż', + tasksZoomOut: 'Oddal', + tasksZoomFit: 'Dopasuj do kanwy', + tasksNodeJump: 'Otwórz transkrypcję', + tasksNodeState: 'Stan', + tasksNodeMode: 'Tryb', + tasksNodeModel: 'Model', + tasksNodeTeamRole: 'Rola w zespole', + tasksNodeActivity: 'Ostatnia aktywność', + tasksStateRunning: 'Działa', + tasksStateIdle: 'Gotowe', + tasksStateDone: 'Ukończone', + tasksStateError: 'Błąd', + teamBoard: 'Tablica zadań zespołu', + teamChip: 'Zespół {members} członków · {tasks} zadań', + teamMembers: 'Członkowie', + teamTasksEmpty: 'Brak wspólnych zadań', + teamTaskCreate: 'Nowe zadanie', + teamTaskSubject: 'Temat', + teamTaskDescription: 'Opis', + teamTaskOwner: 'Odpowiedzialny', + teamTaskUnowned: 'Nieprzypisane', + teamTaskPending: 'Do zrobienia', + teamTaskInProgress: 'W toku', + teamTaskCompleted: 'Ukończone', + teamTaskBlocked: 'Zablokowane', + teamTaskComplete: 'Ukończ', + teamTaskReopen: 'Otwórz ponownie', + teamTaskDelete: 'Usuń', + teamTaskDeleteConfirm: 'Potwierdź usunięcie', + teamTaskEdit: 'Edytuj', + teamTaskSave: 'Zapisz', + teamTaskCancel: 'Anuluj', + teamTaskConflict: 'Zadanie zostało zmienione przez kogoś innego; odświeżono', + teamTaskError: 'Operacja nie powiodła się: {message}', + teamTaskDeleted: 'Usunięte', + workflowRunning: 'W toku', + workflowCompleted: 'Ukończony', + workflowCancelled: 'Anulowany', + workflowFailed: 'Niepowodzenie', + workflowPhaseUnnamed: 'Faza bez nazwy', + workflowMembers: '{done}/{total} członków', + jobsAutoCollapsed: 'Automatycznie zwinięto (wiele agentów)', + catalogLoadFailed: 'Nie udało się załadować {count} gałęzi', addPluginsTabCard: 'Dodaj wtyczki kart', addPluginsTabCardDesc: 'Zarejestruj nową stronę panelu bocznego', addPluginsViewerCard: 'Dodaj wtyczki podglądu', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 4e367e6..5b91c8e 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -373,6 +373,64 @@ export const pt: Record = { jobKill: 'Encerrar', jobKillConfirm: 'Clique novamente para confirmar o encerramento', jobKillError: 'Falha ao encerrar', + tasksViewGraph: 'Grafo do workflow', + tasksViewTree: 'Árvore', + tasksViewSwitchToGraph: 'Mudar para o grafo do workflow', + tasksViewSwitchToTree: 'Mudar para a árvore', + settingsViewModeTitle: 'Visualização padrão da página de tarefas', + settingsViewModeDesc: 'Como a página de tarefas é exibida ao abrir (o seletor na própria página ainda permite alternar pontualmente)', + settingsViewModeGraph: 'Grafo do workflow', + settingsViewModeGraphDesc: 'Grafo de nós em camadas, com zoom e deslocamento', + settingsViewModeTree: 'Árvore', + settingsViewModeTreeDesc: 'Árvore com indentação clássica, navegável pelo teclado', + tasksWorkflowMember: 'Membro do workflow', + tasksFoldCompleted: '✓ {count} concluídos', + tasksFoldExpand: 'Expandir os nós concluídos', + tasksFoldCollapse: 'Recolher os nós concluídos', + tasksZoomIn: 'Aproximar', + tasksZoomOut: 'Afastar', + tasksZoomFit: 'Ajustar à tela', + tasksNodeJump: 'Abrir transcrição', + tasksNodeState: 'Estado', + tasksNodeMode: 'Modo', + tasksNodeModel: 'Modelo', + tasksNodeTeamRole: 'Função na equipe', + tasksNodeActivity: 'Atividade recente', + tasksStateRunning: 'Em execução', + tasksStateIdle: 'Em espera', + tasksStateDone: 'Concluído', + tasksStateError: 'Erro', + teamBoard: 'Quadro de tarefas da equipe', + teamChip: 'Equipe {members} membros · {tasks} tarefas', + teamMembers: 'Membros', + teamTasksEmpty: 'Ainda não há tarefas compartilhadas', + teamTaskCreate: 'Nova tarefa', + teamTaskSubject: 'Assunto', + teamTaskDescription: 'Descrição', + teamTaskOwner: 'Responsável', + teamTaskUnowned: 'Não reivindicada', + teamTaskPending: 'Pendente', + teamTaskInProgress: 'Em andamento', + teamTaskCompleted: 'Concluída', + teamTaskBlocked: 'Bloqueada', + teamTaskComplete: 'Concluir', + teamTaskReopen: 'Reabrir', + teamTaskDelete: 'Excluir', + teamTaskDeleteConfirm: 'Confirmar exclusão', + teamTaskEdit: 'Editar', + teamTaskSave: 'Salvar', + teamTaskCancel: 'Cancelar', + teamTaskConflict: 'A tarefa foi alterada por outra pessoa; atualizada', + teamTaskError: 'Operação falhou: {message}', + teamTaskDeleted: 'Excluída', + workflowRunning: 'Em execução', + workflowCompleted: 'Concluído', + workflowCancelled: 'Cancelado', + workflowFailed: 'Falhou', + workflowPhaseUnnamed: 'Fase sem nome', + workflowMembers: '{done}/{total} membros', + jobsAutoCollapsed: 'Recolhido automaticamente (muitos agentes)', + catalogLoadFailed: 'Falha ao carregar {count} branches', addPluginsTabCard: 'Adicionar plugins de aba', addPluginsTabCardDesc: 'Registrar uma nova página da barra lateral', addPluginsViewerCard: 'Adicionar plugins de pré-visualização', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 5182bc7..160ffe3 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -388,6 +388,64 @@ export const ru: Record = { jobKill: 'Завершить', jobKillConfirm: 'Нажмите ещё раз, чтобы подтвердить завершение', jobKillError: 'Не удалось завершить', + tasksViewGraph: 'Граф рабочего процесса', + tasksViewTree: 'Дерево', + tasksViewSwitchToGraph: 'Переключить на граф рабочего процесса', + tasksViewSwitchToTree: 'Переключить на дерево', + settingsViewModeTitle: 'Вид страницы задач по умолчанию', + settingsViewModeDesc: 'Как отображается страница задач при открытии (переключатель на странице всё ещё меняет её на лету)', + settingsViewModeGraph: 'Граф рабочего процесса', + settingsViewModeGraphDesc: 'Многоуровневый граф узлов с масштабированием и панорамированием', + settingsViewModeTree: 'Дерево', + settingsViewModeTreeDesc: 'Классическое дерево с отступами, навигация с клавиатуры', + tasksWorkflowMember: 'Участник рабочего процесса', + tasksFoldCompleted: '✓ {count} завершено', + tasksFoldExpand: 'Развернуть завершённые узлы', + tasksFoldCollapse: 'Свернуть завершённые узлы', + tasksZoomIn: 'Увеличить', + tasksZoomOut: 'Уменьшить', + tasksZoomFit: 'По размеру холста', + tasksNodeJump: 'Открыть транскрипт', + tasksNodeState: 'Состояние', + tasksNodeMode: 'Режим', + tasksNodeModel: 'Модель', + tasksNodeTeamRole: 'Роль в команде', + tasksNodeActivity: 'Последняя активность', + tasksStateRunning: 'Выполняется', + tasksStateIdle: 'Ожидает', + tasksStateDone: 'Завершено', + tasksStateError: 'Ошибка', + teamBoard: 'Доска задач команды', + teamChip: 'Команда: {members} участников · {tasks} задач', + teamMembers: 'Участники', + teamTasksEmpty: 'Общих задач пока нет', + teamTaskCreate: 'Новая задача', + teamTaskSubject: 'Тема', + teamTaskDescription: 'Описание', + teamTaskOwner: 'Ответственный', + teamTaskUnowned: 'Не занята', + teamTaskPending: 'Ожидает', + teamTaskInProgress: 'В работе', + teamTaskCompleted: 'Завершена', + teamTaskBlocked: 'Заблокирована', + teamTaskComplete: 'Завершить', + teamTaskReopen: 'Открыть снова', + teamTaskDelete: 'Удалить', + teamTaskDeleteConfirm: 'Подтвердите удаление', + teamTaskEdit: 'Изменить', + teamTaskSave: 'Сохранить', + teamTaskCancel: 'Отмена', + teamTaskConflict: 'Задача изменена другим участником, обновлено', + teamTaskError: 'Не удалось выполнить: {message}', + teamTaskDeleted: 'Удалена', + workflowRunning: 'Выполняется', + workflowCompleted: 'Завершён', + workflowCancelled: 'Отменён', + workflowFailed: 'Сбой', + workflowPhaseUnnamed: 'Безымянная фаза', + workflowMembers: '{done}/{total} участников', + jobsAutoCollapsed: 'Свернуто автоматически (много агентов)', + catalogLoadFailed: 'Не удалось загрузить ветки: {count}', addPluginsTabCard: 'Добавить плагины вкладок', addPluginsTabCardDesc: 'Зарегистрировать новую страницу боковой панели', addPluginsViewerCard: 'Добавить плагины предпросмотра', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index c9572e3..b83a777 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -373,6 +373,64 @@ export const sv: Record = { jobKill: 'Döda', jobKillConfirm: 'Klicka igen för att bekräfta dödande', jobKillError: 'Kunde inte döda', + tasksViewGraph: 'Arbetsflödesgraf', + tasksViewTree: 'Träd', + tasksViewSwitchToGraph: 'Växla till arbetsflödesgrafen', + tasksViewSwitchToTree: 'Växla till trädet', + settingsViewModeTitle: 'Standardvy för uppgiftssidan', + settingsViewModeDesc: 'Hur uppgiftssidan visas vid öppning (växeln på sidan ändrar den fortfarande ad hoc)', + settingsViewModeGraph: 'Arbetsflödesgraf', + settingsViewModeGraphDesc: 'Lagerindelad nodgraf med zoom och panorering', + settingsViewModeTree: 'Träd', + settingsViewModeTreeDesc: 'Klassiskt indragsträd, navigerbart med tangentbord', + tasksWorkflowMember: 'Arbetsflödesmedlem', + tasksFoldCompleted: '✓ {count} klara', + tasksFoldExpand: 'Visa färdiga noder', + tasksFoldCollapse: 'Dölj färdiga noder', + tasksZoomIn: 'Zooma in', + tasksZoomOut: 'Zooma ut', + tasksZoomFit: 'Anpassa till ytan', + tasksNodeJump: 'Öppna transkription', + tasksNodeState: 'Status', + tasksNodeMode: 'Läge', + tasksNodeModel: 'Modell', + tasksNodeTeamRole: 'Teamroll', + tasksNodeActivity: 'Senaste aktivitet', + tasksStateRunning: 'Körs', + tasksStateIdle: 'Vilar', + tasksStateDone: 'Klar', + tasksStateError: 'Fel', + teamBoard: 'Teamuppgiftstavla', + teamChip: 'Team {members} medlemmar · {tasks} uppgifter', + teamMembers: 'Medlemmar', + teamTasksEmpty: 'Inga delade uppgifter än', + teamTaskCreate: 'Ny uppgift', + teamTaskSubject: 'Ämne', + teamTaskDescription: 'Beskrivning', + teamTaskOwner: 'Ansvarig', + teamTaskUnowned: 'Ej tilldelad', + teamTaskPending: 'Väntar', + teamTaskInProgress: 'Pågår', + teamTaskCompleted: 'Klar', + teamTaskBlocked: 'Blockerad', + teamTaskComplete: 'Färdigställ', + teamTaskReopen: 'Öppna igen', + teamTaskDelete: 'Ta bort', + teamTaskDeleteConfirm: 'Bekräfta borttagning', + teamTaskEdit: 'Redigera', + teamTaskSave: 'Spara', + teamTaskCancel: 'Avbryt', + teamTaskConflict: 'Uppgiften ändrades av någon annan, har uppdaterats', + teamTaskError: 'Åtgärden misslyckades: {message}', + teamTaskDeleted: 'Borttagen', + workflowRunning: 'Körs', + workflowCompleted: 'Slutförd', + workflowCancelled: 'Avbruten', + workflowFailed: 'Misslyckad', + workflowPhaseUnnamed: 'Namnlös fas', + workflowMembers: '{done}/{total} medlemmar', + jobsAutoCollapsed: 'Auto-dold (många agenter)', + catalogLoadFailed: '{count} grenar kunde inte läsas in', addPluginsTabCard: 'Lägg till flik-plugins', addPluginsTabCardDesc: 'Registrera en ny sidopanelssida', addPluginsViewerCard: 'Lägg till förhandsgransknings-plugins', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 6e51e24..8e3ee8c 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -390,6 +390,64 @@ export const th: Record = { jobKill: 'Kill', jobKillConfirm: 'คลิกอีกครั้งเพื่อยืนยันการ kill', jobKillError: 'Kill ล้มเหลว', + tasksViewGraph: 'กราฟเวิร์กโฟลว์', + tasksViewTree: 'มุมมองต้นไม้', + tasksViewSwitchToGraph: 'สลับเป็นกราฟเวิร์กโฟลว์', + tasksViewSwitchToTree: 'สลับเป็นมุมมองต้นไม้', + settingsViewModeTitle: 'มุมมองเริ่มต้นของหน้างาน', + settingsViewModeDesc: 'รูปแบบการแสดงหน้างานเมื่อเปิด (ปุ่มสลับในหน้ายังใช้สลับชั่วคราวได้)', + settingsViewModeGraph: 'กราฟเวิร์กโฟลว์', + settingsViewModeGraphDesc: 'กราฟโหนดแบบชั้น ซูมและเลื่อนได้', + settingsViewModeTree: 'มุมมองต้นไม้', + settingsViewModeTreeDesc: 'ต้นไม้แบบเยื้องคลาสสิก นำทางด้วยคีย์บอร์ดได้', + tasksWorkflowMember: 'สมาชิกเวิร์กโฟลว์', + tasksFoldCompleted: '✓ เสร็จแล้ว {count}', + tasksFoldExpand: 'ขยายโหนดที่เสร็จแล้ว', + tasksFoldCollapse: 'ย่อโหนดที่เสร็จแล้ว', + tasksZoomIn: 'ซูมเข้า', + tasksZoomOut: 'ซูมออก', + tasksZoomFit: 'พอดีผืนผ้าใบ', + tasksNodeJump: 'เปิดบันทึกถอดความ', + tasksNodeState: 'สถานะ', + tasksNodeMode: 'โหมด', + tasksNodeModel: 'โมเดล', + tasksNodeTeamRole: 'บทบาทในทีม', + tasksNodeActivity: 'กิจกรรมล่าสุด', + tasksStateRunning: 'กำลังทำงาน', + tasksStateIdle: 'พร้อม', + tasksStateDone: 'เสร็จแล้ว', + tasksStateError: 'ข้อผิดพลาด', + teamBoard: 'บอร์ดงานของทีม', + teamChip: 'ทีม {members} สมาชิก · {tasks} งาน', + teamMembers: 'สมาชิก', + teamTasksEmpty: 'ยังไม่มีงานที่แชร์', + teamTaskCreate: 'งานใหม่', + teamTaskSubject: 'หัวข้อ', + teamTaskDescription: 'คำอธิบาย', + teamTaskOwner: 'ผู้รับผิดชอบ', + teamTaskUnowned: 'ยังไม่มีผู้รับ', + teamTaskPending: 'รอดำเนินการ', + teamTaskInProgress: 'กำลังดำเนินการ', + teamTaskCompleted: 'เสร็จแล้ว', + teamTaskBlocked: 'ถูกบล็อก', + teamTaskComplete: 'ทำให้เสร็จ', + teamTaskReopen: 'เปิดใหม่', + teamTaskDelete: 'ลบ', + teamTaskDeleteConfirm: 'ยืนยันการลบ', + teamTaskEdit: 'แก้ไข', + teamTaskSave: 'บันทึก', + teamTaskCancel: 'ยกเลิก', + teamTaskConflict: 'งานถูกแก้ไขโดยผู้อื่น รีเฟรชแล้ว', + teamTaskError: 'ดำเนินการไม่สำเร็จ: {message}', + teamTaskDeleted: 'ลบแล้ว', + workflowRunning: 'กำลังทำงาน', + workflowCompleted: 'เสร็จสิ้น', + workflowCancelled: 'ยกเลิกแล้ว', + workflowFailed: 'ล้มเหลว', + workflowPhaseUnnamed: 'เฟสไม่มีชื่อ', + workflowMembers: '{done}/{total} สมาชิก', + jobsAutoCollapsed: 'ย่ออัตโนมัติ (เอเจนต์จำนวนมาก)', + catalogLoadFailed: 'โหลดสาขาไม่สำเร็จ {count} รายการ', addPluginsTabCard: 'เพิ่มปลั๊กอิน tab', addPluginsTabCardDesc: 'ลงทะเบียนหน้าแถบด้านข้างใหม่', addPluginsViewerCard: 'เพิ่มปลั๊กอินพรีวิว', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index db53867..3db33e3 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -390,6 +390,64 @@ export const tr: Record = { jobKill: 'Sonlandır', jobKillConfirm: 'Sonlandırmayı onaylamak için tekrar tıklayın', jobKillError: 'Sonlandırma başarısız', + tasksViewGraph: 'İş akışı grafiği', + tasksViewTree: 'Ağaç', + tasksViewSwitchToGraph: 'İş akışı grafiğine geç', + tasksViewSwitchToTree: 'Ağaç görünümüne geç', + settingsViewModeTitle: 'Görevler sayfası varsayılan görünümü', + settingsViewModeDesc: 'Görevler sayfasının açılıştaki görünümü (sayfa içi düğme yine anlık değiştirebilir)', + settingsViewModeGraph: 'İş akışı grafiği', + settingsViewModeGraphDesc: 'Yakınlaştırma ve kaydırma ile katmanlı düğüm grafiği', + settingsViewModeTree: 'Ağaç', + settingsViewModeTreeDesc: 'Klasik girintili ağaç, klavye ile gezilebilir', + tasksWorkflowMember: 'İş akışı üyesi', + tasksFoldCompleted: '✓ {count} tamamlandı', + tasksFoldExpand: 'Tamamlanan düğümleri genişlet', + tasksFoldCollapse: 'Tamamlanan düğümleri daralt', + tasksZoomIn: 'Yakınlaştır', + tasksZoomOut: 'Uzaklaştır', + tasksZoomFit: 'Tuvale sığdır', + tasksNodeJump: 'Transkripti aç', + tasksNodeState: 'Durum', + tasksNodeMode: 'Mod', + tasksNodeModel: 'Model', + tasksNodeTeamRole: 'Takım rolü', + tasksNodeActivity: 'Son etkinlik', + tasksStateRunning: 'Çalışıyor', + tasksStateIdle: 'Beklemede', + tasksStateDone: 'Tamamlandı', + tasksStateError: 'Hata', + teamBoard: 'Takım görev panosu', + teamChip: 'Takım {members} üye · {tasks} görev', + teamMembers: 'Üyeler', + teamTasksEmpty: 'Henüz paylaşılan görev yok', + teamTaskCreate: 'Yeni görev', + teamTaskSubject: 'Konu', + teamTaskDescription: 'Açıklama', + teamTaskOwner: 'Sahip', + teamTaskUnowned: 'Sahipsiz', + teamTaskPending: 'Bekliyor', + teamTaskInProgress: 'Devam ediyor', + teamTaskCompleted: 'Tamamlandı', + teamTaskBlocked: 'Engelli', + teamTaskComplete: 'Tamamla', + teamTaskReopen: 'Yeniden aç', + teamTaskDelete: 'Sil', + teamTaskDeleteConfirm: 'Silmeyi onayla', + teamTaskEdit: 'Düzenle', + teamTaskSave: 'Kaydet', + teamTaskCancel: 'İptal', + teamTaskConflict: 'Görev başkası tarafından değiştirildi, güncellendi', + teamTaskError: 'İşlem başarısız: {message}', + teamTaskDeleted: 'Silindi', + workflowRunning: 'Çalışıyor', + workflowCompleted: 'Tamamlandı', + workflowCancelled: 'İptal edildi', + workflowFailed: 'Başarısız', + workflowPhaseUnnamed: 'Adsız aşama', + workflowMembers: '{done}/{total} üye', + jobsAutoCollapsed: 'Otomatik daraltıldı (çok sayıda ajan)', + catalogLoadFailed: '{count} dal yüklenemedi', addPluginsTabCard: 'Sekme eklentileri ekle', addPluginsTabCardDesc: 'Yeni bir kenar çubuğu sayfası kaydet', addPluginsViewerCard: 'Önizleme eklentileri ekle', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 34a9710..cdbbfad 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -390,6 +390,64 @@ export const vi: Record = { jobKill: 'Hủy', jobKillConfirm: 'Nhấp lại để xác nhận hủy', jobKillError: 'Hủy thất bại', + tasksViewGraph: 'Đồ thị quy trình', + tasksViewTree: 'Cây', + tasksViewSwitchToGraph: 'Chuyển sang đồ thị quy trình', + tasksViewSwitchToTree: 'Chuyển sang chế độ cây', + settingsViewModeTitle: 'Chế độ xem mặc định của trang tác vụ', + settingsViewModeDesc: 'Cách trang tác vụ hiển thị khi mở (nút chuyển trên trang vẫn đổi tùy lúc)', + settingsViewModeGraph: 'Đồ thị quy trình', + settingsViewModeGraphDesc: 'Đồ thị nút phân tầng với thu phóng và di chuyển', + settingsViewModeTree: 'Cây', + settingsViewModeTreeDesc: 'Cây thụt lề cổ điển, điều hướng được bằng bàn phím', + tasksWorkflowMember: 'Thành viên quy trình', + tasksFoldCompleted: '✓ {count} hoàn thành', + tasksFoldExpand: 'Mở rộng các nút đã hoàn thành', + tasksFoldCollapse: 'Thu gọn các nút đã hoàn thành', + tasksZoomIn: 'Phóng to', + tasksZoomOut: 'Thu nhỏ', + tasksZoomFit: 'Vừa khung vẽ', + tasksNodeJump: 'Mở bản ghi hội thoại', + tasksNodeState: 'Trạng thái', + tasksNodeMode: 'Chế độ', + tasksNodeModel: 'Mô hình', + tasksNodeTeamRole: 'Vai trò trong nhóm', + tasksNodeActivity: 'Hoạt động gần đây', + tasksStateRunning: 'Đang chạy', + tasksStateIdle: 'Nhàn rỗi', + tasksStateDone: 'Hoàn thành', + tasksStateError: 'Lỗi', + teamBoard: 'Bảng tác vụ nhóm', + teamChip: 'Nhóm {members} thành viên · {tasks} tác vụ', + teamMembers: 'Thành viên', + teamTasksEmpty: 'Chưa có tác vụ dùng chung', + teamTaskCreate: 'Tác vụ mới', + teamTaskSubject: 'Chủ đề', + teamTaskDescription: 'Mô tả', + teamTaskOwner: 'Người phụ trách', + teamTaskUnowned: 'Chưa nhận', + teamTaskPending: 'Chờ xử lý', + teamTaskInProgress: 'Đang tiến hành', + teamTaskCompleted: 'Hoàn thành', + teamTaskBlocked: 'Bị chặn', + teamTaskComplete: 'Hoàn tất', + teamTaskReopen: 'Mở lại', + teamTaskDelete: 'Xóa', + teamTaskDeleteConfirm: 'Xác nhận xóa', + teamTaskEdit: 'Sửa', + teamTaskSave: 'Lưu', + teamTaskCancel: 'Hủy', + teamTaskConflict: 'Tác vụ đã bị người khác thay đổi, đã làm mới', + teamTaskError: 'Thao tác thất bại: {message}', + teamTaskDeleted: 'Đã xóa', + workflowRunning: 'Đang chạy', + workflowCompleted: 'Hoàn thành', + workflowCancelled: 'Đã hủy', + workflowFailed: 'Thất bại', + workflowPhaseUnnamed: 'Giai đoạn chưa đặt tên', + workflowMembers: '{done}/{total} thành viên', + jobsAutoCollapsed: 'Tự động thu gọn (nhiều agent)', + catalogLoadFailed: '{count} nhánh không tải được', addPluginsTabCard: 'Thêm plugin tab', addPluginsTabCardDesc: 'Đăng ký trang thanh bên mới', addPluginsViewerCard: 'Thêm plugin xem trước', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 303032e..32bf212 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -405,6 +405,64 @@ export const zhHK: Record = { jobKill: '終止', jobKillConfirm: '再次點擊確認終止', jobKillError: '終止失敗', + tasksViewGraph: '工作流圖', + tasksViewTree: '樹狀圖', + tasksViewSwitchToGraph: '切換為工作流圖', + tasksViewSwitchToTree: '切換為樹狀圖', + settingsViewModeTitle: '任務管理預設檢視', + settingsViewModeDesc: '任務管理頁開啟時的預設顯示方式(頁內仍可臨時切換)', + settingsViewModeGraph: '工作流圖', + settingsViewModeGraphDesc: '分層節點圖,可縮放平移', + settingsViewModeTree: '樹狀圖', + settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航', + tasksWorkflowMember: '工作流成員', + tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldExpand: '展開已完成的節點', + tasksFoldCollapse: '收起已完成的節點', + tasksZoomIn: '放大', + tasksZoomOut: '縮小', + tasksZoomFit: '適應畫布', + tasksNodeJump: '檢視逐字稿', + tasksNodeState: '狀態', + tasksNodeMode: '模式', + tasksNodeModel: '模型', + tasksNodeTeamRole: '團隊角色', + tasksNodeActivity: '最近活動', + tasksStateRunning: '執行中', + tasksStateIdle: '閒置', + tasksStateDone: '已完成', + tasksStateError: '出錯', + teamBoard: '團隊任務板', + teamChip: '團隊 {members} 成員 · {tasks} 任務', + teamMembers: '成員', + teamTasksEmpty: '暫無共用任務', + teamTaskCreate: '新增任務', + teamTaskSubject: '標題', + teamTaskDescription: '描述', + teamTaskOwner: '負責人', + teamTaskUnowned: '未認領', + teamTaskPending: '待辦', + teamTaskInProgress: '進行中', + teamTaskCompleted: '已完成', + teamTaskBlocked: '受阻', + teamTaskComplete: '完成', + teamTaskReopen: '重開', + teamTaskDelete: '刪除', + teamTaskDeleteConfirm: '確認刪除', + teamTaskEdit: '編輯', + teamTaskSave: '儲存', + teamTaskCancel: '取消', + teamTaskConflict: '任務已被他人修改,已重新整理', + teamTaskError: '操作失敗:{message}', + teamTaskDeleted: '已刪除', + workflowRunning: '進行中', + workflowCompleted: '已完成', + workflowCancelled: '已取消', + workflowFailed: '失敗', + workflowPhaseUnnamed: '未命名階段', + workflowMembers: '{done}/{total} 成員', + jobsAutoCollapsed: '代理較多,已自動收起', + catalogLoadFailed: '{count} 個分支載入失敗', addPluginsTabCard: '新增 Tab 插件', addPluginsTabCardDesc: '註冊新的側邊欄頁面', addPluginsViewerCard: '新增預覽插件', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 254914f..b2a77ca 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -405,6 +405,64 @@ export const zhMO: Record = { jobKill: '終止', jobKillConfirm: '再次點擊確認終止', jobKillError: '終止失敗', + tasksViewGraph: '工作流圖', + tasksViewTree: '樹狀圖', + tasksViewSwitchToGraph: '切換為工作流圖', + tasksViewSwitchToTree: '切換為樹狀圖', + settingsViewModeTitle: '任務管理預設檢視', + settingsViewModeDesc: '任務管理頁開啟時的預設顯示方式(頁內仍可臨時切換)', + settingsViewModeGraph: '工作流圖', + settingsViewModeGraphDesc: '分層節點圖,可縮放平移', + settingsViewModeTree: '樹狀圖', + settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航', + tasksWorkflowMember: '工作流成員', + tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldExpand: '展開已完成的節點', + tasksFoldCollapse: '收起已完成的節點', + tasksZoomIn: '放大', + tasksZoomOut: '縮小', + tasksZoomFit: '適應畫布', + tasksNodeJump: '檢視逐字稿', + tasksNodeState: '狀態', + tasksNodeMode: '模式', + tasksNodeModel: '模型', + tasksNodeTeamRole: '團隊角色', + tasksNodeActivity: '最近活動', + tasksStateRunning: '執行中', + tasksStateIdle: '閒置', + tasksStateDone: '已完成', + tasksStateError: '出錯', + teamBoard: '團隊任務板', + teamChip: '團隊 {members} 成員 · {tasks} 任務', + teamMembers: '成員', + teamTasksEmpty: '暫無共用任務', + teamTaskCreate: '新增任務', + teamTaskSubject: '標題', + teamTaskDescription: '描述', + teamTaskOwner: '負責人', + teamTaskUnowned: '未認領', + teamTaskPending: '待辦', + teamTaskInProgress: '進行中', + teamTaskCompleted: '已完成', + teamTaskBlocked: '受阻', + teamTaskComplete: '完成', + teamTaskReopen: '重開', + teamTaskDelete: '刪除', + teamTaskDeleteConfirm: '確認刪除', + teamTaskEdit: '編輯', + teamTaskSave: '儲存', + teamTaskCancel: '取消', + teamTaskConflict: '任務已被他人修改,已重新整理', + teamTaskError: '操作失敗:{message}', + teamTaskDeleted: '已刪除', + workflowRunning: '進行中', + workflowCompleted: '已完成', + workflowCancelled: '已取消', + workflowFailed: '失敗', + workflowPhaseUnnamed: '未命名階段', + workflowMembers: '{done}/{total} 成員', + jobsAutoCollapsed: '代理較多,已自動收起', + catalogLoadFailed: '{count} 個分支載入失敗', addPluginsTabCard: '新增 Tab 插件', addPluginsTabCardDesc: '註冊新的側邊欄頁面', addPluginsViewerCard: '新增預覽插件', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 7790aa4..b788206 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -405,6 +405,64 @@ export const zhTW: Record = { jobKill: '終止', jobKillConfirm: '再次點擊確認終止', jobKillError: '終止失敗', + tasksViewGraph: '工作流圖', + tasksViewTree: '樹狀圖', + tasksViewSwitchToGraph: '切換為工作流圖', + tasksViewSwitchToTree: '切換為樹狀圖', + settingsViewModeTitle: '任務管理預設檢視', + settingsViewModeDesc: '任務管理頁開啟時的預設顯示方式(頁內仍可臨時切換)', + settingsViewModeGraph: '工作流圖', + settingsViewModeGraphDesc: '分層節點圖,可縮放平移', + settingsViewModeTree: '樹狀圖', + settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航', + tasksWorkflowMember: '工作流成員', + tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldExpand: '展開已完成的節點', + tasksFoldCollapse: '收起已完成的節點', + tasksZoomIn: '放大', + tasksZoomOut: '縮小', + tasksZoomFit: '適應畫布', + tasksNodeJump: '檢視逐字稿', + tasksNodeState: '狀態', + tasksNodeMode: '模式', + tasksNodeModel: '模型', + tasksNodeTeamRole: '團隊角色', + tasksNodeActivity: '最近活動', + tasksStateRunning: '執行中', + tasksStateIdle: '閒置', + tasksStateDone: '已完成', + tasksStateError: '出錯', + teamBoard: '團隊任務板', + teamChip: '團隊 {members} 成員 · {tasks} 任務', + teamMembers: '成員', + teamTasksEmpty: '暫無共用任務', + teamTaskCreate: '新增任務', + teamTaskSubject: '標題', + teamTaskDescription: '描述', + teamTaskOwner: '負責人', + teamTaskUnowned: '未認領', + teamTaskPending: '待辦', + teamTaskInProgress: '進行中', + teamTaskCompleted: '已完成', + teamTaskBlocked: '受阻', + teamTaskComplete: '完成', + teamTaskReopen: '重開', + teamTaskDelete: '刪除', + teamTaskDeleteConfirm: '確認刪除', + teamTaskEdit: '編輯', + teamTaskSave: '儲存', + teamTaskCancel: '取消', + teamTaskConflict: '任務已被他人修改,已重新整理', + teamTaskError: '操作失敗:{message}', + teamTaskDeleted: '已刪除', + workflowRunning: '進行中', + workflowCompleted: '已完成', + workflowCancelled: '已取消', + workflowFailed: '失敗', + workflowPhaseUnnamed: '未命名階段', + workflowMembers: '{done}/{total} 成員', + jobsAutoCollapsed: '代理較多,已自動收起', + catalogLoadFailed: '{count} 個分支載入失敗', addPluginsTabCard: '新增 Tab 插件', addPluginsTabCardDesc: '註冊新的側邊欄頁面', addPluginsViewerCard: '新增預覽插件', diff --git a/src/client/locales.ts b/src/client/locales.ts index a869d85..76cfb2a 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -402,6 +402,64 @@ export const zh = { jobKill: '终止', jobKillConfirm: '再次点击确认终止', jobKillError: '终止失败', + tasksViewGraph: '工作流图', + tasksViewTree: '树状图', + tasksViewSwitchToGraph: '切换为工作流图', + tasksViewSwitchToTree: '切换为树状图', + settingsViewModeTitle: '任务管理默认视图', + settingsViewModeDesc: '任务管理页打开时的默认展示方式(页内仍可临时切换)', + settingsViewModeGraph: '工作流图', + settingsViewModeGraphDesc: '分层节点图,可缩放平移', + settingsViewModeTree: '树状图', + settingsViewModeTreeDesc: '经典缩进树,键盘可导航', + tasksWorkflowMember: '工作流成员', + tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldExpand: '展开已完成的节点', + tasksFoldCollapse: '折叠已完成的节点', + tasksZoomIn: '放大', + tasksZoomOut: '缩小', + tasksZoomFit: '适应画布', + tasksNodeJump: '查看转录', + tasksNodeState: '状态', + tasksNodeMode: '模式', + tasksNodeModel: '模型', + tasksNodeTeamRole: '团队角色', + tasksNodeActivity: '最近活动', + tasksStateRunning: '运行中', + tasksStateIdle: '待命', + tasksStateDone: '已完成', + tasksStateError: '出错', + teamBoard: '团队任务板', + teamChip: '团队 {members} 成员 · {tasks} 任务', + teamMembers: '成员', + teamTasksEmpty: '暂无共享任务', + teamTaskCreate: '新建任务', + teamTaskSubject: '标题', + teamTaskDescription: '描述', + teamTaskOwner: '负责人', + teamTaskUnowned: '未认领', + teamTaskPending: '待办', + teamTaskInProgress: '进行中', + teamTaskCompleted: '已完成', + teamTaskBlocked: '阻塞', + teamTaskComplete: '完成', + teamTaskReopen: '重开', + teamTaskDelete: '删除', + teamTaskDeleteConfirm: '确认删除', + teamTaskEdit: '编辑', + teamTaskSave: '保存', + teamTaskCancel: '取消', + teamTaskConflict: '任务已被他人修改,已刷新', + teamTaskError: '操作失败:{message}', + teamTaskDeleted: '已删除', + workflowRunning: '进行中', + workflowCompleted: '已完成', + workflowCancelled: '已取消', + workflowFailed: '失败', + workflowPhaseUnnamed: '未命名阶段', + workflowMembers: '{done}/{total} 成员', + jobsAutoCollapsed: '代理较多,已自动折叠', + catalogLoadFailed: '{count} 个分支加载失败', addPluginsTabCard: '添加 Tab 插件', addPluginsTabCardDesc: '注册新的侧边栏页面', addPluginsViewerCard: '添加预览插件', @@ -837,6 +895,64 @@ export const en: Record = { jobKill: 'Kill', jobKillConfirm: 'Click again to confirm kill', jobKillError: 'Kill failed', + tasksViewGraph: 'Workflow graph', + tasksViewTree: 'Tree', + tasksViewSwitchToGraph: 'Switch to the workflow graph', + tasksViewSwitchToTree: 'Switch to the tree', + settingsViewModeTitle: 'Tasks page default view', + settingsViewModeDesc: 'How the Tasks page renders on open (the in-page toggle still flips it ad hoc)', + settingsViewModeGraph: 'Workflow graph', + settingsViewModeGraphDesc: 'Layered node graph with zoom and pan', + settingsViewModeTree: 'Tree', + settingsViewModeTreeDesc: 'Classic indentation tree, keyboard navigable', + tasksWorkflowMember: 'Workflow member', + tasksFoldCompleted: '✓ {count} completed', + tasksFoldExpand: 'Expand the completed nodes', + tasksFoldCollapse: 'Collapse the completed nodes', + tasksZoomIn: 'Zoom in', + tasksZoomOut: 'Zoom out', + tasksZoomFit: 'Fit to canvas', + tasksNodeJump: 'Open transcript', + tasksNodeState: 'State', + tasksNodeMode: 'Mode', + tasksNodeModel: 'Model', + tasksNodeTeamRole: 'Team role', + tasksNodeActivity: 'Latest activity', + tasksStateRunning: 'Running', + tasksStateIdle: 'Idle', + tasksStateDone: 'Done', + tasksStateError: 'Error', + teamBoard: 'Team task board', + teamChip: 'Team {members} members · {tasks} tasks', + teamMembers: 'Members', + teamTasksEmpty: 'No shared tasks yet', + teamTaskCreate: 'New task', + teamTaskSubject: 'Subject', + teamTaskDescription: 'Description', + teamTaskOwner: 'Owner', + teamTaskUnowned: 'Unclaimed', + teamTaskPending: 'Pending', + teamTaskInProgress: 'In progress', + teamTaskCompleted: 'Completed', + teamTaskBlocked: 'Blocked', + teamTaskComplete: 'Complete', + teamTaskReopen: 'Reopen', + teamTaskDelete: 'Delete', + teamTaskDeleteConfirm: 'Confirm delete', + teamTaskEdit: 'Edit', + teamTaskSave: 'Save', + teamTaskCancel: 'Cancel', + teamTaskConflict: 'The task changed elsewhere; refreshed', + teamTaskError: 'Operation failed: {message}', + teamTaskDeleted: 'Deleted', + workflowRunning: 'Running', + workflowCompleted: 'Completed', + workflowCancelled: 'Cancelled', + workflowFailed: 'Failed', + workflowPhaseUnnamed: 'Unnamed phase', + workflowMembers: '{done}/{total} members', + jobsAutoCollapsed: 'Auto-collapsed (many agents)', + catalogLoadFailed: '{count} branches failed to load', addPluginsTabCard: 'Add tab plugins', addPluginsTabCardDesc: 'Register a new sidebar page', addPluginsViewerCard: 'Add preview plugins', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index f1aa683..c03e2a3 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -45,6 +45,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { autoOpenJobs: typeof record.autoOpenJobs === 'boolean' ? record.autoOpenJobs : SIDEBAR_PREFS_DEFAULTS.autoOpenJobs, + tasksViewMode: record.tasksViewMode === 'tree' || record.tasksViewMode === 'graph' + ? record.tasksViewMode + : SIDEBAR_PREFS_DEFAULTS.tasksViewMode, agentOpenTools: typeof record.agentOpenTools === 'boolean' ? record.agentOpenTools : SIDEBAR_PREFS_DEFAULTS.agentOpenTools, diff --git a/src/client/tasks-graph.module.css b/src/client/tasks-graph.module.css new file mode 100644 index 0000000..5bdfe2a --- /dev/null +++ b/src/client/tasks-graph.module.css @@ -0,0 +1,684 @@ +/** + * Styles of the Tasks page's graph canvas, tree rows, anchored popovers, and + * the bottom jobs drawer. Skin contract: every color resolves to a + * --dsw-alias-* / --dsw-font-* token (guarded by tests/theme.spec.ts); the + * hard-projection shadows mix a border token so they follow the theme. + */ + +/* ── Graph canvas ──────────────────────────────────────────────────────── */ + +.graphView { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.canvas { + position: absolute; + inset: 0; + cursor: grab; + touch-action: none; +} + +.canvasDragging { + cursor: grabbing; +} + +.canvasInner { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} + +.edges { + position: absolute; + top: 0; + left: 0; + overflow: visible; + pointer-events: none; +} + +.edge { + fill: none; + stroke: var(--dsw-alias-border-l2); + stroke-width: 1.5; +} + +.edgeTeam, +.edgeWorkflow { + stroke: var(--dsw-alias-accent); + stroke-opacity: 0.65; +} + +.edgeFold { + stroke-dasharray: 4 3; +} + +.phaseFrame { + position: absolute; + border: 1px dashed var(--dsw-alias-border-l2); + border-radius: 12px; + pointer-events: none; +} + +.phaseLabel { + position: absolute; + top: -9px; + left: 10px; + padding: 0 6px; + background: var(--dsw-alias-bg-base); + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-tertiary); + border-radius: 6px; +} + +/* ── Graph nodes ───────────────────────────────────────────────────────── */ + +.node { + position: absolute; + display: flex; + flex-direction: column; + gap: 2px; + padding: 7px 9px; + background: var(--dsw-alias-bg-layer-1); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 10px; + box-shadow: 3px 3px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 55%, transparent); + cursor: pointer; + overflow: hidden; +} + +.node:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.nodeRunning { + border-color: var(--dsw-alias-accent); +} + +.nodeCurrent { + box-shadow: 0 0 0 2px var(--dsw-alias-accent), 3px 3px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 55%, transparent); +} + +.nodeDone { + opacity: 0.68; +} + +.nodeError { + border-color: var(--dsw-alias-state-error-primary); +} + +.nodeFold { + border-style: dashed; + border-color: var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-base); + box-shadow: none; + align-items: flex-start; + justify-content: center; +} + +.nodeWorkflow { + border-color: var(--dsw-alias-accent); +} + +.nodeHeader { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.nodeDot { + flex-shrink: 0; +} + +.nodeTitle { + font: var(--dsw-font-xxxs-strong-11); + color: var(--dsw-alias-label-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nodeSecondary { + font-size: var(--dsw-font-xxxs-11-font-size); + line-height: var(--dsw-font-xxxs-11-line-height); + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nodeInfo { + position: absolute; + top: 4px; + right: 4px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 5px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s ease; +} + +.node:hover .nodeInfo, +.nodeInfo:focus-visible { + opacity: 1; +} + +.nodeInfo:hover { + background: var(--dsw-alias-interactive-bg-active); + color: var(--dsw-alias-label-primary); +} + +/* ── Live activity lines (icon + tool + args) ──────────────────────────── */ + +.liveLine { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + font-size: var(--dsw-font-xxxs-11-font-size); + line-height: var(--dsw-font-xxxs-11-line-height); + color: var(--dsw-alias-label-secondary); + white-space: nowrap; + overflow: hidden; +} + +.liveGlyph { + display: inline-flex; + flex-shrink: 0; + color: var(--dsw-alias-accent); +} + +.liveTool { + flex-shrink: 0; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.liveArgs { + overflow: hidden; + text-overflow: ellipsis; + color: var(--dsw-alias-label-tertiary); +} + +.liveText { + display: block; + font-size: var(--dsw-font-xxxs-11-font-size); + line-height: var(--dsw-font-xxxs-11-line-height); + color: var(--dsw-alias-label-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Control cluster (bottom-right; visible in BOTH modes) ─────────────── */ + +.controls { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 6; + display: flex; + flex-direction: column; + gap: 1px; + padding: 3px; + background: var(--dsw-alias-bg-layer-1); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 10px; + box-shadow: 2px 2px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 45%, transparent); +} + +.controlBtn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 7px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxxs-strong-11); + cursor: pointer; +} + +.controlBtn:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.controlBtnActive { + color: var(--dsw-alias-accent); +} + +.controlPct { + font-size: 9px; +} + +/* ── Tree mode ─────────────────────────────────────────────────────────── */ + +.treeView { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 6px 12px; +} + +.treeRow { + position: relative; + display: flex; + align-items: flex-start; + gap: 6px; + padding: 5px 8px; + margin-top: 2px; + border-radius: 8px; + cursor: pointer; +} + +.treeRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.treeRow:focus-visible { + outline: 2px solid var(--dsw-alias-accent); + outline-offset: -2px; +} + +.treeRowActive { + background: var(--dsw-alias-interactive-bg-active); +} + +.treeRowDone { + opacity: 0.68; +} + +.treeContent { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.treeFold { + border: 1px dashed var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxxs-11); +} + +.treeFold:hover { + color: var(--dsw-alias-label-primary); +} + +.treeInfo { + flex-shrink: 0; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 5px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s ease; +} + +.treeRow:hover .treeInfo, +.treeInfo:focus-visible { + opacity: 1; +} + +.treeInfo:hover { + background: var(--dsw-alias-interactive-bg-active); + color: var(--dsw-alias-label-primary); +} + +/* ── Anchored popover card ─────────────────────────────────────────────── */ + +.popCard { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + background: var(--dsw-alias-bg-layer-2); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + box-shadow: 4px 4px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 55%, transparent); + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-primary); +} + +.popTitle { + font: var(--dsw-font-xxxs-strong-11); + color: var(--dsw-alias-label-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.popRow { + display: flex; + gap: 8px; + align-items: baseline; + min-width: 0; +} + +.popKey { + flex-shrink: 0; + color: var(--dsw-alias-label-tertiary); +} + +.popValue { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--dsw-alias-label-primary); +} + +.popSection { + margin-top: 2px; + padding-top: 6px; + border-top: 1px solid var(--dsw-alias-border-l1); +} + +.popPrimary { + align-self: stretch; + padding: 5px 8px; + border: none; + border-radius: 8px; + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-accent-ink); + font: var(--dsw-font-xxxs-strong-11); + cursor: pointer; +} + +.popPrimary:hover { + background: var(--dsw-alias-button-primary-hover); +} + +.popGhost { + padding: 3px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 7px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxxs-11); + cursor: pointer; +} + +.popGhost:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.popGhostDanger { + color: var(--dsw-alias-state-error-primary); + border-color: var(--dsw-alias-state-error-primary); +} + +.popActions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.popError { + color: var(--dsw-alias-state-error-primary); +} + +.popNote { + color: var(--dsw-alias-state-warn-label); +} + +.popList { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 220px; + overflow-y: auto; +} + +.popListRow { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 7px; + cursor: pointer; +} + +.popListRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.popInput, +.popSelect { + width: 100%; + padding: 4px 7px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 7px; + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxxs-11); + box-sizing: border-box; +} + +.popInput:focus, +.popSelect:focus { + outline: none; + border-color: var(--dsw-alias-accent); +} + +.popPre { + margin: 0; + max-height: 180px; + overflow: auto; + padding: 6px 8px; + background: var(--dsw-alias-bg-base); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 8px; + font-family: var(--dsw-font-s-14); + font-size: var(--dsw-font-xxxs-11-font-size); + line-height: var(--dsw-font-xxxs-11-line-height); + color: var(--dsw-alias-label-secondary); + white-space: pre-wrap; + word-break: break-all; +} + +.popHint { + color: var(--dsw-alias-label-tertiary); +} + +/* ── Team chip in the header ───────────────────────────────────────────── */ + +.teamChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxxs-11); + cursor: pointer; + white-space: nowrap; +} + +.teamChip:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +/* ── Jobs drawer ───────────────────────────────────────────────────────── */ + +.jobsDrawer { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 5; + display: flex; + flex-direction: column; + background: var(--dsw-alias-bg-layer-1); + border-top: 1px solid var(--dsw-alias-border-l1); + box-shadow: 0 -3px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 35%, transparent); +} + +.jobsDrawerBar { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 7px 10px; + border: none; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxxs-strong-11); + cursor: pointer; + text-align: left; +} + +.jobsDrawerBar:hover { + color: var(--dsw-alias-label-primary); +} + +.jobsDrawerCount { + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xxxs-11); +} + +.jobsDrawerAuto { + margin-left: auto; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xxxs-11); +} + +.jobsDrawerChevron { + margin-left: auto; + display: inline-flex; + transition: transform 0.15s ease; +} + +.jobsDrawerChevronOpen { + transform: rotate(180deg); +} + +.jobsDrawerBody { + max-height: 42%; + overflow-y: auto; + padding: 0 6px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.jobsRow { + display: flex; + align-items: flex-start; + gap: 4px; +} + +.jobsRowMain { + flex: 1; + min-width: 0; + display: flex; + align-items: flex-start; + gap: 6px; + padding: 5px 6px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xxxs-11); + cursor: pointer; + text-align: left; +} + +.jobsRowMain:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.jobsRowSettled { + opacity: 0.72; +} + +.jobsContent { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.jobsLabelLine { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.jobsKind { + flex-shrink: 0; + color: var(--dsw-alias-label-tertiary); +} + +.jobsLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-primary); +} + +.jobsSecondary { + color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.jobsKill { + flex-shrink: 0; + margin-top: 4px; + padding: 2px 6px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xxxs-11); + cursor: pointer; +} + +.jobsKill:hover { + color: var(--dsw-alias-state-error-primary); + border-color: var(--dsw-alias-state-error-primary); +} + +.jobsKillArmed { + color: var(--dsw-alias-state-error-primary); + border-color: var(--dsw-alias-state-error-primary); +} + +.jobsKillError { + flex-shrink: 0; + margin-top: 4px; + color: var(--dsw-alias-state-error-primary); + font: var(--dsw-font-xxxs-11); +} diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx new file mode 100644 index 0000000..8a2e5d8 --- /dev/null +++ b/src/client/tasks-shared.tsx @@ -0,0 +1,88 @@ +/** + * Shared presentation bits of the Tasks page's two modes (graph + tree): + * the state-dot mapping, the secondary card line, the iconified live + * activity row ("glyph + tool + args" — the user-approved format replacing + * the old `>read[file]` text), and the fold aggregate caption. Kept in one + * module so graph and tree can never drift apart visually. + */ +import type { ReactNode } from 'react' +import { StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import type { LastActivity } from '../subagent-activity.ts' +import type { TasksAgentNode, TasksNodeState } from './tasks-model.ts' +import { toolGlyph } from './tool-icons.tsx' +import { t } from './locales.ts' +import css from './tasks-graph.module.css' + +/** Preview cap of one tool-call argument line. */ +const ARGS_PREVIEW = 60 + +/** The host StateDot semantic of a node display state. */ +export function nodeDotState(state: TasksNodeState): StateDotState { + switch (state) { + case 'running': return 'ongoing' + case 'idle': return 'idle' + case 'done': return 'done' + case 'error': return 'error' + } +} + +/** First `limit` characters with an ellipsis when truncated. */ +export function preview(text: string, limit: number = ARGS_PREVIEW): string { + return text.length > limit ? `${text.slice(0, limit)}…` : text +} + +/** Collapse whitespace for single-line previews. */ +export function flatten(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +/** + * The secondary line of an agent node: display title (when it differs from + * the durable label) · mode · activity · teammate model. Empty parts drop + * out. Synthesized workflow members show their origin instead of a mode. + */ +export function agentSecondary(node: TasksAgentNode): string { + const parts: string[] = [] + if (node.title !== undefined && node.title !== node.label) parts.push(node.title) + if (node.mode !== undefined) { + parts.push(node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')) + } else if (node.synthesized === true) { + parts.push(t('tasksWorkflowMember')) + } + parts.push(node.activity === 'running' ? t('subagentRunning') : t('subagentInactive')) + if (node.team?.model !== undefined) parts.push(node.team.model) + return parts.join(' · ') +} + +/** + * The live activity row of a RUNNING agent node: the tool line renders as + * "glyph + tool + args" (the approved live-line format), plus the flattened + * last text line underneath. A running node with neither reads "thinking…". + */ +export function LiveLine(props: { live: LastActivity | undefined }): ReactNode { + const { live } = props + if (live?.text === undefined && live?.tool === undefined) { + return {t('subagentThinking')} + } + return ( + <> + {live.tool !== undefined && ( + + + {live.tool.name} + {live.tool.args !== '' && ( + {preview(live.tool.args)} + )} + + )} + {live.text !== undefined && ( + {flatten(live.text)} + )} + + ) +} + +/** The fold aggregate's subtitle: up to two label previews joined by ·. */ +export function foldPreviews(previews: readonly string[]): string { + return previews.join(' · ') +} diff --git a/src/client/tool-icons.tsx b/src/client/tool-icons.tsx new file mode 100644 index 0000000..6531adf --- /dev/null +++ b/src/client/tool-icons.tsx @@ -0,0 +1,54 @@ +/** + * Tool-call glyphs for the live activity lines of the Tasks page (the + * "icon + tool + args" row on running agent nodes). A small explicit map of + * the common tool families onto host primitive icons; anything unmapped + * gets the generic ellipsis glyph. All icons are the host's own components + * (currentColor — zero color literals, the theme contract stays intact). + */ +import type { ReactNode } from 'react' +import { + IconCodeOutline16, + IconEditOutline16, + IconEllipsisOutline16, + IconGlobeOutline14, + IconPlayOutline16, + IconSearchOutline16, + IconThinkOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' + +/** One glyph renderer (host icon components accept a pixel size). */ +type Glyph = (size: number) => ReactNode + +/** The explicit tool-family map (longest-prefix wins at lookup). */ +const TOOL_GLYPHS: ReadonlyArray = [ + ['read', size => ], + ['write', size => ], + ['edit', size => ], + ['glob', size => ], + ['grep', size => ], + ['search', size => ], + ['bash', size => ], + ['shell', size => ], + ['web', size => ], + ['fetch', size => ], + ['think', size => ], +] + +/** The fallback glyph for unmapped tools. */ +const FALLBACK_GLYPH: Glyph = size => + +/** + * The glyph of one tool name: exact match first, then a prefix match so + * `mcp__fs__read_file`-style names still land on their family glyph. The + * lookup is case-insensitive. + */ +export function toolGlyph(toolName: string): Glyph { + const name = toolName.toLowerCase() + for (const [key, glyph] of TOOL_GLYPHS) { + if (name === key) return glyph + } + for (const [key, glyph] of TOOL_GLYPHS) { + if (name.includes(key)) return glyph + } + return FALLBACK_GLYPH +} diff --git a/src/config.ts b/src/config.ts index dc8d9b4..419f43b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -80,6 +80,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve export const PrefsSchema: z = z.object({ autoOpenSubagent: z.boolean().default(true), autoOpenJobs: z.boolean().default(true), + tasksViewMode: z.union([z.const('graph'), z.const('tree')]).default('graph'), agentOpenTools: z.boolean().default(false), editorExplorer: z.boolean().default(false), workspaceFence: z.boolean().default(true), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index ae8e987..a23b016 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -22,6 +22,11 @@ export interface SidebarPrefs { * conversation (any new job id, not just the first one). */ autoOpenJobs: boolean + /** + * The Tasks page's default presentation: the workflow graph canvas or the + * classic indentation tree (the in-page toggle still flips it ad hoc). + */ + tasksViewMode: 'graph' | 'tree' /** * Whether the model-facing `sidebar_open` tool is injected into the * model's toolset — one tool that lets the model actively open a local @@ -151,6 +156,7 @@ export type TitleBarScheme = typeof TITLE_BAR_SCHEMES[number] export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { autoOpenSubagent: true, autoOpenJobs: true, + tasksViewMode: 'graph', agentOpenTools: false, editorExplorer: false, workspaceFence: true, diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 4f757f9..777aa00 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -133,7 +133,11 @@ describe('built-in tab registrations', () => { it('the subagent tab declares its auto-open related settings', () => { const { service } = setup() const toggles = service.getTab('subagent')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs']) + expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs', 'tasksViewMode']) + // The default-view row is a graph/tree select (values are strings). + const viewMode = toggles[2] + expect(viewMode?.type).toBe('select') + expect((viewMode?.options ?? []).map(o => o.value)).toEqual(['graph', 'tree']) }) it('the editor tab declares its merged-mode (embedded file tree) setting', () => { diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 1bd3cb3..f5a493c 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -34,6 +34,7 @@ describe('side card preferences', () => { .toEqual({ autoOpenSubagent: false, autoOpenJobs: true, + tasksViewMode: 'graph', agentOpenTools: true, editorExplorer: false, workspaceFence: true, @@ -55,6 +56,7 @@ describe('side card preferences', () => { .toEqual({ autoOpenSubagent: true, autoOpenJobs: true, + tasksViewMode: 'graph', agentOpenTools: false, editorExplorer: false, workspaceFence: true, @@ -76,6 +78,7 @@ describe('side card preferences', () => { .toEqual({ autoOpenSubagent: true, autoOpenJobs: true, + tasksViewMode: 'graph', agentOpenTools: false, editorExplorer: false, workspaceFence: true, diff --git a/tests/subagent-jobs-view.spec.tsx b/tests/subagent-jobs-view.spec.tsx index b72f9b4..8c1da5f 100644 --- a/tests/subagent-jobs-view.spec.tsx +++ b/tests/subagent-jobs-view.spec.tsx @@ -1,26 +1,17 @@ /** - * Subagent page tests: the projection-backed topology (branch expansion, the - * `mode: 'unknown'` row, navigation and catalog retry) and the background-job - * section (rows read through `jobs.list` per tree session, the output dock - * with the OWNER session scope, the two-click kill confirm, and no polling - * while the page is hidden). + * Tasks page tests for the background-jobs DRAWER: rows render from the + * `jobsBySession` mirror, clicking a row peeks its output in an anchored + * popover (portaled to document.body) through `jobs.output` with the OWNER + * session scope, the kill button needs a two-click confirm, settled rows + * offer no kill, and the output popover never polls while the page is hidden. */ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createElement } from 'react' import { act } from 'react-dom/test-utils' -import { renderRoot, setupReactAct } from './test-utils.ts' - -setupReactAct() - +import { renderRoot } from './test-utils.ts' import { SubagentView } from '../src/client/SubagentView.tsx' -import type { - Context, - SidebarJobView, - SidebarSessionList, - SidebarSubagentCatalogEntry, - SidebarSubagentAddress, -} from '../src/context-types.ts' +import type { Context, SidebarSessionList } from '../src/context-types.ts' /** A subscribable sessions-list snapshot (mirror of the runtime list feed). */ function makeStore(initial: SidebarSessionList) { @@ -41,58 +32,48 @@ function makeStore(initial: SidebarSessionList) { type Store = ReturnType -/** The navigation spy the page must reach (DSH 0.1.6+ `ctx.uiWorkspace`). */ -interface NavigationSpy { - opened: Array -} - -/** The client context face SubagentView touches (everything else inert). */ -function makeCtx(store: Store, navigation?: NavigationSpy): Context { +/** The client context face SubagentView touches (history stub; everything else inert). */ +function makeCtx(store: Store): Context { return { sessions: { list: store, - open: (id: string) => { navigation?.opened.push(id) }, + setSubagentCatalogOpen: () => {}, + openSubagent: () => {}, + open: () => {}, + refreshSubagents: async () => {}, + }, + connection: { + api: { + subagents: { + history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }), + }, + }, }, - get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined - ? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } } - : undefined), } as unknown as Context } const outputCalls: Array<{ sessionId: string; id: string }> = [] const killCalls: Array<{ sessionId: string; id: string }> = [] -/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */ -let jobsByOwner: Record = {} -/** The owner sessions the page actually read (the fence's unit of access). */ -let jobListCalls: string[] = [] function jsonResponse(value: unknown): Response { return { ok: true, status: 200, json: async () => value } as unknown as Response } -/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */ -function entry( - id: string, - mode: SidebarSubagentCatalogEntry['mode'], - label?: string, -): SidebarSubagentCatalogEntry { - return { id, createdAt: 1_000, mode, ...(label === undefined ? {} : { label }) } -} - -/** One session's loaded projection snapshot. */ -function ready(entries: SidebarSubagentCatalogEntry[]) { - return { values: { subagentCatalog: entries }, state: 'ready' as const, error: null } -} - function baseSnapshot(): SidebarSessionList { return { + current: 'root', byId: { root: { id: 'root', displayTitle: '主会话', running: true }, child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true }, }, - projectionsBySession: { - root: ready([entry('child', 'continuable')]), - child: ready([]), + subagentsByParent: {}, + jobsBySession: { + root: [ + { id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }, + ], + child: [ + { id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }, + ], }, } } @@ -100,22 +81,12 @@ function baseSnapshot(): SidebarSessionList { beforeEach(() => { outputCalls.length = 0 killCalls.length = 0 - jobListCalls = [] - jobsByOwner = { - root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }], - child: [{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }], - } vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { const method = String(url).split('/').pop() const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string } if (method === 'subagents.live') { return jsonResponse({ ok: true, value: { live: {} } }) } - if (method === 'jobs.list') { - const owner = body.sessionId ?? '' - jobListCalls.push(owner) - return jsonResponse({ ok: true, value: { jobs: jobsByOwner[owner] ?? [] } }) - } if (method === 'jobs.output') { outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' }) // bash-9 stands for a job the model never read (read:false). @@ -132,6 +103,12 @@ beforeEach(() => { killCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' }) return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } }) } + if (method === 'workflows.list') { + return jsonResponse({ ok: true, value: { runs: [] } }) + } + if (method === 'teams.view') { + return jsonResponse({ ok: true, value: { available: false } }) + } throw new Error(`unexpected fetch ${String(url)}`) }) Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true }) @@ -139,139 +116,15 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() - vi.useRealTimers() for (const el of document.querySelectorAll('body > div')) el.remove() }) -/** - * Flush the mount-time job reads: the poll fires immediately, and the request - * → envelope → json → Promise.all chain settles over several microtask ticks. - */ -async function flushJobs(): Promise { - for (let tick = 0; tick < 4; tick++) { - await act(async () => { await Promise.resolve() }) - } -} - -/** Render the page and wait for its first job read to land. */ -async function renderPage(store: Store, active = true, navigation?: NavigationSpy) { - const rendered = renderRoot( - createElement(SubagentView, { sessionId: 'root', active, ctx: makeCtx(store, navigation) }), - ) - await flushJobs() - return rendered -} - -/** One topology row by its aria-label prefix. */ -function row(container: HTMLElement, label: string): HTMLElement { - const found = container.querySelector(`[role="treeitem"][aria-label^="${label}"]`) - if (found === null) throw new Error(`no topology row labelled ${label}`) - return found -} - -describe('SubagentView topology (projectionsBySession)', () => { - it('renders the tree and expands a branch inferred from its own catalog', async () => { - const store = makeStore({ - byId: { - root: { id: 'root', displayTitle: '主会话' }, - child: { id: 'child', displayTitle: 'Worker', origin: 'subagent', parentId: 'root' }, - grand: { id: 'grand', displayTitle: 'Grandchild', origin: 'subagent', parentId: 'child' }, - }, - projectionsBySession: { - root: ready([entry('child', 'continuable', 'Worker')]), - // The child carries a child of its own: `hasChildren` is not in the - // 0.1.7 row, it is the child's own catalog that says so. - child: ready([entry('grand', 'one-shot', 'Grandchild')]), - grand: ready([]), - }, - }) - const { container, unmount } = await renderPage(store) - const branch = row(container, 'Worker') - expect(branch.getAttribute('aria-level')).toBe('1') - expect(branch.getAttribute('aria-expanded')).toBe('true') - // The grandchild is rendered one level down and is a KNOWN LEAF (its own - // catalog loaded empty), so it carries no disclosure. - const leaf = row(container, 'Grandchild') - expect(leaf.getAttribute('aria-level')).toBe('2') - expect(leaf.hasAttribute('aria-expanded')).toBe(false) - // Mode + activity come from the row and the live channel respectively. - expect(branch.getAttribute('aria-label')).toContain('可续接') - expect(leaf.getAttribute('aria-label')).toContain('一次性') - expect(branch.getAttribute('aria-label')).toContain('空闲') - unmount() - }) - - it('keeps a mode-less (unknown) row unlabelled instead of claiming a mode', async () => { - const store = makeStore({ - byId: { root: { id: 'root', displayTitle: '主会话' } }, - projectionsBySession: { - // 0.1.7's third mode: a child the host kept without a readable - // descriptor. It claims neither mode, so the card states neither. - root: ready([entry('opaque', 'unknown', 'Opaque child')]), - opaque: ready([]), - }, - }) - const { container, unmount } = await renderPage(store) - const unknown = row(container, 'Opaque child') - const label = unknown.getAttribute('aria-label') ?? '' - expect(label).toContain('Opaque child') - expect(label).toContain('空闲') - expect(label).not.toContain('一次性') - expect(label).not.toContain('可续接') - unmount() - }) - - it('opens a child through the workspace face (the sessions face is gone since 0.1.6)', async () => { - const navigation: NavigationSpy = { opened: [] } - const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store, true, navigation) - await act(async () => { row(container, '子代理').click() }) - // The runtime removed ISessions.openSubagent after 0.1.5-rc.2: the - // workspace face owns main-view selection and is what must be called. - expect(navigation.opened).toEqual([ - { parentSessionId: 'root', childSessionId: 'child', mode: 'continuable' }, - ]) - unmount() - }) - - it('retries a failed catalog through the 0.1.7 refresh name', async () => { - const refreshed: string[] = [] - const store = makeStore({ - byId: { root: { id: 'root', displayTitle: '主会话' } }, - projectionsBySession: { - root: { - values: {}, - state: 'error', - error: { code: 'boom', message: '目录读取失败' }, - }, - }, - }) - const rendered = renderRoot(createElement(SubagentView, { - sessionId: 'root', - active: true, - ctx: { - sessions: { - list: store, - refreshProjections: async (sessionId: string) => { refreshed.push(sessionId) }, - }, - get: () => undefined, - } as unknown as Context, - })) - await flushJobs() - expect(rendered.container.textContent).toContain('目录读取失败') - const retry = [...rendered.container.querySelectorAll('button')] - .find(button => button.textContent?.includes('重试')) - expect(retry).toBeDefined() - await act(async () => { retry?.click() }) - expect(refreshed).toEqual(['root']) - rendered.unmount() - }) -}) - describe('SubagentView background jobs', () => { - it('renders the tree jobs with status, durations, and owner labels', async () => { + it('renders the tree jobs with status, durations, and owner labels', () => { const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) const text = container.textContent ?? '' expect(text).toContain('后台任务') expect(text).toContain('2 个后台任务 · 1 运行中') @@ -280,99 +133,97 @@ describe('SubagentView background jobs', () => { expect(text).toContain('echo hi') expect(text).toContain('主会话') expect(text).toContain('子代理') - // The fence admits a job to its owner only, so the tree costs one read per - // tree session (and never a read of a session outside the tree). - expect([...jobListCalls].sort()).toEqual(['child', 'root']) // Only the running row offers a kill button. expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1) unmount() }) - it('renders nothing job-related when the tree has no jobs', async () => { - jobsByOwner = {} - const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } }) - const { container, unmount } = await renderPage(store) + it('renders nothing job-related when the mirror is empty', () => { + const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} }) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) expect(container.textContent).not.toContain('后台任务') unmount() }) - it('survives the job list emptying while mounted (hook-order regression #300)', async () => { - vi.useFakeTimers() - try { - const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store) - expect(container.textContent).toContain('sleep 300') - // Every job settles and drops: the section must vanish WITHOUT reordering - // hooks — a hook below the empty-state return would crash React with - // "Rendered fewer hooks than expected" (the minified #300 the sidebar - // boundary surfaces with a retry button). - jobsByOwner = {} - await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) - expect(container.textContent).not.toContain('后台任务') - // And returning jobs must work too, with the same hook order. - jobsByOwner = { - root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }], - } - await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) - expect(container.textContent).toContain('sleep 300') - unmount() - } finally { - vi.useRealTimers() - } - }) - - it('shows the selected job output in the bottom dock, closeable', async () => { + it('survives the mirror emptying while mounted (hook-order regression #300)', async () => { const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store) - const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement - await act(async () => { first.click() }) - // The peek request carries the OWNER session (the fence compares it). - expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }]) - expect(container.textContent).toContain('output-of-bash-1') - // Exactly one dock region exists (never one per row). - expect(container.querySelectorAll('[role="region"]')).toHaveLength(1) - // The selected row is marked, and the close button dismisses the dock. - expect(first.getAttribute('aria-pressed')).toBe('true') - const close = container.querySelector('button[aria-label="关闭"]') as HTMLButtonElement - await act(async () => { close.click() }) - expect(container.textContent).not.toContain('output-of-bash-1') - expect(container.querySelectorAll('[role="region"]')).toHaveLength(0) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + expect(container.textContent).toContain('sleep 300') + // The mirror empties (all jobs settled and dropped): the section must + // vanish WITHOUT reordering hooks — a hook below the empty-state return + // would crash React with "Rendered fewer hooks than expected" (the + // minified #300 the sidebar boundary surfaces with a retry button). + await act(async () => { store.set({ ...baseSnapshot(), jobsBySession: {} }) }) + expect(container.textContent).not.toContain('后台任务') + // And returning jobs must work too, with the same hook order. + await act(async () => { store.set(baseSnapshot()) }) + expect(container.textContent).toContain('sleep 300') unmount() }) - it('switches the single dock between selected rows', async () => { + it('shows the selected job output in an anchored popover, dismissable', async () => { const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement + await act(async () => { row.click() }) + // The peek request carries the OWNER session (the fence compares it). + expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }]) + // The popover is portaled to document.body (outside the tab container). + expect(document.body.textContent).toContain('output-of-bash-1') + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1) + // Escape dismisses the popover. + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + expect(document.body.textContent).not.toContain('output-of-bash-1') + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(0) + unmount() + }) + + it('switches the single output popover between rows', async () => { + const store = makeStore(baseSnapshot()) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement await act(async () => { first.click() }) - expect(container.textContent).toContain('output-of-bash-1') + expect(document.body.textContent).toContain('output-of-bash-1') const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement await act(async () => { second.click() }) - // One dock, now fed by the second job (its owner session scopes the replay). + // One popover, now fed by the second job (its owner session scopes the replay). expect(outputCalls).toEqual([ { sessionId: 'root', id: 'bash-1' }, { sessionId: 'child', id: 'bash-2' }, ]) - expect(container.querySelectorAll('[role="region"]')).toHaveLength(1) - expect(container.textContent).not.toContain('output-of-bash-1') - expect(container.textContent).toContain('output-of-bash-2') + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1) + expect(document.body.textContent).not.toContain('output-of-bash-1') + expect(document.body.textContent).toContain('output-of-bash-2') unmount() }) it('explains when the model has not read the job yet', async () => { - jobsByOwner = { + const snapshot = baseSnapshot() + snapshot.jobsBySession = { root: [ - ...jobsByOwner.root ?? [], + ...(snapshot.jobsBySession?.root ?? []), { id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 }, ], } - const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store) - const unread = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement - await act(async () => { unread.click() }) - // read:false → the pane explains the output awaits the model's job_output + const store = makeStore(snapshot) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement + await act(async () => { row.click() }) + // read:false → the popover explains the output awaits the model's job_output // (never the model's cursor, so there is nothing to steal yet). - expect(container.textContent).toContain('等待模型读取该任务的输出') + expect(document.body.textContent).toContain('等待模型读取该任务的输出') unmount() }) @@ -385,22 +236,30 @@ describe('SubagentView background jobs', () => { startedAt: 1_000 + index, ...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }), })) - jobsByOwner = { root: many } - const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } }) - const { container, unmount } = await renderPage(store) + const store = makeStore({ + current: 'root', + byId: { root: { id: 'root', displayTitle: '主会话' } }, + subagentsByParent: {}, + jobsBySession: { root: many }, + }) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) expect(container.textContent).toContain('60 个后台任务 · 30 运行中') expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60) - // Clicking a row anywhere in the long list still feeds the single dock. - const bulk = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement - await act(async () => { bulk.click() }) + // Clicking a row anywhere in the long list still feeds the single popover. + const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement + await act(async () => { row.click() }) expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-69' }]) - expect(container.textContent).toContain('output-of-bash-69') + expect(document.body.textContent).toContain('output-of-bash-69') unmount() }) it('kills a live job only after the two-click confirm', async () => { const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement await act(async () => { kill.click() }) // First click only arms the confirm — no request leaves the page. @@ -412,54 +271,21 @@ describe('SubagentView background jobs', () => { unmount() }) - it('does not read jobs or poll output while the page is hidden', async () => { + it('does not poll the output while the page is hidden', async () => { vi.useFakeTimers() try { const store = makeStore(baseSnapshot()) - const { container, unmount } = await renderPage(store, false) - // A hidden page issues no job read at all (it also has no rows to show). - expect(jobListCalls).toEqual([]) - expect(container.textContent).not.toContain('后台任务') + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }), + ) + const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement + await act(async () => { row.click() }) await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) - expect(jobListCalls).toEqual([]) - expect(outputCalls).toEqual([]) + // One peek for the initial load; the 2s poll never ran while inactive. + expect(outputCalls).toHaveLength(1) unmount() } finally { vi.useRealTimers() } }) - - it('stops polling the output and the job lists while the page is hidden', async () => { - vi.useFakeTimers() - try { - const store = makeStore(baseSnapshot()) - // Render visible to load the rows (a hidden page has none), then hide the - // page and hold the counters still. - const rendered = renderRoot(createElement(SubagentView, { - sessionId: 'root', - active: true, - ctx: makeCtx(store), - })) - await flushJobs() - const first = rendered.container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement - await act(async () => { first.click() }) - expect(outputCalls).toHaveLength(1) - rendered.rerender(createElement(SubagentView, { - sessionId: 'root', - active: false, - ctx: makeCtx(store), - })) - // The visibility flip itself re-pulls the dock once (the pane's own - // effect deps include `active`); what must stop is everything after it. - await flushJobs() - const peeks = outputCalls.length - const reads = jobListCalls.length - await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) - expect(outputCalls).toHaveLength(peeks) - expect(jobListCalls).toHaveLength(reads) - rendered.unmount() - } finally { - vi.useRealTimers() - } - }) }) diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx new file mode 100644 index 0000000..fe54794 --- /dev/null +++ b/tests/tasks-page.spec.tsx @@ -0,0 +1,185 @@ +/** + * Tasks page interaction tests (jsdom): the jobs drawer's auto-collapse at + * the agent-count threshold, the graph/tree view toggle visible in BOTH + * modes, node click → transcript jump, ⓘ → anchored popover, and the + * settled-leaf fold aggregate expanding on click. + */ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { act } from 'react-dom/test-utils' +import { renderRoot } from './test-utils.ts' +import { SubagentView } from '../src/client/SubagentView.tsx' +import type { + Context, + SidebarSessionList, + SidebarSessionSummary, + SidebarSubagentCatalog, +} from '../src/context-types.ts' + +/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */ +function makeStore(initial: SidebarSessionList) { + let snapshot = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => { listeners.delete(fn) } + }, + set(next: SidebarSessionList) { + snapshot = next + for (const fn of [...listeners]) fn() + }, + } +} + +type Store = ReturnType + +/** The client context face SubagentView touches. */ +function makeCtx(store: Store, spies: { openSubagent?: (address: unknown) => void } = {}): Context { + return { + sessions: { + list: store, + setSubagentCatalogOpen: () => {}, + openSubagent: spies.openSubagent ?? (() => {}), + open: () => {}, + refreshSubagents: async () => {}, + }, + } as unknown as Context +} + +function jsonResponse(value: unknown): Response { + return { ok: true, status: 200, json: async () => value } as unknown as Response +} + +beforeEach(() => { + vi.stubGlobal('fetch', async (url: string | URL | Request) => { + const method = String(url).split('/').pop() + if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } }) + if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } }) + if (method === 'teams.view') return jsonResponse({ ok: true, value: { available: false } }) + if (method === 'jobs.output') return jsonResponse({ ok: true, value: { text: 'out', truncated: false, read: true } }) + throw new Error(`unexpected fetch ${String(url)}`) + }) + Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + for (const el of document.querySelectorAll('body > div')) el.remove() +}) + +/** A snapshot with N direct children of root (all settled one-shots). */ +function snapshotWithChildren(count: number): SidebarSessionList { + const byId: Record = { + root: { id: 'root', displayTitle: '主会话', running: true }, + } + const entries: SidebarSubagentCatalog['entries'] = [] + for (let index = 0; index < count; index += 1) { + const id = `child-${index}` + byId[id] = { id, displayTitle: id, origin: 'subagent', parentId: 'root', running: false } + entries.push({ kind: 'child', id, activity: 'inactive', hasChildren: false, mode: 'one-shot', label: `子代理 ${index}` }) + } + return { + current: 'root', + byId, + subagentsByParent: { + root: { entries, parentAvailable: true, state: 'ready', error: null }, + }, + jobsBySession: { + root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }], + }, + } +} + +describe('Tasks page interactions', () => { + it('keeps the jobs drawer open below the agent threshold', () => { + const store = makeStore(snapshotWithChildren(2)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + // 3 agents (root + 2 children) < 8: the drawer renders its rows open. + expect(container.textContent).toContain('sleep 300') + expect(container.textContent).not.toContain('已自动折叠') + unmount() + }) + + it('auto-collapses the jobs drawer at 8+ agents, expandable by the bar', async () => { + const store = makeStore(snapshotWithChildren(8)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + // 9 agents ≥ 8: collapsed, the auto note explains, no rows rendered. + expect(container.textContent).not.toContain('sleep 300') + expect(container.textContent).toContain('已自动折叠') + const bar = container.querySelector('button[aria-expanded]') as HTMLButtonElement + expect(bar.getAttribute('aria-expanded')).toBe('false') + await act(async () => { bar.click() }) + expect(container.textContent).toContain('sleep 300') + unmount() + }) + + it('toggles between graph and tree with the cluster visible in BOTH modes', async () => { + const store = makeStore(snapshotWithChildren(2)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + // Default graph: the toggle offers the tree switch. + const toTree = container.querySelector('button[aria-label="切换为树状图"]') as HTMLButtonElement + expect(toTree).not.toBeNull() + expect(container.querySelector('[role="group"]')).not.toBeNull() + await act(async () => { toTree.click() }) + // Tree mode: the toggle stays (the mockup's hidden-toggle bug is guarded). + const toGraph = container.querySelector('button[aria-label="切换为工作流图"]') as HTMLButtonElement + expect(toGraph).not.toBeNull() + expect(container.querySelector('[role="tree"]')).not.toBeNull() + await act(async () => { toGraph.click() }) + expect(container.querySelector('[role="group"]')).not.toBeNull() + unmount() + }) + + it('jumps to the transcript on node click and opens the ⓘ popover', async () => { + const opened: unknown[] = [] + const store = makeStore(snapshotWithChildren(1)) + const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } }) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx }), + ) + // Folded by default: unfold so the settled child is visible. + const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement + await act(async () => { foldToggle.click() }) + const node = container.querySelector('[aria-label*="子代理 0"]') as HTMLElement + await act(async () => { node.click() }) + expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }]) + unmount() + }) + + it('folds settled leaves into an aggregate that expands on click', async () => { + const store = makeStore(snapshotWithChildren(3)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + // Folded: one aggregate node, no individual children. + expect(container.textContent).toContain('✓ 3 已完成') + expect(container.querySelector('[aria-label*="子代理 0"]')).toBeNull() + const fold = container.querySelector('[aria-label*="✓ 3 已完成"]') as HTMLElement + await act(async () => { fold.click() }) + expect(container.textContent).toContain('子代理 0') + expect(container.textContent).toContain('子代理 2') + unmount() + }) + + it('opens the node detail popover from the ⓘ button', async () => { + const store = makeStore(snapshotWithChildren(1)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + // The root node's ⓘ opens the detail card (portaled to body). + const info = container.querySelector('button[aria-label="状态"]') as HTMLButtonElement + await act(async () => { info.click() }) + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1) + expect(document.body.textContent).toContain('查看转录') + unmount() + }) +})