feat(client): workflow-graph Tasks page with teams board and jobs drawer

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.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:14 +08:00
parent fdc4520080
commit 6448d8bad9
38 changed files with 4190 additions and 1387 deletions
+107
View File
@@ -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<HTMLDivElement>(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(
<div
ref={cardRef}
role="dialog"
style={{
position: 'fixed',
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
width: Math.min(POP_WIDTH, window.innerWidth - 16),
zIndex: 90,
visibility: pos === null ? 'hidden' : 'visible',
}}
>
{children}
</div>,
document.body,
)
}
+248
View File
@@ -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<boolean | undefined>(undefined)
const open = manualOpen ?? autoOpen
const [armedId, setArmedId] = useState<string | undefined>(undefined)
const [killingId, setKillingId] = useState<string | undefined>(undefined)
const [killErrorId, setKillErrorId] = useState<string | undefined>(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<void> => {
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 (
<section className={css.jobsDrawer} aria-label={t('jobs')}>
<button
type="button"
className={css.jobsDrawerBar}
aria-expanded={open}
onClick={() => { setManualOpen(!open) }}
>
<span>{t('jobs')}</span>
<span className={css.jobsDrawerCount}>{countLabel}</span>
{!open && !autoOpen && <span className={css.jobsDrawerAuto}>{t('jobsAutoCollapsed')}</span>}
<span className={clsx(css.jobsDrawerChevron, open && css.jobsDrawerChevronOpen)} aria-hidden="true">
<IconChevronUpOutline14 size={12} />
</span>
</button>
{open && (
<div className={css.jobsDrawerBody}>
{rows.map((row) => {
const { job } = row
const live = isJobLive(job)
const armed = armedId === job.id
const killing = killingId === job.id
const killFailed = killErrorId === job.id
const elapsed = live
? now - job.startedAt
: (job.finishedAt ?? job.startedAt) - job.startedAt
const secondary = [
...(multiOwner ? [row.ownerTitle] : []),
jobStatusLabel(job.status, t),
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
formatJobDuration(elapsed, t),
].filter(Boolean).join(' · ')
return (
<div key={job.id} className={clsx(css.jobsRow, !live && css.jobsRowSettled)}>
<button
type="button"
className={css.jobsRowMain}
aria-label={`${job.label} ${secondary}`}
title={t('jobViewOutput')}
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
>
<StateDot state={jobDotState(job.status)} size={8} />
<span className={css.jobsContent}>
<span className={css.jobsLabelLine}>
<span className={css.jobsKind}>{job.kind}</span>
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
</span>
<span className={css.jobsSecondary}>{secondary}</span>
</span>
</button>
{job.status === 'running' && (
<button
type="button"
className={clsx(css.jobsKill, armed && css.jobsKillArmed)}
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing}
onClick={(event) => {
event.stopPropagation()
if (armed) void kill(row)
else setArmedId(job.id)
}}
>
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
</button>
)}
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
</div>
)
})}
</div>
)}
</section>
)
}
/**
* 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<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null)
const load = useCallback(async (): Promise<void> => {
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 (
<div className={css.popCard} style={{ width: 340 }}>
<span className={css.popTitle} title={job.label}>
<StateDot state={jobDotState(job.status)} size={8} /> {job.label}
</span>
<span className={css.popHint}>
{jobStatusLabel(job.status, t)}
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
</span>
{state === 'loading' && <div className={css.popHint}>{t('loading')}</div>}
{state === 'error' && <div className={css.popError}>{t('jobOutputError')}</div>}
{typeof state === 'object' && (
<>
{state.text.length > 0
? <pre ref={preRef} className={css.popPre}>{state.text}</pre>
: state.read
? <div className={css.popHint}>{t('jobNoOutput')}</div>
: <div className={css.popHint}>{t('jobNotReadYet')}</div>}
{state.truncated && <div className={css.popHint}>{t('jobOutputTruncated')}</div>}
</>
)}
</div>
)
}
+4 -325
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+451
View File
@@ -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 (
<button
type="button"
className={css.controlBtn}
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
>
{toTree ? '☰' : '⌗'}
</button>
)
}
/** The fold toggle button (shared by both modes). */
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
const { folded, onToggleFold } = props
return (
<button
type="button"
className={clsx(css.controlBtn, folded && css.controlBtnActive)}
aria-pressed={folded}
aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
onClick={onToggleFold}
>
<span
aria-hidden="true"
style={{ display: 'inline-flex', transform: folded ? 'rotate(90deg)' : 'rotate(180deg)' }}
>
<IconChevronUpOutline14 size={12} />
</span>
</button>
)
}
export function TasksGraph(props: TasksGraphProps): ReactNode {
const { nodes, folded, rootId, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange } = props
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>): 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 (
<div className={css.graphView}>
<div
ref={containerRef}
className={clsx(css.canvas, dragging && css.canvasDragging)}
role="group"
aria-label={t('tasksViewGraph')}
onPointerDown={onPointerDown}
>
<div
className={css.canvasInner}
style={{
width: layout.width,
height: layout.height,
transform: `translate(${tf.x}px, ${tf.y}px) scale(${tf.k})`,
}}
>
<svg className={css.edges} width={layout.width} height={layout.height} aria-hidden="true">
{edges.map((edge) => {
const from = layout.boxes.get(edge.from)
const to = layout.boxes.get(edge.to)
if (from === undefined || to === undefined) return null
const target = nodeById.get(edge.to)
return (
<path
key={`${edge.from}->${edge.to}`}
className={clsx(
css.edge,
(edge.kind === 'team' || edge.kind === 'workflow') && css.edgeTeam,
target?.kind === 'fold' && css.edgeFold,
)}
d={edgePath(from, to)}
/>
)
})}
</svg>
{phaseFrames.map(frame => (
<div
key={frame.key}
className={css.phaseFrame}
style={{ left: frame.x, top: frame.y, width: frame.w, height: frame.h }}
>
<span className={css.phaseLabel}>{frame.title ?? t('workflowPhaseUnnamed')}</span>
</div>
))}
{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)
})}
</div>
</div>
<div className={css.controls}>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
<button
type="button"
className={css.controlBtn}
aria-label={t('tasksZoomOut')}
title={t('tasksZoomOut')}
onClick={() => { zoomBy(1 / 1.25) }}
>
−
</button>
<button
type="button"
className={clsx(css.controlBtn, css.controlPct)}
aria-label={t('tasksZoomFit')}
title={t('tasksZoomFit')}
onClick={fit}
>
{Math.round(tf.k * 100)}%
</button>
<button
type="button"
className={css.controlBtn}
aria-label={t('tasksZoomIn')}
title={t('tasksZoomIn')}
onClick={() => { zoomBy(1.25) }}
>
+
</button>
</div>
</div>
)
}
/** 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 (
<div
key={node.id}
role="button"
tabIndex={-1}
aria-label={`${node.label} ${agentSecondary(node)}`}
aria-current={node.current ? 'true' : undefined}
className={clsx(
css.node,
node.state === 'running' && css.nodeRunning,
node.state === 'done' && css.nodeDone,
node.state === 'error' && css.nodeError,
node.current && css.nodeCurrent,
)}
style={style}
onClick={(event) => {
if (!clickAllowed()) return
onActivate(node)
void event
}}
>
<span className={css.nodeHeader}>
<StateDot state={nodeDotState(node.state)} size={8} className={css.nodeDot} />
<span className={css.nodeTitle} title={node.label}>{node.label}</span>
</span>
<span className={css.nodeSecondary} title={agentSecondary(node)}>{agentSecondary(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<button
type="button"
className={css.nodeInfo}
aria-label={t('tasksNodeState')}
title={t('tasksNodeState')}
onClick={(event) => {
event.stopPropagation()
onNodeInfo(node, event.currentTarget)
}}
>
ⓘ
</button>
</div>
)
}
/** 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 (
<div
key={node.id}
role="button"
tabIndex={-1}
aria-label={node.run.name}
className={clsx(css.node, css.nodeWorkflow, node.run.status === 'running' && css.nodeRunning)}
style={style}
onClick={(event) => {
if (!clickAllowed()) return
onWorkflowInfo(node, event.currentTarget)
}}
>
<span className={css.nodeHeader}>
<span aria-hidden="true">▶</span>
<span className={css.nodeTitle} title={node.run.name}>{node.run.name}</span>
</span>
<span className={css.nodeSecondary}>
{`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done: tally.done, total: tally.total })}`}
</span>
</div>
)
}
/** One fold aggregate node. */
function renderFoldNode(
node: TasksFoldNode,
style: { left: number; top: number; width: number; minHeight: number },
onToggleFold: () => void,
clickAllowed: () => boolean,
): ReactNode {
return (
<div
key={node.id}
role="button"
tabIndex={-1}
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
className={clsx(css.node, css.nodeFold)}
style={style}
onClick={() => {
if (!clickAllowed()) return
onToggleFold()
}}
>
<span className={css.nodeTitle}>{t('tasksFoldCompleted', { count: node.count })}</span>
<span className={css.nodeSecondary}>{foldPreviews(node.previews)}</span>
</div>
)
}
+417
View File
@@ -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 (
<div className={css.popRow}>
<span className={css.popKey}>{props.label}</span>
<span className={css.popValue}>{props.children}</span>
</div>
)
}
/** 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 (
<div className={css.popCard}>
<span className={css.popTitle} title={node.label}>{node.label}</span>
<PopRow label={t('tasksNodeState')}>
<StateDot state={nodeDotState(node.state)} size={8} /> {t(stateKey(node.state))}
</PopRow>
{node.mode !== undefined && (
<PopRow label={t('tasksNodeMode')}>
{node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
</PopRow>
)}
{node.team !== undefined && (
<>
<PopRow label={t('tasksNodeTeamRole')}>
{node.team.role === 'lead' ? 'lead' : node.team.name}
</PopRow>
{node.team.model !== undefined && (
<PopRow label={t('tasksNodeModel')}>{node.team.model}</PopRow>
)}
</>
)}
{(liveTool !== undefined || liveText !== undefined) && (
<PopRow label={t('tasksNodeActivity')}>{liveTool ?? liveText}</PopRow>
)}
{node.childAddress !== undefined || node.parentId === undefined ? (
<button
type="button"
className={css.popPrimary}
onClick={() => { onJump(node) }}
>
{t('tasksNodeJump')}
</button>
) : null}
</div>
)
}
/** 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 (
<div className={css.popCard}>
<span className={css.popTitle} title={run.name}>▶ {run.name}</span>
<PopRow label={t('tasksNodeState')}>
{t(run.status === 'running'
? 'workflowRunning'
: run.status === 'completed'
? 'workflowCompleted'
: run.status === 'cancelled' ? 'workflowCancelled' : 'workflowFailed')}
</PopRow>
<div className={css.popSection}>
<div className={css.popList}>
{run.phases.map((phase, phaseIndex) => (
<div key={`${phase.title ?? 'phase'}-${phaseIndex}`}>
<div className={clsx(css.popKey, css.popRow)}>
{phase.title ?? t('workflowPhaseUnnamed')}
</div>
{phase.members.map(member => (
<div
key={member.seq}
role={member.childId === '' ? undefined : 'button'}
className={css.popListRow}
onClick={() => {
if (member.childId === '') return
onJumpMember({
parentSessionId: run.originSessionId,
childSessionId: member.childId,
mode: 'one-shot',
})
}}
>
<StateDot
size={8}
state={member.outcome === undefined
? 'ongoing'
: member.outcome === 'completed' ? 'done'
: member.outcome === 'failed' ? 'error' : 'idle'}
/>
<span className={css.popValue} title={member.label}>{member.label}</span>
</div>
))}
</div>
))}
</div>
</div>
</div>
)
}
/** 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<string | undefined>(undefined)
const [editingId, setEditingId] = useState<string | undefined>(undefined)
const [creating, setCreating] = useState(false)
const [armedDeleteId, setArmedDeleteId] = useState<string | undefined>(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<void> => {
if (busy) return
setBusy(true)
setNote(undefined)
try {
const result = await action()
if (!result.ok) {
setNote(result.error.code === 'team-task-conflict' ? t('teamTaskConflict') : t('teamTaskError', { message: result.error.message }))
}
onChanged()
} catch (error) {
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
} finally {
setBusy(false)
}
}
return (
<div className={css.popCard} style={{ width: 300 }}>
<span className={css.popTitle}>{t('teamBoard')}</span>
<PopRow label={t('teamMembers')}>
{members.map(member => member.name).join(' · ')}
</PopRow>
<div className={css.popSection}>
<div className={css.popList}>
{tasks.length === 0 && <div className={css.popHint}>{t('teamTasksEmpty')}</div>}
{tasks.filter(task => task.status !== 'deleted').map(task => (
<TeamTaskRow
key={task.id}
rootId={rootId}
task={task}
members={members}
busy={busy}
editing={editingId === task.id}
armedDelete={armedDeleteId === task.id}
onEdit={(open) => { setEditingId(open ? task.id : undefined) }}
onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }}
mutate={mutate}
/>
))}
</div>
{note !== undefined && <div className={css.popNote}>{note}</div>}
{creating
? (
<TeamTaskForm
busy={busy}
submitLabel={t('teamTaskCreate')}
onCancel={() => { setCreating(false) }}
onSubmit={(subject, description) => void mutate(async () => {
const result = await api.teamsTaskCreate(rootId, { subject, description })
if (result.ok) setCreating(false)
return result
})}
/>
)
: (
<button
type="button"
className={css.popGhost}
disabled={busy}
onClick={() => { setCreating(true) }}
>
+ {t('teamTaskCreate')}
</button>
)}
</div>
</div>
)
}
/** 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<void>
}): ReactNode {
const { rootId, task, members, busy, editing, armedDelete, onEdit, onArmDelete, mutate } = props
if (editing) {
return (
<TeamTaskForm
busy={busy}
initialSubject={task.subject}
initialDescription={task.description}
submitLabel={t('teamTaskSave')}
onCancel={() => { 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 (
<div className={css.popListRow} style={{ cursor: 'default' }}>
<StateDot
size={8}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
<span className={css.popValue} title={task.subject}>
{task.subject}
{!task.ready && ` · ${t('teamTaskBlocked')}`}
</span>
<select
className={css.popSelect}
style={{ width: 'auto', flexShrink: 0 }}
aria-label={t('teamTaskOwner')}
title={t('teamTaskOwner')}
disabled={busy || task.status === 'completed'}
value={task.ownerName ?? ''}
onChange={(event) => {
const owner = event.target.value
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'reassign',
...(owner === '' ? {} : { owner }),
}))
}}
>
<option value="">{t('teamTaskUnowned')}</option>
{members.filter(member => member.role === 'teammate').map(member => (
<option key={member.id} value={member.name}>{member.name}</option>
))}
</select>
<span className={css.popActions}>
{task.status !== 'completed' && (
<button
type="button"
className={css.popGhost}
disabled={busy}
title={t('teamTaskComplete')}
aria-label={`${t('teamTaskComplete')} ${task.subject}`}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete',
}))}
>
✓
</button>
)}
{task.status === 'completed' && (
<button
type="button"
className={css.popGhost}
disabled={busy}
title={t('teamTaskReopen')}
aria-label={`${t('teamTaskReopen')} ${task.subject}`}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))}
>
↺
</button>
)}
<button
type="button"
className={css.popGhost}
disabled={busy}
title={t('teamTaskEdit')}
aria-label={`${t('teamTaskEdit')} ${task.subject}`}
onClick={() => { onEdit(true) }}
>
✎
</button>
<button
type="button"
className={clsx(css.popGhost, armedDelete && css.popGhostDanger)}
disabled={busy}
title={armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
aria-label={`${armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')} ${task.subject}`}
onClick={() => {
if (!armedDelete) { onArmDelete(true); return }
onArmDelete(false)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'delete',
}))
}}
>
{armedDelete ? t('teamTaskDeleteConfirm') : '🗑'}
</button>
</span>
<span className={css.popHint}>{t(taskStatusKey(task.status))}</span>
</div>
)
}
/** 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 (
<div className={css.popSection}>
<input
className={css.popInput}
placeholder={t('teamTaskSubject')}
aria-label={t('teamTaskSubject')}
value={subject}
onChange={(event) => { setSubject(event.target.value) }}
/>
<input
className={css.popInput}
placeholder={t('teamTaskDescription')}
aria-label={t('teamTaskDescription')}
value={description}
onChange={(event) => { setDescription(event.target.value) }}
/>
<div className={css.popActions}>
<button
type="button"
className={css.popPrimary}
disabled={props.busy || subject.trim() === ''}
onClick={() => { props.onSubmit(subject.trim(), description.trim()) }}
>
{props.submitLabel}
</button>
<button type="button" className={css.popGhost} disabled={props.busy} onClick={props.onCancel}>
{t('teamTaskCancel')}
</button>
</div>
</div>
)
}
+216
View File
@@ -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<HTMLDivElement>(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<string, number>()
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<HTMLElement>('[data-tasks-row]') ?? []
if (items.length === 0) return
items[(index + items.length) % items.length]?.focus()
}, [])
const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
const items = bodyRef.current?.querySelectorAll<HTMLElement>('[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 (
<div className={css.graphView}>
<div
ref={bodyRef}
className={css.treeView}
role="tree"
aria-label={t('subagent')}
onKeyDown={onTreeKeyDown}
>
{loading === true && (
<div className={legacy.subagentEmpty}>{t('loading')}</div>
)}
{nodes.map((node) => {
const depth = depthById.get(node.id) ?? 0
const indent = { marginLeft: depth * INDENT_PX }
if (node.kind === 'fold') {
return (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-label={t('tasksFoldExpand')}
className={clsx(css.treeRow, css.treeFold)}
style={indent}
onClick={onToggleFold}
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
>
<span className={css.treeContent}>
<span className={css.nodeTitle}>{t('tasksFoldCompleted', { count: node.count })}</span>
<span className={css.nodeSecondary}>{foldPreviews(node.previews)}</span>
</span>
</div>
)
}
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 (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-expanded="true"
aria-label={node.run.name}
className={css.treeRow}
style={indent}
onClick={(event) => { onWorkflowInfo(node, event.currentTarget) }}
onKeyDown={(event) => {
activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
}}
>
<span aria-hidden="true">▶</span>
<span className={css.treeContent}>
<span className={css.nodeTitle}>{node.run.name}</span>
<span className={css.nodeSecondary}>
{`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done, total })}`}
</span>
</span>
</div>
)
}
return (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-label={`${node.label} ${agentSecondary(node)}`}
aria-current={node.current ? 'true' : undefined}
className={clsx(
css.treeRow,
node.current && css.treeRowActive,
node.state === 'done' && css.treeRowDone,
)}
style={indent}
onClick={() => { onActivate(node) }}
onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
>
<StateDot state={nodeDotState(node.state)} className={legacy.subagentDot} />
<span className={css.treeContent}>
<span className={css.nodeTitle}>{node.label}</span>
<span className={css.nodeSecondary}>{agentSecondary(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
</span>
<button
type="button"
className={css.treeInfo}
aria-label={t('tasksNodeState')}
title={t('tasksNodeState')}
onClick={(event) => {
event.stopPropagation()
onNodeInfo(node, event.currentTarget)
}}
>
ⓘ
</button>
</div>
)
})}
</div>
<div className={css.controls}>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
</div>
</div>
)
}
+19 -1
View File
@@ -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 }) => (
<SubagentView
sessionId={scope.sessionId}
ctx={ctx}
store={store}
active={visible}
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
/>
+58
View File
@@ -391,6 +391,64 @@ export const ar: Record<string, string> = {
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: 'إضافة إضافات معاينة',
+58
View File
@@ -376,6 +376,64 @@ export const de: Record<string, string> = {
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',
+58
View File
@@ -383,6 +383,64 @@ export const fr: Record<string, string> = {
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',
+58
View File
@@ -390,6 +390,64 @@ export const hi: Record<string, string> = {
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: 'पूर्वावलोकन प्लगइन जोड़ें',
+58
View File
@@ -388,6 +388,64 @@ export const id: Record<string, string> = {
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',
+58
View File
@@ -381,6 +381,64 @@ export const it: Record<string, string> = {
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',
+58
View File
@@ -390,6 +390,64 @@ export const ja: Record<string, string> = {
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: 'プレビュープラグインを追加',
+58
View File
@@ -382,6 +382,64 @@ export const ko: Record<string, string> = {
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: '미리보기 플러그인 추가',
+58
View File
@@ -388,6 +388,64 @@ export const nl: Record<string, string> = {
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',
+58
View File
@@ -392,6 +392,64 @@ export const pl: Record<string, string> = {
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',
+58
View File
@@ -373,6 +373,64 @@ export const pt: Record<string, string> = {
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',
+58
View File
@@ -388,6 +388,64 @@ export const ru: Record<string, string> = {
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: 'Добавить плагины предпросмотра',
+58
View File
@@ -373,6 +373,64 @@ export const sv: Record<string, string> = {
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',
+58
View File
@@ -390,6 +390,64 @@ export const th: Record<string, string> = {
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: 'เพิ่มปลั๊กอินพรีวิว',
+58
View File
@@ -390,6 +390,64 @@ export const tr: Record<string, string> = {
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',
+58
View File
@@ -390,6 +390,64 @@ export const vi: Record<string, string> = {
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',
+58
View File
@@ -405,6 +405,64 @@ export const zhHK: Record<string, string> = {
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: '新增預覽插件',
+58
View File
@@ -405,6 +405,64 @@ export const zhMO: Record<string, string> = {
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: '新增預覽插件',
+58
View File
@@ -405,6 +405,64 @@ export const zhTW: Record<string, string> = {
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: '新增預覽插件',
+116
View File
@@ -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<keyof typeof zh, string> = {
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',
+3
View File
@@ -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,
+684
View File
@@ -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);
}
+88
View File
@@ -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 <span className={css.liveLine}>{t('subagentThinking')}</span>
}
return (
<>
{live.tool !== undefined && (
<span className={css.liveLine}>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(10)}</span>
<span className={css.liveTool}>{live.tool.name}</span>
{live.tool.args !== '' && (
<span className={css.liveArgs}>{preview(live.tool.args)}</span>
)}
</span>
)}
{live.text !== undefined && (
<span className={css.liveText}>{flatten(live.text)}</span>
)}
</>
)
}
/** The fold aggregate's subtitle: up to two label previews joined by ·. */
export function foldPreviews(previews: readonly string[]): string {
return previews.join(' · ')
}
+54
View File
@@ -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<readonly [string, Glyph]> = [
['read', size => <IconCodeOutline16 size={size} />],
['write', size => <IconEditOutline16 size={size} />],
['edit', size => <IconEditOutline16 size={size} />],
['glob', size => <IconSearchOutline16 size={size} />],
['grep', size => <IconSearchOutline16 size={size} />],
['search', size => <IconSearchOutline16 size={size} />],
['bash', size => <IconPlayOutline16 size={size} />],
['shell', size => <IconPlayOutline16 size={size} />],
['web', size => <IconGlobeOutline14 size={size} />],
['fetch', size => <IconGlobeOutline14 size={size} />],
['think', size => <IconThinkOutline16 size={size} />],
]
/** The fallback glyph for unmapped tools. */
const FALLBACK_GLYPH: Glyph = size => <IconEllipsisOutline16 size={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
}
+1
View File
@@ -80,6 +80,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
export const PrefsSchema: z<SidebarPrefs> = 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),
+6
View File
@@ -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,