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
@@ -199,6 +199,18 @@ PR:[#680](https://github.com/omdsh-dev/DSH-better-sidebar/pull/680)(分支 `
完整设计与偏差记录见 [2026-09-27-tasks-graph-polish.md](2026-09-27-tasks-graph-polish.md)。
## ⚠️ 0.1.7 复审(2026-09-27):本文的团队一半已成历史
本文档的数据源侦察与路由设计写于 **DSH 0.1.5-rc.2 / 0.1.6-alpha.2** 时代。团队那一半——`remoteView` /
`remoteCreateTask` / `remoteUpdateTask` 三个 Remote 方法、`teams.view` 路由、`team-task-conflict`
冲突联合、以及只有 `listMembers` 才带的成员 `status` / `model`——**在 0.1.7 上全部失效**:上游删掉了
那三个方法,把团队的读路径改到 Lead Session 的 `agentTeam` Session projection(写路径保留
`createTask` / `updateTask`,但拒绝语义从「返回联合」改成「抛 `TeamError`」,冲突码
`TEAM_TASK_STALE_REVISION`)。结果是 0.1.7 上团队条从来不渲染。
改写、真机证据与实施偏差见 [2026-09-27-agent-teams-dsh-0.1.7-adaptation.md](2026-09-27-agent-teams-dsh-0.1.7-adaptation.md);
本文档其余章节(workflow 折叠、图/树布局、fold 规则、jobs 数据源)仍然有效。
## 验收(回退后,2026-09-21)
`pnpm typecheck` ✅ 0 错误;`pnpm lint` ✅ 0 错误;`pnpm vitest run` **133 文件通过 / 1410 用例通过 / 9 skipped / 0 失败**(回退前:4 个文件失败,其中 `theme.spec.ts`、`ui-foundation.spec.ts` 在收集期就因 `src/client/ui/theme.css` 缺失报 ENOENT,`ui-shadows.spec.ts` 2 例、`bundle-route.spec.ts` 1 例断言失败)。
@@ -0,0 +1,121 @@
# Agent Teams 适配 DSH 0.1.7:数据源改 projection、写路由对接真实服务、任务窗复用 FloatingWindow
日期:2026-09-27 分支:`feat/tasks-graph-polish`(承接 PR #680 的打磨线)
## 背景(用户原话)
> 目前是不是没有适配智能体团队功能?包括其任务等功能。注意之前我们另一个分支是有适配的,注意复用我们之前的那个弹窗组件
> 保持 KISS 原则,如有任意不清楚的地方,都向我详细确认
用户确认的三个决策:
| # | 问题 | 结论 |
|---|---|---|
| 1 | 团队数据源 | 改读宿主 **Session projection `agentTeam`**,读路径只剩 1 条路由(写) |
| 2 | 任务弹窗 | 复用上一轮的 **`FloatingWindow`**(可拖拽 + 可拉伸,只在关闭/Esc 时消失) |
| 3 | 验证深度 | **真机跑到有团队为止**:临时 DSH 环境里造真团队会话,看板/任务 CRUD 全验 |
## 诊断:为什么「没有适配」
不是没做,是**按 0.1.6 的服务 API 接的,而 0.1.7 把那套 API 删了**。真机(3080)curl 打我们自己的路由:
```
POST /sidebar/api/teams.view {"rootSessionId":"212f7157-…"} → 200 {"available":true,"team":null} 无团队,正常降级
POST /sidebar/api/teams.view {"rootSessionId":"session-b98c…"} → 400 team-error "membership.svc.remoteView is not a function"
```
`src/team-routes.ts` 当初镜像的是 0.1.6 的 **Remote 词汇**(`npm pack @deepseek-ai/dsh-experimental-agent-team@0.1.6-alpha.2` 解包核对过:`remoteView` / `remoteCreateTask` / `remoteUpdateTask` 三个方法都在,README 明说它们服务于 `./remote` 导出):
| | 0.1.6-alpha.2 | 0.1.7-rc.1(实际安装的包) |
|---|---|---|
| 读 | `remoteView(agent): TeamView` | `listMembers(agent)` / `listTasks(caller)`;官方 Web UI 改读 **Session projection `agentTeam`** |
| 写 | `remoteCreateTask` / `remoteUpdateTask` → 返回 `TeamTaskMutationResult` 联合 | `createTask` / `updateTask` → 返回已提交的 `TeamTaskView`,**拒绝改为抛 `TeamError`** |
| 冲突 | `{ok:false, error:{code:'team-task-conflict'}}` | 抛 `TeamError`,`code = 'TEAM_TASK_STALE_REVISION'` |
官方 README(0.1.7)原文:
> The Web UI reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools.
**后果**:`TeamBoard` 整条团队条从不渲染(`useTeamView` 落进错误分支,页面静默无提示),团队节点富化、任务行、任务弹窗全部连带失效;客户端判的冲突码也是 0.1.6 的 `team-task-conflict`,所以「别人改过、已刷新」永远不会出现。
## 架构(改后)
```
host(src/)
team-routes.ts 只留两条写路由:taskCreate / taskUpdate
├ ctx.get('agentTeams') 缺席 → 503 team-error
├ ctx.agents.get(root) 未命中 → 404 team-error(团队按定义是 live-led)
├ tryMembership 未命中 → 404 team-error
├ TEAM_TASK_STALE_REVISION → 409 team-conflict
└ 其余 TeamError → 400 team-error
wire.ts SidebarErrorCode += 'team-conflict'
context-types.ts agentTeams 只镜像写半边;新增 SidebarTeamProjection /
SidebarTeamMemberProjection 镜像;projection snapshot 加 agentTeam
client(src/client/)
team-projection.ts 新:读 projectionsBySession[root].values.agentTeam + 派生成员运行状态
SubagentView.tsx 删 useTeamView 及其 5s 轮询;team/teamMembers 改为 useMemo 派生
tasks-model.ts 节点富化改吃 roster 行(role/name/phase/status/diagnostics)
TeamBoard.tsx 成员点、任务 Tag 走 tasks-shared 的统一状态规则;根加 data-team-board
TaskWindow.tsx 外壳换 FloatingWindow;动作行下沉到 footer;状态机跟随宿主
tasks-shared.tsx taskBlocked / taskStatusLabel / taskTone / taskDotState(任务状态唯一规则)
api.ts 删 teamsView;写路由经 writeTask 把失败信封还原成 {ok:false, code, message}
```
### 为什么删掉 `teams.view` 而不是修它
官方 0.1.7 把「读团队」明确交给 projection,而插件**已经在读同一个快照**(`subagentCatalog` 就是同一个 `projectionsBySession`)。再留一条轮询路由等于把同一份数据读两遍、并继续维护两套降级语义(`{available:false}` / `{team:null}`)。改为一次查表后:
- `available:false` 与 `team:null` 合并成**一个**状态(projection 缺席 = 这里没有团队),页面行为不变(团队条不渲染、无横幅、无 console error);
- 团队数据**跟着 snapshot 推送自动更新**,不需要手动刷新(刷新按钮上的 `team.refresh()` 一并删除);
- `subagents.live` 本来就在给每个 child 报 `running`,成员运行状态直接复用,不新增任何请求。
## 真机验证(可复现)
### 造一个真团队会话
host 侧的 Agent Teams 实验层是 profile 层的(`dsh-experimental-agent-team-profile`),headless 通道默认不挂,用 `--patch` 注入同一份 patch 即可,不动 profile:
```bash
DSH_HOME=/Users/menghuan/.dsh-web npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 \
dsh headless --patch /tmp/dsh-selfcheck/agent-team-layer.yml --json "<spawn 一个 teammate + 建一条共享任务>"
```
`/tmp/dsh-selfcheck/agent-team-layer.yml` 是 `@deepseek-ai/dsh-experimental-agent-team-profile/cordis.patch.yml` 的逐字复制(三条 insert + 四条 disable)。
### 证据链(本轮真实产出)
1. **写路由直连真实服务**(3080,新建构建):
- 未 live 的 root → `404 team-error "the tree root is not live in this process"`(**不再是 `remoteView is not a function`**)
- `teams.view` → `404 not-found`(路由已删)
2. **看板从 projection 渲染**(Playwright,真实浏览器):`[data-team-board]` 出现,文本 `团队任务板团队 2 成员 · 1 任务全部leadprobe-writerlive board probe待办新建任务`。
3. **任务窗 = FloatingWindow**:`aria-label=任务详情`、`[data-window-footer]` = `编辑 完成 删除`、**外部点击不关**、Escape 关。
4. **claim → complete 全链路**(浏览器点击 → 宿主路由 → 真实 `TeamService`):
- 队列中任务点「认领」→ 窗口与看板同时变 `负责人 lead` / `进行中`(Tag 不再是 阻塞)
- 再点「完成」→ `已完成`,动作行变 `编辑 重开 删除`
- 截图:`/tmp/dsh-selfcheck/22-live-turn.png`、`23-team-board.png`、`24-task-window.png`、`25-after-write.png`
5. **单元/集成**:`pnpm typecheck` / `lint` 通过;`pnpm test` **1276 passed / 9 skipped**(131 个 spec,新增 9 例:team-routes 重写 12 例、team-projection 9 例、任务窗状态机 2 例、状态规则 4 例)。
### 真机抓到的两个单测抓不到的问题(已修)
| # | 现象 | 根因 | 修法 |
|---|---|---|---|
| 1 | 队列中任务点「完成」→ 窗口报 `only an in-progress task can complete` | 宿主的任务是状态机(`pending → claim → in_progress → complete → completed → reopen`,`release`/`reassign` 回退为 pending),而窗口只提供「完成」 | 动作行跟随状态:pending 出「认领」(`ready:false` 时禁用,理由是标签已写`阻塞`)、in_progress 出「完成」、completed 出「重开」;新增 `teamTaskClaim` 词条 ×20 |
| 2 | 认领之后 Tag 变「阻塞」 | `ready` 的语义只是「pending 且所有 blocker 已完成 → 可以认领」,**对所有非 pending 任务都是 false**,用 `!ready` 判「阻塞」把正在做的任务标成了阻塞 | 抽出唯一规则 `taskBlocked(status, ready) = status === 'pending' && !ready`,`taskStatusLabel` / `taskTone` / `taskDotState` 共用;四处展示点(看板行、任务窗、节点任务行、图卡片)全部改走它 |
第 2 条是「修源头」而不是补丁:三处各自写了一遍 `ready ? … : 'teamTaskBlocked'`,所以把规则收敛成一个函数,未来只有一个地方会错。
## 实施偏差记录
1. **成员 `model` 与 `diagnostics` 从此缺失**:`agentTeam` projection 只带 `{id,name,role,phase,error?}`(已核对 `lib/types/types.d.ts`),0.1.6 的 `listMembers` 才带 `status/model/description/provider/context`。不做额外 host 路由去补(官方面板同样不显示模型):节点详情的「模型」行删除;卡片 meta 行在成员没有 catalog mode 时回退到 `成员` / `部署中` / `出错`。
2. **运行状态改为派生**:projection 的 `phase` 是**持久生命周期**(`provisioning | active | failed`),turn 活动由 `subagents.live` 的 `running` 覆盖(官方 README 的 “overlays activity from Session status” 就是这个意思),Lead 行回退到 session list 的 `running`。规则写在 `teamMembersOf` 一处。
3. **不做 0.1.6 回退**:不保留 `remote*` 探测。插件这一版本来就要求 0.1.7+,且 0.1.6 的 Web UI 自己在这台机器上已经是重复插入(`ui-agent-team` 被 `-profile` 与 `-web-profile` 两个 bundle 各插一次,见 `dsh --profile web --dump-config` 第 1779/1782 行)——这是既有 profile 事实,本轮不动。
4. **`api.teamsTaskUpdate` 返回结果联合而不是抛异常**:`readEnvelope` 对失败信封是**抛** `SidebarApiError`,所以任务窗原本永远走不到 `{ok:false}` 分支(这是本轮唯一一个先写错、被真机证据纠正的地方)。现在 `writeTask()` 只在这一处把失败信封还原成 `{ok:false, code, message}`,让窗口能对 `team-conflict` 说自己的话。
5. **动作行从窗口体内挪到 shell 的 footer**:FloatingWindow 的 body 是滚动容器,长描述会把动作推出视野;footer 是固定行。`jobPopActions` 因此去掉自己的上边框(footer 已经画了分隔)。
6. **看板开关未改**:团队条的折叠箭头仍是旋转的 chevron(上一轮「折叠/展开分向」的结论只应用于图卡片簇),本轮不动以免扩大改动面。
## 已知限制
- **写路由需要 live 的 lead**:冷会话 / 另一个进程持有的会话 → 404。团队按定义是 live-led 的(服务要求把 live Agent 当权限凭证),这是结构性事实而非缺陷;页面在这种情况下仍能显示看板(projection 是持久的),只是动作会被拒。
- projection 对「本客户端从未加载过的会话」是否可用:真机上验证过**新会话 + 重启后**两条路径都可用(浏览器是从 `projectionsBySession` 读的,与 `subagentCatalog` 同源);但如果将来宿主改成按需加载 projection,这块会退化成空看板而不是报错。
- 每个成员一行、每个任务一行都在 DOM 里(上限 8 成员 / 256 任务),未做虚拟化。
+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'
-3
View File
@@ -161,9 +161,6 @@ beforeEach(() => {
if (method === 'workflows.list') {
return jsonResponse({ ok: true, value: { runs: [] } })
}
if (method === 'teams.view') {
return jsonResponse({ ok: true, value: { available: false } })
}
throw new Error(`unexpected fetch ${String(url)}`)
})
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
+48 -7
View File
@@ -14,8 +14,9 @@ import {
import type {
SidebarSessionSummary,
SidebarSubagentCatalogEntry,
SidebarTeamMemberView,
} from '../src/context-types.ts'
import type { TeamMemberRow } from '../src/client/team-projection.ts'
import { taskBlocked, taskDotState, taskStatusLabel, taskTone } from '../src/client/tasks-shared.tsx'
import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
import type { SidebarChildLiveView } from '../src/context-types.ts'
import type { WorkflowRunView } from '../src/workflow-runs.ts'
@@ -49,9 +50,9 @@ function run(over: Partial<WorkflowRunView> = {}): WorkflowRunView {
}
}
/** A team member view. */
function member(over: Partial<SidebarTeamMemberView> = {}): SidebarTeamMemberView {
return { id: 'x', name: 'n', role: 'teammate', status: 'running', diagnostics: [], ...over }
/** A derived roster row (the shape ./team-projection.ts hands the model). */
function member(over: Partial<TeamMemberRow> = {}): TeamMemberRow {
return { id: 'x', name: 'n', role: 'teammate', phase: 'active', status: 'running', diagnostics: [], ...over }
}
/** A base input; override per test. */
@@ -131,18 +132,27 @@ describe('buildTasksModel', () => {
expect(existing?.parentId).toBe('run:run-1')
})
it('enriches teammates and the lead from the team view', () => {
it('enriches teammates and the lead from the roster projection', () => {
const model = buildTasksModel(input({
catalogs: { root: catalog([child('w', { label: 'writer' })]) },
byId: { root: summary('root'), w: summary('w') },
teamMembers: [
member({ id: 'root', name: 'lead', role: 'lead' }),
member({ id: 'w', name: 'writer', model: 'glm-5.3', status: 'idle' }),
member({ id: 'w', name: 'writer', phase: 'active', status: 'idle' }),
],
}))
expect(agents(model).find(node => node.id === 'root')?.team?.role).toBe('lead')
const w = agents(model).find(node => node.id === 'w')
expect(w?.team).toMatchObject({ role: 'teammate', name: 'writer', model: 'glm-5.3' })
expect(w?.team).toMatchObject({ role: 'teammate', name: 'writer', phase: 'active', status: 'idle' })
})
it('gives a provisioning teammate the running node state', () => {
const model = buildTasksModel(input({
catalogs: { root: catalog([child('w')]) },
byId: { root: summary('root'), w: summary('w') },
teamMembers: [member({ id: 'w', phase: 'provisioning', status: 'provisioning' })],
}))
expect(agents(model).find(node => node.id === 'w')?.state).toBe('running')
})
it('folds settled leaves per parent and keeps running/teammate/current rows', () => {
@@ -255,3 +265,34 @@ describe('buildTasksModel: duplicate workflow members', () => {
expect(ghost).toMatchObject({ parentId: 'run:run-1', synthesized: true })
})
})
describe('shared task status rule (taskBlocked / taskStatusLabel / taskTone)', () => {
const facts = (status: 'pending' | 'in_progress' | 'completed', ready: boolean) => ({ status, ready })
it('calls a queued task with an open blocker blocked', () => {
expect(taskBlocked(facts('pending', false))).toBe(true)
expect(taskStatusLabel(facts('pending', false))).toBe('Blocked')
expect(taskTone(facts('pending', false))).toBe('warning')
})
it('never calls a CLAIMED task blocked, even though the service reports ready:false', () => {
// The board's `ready` flag means "a pending task clear to claim": it is
// false for every non-pending task, which is exactly how a task being
// actively worked on got labelled 阻塞 on the real board.
expect(taskBlocked(facts('in_progress', false))).toBe(false)
expect(taskStatusLabel(facts('in_progress', false))).toBe('In progress')
expect(taskTone(facts('in_progress', false))).toBe('info')
})
it('never calls a finished task blocked', () => {
expect(taskBlocked(facts('completed', false))).toBe(false)
expect(taskStatusLabel(facts('completed', false))).toBe('Completed')
expect(taskTone(facts('completed', false))).toBe('success')
})
it('reads a claimable queued task as pending, not blocked', () => {
expect(taskStatusLabel(facts('pending', true))).toBe('Pending')
expect(taskTone(facts('pending', true))).toBe('info')
expect(taskDotState(facts('pending', true))).toBe('ongoing')
})
})
+188 -79
View File
@@ -20,6 +20,9 @@ import type {
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalogEntry,
SidebarTeamMemberProjection,
SidebarTeamProjection,
SidebarTeamTaskView,
} from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
@@ -102,18 +105,29 @@ function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
let teamPayload: unknown = { available: false }
/** A failed `/sidebar/api` envelope (the wire shape `readEnvelope` parses). */
function jsonError(status: number, code: string, message: string): Response {
return {
ok: false, status, json: async () => ({ ok: false, error: { code, message } }),
} as unknown as Response
}
/** The Lead Session's `agentTeam` projection the board reads (undefined = none). */
let teamProjection: SidebarTeamProjection | undefined
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
const fetchedMethods: string[] = []
/** The `subagents.live` payload the stub answers with (session id → live view). */
let livePayload: Record<string, unknown> = {}
/** The folded workflow runs the stub answers `workflows.list` with. */
let runsPayload: unknown[] = []
/** A write rejection the stub answers `teams.taskUpdate` with (undefined = ok). */
let writeRejection: { status: number; code: string; message: string } | undefined
beforeEach(() => {
teamPayload = { available: false }
teamProjection = undefined
livePayload = {}
runsPayload = []
writeRejection = undefined
teamMutations.length = 0
fetchedMethods.length = 0
jobsFake = makeJobsService()
@@ -128,17 +142,17 @@ beforeEach(() => {
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: livePayload } })
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: runsPayload } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
teamMutations.push({ method: method ?? '', body })
if (writeRejection !== undefined) {
return jsonError(writeRejection.status, writeRejection.code, writeRejection.message)
}
// 0.1.7 hands back the COMMITTED view (no result union any more).
return jsonResponse({
ok: true,
value: {
ok: true,
value: {
id: 't9', revision: 9, subject: body.subject ?? 'x', description: body.description ?? '',
status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
},
id: 't9', revision: 9, subject: body.subject ?? 'x', description: body.description ?? '',
status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
},
})
}
@@ -171,7 +185,11 @@ function snapshotWithChildren(count: number): SidebarSessionList {
entries.push({ id, createdAt: 1_000 + index, mode: 'one-shot', label: `子代理 ${index}` })
projectionsBySession[id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
}
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
projectionsBySession.root = {
values: { subagentCatalog: entries, ...(teamProjection === undefined ? {} : { agentTeam: teamProjection }) },
state: 'ready',
error: null,
}
return { byId, projectionsBySession }
}
@@ -193,7 +211,11 @@ function snapshotWithRun(): SidebarSessionList {
byId[entry.id] = { id: entry.id, displayTitle: entry.id, origin: 'subagent', parentId: 'root', running: false }
projectionsBySession[entry.id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
}
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
projectionsBySession.root = {
values: { subagentCatalog: entries, ...(teamProjection === undefined ? {} : { agentTeam: teamProjection }) },
state: 'ready',
error: null,
}
return { byId, projectionsBySession }
}
@@ -349,6 +371,36 @@ describe('Tasks page interactions', () => {
})
})
/**
* The Lead Session's projection for one test run: members + tasks, or absent
* (`withTeam(null)`) when the tree leads no team. A task omitted from `tasks`
* is not on the board at all.
*/
function withTeam(input: {
members?: SidebarTeamMemberProjection[]
tasks?: SidebarTeamTaskView[]
} | null = {}): void {
if (input === null) {
teamProjection = undefined
return
}
teamProjection = {
members: input.members ?? [
{ id: 'root', name: 'lead', role: 'lead', phase: 'active' },
{ id: 'child-0', name: 'writer', role: 'teammate', phase: 'active' },
],
tasks: input.tasks ?? [],
}
}
/** One shared task row. */
function teamTask(over: Partial<SidebarTeamTaskView> = {}): SidebarTeamTaskView {
return {
id: 't1', revision: 1, subject: '收窄卡片', description: '细节', status: 'in_progress',
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], ...over,
}
}
describe('Tasks page graph interactions and team board', () => {
it('activates a graph node after a background pointerdown (no click theft)', async () => {
const navigation: NavigationSpy = { opened: [] }
@@ -395,59 +447,50 @@ describe('Tasks page graph interactions and team board', () => {
})
it('shows the team board without any click when the root leads a team', async () => {
teamPayload = {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'idle', model: 'glm-5.3', diagnostics: [] },
],
tasks: [{
id: 't1', revision: 1, subject: '收窄卡片', description: '', status: 'in_progress',
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
}],
},
}
withTeam({ tasks: [teamTask()] })
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
// Visible by default: header, members and the task row (no chip to click).
expect(container.querySelector('[data-team-board]')).not.toBeNull()
expect(container.textContent).toContain('团队任务板')
expect(container.textContent).toContain('lead')
expect(container.textContent).toContain('writer')
expect(container.textContent).toContain('收窄卡片')
unmount()
})
it('draws no team surface at all when the root leads no team', async () => {
withTeam(null)
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
expect(container.querySelector('[data-team-board]')).toBeNull()
expect(container.textContent).not.toContain('团队任务板')
unmount()
})
})
describe('Tasks page: owned tasks, host-primitive controls, draggable output', () => {
/** A team payload whose only task is owned by the child agent. */
function teamWithOwnedTask(): unknown {
return {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', model: 'glm-5.3', diagnostics: [] },
],
tasks: [
{
id: 't1', revision: 1, subject: '收窄卡片与图标化', description: '细节', status: 'in_progress',
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
},
{
id: 't2', revision: 2, subject: '补点击回归', description: '', status: 'pending',
ownerName: 'writer', blockedBy: ['t1'], writeScopes: [], ready: false, writeScopeWarnings: [],
},
],
},
}
/** A board whose two tasks are both owned by the child agent. */
function teamWithOwnedTask(): void {
withTeam({
tasks: [
teamTask({ subject: '收窄卡片与图标化', ownerName: 'writer' }),
teamTask({
id: 't2', revision: 2, subject: '补点击回归', description: '', status: 'pending',
ownerName: 'writer', blockedBy: ['t1'], ready: false,
}),
],
})
}
it('renders the owned task on its agent node', async () => {
teamPayload = teamWithOwnedTask()
teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
@@ -463,7 +506,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
})
it('opens the shared task window from a board row (markdown first, no native select)', async () => {
teamPayload = teamWithOwnedTask()
teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
@@ -473,7 +516,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
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
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
expect(dialog).not.toBeNull()
// View mode first: the description renders as markdown, edited only on demand.
expect(dialog.textContent).toContain('细节')
@@ -489,7 +532,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
})
it('creates a task through the dialog (host Input + modal) and posts the CAS-free create', async () => {
teamPayload = teamWithOwnedTask()
teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
@@ -506,7 +549,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
setter?.call(field, '新任务标题')
field.dispatchEvent(new Event('input', { bubbles: true }))
})
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
expect(dialog).not.toBeNull()
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
expect(save).toBeDefined()
@@ -552,25 +595,17 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(document.querySelector('[data-floating-window]')).toBeNull()
unmount()
await act(async () => { 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: [],
}],
},
}
withTeam({
tasks: [teamTask({
revision: 4, description: '第一行\n\n- 第二行', ownerName: 'writer',
})],
})
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
@@ -580,7 +615,7 @@ describe('Tasks page: the shared task window', () => {
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
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
expect(dialog).not.toBeNull()
expect(dialog.textContent).toContain('收窄卡片')
// Markdown view renders the multi-line body before any editing.
@@ -605,20 +640,14 @@ describe('Tasks page: the shared task window', () => {
})
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: [],
}],
},
}
withTeam({
members: [
{ id: 'root', name: 'lead', role: 'lead', phase: 'active' },
{ id: 'child-0', name: 'writer', role: 'teammate', phase: 'active' },
{ id: 'child-1', name: 'reviewer', role: 'teammate', phase: 'active' },
],
tasks: [teamTask({ revision: 7, description: '', status: 'pending', ownerName: 'writer' })],
})
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
@@ -626,7 +655,7 @@ describe('Tasks page: the shared task window', () => {
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 dialog = document.querySelector('[data-floating-window]') as HTMLElement
const reviewer = [...dialog.querySelectorAll('button')].find(button => button.textContent === 'reviewer')
expect(reviewer).toBeDefined()
await act(async () => { reviewer?.click() })
@@ -636,6 +665,86 @@ describe('Tasks page: the shared task window', () => {
unmount()
})
it('follows the board state machine: claim a queued task, complete a claimed one', async () => {
withTeam({ tasks: [teamTask({ revision: 4, subject: '收窄卡片', description: '', status: 'pending' })] })
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 row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
await act(async () => { row.click() })
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
// The action row is the shell's FOOTER (outside the scrolling body), so it
// stays put while a long description scrolls.
const footer = dialog.querySelector('[data-window-footer]') as HTMLElement
expect(footer).not.toBeNull()
const button = (label: string): HTMLButtonElement =>
[...footer.querySelectorAll('button')].find(candidate => candidate.textContent?.includes(label)) as HTMLButtonElement
// A QUEUED task cannot be completed — the service enforces
// pending → claim → in_progress → complete, so the window offers claim.
expect(button('完成')).toBeUndefined()
const claim = button('认领')
expect(claim).toBeDefined()
// A stale revision is a REAL failure the reader must see: 0.1.7 throws a
// typed rejection, the host maps it to `team-conflict` (409).
writeRejection = { status: 409, code: 'team-conflict', message: 'stale team task "t1" revision 4' }
await act(async () => { claim.click() })
await act(async () => { await Promise.resolve() })
expect(teamMutations.at(-1)?.body).toMatchObject({ action: 'claim', expectedRevision: 4 })
expect(dialog.textContent).toContain('已被他人修改')
// The window stays open on the failed write: nothing was committed.
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
// A clean write clears the note.
writeRejection = undefined
await act(async () => { claim.click() })
await act(async () => { await Promise.resolve() })
expect(dialog.textContent).not.toContain('已被他人修改')
await act(async () => { unmount() })
})
it('refuses to claim a task whose blockers are still open', async () => {
withTeam({ tasks: [teamTask({ revision: 2, ready: false, status: 'pending' })] })
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 row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
await act(async () => { row.click() })
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
const claim = [...dialog.querySelectorAll('[data-window-footer] button')]
.find(candidate => candidate.textContent?.includes('认领')) as HTMLButtonElement
// `claim` would be refused with TEAM_TASK_BLOCKED, so it never leaves.
expect(claim.disabled).toBe(true)
await act(async () => { unmount() })
})
it('closes the task window on Escape, never on an outside click', async () => {
withTeam({ tasks: [teamTask()] })
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 row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
await act(async () => { row.click() })
// The window portals to the body: query the DOCUMENT, not the page root.
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
await act(async () => {
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(document.querySelector('[data-floating-window]')).toBeNull()
await act(async () => { unmount() })
})
it('draws the two-segment card: kind badge + name on top, state bar below', async () => {
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
+120
View File
@@ -0,0 +1,120 @@
/**
* Unit tests for the `agentTeam` projection adapter: reading the Lead
* Session's board off the client snapshot, and overlaying runtime activity
* onto the durable roster.
*
* The shape under test is DSH 0.1.7's `TeamProjection` (see the experimental
* package's `lib/types/types.d.ts`): `{ members: {id,name,role,phase,error?},
* tasks: TeamTaskView[], failure? }`. There is no `available` flag and no
* `model` field — a projection that is absent means "this session leads no
* team" (or the deployment never mounted the layer), which is the same silent
* degradation either way.
*/
import { describe, expect, it } from 'vitest'
import { teamMembersOf, teamProjectionOf } from '../src/client/team-projection.ts'
import type {
SidebarChildLiveView,
SidebarSessionList,
SidebarSessionSummary,
SidebarTeamMemberProjection,
SidebarTeamProjection,
} from '../src/context-types.ts'
/** One durable roster row. */
function member(over: Partial<SidebarTeamMemberProjection> = {}): SidebarTeamMemberProjection {
return { id: 'w', name: 'writer', role: 'teammate', phase: 'active', ...over }
}
/** One Lead Session projection. */
function team(over: Partial<SidebarTeamProjection> = {}): SidebarTeamProjection {
return { members: [member()], tasks: [], ...over }
}
/** The snapshot's projection map with one session's team installed. */
function projections(
rootId: string,
teamValue: SidebarTeamProjection,
): SidebarSessionList['projectionsBySession'] {
return { [rootId]: { values: { agentTeam: teamValue }, state: 'ready', error: null } }
}
/** A summary row. */
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
return { id, displayTitle: id, ...over }
}
describe('teamProjectionOf', () => {
it('reads the root session own projection', () => {
const value = team()
expect(teamProjectionOf(projections('root', value), 'root')).toBe(value)
})
it('answers undefined without a root, without a snapshot, or without a team', () => {
expect(teamProjectionOf(projections('root', team()), undefined)).toBeUndefined()
expect(teamProjectionOf(undefined, 'root')).toBeUndefined()
// A session the projection store knows, but which leads no team (the
// deployment without the experimental layer, or a plain subagent tree).
const other: SidebarSessionList['projectionsBySession'] = {
root: { values: {}, state: 'ready', error: null },
}
expect(teamProjectionOf(other, 'root')).toBeUndefined()
expect(teamProjectionOf(projections('root', team()), 'someone-else')).toBeUndefined()
})
})
describe('teamMembersOf', () => {
const noLive: Record<string, SidebarChildLiveView | undefined> = {}
it('answers an empty roster without a team', () => {
expect(teamMembersOf(undefined, noLive, {})).toEqual([])
})
it('carries the durable phase and the member identity through', () => {
const rows = teamMembersOf(
team({ members: [member({ id: 'lead', name: 'lead', role: 'lead' }), member({ id: 'w' })] }),
noLive,
{},
)
expect(rows).toEqual([
{ id: 'lead', name: 'lead', role: 'lead', phase: 'active', status: 'idle', diagnostics: [] },
{ id: 'w', name: 'writer', role: 'teammate', phase: 'active', status: 'idle', diagnostics: [] },
])
})
it('reads an active member activity from the live channel', () => {
const live: Record<string, SidebarChildLiveView | undefined> = { w: { running: true } }
expect(teamMembersOf(team(), live, {})[0]?.status).toBe('running')
})
it('falls back to the session list running flag (the Lead is no live child)', () => {
const byId = { lead: summary('lead', { running: true }) }
const rows = teamMembersOf(
team({ members: [member({ id: 'lead', name: 'lead', role: 'lead' })] }),
noLive,
byId,
)
expect(rows[0]?.status).toBe('running')
})
it('never claims a running turn while the durable phase disagrees', () => {
const live: Record<string, SidebarChildLiveView | undefined> = { w: { running: true } }
const provisioning = teamMembersOf(team({ members: [member({ phase: 'provisioning' })] }), live, {})
expect(provisioning[0]?.status).toBe('provisioning')
const failed = teamMembersOf(team({ members: [member({ phase: 'failed' })] }), live, {})
expect(failed[0]?.status).toBe('failed')
})
it('exposes the durable member error as the diagnostics list', () => {
const rows = teamMembersOf(
team({ members: [member({ phase: 'failed', error: 'spawn rejected' })] }),
noLive,
{},
)
expect(rows[0]?.diagnostics).toEqual(['spawn rejected'])
})
it('treats a member the live channel has not mentioned as idle', () => {
const live: Record<string, SidebarChildLiveView | undefined> = { other: { running: true } }
expect(teamMembersOf(team(), live, {})[0]?.status).toBe('idle')
})
})
+138 -89
View File
@@ -1,25 +1,23 @@
/**
* Host route tests for the Agent Teams API ('teams.view' / 'teams.taskCreate'
* / 'teams.taskUpdate'): structural degradation (layer absent → available:
* false; root leads no team → team: null), passthrough of the service's own
* Remote vocabulary, CAS conflict unions returned untouched, and payload
* validation. The plugin never imports the experimental package — the fake
* service here is a structural double.
* Host route tests for the Agent Teams WRITE API ('teams.taskCreate' /
* 'teams.taskUpdate'): structural degradation (layer absent → 503, root not
* live / leads no team → 404), the exact request shape the 0.1.7 service
* demands, and the rejection→wire-code mapping that keeps a stale CAS
* revision distinct from every other failure.
*
* There is deliberately no `teams.view` coverage left: DSH 0.1.7 moved the
* board's read path onto the Lead Session's `agentTeam` projection, which the
* client reads off its own snapshot (tests/team-projection.spec.ts). The 0.1.6
* `remoteView` vocabulary this suite used to lock down no longer exists in the
* service — that is the drift this file now guards against.
*
* The plugin never imports the experimental package; the fake service here is
* a structural double whose method names and rejection shapes mirror
* `@deepseek-ai/dsh-experimental-agent-team@0.1.7-rc.1`'s `TeamService`.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildTeamsApi } from '../src/team-routes.ts'
import type {
Context,
SidebarTeamMemberView,
SidebarTeamTaskView,
} from '../src/context-types.ts'
/** One member row. */
function member(over: Partial<SidebarTeamMemberView> = {}): SidebarTeamMemberView {
return {
id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [], ...over,
}
}
import type { Context, SidebarTeamTaskView } from '../src/context-types.ts'
/** One task row. */
function task(over: Partial<SidebarTeamTaskView> = {}): SidebarTeamTaskView {
@@ -41,116 +39,144 @@ function agentsWithRoot(): { get(id: string): { id: string } | undefined } {
return { get: (id: string) => (id === 'root' ? { id: 'root' } : undefined) }
}
describe('teams.view route', () => {
it('reports available:false when the experimental layer is absent', async () => {
const api = buildTeamsApi(ctxWith(undefined, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: false })
})
it('reports team:null when the root agent is not live', async () => {
const teams = { tryMembership: vi.fn(), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, { get: () => undefined }))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
expect(teams.remoteView).not.toHaveBeenCalled()
})
it('reports team:null when tryMembership misses', async () => {
const teams = { tryMembership: vi.fn(async () => undefined), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
expect(teams.remoteView).not.toHaveBeenCalled()
})
it('reports team:null when tryMembership throws (stale member)', async () => {
const teams = { tryMembership: vi.fn(async () => { throw new Error('stale') }), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
})
it('returns the service view on a membership hit', async () => {
const view = { members: [member()], tasks: [task()] }
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteView: vi.fn(async () => view),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: view })
})
it('maps a remoteView rejection to a 400 team-error', async () => {
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteView: vi.fn(async () => { throw new Error('replay failed') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 400 }),
)
})
})
/** The service's typed rejection (a `TeamError` / `HarnessError` subclass). */
function teamError(code: string, message: string): Error & { code: string } {
return Object.assign(new Error(message), { code, name: 'TeamError' })
}
describe('teams.taskCreate route', () => {
it('passes the shaped request through and returns the union', async () => {
it('passes the shaped request through and returns the committed view', async () => {
const created = task({ id: 'task-9', subject: '新任务' })
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteCreateTask: vi.fn(async () => ({ ok: true, value: created })),
tryMembership: vi.fn(() => ({ role: 'lead' })),
createTask: vi.fn(async () => created),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
const result = await api.taskCreate({
rootSessionId: 'root', subject: '新任务', description: '详', blockedBy: ['task-1'],
})
expect(result).toEqual({ ok: true, value: created })
expect(teams.remoteCreateTask).toHaveBeenCalledWith(
expect(teams.createTask).toHaveBeenCalledWith(
{ id: 'root' },
{ subject: '新任务', description: '详', blockedBy: ['task-1'] },
)
})
it('degrades to 503 on a mutation when the layer is absent', async () => {
it('degrades to 503 when the experimental layer is absent', async () => {
const api = buildTeamsApi(ctxWith(undefined, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 503 }),
)
})
it('degrades to 404 when the root leads no team', async () => {
const teams = { tryMembership: vi.fn(async () => undefined) }
it('degrades to 404 when the root agent is not live in this process', async () => {
const teams = { tryMembership: vi.fn(() => ({ role: 'lead' })), createTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, { get: () => undefined }))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 404 }),
)
expect(teams.createTask).not.toHaveBeenCalled()
})
it('degrades to 404 when tryMembership misses (root leads no team)', async () => {
const teams = { tryMembership: vi.fn(() => undefined), createTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 404 }),
)
})
it('rejects a missing subject as bad-request', async () => {
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })) }
it('maps a throwing tryMembership to 404 (stale member identity)', async () => {
const teams = {
tryMembership: vi.fn(() => { throw new Error('stale') }),
createTask: vi.fn(),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 404 }),
)
})
it('rejects a missing subject as bad-request before calling the service', async () => {
const teams = { tryMembership: vi.fn(() => ({ role: 'lead' })), createTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining({ code: 'bad-request' }),
)
expect(teams.createTask).not.toHaveBeenCalled()
})
it('maps a service rejection to a 400 team-error', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
createTask: vi.fn(async () => { throw teamError('TEAM_TASK_LIMIT', 'task limit reached') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 400, message: 'task limit reached' }),
)
})
})
describe('teams.taskUpdate route', () => {
it('passes the CAS request through, conflict union untouched', async () => {
const conflict = { ok: false, error: { code: 'team-task-conflict', message: 'stale revision' } }
it('passes the CAS request through and returns the committed view', async () => {
const done = task({ status: 'completed', revision: 2 })
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteUpdateTask: vi.fn(async () => conflict),
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => done),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
const result = await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 3, action: 'complete',
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 1, action: 'complete',
})
expect(result).toEqual(conflict)
expect(teams.remoteUpdateTask).toHaveBeenCalledWith(
expect(result).toEqual({ ok: true, value: done })
expect(teams.updateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 3, action: 'complete' },
{ taskId: 'task-1', expectedRevision: 1, action: 'complete' },
)
})
it('validates taskId / expectedRevision / action', async () => {
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })), remoteUpdateTask: vi.fn() }
it('maps a stale revision to 409 team-conflict (0.1.7 throws instead of returning)', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => {
throw teamError('TEAM_TASK_STALE_REVISION', 'stale team task "task-1" revision 1; current revision is 2')
}),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 1, action: 'complete',
})).rejects.toThrowError(expect.objectContaining({ code: 'team-conflict', status: 409 }))
})
it('keeps every other rejection on team-error, never on team-conflict', async () => {
for (const code of ['TEAM_TASK_NOT_FOUND', 'TEAM_NOT_MEMBER', 'TEAM_DISPOSED']) {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => { throw teamError(code, 'nope') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({
rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'complete',
})).rejects.toThrowError(expect.objectContaining({ code: 'team-error', status: 400 }))
}
})
it('reports an untagged rejection as a 400 with its own message', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => { throw new Error('board exploded') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({
rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'complete',
})).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 400, message: 'board exploded' }),
)
})
it('validates taskId / expectedRevision / action before calling the service', async () => {
const teams = { tryMembership: vi.fn(() => ({ role: 'lead' })), updateTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({ rootSessionId: 'root', expectedRevision: 1, action: 'complete' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
@@ -160,21 +186,44 @@ describe('teams.taskUpdate route', () => {
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
await expect(api.taskUpdate({ rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'nuke' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
expect(teams.remoteUpdateTask).not.toHaveBeenCalled()
expect(teams.updateTask).not.toHaveBeenCalled()
})
it('forwards optional edit fields when present', async () => {
it('forwards optional edit fields only when present', async () => {
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteUpdateTask: vi.fn(async () => ({ ok: true, value: task() })),
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => task()),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer',
})
expect(teams.remoteUpdateTask).toHaveBeenCalledWith(
expect(teams.updateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer' },
)
teams.updateTask.mockClear()
await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 2, action: 'edit',
subject: 'S', description: 'D', blockedBy: ['task-2'], writeScopes: ['src/'],
})
expect(teams.updateTask).toHaveBeenCalledWith(
{ id: 'root' },
{
taskId: 'task-1', expectedRevision: 2, action: 'edit',
subject: 'S', description: 'D', blockedBy: ['task-2'], writeScopes: ['src/'],
},
)
})
it('never leaks the 0.1.6 remote vocabulary into the service call', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => task()),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await api.taskUpdate({ rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'claim' })
expect(Object.keys(teams)).not.toContain('remoteUpdateTask')
expect(Object.keys(teams)).not.toContain('remoteView')
})
})