feat(tasks): 适配 DSH 0.1.7 的 Agent Teams(读 projection + 写路由对接真实服务)

团队功能在 0.1.7 上整条失效:team-routes.ts 当初镜像的是 0.1.6 的 Remote 词汇
(remoteView / remoteCreateTask / remoteUpdateTask),而 0.1.7 把这三个方法删掉了,
读路径改到 Lead Session 的 `agentTeam` Session projection。真机 3080 复现:
teams.view 对带团队的会话返回 400 "membership.svc.remoteView is not a function",
于是 TeamBoard 从不渲染、节点富化与任务窗连带失效。

- 读:删掉 teams.view 与 5s 轮询,改读 SessionListState.projectionsBySession[root]
  .values.agentTeam(与 subagentCatalog 同源,推送式);新增 client/team-projection.ts
  做纯适配器,成员运行状态由 subagents.live 的 running 叠加、Lead 回退 session list
  (官方 README 的 "overlays activity from Session status")。projection 缺席 =
  这里没有团队,与旧的 {available:false}/{team:null} 合并成同一静默降级。
- 写:taskCreate/taskUpdate 改调 0.1.7 的 createTask/updateTask(返回已提交的 view,
  拒绝改为抛 TeamError),TEAM_TASK_STALE_REVISION → 409 team-conflict,其余 → 400
  team-error(wire 新增 team-conflict)。api.ts 的 writeTask 把失败信封还原成
  {ok:false,code,message} —— 原先窗口永远走不到该分支(readEnvelope 是抛)。
- 任务窗:外壳换成上一轮的 FloatingWindow(可拖拽/可拉伸/只在关闭或 Esc 时消失),
  动作行下沉到 shell 的 footer,长描述不再把动作推出视野。
- 真机抓到的两个单测抓不到的问题:
  1. 队列中任务的「完成」被宿主状态机拒绝(pending → claim → in_progress →
     complete → completed → reopen)→ 动作行跟随状态,pending 出「认领」;
  2. 认领后 Tag 变「阻塞」—— ready 的语义只是「pending 且无未完成 blocker」,
     对所有非 pending 任务都是 false → 抽出唯一规则 taskBlocked/taskStatusLabel/
     taskTone/taskDotState,四处展示点全部改走它。
- i18n:新增 teamTaskClaim / taskWindowCreate / taskWindowDetail /
  tasksStateProvisioning 词条 ×20 词典。

验证:typecheck / lint 通过;pnpm test 1276 passed / 9 skipped;
pnpm test:mount 7/7(真实 DSH 0.1.7-rc.2 挂载 + tab 全扫无崩溃)。
真机 3080(0.1.7-rc.1 + Agent Teams 实验层):看板从 projection 渲染、
队列任务「认领」→「完成」全链路写通(负责人 lead / 进行中 / 已完成)、
外部点击不关窗、Escape 关窗,截图见 docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md。

