feat(tasks-ui): tasks on their owner nodes, host-primitive controls, icon glyphs, draggable output

Round-two review feedback, all three points:

- TASKS ON NODES: the model maps each shared task (ownerName -> member name
  -> member session id) onto its owner's node; graph cards and tree rows
  render a fourth line (icon + subject + status + '+N'), and the node detail
  popover lists every owned task.
- TEAM BOARD: member Pills double as the owner filter; each task row is a
  status dot + subject + owner + status Tag + ONE overflow Menu (complete /
  reopen / edit / reassign submenu / delete with a two-step arm). Creating
  and editing moved into a real Modal with labeled Inputs and owner Pills —
  the narrow strip is no longer where you type.
- HOST PRIMITIVES ONLY: the board and the jobs drawer now use Menu / Modal /
  Input / Button / Pill / Tag / Switch / StateDot; no native select or input
  remains (guarded by a spec).
- ICONS, NOT HAND-DRAWN GLYPHS: every text glyph (circle/diamond/triangle/
  check/chevrons/i/house) is replaced by a host icon — agent preset, user
  (teammate), branch (workflow run), checklist (fold + tasks), tree corner,
  fullscreen (fit), ellipsis (node details), copy/stop (jobs). The live line
  now shows the tool's own icon + name + args without the drawn box.
- JOBS UX: the output popover is DRAGGABLE (offset clamped to the viewport,
  double click re-anchors, controls do not start a drag), defaults to 380px,
  and gains copy-output, a follow-latest switch, kind/status Tags and a drag
  hint; the drawer highlights the open row.
- 19 new copy keys across zh/en/ja and the 18 third-language dictionaries.

