feat(tasks-ui): one task window everywhere, cards are their own detail entry

Round-three review feedback:

- NO PER-CARD DETAIL BUTTON: the ellipsis control is gone from graph cards
  and tree rows — the card IS the affordance, and clicking it opens the
  detail window (the transcript jump moved inside as the primary button).
  Fold aggregates keep click-to-expand.
- ONE TASK WINDOW: every task surface (board row, agent node task line, the
  node detail list, and creating a task) now opens the same non-fullscreen
  DRAGGABLE window (TaskWindow.tsx + its TaskPopover shell): markdown
  description by default, 编辑 switches the same card to multi-line editing,
  plus owner reassignment, complete/reopen and a two-step delete. The board
  lost its overflow menu and its Modal entirely; create uses the same window
  in create mode.
- REUSE: TaskPopover (4 call sites), MultilineField (the plugin's own
  multi-line input — the host primitive set has none), OwnerPicker,
  TaskCreateButton, and the shared glyph/TaskLine helpers across both modes.
- 3 new copy keys across zh/en/ja and the 18 third-language dictionaries.

Tests: the node-click spec now asserts the detail window (and that the jump
button inside it navigates), a new spec pins 'no nested control inside a
card', and two more cover the shared window: markdown-first view then
multi-line edit posting an 'edit' with the current revision, and immediate
owner reassignment. Full suite 1406 passed / 133 files.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent e41f10afc9
commit 7cbfc766a1
31 changed files with 799 additions and 347 deletions
@@ -115,6 +115,26 @@ client 半(src/client/)
i18n:本轮新增 19 条文案,zh/en/ja + 18 份第三语言词典同步(`tests/locales.spec.ts` 16/16)。
## 第三轮返工(2026-09-14 深夜,交互统一)
用户第三轮反馈两点,都指向「减少控件、统一入口」:
| 反馈 | 做法 |
|---|---|
| 图卡片去掉详情按钮,「统一改为弹出详细」 | 删除每张卡片右上角的 `…` 按钮(树行同理):**卡片/行本身就是详情入口**,点击即弹详情窗口;跳转转录改为详情窗口内的主按钮。折叠聚合节点仍保留「点击=展开/再折叠」的语义 |
| 任务所有入口统一为「点击弹出非全屏可拖动编辑(兼查看)窗口」,含 编辑owner + 编辑 + 重开 + 删除;默认多行 markdown 预览,点编辑才进入多行编辑 | 新增单一组件 `TaskWindow.tsx`:`TaskWindow`(内容)+ `TaskPopover`(可拖动外壳,宽 430)+ `MultilineField`(自建多行输入)+ `OwnerPicker`(Pill 即点即改派)+ `TaskCreateButton`。三处入口全部复用它——任务板行、节点任务行、节点详情里的任务清单;「新建」也走同一个窗口(create 模式直接进入编辑态)。任务板由此**删掉了溢出菜单与 Modal**,只留筛选 Pills、任务行与新建按钮 |
新文案 3 条(任务详情 / 暂无描述 / 被阻塞),zh/en/ja + 18 份第三语言同步。
组件复用清单(本轮):
| 组件 | 复用点 |
|---|---|
| `TaskPopover` | 任务板行、节点任务行、节点详情任务清单、新建按钮(4 处) |
| `MultilineField` | 任务描述编辑(宿主 primitives 无多行输入,故自建) |
| `TaskCreateButton` | 任务板 / 其它入口共用同一外观 |
| `AgentGlyph` / `WorkflowGlyph` / `FoldGlyph` / `TaskLine` | 图与树两种模式共用 |
## 验证结果(2026-09-14)
PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `feat/tasks-graph-workflow-teams`)。
+1 -1
View File
@@ -86,7 +86,7 @@ export function AnchoredPopover(props: AnchoredPopoverProps): ReactNode {
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
if (target.closest('button, input, textarea, select, a, [data-popover-no-drag]') !== null) return
event.preventDefault()
const startX = event.clientX
const startY = event.clientY
+54 -21
View File
@@ -49,6 +49,7 @@ import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx'
import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.tsx'
import { TeamBoard } from './TeamBoard.tsx'
import { TaskPopover } from './TaskWindow.tsx'
import type { SidebarStore } from './state.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import legacy from './SubagentView.module.css'
@@ -133,6 +134,8 @@ type PagePopover =
| { kind: 'node'; nodeId: string; anchor: HTMLElement }
| { kind: 'workflow'; nodeId: string; anchor: HTMLElement }
| { kind: 'job'; jobId: string; anchor: HTMLElement }
/** The shared task window; taskId undefined = create mode. */
| { kind: 'task'; taskId: string | undefined; anchor: HTMLElement }
/**
* The sidebar's Tasks page.
@@ -274,9 +277,8 @@ export function SubagentView(props: {
void sessions.refreshSubagents?.(parentSessionId)
}, [sessions])
/** Node click = jump to the transcript (synthesized members without a
* session address open their detail popover instead). */
const activateNode = useCallback((node: TasksAgentNode): void => {
/** Jump from the detail window into the node's transcript. */
const jumpToNode = useCallback((node: TasksAgentNode): void => {
if (node.childAddress !== undefined) {
openChild(node.childAddress)
setPopover(null)
@@ -286,10 +288,21 @@ export function SubagentView(props: {
openMain()
setPopover(null)
}
// Synthesized workflow members without a childId have no transcript to
// jump to: their ⓘ popover is the only detail surface.
}, [openChild, openMain])
/** Open the shared task window from a board row (undefined = create). */
const openTaskFromBoard = useCallback((
task: { id: string } | undefined,
anchor: HTMLElement,
): void => {
setPopover({ kind: 'task', taskId: task?.id, anchor })
}, [])
/** Open the shared task window by id (node task lines, detail lists). */
const openTaskById = useCallback((taskId: string, anchor: HTMLElement): void => {
setPopover({ kind: 'task', taskId, anchor })
}, [])
const totals = useMemo(
() => rootId === undefined
? { count: 0, runningCount: 0 }
@@ -351,6 +364,25 @@ export function SubagentView(props: {
/** The current popover's resolved content (subjects re-resolve live). */
const popoverContent = ((): ReactNode => {
if (popover === null) return null
if (popover.kind === 'task') {
if (teamView?.available !== true || teamView.team === null || rootId === undefined) return null
const task = popover.taskId === undefined
? undefined
: teamView.team.tasks.find(candidate => candidate.id === popover.taskId)
// A task that vanished (deleted elsewhere) closes the window instead of
// showing a stale card.
if (popover.taskId !== undefined && task === undefined) return null
return (
<TaskPopover
rootId={rootId}
task={task}
members={teamView.team.members}
onChanged={team.refresh}
onClose={closePopover}
anchor={popover.anchor}
/>
)
}
if (popover.kind === 'job') {
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
if (row === undefined) return null
@@ -370,11 +402,8 @@ export function SubagentView(props: {
return (
<AgentNodePopover
node={node}
onJump={(target) => {
if (target.childAddress !== undefined) openChild(target.childAddress)
else openMain()
setPopover(null)
}}
onJump={jumpToNode}
onOpenTask={openTaskById}
/>
)
}
@@ -410,7 +439,7 @@ export function SubagentView(props: {
rootId={rootId}
members={teamView.team.members}
tasks={teamView.team.tasks}
onChanged={team.refresh}
onOpenTask={openTaskFromBoard}
collapsed={teamBoardCollapsed}
onToggleCollapsed={() => { setTeamBoardCollapsed(current => !current) }}
/>
@@ -441,9 +470,9 @@ export function SubagentView(props: {
nodes={model}
folded={folded}
rootId={rootId}
onActivate={activateNode}
onNodeInfo={(node, anchor) => { setPopover({ kind: 'node', nodeId: node.id, anchor }) }}
onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }}
onOpenTask={openTaskById}
onToggleFold={() => { setFolded(current => !current) }}
mode={mode}
onModeChange={setModeOverride}
@@ -454,9 +483,9 @@ export function SubagentView(props: {
nodes={model}
folded={folded}
loading={summaryBackedLoading}
onActivate={activateNode}
onNodeInfo={(node, anchor) => { setPopover({ kind: 'node', nodeId: node.id, anchor }) }}
onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }}
onOpenTask={openTaskById}
onToggleFold={() => { setFolded(current => !current) }}
mode={mode}
onModeChange={setModeOverride}
@@ -473,14 +502,18 @@ export function SubagentView(props: {
: { kind: 'job', jobId: row.job.id, anchor })
}}
/>
<AnchoredPopover
anchor={popover?.anchor ?? null}
onClose={closePopover}
draggable={popover?.kind === 'job'}
width={popover?.kind === 'job' ? 380 : 280}
>
{popoverContent}
</AnchoredPopover>
{popover?.kind === 'task'
? popoverContent
: (
<AnchoredPopover
anchor={popover?.anchor ?? null}
onClose={closePopover}
draggable={popover?.kind === 'job'}
width={popover?.kind === 'job' ? 380 : 280}
>
{popoverContent}
</AnchoredPopover>
)}
</div>
)
}
+434
View File
@@ -0,0 +1,434 @@
/**
* The ONE task window: view, edit and create all live here, and every task
* surface (the board's rows, an agent node's task line, the node detail
* popover) opens THIS component — pointer targets stay thin, the window owns
* the behaviour.
*
* Shape: a non-fullscreen, DRAGGABLE card (never a modal dialog). Details
* render as multi-line MARKDOWN by default; 编辑 switches the same card into
* multi-line editing, so reading and writing never jump between surfaces.
* Actions: owner reassignment (Pills, CAS-immediate), edit/save, reopen or
* complete, and a two-step delete.
*
* Host primitives everywhere (Button / Input / Pill / Tag / StateDot) plus
* the plugin's own `MultilineField` (the host set ships no multi-line input)
* and the shared `MarkdownText` renderer with the plugin's copy labels.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
Button, IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16,
IconRefreshOutline14, IconTrashOutline16, Input, MarkdownText, Pill, StateDot, Tag,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { api } from './api.ts'
import { markdownTextProps } from './markdown-labels.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
/** One mutation outcome (the host route's own union). */
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
/** 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 plugin's own multi-line input: the host primitive set ships no
* textarea, so this is OUR component (token-styled, drag-exempt) and every
* multi-line edit in the page goes through it.
*/
export function MultilineField(props: {
value: string
label: string
placeholder?: string
rows?: number
onChange?(next: string): void
}): ReactNode {
return (
<textarea
className={css.taskTextarea}
value={props.value}
rows={props.rows ?? 7}
placeholder={props.placeholder}
aria-label={props.label}
data-popover-no-drag
onChange={(event) => { props.onChange?.(event.target.value) }}
/>
)
}
/** The owner picker (Pills; CAS-immediate, so it is never a draft field). */
function OwnerPicker(props: {
members: readonly SidebarTeamMemberView[]
owner: string | undefined
disabled: boolean
onPick(owner: string): void
}): ReactNode {
return (
<div className={css.taskOwnerRow}>
<Pill
active={props.owner === undefined}
disabled={props.disabled}
onClick={() => { props.onPick('') }}
>
{t('teamTaskUnowned')}
</Pill>
{props.members.map(member => (
<Pill
key={member.id}
active={props.owner === member.name}
disabled={props.disabled}
onClick={() => { props.onPick(member.name) }}
>
{member.name}
</Pill>
))}
</div>
)
}
/** The read-only body: markdown description, meta, owner picker. */
function TaskViewBody(props: {
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
busy: boolean
onReassign(owner: string): void
}): ReactNode {
const { task } = props
const body = task.description.trim()
return (
<>
<div className={css.jobPopTitle}>
<StateDot
size={6}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
<span className={css.popTitle} title={task.subject}>{task.subject}</span>
<Tag tone={task.status === 'completed' ? 'success' : task.ready ? 'info' : 'warning'}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</Tag>
</div>
<div className={css.jobPopMeta}>
<span className={css.popKey}>{t('teamTaskOwner')}</span>
<span>{task.ownerName ?? t('teamTaskUnowned')}</span>
{task.blockedBy.length > 0 && (
<>
<span className={css.popKey}>{t('teamTaskBlockedBy')}</span>
<span>{task.blockedBy.join(' · ')}</span>
</>
)}
</div>
<div className={css.taskMarkdown}>
{body === ''
? <div className={css.popHint}>{t('teamTaskNoDescription')}</div>
: (
<MarkdownText
{...markdownTextProps(body, { copyLabel: t('copy'), copiedLabel: t('copied') })}
/>
)}
</div>
<div className={css.taskField}>
<span className={css.taskLabel}>{t('teamTaskOwner')}</span>
<OwnerPicker
members={props.teammates}
owner={task.ownerName}
disabled={props.busy}
onPick={props.onReassign}
/>
</div>
</>
)
}
/** The editing body: subject (host Input) + description (our multi-line field). */
function TaskEditBody(props: {
subject: string
description: string
onSubject(next: string): void
onDescription(next: string): void
}): ReactNode {
return (
<div className={css.taskForm} data-popover-no-drag>
<label className={css.taskField}>
<span className={css.taskLabel}>{t('teamTaskSubject')}</span>
<Input
value={props.subject}
placeholder={t('teamTaskSubjectPlaceholder')}
aria-label={t('teamTaskSubject')}
onChange={(event) => { props.onSubject(event.target.value) }}
/>
</label>
<label className={css.taskField}>
<span className={css.taskLabel}>{t('teamTaskDescription')}</span>
<MultilineField
value={props.description}
label={t('teamTaskDescription')}
placeholder={t('teamTaskDescriptionPlaceholder')}
onChange={props.onDescription}
/>
</label>
</div>
)
}
export interface TaskWindowProps {
rootId: string
/** The task under view/edit; undefined = create mode. */
task: SidebarTeamTaskView | undefined
members: readonly SidebarTeamMemberView[]
/** Re-pull `teams.view` after a mutation (the parent owns the poller). */
onChanged(): void
/** Close the window (the caller's popover state). */
onClose(): void
}
/** The task window content (the caller supplies the popover shell). */
export function TaskWindow(props: TaskWindowProps): ReactNode {
const { rootId, task, members, onChanged, onClose } = props
const creating = task === undefined
const [editing, setEditing] = useState(creating)
const [subject, setSubject] = useState(task?.subject ?? '')
const [description, setDescription] = useState(task?.description ?? '')
const [busy, setBusy] = useState(false)
const [note, setNote] = useState<string | undefined>(undefined)
const [armedDelete, setArmedDelete] = useState(false)
// Re-seed the draft when the caller swaps the subject task: the poller
// hands us fresh revisions, and unsaved text must not leak across tasks.
// Keyed by id on purpose — a same-id revision bump must NOT clobber the
// reader's in-flight edit (the seed rides a render-time ref so the effect
// depends on the id alone).
const seedRef = useRef({
subject: task?.subject ?? '',
description: task?.description ?? '',
creating: task === undefined,
})
seedRef.current = {
subject: task?.subject ?? '',
description: task?.description ?? '',
creating: task === undefined,
}
useEffect(() => {
const seed = seedRef.current
setSubject(seed.subject)
setDescription(seed.description)
setEditing(seed.creating)
}, [task?.id])
const teammates = members.filter(member => member.role === 'teammate')
/** Run one CAS mutation with the shared busy/conflict handling. */
const mutate = async (
action: () => Promise<MutationResult>,
options: { close?: boolean } = {},
): 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 }))
} else {
if (options.close === true) onClose()
else setEditing(false)
}
onChanged()
} catch (error) {
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
} finally {
setBusy(false)
}
}
/** Save the draft (create closes the window; edit returns to the view). */
const save = (): void => {
const nextSubject = subject.trim()
if (nextSubject === '') return
void mutate(() => (creating
? api.teamsTaskCreate(rootId, { subject: nextSubject, description: description.trim() })
: api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'edit',
subject: nextSubject,
description: description.trim(),
})), { close: creating })
}
/** Reassign the task (or return it to the unowned pool). */
const reassign = (owner: string): void => {
if (task === undefined || busy) return
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'reassign',
...(owner === '' ? {} : { owner }),
}))
}
return (
<div className={css.popCard} data-popover-handle>
<div className={css.popHead}>
<span>{creating ? t('teamTaskCreate') : editing ? t('teamTaskEdit') : t('teamTaskDetail')}</span>
<span className={css.popHeadActions} data-popover-no-drag>
<Button
variant="ghost"
size="sm"
icon={<IconCloseOutline16 size={12} />}
aria-label={t('teamTaskCancel')}
title={t('teamTaskCancel')}
onClick={onClose}
/>
</span>
</div>
{editing || task === undefined
? (
<TaskEditBody
subject={subject}
description={description}
onSubject={setSubject}
onDescription={setDescription}
/>
)
: (
<TaskViewBody
task={task}
teammates={teammates}
busy={busy}
onReassign={reassign}
/>
)}
{note !== undefined && <div className={css.teamNote}>{note}</div>}
<div className={css.jobPopActions} data-popover-no-drag>
{editing
? (
<>
<Button
variant="primary"
size="sm"
icon={<IconCheckOutline14 size={12} />}
disabled={busy || subject.trim() === ''}
onClick={save}
>
{creating ? t('teamTaskCreate') : t('teamTaskSave')}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
if (creating) onClose()
else if (task !== undefined) {
setSubject(task.subject)
setDescription(task.description)
setEditing(false)
}
}}
>
{t('teamTaskCancel')}
</Button>
</>
)
: task === undefined ? null : (
<>
<Button
variant="outline"
size="sm"
icon={<IconEditOutline16 size={12} />}
onClick={() => { setEditing(true) }}
>
{t('teamTaskEdit')}
</Button>
{task.status === 'completed'
? (
<Button
variant="outline"
size="sm"
icon={<IconRefreshOutline14 size={12} />}
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))}
>
{t('teamTaskReopen')}
</Button>
)
: (
<Button
variant="outline"
size="sm"
icon={<IconCheckOutline14 size={12} />}
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete',
}))}
>
{t('teamTaskComplete')}
</Button>
)}
<Button
variant="outline"
size="sm"
className={armedDelete ? css.jobPopKillArmed : undefined}
icon={<IconTrashOutline16 size={12} />}
disabled={busy}
onClick={() => {
if (!armedDelete) { setArmedDelete(true); return }
setArmedDelete(false)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'delete',
}), { close: true })
}}
>
{armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
</Button>
</>
)}
</div>
</div>
)
}
/**
* The task window inside its draggable popover shell — the single entry point
* every task surface uses (board row, node task line, node detail list).
*/
export function TaskPopover(props: TaskWindowProps & { anchor: HTMLElement | null }): ReactNode {
const { anchor, onClose, ...windowProps } = props
return (
<AnchoredPopover anchor={anchor} onClose={onClose} draggable width={430}>
{anchor === null ? null : <TaskWindow {...windowProps} onClose={onClose} />}
</AnchoredPopover>
)
}
/** The create affordance every surface shares. */
export function TaskCreateButton(props: {
disabled?: boolean
onClick(anchor: HTMLElement): void
}): ReactNode {
return (
<Button
variant="outline"
size="sm"
icon={<IconPlusOutline16 size={13} />}
disabled={props.disabled}
onClick={(event) => { props.onClick(event.currentTarget) }}
>
{t('teamTaskCreate')}
</Button>
)
}
+9 -21
View File
@@ -24,8 +24,7 @@ import {
} from 'react'
import clsx from 'clsx'
import {
Button, IconChecklistOutline14, IconEllipsisOutline16, IconFullscreenOutline16, IconTreeCorner8x10,
StateDot,
Button, IconChecklistOutline14, 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'
@@ -63,9 +62,10 @@ export interface TasksGraphProps {
folded: boolean
/** Re-fit trigger: the topology root id (a reroot re-centers). */
rootId: string | undefined
onActivate(node: TasksAgentNode): void
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void
/** Open the shared task window for one task id. */
onOpenTask(taskId: string, anchor: HTMLElement): void
onToggleFold(): void
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
@@ -112,7 +112,7 @@ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void
}
export function TasksGraph(props: TasksGraphProps): ReactNode {
const { nodes, folded, rootId, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange } = props
const { nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange } = props
const containerRef = useRef<HTMLDivElement>(null)
const [tf, setTf] = useState({ x: 0, y: 0, k: 1 })
const [dragging, setDragging] = useState(false)
@@ -335,7 +335,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
if (node.kind === 'workflow') {
return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed)
}
return renderAgentNode(node, style, onActivate, onNodeInfo, clickAllowed)
return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed)
})}
</div>
<div className={css.controls} data-graph-controls>
@@ -381,8 +381,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
function renderAgentNode(
node: TasksAgentNode,
style: { left: number; top: number; width: number; minHeight: number },
onActivate: (node: TasksAgentNode) => void,
onNodeInfo: (node: TasksAgentNode, anchor: HTMLElement) => void,
onOpenTask: (taskId: string, anchor: HTMLElement) => void,
clickAllowed: () => boolean,
): ReactNode {
return (
@@ -402,9 +402,9 @@ function renderAgentNode(
node.state === 'error' && css.nodeError,
)}
style={style}
onClick={() => {
onClick={(event) => {
if (!clickAllowed()) return
onActivate(node)
onNodeInfo(node, event.currentTarget)
}}
>
<span className={css.nodeHeader}>
@@ -414,19 +414,7 @@ function renderAgentNode(
</span>
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<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)
}}
/>
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
</div>
)
}
+11 -3
View File
@@ -46,8 +46,10 @@ function PopRow(props: { label: string; mono?: boolean; children: ReactNode }):
export function AgentNodePopover(props: {
node: TasksAgentNode
onJump(node: TasksAgentNode): void
/** Open the shared task window for one of the node's tasks. */
onOpenTask(taskId: string, anchor: HTMLElement): void
}): ReactNode {
const { node, onJump } = props
const { node, onJump, onOpenTask } = 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}`}`
@@ -87,13 +89,19 @@ export function AgentNodePopover(props: {
<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}>
<button
key={task.id}
type="button"
className={css.popListRow}
title={task.subject}
onClick={(event) => { onOpenTask(task.id, event.currentTarget) }}
>
<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>
</button>
))}
</div>
</div>
+9 -18
View File
@@ -7,7 +7,7 @@
*/
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { Button, IconChevronUpOutline14, IconEllipsisOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
@@ -20,9 +20,10 @@ import css from './tasks-graph.module.css'
export interface TasksTreeProps {
nodes: readonly TasksNode[]
folded: boolean
onActivate(node: TasksAgentNode): void
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void
/** Open the shared task window for one task id. */
onOpenTask(taskId: string, anchor: HTMLElement): void
onToggleFold(): void
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
@@ -32,7 +33,7 @@ export interface TasksTreeProps {
export function TasksTree(props: TasksTreeProps): ReactNode {
const {
nodes, folded, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange, loading,
nodes, folded, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange, loading,
} = props
const bodyRef = useRef<HTMLDivElement>(null)
@@ -147,8 +148,10 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
node.current && css.treeRowActive,
(node.state === 'done' || node.state === 'error') && !node.current && css.treeRowSettled,
)}
onClick={() => { onActivate(node) }}
onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
onClick={(event) => { onNodeInfo(node, event.currentTarget) }}
onKeyDown={(event) => {
activateOnKey(event, () => { onNodeInfo(node, event.currentTarget as HTMLElement) })
}}
>
<StateDot state={nodeDotState(node.state)} size={6} />
<span className={css.treeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
@@ -156,20 +159,8 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
<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} />
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
</span>
<Button
variant="ghost"
size="sm"
className={css.treeInfo}
icon={<IconEllipsisOutline16 size={12} />}
aria-label={t('tasksNodeDetail')}
title={t('tasksNodeDetail')}
onClick={(event) => {
event.stopPropagation()
onNodeInfo(node, event.currentTarget)
}}
/>
</div>
)
if (children.length === 0) return <div key={node.id}>{row}</div>
+17 -265
View File
@@ -14,13 +14,10 @@
*/
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,
Button, IconChecklistOutline14, IconChevronUpOutline14, IconPlusOutline16, Pill, StateDot, Tag,
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'
import css from './tasks-graph.module.css'
@@ -41,177 +38,27 @@ function taskTone(task: SidebarTeamTaskView): TagTone {
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[]
tasks: readonly SidebarTeamTaskView[]
/** Re-pull `teams.view` now (after every mutation / conflict). */
onChanged(): void
/** Open the shared task window (undefined = create) anchored at the click. */
onOpenTask(task: SidebarTeamTaskView | undefined, anchor: HTMLElement): void
/** The strip's own collapse state (the reader's choice, remembered while mounted). */
collapsed: boolean
onToggleCollapsed(): void
}
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 [draft, setDraft] = useState<TaskDraft | undefined>(undefined)
const [menuTaskId, setMenuTaskId] = useState<string | undefined>(undefined)
const [armedDeleteId, setArmedDeleteId] = useState<string | undefined>(undefined)
const { members, tasks, onOpenTask, collapsed, onToggleCollapsed } = props
const [ownerFilter, setOwnerFilter] = useState<string | undefined>(undefined)
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> => {
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)
}
}
/** 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')}>
<button
@@ -266,136 +113,41 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
<div className={css.teamTasks}>
{shown.length === 0 && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>}
{shown.map(task => (
<div key={task.id} className={css.teamTask}>
<Button
key={task.id}
variant="ghost"
size="sm"
className={css.teamTask}
aria-label={`${t('teamTaskDetail')} ${task.subject}`}
title={t('teamTaskDetail')}
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
>
<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>
<span className={css.teamTaskSubject}>{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>
</Button>
))}
</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: '' }) }}
onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
>
{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>
)
}
+3
View File
@@ -444,6 +444,9 @@ export const ar: Record<string, string> = {
teamTaskError: 'فشلت العملية: {message}',
teamTaskDeleted: 'محذوفة',
teamTaskActions: 'إجراءات المهمة',
teamTaskDetail: 'تفاصيل المهمة',
teamTaskNoDescription: 'لا يوجد وصف بعد — انقر على «تحرير» لإضافة واحد',
teamTaskBlockedBy: 'محجوبة بسبب',
teamTaskSubjectPlaceholder: 'سطر واحد يصف العمل',
teamTaskDescriptionPlaceholder: 'تفاصيل إضافية (اختياري)',
teamFilterAll: 'الكل',
+3
View File
@@ -429,6 +429,9 @@ export const de: Record<string, string> = {
teamTaskError: 'Vorgang fehlgeschlagen: {message}',
teamTaskDeleted: 'Gelöscht',
teamTaskActions: 'Aufgabenaktionen',
teamTaskDetail: 'Aufgabendetails',
teamTaskNoDescription: 'Noch keine Beschreibung – über „Bearbeiten“ ergänzen',
teamTaskBlockedBy: 'Blockiert durch',
teamTaskSubjectPlaceholder: 'Eine Zeile, die die Arbeit beschreibt',
teamTaskDescriptionPlaceholder: 'Zusätzliche Details (optional)',
teamFilterAll: 'Alle',
+3
View File
@@ -436,6 +436,9 @@ export const fr: Record<string, string> = {
teamTaskError: 'Échec de l’opération : {message}',
teamTaskDeleted: 'Supprimée',
teamTaskActions: 'Actions sur la tâche',
teamTaskDetail: 'Détails de la tâche',
teamTaskNoDescription: 'Aucune description pour le moment — cliquez sur « Modifier » pour en ajouter une',
teamTaskBlockedBy: 'Bloquée par',
teamTaskSubjectPlaceholder: 'Décrire le travail en une ligne',
teamTaskDescriptionPlaceholder: 'Précisions (facultatif)',
teamFilterAll: 'Tout',
+3
View File
@@ -443,6 +443,9 @@ export const hi: Record<string, string> = {
teamTaskError: 'ऑपरेशन विफल: {message}',
teamTaskDeleted: 'हटाया गया',
teamTaskActions: 'कार्य क्रियाएँ',
teamTaskDetail: 'कार्य विवरण',
teamTaskNoDescription: 'अभी कोई विवरण नहीं — जोड़ने के लिए “संपादित करें” पर क्लिक करें',
teamTaskBlockedBy: 'इनसे अवरुद्ध',
teamTaskSubjectPlaceholder: 'काम को एक पंक्ति में बताएँ',
teamTaskDescriptionPlaceholder: 'अतिरिक्त जानकारी (वैकल्पिक)',
teamFilterAll: 'सभी',
+3
View File
@@ -441,6 +441,9 @@ export const id: Record<string, string> = {
teamTaskError: 'Operasi gagal: {message}',
teamTaskDeleted: 'Terhapus',
teamTaskActions: 'Aksi tugas',
teamTaskDetail: 'Detail tugas',
teamTaskNoDescription: 'Belum ada deskripsi — klik “Edit” untuk menambahkan',
teamTaskBlockedBy: 'Diblokir oleh',
teamTaskSubjectPlaceholder: 'Jelaskan pekerjaan dalam satu baris',
teamTaskDescriptionPlaceholder: 'Keterangan tambahan (opsional)',
teamFilterAll: 'Semua',
+3
View File
@@ -434,6 +434,9 @@ export const it: Record<string, string> = {
teamTaskError: 'Operazione non riuscita: {message}',
teamTaskDeleted: 'Eliminata',
teamTaskActions: 'Azioni attività',
teamTaskDetail: 'Dettagli attività',
teamTaskNoDescription: 'Nessuna descrizione — fai clic su «Modifica» per aggiungerne una',
teamTaskBlockedBy: 'Bloccata da',
teamTaskSubjectPlaceholder: 'Descrivi il lavoro in una riga',
teamTaskDescriptionPlaceholder: 'Dettagli aggiuntivi (opzionale)',
teamFilterAll: 'Tutti',
+3
View File
@@ -443,6 +443,9 @@ export const ja: Record<string, string> = {
teamTaskError: '操作に失敗: {message}',
teamTaskDeleted: '削除済み',
teamTaskActions: 'タスク操作',
teamTaskDetail: 'タスク詳細',
teamTaskNoDescription: '説明はまだありません(「編集」で追加)',
teamTaskBlockedBy: 'ブロック元',
teamTaskSubjectPlaceholder: 'やることを一行で',
teamTaskDescriptionPlaceholder: '補足(任意)',
teamFilterAll: 'すべて',
+3
View File
@@ -435,6 +435,9 @@ export const ko: Record<string, string> = {
teamTaskError: '작업 실패: {message}',
teamTaskDeleted: '삭제됨',
teamTaskActions: '작업 동작',
teamTaskDetail: '작업 상세',
teamTaskNoDescription: '설명이 아직 없음 — “편집”을 클릭해 추가',
teamTaskBlockedBy: '차단 원인',
teamTaskSubjectPlaceholder: '할 일을 한 줄로 설명',
teamTaskDescriptionPlaceholder: '추가 설명 (선택)',
teamFilterAll: '모두',
+3
View File
@@ -441,6 +441,9 @@ export const nl: Record<string, string> = {
teamTaskError: 'Bewerking mislukt: {message}',
teamTaskDeleted: 'Verwijderd',
teamTaskActions: 'Taakacties',
teamTaskDetail: 'Taakdetails',
teamTaskNoDescription: 'Nog geen beschrijving — klik op “Bewerken” om er een toe te voegen',
teamTaskBlockedBy: 'Geblokkeerd door',
teamTaskSubjectPlaceholder: 'Beschrijf het werk in één regel',
teamTaskDescriptionPlaceholder: 'Aanvullende details (optioneel)',
teamFilterAll: 'Alles',
+3
View File
@@ -445,6 +445,9 @@ export const pl: Record<string, string> = {
teamTaskError: 'Operacja nie powiodła się: {message}',
teamTaskDeleted: 'Usunięte',
teamTaskActions: 'Akcje zadania',
teamTaskDetail: 'Szczegóły zadania',
teamTaskNoDescription: 'Brak opisu — kliknij „Edytuj”, aby dodać',
teamTaskBlockedBy: 'Zablokowane przez',
teamTaskSubjectPlaceholder: 'Opisz pracę w jednym zdaniu',
teamTaskDescriptionPlaceholder: 'Dodatkowe informacje (opcjonalnie)',
teamFilterAll: 'Wszystko',
+3
View File
@@ -426,6 +426,9 @@ export const pt: Record<string, string> = {
teamTaskError: 'Operação falhou: {message}',
teamTaskDeleted: 'Excluída',
teamTaskActions: 'Ações da tarefa',
teamTaskDetail: 'Detalhes da tarefa',
teamTaskNoDescription: 'Ainda sem descrição — clique em “Editar” para adicionar',
teamTaskBlockedBy: 'Bloqueada por',
teamTaskSubjectPlaceholder: 'Descreva o trabalho em uma linha',
teamTaskDescriptionPlaceholder: 'Detalhes adicionais (opcional)',
teamFilterAll: 'Tudo',
+3
View File
@@ -441,6 +441,9 @@ export const ru: Record<string, string> = {
teamTaskError: 'Не удалось выполнить: {message}',
teamTaskDeleted: 'Удалена',
teamTaskActions: 'Действия с задачей',
teamTaskDetail: 'Детали задачи',
teamTaskNoDescription: 'Описание пока отсутствует — нажмите «Изменить», чтобы добавить',
teamTaskBlockedBy: 'Заблокирована задачей',
teamTaskSubjectPlaceholder: 'Опишите работу одной строкой',
teamTaskDescriptionPlaceholder: 'Дополнительные сведения (необязательно)',
teamFilterAll: 'Все',
+3
View File
@@ -426,6 +426,9 @@ export const sv: Record<string, string> = {
teamTaskError: 'Åtgärden misslyckades: {message}',
teamTaskDeleted: 'Borttagen',
teamTaskActions: 'Uppgiftsåtgärder',
teamTaskDetail: 'Uppgiftsdetaljer',
teamTaskNoDescription: 'Ingen beskrivning än — klicka på “Redigera” för att lägga till en',
teamTaskBlockedBy: 'Blockerad av',
teamTaskSubjectPlaceholder: 'Beskriv arbetet på en rad',
teamTaskDescriptionPlaceholder: 'Ytterligare information (valfritt)',
teamFilterAll: 'Alla',
+3
View File
@@ -443,6 +443,9 @@ export const th: Record<string, string> = {
teamTaskError: 'ดำเนินการไม่สำเร็จ: {message}',
teamTaskDeleted: 'ลบแล้ว',
teamTaskActions: 'การดำเนินการของงาน',
teamTaskDetail: 'รายละเอียดงาน',
teamTaskNoDescription: 'ยังไม่มีคำอธิบาย — คลิก “แก้ไข” เพื่อเพิ่ม',
teamTaskBlockedBy: 'ถูกบล็อกโดย',
teamTaskSubjectPlaceholder: 'อธิบายงานที่ต้องทำในบรรทัดเดียว',
teamTaskDescriptionPlaceholder: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
teamFilterAll: 'ทั้งหมด',
+3
View File
@@ -443,6 +443,9 @@ export const tr: Record<string, string> = {
teamTaskError: 'İşlem başarısız: {message}',
teamTaskDeleted: 'Silindi',
teamTaskActions: 'Görev eylemleri',
teamTaskDetail: 'Görev ayrıntısı',
teamTaskNoDescription: 'Henüz açıklama yok — eklemek için Düzenle adımına tıklayın',
teamTaskBlockedBy: 'Engellendiği görevler',
teamTaskSubjectPlaceholder: 'İşi tek satırda açıkla',
teamTaskDescriptionPlaceholder: 'Ek ayrıntılar (isteğe bağlı)',
teamFilterAll: 'Tümü',
+3
View File
@@ -443,6 +443,9 @@ export const vi: Record<string, string> = {
teamTaskError: 'Thao tác thất bại: {message}',
teamTaskDeleted: 'Đã xóa',
teamTaskActions: 'Thao tác với tác vụ',
teamTaskDetail: 'Chi tiết tác vụ',
teamTaskNoDescription: 'Chưa có mô tả — bấm “Sửa” để thêm',
teamTaskBlockedBy: 'Bị chặn bởi',
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ả',
+3
View File
@@ -458,6 +458,9 @@ export const zhHK: Record<string, string> = {
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
teamTaskDetail: '任務詳情',
teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
teamTaskBlockedBy: '受阻於',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
+3
View File
@@ -458,6 +458,9 @@ export const zhMO: Record<string, string> = {
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
teamTaskDetail: '任務詳情',
teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
teamTaskBlockedBy: '受阻於',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
+3
View File
@@ -458,6 +458,9 @@ export const zhTW: Record<string, string> = {
teamTaskError: '操作失敗:{message}',
teamTaskDeleted: '已刪除',
teamTaskActions: '任務操作',
teamTaskDetail: '任務詳情',
teamTaskNoDescription: '暫無描述,點擊「編輯」補充',
teamTaskBlockedBy: '受阻於',
teamTaskSubjectPlaceholder: '一句話說明要做什麼',
teamTaskDescriptionPlaceholder: '補充說明(可選)',
teamFilterAll: '全部',
+6
View File
@@ -455,6 +455,9 @@ export const zh = {
teamTaskError: '操作失败:{message}',
teamTaskDeleted: '已删除',
teamTaskActions: '任务操作',
teamTaskDetail: '任务详情',
teamTaskNoDescription: '暂无描述,点击「编辑」补充',
teamTaskBlockedBy: '被阻塞',
teamTaskSubjectPlaceholder: '一句话说明要做什么',
teamTaskDescriptionPlaceholder: '补充说明(可选)',
teamFilterAll: '全部',
@@ -959,6 +962,9 @@ export const en: Record<keyof typeof zh, string> = {
teamTaskError: 'Operation failed: {message}',
teamTaskDeleted: 'Deleted',
teamTaskActions: 'Task actions',
teamTaskDetail: 'Task detail',
teamTaskNoDescription: 'No description yet — click Edit to add one',
teamTaskBlockedBy: 'Blocked by',
teamTaskSubjectPlaceholder: 'One line describing the work',
teamTaskDescriptionPlaceholder: 'Extra context (optional)',
teamFilterAll: 'All',
+49
View File
@@ -223,6 +223,7 @@
.taskLine {
display: flex;
cursor: pointer;
align-items: center;
gap: 4px;
margin-top: 3px;
@@ -240,6 +241,16 @@
color: var(--dsw-alias-state-business-primary);
}
.taskLine:hover .taskSubject {
color: var(--dsw-alias-label-primary);
}
.taskLine:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
border-radius: 2px;
}
.taskSubject {
flex: 1;
min-width: 0;
@@ -699,6 +710,38 @@
color: var(--dsw-alias-state-warn-primary);
}
/* ── Task window: markdown view + multi-line edit ──────────────────────── */
.taskMarkdown {
max-height: 260px;
overflow-y: auto;
padding: 2px 0;
font-size: 12px;
line-height: 1.55;
color: var(--dsw-alias-label-primary);
scrollbar-width: thin;
}
.taskTextarea {
width: 100%;
min-height: 120px;
padding: 6px 8px;
border: 1px solid var(--dsw-alias-border-l4);
border-radius: 3px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 11.5px;
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
}
.taskTextarea:focus {
outline: none;
border-color: var(--dsw-alias-state-business-primary);
}
/* ── Task create/edit dialog ───────────────────────────────────────────── */
.taskForm {
@@ -836,8 +879,14 @@
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 5px;
border-radius: 3px;
text-align: left;
}
.teamTask > * {
min-width: 0;
}
.teamTask:hover {
+16 -1
View File
@@ -143,14 +143,29 @@ export function primaryTask(tasks: readonly TasksNodeTask[]): TasksNodeTask | un
* 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 {
export function TaskLine(props: {
tasks: readonly TasksNodeTask[] | undefined
onOpenTask(taskId: string, anchor: HTMLElement): void
}): ReactNode {
const tasks = props.tasks ?? []
const primary = primaryTask(tasks)
if (primary === undefined) return null
return (
<span
role="button"
tabIndex={0}
className={css.taskLine}
title={tasks.map(task => `${task.subject}(${t(taskStatusKey(task.status))})`).join('\n')}
onClick={(event) => {
event.stopPropagation()
props.onOpenTask(primary.id, event.currentTarget)
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
event.stopPropagation()
props.onOpenTask(primary.id, event.currentTarget)
}}
>
<span className={css.taskGlyph} aria-hidden="true"><IconChecklistOutline14 size={9} /></span>
<span className={css.taskSubject}>{primary.subject}</span>
+116 -17
View File
@@ -161,7 +161,7 @@ describe('Tasks page interactions', () => {
unmount()
})
it('jumps to the transcript on node click and opens the ⓘ popover', async () => {
it('opens the node detail window on card click; the jump button goes to the transcript', async () => {
const opened: unknown[] = []
const store = makeStore(snapshotWithChildren(1))
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
@@ -171,8 +171,16 @@ describe('Tasks page interactions', () => {
// Folded by default: unfold so the settled child is visible.
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
await act(async () => { foldToggle.click() })
const node = container.querySelector('[aria-label*="子代理 0"]') as HTMLElement
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
await act(async () => { node.click() })
// The card no longer jumps directly: it IS the detail affordance.
expect(opened).toEqual([])
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
expect(dialog.textContent).toContain('节点详情')
const jump = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('查看转录'))
expect(jump).toBeDefined()
await act(async () => { jump?.click() })
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
unmount()
})
@@ -192,14 +200,16 @@ describe('Tasks page interactions', () => {
unmount()
})
it('opens the node detail popover from the ⓘ button', async () => {
it('ships no per-card detail button any more (the card itself is the affordance)', async () => {
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
// 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() })
const root = container.querySelector('[data-graph-node="root"]') as HTMLElement
expect(root).not.toBeNull()
// A card is a single click target: no nested control inside it.
expect(root.querySelectorAll('button')).toHaveLength(0)
await act(async () => { root.click() })
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
expect(document.body.textContent).toContain('查看转录')
unmount()
@@ -226,7 +236,9 @@ describe('Tasks page graph interactions and team board', () => {
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
expect(node).not.toBeNull()
await act(async () => { node.click() })
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
// The click is not stolen: the card's own action (its detail window) ran.
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
expect(opened).toEqual([])
unmount()
})
@@ -317,23 +329,29 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
unmount()
})
it('ships no native select/input: the board edits through host primitives', async () => {
it('opens the shared task window from a board row (markdown first, no native select)', 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('删除')
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
expect(row).not.toBeNull()
await act(async () => { row.click() })
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
// View mode first: the description renders as markdown, edited only on demand.
expect(dialog.textContent).toContain('细节')
expect(dialog.querySelector('textarea')).toBeNull()
const edit = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('编辑'))
expect(edit).toBeDefined()
await act(async () => { edit?.click() })
// Editing turns the SAME window into the multi-line editor.
const textarea = dialog.querySelector('textarea')
expect(textarea).not.toBeNull()
expect(textarea?.getAttribute('aria-label')).toBe('描述')
unmount()
})
@@ -387,3 +405,84 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
unmount()
})
})
describe('Tasks page: the shared task window', () => {
it('opens from an agent node task line and saves a multi-line edit', async () => {
teamPayload = {
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: 4, subject: '收窄卡片', description: '第一行\n\n- 第二行', status: 'in_progress',
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
}],
},
}
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
const taskLine = node.querySelector('[role="button"]') as HTMLElement
expect(taskLine).not.toBeNull()
await act(async () => { taskLine.click() })
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
expect(dialog.textContent).toContain('收窄卡片')
// Markdown view renders the multi-line body before any editing.
expect(dialog.querySelector('textarea')).toBeNull()
const edit = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('编辑'))
await act(async () => { edit?.click() })
const area = dialog.querySelector('textarea') as HTMLTextAreaElement
expect(area.value).toBe('第一行\n\n- 第二行')
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
setter?.call(area, '改过的描述')
area.dispatchEvent(new Event('input', { bubbles: true }))
})
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('保存'))
await act(async () => { save?.click() })
await act(async () => { await Promise.resolve() })
const update = teamMutations.find(entry => entry.method === 'teams.taskUpdate')
expect(update?.body.action).toBe('edit')
expect(update?.body.expectedRevision).toBe(4)
expect(update?.body.description).toBe('改过的描述')
unmount()
})
it('reassigns the owner straight from the window (CAS on the current revision)', async () => {
teamPayload = {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', diagnostics: [] },
{ id: 'child-1', name: 'reviewer', role: 'teammate', status: 'idle', diagnostics: [] },
],
tasks: [{
id: 't1', revision: 7, subject: '收窄卡片', description: '', status: 'pending',
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
}],
},
}
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
await act(async () => { row.click() })
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
const reviewer = [...dialog.querySelectorAll('button')].find(button => button.textContent === 'reviewer')
expect(reviewer).toBeDefined()
await act(async () => { reviewer?.click() })
await act(async () => { await Promise.resolve() })
const update = teamMutations.find(entry => entry.method === 'teams.taskUpdate')
expect(update?.body).toMatchObject({ action: 'reassign', owner: 'reviewer', expectedRevision: 7 })
unmount()
})
})