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:
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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: 'حذف',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'हटाएँ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '削除',
|
||||
|
||||
@@ -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: '삭제',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ń',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Удалить',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ลบ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '刪除',
|
||||
|
||||
@@ -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: '刪除',
|
||||
|
||||
@@ -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: '刪除',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SidebarErrorCode =
|
||||
| 'git-error'
|
||||
| 'job-error'
|
||||
| 'team-error'
|
||||
| 'team-conflict'
|
||||
| 'sidechat-error'
|
||||
| 'subagents-unavailable'
|
||||
| 'settings-rejected'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user