Tests: 4 new specs (task rendered on its node, no native select/input +
menu-driven board, create-through-dialog posts the CAS-free create, job
popover drag moves the card). Full suite 1404 passed / 133 files.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 85a19cecc3
commit e41f10afc9
32 changed files with 1257 additions and 382 deletions
@@ -102,6 +102,19 @@ client 半(src/client/)
真实浏览器里的组件级回归无法靠 jsdom 覆盖(点击捕获、fit/居中、主题令牌解析都是浏览器行为)。harness 用 esbuild 把**真实组件** + mockup 形状的 fixture(走 `buildTasksModel`,因此折叠/重挂/富化都真实生效)打包进一个页面,注入从 `dsh-client-ui-theme` 抽出的令牌 CSS,`@deepseek-ai/dsh-client-ui-primitives` 别名到轻量桩(避免把 katex/shiki 资源拖进截图包),再由 Playwright 在 360px / 720px 两档宽度截图并断言:节点点击回调、ⓘ 回调、背景拖拽平移量。本次返工的四条反馈里有三条正是它先复现、修完再确认的。
## 第二轮返工(2026-09-14 深夜,13 条反馈)
用户第二轮反馈三条,逐条落地:
| 反馈 | 做法 |
|---|---|
| 任务板「太窄太小」,且任务应显示到**对应节点**上 | 任务按 `ownerName → 成员名 → 成员会话 id` 映射进模型(`TasksNodeTask`),节点第 4 行渲染「☑ 主题 · 状态 +N」;任务板改为:成员 Pill 行**兼当负责人筛选**、任务行 = 状态点 + 主题 + 负责人 + 状态 Tag + **一个**溢出菜单;新建/编辑改为 `Modal` + `Input`(真正的宽表单),负责人用 Pill 选择 |
| 所有组件用自建、不用原生 HTML | 任务板全量换成宿主 primitives:`Menu`(含子菜单的改派)/`Modal`/`Input`/`Button`/`Pill`/`Tag`/`Switch`/`StateDot`;后台任务行的 kind/状态用 `Tag`、终止用 `Button`、输出浮窗的复制/跟随/终止同理。页面仅剩结构性 div(卡片、树行)不是表单控件 |
| Agent 用图标而非自绘 SVG;优化工具展示 | `◉/◇/▶/✓/⇪/⇩/i/☰/⌗/⌂` 等字形全部删除,改用宿主图标:`IconAgentPresetOutline16`(代理)、`IconUserOutline16`(teammate)、`IconBranchOutline16`(workflow run)、`IconChecklistOutline14`(折叠聚合 / 任务行 / 折叠按钮)、`IconTreeCorner8x10`(树视图)、`IconFullscreenOutline16`(适应)、`IconEllipsisOutline16`(节点详情)、`IconCopyOutline16`/`IconStopFill16`(作业)。live 行改为工具自身图标 + 工具名 + 参数(去掉手绘方框) |
| 后台任务弹窗要能拖动 + 体验 | `AnchoredPopover` 增加 `draggable`(拖动整体、按钮/输入/`pre` 不拦截、双击复位、视口内钳制)与 `width` 参数;输出浮窗宽 380 且可拖,新增「复制输出」「跟随最新」开关(关闭后不再尾钉)、kind/状态 Tag、拖动提示;抽屉行高亮当前打开的作业 |
i18n:本轮新增 19 条文案,zh/en/ja + 18 份第三语言词典同步(`tests/locales.spec.ts` 16/16)。
## 验证结果(2026-09-14)
PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `feat/tasks-graph-workflow-teams`)。
+84 -19
View File
@@ -1,38 +1,62 @@
/**
* The anchored floating popover of the Tasks page (node details, job output,
* workflow run detail, team task editor): a viewport-anchored card portaled
* to `document.body`, positioned below its anchor and flipped above when the
* viewport bottom would clip it. Dismissal follows the proven
* selection-popup contract (fixes upstream issue #425): outside mousedown,
* Escape, document hidden, window blur, and the ANCHOR leaving the viewport
* (tab switches flip the pane to display:none, which has no DOM event — the
* IntersectionObserver geometry signal is the only reliable one).
* workflow run detail): a viewport-anchored card portaled to `document.body`,
* positioned below its anchor and flipped above when the viewport bottom
* would clip it. Dismissal follows the proven selection-popup contract
* (fixes upstream issue #425): outside mousedown, Escape, document hidden,
* window blur, and the ANCHOR leaving the viewport (tab switches flip the
* pane to display:none, which has no DOM event — the IntersectionObserver
* geometry signal is the only reliable one).
*
* DRAGGABLE mode: the job-output popover must be movable (a long log needs to
* sit wherever the reader puts it). While `draggable`, dragging the card (or
* its `data-popover-handle` area) moves it; the offset is clamped to the
* viewport and a double click re-anchors it. The card stays portaled and
* viewport-positioned — DSH 0.1.5 has no host free-window API, so the drag
* is implemented here rather than reaching for a platform window.
*/
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import {
useCallback, useEffect, useLayoutEffect, useRef, useState,
type PointerEvent as ReactPointerEvent, type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
/** The popover's visual width (viewport-clamped). */
/** The popover's default visual width (viewport-clamped). */
const POP_WIDTH = 264
/** Free space kept at the viewport edges. */
const VIEWPORT_MARGIN = 8
export interface AnchoredPopoverProps {
/** The anchor element (null = closed). */
anchor: HTMLElement | null
/** Dismiss (outside click / Escape / anchor off-screen). */
onClose(): void
/** Allow dragging the card (default false = pinned to the anchor). */
draggable?: boolean
/** Card width in px before viewport clamping (default 264). */
width?: number
children: ReactNode
}
/**
* Render a viewport-anchored popover. The caller owns WHAT is shown inside;
* this component owns geometry + dismissal. Re-anchoring while open (a new
* anchor) re-measures and keeps the card on screen.
* this component owns geometry, dragging and dismissal. Changing the anchor
* re-measures and resets any drag offset.
*/
export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
const { anchor, onClose, children } = props
const { anchor, onClose, draggable = false, width = POP_WIDTH, children } = props
const cardRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
/** Reader-applied drag offset (added to the anchored position). */
const [offset, setOffset] = useState({ x: 0, y: 0 })
const offsetRef = useRef(offset)
offsetRef.current = offset
/** The anchored baseline: a drag always starts from the measured position. */
const baseRef = useRef({ left: 0, top: 0 })
const cardWidth = Math.min(width, window.innerWidth - VIEWPORT_MARGIN * 2)
// Measure + position once the card is in the DOM (and whenever the anchor
// or content changes size). layout effect: no visible jump.
@@ -41,15 +65,54 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
const card = cardRef.current
if (card === null) return
const rect = anchor.getBoundingClientRect()
const width = Math.min(POP_WIDTH, window.innerWidth - 16)
const height = card.offsetHeight
let top = rect.bottom + 6
if (top + height > window.innerHeight - 8) {
top = Math.max(8, rect.top - height - 6)
if (top + height > window.innerHeight - VIEWPORT_MARGIN) {
top = Math.max(VIEWPORT_MARGIN, rect.top - height - 6)
}
const left = Math.min(Math.max(8, rect.left), window.innerWidth - width - 8)
setPos({ left, top })
}, [anchor, children])
const left = Math.min(
Math.max(VIEWPORT_MARGIN, rect.left),
Math.max(VIEWPORT_MARGIN, window.innerWidth - cardWidth - VIEWPORT_MARGIN),
)
baseRef.current = { left, top }
setPos({ left: left + offsetRef.current.x, top: top + offsetRef.current.y })
}, [anchor, children, cardWidth])
// A new anchor starts a fresh placement (never inherit another node's drag).
useEffect(() => { setOffset({ x: 0, y: 0 }) }, [anchor])
/** Drag the card (buttons inside it keep working). */
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (!draggable || event.button !== 0) return
const target = event.target as HTMLElement
// A control inside the card (copy, kill, follow…) must not start a drag.
if (target.closest('button, input, select, a, [data-popover-no-drag]') !== null) return
event.preventDefault()
const startX = event.clientX
const startY = event.clientY
const start = offsetRef.current
const base = baseRef.current
const onMove = (move: PointerEvent): void => {
const nextX = Math.min(
Math.max(start.x + (move.clientX - startX), VIEWPORT_MARGIN - base.left),
Math.max(VIEWPORT_MARGIN - base.left, window.innerWidth - cardWidth - VIEWPORT_MARGIN - base.left),
)
const nextY = Math.min(
Math.max(start.y + (move.clientY - startY), VIEWPORT_MARGIN - base.top),
Math.max(VIEWPORT_MARGIN - base.top, window.innerHeight - 48 - base.top),
)
setOffset({ x: nextX, y: nextY })
setPos({ left: base.left + nextX, top: base.top + nextY })
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}, [draggable, cardWidth])
// Global dismissal (see the file header for the contract).
useEffect(() => {
@@ -95,10 +158,12 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
position: 'fixed',
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
width: Math.min(POP_WIDTH, window.innerWidth - 16),
width: cardWidth,
zIndex: 90,
visibility: pos === null ? 'hidden' : 'visible',
}}
onPointerDown={onPointerDown}
onDoubleClick={draggable ? () => { setOffset({ x: 0, y: 0 }) } : undefined}
>
{children}
</div>,
+150 -53
View File
@@ -1,15 +1,23 @@
/**
* The background-jobs bottom drawer of the Tasks page: the tree's jobs
* (owner-labeled, fed by the `session/jobs` push mirror) collapse into a
* bottom bar that AUTO-COLLAPSES when the agent count crosses the page's
* threshold — the manual toggle always wins afterwards. Job output no
* longer docks inside the page: clicking a row opens an anchored popover
* (event replay — never the model's job_output cursor). Live rows keep the
* two-click-confirm kill.
* The background-jobs bottom drawer of the Tasks page and its output popover.
*
* Drawer: the tree's jobs (owner-labeled, fed by the `session/jobs` push
* mirror) collapse into a bottom bar that AUTO-COLLAPSES once the tree has
* many agents — the manual toggle always wins afterwards.
*
* Popover: clicking a row opens a DRAGGABLE, portalled output card (see
* AnchoredPopover) instead of docking a pane inside the page. It shows the
* output the MODEL has read (event replay — never the model's job_output
* cursor), with a copy action, a follow-latest switch, the two-click kill,
* and a terminal-style tail while the job runs.
*
* Every control is a host primitive (Button / Tag / Switch / StateDot).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import {
Button, IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch, Tag,
type TagTone,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarJobView } from '../context-types.ts'
import {
formatJobDuration,
@@ -19,7 +27,6 @@ import {
type TreeJob,
} from './subagent-jobs.ts'
import { api, type JobOutputResult } from './api.ts'
import { IconStopOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -27,6 +34,16 @@ import css from './tasks-graph.module.css'
const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/** The agent count at which the drawer starts collapsed. */
export const JOBS_DRAWER_COLLAPSE_AT = 8
/** The Tag tone of one job status. */
function jobTone(job: SidebarJobView): TagTone {
if (job.status === 'running') return 'info'
if (job.status === 'completed') return 'success'
if (job.status === 'failed') return 'danger'
return 'neutral'
}
export interface JobsDrawerProps {
rows: readonly TreeJob[]
@@ -34,13 +51,12 @@ export interface JobsDrawerProps {
agentCount: number
/** Open the output popover of one row (anchor = the row's main button). */
onOpenOutput(row: TreeJob, anchor: HTMLElement): void
/** The job whose output popover is currently open (row highlight). */
openJobId?: string
}
/** The agent count at which the drawer starts collapsed. */
export const JOBS_DRAWER_COLLAPSE_AT = 8
export function JobsDrawer(props: JobsDrawerProps): ReactNode {
const { rows, agentCount, onOpenOutput } = props
const { rows, agentCount, onOpenOutput, openJobId } = props
const autoOpen = agentCount < JOBS_DRAWER_COLLAPSE_AT
/** Manual override; undefined = follow the auto rule. */
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
@@ -103,15 +119,11 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
<span>{t('jobs')}</span>
<span className={css.jobsBigNum}>{liveCount > 0 ? liveCount : rows.length}</span>
<span className={css.jobsDrawerCount}>{countLabel}</span>
<span className={clsx(css.jobsDrawerChev, open && css.jobsDrawerChevOpen)} aria-hidden="true">
<span className={css.jobsDrawerChev} data-open={open ? 'true' : 'false'} aria-hidden="true">
<IconChevronUpOutline14 size={12} />
</span>
</button>
{!autoOpen && (
<div className={css.jobsAutoNote}>
{t('jobsAutoCollapsed')}
</div>
)}
{!autoOpen && <div className={css.jobsAutoNote}>{t('jobsAutoCollapsed')}</div>}
{open && (
<div className={css.jobsDrawerBody}>
{rows.map((row) => {
@@ -125,39 +137,47 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
: (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"
<div
key={job.id}
className={css.jobsRow}
data-settled={live ? 'false' : 'true'}
data-open={openJobId === job.id ? 'true' : 'false'}
>
<Button
variant="ghost"
size="sm"
className={css.jobsRowMain}
aria-label={`${job.label} ${secondary}`}
aria-label={`${job.label} ${jobStatusLabel(job.status, t)} ${secondary}`}
title={t('jobViewOutput')}
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
>
<StateDot state={jobDotState(job.status)} size={6} />
<span className={css.jobsKind}>{job.kind}</span>
<Tag tone="quiet">{job.kind}</Tag>
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag>
<span className={css.jobsMeta}>{secondary}</span>
</button>
</Button>
{job.status === 'running' && (
<button
type="button"
className={clsx(css.jobsKill, armed && css.jobsKillArmed)}
<Button
variant="outline"
size="sm"
className={css.jobsKill}
data-armed={armed ? 'true' : 'false'}
icon={<IconStopFill16 size={11} />}
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing}
onClick={(event) => {
event.stopPropagation()
onClick={() => {
if (armed) void kill(row)
else setArmedId(job.id)
}}
>
{armed ? '!' : <IconStopOutline16 size={12} />}
</button>
{armed ? t('jobKillConfirm') : undefined}
</Button>
)}
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
</div>
@@ -172,8 +192,9 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
/**
* 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.
* {@link JOB_POLL_MS} while the job runs and the page is visible. The reader
* can copy the output and turn the terminal-style tail off; the kill action
* keeps its two-click confirm.
*/
export function JobOutputPopoverContent(props: {
ownerSessionId: string
@@ -182,8 +203,14 @@ export function JobOutputPopoverContent(props: {
}): ReactNode {
const { ownerSessionId, job, active } = props
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
const [follow, setFollow] = useState(true)
const [copied, setCopied] = useState(false)
const [armed, setArmed] = useState(false)
const [killing, setKilling] = useState(false)
const [killFailed, setKillFailed] = useState(false)
const controllerRef = useRef<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null)
const live = isJobLive(job)
const load = useCallback(async (): Promise<void> => {
controllerRef.current?.abort()
@@ -201,47 +228,117 @@ export function JobOutputPopoverContent(props: {
useEffect(() => {
void load()
if (!active || !isJobLive(job)) return
if (!active || !live) 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.
// isJobLive reads only job.status; the job object 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.
// The armed kill disarms itself, like the drawer's button.
useEffect(() => {
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
if (!armed) return
const timer = window.setTimeout(() => { setArmed(false) }, JOB_KILL_ARM_MS)
return () => { window.clearTimeout(timer) }
}, [armed])
// Terminal-tail behavior: each refresh pins the view to the newest output
// while the reader keeps the follow switch on.
useEffect(() => {
if (!live || !follow || 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.
// Same as the poll effect: only the status transition matters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, job.status])
}, [state, job.status, follow])
const text = typeof state === 'object' ? state.text : ''
/** Two-click kill from the popover (then re-read so the row settles). */
const kill = async (): Promise<void> => {
setKilling(true)
setKillFailed(false)
try {
await api.jobKill({ sessionId: ownerSessionId }, job.id)
setArmed(false)
await load()
} catch {
setKillFailed(true)
} finally {
setKilling(false)
}
}
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>
<div className={css.popCard} data-popover-handle>
<div className={css.popHead}>
<span>{t('jobs')}</span>
<span className={css.popHeadActions} data-popover-no-drag>
<Button
variant="ghost"
size="sm"
icon={<IconCopyOutline16 size={12} />}
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
title={copied ? t('jobCopied') : t('jobCopyOutput')}
disabled={text === ''}
onClick={() => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1500)
})
}}
/>
</span>
</div>
<div className={css.jobPopTitle}>
<StateDot state={jobDotState(job.status)} size={6} />
<span className={css.popTitle} title={job.label}>{job.label}</span>
<Tag tone={jobTone(job)}>{jobStatusLabel(job.status, t)}</Tag>
</div>
<div className={css.jobPopMeta}>
<Tag tone="quiet">{job.kind}</Tag>
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
<span className={css.popHint}>{t('jobDragHint')}</span>
</div>
{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>
? <pre ref={preRef} className={css.popPre} data-popover-no-drag>{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 className={css.jobPopActions} data-popover-no-drag>
<Switch
checked={follow}
onChange={setFollow}
label={t('jobFollowTail')}
disabled={!live}
/>
{live && (
<Button
variant={armed ? 'primary' : 'outline'}
size="sm"
className={armed ? css.jobPopKillArmed : undefined}
icon={<IconStopFill16 size={11} />}
disabled={killing}
onClick={() => {
if (armed) void kill()
else setArmed(true)
}}
>
{armed ? t('jobKillConfirm') : t('jobKill')}
</Button>
)}
</div>
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>}
</div>
)
}
+9 -2
View File
@@ -198,9 +198,10 @@ export function SubagentView(props: {
live,
runs,
teamMembers,
teamTasks: teamView?.available === true ? teamView.team?.tasks ?? [] : [],
folded,
})),
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, folded],
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded],
)
/** Catalog owners currently consuming live membership updates. */
@@ -465,13 +466,19 @@ export function SubagentView(props: {
<JobsDrawer
rows={jobRows}
agentCount={agentCount}
openJobId={popover?.kind === 'job' ? popover.jobId : undefined}
onOpenOutput={(row, anchor) => {
setPopover(popover?.kind === 'job' && popover.jobId === row.job.id
? null
: { kind: 'job', jobId: row.job.id, anchor })
}}
/>
<AnchoredPopover anchor={popover?.anchor ?? null} onClose={closePopover}>
<AnchoredPopover
anchor={popover?.anchor ?? null}
onClose={closePopover}
draggable={popover?.kind === 'job'}
width={popover?.kind === 'job' ? 380 : 280}
>
{popoverContent}
</AnchoredPopover>
</div>
+49 -39
View File
@@ -23,11 +23,17 @@ import {
type ReactNode,
} from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import {
Button, IconChecklistOutline14, IconEllipsisOutline16, IconFullscreenOutline16, IconTreeCorner8x10,
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_W, layoutTasksGraphForWidth, type GraphBox } from './tasks-graph-layout.ts'
import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -72,17 +78,19 @@ export function ViewModeToggle(props: {
}): ReactNode {
const { mode, onModeChange } = props
const toTree = mode === 'graph'
const label = toTree ? `▤ ${t('tasksViewTree')}` : `⌗ ${t('tasksViewGraph')}`
const label = toTree ? t('tasksViewTree') : t('tasksViewGraph')
return (
<button
type="button"
className={clsx(css.controlBtn, css.controlMode)}
<Button
variant="ghost"
size="sm"
className={css.controlMode}
icon={toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph size={13} />}
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
>
{label}
</button>
</Button>
)
}
@@ -90,16 +98,16 @@ export function ViewModeToggle(props: {
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
const { folded, onToggleFold } = props
return (
<button
type="button"
className={css.controlBtn}
<Button
variant="ghost"
size="sm"
className={clsx(css.controlBtn, folded && css.controlBtnActive)}
icon={<IconChecklistOutline14 size={13} />}
aria-pressed={folded}
aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
onClick={onToggleFold}
>
{folded ? '⇪' : '⇩'}
</button>
/>
)
}
@@ -333,34 +341,36 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
<div className={css.controls} data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
<button
type="button"
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
aria-label={t('tasksZoomOut')}
title={t('tasksZoomOut')}
onClick={() => { zoomBy(1 / 1.2) }}
>
−
</button>
</Button>
<span className={css.controlZoomLevel} aria-hidden="true">{Math.round(tf.k * 100)}%</span>
<button
type="button"
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
aria-label={t('tasksZoomIn')}
title={t('tasksZoomIn')}
onClick={() => { zoomBy(1.2) }}
>
+
</button>
<button
type="button"
</Button>
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
icon={<IconFullscreenOutline16 size={13} />}
aria-label={t('tasksZoomFit')}
title={t('tasksZoomFit')}
onClick={refit}
>
⌂
</button>
/>
</div>
</div>
</div>
@@ -399,25 +409,24 @@ function renderAgentNode(
>
<span className={css.nodeHeader}>
<StateDot state={nodeDotState(node.state)} size={6} className={css.nodeDot} />
<span className={css.nodeTitle} title={node.label}>
{node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
</span>
<span className={css.nodeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
<span className={css.nodeTitle} title={node.label}>{node.label}</span>
</span>
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<button
type="button"
className={css.treeInfo}
style={{ position: 'absolute', top: 3, right: 3 }}
aria-label={t('tasksNodeState')}
title={t('tasksNodeState')}
<TaskLine tasks={node.tasks} />
<Button
variant="ghost"
size="sm"
className={css.nodeInfo}
icon={<IconEllipsisOutline16 size={12} />}
aria-label={t('tasksNodeDetail')}
title={t('tasksNodeDetail')}
onClick={(event) => {
event.stopPropagation()
onNodeInfo(node, event.currentTarget)
}}
>
i
</button>
/>
</div>
)
}
@@ -445,7 +454,8 @@ function renderWorkflowNode(
>
<span className={css.nodeHeader}>
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} className={css.nodeDot} />
<span className={css.nodeTitle} title={node.run.name}>{`▶ ${node.run.name}`}</span>
<span className={css.nodeGlyph} aria-hidden="true"><WorkflowGlyph /></span>
<span className={css.nodeTitle} title={node.run.name}>{node.run.name}</span>
</span>
<span className={css.nodeMeta}>{workflowMeta(node)}</span>
</div>
@@ -474,13 +484,13 @@ function renderFoldNode(
}}
>
<span className={css.nodeHeader}>
<StateDot state="done" size={6} className={css.nodeDot} />
<span className={css.nodeGlyph} aria-hidden="true"><FoldGlyph /></span>
<span className={clsx(css.nodeTitle, css.nodeTitlePlain)}>
{t('tasksFoldCompleted', { count: node.count })}
</span>
</span>
<span className={css.nodeMeta} title={foldPreviews(node.previews)}>
{`⇪ ${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
{`${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
</span>
</div>
)
+30 -5
View File
@@ -8,10 +8,12 @@
*/
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, IconRightUpOutline14, StateDot, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarSubagentAddress } from '../context-types.ts'
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
import { flatten, nodeDotState, workflowStatusKey } from './tasks-shared.tsx'
import {
flatten, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
} from './tasks-shared.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -80,10 +82,33 @@ export function AgentNodePopover(props: {
{liveText !== undefined && liveTool !== undefined && (
<div className={css.popHint}>{liveText}</div>
)}
{(node.tasks?.length ?? 0) > 0 && (
<div className={css.popSection}>
<div className={css.popGroup}>{t('tasksNodeTasks')}</div>
<div className={css.popList}>
{(node.tasks ?? []).map(task => (
<div key={task.id} className={css.popListRow} title={task.subject}>
<StateDot state={taskDotState(task)} size={6} />
<span className={css.popListLabel}>{task.subject}</span>
<Tag tone={task.status === 'completed' ? 'success' : task.ready ? 'info' : 'warning'}>
{t(taskStatusKey(task.status))}
</Tag>
</div>
))}
</div>
</div>
)}
{(node.childAddress !== undefined || node.parentId === undefined) && (
<button type="button" className={css.popPrimary} onClick={() => { onJump(node) }}>
{t('tasksNodeJump')}
</button>
<div className={css.popFoot}>
<Button
variant="primary"
size="sm"
icon={<IconRightUpOutline14 size={12} />}
onClick={() => { onJump(node) }}
>
{t('tasksNodeJump')}
</Button>
</div>
)}
</div>
)
+23 -16
View File
@@ -7,9 +7,12 @@
*/
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, IconChevronUpOutline14, IconEllipsisOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx'
import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -94,12 +97,12 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
onClick={onToggleFold}
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
>
<StateDot state="done" size={6} />
<span className={css.treeGlyph} aria-hidden="true"><FoldGlyph /></span>
<span className={css.treeContent}>
<span className={css.treeTitle}>
{t('tasksFoldCompleted', { count: node.count })}
<span className={css.treeMeta}>
{`${folded ? '⇪' : '⇩'} ${t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}`}
{t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
</span>
</span>
<span className={css.treeMeta}>{foldPreviews(node.previews)}</span>
@@ -123,8 +126,9 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
}}
>
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} />
<span className={css.treeGlyph} aria-hidden="true"><WorkflowGlyph /></span>
<span className={css.treeContent}>
<span className={css.treeTitle}>{`▶ ${node.run.name}`}</span>
<span className={css.treeTitle}>{node.run.name}</span>
<span className={css.treeMeta}>{workflowMeta(node)}</span>
</span>
</div>
@@ -147,25 +151,25 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
>
<StateDot state={nodeDotState(node.state)} size={6} />
<span className={css.treeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
<span className={css.treeContent}>
<span className={css.treeTitle}>
{node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
</span>
<span className={css.treeTitle}>{node.label}</span>
<span className={css.treeMeta}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<TaskLine tasks={node.tasks} />
</span>
<button
type="button"
<Button
variant="ghost"
size="sm"
className={css.treeInfo}
aria-label={t('tasksNodeState')}
title={t('tasksNodeState')}
icon={<IconEllipsisOutline16 size={12} />}
aria-label={t('tasksNodeDetail')}
title={t('tasksNodeDetail')}
onClick={(event) => {
event.stopPropagation()
onNodeInfo(node, event.currentTarget)
}}
>
i
</button>
/>
</div>
)
if (children.length === 0) return <div key={node.id}>{row}</div>
@@ -191,7 +195,10 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
onKeyDown={onTreeKeyDown}
>
{loading === true && roots.length === 0 && (
<div className={css.viewEmptyHint}>{t('loading')}</div>
<div className={css.viewEmptyHint}>
<span className={css.treeGlyph} aria-hidden="true"><IconChevronUpOutline14 size={12} /></span>
{t('loading')}
</div>
)}
{roots.map(root => renderNode(root, 0))}
</div>
+296 -228
View File
@@ -1,16 +1,24 @@
/**
* The Agent Teams board — ALWAYS VISIBLE under the page header whenever the
* tree's root leads a team (the explicit review ask: the board must not hide
* behind a chip). It is a full-width strip, not a side rail, because the
* native sidebar is narrow (~360px): members wrap as chips and the task list
* scrolls inside a bounded height, so the canvas keeps its room.
* tree's root leads a team, plus the task editor dialog. Every control is a
* host primitive (Menu / Modal / Input / Button / Pill / Tag / StateDot); the
* page ships no native form control of its own.
*
* Mutations are CAS: every action sends the task's CURRENT revision; a
* Layout: the strip is full width because the native sidebar is narrow — a
* member row of Pills (also the owner filter), then task rows (subject +
* owner + status Tag + ONE overflow Menu). Editing opens a Modal with real
* Inputs — that is where the "too narrow to use" complaint is answered.
*
* Mutations are CAS: each action sends the task's CURRENT revision; a
* conflict surfaces as a note and re-syncs through the parent's poller.
*/
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { useMemo, useState, type ReactNode } from 'react'
import {
Button, IconChecklistOutline14, IconCheckOutline14, IconChevronUpOutline14, IconEditOutline16,
IconEllipsisOutline16, IconPlusOutline16, IconRefreshOutline14, IconTrashOutline16,
IconUserOutline16, Input, Menu, Modal, Pill, StateDot, Tag,
type MenuEntry, type TagTone,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { api } from './api.ts'
import { t, type CopyKey } from './locales.ts'
@@ -26,9 +34,25 @@ function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
}
}
/** The status Tag tone of one task. */
function taskTone(task: SidebarTeamTaskView): TagTone {
if (task.status === 'completed') return 'success'
if (!task.ready) return 'warning'
return task.status === 'in_progress' ? 'info' : 'neutral'
}
/** One mutation outcome (the host route's own union). */
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
/** The task being created or edited (undefined = dialog closed). */
interface TaskDraft {
/** The task under edit; undefined = a new task. */
task: SidebarTeamTaskView | undefined
subject: string
description: string
owner: string
}
export interface TeamBoardProps {
rootId: string
members: readonly SidebarTeamMemberView[]
@@ -44,11 +68,17 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
const { rootId, members, tasks, onChanged, collapsed, onToggleCollapsed } = 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 [draft, setDraft] = useState<TaskDraft | undefined>(undefined)
const [menuTaskId, setMenuTaskId] = useState<string | undefined>(undefined)
const [armedDeleteId, setArmedDeleteId] = useState<string | undefined>(undefined)
const [ownerFilter, setOwnerFilter] = useState<string | undefined>(undefined)
const open = tasks.filter(task => task.status !== 'deleted')
const teammates = useMemo(() => members.filter(member => member.role === 'teammate'), [members])
const live = useMemo(() => tasks.filter(task => task.status !== 'deleted'), [tasks])
const shown = useMemo(
() => live.filter(task => ownerFilter === undefined || task.ownerName === ownerFilter),
[live, ownerFilter],
)
/** Run one mutation with the shared busy/conflict handling. */
const mutate = async (action: () => Promise<MutationResult>): Promise<void> => {
@@ -70,7 +100,117 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
}
}
const teammates = members.filter(member => member.role === 'teammate')
/** The overflow menu of one task (actions per status). */
const menuItems = (task: SidebarTeamTaskView): MenuEntry[] => {
const entries: MenuEntry[] = []
if (task.status === 'completed') {
entries.push({ id: 'reopen', label: t('teamTaskReopen'), icon: <IconRefreshOutline14 size={14} /> })
} else {
entries.push({ id: 'complete', label: t('teamTaskComplete'), icon: <IconCheckOutline14 size={14} /> })
}
entries.push({ id: 'edit', label: t('teamTaskEdit'), icon: <IconEditOutline16 size={14} /> })
if (teammates.length > 0) {
entries.push({
id: 'reassign',
label: t('teamTaskOwner'),
icon: <IconUserOutline16 size={14} />,
submenu: [
{ id: 'reassign:', label: t('teamTaskUnowned') },
...teammates.map(member => ({
id: `reassign:${member.name}`,
label: member.name,
...(task.ownerName === member.name ? { disabled: true } : {}),
})),
],
})
}
entries.push({ type: 'separator', id: 'team-task-sep' })
entries.push({
id: 'delete',
label: armedDeleteId === task.id ? t('teamTaskDeleteConfirm') : t('teamTaskDelete'),
icon: <IconTrashOutline16 size={14} />,
danger: true,
})
return entries
}
/** Dispatch one overflow-menu pick. */
const onMenuSelect = (task: SidebarTeamTaskView, id: string): void => {
if (id === 'complete') {
setMenuTaskId(undefined)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete',
}))
return
}
if (id === 'reopen') {
setMenuTaskId(undefined)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))
return
}
if (id === 'edit') {
setMenuTaskId(undefined)
setDraft({ task, subject: task.subject, description: task.description, owner: task.ownerName ?? '' })
return
}
if (id === 'delete') {
// Two-step: the first pick arms the label, the second deletes.
if (armedDeleteId !== task.id) {
setArmedDeleteId(task.id)
return
}
setArmedDeleteId(undefined)
setMenuTaskId(undefined)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'delete',
}))
return
}
if (id.startsWith('reassign:')) {
const owner = id.slice('reassign:'.length)
setMenuTaskId(undefined)
setArmedDeleteId(undefined)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'reassign',
...(owner === '' ? {} : { owner }),
}))
}
}
/** Commit the create/edit dialog (subject/description, then the owner). */
const saveDraft = async (): Promise<void> => {
const current = draft
if (current === undefined) return
const subject = current.subject.trim()
if (subject === '') return
await mutate(async () => {
const result = current.task === undefined
? await api.teamsTaskCreate(rootId, { subject, description: current.description.trim() })
: await api.teamsTaskUpdate(rootId, {
taskId: current.task.id,
expectedRevision: current.task.revision,
action: 'edit',
subject,
description: current.description.trim(),
})
if (!result.ok) return result
if (current.task !== undefined && current.owner !== (current.task.ownerName ?? '')) {
const moved = await api.teamsTaskUpdate(rootId, {
taskId: current.task.id,
expectedRevision: result.value.revision,
action: 'reassign',
...(current.owner === '' ? {} : { owner: current.owner }),
})
if (!moved.ok) return moved
}
setDraft(undefined)
return result
})
}
return (
<section className={css.teamBoard} aria-label={t('teamBoard')}>
@@ -80,9 +220,10 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
aria-expanded={!collapsed}
onClick={onToggleCollapsed}
>
<span className={css.teamBoardIcon} aria-hidden="true"><IconChecklistOutline14 size={11} /></span>
<span>{t('teamBoard')}</span>
<span className={css.teamBoardCount}>
{t('teamChip', { members: members.length, tasks: open.length })}
{t('teamChip', { members: members.length, tasks: live.length })}
</span>
<span
className={css.teamBoardChev}
@@ -95,11 +236,22 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
{!collapsed && (
<>
<div className={css.teamMembers}>
<Pill
active={ownerFilter === undefined}
className={css.teamMemberPill}
onClick={() => { setOwnerFilter(undefined) }}
>
{t('teamFilterAll')}
</Pill>
{members.map(member => (
<span
<Pill
key={member.id}
className={clsx(css.teamMember, member.role === 'lead' && css.teamMemberLead)}
active={ownerFilter === member.name}
className={css.teamMemberPill}
title={`${member.name} · ${member.role}`}
onClick={() => {
setOwnerFilter(current => (current === member.name ? undefined : member.name))
}}
>
<StateDot
size={6}
@@ -108,226 +260,142 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
: member.status === 'failed' ? 'error' : 'idle'}
/>
<span className={css.teamMemberName}>{member.name}</span>
</span>
</Pill>
))}
</div>
{open.length === 0 && !creating && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>}
{open.length > 0 && (
<div className={css.teamTasks}>
{open.map(task => (
<TeamTaskRow
key={task.id}
rootId={rootId}
task={task}
teammates={teammates}
busy={busy}
editing={editingId === task.id}
armedDelete={armedDeleteId === task.id}
onEdit={(edit) => { setEditingId(edit ? task.id : undefined) }}
onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }}
mutate={mutate}
<div className={css.teamTasks}>
{shown.length === 0 && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>}
{shown.map(task => (
<div key={task.id} className={css.teamTask}>
<StateDot
size={6}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
))}
</div>
)}
{note !== undefined && <div className={css.teamEmpty}>{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
})}
/>
)
: (
<div className={css.teamFormActions} style={{ padding: '0 12px 8px' }}>
<button
type="button"
className={css.teamBtn}
disabled={busy}
onClick={() => { setCreating(true) }}
<span
className={css.teamTaskSubject}
title={`${task.subject}${task.description === '' ? '' : `\n${task.description}`}`}
>
{`+ ${t('teamTaskCreate')}`}
</button>
{task.subject}
</span>
{task.ownerName !== undefined && (
<span className={css.teamTaskOwner}>{task.ownerName}</span>
)}
<Tag tone={taskTone(task)}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</Tag>
<Menu
open={menuTaskId === task.id}
onClose={() => {
setMenuTaskId(undefined)
setArmedDeleteId(undefined)
}}
items={menuItems(task)}
onSelect={(id) => { onMenuSelect(task, id) }}
align="end"
portal
compact
anchor={(
<Button
variant="ghost"
size="sm"
icon={<IconEllipsisOutline16 size={13} />}
aria-label={`${t('teamTaskActions')} ${task.subject}`}
title={t('teamTaskActions')}
onClick={() => {
setArmedDeleteId(undefined)
setMenuTaskId(current => (current === task.id ? undefined : task.id))
}}
/>
)}
/>
</div>
)}
))}
</div>
{note !== undefined && <div className={css.teamNote}>{note}</div>}
<div className={css.teamActions}>
<Button
variant="outline"
size="sm"
icon={<IconPlusOutline16 size={13} />}
disabled={busy}
onClick={() => { setDraft({ task: undefined, subject: '', description: '', owner: '' }) }}
>
{t('teamTaskCreate')}
</Button>
</div>
</>
)}
<Modal
open={draft !== undefined}
onClose={() => { setDraft(undefined) }}
title={draft?.task === undefined ? t('teamTaskCreate') : t('teamTaskEdit')}
closeLabel={t('teamTaskCancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setDraft(undefined) }}>
{t('teamTaskCancel')}
</Button>
<Button
variant="primary"
disabled={busy || (draft?.subject.trim() ?? '') === ''}
onClick={() => { void saveDraft() }}
>
{draft?.task === undefined ? t('teamTaskCreate') : t('teamTaskSave')}
</Button>
</>
)}
>
<div className={css.taskForm}>
<label className={css.taskField}>
<span className={css.taskLabel}>{t('teamTaskSubject')}</span>
<Input
value={draft?.subject ?? ''}
placeholder={t('teamTaskSubjectPlaceholder')}
aria-label={t('teamTaskSubject')}
onChange={(event) => {
const value = event.target.value
setDraft(current => (current === undefined ? current : { ...current, subject: value }))
}}
/>
</label>
<label className={css.taskField}>
<span className={css.taskLabel}>{t('teamTaskDescription')}</span>
<Input
value={draft?.description ?? ''}
placeholder={t('teamTaskDescriptionPlaceholder')}
aria-label={t('teamTaskDescription')}
onChange={(event) => {
const value = event.target.value
setDraft(current => (current === undefined ? current : { ...current, description: value }))
}}
/>
</label>
<div className={css.taskField}>
<span className={css.taskLabel}>{t('teamTaskOwner')}</span>
<div className={css.taskOwnerRow}>
<Pill
active={(draft?.owner ?? '') === ''}
onClick={() => {
setDraft(current => (current === undefined ? current : { ...current, owner: '' }))
}}
>
{t('teamTaskUnowned')}
</Pill>
{teammates.map(member => (
<Pill
key={member.id}
active={draft?.owner === member.name}
onClick={() => {
setDraft(current => (current === undefined ? current : { ...current, owner: member.name }))
}}
>
{member.name}
</Pill>
))}
</div>
</div>
</div>
</Modal>
</section>
)
}
/** One task row: status dot, subject, owner select, status chip, actions. */
function TeamTaskRow(props: {
rootId: string
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
busy: boolean
editing: boolean
armedDelete: boolean
onEdit(edit: boolean): void
onArmDelete(armed: boolean): void
mutate(action: () => Promise<MutationResult>): Promise<void>
}): ReactNode {
const { rootId, task, teammates, 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.teamTask}>
<StateDot size={6} state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'} />
<span className={css.teamTaskSubject} title={`${task.subject}${task.description === '' ? '' : `\n${task.description}`}`}>
{task.subject}
</span>
<select
className={css.teamOwnerSelect}
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>
{teammates.map(member => (
<option key={member.id} value={member.name}>{member.name}</option>
))}
</select>
<span className={clsx(css.teamTaskStatus, !task.ready && css.teamTaskBlocked)}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</span>
<span className={css.teamActions}>
{task.status !== 'completed'
? (
<button
type="button"
className={css.teamBtn}
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>
)
: (
<button
type="button"
className={css.teamBtn}
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.teamBtn}
disabled={busy}
title={t('teamTaskEdit')}
aria-label={`${t('teamTaskEdit')} ${task.subject}`}
onClick={() => { onEdit(true) }}
>
✎
</button>
<button
type="button"
className={clsx(css.teamBtn, armedDelete && css.teamBtnDanger)}
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 ? '!' : '✕'}
</button>
</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.teamForm}>
<input
className={css.teamInput}
placeholder={t('teamTaskSubject')}
aria-label={t('teamTaskSubject')}
value={subject}
onChange={(event) => { setSubject(event.target.value) }}
/>
<input
className={css.teamInput}
placeholder={t('teamTaskDescription')}
aria-label={t('teamTaskDescription')}
value={description}
onChange={(event) => { setDescription(event.target.value) }}
/>
<div className={css.teamFormActions}>
<button
type="button"
className={css.teamBtn}
disabled={props.busy || subject.trim() === ''}
onClick={() => { props.onSubmit(subject.trim(), description.trim()) }}
>
{props.submitLabel}
</button>
<button type="button" className={css.teamBtn} disabled={props.busy} onClick={props.onCancel}>
{t('teamTaskCancel')}
</button>
</div>
</div>
)
}
+9
View File
@@ -443,6 +443,15 @@ export const ar: Record<string, string> = {
teamTaskConflict: 'تم تعديل المهمة من جهة أخرى، تم التحديث',
teamTaskError: 'فشلت العملية: {message}',
teamTaskDeleted: 'محذوفة',
teamTaskActions: 'إجراءات المهمة',
teamTaskSubjectPlaceholder: 'سطر واحد يصف العمل',
teamTaskDescriptionPlaceholder: 'تفاصيل إضافية (اختياري)',
teamFilterAll: 'الكل',
tasksNodeTasks: 'المهام المشتركة',
jobCopyOutput: 'نسخ الإخراج',
jobCopied: 'تم النسخ',
jobFollowTail: 'متابعة الأحدث',
jobDragHint: 'اسحب للتحريك',
workflowRunning: 'قيد التشغيل',
workflowCompleted: 'مكتمل',
workflowCancelled: 'أُلغي',
+9
View File
@@ -428,6 +428,15 @@ export const de: Record<string, string> = {
teamTaskConflict: 'Die Aufgabe wurde anderweitig geändert; aktualisiert',
teamTaskError: 'Vorgang fehlgeschlagen: {message}',
teamTaskDeleted: 'Gelöscht',
teamTaskActions: 'Aufgabenaktionen',
teamTaskSubjectPlaceholder: 'Eine Zeile, die die Arbeit beschreibt',
teamTaskDescriptionPlaceholder: 'Zusätzliche Details (optional)',
teamFilterAll: 'Alle',
tasksNodeTasks: 'Geteilte Aufgaben',
jobCopyOutput: 'Ausgabe kopieren',
jobCopied: 'Kopiert',
jobFollowTail: 'Neuestes verfolgen',
jobDragHint: 'Zum Verschieben ziehen',
workflowRunning: 'Läuft',
workflowCompleted: 'Abgeschlossen',
workflowCancelled: 'Abgebrochen',
+9
View File
@@ -435,6 +435,15 @@ export const fr: Record<string, string> = {
teamTaskConflict: 'La tâche a été modifiée ailleurs ; actualisée',
teamTaskError: 'Échec de l’opération : {message}',
teamTaskDeleted: 'Supprimée',
teamTaskActions: 'Actions sur la tâche',
teamTaskSubjectPlaceholder: 'Décrire le travail en une ligne',
teamTaskDescriptionPlaceholder: 'Précisions (facultatif)',
teamFilterAll: 'Tout',
tasksNodeTasks: 'Tâches partagées',
jobCopyOutput: 'Copier la sortie',
jobCopied: 'Copié',
jobFollowTail: 'Suivre la fin',
jobDragHint: 'Glisser pour déplacer',
workflowRunning: 'En cours',
workflowCompleted: 'Terminé',
workflowCancelled: 'Annulé',
+9
View File
@@ -442,6 +442,15 @@ export const hi: Record<string, string> = {
teamTaskConflict: 'कार्य दूसरी जगह बदल गया; ताज़ा कर दिया गया',
teamTaskError: 'ऑपरेशन विफल: {message}',
teamTaskDeleted: 'हटाया गया',
teamTaskActions: 'कार्य क्रियाएँ',
teamTaskSubjectPlaceholder: 'काम को एक पंक्ति में बताएँ',
teamTaskDescriptionPlaceholder: 'अतिरिक्त जानकारी (वैकल्पिक)',
teamFilterAll: 'सभी',
tasksNodeTasks: 'साझा कार्य',
jobCopyOutput: 'आउटपुट कॉपी',
jobCopied: 'कॉपी हो गया',
jobFollowTail: 'नवीनतम पर बने रहें',
jobDragHint: 'ड्रैग करके खिसकाएँ',
workflowRunning: 'चल रहा है',
workflowCompleted: 'पूर्ण',
workflowCancelled: 'रद्द किया गया',
+9
View File
@@ -440,6 +440,15 @@ export const id: Record<string, string> = {
teamTaskConflict: 'Tugas berubah di tempat lain; sudah disegarkan',
teamTaskError: 'Operasi gagal: {message}',
teamTaskDeleted: 'Terhapus',
teamTaskActions: 'Aksi tugas',
teamTaskSubjectPlaceholder: 'Jelaskan pekerjaan dalam satu baris',
teamTaskDescriptionPlaceholder: 'Keterangan tambahan (opsional)',
teamFilterAll: 'Semua',
tasksNodeTasks: 'Tugas bersama',
jobCopyOutput: 'Salin keluaran',
jobCopied: 'Tersalin',
jobFollowTail: 'Ikuti baris terbaru',
jobDragHint: 'Seret untuk memindahkan',
workflowRunning: 'Berjalan',
workflowCompleted: 'Selesai',
workflowCancelled: 'Dibatalkan',
+9
View File
@@ -433,6 +433,15 @@ export const it: Record<string, string> = {
teamTaskConflict: 'L’attività è stata modificata da altri; aggiornata',
teamTaskError: 'Operazione non riuscita: {message}',
teamTaskDeleted: 'Eliminata',
teamTaskActions: 'Azioni attività',
teamTaskSubjectPlaceholder: 'Descrivi il lavoro in una riga',
teamTaskDescriptionPlaceholder: 'Dettagli aggiuntivi (opzionale)',
teamFilterAll: 'Tutti',
tasksNodeTasks: 'Attività condivise',
jobCopyOutput: 'Copia output',
jobCopied: 'Copiato',
jobFollowTail: 'Segui le ultime righe',
jobDragHint: 'Trascina per spostare',
workflowRunning: 'In corso',
workflowCompleted: 'Completato',
workflowCancelled: 'Annullato',
+9
View File
@@ -442,6 +442,15 @@ export const ja: Record<string, string> = {
teamTaskConflict: '他の場所で変更されました。最新に更新しました',
teamTaskError: '操作に失敗: {message}',
teamTaskDeleted: '削除済み',
teamTaskActions: 'タスク操作',
teamTaskSubjectPlaceholder: 'やることを一行で',
teamTaskDescriptionPlaceholder: '補足(任意)',
teamFilterAll: 'すべて',
tasksNodeTasks: '共有タスク',
jobCopyOutput: '出力をコピー',
jobCopied: 'コピーしました',
jobFollowTail: '最新に追従',
jobDragHint: 'ドラッグで移動',
workflowRunning: '進行中',
workflowCompleted: '完了',
workflowCancelled: 'キャンセル',
+9
View File
@@ -434,6 +434,15 @@ export const ko: Record<string, string> = {
teamTaskConflict: '다른 사람이 작업을 수정해 새로고침했습니다',
teamTaskError: '작업 실패: {message}',
teamTaskDeleted: '삭제됨',
teamTaskActions: '작업 동작',
teamTaskSubjectPlaceholder: '할 일을 한 줄로 설명',
teamTaskDescriptionPlaceholder: '추가 설명 (선택)',
teamFilterAll: '모두',
tasksNodeTasks: '공유 작업',
jobCopyOutput: '출력 복사',
jobCopied: '복사됨',
jobFollowTail: '최신 따라가기',
jobDragHint: '드래그하여 이동',
workflowRunning: '진행 중',
workflowCompleted: '완료됨',
workflowCancelled: '취소됨',
+9
View File
@@ -440,6 +440,15 @@ export const nl: Record<string, string> = {
teamTaskConflict: 'De taak is elders gewijzigd; vernieuwd',
teamTaskError: 'Bewerking mislukt: {message}',
teamTaskDeleted: 'Verwijderd',
teamTaskActions: 'Taakacties',
teamTaskSubjectPlaceholder: 'Beschrijf het werk in één regel',
teamTaskDescriptionPlaceholder: 'Aanvullende details (optioneel)',
teamFilterAll: 'Alles',
tasksNodeTasks: 'Gedeelde taken',
jobCopyOutput: 'Uitvoer kopiëren',
jobCopied: 'Gekopieerd',
jobFollowTail: 'Nieuwste volgen',
jobDragHint: 'Slepen om te verplaatsen',
workflowRunning: 'Bezig',
workflowCompleted: 'Voltooid',
workflowCancelled: 'Geannuleerd',
+9
View File
@@ -444,6 +444,15 @@ export const pl: Record<string, string> = {
teamTaskConflict: 'Zadanie zostało zmienione przez kogoś innego; odświeżono',
teamTaskError: 'Operacja nie powiodła się: {message}',
teamTaskDeleted: 'Usunięte',
teamTaskActions: 'Akcje zadania',
teamTaskSubjectPlaceholder: 'Opisz pracę w jednym zdaniu',
teamTaskDescriptionPlaceholder: 'Dodatkowe informacje (opcjonalnie)',
teamFilterAll: 'Wszystko',
tasksNodeTasks: 'Wspólne zadania',
jobCopyOutput: 'Kopiuj wyjście',
jobCopied: 'Skopiowano',
jobFollowTail: 'Śledź najnowsze',
jobDragHint: 'Przeciągnij, aby przenieść',
workflowRunning: 'W toku',
workflowCompleted: 'Ukończony',
workflowCancelled: 'Anulowany',
+9
View File
@@ -425,6 +425,15 @@ export const pt: Record<string, string> = {
teamTaskConflict: 'A tarefa foi alterada por outra pessoa; atualizada',
teamTaskError: 'Operação falhou: {message}',
teamTaskDeleted: 'Excluída',
teamTaskActions: 'Ações da tarefa',
teamTaskSubjectPlaceholder: 'Descreva o trabalho em uma linha',
teamTaskDescriptionPlaceholder: 'Detalhes adicionais (opcional)',
teamFilterAll: 'Tudo',
tasksNodeTasks: 'Tarefas compartilhadas',
jobCopyOutput: 'Copiar saída',
jobCopied: 'Copiado',
jobFollowTail: 'Acompanhar as últimas linhas',
jobDragHint: 'Arraste para mover',
workflowRunning: 'Em execução',
workflowCompleted: 'Concluído',
workflowCancelled: 'Cancelado',
+9
View File
@@ -440,6 +440,15 @@ export const ru: Record<string, string> = {
teamTaskConflict: 'Задача изменена другим участником, обновлено',
teamTaskError: 'Не удалось выполнить: {message}',
teamTaskDeleted: 'Удалена',
teamTaskActions: 'Действия с задачей',
teamTaskSubjectPlaceholder: 'Опишите работу одной строкой',
teamTaskDescriptionPlaceholder: 'Дополнительные сведения (необязательно)',
teamFilterAll: 'Все',
tasksNodeTasks: 'Общие задачи',
jobCopyOutput: 'Копировать вывод',
jobCopied: 'Скопировано',
jobFollowTail: 'Следить за новыми строками',
jobDragHint: 'Перетащите, чтобы переместить',
workflowRunning: 'Выполняется',
workflowCompleted: 'Завершён',
workflowCancelled: 'Отменён',
+9
View File
@@ -425,6 +425,15 @@ export const sv: Record<string, string> = {
teamTaskConflict: 'Uppgiften ändrades av någon annan, har uppdaterats',
teamTaskError: 'Åtgärden misslyckades: {message}',
teamTaskDeleted: 'Borttagen',
teamTaskActions: 'Uppgiftsåtgärder',
teamTaskSubjectPlaceholder: 'Beskriv arbetet på en rad',
teamTaskDescriptionPlaceholder: 'Ytterligare information (valfritt)',
teamFilterAll: 'Alla',
tasksNodeTasks: 'Delade uppgifter',
jobCopyOutput: 'Kopiera utdata',
jobCopied: 'Kopierades',
jobFollowTail: 'Följ senaste',
jobDragHint: 'Dra för att flytta',
workflowRunning: 'Körs',
workflowCompleted: 'Slutförd',
workflowCancelled: 'Avbruten',
+9
View File
@@ -442,6 +442,15 @@ export const th: Record<string, string> = {
teamTaskConflict: 'งานถูกแก้ไขโดยผู้อื่น รีเฟรชแล้ว',
teamTaskError: 'ดำเนินการไม่สำเร็จ: {message}',
teamTaskDeleted: 'ลบแล้ว',
teamTaskActions: 'การดำเนินการของงาน',
teamTaskSubjectPlaceholder: 'อธิบายงานที่ต้องทำในบรรทัดเดียว',
teamTaskDescriptionPlaceholder: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
teamFilterAll: 'ทั้งหมด',
tasksNodeTasks: 'งานที่แชร์',
jobCopyOutput: 'คัดลอกเอาต์พุต',
jobCopied: 'คัดลอกแล้ว',
jobFollowTail: 'ติดตามบรรทัดล่าสุด',
jobDragHint: 'ลากเพื่อย้าย',
workflowRunning: 'กำลังทำงาน',
workflowCompleted: 'เสร็จสิ้น',
workflowCancelled: 'ยกเลิกแล้ว',
+9
View File
@@ -442,6 +442,15 @@ export const tr: Record<string, string> = {
teamTaskConflict: 'Görev başkası tarafından değiştirildi, güncellendi',
teamTaskError: 'İşlem başarısız: {message}',
teamTaskDeleted: 'Silindi',
teamTaskActions: 'Görev eylemleri',
teamTaskSubjectPlaceholder: 'İşi tek satırda açıkla',
teamTaskDescriptionPlaceholder: 'Ek ayrıntılar (isteğe bağlı)',
teamFilterAll: 'Tümü',
tasksNodeTasks: 'Paylaşılan görevler',
jobCopyOutput: 'Çıkışı kopyala',
jobCopied: 'Kopyalandı',
jobFollowTail: 'En sona kaydır',
jobDragHint: 'Taşımak için sürükle',
workflowRunning: 'Çalışıyor',
workflowCompleted: 'Tamamlandı',
workflowCancelled: 'İptal edildi',
+9
View File
@@ -442,6 +442,15 @@ export const vi: Record<string, string> = {
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',
teamTaskActions: 'Thao tác với tác vụ',
teamTaskSubjectPlaceholder: 'Mô tả việc cần làm trong một dòng',
teamTaskDescriptionPlaceholder: 'Thông tin bổ sung (không bắt buộc)',
teamFilterAll: 'Tất cả',
tasksNodeTasks: 'Tác vụ dùng chung',
jobCopyOutput: 'Sao chép đầu ra',
jobCopied: 'Đã sao chép',
jobFollowTail: 'Theo dòng mới nhất',
jobDragHint: 'Kéo để di chuyển',
workflowRunning: 'Đang chạy',
workflowCompleted: 'Hoàn thành',
workflowCancelled: 'Đã hủy',
+9
View File
@@ -457,6 +457,15 @@ export const zhHK: Record<string, string> = {
teamTaskConflict: '任務已被他人修改,已重新整理',
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
tasksNodeTasks: '共用任務',
jobCopyOutput: '複製輸出',
jobCopied: '已複製',
jobFollowTail: '跟隨最新',
jobDragHint: '拖曳浮窗',
workflowRunning: '進行中',
workflowCompleted: '已完成',
workflowCancelled: '已取消',
+9
View File
@@ -457,6 +457,15 @@ export const zhMO: Record<string, string> = {
teamTaskConflict: '任務已被他人修改,已重新整理',
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
tasksNodeTasks: '共用任務',
jobCopyOutput: '複製輸出',
jobCopied: '已複製',
jobFollowTail: '跟隨最新',
jobDragHint: '拖曳浮窗',
workflowRunning: '進行中',
workflowCompleted: '已完成',
workflowCancelled: '已取消',
+9
View File
@@ -457,6 +457,15 @@ export const zhTW: Record<string, string> = {
teamTaskConflict: '任務已被他人修改,已重新整理',
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
tasksNodeTasks: '共用任務',
jobCopyOutput: '複製輸出',
jobCopied: '已複製',
jobFollowTail: '跟隨最新',
jobDragHint: '拖曳浮窗',
workflowRunning: '進行中',
workflowCompleted: '已完成',
workflowCancelled: '已取消',
+18
View File
@@ -454,6 +454,15 @@ export const zh = {
teamTaskConflict: '任务已被他人修改,已刷新',
teamTaskError: '操作失败:{message}',
teamTaskDeleted: '已删除',
teamTaskActions: '任务操作',
teamTaskSubjectPlaceholder: '一句话说明要做什么',
teamTaskDescriptionPlaceholder: '补充说明(可选)',
teamFilterAll: '全部',
tasksNodeTasks: '共享任务',
jobCopyOutput: '复制输出',
jobCopied: '已复制',
jobFollowTail: '跟随最新',
jobDragHint: '拖动浮窗',
workflowRunning: '进行中',
workflowCompleted: '已完成',
workflowCancelled: '已取消',
@@ -949,6 +958,15 @@ export const en: Record<keyof typeof zh, string> = {
teamTaskConflict: 'The task changed elsewhere; refreshed',
teamTaskError: 'Operation failed: {message}',
teamTaskDeleted: 'Deleted',
teamTaskActions: 'Task actions',
teamTaskSubjectPlaceholder: 'One line describing the work',
teamTaskDescriptionPlaceholder: 'Extra context (optional)',
teamFilterAll: 'All',
tasksNodeTasks: 'Shared tasks',
jobCopyOutput: 'Copy output',
jobCopied: 'Copied',
jobFollowTail: 'Follow latest',
jobDragHint: 'Drag to move',
workflowRunning: 'Running',
workflowCompleted: 'Completed',
workflowCancelled: 'Cancelled',
+166
View File
@@ -180,6 +180,19 @@
color: var(--dsw-alias-label-secondary);
}
.nodeGlyph {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.treeGlyph {
display: inline-flex;
flex: none;
margin-top: 2px;
color: var(--dsw-alias-label-tertiary);
}
.nodeMeta {
margin-top: 2px;
font-family: var(--ds-font-family-code);
@@ -191,6 +204,66 @@
text-overflow: ellipsis;
}
/* ── Node shared-task line (team members only) ─────────────────────────── */
/* The node's detail affordance: a small ghost button pinned to the corner. */
.nodeInfo {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
padding: 0 2px;
opacity: 0.55;
}
.node:hover .nodeInfo,
.nodeInfo:focus-visible {
opacity: 1;
}
.taskLine {
display: flex;
align-items: center;
gap: 4px;
margin-top: 3px;
min-width: 0;
font-size: 9.5px;
line-height: 1.3;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
}
.taskGlyph {
display: inline-flex;
flex: none;
color: var(--dsw-alias-state-business-primary);
}
.taskSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.taskState {
flex: none;
font-family: var(--ds-font-family-code);
font-size: 8.5px;
color: var(--dsw-alias-label-tertiary);
}
.taskMore {
flex: none;
padding: 0 3px;
border: 1px solid var(--dsw-alias-border-l4);
border-radius: 2px;
font-family: var(--ds-font-family-code);
font-size: 8.5px;
color: var(--dsw-alias-label-tertiary);
}
/* ── Live activity row (icon + tool + args; shared by both modes) ──────── */
.live {
@@ -443,6 +516,49 @@
color: var(--dsw-alias-label-tertiary);
}
.popHeadActions {
display: inline-flex;
align-items: center;
gap: 2px;
text-transform: none;
letter-spacing: 0;
}
.popFoot {
display: flex;
margin-top: 8px;
}
.popFoot > * {
flex: 1;
}
.jobPopTitle {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.jobPopMeta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 10px;
color: var(--dsw-alias-label-secondary);
}
.jobPopActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.popTitle {
overflow: hidden;
text-overflow: ellipsis;
@@ -583,6 +699,34 @@
color: var(--dsw-alias-state-warn-primary);
}
/* ── Task create/edit dialog ───────────────────────────────────────────── */
.taskForm {
display: flex;
flex-direction: column;
gap: 10px;
}
.taskField {
display: flex;
flex-direction: column;
gap: 4px;
}
.taskLabel {
font-family: var(--ds-font-family-code);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--dsw-alias-label-tertiary);
}
.taskOwnerRow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* ── Team board (always-visible strip under the header) ────────────────── */
.teamBoard {
@@ -623,6 +767,28 @@
color: var(--dsw-alias-label-tertiary);
}
.teamBoardIcon {
display: inline-flex;
color: var(--dsw-alias-label-tertiary);
}
.teamMemberPill {
font-size: 9.5px;
}
.teamNote {
padding: 0 12px 6px;
font-size: 10px;
line-height: 1.4;
color: var(--dsw-alias-state-warn-primary);
}
.teamActions {
display: flex;
gap: 6px;
padding: 0 12px 8px;
}
.teamMembers {
display: flex;
flex-wrap: wrap;
+35
View File
@@ -25,6 +25,7 @@ import type {
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarTeamMemberView,
SidebarTeamTaskView,
} from '../context-types.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
@@ -33,6 +34,15 @@ import { isSideThreadSummary } from './subagent-detect.ts'
/** Display state of one agent node (drives the dot + fold candidacy). */
export type TasksNodeState = 'running' | 'idle' | 'done' | 'error'
/** One shared task as a node shows it (the board owns the full editing). */
export interface TasksNodeTask {
id: string
subject: string
status: 'pending' | 'in_progress' | 'completed'
/** Not ready = blocked by another task. */
ready: boolean
}
/** One agent node (root, subagent, teammate, or synthesized workflow member). */
export interface TasksAgentNode {
kind: 'agent'
@@ -60,6 +70,8 @@ export interface TasksAgentNode {
}
/** The catalog's durable children signal (fold candidacy's leaf test). */
hasChildren?: boolean
/** Shared tasks owned by this agent (team boards only; empty otherwise). */
tasks?: TasksNodeTask[]
/** Synthesized from a workflow run's member row (no catalog entry). */
synthesized?: boolean
/** Jump target of the row (absent on the root node). */
@@ -97,6 +109,8 @@ export interface TasksModelInput {
live: Readonly<Record<string, LastActivity | undefined>>
runs: readonly WorkflowRunView[]
teamMembers: readonly SidebarTeamMemberView[]
/** The team's shared tasks; each lands on its OWNER's node. */
teamTasks?: readonly SidebarTeamTaskView[]
/** Whether settled leaves collapse into fold nodes. */
folded: boolean
}
@@ -138,7 +152,23 @@ function memberOutcomeState(outcome: 'completed' | 'failed' | 'cancelled' | unde
*/
export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const { byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded } = input
const teamTasks = input.teamTasks ?? []
const teamById = new Map(teamMembers.map(member => [member.id, member]))
/** Owner display name → node id (the board assigns by member name). */
const nodeByOwner = new Map<string, string>()
for (const member of teamMembers) nodeByOwner.set(member.name, member.id)
const tasksByNode = new Map<string, TasksNodeTask[]>()
for (const task of teamTasks) {
if (task.status === 'deleted' || task.ownerName === undefined) continue
const nodeId = nodeByOwner.get(task.ownerName)
if (nodeId === undefined) continue
const list = tasksByNode.get(nodeId)
const entry: TasksNodeTask = {
id: task.id, subject: task.subject, status: task.status, ready: task.ready,
}
if (list === undefined) tasksByNode.set(nodeId, [entry])
else list.push(entry)
}
const runsByOrigin = new Map<string, WorkflowRunView[]>()
for (const run of runs) {
const list = runsByOrigin.get(run.originSessionId)
@@ -183,6 +213,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
current: entry.id === currentSessionId,
...(live[entry.id] !== undefined ? { live: live[entry.id] } : {}),
...(team !== undefined ? { team } : {}),
...(tasksByNode.get(entry.id) !== undefined ? { tasks: tasksByNode.get(entry.id) } : {}),
hasChildren: entry.hasChildren,
childAddress: { parentSessionId: parentId, childSessionId: entry.id, mode: entry.mode },
})
@@ -203,6 +234,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
: undefined
if (existing !== undefined) {
existing.parentId = runNode.id
if (tasksByNode.get(existing.id) !== undefined) existing.tasks = tasksByNode.get(existing.id)
members.push(existing)
} else {
const childKnown = member.childId !== ''
@@ -215,6 +247,8 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
activity: member.outcome === undefined ? 'running' : 'inactive',
current: member.childId !== '' && member.childId === currentSessionId,
synthesized: true,
...(childKnown && tasksByNode.get(member.childId) !== undefined
? { tasks: tasksByNode.get(member.childId) } : {}),
...(childKnown
? { childAddress: { parentSessionId: parentId, childSessionId: member.childId, mode: 'one-shot' as const } }
: {}),
@@ -298,6 +332,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
activity: rootSummary?.running === true ? 'running' : 'inactive',
current: rootId === currentSessionId,
...(rootTeam !== undefined ? { team: rootTeam } : {}),
...(tasksByNode.get(rootId) !== undefined ? { tasks: tasksByNode.get(rootId) } : {}),
})
appendChildren(rootId)
return out
+80 -17
View File
@@ -1,27 +1,29 @@
/**
* Shared presentation bits of the Tasks page's two modes (graph + tree): the
* state-dot mapping, the SHORT mono meta line (one or two tokens — the card
* is 132px wide in the narrow native sidebar, so anything longer moves into
* the ⓘ popover), the iconified live activity row ("glyph + tool + args",
* the approved format), and the fold aggregate caption.
* Shared presentation bits of the Tasks page's two modes (graph + tree):
* the state dot, the agent/workflow/task GLYPHS (host primitive icons — the
* page draws no glyphs of its own), the short mono meta line, the node task
* line, and the iconified live activity row ("tool icon + tool + args").
*
* Card content contract (the page's answer to "everything is ellipsized"):
* line 1 dot + name (11px, semibold, one line)
* line 2 mode/model · activity (9px mono, one line)
* line 3 live line (running nodes only)
* Everything else — display title, full model id, team role, phase names,
* latest output text, jump action — lives in the anchored popover.
* line 1 state dot + agent icon + name (11px semibold, one line)
* line 2 mode/model · activity (9px mono, one line)
* line 3 live tool line (running nodes only)
* line 4 owned shared task (team members only)
* Everything else lives in the popovers.
*/
import type { ReactNode } from 'react'
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconAgentPresetOutline16, IconBranchOutline16, IconChecklistOutline14, IconUserOutline16,
type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { LastActivity } from '../subagent-activity.ts'
import type { TasksAgentNode, TasksNodeState, TasksWorkflowNode } from './tasks-model.ts'
import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts'
import { toolGlyph } from './tool-icons.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 42
const ARGS_PREVIEW = 48
/** Preview cap of one text line. */
const TEXT_PREVIEW = 72
@@ -45,6 +47,21 @@ export function stateLabel(state: TasksNodeState): string {
return t(key)
}
/** The task status label key. */
export function taskStatusKey(status: TasksNodeTask['status']): CopyKey {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
}
}
/** The host StateDot semantic of a task row/line. */
export function taskDotState(task: TasksNodeTask): StateDotState {
if (task.status === 'completed') return 'done'
return task.ready ? 'ongoing' : 'warning'
}
/** 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
@@ -55,6 +72,23 @@ export function flatten(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
/** The host icon of one agent node (the lead and plain children share one). */
export function AgentGlyph(props: { node: TasksAgentNode; size?: number }): ReactNode {
const size = props.size ?? 11
if (props.node.team?.role === 'teammate') return <IconUserOutline16 size={size} />
return <IconAgentPresetOutline16 size={size} />
}
/** The workflow-run glyph. */
export function WorkflowGlyph(props: { size?: number }): ReactNode {
return <IconBranchOutline16 size={props.size ?? 11} />
}
/** The fold aggregate glyph. */
export function FoldGlyph(props: { size?: number }): ReactNode {
return <IconChecklistOutline14 size={props.size ?? 11} />
}
/**
* The mono meta line of an agent card — at most two short tokens:
* - the topology root: 主代理 · 状态
@@ -97,10 +131,39 @@ export function workflowStatusKey(status: TasksWorkflowNode['run']['status']): C
}
}
/** The task a node surfaces first: in-progress, else pending, else the last. */
export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | undefined {
return tasks.find(task => task.status === 'in_progress')
?? tasks.find(task => task.status === 'pending')
?? tasks[tasks.length - 1]
}
/**
* The live activity row of a RUNNING agent: "glyph + tool + args" (the
* approved format), plus the flattened last text line underneath. A running
* node with neither reads as thinking.
* The node's shared-task line: an icon, the primary task's subject, its
* status word, and a `+N` tail when the agent owns more. Renders nothing
* without tasks, so non-team nodes keep the three-line shape.
*/
export function TaskLine(props: { tasks: readonly TasksNodeTask[] | undefined }): ReactNode {
const tasks = props.tasks ?? []
const primary = primaryTask(tasks)
if (primary === undefined) return null
return (
<span
className={css.taskLine}
title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
>
<span className={css.taskGlyph} aria-hidden="true"><IconChecklistOutline14 size={9} /></span>
<span className={css.taskSubject}>{primary.subject}</span>
<span className={css.taskState}>{t(taskStatusKey(primary.status))}</span>
{tasks.length > 1 && <span className={css.taskMore}>{`+${tasks.length - 1}`}</span>}
</span>
)
}
/**
* The live activity row of a RUNNING agent: the tool's own icon + tool name +
* args (the approved format), plus the flattened last text line underneath.
* A running node with neither reads as thinking.
*/
export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
const { live } = props
@@ -111,7 +174,7 @@ export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
<>
{live.tool !== undefined && (
<span className={css.live}>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(8)}</span>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(9)}</span>
<span className={css.liveTool}>{live.tool.name}</span>
{live.tool.args !== '' && <span className={css.liveArgs}>{preview(live.tool.args)}</span>}
</span>
+133 -3
View File
@@ -54,14 +54,33 @@ function jsonResponse(value: unknown): Response {
}
let teamPayload: unknown = { available: false }
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
const fetchedMethods: string[] = []
beforeEach(() => {
teamPayload = { available: false }
vi.stubGlobal('fetch', async (url: string | URL | Request) => {
teamMutations.length = 0
fetchedMethods.length = 0
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
fetchedMethods.push(method ?? '')
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
teamMutations.push({ method: method ?? '', body })
return jsonResponse({
ok: true,
value: {
ok: true,
value: {
id: 't9', revision: 9, subject: body.subject ?? 'x', description: body.description ?? '',
status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
},
},
})
}
if (method === 'jobs.output') return jsonResponse({ ok: true, value: { text: 'out', truncated: false, read: true } })
throw new Error(`unexpected fetch ${String(url)}`)
})
@@ -178,8 +197,8 @@ describe('Tasks page interactions', () => {
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
// The root node's ⓘ opens the detail card (portaled to body).
const info = container.querySelector('button[aria-label="状态"]') as HTMLButtonElement
// The root node's detail affordance opens the detail card (portaled to body).
const info = container.querySelector('button[aria-label="节点详情"]') as HTMLButtonElement
await act(async () => { info.click() })
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
expect(document.body.textContent).toContain('查看转录')
@@ -257,3 +276,114 @@ describe('Tasks page graph interactions and team board', () => {
unmount()
})
})
describe('Tasks page: owned tasks, host-primitive controls, draggable output', () => {
/** A team payload whose only task is owned by the child agent. */
function teamWithOwnedTask(): unknown {
return {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', model: 'glm-5.3', diagnostics: [] },
],
tasks: [
{
id: 't1', revision: 1, subject: '收窄卡片与图标化', description: '细节', status: 'in_progress',
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
},
{
id: 't2', revision: 2, subject: '补点击回归', description: '', status: 'pending',
ownerName: 'writer', blockedBy: ['t1'], writeScopes: [], ready: false, writeScopeWarnings: [],
},
],
},
}
}
it('renders the owned task on its agent node', async () => {
teamPayload = teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
expect(node).not.toBeNull()
// The node carries the task subject + its status word + the +N tail.
expect(node.textContent).toContain('收窄卡片与图标化')
expect(node.textContent).toContain('进行中')
expect(node.textContent).toContain('+1')
unmount()
})
it('ships no native select/input: the board edits through host primitives', async () => {
teamPayload = teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
// No native form controls anywhere on the page.
expect(container.querySelectorAll('select')).toHaveLength(0)
expect(container.querySelectorAll('input')).toHaveLength(0)
// The row's overflow menu is a host Menu opened from a host Button.
const menuButton = container.querySelector('button[aria-label^="任务操作"]') as HTMLButtonElement
expect(menuButton).not.toBeNull()
await act(async () => { menuButton.click() })
expect(document.body.textContent).toContain('完成')
expect(document.body.textContent).toContain('编辑')
expect(document.body.textContent).toContain('删除')
unmount()
})
it('creates a task through the dialog (host Input + modal) and posts the CAS-free create', async () => {
teamPayload = teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
const create = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
expect(create).toBeDefined()
await act(async () => { create?.click() })
// The dialog's field is a host Input (rendered as a real <input> inside the modal).
const field = document.querySelector('input[aria-label="标题"]') as HTMLInputElement
expect(field).not.toBeNull()
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(field, '新任务标题')
field.dispatchEvent(new Event('input', { bubbles: true }))
})
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
expect(save).toBeDefined()
expect(field.value).toBe('新任务标题')
expect((save as HTMLButtonElement).disabled).toBe(false)
await act(async () => { save?.click() })
await act(async () => { await Promise.resolve() })
expect(fetchedMethods).toContain('teams.taskCreate')
expect(teamMutations[0]?.body.subject).toBe('新任务标题')
unmount()
})
it('drags the job output popover away from its anchor', async () => {
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
const card = document.querySelector('[role="dialog"]') as HTMLElement
expect(card).not.toBeNull()
const before = { left: card.style.left, top: card.style.top }
await act(async () => {
card.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 40, clientY: 40 }))
window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 120 }))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
})
expect({ left: card.style.left, top: card.style.top }).not.toEqual(before)
unmount()
})
})