(cherry picked from commit dbc524e0b350c5d3564843f4776cd15d797506d8)
This commit is contained in:
Menghuan1918
2026-09-27 22:19:05 +08:00
parent 421fd3fde7
commit d8bc6566df
40 changed files with 1352 additions and 576 deletions
+28 -48
View File
@@ -9,13 +9,14 @@
* - WORKFLOW RUNS: the host folds `tool-workflow/*` session events of the
* whole tree (workflows.list); a run hangs under its origin agent with its
* member agents re-parented below it and phase frames behind them;
* - AGENT TEAMS (experimental host layer): when the root leads a team, the
* roster enriches matching nodes and a header chip opens the shared task
* board popover (CAS operations); without the layer the UI hides itself
* entirely;
* - AGENT TEAMS (experimental host layer): when the root leads a team, its
* `agentTeam` Session projection feeds the always-visible team strip and
* enriches the matching nodes; the projection is absent without the layer,
* so the whole surface hides itself with no banner and no polling;
* - BACKGROUND JOBS: a bottom drawer replaces the old in-page section and
* auto-collapses once the tree has many agents; job output opens as an
* anchored popover (event replay — never the model's cursor).
* auto-collapses once the tree has many agents; job output opens as a
* persistent floating window (the host's own client jobs service — never
* the model's cursor).
*
* Node click jumps straight into the transcript (root → main session); the
* ⓘ button opens the detail popover. Completed leaf agents fold into one
@@ -46,9 +47,10 @@ import {
rootAncestor,
} from './subagent-detect.ts'
import { subagentCatalogs } from './subagent-catalog.ts'
import { teamMembersOf, teamProjectionOf } from './team-projection.ts'
import { treeSessionIds } from './subagent-lineage.ts'
import { orderJobs, type TreeJob } from './subagent-jobs.ts'
import { api, type TeamsViewResult } from './api.ts'
import { api } from './api.ts'
import { usePolling } from './use-polling.ts'
import { useNarrowViewport } from './breakpoints.ts'
import {
@@ -62,7 +64,7 @@ import { JobsDrawer, JobOutputWindow } 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 { TaskWindow } from './TaskWindow.tsx'
import type { SidebarStore } from './state.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import legacy from './SubagentView.module.css'
@@ -133,31 +135,6 @@ function useWorkflowRuns(rootId: string | undefined, active: boolean): readonly
return runs
}
/** The team view of the tree's root (poll-driven, structurally degraded). */
function useTeamView(
rootId: string | undefined,
active: boolean,
): { view: TeamsViewResult | undefined; refresh(): void } {
const [view, setView] = useState<TeamsViewResult | undefined>(undefined)
const [epoch, setEpoch] = useState(0)
useEffect(() => { setView(undefined) }, [rootId])
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
if (rootId === undefined) return
const result = await api.teamsView(rootId, signal)
if (!signal.aborted) setView(result)
// `epoch` is the manual-refresh trigger: bumping it changes the task
// identity, which restarts the poller with an immediate tick.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootId, epoch])
usePolling(rootId !== undefined && active, poll, {
intervalMs: TELEMETRY_POLL_MS,
mode: 'self-scheduling',
immediate: true,
})
const refresh = useCallback((): void => { setEpoch(current => current + 1) }, [])
return { view, refresh }
}
/**
* The tree's background jobs, read through the HOST's client jobs service
* (`ctx.jobs` — see jobs-client.ts). Watching is per tree session (the host
@@ -233,11 +210,16 @@ export function SubagentView(props: {
const rootSummary = rootId === undefined ? undefined : byId[rootId]
const live = useSubagentLive(rootId, active)
const runs = useWorkflowRuns(rootId, active)
const team = useTeamView(rootId, active)
const teamView = team.view
// The root-led team, straight off the SAME snapshot the catalogs come from
// (see team-projection.ts): undefined = no team here (no experimental layer,
// or the root is not a Lead), which is exactly the "hide the strip" state.
const team = useMemo(
() => teamProjectionOf(list.projectionsBySession, rootId),
[list.projectionsBySession, rootId],
)
const teamMembers = useMemo(
() => (teamView?.available === true ? teamView.team?.members ?? [] : []),
[teamView],
() => teamMembersOf(team, live, byId),
[team, live, byId],
)
// The default view mode comes from the side card prefs (settings select);
@@ -277,11 +259,11 @@ export function SubagentView(props: {
live,
runs,
teamMembers,
teamTasks: teamView?.available === true ? teamView.team?.tasks ?? [] : [],
teamTasks: team?.tasks ?? [],
folded,
foldedIds,
})),
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded, foldedIds],
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, team, folded, foldedIds],
)
/**
@@ -439,19 +421,18 @@ export function SubagentView(props: {
const popoverContent = ((): ReactNode => {
if (popover === null) return null
if (popover.kind === 'task') {
if (teamView?.available !== true || teamView.team === null || rootId === undefined) return null
if (team === undefined || rootId === undefined) return null
const task = popover.taskId === undefined
? undefined
: teamView.team.tasks.find(candidate => candidate.id === popover.taskId)
: 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
<TaskWindow
rootId={rootId}
task={task}
members={teamView.team.members}
onChanged={team.refresh}
members={teamMembers}
onClose={closePopover}
anchor={popover.anchor}
/>
@@ -529,15 +510,14 @@ export function SubagentView(props: {
disabled={rootId === undefined}
onClick={() => {
if (rootId !== undefined) refresh(rootId)
team.refresh()
}}
/>
</div>
{rootId !== undefined && teamView?.available === true && teamView.team !== null && (
{rootId !== undefined && team !== undefined && (
<TeamBoard
rootId={rootId}
members={teamView.team.members}
tasks={teamView.team.tasks}
members={teamMembers}
tasks={team.tasks}
onOpenTask={openTaskFromBoard}
collapsed={teamBoardCollapsed}
onToggleCollapsed={() => { setTeamBoardCollapsed(current => !current) }}
+185 -166
View File
@@ -4,41 +4,40 @@
* 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.
* Shape: the plugin's shared {@link FloatingWindow} — a draggable, resizable,
* body-portaled card with a title bar and a fixed action row, NOT a popover.
* A task edit is a sustained interaction (a long markdown description), so it
* gets the same lifecycle a watched job output gets: it survives clicking
* elsewhere and only the close button or Escape ends it. Details render as
* multi-line MARKDOWN; 编辑 switches the same window into multi-line editing,
* and the footer keeps its actions while the body scrolls.
* 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.
* 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, useId, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular,
Button, IconCheckOutlineRegular, IconEditOutlineRegular,
IconRefreshOutlineRegular, IconTrashOutlineRegular, Input, MarkdownText, Pill, StateDot, Tag,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import type { 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 { FloatingWindow } from './FloatingWindow.tsx'
import { taskBlocked, taskDotState, taskStatusLabel, taskTone } from './tasks-shared.tsx'
import type { TeamMemberRow } from './team-projection.ts'
import { t } 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 wire code of a stale-revision write (see src/team-routes.ts). */
const CONFLICT_CODE = 'team-conflict'
/** 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'
}
}
/** One mutation outcome: success, or a rejection the window must report. */
type MutationOutcome = { ok: true } | { ok: false; code?: string; message: string }
/**
* The plugin's own multi-line input: the host primitive set ships no
@@ -59,7 +58,7 @@ export function MultilineField(props: {
rows={props.rows ?? 7}
placeholder={props.placeholder}
aria-label={props.label}
data-popover-no-drag
data-window-no-drag
onChange={(event) => { props.onChange?.(event.target.value) }}
/>
)
@@ -67,7 +66,7 @@ export function MultilineField(props: {
/** The owner picker (Pills; CAS-immediate, so it is never a draft field). */
function OwnerPicker(props: {
members: readonly SidebarTeamMemberView[]
members: readonly TeamMemberRow[]
owner: string | undefined
disabled: boolean
onPick(owner: string): void
@@ -97,10 +96,14 @@ function OwnerPicker(props: {
)
}
/** The read-only body: markdown description, meta, owner picker. */
/**
* The read-only body: the owner line and the markdown description. The status
* and the owner NAME live in the window's own head row (see `TaskWindow`); this
* body carries the editable owner picker beside the prose it belongs to.
*/
function TaskViewBody(props: {
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
teammates: readonly TeamMemberRow[]
busy: boolean
onReassign(owner: string): void
}): ReactNode {
@@ -108,16 +111,6 @@ function TaskViewBody(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 className={css.jobPopValue}>{task.ownerName ?? t('teamTaskUnowned')}</span>
@@ -175,7 +168,7 @@ function TaskEditBody(props: {
}): ReactNode {
const subjectHintId = useId()
return (
<div className={css.taskForm} data-popover-no-drag>
<div className={css.taskForm} data-window-no-drag>
<label className={css.taskField}>
<span className={css.taskLabel}>{t('teamTaskSubject')}</span>
<Input
@@ -210,16 +203,20 @@ 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
members: readonly TeamMemberRow[]
/** Where the window first appears (the row that opened it). */
anchor?: HTMLElement | null
/** Close the window (the caller's popover state). */
onClose(): void
}
/** The task window content (the caller supplies the popover shell). */
/**
* The ONE task window: the plugin's floating shell wrapping the view/edit body
* and the action row, so reading, writing and the mutation state all stay in
* one component.
*/
export function TaskWindow(props: TaskWindowProps): ReactNode {
const { rootId, task, members, onChanged, onClose } = props
const { rootId, task, members, onClose } = props
const creating = task === undefined
const [editing, setEditing] = useState(creating)
const [subject, setSubject] = useState(task?.subject ?? '')
@@ -230,7 +227,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
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
// Re-seed the draft when the caller swaps the subject task: the projection
// 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
@@ -257,7 +254,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
/** Run one CAS mutation with the shared busy/conflict handling. */
const mutate = async (
action: () => Promise<MutationResult>,
action: () => Promise<MutationOutcome>,
options: { close?: boolean } = {},
): Promise<void> => {
if (busy) return
@@ -265,16 +262,21 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
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 (result.ok) {
if (options.close === true) onClose()
else setEditing(false)
// No refresh call: the board is read off the Lead Session's
// projection, so a committed revision lands in this window's props
// by itself.
} else {
setNote(result.code === CONFLICT_CODE
? t('teamTaskConflict')
: t('teamTaskError', { message: result.message }))
}
onChanged()
} catch (error) {
// `writeTask` turns every WIRE failure into a result above; this arm is
// for a programming error or a caller bug, which must not turn into an
// unhandled rejection inside a click handler.
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
} finally {
setBusy(false)
@@ -308,19 +310,140 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
}
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={<IconCloseOutlineRegular size={12} />}
aria-label={t('teamTaskCancel')}
title={t('teamTaskCancel')}
onClick={onClose}
/>
<FloatingWindow
title={creating ? t('taskWindowCreate') : t('taskWindowDetail')}
onClose={onClose}
initialSize={{ width: 460, height: 360 }}
minSize={{ width: 320, height: 240 }}
anchor={props.anchor}
footer={(
// The action row is the shell's FOOTER, so it never scrolls away from
// the body it acts on. One state owner: `busy` and `armedDelete` are
// the mutation state above, not a second copy.
<div className={css.jobPopActions}>
{editing
? (
<>
<Button
variant="primary"
size="sm"
icon={<IconCheckOutlineRegular 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)
setSubjectTouched(false)
setEditing(false)
}
}}
>
{t('teamTaskCancel')}
</Button>
</>
)
: task === undefined ? null : (
<>
<Button
variant="outline"
size="sm"
icon={<IconEditOutlineRegular size={12} />}
onClick={() => { setEditing(true) }}
>
{t('teamTaskEdit')}
</Button>
{/*
The board's state machine, in the window: pending → claim →
in_progress → complete → completed → reopen. 0.1.7's service
REJECTS `complete` on a queued task ("only an in-progress
task can complete"), so the primary action has to follow the
transition rather than promise one that cannot happen. One
primary per window: it is the affirmative change, so 编辑
stays a plain outline and 删除 keeps the danger ink.
*/}
{task.status === 'completed'
? (
<Button
variant="primary"
size="sm"
icon={<IconRefreshOutlineRegular size={12} />}
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))}
>
{t('teamTaskReopen')}
</Button>
)
: task.status === 'pending'
? (
<Button
variant="primary"
size="sm"
icon={<IconCheckOutlineRegular size={12} />}
// Claiming is refused while a blocker is open
// (`TEAM_TASK_BLOCKED`); the Tag beside the subject
// already says why, so the button just says "not yet".
disabled={busy || taskBlocked(task)}
title={taskBlocked(task) ? t('teamTaskBlocked') : undefined}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'claim',
}))}
>
{t('teamTaskClaim')}
</Button>
)
: (
<Button
variant="primary"
size="sm"
icon={<IconCheckOutlineRegular 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={clsx(css.taskDanger, armedDelete && css.taskDangerArmed)}
icon={<IconTrashOutlineRegular 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 className={css.taskHeadRow}>
{task !== undefined && <StateDot size={6} state={taskDotState(task)} />}
<span className={css.popTitle} title={task?.subject ?? subject}>
{task === undefined ? t('taskWindowCreate') : task.subject}
</span>
{task !== undefined && (
<Tag tone={taskTone(task)}>{taskStatusLabel(task)}</Tag>
)}
</div>
{editing || task === undefined
@@ -343,110 +466,6 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
)}
{note !== undefined && <div className={css.teamNote}>{note}</div>}
<div className={css.jobPopActions} data-popover-no-drag>
{editing
? (
<>
<Button
variant="primary"
size="sm"
icon={<IconCheckOutlineRegular 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)
setSubjectTouched(false)
setEditing(false)
}
}}
>
{t('teamTaskCancel')}
</Button>
</>
)
: task === undefined ? null : (
<>
<Button
variant="outline"
size="sm"
icon={<IconEditOutlineRegular size={12} />}
onClick={() => { setEditing(true) }}
>
{t('teamTaskEdit')}
</Button>
{/* One primary per card: the affirmative state change (完成 /
重新打开) is it, so 编辑 stays a plain outline and 删除 keeps
the danger ink beside it. */}
{task.status === 'completed'
? (
<Button
variant="primary"
size="sm"
icon={<IconRefreshOutlineRegular size={12} />}
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))}
>
{t('teamTaskReopen')}
</Button>
)
: (
<Button
variant="primary"
size="sm"
icon={<IconCheckOutlineRegular 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={clsx(css.taskDanger, armedDelete && css.taskDangerArmed)}
icon={<IconTrashOutlineRegular 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>
</FloatingWindow>
)
}
+3 -7
View File
@@ -30,7 +30,8 @@ import { Button, IconRightUpOutlineRegular, StateDot, Tag } from '@deepseek-ai/d
import type { SidebarSubagentAddress } from '../context-types.ts'
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
import {
AgentGlyph, WorkflowGlyph, flatten, modeLabel, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
AgentGlyph, WorkflowGlyph, flatten, modeLabel, nodeDotState, taskDotState,
taskStatusLabel, taskTone, workflowStatusKey,
} from './tasks-shared.tsx'
import { liveActivityLabel } from './process-labels.ts'
import { t, type CopyKey } from './locales.ts'
@@ -103,9 +104,6 @@ export function AgentNodePopover(props: {
{node.team.role === 'lead' ? 'lead' : node.team.name}
</PopRow>
)}
{node.team?.model !== undefined && (
<PopRow label={t('tasksNodeModel')} mono>{node.team.model}</PopRow>
)}
{(liveTool !== undefined || liveText !== undefined) && (
<PopRow label={t('tasksNodeActivity')} mono>{liveTool ?? liveText}</PopRow>
)}
@@ -129,9 +127,7 @@ export function AgentNodePopover(props: {
>
<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>
<Tag tone={taskTone(task)}>{taskStatusLabel(task)}</Tag>
</Button>
))}
</div>
+21 -33
View File
@@ -23,29 +23,27 @@ import {
Button, IconChecklistOutlineRegular, IconCheckOutlineRegular, IconChevronUpOutlineRegular, IconEditOutlineRegular,
IconEllipsisOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
IconUserOutlineRegular, Menu, Pill, StateDot, Tag,
type MenuEntry, type TagTone,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { t, type CopyKey } from './locales.ts'
import type { SidebarTeamTaskView } from '../context-types.ts'
import type { TeamMemberRow } from './team-projection.ts'
import { taskDotState, taskStatusLabel, taskTone } from './tasks-shared.tsx'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
/** The task status label key. */
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
/**
* The status dot of one roster row: the member's derived status (see
* ./team-projection.ts — durable phase first, live activity second).
*/
function memberDotState(status: TeamMemberRow['status']): 'ongoing' | 'idle' | 'error' {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
case 'deleted': return 'teamTaskDeleted'
case 'running': return 'ongoing'
case 'provisioning': return 'ongoing'
case 'failed': return 'error'
case 'idle': return 'idle'
}
}
/** 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 task's row menu: the shared task window's own affordance list hoisted
* onto the row — complete (or reopen, once completed), edit, reassign the
@@ -59,7 +57,7 @@ function taskTone(task: SidebarTeamTaskView): TagTone {
*/
function taskMenuItems(
task: SidebarTeamTaskView,
teammates: readonly SidebarTeamMemberView[],
teammates: readonly TeamMemberRow[],
): MenuEntry[] {
const completed = task.status === 'completed'
return [
@@ -96,7 +94,7 @@ function taskMenuItems(
*/
function TeamTaskRow(props: {
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
teammates: readonly TeamMemberRow[]
onOpenTask(task: SidebarTeamTaskView, anchor: HTMLElement): void
}): ReactNode {
const { task, teammates, onOpenTask } = props
@@ -121,17 +119,12 @@ function TeamTaskRow(props: {
title={t('teamTaskDetail')}
onClick={(event) => { onOpenTask(task, event.currentTarget) }}
>
<StateDot
size={6}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
<StateDot size={6} state={taskDotState(task)} />
<span className={css.teamTaskSubject} title={task.subject}>{task.subject}</span>
{task.ownerName !== undefined && (
<span className={css.teamTaskOwner} title={task.ownerName}>{task.ownerName}</span>
)}
<Tag tone={taskTone(task)}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</Tag>
<Tag tone={taskTone(task)}>{taskStatusLabel(task)}</Tag>
</Button>
<Menu
open={menuOpen}
@@ -163,7 +156,7 @@ function TeamTaskRow(props: {
export interface TeamBoardProps {
rootId: string
members: readonly SidebarTeamMemberView[]
members: readonly TeamMemberRow[]
tasks: readonly SidebarTeamTaskView[]
/** Open the shared task window (undefined = create) anchored at the click. */
onOpenTask(task: SidebarTeamTaskView | undefined, anchor: HTMLElement): void
@@ -184,7 +177,7 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
const teammates = useMemo(() => members.filter(member => member.role === 'teammate'), [members])
return (
<section className={css.teamBoard} aria-label={t('teamBoard')}>
<section className={css.teamBoard} aria-label={t('teamBoard')} data-team-board>
<button
type="button"
className={css.teamBoardBar}
@@ -222,12 +215,7 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
setOwnerFilter(current => (current === member.name ? undefined : member.name))
}}
>
<StateDot
size={6}
state={member.status === 'running' || member.status === 'provisioning'
? 'ongoing'
: member.status === 'failed' ? 'error' : 'idle'}
/>
<StateDot size={6} state={memberDotState(member.status)} />
<span className={css.teamMemberName}>{member.name}</span>
</Pill>
))}
+38 -19
View File
@@ -12,8 +12,6 @@ import type {
SidebarCreateTeamTaskRequest,
SidebarChildLiveView,
SidebarSessionEvent,
SidebarTeamMemberView,
SidebarTeamTaskMutationResult,
SidebarTeamTaskView,
SidebarUpdateTeamTaskRequest,
} from '../context-types.ts'
@@ -109,19 +107,24 @@ export type SubagentLiveResult = { live: Record<string, SidebarChildLiveView> }
/** The `workflows.list` response: the tree's folded workflow runs. */
export type WorkflowsListResult = { runs: WorkflowRunView[] }
/** The `teams.view` response (structural degradation contract). */
export type TeamsViewResult =
| { available: false }
| { available: true; team: { members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] } | null }
/** The `teams.taskCreate` request payload (minus the rootSessionId). */
export type TeamsTaskCreateRequest = SidebarCreateTeamTaskRequest
/** The `teams.taskUpdate` request payload (minus the rootSessionId). */
export type TeamsTaskUpdateRequest = SidebarUpdateTeamTaskRequest
/** The task-mutation result union (CAS conflict stays distinct). */
export type TeamsTaskMutationResult = SidebarTeamTaskMutationResult
/**
* One team-task write as CALLERS consume it. The board's READ path has no
* route at all (it rides the Lead Session's `agentTeam` projection, see
* team-projection.ts), and a rejected write is a RESULT rather than an
* exception: the route answers 409 `team-conflict` for a stale revision and
* 400 `team-error` otherwise, {@link writeTask} turns that failed envelope
* back into `{ok:false, code, message}`, and the task window routes on the
* code (a stale revision deserves its own wording, not "operation failed").
*/
export type TeamsTaskMutationResult =
| { ok: true; value: SidebarTeamTaskView }
| { ok: false; code?: string; message: string }
/**
* Parse one `/sidebar` JSON response envelope into its value. A non-ok
@@ -157,6 +160,29 @@ async function call<T>(method: string, payload: Record<string, unknown>, signal?
return readEnvelope<T>(response)
}
/**
* One team-task write with the rejection turned back into a RESULT. Every
* other `/sidebar/api` caller lets a failed envelope throw (that is what
* {@link readEnvelope} is for); here the route's error CODE is data the task
* window needs — `team-conflict` means "someone changed this task, refresh",
* which is a different sentence from every other failure.
* @param method - `teams.taskCreate` or `teams.taskUpdate`.
* @param payload - the request plus its `rootSessionId`.
*/
async function writeTask(
method: string,
payload: Record<string, unknown>,
): Promise<TeamsTaskMutationResult> {
try {
return { ok: true, value: await call<SidebarTeamTaskView>(method, payload) }
} catch (error) {
if (error instanceof SidebarApiError) {
return { ok: false, code: error.code, message: error.message }
}
throw error
}
}
/**
* Upload one file to the sidebar's raw upload route: the File goes straight
* into the POST body (no JSON/base64 re-encoding — the host streams it into
@@ -348,19 +374,12 @@ export const api = {
*/
workflowsList: (rootSessionId: string, signal?: AbortSignal) =>
call<WorkflowsListResult>('workflows.list', { rootSessionId }, signal),
/**
* The team led by the tree's root agent (experimental Agent Teams layer):
* `{available:false}` hides the whole block; `{team:null}` means the root
* leads no team. Poll-driven (the official panel is manual-refresh too).
*/
teamsView: (rootSessionId: string, signal?: AbortSignal) =>
call<TeamsViewResult>('teams.view', { rootSessionId }, signal),
/** Create one shared task on the root-led team. */
teamsTaskCreate: (rootSessionId: string, req: TeamsTaskCreateRequest) =>
call<TeamsTaskMutationResult>('teams.taskCreate', { rootSessionId, ...req }),
/** CAS-mutate one shared task; conflicts ride the returned union. */
writeTask('teams.taskCreate', { rootSessionId, ...req }),
/** CAS-mutate one shared task; a stale revision yields `team-conflict`. */
teamsTaskUpdate: (rootSessionId: string, req: TeamsTaskUpdateRequest) =>
call<TeamsTaskMutationResult>('teams.taskUpdate', { rootSessionId, ...req }),
writeTask('teams.taskUpdate', { rootSessionId, ...req }),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */
+4
View File
@@ -388,11 +388,14 @@ export const ar: Record<string, string> = {
tasksNodeActivity: 'آخر نشاط',
tasksStateRunning: 'قيد التشغيل',
tasksStateIdle: 'خامل',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'مكتمل',
tasksStateError: 'خطأ',
tasksKindSubagent: 'وكيل فرعي',
tasksKindTeammate: 'عضو فريق',
tasksKindWorkflow: 'سير عمل',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'لوحة مهام الفريق',
teamChip: 'فريق {members} أعضاء · {tasks} مهام',
teamTasksEmpty: 'لا مهام مشتركة بعد',
@@ -405,6 +408,7 @@ export const ar: Record<string, string> = {
teamTaskInProgress: 'قيد التنفيذ',
teamTaskCompleted: 'مكتملة',
teamTaskBlocked: 'محجوبة',
teamTaskClaim: 'Claim',
teamTaskComplete: 'إكمال',
teamTaskReopen: 'إعادة فتح',
teamTaskDelete: 'حذف',
+4
View File
@@ -373,11 +373,14 @@ export const de: Record<string, string> = {
tasksNodeActivity: 'Letzte Aktivität',
tasksStateRunning: 'Läuft',
tasksStateIdle: 'Bereit',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Abgeschlossen',
tasksStateError: 'Fehler',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teammitglied',
tasksKindWorkflow: 'Workflow',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Team-Aufgabenboard',
teamChip: 'Team {members} Mitglieder · {tasks} Aufgaben',
teamTasksEmpty: 'Noch keine geteilten Aufgaben',
@@ -390,6 +393,7 @@ export const de: Record<string, string> = {
teamTaskInProgress: 'In Arbeit',
teamTaskCompleted: 'Abgeschlossen',
teamTaskBlocked: 'Blockiert',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Abschließen',
teamTaskReopen: 'Wieder öffnen',
teamTaskDelete: 'Löschen',
+4
View File
@@ -380,11 +380,14 @@ export const fr: Record<string, string> = {
tasksNodeActivity: 'Dernière activité',
tasksStateRunning: 'En cours',
tasksStateIdle: 'Inactif',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Terminé',
tasksStateError: 'Erreur',
tasksKindSubagent: 'Sous-agent',
tasksKindTeammate: 'Membre',
tasksKindWorkflow: 'Workflow',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Tableau des tâches d’équipe',
teamChip: 'Équipe {members} membres · {tasks} tâches',
teamTasksEmpty: 'Aucune tâche partagée pour l’instant',
@@ -397,6 +400,7 @@ export const fr: Record<string, string> = {
teamTaskInProgress: 'En cours',
teamTaskCompleted: 'Terminée',
teamTaskBlocked: 'Bloquée',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Terminer',
teamTaskReopen: 'Rouvrir',
teamTaskDelete: 'Supprimer',
+4
View File
@@ -387,11 +387,14 @@ export const hi: Record<string, string> = {
tasksNodeActivity: 'नवीनतम गतिविधि',
tasksStateRunning: 'चल रहा है',
tasksStateIdle: 'निष्क्रिय',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'पूर्ण',
tasksStateError: 'त्रुटि',
tasksKindSubagent: 'उप-एजेंट',
tasksKindTeammate: 'टीम सदस्य',
tasksKindWorkflow: 'वर्कफ़्लो',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'टीम कार्य बोर्ड',
teamChip: 'टीम {members} सदस्य · {tasks} कार्य',
teamTasksEmpty: 'अभी कोई साझा कार्य नहीं',
@@ -404,6 +407,7 @@ export const hi: Record<string, string> = {
teamTaskInProgress: 'प्रगति पर',
teamTaskCompleted: 'पूर्ण',
teamTaskBlocked: 'अवरुद्ध',
teamTaskClaim: 'Claim',
teamTaskComplete: 'पूर्ण करें',
teamTaskReopen: 'पुनः खोलें',
teamTaskDelete: 'हटाएँ',
+4
View File
@@ -385,11 +385,14 @@ export const id: Record<string, string> = {
tasksNodeActivity: 'Aktivitas terbaru',
tasksStateRunning: 'Berjalan',
tasksStateIdle: 'Siaga',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Selesai',
tasksStateError: 'Error',
tasksKindSubagent: 'Subagen',
tasksKindTeammate: 'Anggota tim',
tasksKindWorkflow: 'Alur kerja',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Papan tugas tim',
teamChip: 'Tim {members} anggota · {tasks} tugas',
teamTasksEmpty: 'Belum ada tugas bersama',
@@ -402,6 +405,7 @@ export const id: Record<string, string> = {
teamTaskInProgress: 'Sedang berjalan',
teamTaskCompleted: 'Selesai',
teamTaskBlocked: 'Terblokir',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Selesaikan',
teamTaskReopen: 'Buka kembali',
teamTaskDelete: 'Hapus',
+4
View File
@@ -378,11 +378,14 @@ export const it: Record<string, string> = {
tasksNodeActivity: 'Ultima attività',
tasksStateRunning: 'In esecuzione',
tasksStateIdle: 'In attesa',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Completato',
tasksStateError: 'Errore',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Membro del team',
tasksKindWorkflow: 'Workflow',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Bacheca delle attività del team',
teamChip: 'Team {members} membri · {tasks} attività',
teamTasksEmpty: 'Ancora nessuna attività condivisa',
@@ -395,6 +398,7 @@ export const it: Record<string, string> = {
teamTaskInProgress: 'In corso',
teamTaskCompleted: 'Completata',
teamTaskBlocked: 'Bloccata',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Completa',
teamTaskReopen: 'Riapri',
teamTaskDelete: 'Elimina',
+4
View File
@@ -387,11 +387,14 @@ export const ja: Record<string, string> = {
tasksNodeActivity: '最新アクティビティ',
tasksStateRunning: '実行中',
tasksStateIdle: '待機',
tasksStateProvisioning: '準備中',
tasksStateDone: '完了',
tasksStateError: 'エラー',
tasksKindSubagent: 'サブエージェント',
tasksKindTeammate: 'チームメンバー',
tasksKindWorkflow: 'ワークフロー',
taskWindowCreate: 'タスクを作成',
taskWindowDetail: 'タスクの詳細',
teamBoard: 'チームタスクボード',
teamChip: 'チーム {members} 名 · {tasks} タスク',
teamTasksEmpty: '共有タスクはまだありません',
@@ -404,6 +407,7 @@ export const ja: Record<string, string> = {
teamTaskInProgress: '進行中',
teamTaskCompleted: '完了',
teamTaskBlocked: 'ブロック',
teamTaskClaim: '引き受ける',
teamTaskComplete: '完了',
teamTaskReopen: '再オープン',
teamTaskDelete: '削除',
+4
View File
@@ -379,11 +379,14 @@ export const ko: Record<string, string> = {
tasksNodeActivity: '최근 활동',
tasksStateRunning: '실행 중',
tasksStateIdle: '대기 중',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: '완료됨',
tasksStateError: '오류',
tasksKindSubagent: '하위 에이전트',
tasksKindTeammate: '팀 멤버',
tasksKindWorkflow: '워크플로',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: '팀 작업 보드',
teamChip: '팀 멤버 {members}명 · 작업 {tasks}개',
teamTasksEmpty: '공유 작업이 없습니다',
@@ -396,6 +399,7 @@ export const ko: Record<string, string> = {
teamTaskInProgress: '진행 중',
teamTaskCompleted: '완료됨',
teamTaskBlocked: '차단됨',
teamTaskClaim: 'Claim',
teamTaskComplete: '완료',
teamTaskReopen: '다시 열기',
teamTaskDelete: '삭제',
+4
View File
@@ -385,11 +385,14 @@ export const nl: Record<string, string> = {
tasksNodeActivity: 'Laatste activiteit',
tasksStateRunning: 'Bezig',
tasksStateIdle: 'Inactief',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Voltooid',
tasksStateError: 'Fout',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teamlid',
tasksKindWorkflow: 'Workflow',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Teamtakenbord',
teamChip: 'Team {members} leden · {tasks} taken',
teamTasksEmpty: 'Nog geen gedeelde taken',
@@ -402,6 +405,7 @@ export const nl: Record<string, string> = {
teamTaskInProgress: 'Bezig',
teamTaskCompleted: 'Voltooid',
teamTaskBlocked: 'Geblokkeerd',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Voltooien',
teamTaskReopen: 'Heropenen',
teamTaskDelete: 'Verwijderen',
+4
View File
@@ -389,11 +389,14 @@ export const pl: Record<string, string> = {
tasksNodeActivity: 'Ostatnia aktywność',
tasksStateRunning: 'Działa',
tasksStateIdle: 'Gotowe',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Ukończone',
tasksStateError: 'Błąd',
tasksKindSubagent: 'Podagent',
tasksKindTeammate: 'Członek zespołu',
tasksKindWorkflow: 'Przepływ pracy',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Tablica zadań zespołu',
teamChip: 'Zespół {members} członków · {tasks} zadań',
teamTasksEmpty: 'Brak wspólnych zadań',
@@ -406,6 +409,7 @@ export const pl: Record<string, string> = {
teamTaskInProgress: 'W toku',
teamTaskCompleted: 'Ukończone',
teamTaskBlocked: 'Zablokowane',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Ukończ',
teamTaskReopen: 'Otwórz ponownie',
teamTaskDelete: 'Usuń',
+4
View File
@@ -370,11 +370,14 @@ export const pt: Record<string, string> = {
tasksNodeActivity: 'Atividade recente',
tasksStateRunning: 'Em execução',
tasksStateIdle: 'Em espera',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Concluído',
tasksStateError: 'Erro',
tasksKindSubagent: 'Subagente',
tasksKindTeammate: 'Membro da equipe',
tasksKindWorkflow: 'Fluxo de trabalho',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Quadro de tarefas da equipe',
teamChip: 'Equipe {members} membros · {tasks} tarefas',
teamTasksEmpty: 'Ainda não há tarefas compartilhadas',
@@ -387,6 +390,7 @@ export const pt: Record<string, string> = {
teamTaskInProgress: 'Em andamento',
teamTaskCompleted: 'Concluída',
teamTaskBlocked: 'Bloqueada',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Concluir',
teamTaskReopen: 'Reabrir',
teamTaskDelete: 'Excluir',
+4
View File
@@ -385,11 +385,14 @@ export const ru: Record<string, string> = {
tasksNodeActivity: 'Последняя активность',
tasksStateRunning: 'Выполняется',
tasksStateIdle: 'Ожидает',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Завершено',
tasksStateError: 'Ошибка',
tasksKindSubagent: 'Субагент',
tasksKindTeammate: 'Участник команды',
tasksKindWorkflow: 'Рабочий процесс',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Доска задач команды',
teamChip: 'Команда: {members} участников · {tasks} задач',
teamTasksEmpty: 'Общих задач пока нет',
@@ -402,6 +405,7 @@ export const ru: Record<string, string> = {
teamTaskInProgress: 'В работе',
teamTaskCompleted: 'Завершена',
teamTaskBlocked: 'Заблокирована',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Завершить',
teamTaskReopen: 'Открыть снова',
teamTaskDelete: 'Удалить',
+4
View File
@@ -370,11 +370,14 @@ export const sv: Record<string, string> = {
tasksNodeActivity: 'Senaste aktivitet',
tasksStateRunning: 'Körs',
tasksStateIdle: 'Vilar',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Klar',
tasksStateError: 'Fel',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teammedlem',
tasksKindWorkflow: 'Arbetsflöde',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Teamuppgiftstavla',
teamChip: 'Team {members} medlemmar · {tasks} uppgifter',
teamTasksEmpty: 'Inga delade uppgifter än',
@@ -387,6 +390,7 @@ export const sv: Record<string, string> = {
teamTaskInProgress: 'Pågår',
teamTaskCompleted: 'Klar',
teamTaskBlocked: 'Blockerad',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Färdigställ',
teamTaskReopen: 'Öppna igen',
teamTaskDelete: 'Ta bort',
+4
View File
@@ -387,11 +387,14 @@ export const th: Record<string, string> = {
tasksNodeActivity: 'กิจกรรมล่าสุด',
tasksStateRunning: 'กำลังทำงาน',
tasksStateIdle: 'พร้อม',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'เสร็จแล้ว',
tasksStateError: 'ข้อผิดพลาด',
tasksKindSubagent: 'เอเจนต์ย่อย',
tasksKindTeammate: 'สมาชิกทีม',
tasksKindWorkflow: 'เวิร์กโฟลว์',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'บอร์ดงานของทีม',
teamChip: 'ทีม {members} สมาชิก · {tasks} งาน',
teamTasksEmpty: 'ยังไม่มีงานที่แชร์',
@@ -404,6 +407,7 @@ export const th: Record<string, string> = {
teamTaskInProgress: 'กำลังดำเนินการ',
teamTaskCompleted: 'เสร็จแล้ว',
teamTaskBlocked: 'ถูกบล็อก',
teamTaskClaim: 'Claim',
teamTaskComplete: 'ทำให้เสร็จ',
teamTaskReopen: 'เปิดใหม่',
teamTaskDelete: 'ลบ',
+4
View File
@@ -387,11 +387,14 @@ export const tr: Record<string, string> = {
tasksNodeActivity: 'Son etkinlik',
tasksStateRunning: 'Çalışıyor',
tasksStateIdle: 'Beklemede',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Tamamlandı',
tasksStateError: 'Hata',
tasksKindSubagent: 'Alt ajan',
tasksKindTeammate: 'Ekip üyesi',
tasksKindWorkflow: 'İş akışı',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Takım görev panosu',
teamChip: 'Takım {members} üye · {tasks} görev',
teamTasksEmpty: 'Henüz paylaşılan görev yok',
@@ -404,6 +407,7 @@ export const tr: Record<string, string> = {
teamTaskInProgress: 'Devam ediyor',
teamTaskCompleted: 'Tamamlandı',
teamTaskBlocked: 'Engelli',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Tamamla',
teamTaskReopen: 'Yeniden aç',
teamTaskDelete: 'Sil',
+4
View File
@@ -387,11 +387,14 @@ export const vi: Record<string, string> = {
tasksNodeActivity: 'Hoạt động gần đây',
tasksStateRunning: 'Đang chạy',
tasksStateIdle: 'Nhàn rỗi',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Hoàn thành',
tasksStateError: 'Lỗi',
tasksKindSubagent: 'Tác nhân con',
tasksKindTeammate: 'Thành viên nhóm',
tasksKindWorkflow: 'Quy trình',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Bảng tác vụ nhóm',
teamChip: 'Nhóm {members} thành viên · {tasks} tác vụ',
teamTasksEmpty: 'Chưa có tác vụ dùng chung',
@@ -404,6 +407,7 @@ export const vi: Record<string, string> = {
teamTaskInProgress: 'Đang tiến hành',
teamTaskCompleted: 'Hoàn thành',
teamTaskBlocked: 'Bị chặn',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Hoàn tất',
teamTaskReopen: 'Mở lại',
teamTaskDelete: 'Xóa',
+4
View File
@@ -402,11 +402,14 @@ export const zhHK: Record<string, string> = {
tasksNodeActivity: '最近活動',
tasksStateRunning: '執行中',
tasksStateIdle: '閒置',
tasksStateProvisioning: '部署中',
tasksStateDone: '已完成',
tasksStateError: '出錯',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成員',
tasksKindWorkflow: '工作流',
taskWindowCreate: '新增任務',
taskWindowDetail: '任務詳情',
teamBoard: '團隊任務板',
teamChip: '團隊 {members} 成員 · {tasks} 任務',
teamTasksEmpty: '暫無共用任務',
@@ -419,6 +422,7 @@ export const zhHK: Record<string, string> = {
teamTaskInProgress: '進行中',
teamTaskCompleted: '已完成',
teamTaskBlocked: '受阻',
teamTaskClaim: '认领',
teamTaskComplete: '完成',
teamTaskReopen: '重開',
teamTaskDelete: '刪除',
+4
View File
@@ -402,11 +402,14 @@ export const zhMO: Record<string, string> = {
tasksNodeActivity: '最近活動',
tasksStateRunning: '執行中',
tasksStateIdle: '閒置',
tasksStateProvisioning: '部署中',
tasksStateDone: '已完成',
tasksStateError: '出錯',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成員',
tasksKindWorkflow: '工作流',
taskWindowCreate: '新增任務',
taskWindowDetail: '任務詳情',
teamBoard: '團隊任務板',
teamChip: '團隊 {members} 成員 · {tasks} 任務',
teamTasksEmpty: '暫無共用任務',
@@ -419,6 +422,7 @@ export const zhMO: Record<string, string> = {
teamTaskInProgress: '進行中',
teamTaskCompleted: '已完成',
teamTaskBlocked: '受阻',
teamTaskClaim: '认领',
teamTaskComplete: '完成',
teamTaskReopen: '重開',
teamTaskDelete: '刪除',
+4
View File
@@ -402,11 +402,14 @@ export const zhTW: Record<string, string> = {
tasksNodeActivity: '最近活動',
tasksStateRunning: '執行中',
tasksStateIdle: '閒置',
tasksStateProvisioning: '部署中',
tasksStateDone: '已完成',
tasksStateError: '出錯',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成員',
tasksKindWorkflow: '工作流',
taskWindowCreate: '新增任務',
taskWindowDetail: '任務詳情',
teamBoard: '團隊任務板',
teamChip: '團隊 {members} 成員 · {tasks} 任務',
teamTasksEmpty: '暫無共用任務',
@@ -419,6 +422,7 @@ export const zhTW: Record<string, string> = {
teamTaskInProgress: '進行中',
teamTaskCompleted: '已完成',
teamTaskBlocked: '受阻',
teamTaskClaim: '认领',
teamTaskComplete: '完成',
teamTaskReopen: '重開',
teamTaskDelete: '刪除',
+8
View File
@@ -399,11 +399,14 @@ export const zh = {
tasksNodeActivity: '最近活动',
tasksStateRunning: '运行中',
tasksStateIdle: '待命',
tasksStateProvisioning: '部署中',
tasksStateDone: '已完成',
tasksStateError: '出错',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成员',
tasksKindWorkflow: '工作流',
taskWindowCreate: '新建任务',
taskWindowDetail: '任务详情',
teamBoard: '团队任务板',
teamChip: '团队 {members} 成员 · {tasks} 任务',
teamTasksEmpty: '暂无共享任务',
@@ -416,6 +419,7 @@ export const zh = {
teamTaskInProgress: '进行中',
teamTaskCompleted: '已完成',
teamTaskBlocked: '阻塞',
teamTaskClaim: '认领',
teamTaskComplete: '完成',
teamTaskReopen: '重开',
teamTaskDelete: '删除',
@@ -878,11 +882,14 @@ export const en: Record<keyof typeof zh, string> = {
tasksNodeActivity: 'Latest activity',
tasksStateRunning: 'Running',
tasksStateIdle: 'Idle',
tasksStateProvisioning: 'Provisioning',
tasksStateDone: 'Done',
tasksStateError: 'Error',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teammate',
tasksKindWorkflow: 'Workflow',
taskWindowCreate: 'New task',
taskWindowDetail: 'Task detail',
teamBoard: 'Team task board',
teamChip: 'Team {members} members · {tasks} tasks',
teamTasksEmpty: 'No shared tasks yet',
@@ -895,6 +902,7 @@ export const en: Record<keyof typeof zh, string> = {
teamTaskInProgress: 'In progress',
teamTaskCompleted: 'Completed',
teamTaskBlocked: 'Blocked',
teamTaskClaim: 'Claim',
teamTaskComplete: 'Complete',
teamTaskReopen: 'Reopen',
teamTaskDelete: 'Delete',
+21 -15
View File
@@ -788,17 +788,6 @@
color: var(--dsw-alias-label-tertiary);
}
/* The chip's own 10px padding would inset its glyph from the card's right
content edge; pull the box back by 8px so the × lands on the text axis. */
.popHeadActions {
display: inline-flex;
align-items: center;
gap: 2px;
margin-right: -8px;
text-transform: none;
letter-spacing: 0;
}
/* The action bar closes EVERY card the same way: one l1 rule above, one 8px
beat, and the primary action left to fill whatever the row gives it. */
.popFoot {
@@ -820,6 +809,24 @@
min-width: 0;
}
/* The task window's own head line (inside the floating window's scrolling
body): state dot + subject + status Tag. Sentence case, unlike the node
detail's letter-spaced uppercase `.popHead` band — this one labels a real
subject rather than a card's section. */
.taskHeadRow {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin-bottom: 8px;
}
.taskHeadRow .popTitle {
flex: 1;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-primary);
}
.jobPopMeta {
display: flex;
align-items: center;
@@ -838,14 +845,13 @@
color: var(--dsw-alias-label-secondary);
}
/* The action row of a floating window. It is the shell's FOOTER, which already
draws the body/footer boundary, so it carries no rule of its own. */
.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-l1);
}
/* The follow-tail switch is the action bar's left cell; the host Switch draws
@@ -940,7 +946,7 @@
/* A section inside a card opens with the family's one hairline: 8px of air on
both sides of the rule (the card's own 4px gap plus 4px here, and the 8px
padding below), exactly the beat `.jobPopActions` closes a card with. */
padding below), the same beat the window footers' own rule uses. */
.popSection {
margin-top: 4px;
padding-top: 8px;
+18 -12
View File
@@ -13,8 +13,8 @@
* exist as the origin's catalog children are RE-PARENTED under the run
* node, members without a catalog row are synthesized from the run's own
* member data (so a finished run still shows its members);
* - team members enrich matching agent nodes in place (role/model/status);
* the lead's membership lands on the root node;
* - team members enrich matching agent nodes in place (role/phase/status);
* the lead's roster row lands on the root node;
* - fold: per parent, settled agent LEAVES (state done/error, never the
* current session, never a workflow member's run node) collapse into one
* trailing `fold` node carrying the member ids and label previews.
@@ -23,13 +23,14 @@ import type {
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalogEntry,
SidebarTeamMemberView,
SidebarTeamMemberProjection,
SidebarTeamTaskView,
} from '../context-types.ts'
import type { SidebarChildLiveView } from '../context-types.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import { isSideThreadSummary } from './subagent-detect.ts'
import { childLive, isKnownLeaf, type SubagentCatalogView } from './subagent-catalog.ts'
import type { TeamMemberRow } from './team-projection.ts'
/** Display state of one agent node (drives the dot + fold candidacy). */
export type TasksNodeState = 'running' | 'idle' | 'done' | 'error'
@@ -61,12 +62,14 @@ export interface TasksAgentNode {
current: boolean
/** Live tail of a running child (the merged activity + newest text). */
live?: SidebarChildLiveView
/** Team enrichment (membership view matched by session id). */
/** Team enrichment (roster row matched by session id). */
team?: {
role: 'lead' | 'teammate'
name: string
model?: string
status: SidebarTeamMemberView['status']
/** Durable lifecycle of the roster row (`provisioning | active | failed`). */
phase: SidebarTeamMemberProjection['phase']
/** The member's derived display status (see ./team-projection.ts). */
status: TeamMemberRow['status']
diagnostics: string[]
}
/**
@@ -121,7 +124,7 @@ export interface TasksModelInput {
currentSessionId: string
live: Readonly<Record<string, SidebarChildLiveView | undefined>>
runs: readonly WorkflowRunView[]
teamMembers: readonly SidebarTeamMemberView[]
teamMembers: readonly TeamMemberRow[]
/** The team's shared tasks; each lands on its OWNER's node. */
teamTasks?: readonly SidebarTeamTaskView[]
/** Whether settled leaves collapse into fold nodes. */
@@ -142,14 +145,17 @@ function childLabel(
return entry.label ?? summary?.displayTitle ?? entry.id
}
/** Map a team member's runtime status onto the node display state. */
function teamState(status: SidebarTeamMemberView['status']): TasksNodeState {
/**
* Map a team member's derived status onto the node display state. The status
* already folds in the durable phase (./team-projection.ts), so this is a
* vocabulary translation and nothing more.
*/
function teamState(status: TeamMemberRow['status']): TasksNodeState {
switch (status) {
case 'running': return 'running'
case 'provisioning': return 'running'
case 'failed': return 'error'
case 'idle':
case 'inactive': return 'idle'
case 'idle': return 'idle'
}
}
@@ -201,7 +207,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const member = teamById.get(id)
return member === undefined
? undefined
: { role: member.role, name: member.name, model: member.model, status: member.status, diagnostics: member.diagnostics }
: { role: member.role, name: member.name, phase: member.phase, status: member.status, diagnostics: member.diagnostics }
}
const out: TasksNode[] = []
+59 -13
View File
@@ -48,19 +48,58 @@ export function stateLabel(state: TasksNodeState): string {
return t(key)
}
/**
* The two facts every task display rule reads. Deliberately NARROWER than
* either carrier (`TasksNodeTask` on the graph, `SidebarTeamTaskView` in the
* board and the task window) so one rule serves all of them — the window sees
* a `deleted` status the graph never draws, and only the shared rule needs to
* know that a deleted task is not "blocked".
*/
export interface TaskStatusFacts {
status: 'pending' | 'in_progress' | 'completed' | 'deleted'
ready: boolean
}
/** The task status label key. */
export function taskStatusKey(status: TasksNodeTask['status']): CopyKey {
export function taskStatusKey(status: TaskStatusFacts['status']): CopyKey {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
case 'deleted': return 'teamTaskDeleted'
}
}
/**
* Whether a shared task is HELD UP by its blockers.
*
* The service's `ready` flag means exactly one thing: "a pending task whose
* every blocker completed, so it can be CLAIMED". It is therefore false for
* every task that is not queued — which makes `!ready` the wrong test for
* "blocked": a claimed task (in_progress) and a finished one both report
* `ready: false`, and reading the flag alone labelled a task that is actively
* being worked on as 阻塞 (reproduced on the real board: claiming a task made
* its Tag flip from 待办 to 阻塞).
*/
export function taskBlocked(task: TaskStatusFacts): boolean {
return task.status === 'pending' && !task.ready
}
/** The status word of a task row: its own status, or 阻塞 when held up. */
export function taskStatusLabel(task: TaskStatusFacts): string {
return t(taskBlocked(task) ? 'teamTaskBlocked' : taskStatusKey(task.status))
}
/** The tone of a task's status Tag (or dot) — the same three-way rule. */
export function taskTone(task: TaskStatusFacts): 'success' | 'info' | 'warning' {
if (task.status === 'completed') return 'success'
return taskBlocked(task) ? 'warning' : 'info'
}
/** The host StateDot semantic of a task row/line. */
export function taskDotState(task: TasksNodeTask): StateDotState {
export function taskDotState(task: TaskStatusFacts): StateDotState {
if (task.status === 'completed') return 'done'
return task.ready ? 'ongoing' : 'warning'
return taskBlocked(task) ? 'warning' : 'ongoing'
}
/** First `limit` characters with an ellipsis when truncated. */
@@ -108,17 +147,17 @@ export function modeLabel(mode: TasksAgentNode['mode']): string | undefined {
/**
* The mono meta line of an agent card — at most two short tokens:
* - the topology root: 主代理 · 状态
* - a teammate with a known model: 模型 · 状态
* - any other agent: 模式 · 状态
* - any other agent: 模式 · 状态 (a teammate without a catalog mode falls back
* to its team role, the one durable identity the roster still carries — the
* `agentTeam` projection has no model field to print, see team-projection.ts)
*/
export function agentMeta(node: TasksAgentNode): string {
const state = stateLabel(node.state)
if (node.parentId === undefined) return `${t('subagentMainAgent')} · ${state}`
if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') {
return `${node.team.model} · ${state}`
}
const mode = modeLabel(node.mode)
return mode === undefined ? state : `${mode} · ${state}`
if (mode !== undefined) return `${mode} · ${state}`
const fallback = node.team === undefined ? undefined : teamRoleLabel(node.team)
return fallback === undefined ? state : `${fallback} · ${state}`
}
/**
@@ -133,10 +172,17 @@ export function agentMeta(node: TasksAgentNode): string {
export function agentIdentity(node: TasksAgentNode): string {
// The root's badge already reads 主代理: an identity line would repeat it.
if (node.parentId === undefined) return ''
if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') {
return node.team.model
}
return modeLabel(node.mode) ?? ''
const mode = modeLabel(node.mode)
if (mode !== undefined) return mode
return node.team === undefined ? '' : teamRoleLabel(node.team) ?? ''
}
/** The roster row's own identity word (the fallback for a modelless teammate). */
function teamRoleLabel(team: NonNullable<TasksAgentNode['team']>): string | undefined {
if (team.role === 'lead') return undefined
if (team.phase === 'provisioning') return t('tasksStateProvisioning')
if (team.phase === 'failed') return t('tasksStateError')
return t('tasksKindTeammate')
}
/** The mono meta line of a workflow run card: status · member tally. */
+122
View File
@@ -0,0 +1,122 @@
/**
* The plugin's view of DSH 0.1.7's `agentTeam` Session projection — the Lead
* Session's team board, read straight off the client snapshot.
*
* 0.1.6 exposed the board through the experimental service's Remote vocabulary
* (`agentTeams.remoteView`, reached over the wire) and the page had to poll it
* every 5s. 0.1.7 deleted that trio and publishes the same state as a Session
* projection instead: `SessionListState.projectionsBySession[leadId].values
* .agentTeam`, loaded once per connection like every other projection. This
* module is therefore a pure adapter — no request, no poller, no observer —
* exactly like ./subagent-catalog.ts for the catalog.
*
* Two facts the durable projection does not carry are DERIVED, not invented:
*
* - TURN ACTIVITY. A projection row is `{ id, name, role, phase, error? }`
* where `phase` is the durable lifecycle (`provisioning | active | failed`,
* the Lead row always `active`). Whether a member is running right now comes
* from the session's own status — upstream's own web UI "reads the shared
* Session projections and overlays activity from Session status" — and this
* plugin already has that overlay: `subagents.live` reports EVERY tree child
* with an explicit `running` flag (see ./subagent-catalog.ts's `childLive`),
* with the session list's own coarse `running` as the fallback for the Lead.
* - `diagnostics`, the 0.1.6 view's array of provisioning notes, collapses to
* the projection's single durable `error` string.
*
* What is genuinely gone is the member's `model` (and `description` /
* `provider` / `context`): the wire view never carried them, so the node
* detail no longer has a model row to show. See the design note
* docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md.
*/
import type {
SidebarChildLiveView,
SidebarSessionList,
SidebarSessionSummary,
SidebarTeamMemberProjection,
SidebarTeamProjection,
} from '../context-types.ts'
/**
* One roster row as this page consumes it: the projection's durable half plus
* the derived runtime status. A THIRD type next to the host service's
* `SidebarTeamMemberView` on purpose — that one is the 0.1.6 `listMembers`
* shape (status/description/provider/model) this plugin no longer reads, and
* conflating the two is exactly how the field drift that broke the board went
* unnoticed.
*/
export interface TeamMemberRow {
/** The member's session id (the teammate's child session under the lead). */
id: string
name: string
role: 'lead' | 'teammate'
/** Durable lifecycle, as the projection publishes it. */
phase: SidebarTeamMemberProjection['phase']
/** Derived display status: durable phase wins, else the live activity flag. */
status: 'running' | 'idle' | 'provisioning' | 'failed'
/** The durable provisioning error as a one-item list (see the header). */
diagnostics: string[]
}
/**
* The team board led by `rootId`, or undefined when that session leads none.
*
* ABSENT MEANS NO TEAM, and there are three ways to get there: the deployment
* never mounted the experimental layer (no projection key at all), the root is
* a teammate rather than the Lead, or the session simply has no team. All
* three collapse into "the page draws no Teams block" — the same silent
* degradation the 0.1.6 route expressed as `{available:false}` (layer absent)
* and `{team:null}` (root leads none), with one lookup instead of two states
* the page had to distinguish and never did.
*
* @param projections - `SessionListState.projectionsBySession`.
* @param rootId - the tree's root session (the only session that can be Lead).
*/
export function teamProjectionOf(
projections: SidebarSessionList['projectionsBySession'],
rootId: string | undefined,
): SidebarTeamProjection | undefined {
if (rootId === undefined) return undefined
return projections?.[rootId]?.values.agentTeam
}
/**
* Overlay runtime activity onto the durable roster.
*
* Status precedence is deliberately narrow: the durable phase wins for the two
* states it owns (`failed` never becomes "running" because a retry is in
* flight, `provisioning` is not yet a turn), and only an `active` member asks
* the live channel whether it is running right now. A member the channel does
* not mention yet (the first poll of a freshly rooted tree) reads as `idle`,
* which is the safe direction: it never claims work that is not happening.
*
* @param team - the Lead's projection ({@link teamProjectionOf}).
* @param live - the `subagents.live` map (`session id → live view`).
* @param byId - the session list by id (the Lead's own running flag lives here).
*/
export function teamMembersOf(
team: SidebarTeamProjection | undefined,
live: Readonly<Record<string, SidebarChildLiveView | undefined>>,
byId: Readonly<Record<string, SidebarSessionSummary | undefined>>,
): TeamMemberRow[] {
if (team === undefined) return []
return team.members.map(member => ({
id: member.id,
name: member.name,
role: member.role,
phase: member.phase,
status: memberStatus(member, live, byId),
diagnostics: member.error === undefined ? [] : [member.error],
}))
}
/** One member's runtime status (see {@link teamMembersOf} for the rule). */
function memberStatus(
member: SidebarTeamMemberProjection,
live: Readonly<Record<string, SidebarChildLiveView | undefined>>,
byId: Readonly<Record<string, SidebarSessionSummary | undefined>>,
): TeamMemberRow['status'] {
if (member.phase === 'failed') return 'failed'
if (member.phase === 'provisioning') return 'provisioning'
const running = live[member.id]?.running ?? byId[member.id]?.running
return running === true ? 'running' : 'idle'
}
+61 -14
View File
@@ -352,22 +352,37 @@ export interface SidebarAgentPresetsService {
/**
* The experimental Agent Teams service face (`ctx.agentTeams`, mounted only
* when the deployment loads `dsh-experimental-agent-team-profile`; absent →
* `ctx.get` returns undefined and the Teams block hides). Only the
* browser-facing Remote vocabulary the Tasks page needs is mirrored —
* structurally, so the plugin never imports the experimental package.
* `ctx.get` returns undefined and the Teams block hides).
*
* Only the WRITE half is mirrored here. DSH 0.1.7 deleted the 0.1.6
* `remoteView` / `remoteCreateTask` / `remoteUpdateTask` trio (the browser UI
* that consumed it went away with `ctx.remote`) and moved the board's READ
* path onto the Lead Session's `agentTeam` projection — which the client
* already receives in `SessionListState.projectionsBySession`. So the reads
* never touch this service any more; the two remaining calls each need the
* exact live Lead Agent as their authority credential, hence the host routes.
*
* Rejections are THROWN now, not returned: `createTask` / `updateTask` hand
* back the committed view, and a stale revision throws a `TeamError`
* (`HarnessError` subclass, `code === 'TEAM_TASK_STALE_REVISION'`) instead of
* resolving the 0.1.6 `TeamTaskMutationResult` union.
*/
export interface SidebarAgentTeamsService {
/** The agent's team membership, or undefined for a non-team/stale agent. */
tryMembership(agent: unknown): unknown
/** The point-in-time team snapshot the official panel renders. */
remoteView(agent: unknown): Promise<{ members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] }>
/** Create one shared task (CAS-free; ids are server-issued). */
remoteCreateTask(agent: unknown, req: SidebarCreateTeamTaskRequest): Promise<SidebarTeamTaskMutationResult>
/** Compare-and-set mutation of one shared task (stale revision → conflict). */
remoteUpdateTask(agent: unknown, req: SidebarUpdateTeamTaskRequest): Promise<SidebarTeamTaskMutationResult>
createTask(agent: unknown, req: SidebarCreateTeamTaskRequest): Promise<SidebarTeamTaskView>
/** Compare-and-set mutation of one shared task (stale revision → throws). */
updateTask(agent: unknown, req: SidebarUpdateTeamTaskRequest): Promise<SidebarTeamTaskView>
}
/** One team member as the runtime-enriched view reports it. */
/**
* One team member as the runtime-enriched `listMembers` view reports it.
* NOT consumed by this plugin any more (the projection below carries the
* durable half and the live channel carries activity, see
* ./client/team-projection.ts); kept as the mirror of the service's own
* vocabulary so a future reader does not re-derive it.
*/
export interface SidebarTeamMemberView {
/** The member's session id (the teammate's child session under the lead). */
id: string
@@ -381,6 +396,33 @@ export interface SidebarTeamMemberView {
diagnostics: string[]
}
/**
* One durable roster row of the Lead Session's `agentTeam` projection. Phase
* is the DURABLE lifecycle (the Lead row is always `active`); turn activity is
* overlaid from the session's own status (see ./client/team-projection.ts).
*/
export interface SidebarTeamMemberProjection {
/** The member's session id (the teammate's child session under the lead). */
id: string
name: string
role: 'lead' | 'teammate'
phase: 'provisioning' | 'active' | 'failed'
/** The provisioning failure, when the durable row records one. */
error?: string
}
/**
* The Lead Session's published team state: durable roster identities and
* phases, member errors, the non-deleted task views (0.1.6's `TeamView` shape,
* enriched per task exactly like the service's own views), and the first
* rejected Team record when the board had to stop at its last valid state.
*/
export interface SidebarTeamProjection {
members: readonly SidebarTeamMemberProjection[]
tasks: readonly SidebarTeamTaskView[]
failure?: string
}
/** One shared task-board row (durable fields plus derived readiness). */
export interface SidebarTeamTaskView {
id: string
@@ -415,10 +457,11 @@ export interface SidebarUpdateTeamTaskRequest {
owner?: string
}
/** Browser task-mutation result (stale revisions kept distinct, passthrough). */
export type SidebarTeamTaskMutationResult =
| { ok: true; value: SidebarTeamTaskView }
| { ok: false; error: { code: 'team-task-conflict' | 'team-rejected'; message: string } }
/** What a team-task write hands back: the committed view (0.1.7 shape). */
export interface SidebarTeamTaskMutationResult {
ok: true
value: SidebarTeamTaskView
}
/** The host session-title service face (mirror of the sessionTitle service). */
export interface SidebarSessionTitleService {
@@ -478,7 +521,11 @@ export interface SidebarSessionList {
/** One session's projection values, as the client snapshot publishes them. */
export interface SidebarProjectionSnapshot {
values: { subagentCatalog?: readonly SidebarSubagentCatalogEntry[] }
values: {
subagentCatalog?: readonly SidebarSubagentCatalogEntry[]
/** The Lead Session's team board (only the team's Lead carries one). */
agentTeam?: SidebarTeamProjection
}
state: 'idle' | 'loading' | 'ready' | 'error'
error: { code?: string; message?: string } | null
}
+3 -3
View File
@@ -483,9 +483,9 @@ function buildApi(
// Workflow runs of the whole tree (folded from `tool-workflow/*`
// session events; empty list when the tree never ran one).
'workflows.list': (payload) => workflowsApi.list(payload),
// Agent Teams (experimental): the root-led team's roster + task board,
// and the task board's create/CAS-update mutations.
'teams.view': (payload) => teamsApi.view(payload),
// Agent Teams (experimental): the task board's create/CAS-update
// mutations. Reads ride the Lead Session's `agentTeam` projection, so
// there is no `teams.view` route (see src/team-routes.ts).
'teams.taskCreate': (payload) => teamsApi.taskCreate(payload),
'teams.taskUpdate': (payload) => teamsApi.taskUpdate(payload),
// The side card preferences. The settings service is optional in the
+81 -68
View File
@@ -1,51 +1,53 @@
/**
* The Agent Teams routes of the /sidebar JSON API ('teams.view' /
* 'teams.taskCreate' / 'teams.taskUpdate'). The experimental
* `dsh-experimental-agent-team` service (`ctx.agentTeams`) exists only when
* the deployment opts in, so the routes degrade structurally:
* The Agent Teams WRITE routes of the /sidebar JSON API ('teams.taskCreate' /
* 'teams.taskUpdate').
*
* - service absent (`ctx.get('agentTeams')` undefined) → `{available:false}`
* — the client hides the whole Teams block silently (absence is a
* deployment fact, never an error banner);
* - the tree's root agent is not a team member (`tryMembership` miss) →
* `{available:true, team:null}` — the page shows the plain agent graph;
* - hit → the service's own Remote vocabulary (`remoteView` /
* `remoteCreateTask` / `remoteUpdateTask`), whose mutation result union
* already keeps CAS conflicts (`team-task-conflict`) distinct from other
* rejections — the route passes it through untouched so the client's
* edit popover can render "someone else changed this task, refreshing".
* The READ path is deliberately absent. DSH 0.1.7 replaced the 0.1.6 Remote
* trio (`remoteView` / `remoteCreateTask` / `remoteUpdateTask`, which this
* plugin's `teams.view` route mirrored) with the Lead Session's `agentTeam`
* Session projection: the browser already receives it in
* `SessionListState.projectionsBySession`, so the Tasks page reads the board
* straight off the shared snapshot (see ./client/team-projection.ts) and no
* host route, poller or observation handshake is involved. That also removed
* the only reason this file needed `tryMembership` for reads.
*
* What remains cannot be done in the browser: `createTask` / `updateTask`
* demand the exact live Lead Agent as their authority credential, which only
* the host holds. The experimental `dsh-experimental-agent-team` service
* (`ctx.agentTeams`) exists only when the deployment opts in, so the routes
* degrade structurally:
*
* - service absent (`ctx.get('agentTeams')` undefined) → 503 `team-error`
* (a mutation can never be accepted there; the page silently hides the
* whole Teams block because the projection is absent too, so this is the
* belt to that pair of braces);
* - the tree's root agent is not live in this process (cold session / another
* harness) → 404 `team-error`; teams are live-led by definition;
* - the root leads no team (`tryMembership` miss) → 404 `team-error`.
*
* Rejections are THROWN by 0.1.7 (0.1.6 returned a `TeamTaskMutationResult`
* union), so the CAS conflict has to be translated back into a wire code the
* client can route on: `TEAM_TASK_STALE_REVISION` → `team-conflict` (409),
* every other `TeamError` → `team-error` (400). The message is never parsed.
*
* Teams are implicit (TeamId ≡ the lead's session id) and every service
* method demands the live member Agent as its authority token, so the
* routes re-derive the caller per request (`ctx.agents.get(rootSessionId)`)
* exactly like the jobs.kill fence. Zero DSH source changes; the plugin
* never imports the experimental package (structural mirrors only).
* method demands the live member Agent as its authority token, so the routes
* re-derive the caller per request (`ctx.agents.get(rootSessionId)`) exactly
* like the jobs.kill fence. Zero DSH source changes; the plugin never imports
* the experimental package (structural mirrors only).
*/
import type {
Context,
SidebarAgentTeamsService,
SidebarAgentsService,
SidebarCreateTeamTaskRequest,
SidebarTeamMemberView,
SidebarTeamTaskMutationResult,
SidebarTeamTaskView,
SidebarUpdateTeamTaskRequest,
} from './context-types.ts'
import { requireString, SidebarError } from './wire.ts'
/** The Agent Teams routes of the sidebar API. */
/** The Agent Teams write routes of the sidebar API. */
export interface SidebarTeamsRoutes {
/**
* The team led by the tree's root agent, if any.
* @param payload - `{ rootSessionId }`.
* @returns `{ available: false }` when the deployment lacks the
* experimental layer; `{ available: true, team: null }` when the root
* leads no team; otherwise the members + task board.
*/
view(payload: unknown): Promise<
| { available: false }
| { available: true; team: { members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] } | null }
>
/** Create one shared task (payload = `{ rootSessionId, ...request }`). */
taskCreate(payload: unknown): Promise<SidebarTeamTaskMutationResult>
/** CAS-mutate one shared task (payload = `{ rootSessionId, ...request }`). */
@@ -57,6 +59,9 @@ const TASK_ACTIONS = new Set([
'claim', 'release', 'edit', 'set_dependencies', 'complete', 'reopen', 'reassign', 'delete',
])
/** The service's stale-revision code (0.1.6 returned it as a result union). */
const STALE_REVISION_CODE = 'TEAM_TASK_STALE_REVISION'
/** Narrow an unknown payload value to a string array (else undefined). */
function stringArrayOf(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined
@@ -65,7 +70,18 @@ function stringArrayOf(value: unknown): string[] | undefined {
}
/**
* Build the Agent Teams routes bound to the plugin context.
* The thrown value's `HarnessError` code, when it carries one. Read
* structurally: the plugin does not import `@deepseek-ai/dsh-llm` for a
* string, and a non-TeamError rejection simply has no code to route on.
*/
function errorCodeOf(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null) return undefined
const code = (error as { code?: unknown }).code
return typeof code === 'string' ? code : undefined
}
/**
* Build the Agent Teams write routes bound to the plugin context.
* @param ctx - host plugin context.
*/
export function buildTeamsApi(ctx: Context): SidebarTeamsRoutes {
@@ -74,13 +90,12 @@ export function buildTeamsApi(ctx: Context): SidebarTeamsRoutes {
/**
* The live lead agent of the requested tree plus its membership check.
* Throws 503 team-error when the experimental layer is absent (a mutation
* can never be accepted there); returns undefined when the root simply
* leads no team (a normal page state).
* Throws 503 when the experimental layer is absent and 404 when the root
* simply leads no team (a normal page state, but never for a mutation).
*/
const membershipOf = async (
payload: unknown,
): Promise<{ svc: SidebarAgentTeamsService; agent: unknown } | undefined> => {
): Promise<{ svc: SidebarAgentTeamsService; agent: unknown }> => {
if (teams === undefined) {
throw new SidebarError('team-error', 'the agent-teams layer is not mounted in this deployment', 503)
}
@@ -88,56 +103,52 @@ export function buildTeamsApi(ctx: Context): SidebarTeamsRoutes {
const agent = agents?.get(rootSessionId)
if (agent === undefined) {
// The root is not live in this process (cold session / other harness):
// teams are live-led by definition, so there is nothing to show.
return undefined
// teams are live-led by definition, so there is nothing to mutate.
throw new SidebarError('team-error', 'the tree root is not live in this process', 404)
}
try {
if (await teams.tryMembership(agent) === undefined) return undefined
} catch {
return undefined
if (teams.tryMembership(agent) === undefined) {
throw new SidebarError('team-error', 'the tree root leads no team', 404)
}
} catch (error) {
if (error instanceof SidebarError) throw error
throw new SidebarError('team-error', error instanceof Error ? error.message : String(error), 404)
}
return { svc: teams, agent }
}
/** Service rejections become a 400 team-error; CAS conflicts ride the union. */
const serviceError = (error: unknown): SidebarError =>
new SidebarError('team-error', error instanceof Error ? error.message : String(error), 400)
/**
* Translate one service rejection: a stale revision keeps its own wire code
* so the client's task window can say "someone else changed this task"
* instead of showing a generic failure.
*/
const serviceError = (error: unknown): SidebarError => {
const message = error instanceof Error ? error.message : String(error)
return errorCodeOf(error) === STALE_REVISION_CODE
? new SidebarError('team-conflict', message, 409)
: new SidebarError('team-error', message, 400)
}
return {
async view(payload) {
if (teams === undefined) return { available: false }
const membership = await membershipOf(payload)
if (membership === undefined) return { available: true, team: null }
try {
const team = await membership.svc.remoteView(membership.agent)
return { available: true, team }
} catch (error) {
throw serviceError(error)
}
},
async taskCreate(payload) {
const membership = await membershipOf(payload)
if (membership === undefined) {
throw new SidebarError('team-error', 'the tree root leads no team', 404)
}
const record = payload as Record<string, unknown>
const blockedBy = stringArrayOf(record.blockedBy)
const writeScopes = stringArrayOf(record.writeScopes)
const req: SidebarCreateTeamTaskRequest = {
subject: requireString(payload, 'subject'),
description: typeof record.description === 'string' ? record.description : '',
...(stringArrayOf(record.blockedBy) !== undefined ? { blockedBy: stringArrayOf(record.blockedBy) } : {}),
...(stringArrayOf(record.writeScopes) !== undefined ? { writeScopes: stringArrayOf(record.writeScopes) } : {}),
...(blockedBy !== undefined ? { blockedBy } : {}),
...(writeScopes !== undefined ? { writeScopes } : {}),
}
try {
return await membership.svc.remoteCreateTask(membership.agent, req)
return { ok: true, value: await membership.svc.createTask(membership.agent, req) }
} catch (error) {
throw serviceError(error)
}
},
async taskUpdate(payload) {
const membership = await membershipOf(payload)
if (membership === undefined) {
throw new SidebarError('team-error', 'the tree root leads no team', 404)
}
const record = payload as Record<string, unknown>
const expectedRevision = record.expectedRevision
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
@@ -147,18 +158,20 @@ export function buildTeamsApi(ctx: Context): SidebarTeamsRoutes {
if (typeof action !== 'string' || !TASK_ACTIONS.has(action)) {
throw new SidebarError('bad-request', 'missing or invalid "action"')
}
const blockedBy = stringArrayOf(record.blockedBy)
const writeScopes = stringArrayOf(record.writeScopes)
const req: SidebarUpdateTeamTaskRequest = {
taskId: requireString(payload, 'taskId'),
expectedRevision,
action: action as SidebarUpdateTeamTaskRequest['action'],
...(typeof record.subject === 'string' ? { subject: record.subject } : {}),
...(typeof record.description === 'string' ? { description: record.description } : {}),
...(stringArrayOf(record.blockedBy) !== undefined ? { blockedBy: stringArrayOf(record.blockedBy) } : {}),
...(stringArrayOf(record.writeScopes) !== undefined ? { writeScopes: stringArrayOf(record.writeScopes) } : {}),
...(blockedBy !== undefined ? { blockedBy } : {}),
...(writeScopes !== undefined ? { writeScopes } : {}),
...(typeof record.owner === 'string' ? { owner: record.owner } : {}),
}
try {
return await membership.svc.remoteUpdateTask(membership.agent, req)
return { ok: true, value: await membership.svc.updateTask(membership.agent, req) }
} catch (error) {
throw serviceError(error)
}
+1
View File
@@ -17,6 +17,7 @@ export type SidebarErrorCode =
| 'git-error'
| 'job-error'
| 'team-error'
| 'team-conflict'
| 'sidechat-error'
| 'subagents-unavailable'
| 'settings-rejected'