From d8bc6566df679d517b800ed57d463b7a3ac7370f Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 27 Sep 2026 16:52:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E9=80=82=E9=85=8D=20DSH=200.1.7?= =?UTF-8?q?=20=E7=9A=84=20Agent=20Teams=EF=BC=88=E8=AF=BB=20projection=20+?= =?UTF-8?q?=20=E5=86=99=E8=B7=AF=E7=94=B1=E5=AF=B9=E6=8E=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=9C=8D=E5=8A=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 团队功能在 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) --- ...09-14-tasks-graph-workflow-teams-design.md | 12 + ...-09-27-agent-teams-dsh-0.1.7-adaptation.md | 121 ++++++ src/client/SubagentView.tsx | 76 ++-- src/client/TaskWindow.tsx | 351 +++++++++--------- src/client/TasksPopovers.tsx | 10 +- src/client/TeamBoard.tsx | 54 ++- src/client/api.ts | 57 ++- src/client/locales-ar.ts | 4 + src/client/locales-de.ts | 4 + src/client/locales-fr.ts | 4 + src/client/locales-hi.ts | 4 + src/client/locales-id.ts | 4 + src/client/locales-it.ts | 4 + src/client/locales-ja.ts | 4 + src/client/locales-ko.ts | 4 + src/client/locales-nl.ts | 4 + src/client/locales-pl.ts | 4 + src/client/locales-pt.ts | 4 + src/client/locales-ru.ts | 4 + src/client/locales-sv.ts | 4 + src/client/locales-th.ts | 4 + src/client/locales-tr.ts | 4 + src/client/locales-vi.ts | 4 + src/client/locales-zh-HK.ts | 4 + src/client/locales-zh-MO.ts | 4 + src/client/locales-zh-TW.ts | 4 + src/client/locales.ts | 8 + src/client/tasks-graph.module.css | 36 +- src/client/tasks-model.ts | 30 +- src/client/tasks-shared.tsx | 72 +++- src/client/team-projection.ts | 122 ++++++ src/context-types.ts | 75 +++- src/index.ts | 6 +- src/team-routes.ts | 149 ++++---- src/wire.ts | 1 + tests/subagent-jobs-view.spec.tsx | 3 - tests/tasks-model.spec.ts | 55 ++- tests/tasks-page.spec.tsx | 267 +++++++++---- tests/team-projection.spec.ts | 120 ++++++ tests/team-routes.spec.ts | 227 ++++++----- 40 files changed, 1352 insertions(+), 576 deletions(-) create mode 100644 docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md create mode 100644 src/client/team-projection.ts create mode 100644 tests/team-projection.spec.ts diff --git a/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md b/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md index 17830d5..03d0462 100644 --- a/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md +++ b/docs/plans/2026-09-14-tasks-graph-workflow-teams-design.md @@ -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 例断言失败)。 diff --git a/docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md b/docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md new file mode 100644 index 0000000..d8ae2de --- /dev/null +++ b/docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md @@ -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 "" +``` + +`/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 任务),未做虚拟化。 diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 00ba8f6..b3c36d2 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -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(undefined) - const [epoch, setEpoch] = useState(0) - useEffect(() => { setView(undefined) }, [rootId]) - const poll = useCallback(async (signal: AbortSignal): Promise => { - 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 ( - @@ -529,15 +510,14 @@ export function SubagentView(props: { disabled={rootId === undefined} onClick={() => { if (rootId !== undefined) refresh(rootId) - team.refresh() }} /> - {rootId !== undefined && teamView?.available === true && teamView.team !== null && ( + {rootId !== undefined && team !== undefined && ( { setTeamBoardCollapsed(current => !current) }} diff --git a/src/client/TaskWindow.tsx b/src/client/TaskWindow.tsx index 560b26f..f53b2aa 100644 --- a/src/client/TaskWindow.tsx +++ b/src/client/TaskWindow.tsx @@ -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 ( <> -
- - {task.subject} - - {t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')} - -
{t('teamTaskOwner')} {task.ownerName ?? t('teamTaskUnowned')} @@ -175,7 +168,7 @@ function TaskEditBody(props: { }): ReactNode { const subjectHintId = useId() return ( -
+
diff --git a/src/client/TeamBoard.tsx b/src/client/TeamBoard.tsx index 559b38a..65105f2 100644 --- a/src/client/TeamBoard.tsx +++ b/src/client/TeamBoard.tsx @@ -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) }} > - + {task.subject} {task.ownerName !== undefined && ( {task.ownerName} )} - - {t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')} - + {taskStatusLabel(task)} members.filter(member => member.role === 'teammate'), [members]) return ( -
+