From e9569eb7a5c1e44a37c12dcb9c8bc9471728e1c5 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:20:08 +0800 Subject: [PATCH 01/23] docs: R17 gap review report (42 confirmed) + fix plan with fork decisions Co-Authored-By: Claude Fable 5 --- .../05-gap-review-2026-07-16.md | 104 ++++++++++++++++++ r16-workbench-redesign/06-gap-fix-plan.md | 23 ++++ 2 files changed, 127 insertions(+) create mode 100644 r16-workbench-redesign/05-gap-review-2026-07-16.md create mode 100644 r16-workbench-redesign/06-gap-fix-plan.md diff --git a/r16-workbench-redesign/05-gap-review-2026-07-16.md b/r16-workbench-redesign/05-gap-review-2026-07-16.md new file mode 100644 index 00000000..0604a593 --- /dev/null +++ b/r16-workbench-redesign/05-gap-review-2026-07-16.md @@ -0,0 +1,104 @@ +# WorkHub 7 维度系统性审查 — 综合报告 + +## 一、四个点名问题的直接回答 + +### 1. 项目群怎么"拉人"? +**唯一入口是建群那一刻的一次性多选,建成后无法再加人。** 桌面 `rail.ts` 的新建协同弹窗里勾选成员,候选来自当前工作区花名册 `workspace_members.items`(非项目专属名单)。而 `conversation_participants` 表全代码库只有两处 `insert`——`createCollab` 建群、`openOrCreateDm` 建 DM,都只在创建事务里执行一次;没有任何 `addParticipant`,`/conversations/:id/participants` 路由只有 GET 没有 POST。**最大缺口:想把新人拉进已有群,产品里唯一办法是另建一个新会话。** 且弹窗文案明写"成员邀请和权限之后在项目设置里调",但项目设置页整页只有 AI 治理内容,根本没有成员 section——一句无法兑现的承诺。 + +### 2. 群成员管理现状? +**"项目成员"这个概念目前不存在。** 三层数据现状:`workspace_members` 是全工作区花名册(scope 硬编码 `workspace`,全员都能被拉进任意项目的群);`conversation_participants` 是每个会话创建时冻结的快照;项目层 `ProjectHomePageVM` 只有 `owner_label`,没有成员列表字段,产品里没有任何地方能看到"这个项目有哪些人"。**管理动作几乎全断:** 工作区邀请链路(`POST /invites`→`/invites/accept`)后端真实可跑但双端零 UI 入口;移出工作区的 `memberships.softDelete` 只有 QA 脚本调用、无路由暴露;退群/移出会话、改成员角色的能力完全不存在。一旦被拉进某个群,用户无法自行退出,管理员也无法移出或降权。 + +### 3. 决策收件箱触发链? +**数据源(`GET /attention`)本身是健全的四源聚合**:escalations(含 budget)、memory-conflicts、approvals、proposals.listReviewableForUser(含任务计划 review)。schema 里另有 `clarification`/`knowledge_result` 两 kind 无真实生产者(死分支)。**真正的病灶在"刷新触发不统一"**:只有 approval 创建和 agent run 终态会发 `notification.created` 让桌面角标实时刷新;而 escalation、budget、sync_conflict 三类要么根本没进用户个人 topic(`escalation.opened` 只发到 workitem/admin 话题),要么进了 `/me` 流却被桌面 `shell.ts:629` 按 `event.type !== "notification.created"` 硬过滤丢弃,只能靠 30s 轮询兜底。**且轮询只更新角标数字,已打开的收件箱面板列表不会跟着重渲——用户正开着收件箱时,新决策落定角标会变但眼前卡片不出现。** 另有一处 execute 类行动卡找不到负责人时只把行标 `escalated` 却从不落 `ai_decisions` 行,导致这类失败彻底不进队列、也无恢复入口。 + +### 4. 军团状态? +**当前军团总览与会话情境面板都是只读展示,且实时性有系统性盲区。** 状态覆盖 AgentRunStatus 全 6 值,但视觉归并成 run/wait/done/fail 四桶,`escalated`(卡住等人拍板)和 `queued`(正常排队)同归"等待"桶,视觉上分不出谁紧急。**核心缺陷:军团情境面板对 `agent_run` 生命周期事件零 SSE 消费**——`started/step/failed/escalated` 只发到 `topics.run`/`topics.workitem`,而面板订阅的会话流唯一刷新触发器是 `action_card.updated`(仅人工决策时发出)。结果一个纯执行中、未触发任何人工决策的 run,状态会长期停在打开会话那一刻的旧快照。交互层面:总览卡片 `interactive:false` 点不动、无法跨项目下钻;军团卡/详情没有取消(abort)入口(后端 `/abort` 已实现但 UI 零调用);后台任务区契约锁死永远空且已移除渲染(真实的 pulse/proactive 系统一律不出现)。 + +**四问共性:后端能力普遍完整,断点集中在"前端无入口"与"事件推送到不了该刷新的那条连接"两类。** + +--- + +## 二、确认发现分层列表(已去重合并,标 kind) + +### 高危 [高] +1. **[missing]** 建群后无法加人——`conversation_participants` 只在创建瞬间写一次,成员集合永久冻结,拉新人只能另建会话。 +2. **[ux]** 新建项目弹窗承诺"成员邀请权限之后在项目设置里调",但项目设置页整页只有 AI 治理、无任何成员入口,是无法兑现的 UI 承诺。 +3. **[missing]** "项目成员"非独立概念——建群候选是整个工作区花名册,项目主页只有 `owner_label`、从不暴露成员列表。 +4. **[missing]** execute 类行动卡找不到负责人时只标 `escalated` 却不落 `ai_decisions` 行,彻底不进决策收件箱、也无恢复入口(decide 类却会落)。 +5. **[dataflow]** `escalation.opened` 只发到 workitem/admin 话题、从不发 `topics.user`,桌面无任何推送通道能实时感知升级,web 也只有恰好停在该工作项详情页才收得到。*(合并原 inbox-triggers + dataflow-breaks 两条同根发现)* +6. **[dataflow]** budget/sync_conflict 事件到了 `/me` 流却被 `shell.ts:629` 硬过滤丢弃,"待拍板"角标不实时增长;且同一 `/me` 话题在桌宠气泡通道能正常出卡,两个消费者行为不一致。*(合并 shell.ts 硬过滤两条)* +7. **[dataflow]** 军团情境面板对 `agent_run` 生命周期事件零 SSE 消费,执行中的 run 状态在面板里长期停在旧快照,直到用户离开会话再回来。 +8. **[missing]** 军团后台任务区契约锁死为永远空 `not_yet_available` 且已停止渲染,而真实的 pulse/proactive/ddl-chase/care-scan 系统一律不出现在面板。 +9. **[be_no_fe]** E3 项目规划草案(起草/列表/详情/批准/驳回/物化五端点)后端全通,但双端只有桌面日程栏一处只读且过滤掉 `pending_review`,用户无法发起/发现/审批任何草案。 +10. **[be_no_fe]** 关怀消息 opt-out 开关(`care_messages_enabled` GET/PUT)已通且 care-scan 在跑,但关怀消息永不落通知行,web 硬编码静音清单和桌面"静音此类"按钮两端都够不到这个开关。 +11. **[be_no_fe]** 桌面 Spotlight 通知面板不读 `actions/target_href/conversation_id`,`data-notif-id` 写出却无处理器读取,点击通知本身无任何跳转(web 端完整渲染)。 +12. **[dataflow]** 变更编辑器合并撞真实冲突时,把所有 409 一律映射成通用"状态变了刷新看看"文案,不分支处理 `merge_conflict`,丢了 Spotlight 已有的逐冲突解决面板。 +13. **[missing]** 变更编辑器一次只挂一份 diff,一个提议改多文件时要关闭编辑器回文件列表逐个点开,编辑器内无文件列表/上一个-下一个导航。 + +### 中危 [中] +14. **[fe_no_be]** 工作区邀请链路后端真实可跑,但 web/desktop 两端 UI 都无任何请求 `/invites` 或"邀请成员"按钮。 +15. **[be_no_fe]** 工作区成员移出 `workspaceMemberships.softDelete` 有实现但零路由暴露(唯一调用是 QA 脚本),也没有改成员角色的路由。 +16. **[missing]** 无"退群/离开会话"能力——`conversation_participants` 无任何 delete 路径,被误拉进群也无法自行退出,群主也无法移出。 +17. **[ux]** 已打开的决策收件箱面板不随 SSE/轮询自动刷新列表,只刷新 rail 角标数字,需切走再切回才看到新卡片。 +18. **[dataflow]** 会话内"待你拍板 N 件"digest 卡只数 `approval_requests`(排除 escalation/budget/sync/proposal 及无 work_item 的个人审批),系统性小于收件箱真实条数。 +19. **[missing]** 军团卡/详情无取消(abort)入口——后端 `/agent-runs/:id/abort` 与 api-client 已就绪但 UI 零调用,卡住/跑偏的 run 无法就地取消。 +20. **[ux]** `escalated` 在军团卡上与 `queued` 视觉同桶(都归 wait),区分不出紧急度,详情页也无跳转到 attention 处理界面的链接。 +21. **[missing/ux]** 军团总览卡片 `interactive:false` 不可点,无法跨项目下钻到某次执行详情,只能先切项目再在右栏找同一条 run。*(合并 army + ux 两条同一发现)* +22. **[dataflow]** 四阶梯 DDL 通知(`work_item.due_soon/overdue/escalated_ddl/needs_owner`)与 `conversation.message` 未接 `notificationTypeLabel` 映射,中文界面裸显大写英文 token。 +23. **[be_no_fe]** web 静音清单 `MUTABLE_NOTIFICATION_TYPES` 未随 R15 新类型同步(违反代码自留注释),web 用户无法关闭这批新通知。 +24. **[be_no_fe]** 项目自定义指令 `instructions_md`(会注入所有 Cuu 回复与 agent-run system prompt)只接了桌面设置,web 完全无入口,web 协作者看不到也改不了。*(合并 be-orphans + dataflow 两条)* +25. **[missing]** 13 个声明事件类型在生产路径从未 publish(仅测试夹具引用),其中 4 个还是桌面 per-run 监听列表里的死订阅。 +26. **[dataflow]** 日程视图对无 `due_at` 的工作项 `if(!due) continue` 静默丢弃,看板/时间线都诚实标"未定期"展示,同批数据在日程里彻底消失、易让人误判本周无事。 +27. **[missing]** 看板四列无按人筛选/分组、无关键词搜索,多人协作时看不了只属于某人的卡片。 +28. **[missing]** 日程只有单周视图,看两周后截止要连点两次"下一周",无月视图或"未来 N 周"一览。 +29. **[missing]** 决策收件箱是扁平流,无按类型/项目筛选 tab、无批量通过/打回。 +30. **[dataflow]** 全局搜索命中聊天消息时后端 `deep_link` 带 `seq`,但桌面 stash 的 `PendingWorkbenchDeepLinkTarget` 无 `seq` 字段,只能跳到会话跳不到具体消息。 + +### 低危 [低] +31. **[missing]** `knowledge_result`/`clarification` 两 AttentionItem kind 在 schema/UI 齐备但无真实生产者(死分支)。 +32. **[missing]** 军团总览无自动刷新(设计取舍),跨项目 run 状态变化零主动提示,全靠手动点刷新。 +33. **[dataflow]** run 详情"返回"只切缓存 vm 不重拉,停留期间状态变化后返回列表仍是旧数据。 +34. **[missing]** spotlight labels 残留一条不可达的 `budget_exhausted` 死态标签(契约已无此值)。 +35. **[ux]** 桌面静音操作把 `item.type` 原始串直接拼进中文句子,用户看到"已静音「work_item.due_soon」类通知"这类裸字符串。 +36. **[missing]** 时间线 OKR 徽标只显示裸目标 UUID,`listObjectiveTitlesByIds` 已有但只在成本页内部用,未开放公共端点给时间线。 +37. **[ux]** 会话 tab 条无中键关闭、无键盘关闭当前 tab 的快捷键,只能点 X。 +38. **[missing]** 工作台内几乎无应用级快捷键,只有全局 Cmd+K 和聊天框内 Enter/Esc,无 Cmd+W 关 tab、无栏目切换。 + +--- + +## 三、修复批建议(按"无重叠可并行"分批) + +> 文件所有权原则:同一批独占一组文件,批间尽量不碰同一文件。`shell.ts`、`attention.ts` 是两处跨批热点,已在下方标注归属与串行依赖。量级:S=1-2 天 / M=3-5 天 / L=1-2 周+。 + +### 批 1 — 群成员 / 项目成员模型与入口(独占 conversation-participants、invites、memberships、项目设置成员区) +包含: #1 建群加人(补 POST + workspace-membership 校验 + 99 人上限)、#16 退群/移出(补 DELETE)、#14 邀请前端入口、#15 成员移出+角色更新路由、#2 项目设置补成员 section 或删弹窗承诺、#3 项目成员模型。 +**依赖: #3(是否新增"项目团队"层,而非复用工作区花名册做群聊候选)需产品先拍板——它是全批的分叉点。** 拍板前 #14/#15/#16/#2 可先做(纯路由+UI 补齐,互不重叠)。 +**量级: 补路由与入口 M;若建项目成员模型 L。** + +### 批 2 — 决策事件推送链 + 收件箱刷新(独占 shell.ts、conversation-observer、human-reserved-guard、inbox/view、approval-digest) +包含: #4 execute 卡失败也 `createEscalationEvent`、#5 `escalation.opened` 增发 `topics.user`、#6 放宽 `shell.ts:629` 过滤为决策类白名单(顺带修 me-stream 与桌宠通道行为一致)、#17 给 InboxViewHandle 加 `refresh()` 并在 `centerTab==='inbox'` 时随角标一并调用、#18 digest 改"至少 N 件"或扩源、#25 死事件类型清理、#31 死 kind 清理/补生产者。 +**依赖: 独占 shell.ts,批 5 的 tab 关闭/快捷键(#37/#38)也在 shell.ts,并入本批做以免冲突(见下)。** +**量级: M。** + +### 批 3 — 军团面板实时性与操作(独占 army/*、chat/view.ts 流转发) +包含: #7 军团面板订阅 run 生命周期事件(或让 emitRunEvent 双发会话话题)、#19 abort 入口、#20 `escalated` 单独徽标+跳 attention 链接、#21 总览卡片可点下钻、#32 总览刷新提示、#33 返回重拉、#34 删 `budget_exhausted` 死标签、#8 后台任务区。 +**依赖: #8(接真实 proactive 数据源 vs 保持锁死空)需产品拍板;其余可直接做。#7 与批 2 都涉 SSE 但文件不同,可并行。** +**量级: M。** + +### 批 4 — 后端能力前端接线(独占 web route-components、通知偏好面板、项目设置、timeline) +包含: #9 E3 草案双端入口(项目主页/审批队列露 `pending_review` 计数 + 桌面起草/审批按钮)、#10 通知偏好加"Cuu 关怀消息"开关直连 `care_messages_enabled`、#24 web 项目设置补 instructions 区块、#22 补 `notificationTypeLabel` 映射、#23 同步 `MUTABLE_NOTIFICATION_TYPES`、#35 桌面静音改用标签映射、#36 开 OKR 标题只读端点或 join 进 timeline VM。 +**依赖: #22/#23/#35 共用标签映射逻辑(可抽共享函数),批内串行即可;与其他批无重叠。E3(#9)是本批最大件。** +**量级: M-L(#9 拉大)。** + +### 批 5 — 编辑器与看板/日程/搜索 UX(独占 editor/*、files/panel、schedule/*、kanban/*、spotlight attention.ts/search.ts、pending-deep-link) +包含: #12 编辑器 merge 命中 `merge_conflict` 复用 `proposalMergeConflictHtml`、#13 编辑器同提议多文件切换条、#26 日程加"无日期"列/计数、#27 看板顶部 assignee+关键词纯前端过滤、#28 日程月/周切换 chip、#29 收件箱按 kind 筛选 chip + 粗粒度批量、#30 `PendingWorkbenchDeepLinkTarget` 加 `seq` 透传并按 seq 定位。 +**依赖: #29 改 `spotlight/views/attention.ts`,批 2 的 #17 给 `inbox/view.ts` 加 refresh() 也间接依赖 mountAttentionInbox——建议批 2 先落 refresh() 钩子,批 5 在其上叠筛选,避免 attention.ts 冲突。其余项互不重叠。** +**量级: M(#12/#13 各为一个高价值中件)。** + +**并行调度建议:** 批 1(等 #3 拍板)、批 3(等 #8 拍板)、批 4 可即刻并行发车;批 2 独占 shell.ts、优先落 refresh() 钩子;批 5 在批 2 的 attention 钩子之后串入,其余内容与批 2 并行。两处产品拍板(项目成员模型 / 后台任务区是否接真数据)是全局关键路径,建议先决。 + +--- + +## 四、Dubious 清单(供抽查) + +**本轮无 dubious 项。** 对抗驳斥阶段没有留下任何存疑发现,上述 38 条(去重前 confirmed 全量)均判定为 real,证据行号已逐条复核可复现。 \ No newline at end of file diff --git a/r16-workbench-redesign/06-gap-fix-plan.md b/r16-workbench-redesign/06-gap-fix-plan.md new file mode 100644 index 00000000..e40acb00 --- /dev/null +++ b/r16-workbench-redesign/06-gap-fix-plan.md @@ -0,0 +1,23 @@ +# R17 卡点修复计划(依据 05-gap-review 42 条确认发现) + +2026-07-16 用户令:文档先行→流水线推进(opus/sonnet 子 agent)→全部完成后推 GitHub main。发现编号引用 05-gap-review-2026-07-16.md。 + +## 两个分叉点拍板(负责人代决,理由留档,可逆) + +**拍板 A(#3 项目成员模型):不新建「项目团队」数据层。** 理由:当前产品单工作区形态下,工作区花名册≈项目候选池,新建层收益有限而成本 L 级;先把管理动作补齐(加人/退群/移出/邀请/成员移出路由),「项目成员」以「会话参与者的可管理化」落地。若未来多项目隔离需求变强再建层,本批的参与者管理端点可完整复用。 + +**拍板 B(#8 军团后台任务区):接真数据。** 理由:批 D 的 pulse/追DDL/关怀系统真实在跑却完全不可见,与用户对"军团状态"的关注直接冲突;接 pulse stats + 最近 proactive intents 展示,把主动性系统摆上台面。 + +## 批次与文件所有权(批 1-4 即刻并行,批 5 等批 2 的 refresh 钩子) + +| 批 | 范围(发现#) | 独占文件域 | 工人 | +|---|---|---|---| +| G1 群成员管理 | #1 加人端点+UI/#16 退群移出/#14 邀请 UI 接线/#15 成员移出路由/#2 项目设置成员区+兑现弹窗承诺/#3 按拍板 A 落注释 | conversations participants 路由与服务/invites/memberships 路由/settings 成员区/chat 成员条 | opus | +| G2 决策推送链 | #4 execute 卡失败落 escalation/#5 escalation.opened 进 user topic/#6 shell.ts:629 白名单放宽/#17 收件箱 refresh() 自刷/#18 digest 扩源/#25#31 死事件死 kind 清理/顺带 #37#38 tab 快捷键(shell.ts 独占) | shell.ts/inbox/observer/human-reserved-guard/approval-digest/escalations 发布处 | opus | +| G3 军团实时性 | #7 run 生命周期进面板/#19 abort 入口/#20 escalated 徽标+跳转/#21 总览下钻/#32 过期提示/#33 返回重拉/#34 死标签/#8 后台任务区接真(拍板 B,含 pulse stats 只读端点) | army/*/agent-runs 事件发布处/pulse stats 新端点 | opus | +| G4 后端接线 | #9 E3 草案双端入口(最大件)/#10 关怀开关 UI/#22 通知标签映射/#23 web 静音清单/#35 桌面静音文案/#36 OKR 标题/#24 web 指令入口 | web route-components/通知偏好双端/timeline VM 小 additive/plan-drafts UI | opus | +| G5 UX 收尾 | #12 编辑器 merge_conflict 面板/#13 多文件切换/#26 日程无日期列/#27 看板筛选/#28 月周切换/#29 收件箱筛选+批量/#30 deep-link seq | editor/files/schedule/kanban/spotlight attention(承批 2 钩子)/pending-deep-link | 等批 2 | + +主流程:各批施工→负责人逐单审查+复验→合入 r15/wave1→全部收口跑全量集成+本地 smoke→推远端→PR CI 全绿→并 main。PR#8(R15+R16 主体)CI 重跑中,绿即先并;修复批走后续 PR。 + +低危未入批(#31 部分/#34 已并入;其余低危如 tab 中键#37-38 并入 G2):全部覆盖。fe-orphans 维度 finder 当轮爆掉,其领地由 G1-G5 的"不假接线"纪律兜底,暂不补扫。 From 556958274e26143e61586914cafdb64de5f717ad Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:42:42 +0800 Subject: [PATCH 02/23] feat(g4): notification type labels + care opt-out + OKR titles wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R17 G4 后端能力前端接线(非 E3/指令的部分): - #22 通知类型标签: 抽 notificationTypeLabel 到 packages/ui/i18n.ts(双端共享), 补 R15 四阶梯 DDL(work_item.due_soon/overdue/escalated_ddl/needs_owner)与 会话消息(conversation.message/mention)的双语映射。route-components 改为 import。 - #23 web 静音清单: MUTABLE_NOTIFICATION_TYPES 同步 R15 真正经 isMutedForRecipient 静音的类型(三档 DDL + conversation.message/mention);needs_owner 主通道是 action_card 决策卡,刻意不列,免「勾了却照发」。 - #35 桌面静音文案: 「静音此类」按钮/toast/已静音清单改用共享标签映射,不再裸拼 type 串。 - #10 关怀开关: 通知偏好加「关怀问候/Cuu 关怀消息」开关直连 care_messages_enabled GET/PUT(web mute 面板 + 桌面通知视图各一处);api-client get/set 带 care 字段; web 文案避开 Cuu 品牌词(smoke 门)。 - #36 OKR 标题: timeline VM additive 补 objective_titles(服务端 join listObjectiveTitlesByIds,未命中回落 id);web/桌面时间线 OKR pill 悬停显目标名。 全仓 typecheck 绿;ui/desktop/api 相关单测绿(含新增 objective_titles 用例)。 Co-Authored-By: Claude Fable 5 --- apps/api/src/project-timeline-pages.test.ts | 38 ++++++++ .../src/services/project-timeline-pages.ts | 33 ++++++- apps/desktop-webview/src/main.test.ts | 4 +- .../src/spotlight/views/dashboards.ts | 66 +++++++++++-- .../src/workbench/timeline/render.ts | 8 +- apps/web/src/browser.ts | 24 ++++- apps/web/src/main.test.ts | 4 +- packages/api-client/src/client.ts | 11 ++- packages/api-client/src/types.ts | 9 +- packages/contracts/src/pages.ts | 6 +- packages/ui/src/gold-path/route-components.ts | 93 +++++++------------ packages/ui/src/i18n.ts | 73 +++++++++++++++ 12 files changed, 283 insertions(+), 86 deletions(-) diff --git a/apps/api/src/project-timeline-pages.test.ts b/apps/api/src/project-timeline-pages.test.ts index c7cffbd2..41996e59 100644 --- a/apps/api/src/project-timeline-pages.test.ts +++ b/apps/api/src/project-timeline-pages.test.ts @@ -169,6 +169,44 @@ test("timeline assembles milestones, blocking closure, overdue_blocking, assigne // 关键路径:B 上榜 blocking + overdue_blocking;A(阻塞 0)不在。 assert.deepEqual(vm.critical.blocking, [{ work_item_id: WI_B, blocks_count: 2 }]); assert.deepEqual(vm.critical.overdue_blocking, [{ work_item_id: WI_B, blocks_count: 2 }]); + // G4 #36:未注入 objectives 依赖时只带 objective_ids、不带 objective_titles(回落显裸 id)。 + assert.equal(a.objective_titles, undefined); +}); + +test("G4 #36: objective_titles joined when objectives dep is provided; falls back to id when a title is missing", async () => { + const OBJ2 = "70000000-0000-4000-8000-0000000000b2"; + const items = [item({ id: WI_A, code: "ALP-1", status: "ai_working", dueAt: FUTURE })]; + const service = createProjectTimelinePageService({ + repo: fakeRepo({ + listTimelineWorkItems: async () => items, + listObjectiveIdsByWorkItemIds: async () => new Map([[WI_A, [OBJ, OBJ2]]]) + }), + projectRepo: projectRepo(projectRow()), + // 只给 OBJ 有名字;OBJ2 没命中 → 回落成裸 id,保证与 objective_ids 等长。 + objectives: { listObjectiveTitlesByIds: async () => new Map([[OBJ, "把交付周期压到两周"]]) }, + now: () => NOW + }); + const vm = await service.page({ actor: actor(), projectId: PROJ }); + const a = vm.items.find((i) => i.id === WI_A)!; + assert.deepEqual(a.objective_ids, [OBJ, OBJ2]); + assert.deepEqual(a.objective_titles, ["把交付周期压到两周", OBJ2]); +}); + +test("G4 #36: objective_titles omitted when every title is missing (all fall back to id)", async () => { + const items = [item({ id: WI_A, code: "ALP-1", status: "ai_working", dueAt: FUTURE })]; + const service = createProjectTimelinePageService({ + repo: fakeRepo({ + listTimelineWorkItems: async () => items, + listObjectiveIdsByWorkItemIds: async () => new Map([[WI_A, [OBJ]]]) + }), + projectRepo: projectRepo(projectRow()), + objectives: { listObjectiveTitlesByIds: async () => new Map() }, + now: () => NOW + }); + const vm = await service.page({ actor: actor(), projectId: PROJ }); + const a = vm.items.find((i) => i.id === WI_A)!; + assert.deepEqual(a.objective_ids, [OBJ]); + assert.equal(a.objective_titles, undefined); }); test("timeline emits empty_state when no work items are visible", async () => { diff --git a/apps/api/src/services/project-timeline-pages.ts b/apps/api/src/services/project-timeline-pages.ts index ea4a7fd7..f30314c8 100644 --- a/apps/api/src/services/project-timeline-pages.ts +++ b/apps/api/src/services/project-timeline-pages.ts @@ -7,9 +7,11 @@ // escalate 文案带 blocks_count 的接缝(见 services/ddl-chase.ts 的注释与 listDependencyEdgesByProject 导出)。 import { computeBlockingCounts, + createObjectiveRepository, createProjectTimelineRepository, createWorkItemRepository, getSharedDatabaseClient, + type ObjectiveRepository, type ProjectTimelineRepository, type TimelineWorkItemRow, type WorkItemDataRepository, @@ -47,6 +49,9 @@ export type ProjectTimelinePageServiceDependencies = { "listActiveMilestonesByProject" | "listTimelineWorkItems" | "listDependencyEdgesByProject" | "listObjectiveIdsByWorkItemIds" >; projectRepo: Pick; + // G4 #36:OKR pill 悬停要显目标名。可选依赖——缺省时(旧测试不注入)VM 只带 objective_ids、不带 + // objective_titles,前端回落显裸 id(additive,不破坏既有行为)。 + objectives?: Pick; now?: () => Date; }; @@ -146,6 +151,21 @@ export function createProjectTimelinePageService( }) : new Map(); + // G4 #36:对本页出现的全部目标 id 批量取名(listObjectiveTitlesByIds,成本页同源),一次查询。 + // 缺省依赖或取名失败时 objectiveTitleById 为空 Map——逐项回落成 id,前端照旧显裸 id(不拖垮时间线)。 + const allObjectiveIds = [...new Set([...objectiveIdsByItem.values()].flat())]; + let objectiveTitleById = new Map(); + if (deps.objectives && objectiveWorkspaceId && allObjectiveIds.length > 0) { + try { + objectiveTitleById = await deps.objectives.listObjectiveTitlesByIds({ + workspaceId: objectiveWorkspaceId, + objectiveIds: allObjectiveIds + }); + } catch { + objectiveTitleById = new Map(); + } + } + const at = now(); const items: TimelineWorkItemVM[] = visibleItems.map((item) => { const blocksCount = blockingCounts.get(item.id) ?? 0; @@ -165,7 +185,15 @@ export function createProjectTimelinePageService( vm.assignee = { user_id: item.claimedByUserId, label: item.claimedByNickname }; } if (item.milestoneId) vm.milestone_id = item.milestoneId; - if (objectiveIds && objectiveIds.length > 0) vm.objective_ids = objectiveIds; + if (objectiveIds && objectiveIds.length > 0) { + vm.objective_ids = objectiveIds; + // G4 #36:与 objective_ids 一一对应的标题(未命中回落成 id,保证等长);只有真拿到名字时才带 + // objective_titles,全都回落成 id 时省略该字段(前端拿不到就照旧显裸 id,行为不变)。 + const titles = objectiveIds.map((id) => objectiveTitleById.get(id) ?? id); + if (titles.some((title, index) => title !== objectiveIds[index])) { + vm.objective_titles = titles; + } + } return vm; }); @@ -208,7 +236,8 @@ export function getDefaultProjectTimelinePageService(): ProjectTimelinePageServi defaultTimelinePageDbClient = getSharedDatabaseClient(); defaultProjectTimelinePageService = createProjectTimelinePageService({ repo: createProjectTimelineRepository(defaultTimelinePageDbClient.db), - projectRepo: createWorkItemRepository(defaultTimelinePageDbClient.db) + projectRepo: createWorkItemRepository(defaultTimelinePageDbClient.db), + objectives: createObjectiveRepository(defaultTimelinePageDbClient.db) }); } return defaultProjectTimelinePageService; diff --git a/apps/desktop-webview/src/main.test.ts b/apps/desktop-webview/src/main.test.ts index e8c6efe9..4512e4ca 100644 --- a/apps/desktop-webview/src/main.test.ts +++ b/apps/desktop-webview/src/main.test.ts @@ -272,10 +272,10 @@ function fakeClient(surface: DesktopTestSurface, session: SessionVM = intakeSess throw new Error("not needed"); }, async getNotificationPreferences() { - return { muted_notification_types: [] }; + return { muted_notification_types: [], care_messages_enabled: true }; }, async setNotificationPreferences() { - return { muted_notification_types: [] }; + return { muted_notification_types: [], care_messages_enabled: true }; }, async bootstrapProject() { return { diff --git a/apps/desktop-webview/src/spotlight/views/dashboards.ts b/apps/desktop-webview/src/spotlight/views/dashboards.ts index 2f314e0e..97bbbd36 100644 --- a/apps/desktop-webview/src/spotlight/views/dashboards.ts +++ b/apps/desktop-webview/src/spotlight/views/dashboards.ts @@ -15,7 +15,7 @@ import type { ProjectListItemVM, ScheduleBlockVM } from "@workhub/contracts"; -import { uiFormatCny } from "@workhub/ui"; +import { notificationTypeLabel, uiFormatCny } from "@workhub/ui"; import { escapeHtml, safeHref } from "@workhub/web-runtime"; import type { CommandId } from "../../command-palette.js"; @@ -648,7 +648,7 @@ function notificationRow(item: NotificationPageVM["items"][number], zh: boolean)
${escapeHtml([when, item.body ?? ""].filter(Boolean).join(" · "))}
${snoozeBtn} - + `; } @@ -664,7 +664,7 @@ export function createNotificationsView(): SpotlightCapabilityView { ctx.client.pages.notifications({ locale: ctx.locale }) as Promise, ctx.client.getNotificationPreferences().catch(() => { prefsFailed = true; - return { muted_notification_types: [] as string[] }; + return { muted_notification_types: [] as string[], care_messages_enabled: true }; }) ]); const rows = [...vm.buckets.needs_decision, ...vm.buckets.fyi, ...vm.buckets.done].slice(0, 12); @@ -674,15 +674,30 @@ export function createNotificationsView(): SpotlightCapabilityView { const muted = prefs.muted_notification_types; const mutedPanel = muted.length ? `

${escapeHtml(zh ? "已静音类型:" : "Muted types:")}

${muted - .map((type) => `
${escapeHtml(type)}
`) + .map((type) => `
${escapeHtml(notificationTypeLabel(type, zh))}
`) .join("")}
` : ""; const prefsFailedNote = prefsFailed ? `

${escapeHtml(zh ? "静音设置没读取到——为避免覆盖你已有的静音,静音按钮暂时不可用。" : "Couldn't load mute settings — mute buttons are locked so we don't overwrite what you saved.")}

` : ""; - const html = rows.length || muted.length + // G4 #10(关怀 opt-out):Cuu 关怀私聊开关(默认开)。始终渲染(是持久偏好,不随收件箱空/满而消失), + // 携带当前 muted 快照以便 PUT 时不误清静音;prefs 没读到时锁禁用+诚实提示(不装作已关/已开)。 + const careEnabled = prefs.care_messages_enabled !== false; + const careCard = `
+
+
+
${escapeHtml(zh ? "Cuu 关怀消息" : "Cuu care check-ins")}
+
${escapeHtml(zh ? "Cuu 会在你负荷高、或连续深夜工作时私下问候一句,可随时关闭。" : "Cuu privately checks in when your load is high or you're working late nights — turn it off anytime.")}
+
+ +
+
`; + const listHtml = rows.length || muted.length ? `
${prefsFailedNote}${rows.map((item) => notificationRow(item, zh)).join("")}${overflow}${mutedPanel}
` : emptyHtml("🔔", zh ? "通知箱是空的" : "Inbox is empty", zh ? "审批、军团收工和升级会出现在这里" : "Approvals, team completions and escalations show here"); + const html = `${careCard}${listHtml}`; const subtitle = zh ? `未读 ${vm.summary.unread_count} · 待决策 ${vm.summary.needs_decision_count}` : `${vm.summary.unread_count} unread · ${vm.summary.needs_decision_count} need a call`; @@ -714,6 +729,43 @@ export function createNotificationsView(): SpotlightCapabilityView { }); return; } + // G4 #10(关怀 opt-out):切换 Cuu 关怀私聊开关。PUT 时带上当前 muted 快照(不误清静音)+ + // careMessagesEnabled;成功后重开面板让状态一致。prefs 没读到时禁用(按钮已 disabled,这里兜底)。 + const careBtn = target.closest("[data-notif-care-toggle]"); + if (careBtn?.dataset.notifCareToggle) { + if (careBtn.disabled) { + return; + } + const careEl = ctx.body.querySelector("[data-notif-care]"); + if (careEl?.dataset.notifPrefsFailed === "true") { + ctx.toast(zh ? "偏好没读取到,先重开通知面板再改关怀开关。" : "Preferences didn't load — reopen notifications before changing this.", "error"); + return; + } + const nextEnabled = careBtn.dataset.notifCareToggle === "on"; + let careMuted: string[] = []; + try { + careMuted = JSON.parse(careEl?.dataset.notifMuted ?? "[]") as string[]; + } catch { + careMuted = []; + } + careBtn.disabled = true; + void ctx.client + .setNotificationPreferences(careMuted, { careMessagesEnabled: nextEnabled }) + .then(() => { + ctx.toast( + nextEnabled + ? (zh ? "已开启 Cuu 关怀消息" : "Cuu care check-ins turned on") + : (zh ? "已关闭 Cuu 关怀消息" : "Cuu care check-ins turned off"), + "ok" + ); + ctx.open("notifications", {}); + }) + .catch(() => { + careBtn.disabled = false; + ctx.toast(zh ? "偏好没保存上,稍后重试" : "Couldn't save the preference. Try again.", "error"); + }); + return; + } const muteBtn = target.closest("[data-notif-mute]"); const unmuteBtn = target.closest("[data-notif-unmute]"); const type = muteBtn?.dataset.notifMute ?? unmuteBtn?.dataset.notifUnmute; @@ -735,8 +787,8 @@ export function createNotificationsView(): SpotlightCapabilityView { void ctx.client.setNotificationPreferences(next) .then(() => { ctx.toast(muteBtn - ? (zh ? `已静音「${type}」类通知` : `Muted “${type}” notifications`) - : (zh ? `已恢复接收「${type}」` : `Unmuted “${type}”`), "ok"); + ? (zh ? `已静音「${notificationTypeLabel(type, zh)}」类通知` : `Muted “${notificationTypeLabel(type, zh)}” notifications`) + : (zh ? `已恢复接收「${notificationTypeLabel(type, zh)}」` : `Unmuted “${notificationTypeLabel(type, zh)}”`), "ok"); // 重挂载最省事且状态一定一致:静音面板/按钮态全部随 VM 重渲。 ctx.open("notifications", {}); }) diff --git a/apps/desktop-webview/src/workbench/timeline/render.ts b/apps/desktop-webview/src/workbench/timeline/render.ts index ee21afc8..a767908a 100644 --- a/apps/desktop-webview/src/workbench/timeline/render.ts +++ b/apps/desktop-webview/src/workbench/timeline/render.ts @@ -295,16 +295,16 @@ function renderRowHtml( } // E2b OKR 挂钩:工作项挂了目标(objective_ids 非空)时,就近在行标题右侧渲一枚 OKR tile。 -// 缺口(见报告):E1 的时间线 VM 只带 objective_ids、不带目标名,也没有可批量取名的公开端点 -// (listObjectiveTitlesByIds 只在成本页服务端内部用,未暴露给前端),本批不改 api——所以 tile 的 -// 悬停(title)先显 id,不编造名字。目标名接入等 E1 补 VM 字段或开只读取名端点后再补(additive)。 +// G4 #36:VM 现带 objective_titles(服务端 join listObjectiveTitlesByIds),悬停(title/aria-label)显目标名; +// 缺省或取名失败时回落成裸 id(与旧行为一致,不编造名字)。 function renderOkrTile(item: TimelineWorkItemVM, zh: boolean): string { const ids = item.objective_ids; if (!ids || ids.length === 0) { return ""; } + const names = item.objective_titles && item.objective_titles.length === ids.length ? item.objective_titles : ids; const label = ids.length > 1 ? `OKR ×${ids.length}` : "OKR"; - const hover = zh ? `目标 ${ids.join("、")}` : `Objectives ${ids.join(", ")}`; + const hover = zh ? `目标 ${names.join("、")}` : `Objectives ${names.join(", ")}`; return `${label}`; } diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts index fad8620b..014ce42e 100644 --- a/apps/web/src/browser.ts +++ b/apps/web/src/browser.ts @@ -2219,10 +2219,15 @@ function bindNotificationMutePanel( // R10-P1-7:水合竞态收口——SSR 开关是禁用的,只有 GET 成功回填后才解禁;GET 失败保持锁定+ // 显式错误+重试按钮(此前失败被静默吞掉,用户在「假的全不勾」上点一下就把已有静音整组覆盖丢了)。 const retryButton = panel.querySelector("[data-r10-notification-mute-retry]"); + // G4 #10(关怀 opt-out):Cuu 关怀私聊开关——与 mute 复选框共用同一份偏好 GET/PUT,故一起水合/一起锁禁用。 + const careToggle = panel.querySelector("[data-r17-notification-care-toggle]"); const setEnabled = (enabled: boolean) => { for (const checkbox of checkboxes) { checkbox.disabled = !enabled; } + if (careToggle) { + careToggle.disabled = !enabled; + } }; const hydrate = async () => { setEnabled(false); @@ -2239,6 +2244,10 @@ function bindNotificationMutePanel( for (const checkbox of checkboxes) { checkbox.checked = muted.has(checkbox.getAttribute("data-r5-notification-mute-type") ?? ""); } + if (careToggle) { + // 勾选=开启关怀(默认 true);旧后端不带该字段时回落成开启,与服务端默认一致。 + careToggle.checked = prefs.care_messages_enabled !== false; + } setEnabled(true); if (status) { status.hidden = true; @@ -2257,15 +2266,19 @@ function bindNotificationMutePanel( retryButton?.addEventListener("click", () => void hydrate(), { signal }); // 保存按到达顺序串行(PUT 是整体替换,乱序完成会用旧勾选覆盖新勾选);每次执行时现读 DOM,最后写赢。 + // includeCare=true 时把关怀开关也一并提交(care 行变更);否则省略该字段=不动关怀(mute 行变更)。 let saveChain: Promise = Promise.resolve(); - const doSave = async () => { + const doSave = async (includeCare: boolean) => { const muted = checkboxes .filter((checkbox) => checkbox.checked) .map((checkbox) => checkbox.getAttribute("data-r5-notification-mute-type") ?? "") .filter((type) => type.length > 0); setStatus(zh ? "保存中…" : "Saving…", "saving"); try { - await client.setNotificationPreferences(muted); + await client.setNotificationPreferences( + muted, + includeCare && careToggle ? { careMessagesEnabled: careToggle.checked } : undefined + ); if (signal.aborted) { return; } @@ -2277,14 +2290,15 @@ function bindNotificationMutePanel( setStatus(zh ? "保存失败,请重试" : "Save failed, please retry", "error"); } }; - const save = () => { - saveChain = saveChain.then(doSave); + const save = (includeCare: boolean) => { + saveChain = saveChain.then(() => doSave(includeCare)); return saveChain; }; for (const checkbox of checkboxes) { - checkbox.addEventListener("change", () => void save(), { signal }); + checkbox.addEventListener("change", () => void save(false), { signal }); } + careToggle?.addEventListener("change", () => void save(true), { signal }); } // R14 批 SEARCH(web-search-page,02-search-design.md §7):顶栏搜索页的客户端水合。SSR diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts index 8b7f50f3..057bc9bc 100644 --- a/apps/web/src/main.test.ts +++ b/apps/web/src/main.test.ts @@ -150,10 +150,10 @@ function fakeClient(surface: GoldPathSurfaceVM, session: SessionVM = intakeSessi throw new Error("not needed"); }, async getNotificationPreferences() { - return { muted_notification_types: [] }; + return { muted_notification_types: [], care_messages_enabled: true }; }, async setNotificationPreferences() { - return { muted_notification_types: [] }; + return { muted_notification_types: [], care_messages_enabled: true }; }, async bootstrapProject() { return { diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 2235446d..38d8d722 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -418,10 +418,17 @@ export function createApiClient(options: WorkHubApiClientOptions = {}): WorkHubA }), getNotificationPreferences: () => request("/api/notifications/preferences"), - setNotificationPreferences: (mutedNotificationTypes) => + setNotificationPreferences: (mutedNotificationTypes, options) => request("/api/notifications/preferences", { method: "PUT", - body: JSON.stringify({ muted_notification_types: mutedNotificationTypes }) + body: JSON.stringify({ + muted_notification_types: mutedNotificationTypes, + // G4 #10:只在调用方显式给了值时才带 care_messages_enabled——缺省=不动关怀开关(服务端 + // 的 PUT schema 对该字段为 optional,缺省不覆盖存量)。 + ...(options?.careMessagesEnabled !== undefined + ? { care_messages_enabled: options.careMessagesEnabled } + : {}) + }) }), bootstrapProject: (payload = {}) => request("/api/projects/bootstrap", { diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 89fc28d8..3f2b7494 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -322,10 +322,13 @@ export type WorkHubApiClient = { completeNotification: (id: string) => Promise; // R15 批 A(A2 提醒阶梯):暂停一条通知的 24h 提醒(POST /snooze,服务端置 next_remind_at=null)。 snoozeNotification: (id: string) => Promise; - getNotificationPreferences: () => Promise<{ muted_notification_types: string[] }>; + // G4 #10(关怀 opt-out):偏好里额外带 care_messages_enabled(Cuu 关怀私聊开关,默认 true)。 + getNotificationPreferences: () => Promise<{ muted_notification_types: string[]; care_messages_enabled: boolean }>; + // options.careMessagesEnabled 缺省=不动关怀开关(沿用当前存量,服务端同口径)。 setNotificationPreferences: ( - mutedNotificationTypes: string[] - ) => Promise<{ muted_notification_types: string[] }>; + mutedNotificationTypes: string[], + options?: { careMessagesEnabled?: boolean } + ) => Promise<{ muted_notification_types: string[]; care_messages_enabled: boolean }>; bootstrapProject: (payload?: BootstrapProjectRequest) => Promise; createSession: (payload?: CreateSessionRequest, options?: PageRequestOptions) => Promise; getSession: (id: string, options?: PageRequestOptions) => Promise; diff --git a/packages/contracts/src/pages.ts b/packages/contracts/src/pages.ts index 8569d452..3ef4fe12 100644 --- a/packages/contracts/src/pages.ts +++ b/packages/contracts/src/pages.ts @@ -733,7 +733,11 @@ export const timelineWorkItemVmSchema = z.object({ blocks_count: z.number().int().nonnegative(), overdue: z.boolean(), // E1d OKR 挂钩:仅当该工作项挂了目标时出现。 - objective_ids: z.array(idSchema).optional() + objective_ids: z.array(idSchema).optional(), + // G4 #36:与 objective_ids 一一对应的目标标题(服务端 join listObjectiveTitlesByIds 得到;未命中的 + // 目标回落成 id,保证长度与 objective_ids 一致)。additive/optional——旧 fixture 或取名失败时省略, + // 前端回落到显示裸 id(见 route-components / 桌面 timeline 的 OKR pill)。 + objective_titles: z.array(z.string().min(1)).optional() }); export type TimelineWorkItemVM = z.infer; diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index 7581e956..400e9125 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -61,6 +61,7 @@ import { checkStatusLabel, deliverableTargetLabel, evidenceSourceLabel, + notificationTypeLabel, previewKindLabel, proposalStatusLabel, taskPlanItemRoleLabel, @@ -1404,60 +1405,8 @@ function notificationSeverityLabel(severity: string, zh: boolean): string { ); } -// 通知类型是点分命名空间(comment.mention / workitem.escalated / agent_run.succeeded / proposal.ready …)。 -// rank7:旧 map 的 key(proposal_review 等)与真实类型对不上 + en map 为空 → 两种语言都掉进 humanizeToken -// 渲出「Workitem.ai Working」式机器 token。改为:精确类型映射(双语) → 命名空间前缀兜底(双语) → 最后才 humanize。 -function notificationTypeLabel(type: string, zh: boolean): string { - const exact: Record = { - "comment.mention": ["提到你", "Mention"], - "workitem.claimed": ["已认领", "Claimed"], - "workitem.escalated": ["已升级", "Escalated"], - "escalation.opened": ["已升级", "Escalated"], - "proposal.ready": ["改动待审", "Proposal ready"], - "proposal.opened": ["改动待审", "Proposal ready"], - "agent_run.step": ["AI 进展", "AI progress"], - "agent_run.succeeded": ["AI 完成", "AI done"], - "agent_run.failed": ["AI 失败", "AI failed"], - "approval.decided": ["审批已定", "Decided"], - "approval.delegated": ["审批转交", "Delegated"], - "approval.expired": ["审批过期", "Expired"], - "approval.commented": ["审批评论", "Comment"], - "meeting.insight.pending": ["会议待办", "Meeting insight"], - "drive.page": ["网盘更新", "Drive"], - "system.notice": ["系统通知", "System"], - milestone: ["里程碑", "Milestone"], - "notification.created": ["新通知", "Update"], - // R12 功能审查 F5:观察者派活问询——此前落进 humanizeToken 兜底,渲成裸英文 - // "Action Card Item Dispatch Ask"。 - "action_card_item.dispatch_ask": ["派活问询", "Dispatch ask"], - // G-web 止血批:project.* 无命名空间前缀兜底(prefix 表没有 "project" key),此前落进 - // humanizeToken 渲出裸英文 "Project Risk Digest"。 - "project.risk_digest": ["风险巡检摘要", "Risk digest"] - }; - const hit = exact[type]; - if (hit) { - return zh ? hit[0] : hit[1]; - } - const prefix: Record = { - comment: ["评论", "Comment"], - workitem: ["工作项", "Work item"], - agent_run: ["AI 运行", "AI run"], - approval: ["审批", "Approval"], - proposal: ["改动", "Proposal"], - escalation: ["升级", "Escalation"], - drive: ["网盘", "Drive"], - drive_comment: ["网盘评论", "Drive comment"], - meeting: ["会议", "Meeting"], - budget: ["预算", "Budget"], - system: ["系统", "System"], - action_card_item: ["行动卡", "Action card"] - }; - const ns = prefix[type.split(".")[0] ?? type]; - if (ns) { - return zh ? ns[0] : ns[1]; - } - return humanizeToken(type.replace(/\./gu, " ")); -} +// 通知类型标签(comment.mention / work_item.due_soon / conversation.message …)现由 packages/ui/src/i18n.ts +// 的 notificationTypeLabel 提供(桌面「静音此类」文案共用同一份,见 G4 #22/#35)——本文件只 import 复用。 function driveItemKindLabel(kind: string, zh: boolean): string { return localizedEnumLabel(kind, zh, { file: "文件", folder: "文件夹" }, { file: "File", folder: "Folder" }); @@ -3619,6 +3568,12 @@ function renderNotificationBucket( // `flushDraft` 里按 `draft.type` 做静音判定的字符串完全一致——否则勾选了却照发就是骗用户。 // 来源:packages/events/src/lifecycle.ts 的 6 个 workitem.* 里程碑 + notifications.ts 的 comment.mention // + schedule-notify-pages.ts 的 meeting.insight.pending。新增可静音类型时同步这里。 +// G4 #23:补 R15 新增的、且经 isMutedForRecipient(createNotification/notifyConversationMessage 内部) +// 真正按 type 静音的类型——三档 DDL 通知(work_item.due_soon/overdue/escalated_ddl,见 +// apps/api/src/services/ddl-chase.ts)+ 会话消息/被@(conversation.message/mention,见 +// apps/api/src/services/notifications.ts notifyConversationMessage 的 isMutedForRecipient 判定)。 +// 刻意不含 work_item.needs_owner:它主通道是项目主区的 action_card(find_owner 决策卡),只有降级 +// 回落时才走可静音的通知,勾了未必拦得住主通道——不放进来免得「勾了却照发」。 const MUTABLE_NOTIFICATION_TYPES: ReadonlyArray<{ type: string; zh: string; en: string }> = [ { type: "workitem.ai_working", zh: "AI 开始处理工作项", en: "AI started working on an item" }, { type: "workitem.in_review", zh: "工作项待审查", en: "An item is ready for review" }, @@ -3627,7 +3582,12 @@ const MUTABLE_NOTIFICATION_TYPES: ReadonlyArray<{ type: string; zh: string; en: { type: "workitem.merged", zh: "工作项已合并交付", en: "An item was merged" }, { type: "workitem.cancelled", zh: "工作项已取消", en: "An item was cancelled" }, { type: "comment.mention", zh: "评论里 @ 了我", en: "Someone mentioned me in a comment" }, - { type: "meeting.insight.pending", zh: "会议洞察待确认", en: "A meeting insight needs confirmation" } + { type: "meeting.insight.pending", zh: "会议洞察待确认", en: "A meeting insight needs confirmation" }, + { type: "work_item.due_soon", zh: "工作项快到期提醒", en: "An item is due soon" }, + { type: "work_item.overdue", zh: "工作项已逾期提醒", en: "An item is overdue" }, + { type: "work_item.escalated_ddl", zh: "工作项逾期升级提醒", en: "An overdue item was escalated" }, + { type: "conversation.message", zh: "会话里的新消息", en: "New messages in a conversation" }, + { type: "conversation.mention", zh: "会话里 @ 了我", en: "Someone mentioned me in a conversation" } ]; // 静音偏好面板:SSR 出一个折叠的
(默认收起→不占版面、不触溢出门),开关默认全不勾(诚实 @@ -3639,12 +3599,25 @@ function renderNotificationMutePanel(locale: WorkHubLocale): string { ).join(""); // R10-P1-7:开关 SSR 先禁用——当前偏好是异步 GET 回填的,回填前就能点会让「看起来全不勾」的 // 假状态被整组 PUT 覆盖掉已有静音。水合成功后由客户端解禁;失败时保持锁定+显式重试。 + const zh = locale === "zh-CN"; + // G4 #10(关怀 opt-out):关怀私聊开关(默认开)。与「按类型静音」是两回事——这是主动关怀而非 + // 某类通知,故独立成一行、勾选=开启(不同于下方 mute 行的勾选=静音)。同样 SSR 先禁用、GET 回填后解禁。 + // 文案避开 web 端不露出的「Cuu」品牌词(smoke 门:web HTML 不含 Cuu;桌面端另有带 Cuu 的措辞)。 + const careToggle = ` +

${escapeHtml( + zh + ? "在你负荷高、或连续深夜工作时,会收到一句私下问候,可随时关闭。" + : "You'll get a private check-in when your load is high or you're working late nights — turn it off anytime." + )}

`; return `
${escapeHtml(routeT(locale, "notifications.muteTitle"))} + ${careToggle}

${escapeHtml(routeT(locale, "notifications.muteHelp"))}

${rows}
- +
`; } @@ -4542,10 +4515,14 @@ function renderProjectTimelineRouteComponent(vm: ProjectTimelinePageVM, locale: `${zh ? "依赖" : "Needs"} ${item.depends_on.map((depId) => codeById.get(depId) ?? depId.slice(0, 8)).join(" ")}` )}` : ""; - // OKR 标注:只显 id(E1 VM 不带目标名,web 也无取名端点——见报告缺口,本批不改 api)。 + // OKR 标注:悬停显目标名(G4 #36:VM 现带 objective_titles,服务端 join 得到;缺省/取名失败时 + // 回落成裸 id,与旧行为一致)。 + const okrHoverNames = item.objective_titles && item.objective_titles.length === (item.objective_ids?.length ?? 0) + ? item.objective_titles + : item.objective_ids ?? []; const okrPill = item.objective_ids && item.objective_ids.length ? `${item.objective_ids.length > 1 ? `OKR ×${item.objective_ids.length}` : "OKR"}` : ""; const assigneePill = item.assignee diff --git a/packages/ui/src/i18n.ts b/packages/ui/src/i18n.ts index dbf3ac5f..2db9c24e 100644 --- a/packages/ui/src/i18n.ts +++ b/packages/ui/src/i18n.ts @@ -380,6 +380,79 @@ export function uiFormatCny(value: string | number | null | undefined, locale: W return `${prefix}${amount}`; } +// G4 #22/#35(通知类型标签,双端共享):通知类型是点分命名空间 +// (comment.mention / workitem.escalated / work_item.due_soon / conversation.message / agent_run.succeeded …)。 +// 精确类型映射(双语)→ 命名空间前缀兜底(双语)→ 最后才 humanize,绝不把裸机器 token 抛给用户。 +// 此前是 route-components.ts 里的私有函数(只 web 用);#35 桌面「静音此类」文案也要走同一份标签,故上提到 +// i18n(uiFormatCny 同款共享层),route-components 与桌面 dashboards 都从这里导入,不再各留一份。 +// 自留注释:新增可静音/可展示的通知类型时,若中文界面会裸显英文 token,就在下方 exact 表补一条。 +const NOTIFICATION_TYPE_EXACT: Record = { + "comment.mention": ["提到你", "Mention"], + "workitem.claimed": ["已认领", "Claimed"], + "workitem.escalated": ["已升级", "Escalated"], + "escalation.opened": ["已升级", "Escalated"], + "proposal.ready": ["改动待审", "Proposal ready"], + "proposal.opened": ["改动待审", "Proposal ready"], + "agent_run.step": ["AI 进展", "AI progress"], + "agent_run.succeeded": ["AI 完成", "AI done"], + "agent_run.failed": ["AI 失败", "AI failed"], + "approval.decided": ["审批已定", "Decided"], + "approval.delegated": ["审批转交", "Delegated"], + "approval.expired": ["审批过期", "Expired"], + "approval.commented": ["审批评论", "Comment"], + "meeting.insight.pending": ["会议待办", "Meeting insight"], + "drive.page": ["网盘更新", "Drive"], + "system.notice": ["系统通知", "System"], + milestone: ["里程碑", "Milestone"], + "notification.created": ["新通知", "Update"], + // R12 功能审查 F5:观察者派活问询(此前落进 humanize 兜底,渲成裸英文 "Action Card Item Dispatch Ask")。 + "action_card_item.dispatch_ask": ["派活问询", "Dispatch ask"], + // G-web 止血批:project.* 无命名空间前缀兜底(prefix 表没有 "project" key),此前渲出裸英文 "Project Risk Digest"。 + "project.risk_digest": ["风险巡检摘要", "Risk digest"], + // G4 #22:R15 四阶梯 DDL 提醒(services/ddl-chase.ts 真实 publish 的 type 串,注意前缀是 work_item.) + // 与会话消息通知(services/notifications.ts:conversation.message / conversation.mention)——此前中文界面 + // 裸显大写英文 token("Work Item.due Soon" 等),补精确映射。 + "work_item.due_soon": ["到期提醒", "Due soon"], + "work_item.overdue": ["已逾期", "Overdue"], + "work_item.escalated_ddl": ["逾期升级", "Overdue escalated"], + "work_item.needs_owner": ["待认领提醒", "Needs owner"], + "conversation.message": ["新消息", "New message"], + "conversation.mention": ["有人提到你", "Mentioned you"] +}; + +const NOTIFICATION_TYPE_PREFIX: Record = { + comment: ["评论", "Comment"], + workitem: ["工作项", "Work item"], + work_item: ["工作项", "Work item"], + conversation: ["会话", "Conversation"], + agent_run: ["AI 运行", "AI run"], + approval: ["审批", "Approval"], + proposal: ["改动", "Proposal"], + escalation: ["升级", "Escalation"], + drive: ["网盘", "Drive"], + drive_comment: ["网盘评论", "Drive comment"], + meeting: ["会议", "Meeting"], + budget: ["预算", "Budget"], + system: ["系统", "System"], + action_card_item: ["行动卡", "Action card"] +}; + +export function notificationTypeLabel(type: string, zh: boolean): string { + const hit = NOTIFICATION_TYPE_EXACT[type]; + if (hit) { + return zh ? hit[0] : hit[1]; + } + const ns = NOTIFICATION_TYPE_PREFIX[type.split(".")[0] ?? type]; + if (ns) { + return zh ? ns[0] : ns[1]; + } + // uiHumanize 只 `_`→空格;点分命名空间还要把 `.` 也摊平后首字母大写,别渲成 "Foo.bar baz"。 + return type + .replace(/[._]/gu, " ") + .replace(/\b\w/gu, (char) => char.toUpperCase()) + .trim(); +} + function labelFromMap(locale: WorkHubLocale, value: string, map: Record) { return map[value]?.[locale] ?? uiHumanize(value); } From 81eeda7d91b788ac65e3ea3ac238e4e91ebdff76 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:45:55 +0800 Subject: [PATCH 03/23] feat(g4 #24/#9-web): project-home custom-instructions card + plan-drafts block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #24 web 项目自定义指令入口: 项目主页加「自定义指令」卡。GET /api/projects/:id/instructions 展示;能管项目→可编辑 textarea 失焦 PATCH 保存,无权(403)→只读说明。错误矩阵对齐桌面 W4b1(403 forbidden / 422 validation / 其它 network,保存失败不回滚用户输入)。 - #9 E3 web 只读入口: 项目主页加「规划草案」小区块,拉 plan-drafts 只读展示 pending_review 计数 + 最新草案状态;起草/审批/物化引导去桌面客户端。无权/取数失败静默降级。 不新增路由(都挂在既有 project-home)。web typecheck + routes.test 绿。 Co-Authored-By: Claude Fable 5 --- apps/web/src/browser.ts | 220 ++++++++++++++++++ packages/ui/src/gold-path/route-components.ts | 19 ++ 2 files changed, 239 insertions(+) diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts index 014ce42e..d85274c0 100644 --- a/apps/web/src/browser.ts +++ b/apps/web/src/browser.ts @@ -2142,6 +2142,8 @@ function bindReadyRoute(result: WebRouteReadyResult, client: BrowserApiClient, l bindRouteLineEditor(root, { signal, markDirty: markActiveRouteDirty }); bindGoldPathNavigation(root, result.shell, client, locale, (href) => navigateWebRoute(href, client, locale), signal); bindNotificationMutePanel(root, result, client, locale, signal); + bindProjectHomePlansPanel(root, result, client, locale, signal); + bindProjectHomeInstructionsPanel(root, result, client, locale, signal); bindSearchRoutePanel(root, result, client, locale, signal); bindSettingsAiProfilePanel(root, result, client, locale, signal); bindSettingsMyProfilePanel(root, result, client, locale, signal); @@ -2301,6 +2303,224 @@ function bindNotificationMutePanel( careToggle?.addEventListener("change", () => void save(true), { signal }); } +// G4 #9(E3 web 只读入口):项目主页「规划草案」小区块——拉 GET /api/projects/:id/plan-drafts, +// 只读展示 pending_review 计数 + 最新草案状态;起草/审批/物化都在桌面客户端(web 是管理者控制台定位, +// 不给假点击)。无权/取数失败 → 静默降级成一句「暂无规划草案」,不拖垮整个项目主页(同 army pill 手法)。 +function bindProjectHomePlansPanel( + container: HTMLElement, + result: WebRouteReadyResult, + client: BrowserApiClient, + locale: WorkHubLocale, + signal: AbortSignal +) { + if (result.match.key !== "project-home") { + return; + } + const section = container.querySelector("[data-r17-project-home-plans]"); + const body = section?.querySelector("[data-r17-project-home-plans-body]"); + const projectId = section?.getAttribute("data-r17-project-home-plans-project") ?? ""; + if (!section || !body || !projectId) { + return; + } + const zh = locale === "zh-CN"; + type PlanDraftSlice = { id: string; status: string; updated_at: string }; + const statusLabel = (status: string): string => { + const map: Record = { + draft: ["草稿", "Draft"], + pending_review: ["待审阅", "Pending review"], + approved: ["已批准", "Approved"], + rejected: ["已驳回", "Rejected"], + materialized: ["已物化", "Materialized"] + }; + const hit = map[status]; + return hit ? (zh ? hit[0] : hit[1]) : status; + }; + void (async () => { + try { + const data = await client.request<{ drafts?: PlanDraftSlice[] }>( + `/api/projects/${encodeURIComponent(projectId)}/plan-drafts?locale=${encodeURIComponent(locale)}` + ); + if (signal.aborted) { + return; + } + const drafts = Array.isArray(data.drafts) ? data.drafts : []; + if (drafts.length === 0) { + body.innerHTML = `

${escapeHtml( + zh + ? "暂无规划草案。让 AI 起草项目计划在桌面客户端进行。" + : "No plan drafts yet. Drafting a project plan with AI happens in the desktop app." + )}

`; + return; + } + const pending = drafts.filter((draft) => draft.status === "pending_review").length; + const latest = [...drafts].sort((a, b) => b.updated_at.localeCompare(a.updated_at))[0]!; + const pendingLine = pending > 0 + ? `

${escapeHtml( + zh ? `${pending} 份待审阅` : `${pending} pending review` + )}

` + : ""; + body.innerHTML = `${pendingLine} +
+ ${escapeHtml(zh ? `共 ${drafts.length} 份` : `${drafts.length} total`)} + ${escapeHtml( + zh ? `最新:${statusLabel(latest.status)}` : `Latest: ${statusLabel(latest.status)}` + )} +
+

${escapeHtml( + zh + ? "查看详情、审批与物化在桌面客户端的日程标签进行。" + : "View details, approve and materialize from the Schedule tab in the desktop app." + )}

`; + } catch { + if (signal.aborted) { + return; + } + // 无权(403)/端点报错——静默降级成中性空态说明,不暴露存在性、不报错。 + body.innerHTML = `

${escapeHtml( + zh ? "暂无规划草案。" : "No plan drafts yet." + )}

`; + } + })(); +} + +// G4 #24(项目自定义指令 web 入口):项目主页「自定义指令」卡——GET /api/projects/:id/instructions +// 展示;能管项目(GET 成功)→ 可编辑 textarea + 失焦 PATCH 保存;无权(403)→ 只读说明。错误矩阵对齐 +// 桌面 W4b1(403 forbidden / 422 validation / 其它 network,保存失败绝不回滚用户刚敲的内容)。 +const PROJECT_INSTRUCTIONS_MAX_CHARS = 4000; +function bindProjectHomeInstructionsPanel( + container: HTMLElement, + result: WebRouteReadyResult, + client: BrowserApiClient, + locale: WorkHubLocale, + signal: AbortSignal +) { + if (result.match.key !== "project-home") { + return; + } + const section = container.querySelector("[data-r17-project-home-instructions]"); + const body = section?.querySelector("[data-r17-project-home-instructions-body]"); + const projectId = section?.getAttribute("data-r17-project-home-instructions-project") ?? ""; + if (!section || !body || !projectId) { + return; + } + const zh = locale === "zh-CN"; + type InstructionsSlice = { project_id: string; instructions_md: string; updated_at: string }; + const path = `/api/projects/${encodeURIComponent(projectId)}/instructions`; + // confirmed = 上一次服务端确认过的值(幂等判定基线);保存失败不回滚 textarea(用户刚敲的还在)。 + let confirmed = ""; + + const renderForbidden = () => { + body.innerHTML = `

${escapeHtml( + zh ? "需要项目管理权限才能查看和修改自定义指令。" : "You need project management permission to view or change custom instructions." + )}

`; + }; + const renderError = () => { + body.innerHTML = `

${escapeHtml( + zh ? "自定义指令没拉到,稍后重试。" : "Couldn't load custom instructions — retry later." + )}

`; + body.querySelector("[data-r17-project-home-instructions-retry]") + ?.addEventListener("click", () => void hydrate(), { signal }); + }; + + const renderEditor = () => { + body.innerHTML = ` +
+ + ${escapeHtml(zh ? "失焦自动保存,留空则不注入项目级指令。" : "Saves on blur; leave blank to skip project-level instructions.")} +
`; + const textarea = body.querySelector("[data-r17-project-home-instructions-textarea]"); + const status = body.querySelector("[data-r17-project-home-instructions-status]"); + if (!textarea) { + return; + } + textarea.value = confirmed; + const setStatus = (text: string, tone: "saving" | "saved" | "error") => { + if (!status) { + return; + } + status.hidden = false; + status.textContent = text; + status.setAttribute("data-r17-project-home-instructions-status", tone); + }; + let saveChain: Promise = Promise.resolve(); + const doSave = async (trimmed: string) => { + setStatus(zh ? "保存中…" : "Saving…", "saving"); + try { + const next = await client.request(path, { + method: "PATCH", + body: JSON.stringify({ instructions_md: trimmed }) + }); + if (signal.aborted) { + return; + } + confirmed = next.instructions_md; + setStatus(zh ? "已保存" : "Saved", "saved"); + } catch (error) { + if (signal.aborted) { + return; + } + if (error instanceof WorkHubApiError && error.status === 403) { + // 权限中途没了——整卡收成只读说明(不留「看起来能编辑」的假象)。 + renderForbidden(); + return; + } + if (error instanceof WorkHubApiError && error.status === 422) { + setStatus( + zh ? `超出 ${PROJECT_INSTRUCTIONS_MAX_CHARS} 字上限,删短一点再试。` : `Over the ${PROJECT_INSTRUCTIONS_MAX_CHARS}-character limit — trim it and try again.`, + "error" + ); + return; + } + // 网络等:保留用户输入,只给错误提示(不回滚 textarea)。 + setStatus(zh ? "没保存成功,你写的内容还在——改一下、失焦即可重试。" : "Couldn't save — what you typed is still here. Edit and blur to retry.", "error"); + } + }; + textarea.addEventListener( + "blur", + () => { + const trimmed = textarea.value.trim(); + if (trimmed === confirmed) { + return; // 幂等:与上次确认值相同,不发请求。 + } + if (trimmed.length > PROJECT_INSTRUCTIONS_MAX_CHARS) { + setStatus( + zh ? `超出 ${PROJECT_INSTRUCTIONS_MAX_CHARS} 字上限,删短一点再试。` : `Over the ${PROJECT_INSTRUCTIONS_MAX_CHARS}-character limit — trim it and try again.`, + "error" + ); + return; + } + saveChain = saveChain.then(() => doSave(trimmed)); + }, + { signal } + ); + }; + + const hydrate = async () => { + body.innerHTML = `

${escapeHtml(zh ? "正在加载自定义指令…" : "Loading custom instructions…")}

`; + try { + const vm = await client.request(path); + if (signal.aborted) { + return; + } + confirmed = vm.instructions_md; + renderEditor(); + } catch (error) { + if (signal.aborted) { + return; + } + // GET 与 PATCH 同一道 canManageProjectDrive 门:403=无权(读写都不行)→ 只读说明;其它=取数错。 + if (error instanceof WorkHubApiError && error.status === 403) { + renderForbidden(); + return; + } + renderError(); + } + }; + void hydrate(); +} + // R14 批 SEARCH(web-search-page,02-search-design.md §7):顶栏搜索页的客户端水合。SSR // (route-components renderSearchRouteComponent)只渲搜索框外壳 + 诚实的空/短词提示,四个结果分组卡 // 先隐藏、无数据。这里在 q ≥ 2 字符时拉 GET /api/search(默认四 scope、limit=10)后按固定 scope 顺序 diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index 400e9125..fb5c5a88 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -4434,6 +4434,23 @@ function renderProjectHomeRouteComponent(vm: ProjectHomePageVM, locale: WorkHubL

${escapeHtml(routeT(locale, "projectHome.github"))}

${githubBody} `; + // G4 #9(E3 web 只读入口):规划草案小区块——SSR 出骨架,browser.ts 拉 GET /api/projects/:id/plan-drafts + // 后填 pending_review 计数 + 最新草案状态(只读;起草/审批/物化都在桌面客户端)。取数失败/无权静默降级。 + const plansSection = `
+

${escapeHtml(zh ? "规划草案" : "Plan drafts")}

+

${escapeHtml(zh ? "正在加载规划草案…" : "Loading plan drafts…")}

+
`; + // G4 #24(项目自定义指令 web 入口):SSR 出骨架,browser.ts 拉 GET /api/projects/:id/instructions + // 后按权限渲可编辑 textarea(失焦 PATCH 保存)或只读说明(403)。与桌面 W4b1 同一错误矩阵。 + const instructionsSection = `
+

${escapeHtml(zh ? "自定义指令" : "Custom instructions")}

+

${escapeHtml( + zh + ? "该项目的所有 AI 对话与自动执行都会读到这段指令;与系统工作纪律冲突时以纪律为准。" + : "Every AI conversation and automated run in this project reads this text; when it conflicts with the system's working discipline, the discipline wins." + )}

+

${escapeHtml(zh ? "正在加载自定义指令…" : "Loading custom instructions…")}

+
`; const rows = vm.open_work_items.length ? vm.open_work_items.map((item) => `
@@ -4484,6 +4501,8 @@ function renderProjectHomeRouteComponent(vm: ProjectHomePageVM, locale: WorkHubL ${escapeHtml(vm.actions.open_drive.label)} → ${githubSection} + ${plansSection} + ${instructionsSection} ${escapeHtml(routeT(locale, "projectHome.back"))} ` }); From 02641151a3c17a095eca0f6b35cdf2034594c08c Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:50:41 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat(members):=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E8=80=85=E5=8A=A0=E4=BA=BA/=E9=80=80?= =?UTF-8?q?=E7=BE=A4/=E7=A7=BB=E5=87=BA=20+=20=E6=8B=8D=E6=9D=BFA=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=20(R17-G1=20#1/#16/#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建群后无法加人、无退群/移出能力的两处缺口(05-gap-review #1/#16)。按拍板 A (不建项目成员数据层,项目成员=会话参与者的可管理化)把参与者集合从建群冻结的 快照升级为可增删对象。 - POST /api/conversations/:id/participants {user_id}:仅 collab 非 DM(main 全员 语义/DM 2 人不变量各自 409);调用者须为参与者(collab 只对参与者可见, visibleConversation 天然把关);目标须同工作区活跃成员;上限沿用建群 cap(100); 唯一约束幂等(已在群回 added=false + 现有列表)。 - DELETE /api/conversations/:id/participants/:userId:自删=退群、owner 删他人=移出; owner 退群且群里还有他人→最早加入者事务内自动升 owner;只剩自己→409(最后一人, 取窄不做归档旁路)。被移出者读游标/消息保留(只删 participant 行)。 - 新增 conversation.participants.updated 事件(best-effort 广播,客户端按需重拉)。 - conversations 服务顶部记录拍板 A 决策与未来扩展点(#3)。 契约/仓库/服务/路由四层 + openapi additive;DB 仓库 12 例、服务 11 例、路由 8 例、 契约 4 例测试矩阵覆盖两不变量(DM 2 人、main 全员)与 owner 移交语义。 Co-Authored-By: Claude Fable 5 --- apps/api/src/conversation-routes.test.ts | 6 + apps/api/src/conversations.test.ts | 318 ++++++++++++++++++ .../routes/conversation-participants.test.ts | 204 +++++++++++ .../src/routes/conversation-participants.ts | 39 +++ .../src/routes/conversation-typing.test.ts | 6 + apps/api/src/services/conversations.ts | 193 +++++++++++ packages/contracts/src/domain/conversation.ts | 33 ++ packages/contracts/src/enums.ts | 5 + packages/contracts/src/events.ts | 29 ++ packages/contracts/src/r14-chat.test.ts | 71 ++++ .../db/src/conversation-repository.test.ts | 250 ++++++++++++++ packages/db/src/repositories/conversations.ts | 208 ++++++++++++ 12 files changed, 1362 insertions(+) diff --git a/apps/api/src/conversation-routes.test.ts b/apps/api/src/conversation-routes.test.ts index 8dbc6b92..53ca6980 100644 --- a/apps/api/src/conversation-routes.test.ts +++ b/apps/api/src/conversation-routes.test.ts @@ -185,6 +185,12 @@ function service(overrides: Partial = {}): ConversationServ async listParticipants() { throw new Error("listParticipants not expected"); }, + async addParticipant() { + throw new Error("addParticipant not expected"); + }, + async removeParticipant() { + throw new Error("removeParticipant not expected"); + }, async listMessages() { return { messages: [], has_more: false, next_after_seq: 0 }; }, diff --git a/apps/api/src/conversations.test.ts b/apps/api/src/conversations.test.ts index 476e7c7c..242ff293 100644 --- a/apps/api/src/conversations.test.ts +++ b/apps/api/src/conversations.test.ts @@ -195,6 +195,13 @@ function repository(overrides: Partial = {}): Conversati async listParticipantsWithNickname() { throw new Error("listParticipantsWithNickname not expected"); }, + // R17 批 G1(群成员管理):新增 addParticipant/removeParticipant——本套件按需 override。 + async addParticipant() { + throw new Error("addParticipant not expected"); + }, + async removeParticipant() { + throw new Error("removeParticipant not expected"); + }, // R14 批 CHAT:新增的编辑/删除/置顶/反应/已读/富化仓库方法——本套件按需 override,未 override 的 // 给拒绝桩(同其它未测方法)。 async editMessage() { @@ -2196,3 +2203,314 @@ test("listParticipants 404s an invisible (or non-participant) conversation befor ); assert.equal(listCalls, 0); }); +// ── R17 批 G1(群成员管理 · #1 建群后加人 / #16 退群/移出)服务层 ───────────────────────────── + +test("addParticipant adds to a non-dm collab, broadcasts participants.updated, and returns the refreshed list", async () => { + const capture = capturingBus(); + let writeInput: unknown; + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ participantRole: "owner" }); + }, + async addParticipant(input) { + writeInput = input; + return { + participant: { + id: "23000000-0000-4000-8000-000000000002", + conversationId, + userId: participantUserId, + role: "member", + createdAt: now, + updatedAt: now + }, + added: true + }; + }, + async listParticipantsWithNickname() { + return [ + { userId, nickname: "阿曼", role: "owner" }, + { userId: participantUserId, nickname: "小赵", role: "member" } + ]; + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + bus: capture.bus, + now: () => now + } + ); + + const result = await service.addParticipant({ actor: actor(), conversationId, addUserId: participantUserId }); + + assert.deepEqual(writeInput, { workspaceId, conversationId, addedUserId: participantUserId, at: now }); + assert.equal(result.added, true); + assert.equal(result.participants.scope, "participants"); + assert.equal(result.participants.participants.length, 2); + assert.equal(capture.published[0]?.type, "conversation.participants.updated"); + const event = capture.published[0]?.data as { data: { conversation_id: string; change: string; user_id: string } }; + assert.deepEqual(event.data, { conversation_id: conversationId, change: "added", user_id: participantUserId }); +}); + +test("addParticipant is idempotent — an existing member returns added=false and does not broadcast", async () => { + const capture = capturingBus(); + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ participantRole: "member" }); + }, + async addParticipant() { + return { + participant: { + id: "23000000-0000-4000-8000-000000000002", + conversationId, + userId: participantUserId, + role: "member", + createdAt: now, + updatedAt: now + }, + added: false + }; + }, + async listParticipantsWithNickname() { + return [{ userId, nickname: "阿曼", role: "owner" }]; + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + bus: capture.bus, + now: () => now + } + ); + + const result = await service.addParticipant({ actor: actor(), conversationId, addUserId: participantUserId }); + + assert.equal(result.added, false); + assert.equal(capture.published.length, 0, "idempotent re-add must not broadcast"); +}); + +test("addParticipant refuses the main conversation with 409 and never writes", async () => { + let writeCalls = 0; + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ conversation: conversationRow({ kind: "main" }), participantRole: null }); + }, + async addParticipant() { + writeCalls += 1; + throw new Error("main must not accept new participants"); + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + now: () => now + } + ); + + await assert.rejects( + () => service.addParticipant({ actor: actor(), conversationId, addUserId: participantUserId }), + (error: unknown) => + error instanceof ConversationServiceError && error.status === 409 && error.code === "conversation_not_group" + ); + assert.equal(writeCalls, 0); +}); + +test("addParticipant refuses a dm conversation with 409 and never writes", async () => { + let writeCalls = 0; + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ + conversation: conversationRow({ dmKey: "dm:a:b" }), + participantRole: "owner" + }); + }, + async addParticipant() { + writeCalls += 1; + throw new Error("dm must not accept new participants"); + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + now: () => now + } + ); + + await assert.rejects( + () => service.addParticipant({ actor: actor(), conversationId, addUserId: participantUserId }), + (error: unknown) => + error instanceof ConversationServiceError && error.status === 409 && error.code === "conversation_dm_no_add" + ); + assert.equal(writeCalls, 0); +}); + +test("addParticipant 404s an invisible (or non-participant) conversation before any write", async () => { + let writeCalls = 0; + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return null; + }, + async addParticipant() { + writeCalls += 1; + throw new Error("must not write"); + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + now: () => now + } + ); + + await assert.rejects( + () => service.addParticipant({ actor: actor(), conversationId, addUserId: participantUserId }), + (error: unknown) => + error instanceof ConversationServiceError && error.status === 404 && error.code === "conversation_not_found" + ); + assert.equal(writeCalls, 0); +}); + +test("removeParticipant lets a member leave themselves (self_left=true) and broadcasts removed", async () => { + const capture = capturingBus(); + let writeInput: unknown; + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ participantRole: "member" }); + }, + async removeParticipant(input) { + writeInput = input; + return { removed: true, newOwnerUserId: null }; + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + bus: capture.bus, + now: () => now + } + ); + + const result = await service.removeParticipant({ actor: actor(), conversationId, targetUserId: userId }); + + assert.deepEqual(writeInput, { workspaceId, conversationId, targetUserId: userId, at: now }); + assert.deepEqual(result, { removed_user_id: userId, self_left: true, new_owner_user_id: null }); + assert.equal(capture.published[0]?.type, "conversation.participants.updated"); + const event = capture.published[0]?.data as { data: { change: string; user_id: string } }; + assert.equal(event.data.change, "removed"); +}); + +test("removeParticipant lets an owner remove another member (move-out)", async () => { + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ participantRole: "owner" }); + }, + async removeParticipant() { + return { removed: true, newOwnerUserId: null }; + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + now: () => now + } + ); + + const result = await service.removeParticipant({ actor: actor(), conversationId, targetUserId: participantUserId }); + + assert.deepEqual(result, { removed_user_id: participantUserId, self_left: false, new_owner_user_id: null }); +}); + +test("removeParticipant refuses a non-owner removing someone else with 403 and never writes", async () => { + let writeCalls = 0; + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ participantRole: "member" }); + }, + async removeParticipant() { + writeCalls += 1; + throw new Error("only the owner can remove others"); + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + now: () => now + } + ); + + await assert.rejects( + () => service.removeParticipant({ actor: actor(), conversationId, targetUserId: participantUserId }), + (error: unknown) => + error instanceof ConversationServiceError && error.status === 403 && error.code === "conversation_remove_forbidden" + ); + assert.equal(writeCalls, 0); +}); + +test("removeParticipant surfaces the promoted successor when the owner leaves", async () => { + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ participantRole: "owner" }); + }, + async removeParticipant() { + return { removed: true, newOwnerUserId: participantUserId }; + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + now: () => now + } + ); + + const result = await service.removeParticipant({ actor: actor(), conversationId, targetUserId: userId }); + + assert.deepEqual(result, { removed_user_id: userId, self_left: true, new_owner_user_id: participantUserId }); +}); + +test("removeParticipant refuses main (all-member) and dm (2-person) conversations with 409", async () => { + const mainService = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ conversation: conversationRow({ kind: "main" }), participantRole: null }); + } + }), + { driveFiles: driveFiles(async () => { throw new Error("no drive"); }), now: () => now } + ); + await assert.rejects( + () => mainService.removeParticipant({ actor: actor(), conversationId, targetUserId: userId }), + (error: unknown) => + error instanceof ConversationServiceError && error.status === 409 && error.code === "conversation_not_group" + ); + + const dmService = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ conversation: conversationRow({ dmKey: "dm:a:b" }), participantRole: "owner" }); + } + }), + { driveFiles: driveFiles(async () => { throw new Error("no drive"); }), now: () => now } + ); + await assert.rejects( + () => dmService.removeParticipant({ actor: actor(), conversationId, targetUserId: participantUserId }), + (error: unknown) => + error instanceof ConversationServiceError && error.status === 409 && error.code === "conversation_dm_no_remove" + ); +}); diff --git a/apps/api/src/routes/conversation-participants.test.ts b/apps/api/src/routes/conversation-participants.test.ts index 7051852e..a3927b43 100644 --- a/apps/api/src/routes/conversation-participants.test.ts +++ b/apps/api/src/routes/conversation-participants.test.ts @@ -120,6 +120,8 @@ function service(overrides: Partial = {}): ConversationServ renameConversation: reject("renameConversation"), updateCuuEnabled: reject("updateCuuEnabled"), listParticipants: reject("listParticipants"), + addParticipant: reject("addParticipant"), + removeParticipant: reject("removeParticipant"), listMessages: reject("listMessages"), createMessage: reject("createMessage"), editMessage: reject("editMessage"), @@ -261,3 +263,205 @@ test("the route preserves the service's typed 404 for an invisible (or non-parti assert.equal(response.status, 404); }); + +// ── R17 批 G1(群成员管理 · #1 建群后加人)POST /participants ───────────────────────────────── + +test("POST participants requires authentication before reaching the service", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + service({ + async addParticipant() { + throw new Error("anonymous request must not reach the service"); + } + }) + ); + + const response = await app.request(`/api/conversations/${conversationId}/participants`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ user_id: otherUserId }) + }); + + assert.equal(response.status, 401); +}); + +test("POST participants forwards the target user id and returns the refreshed list", async () => { + const runtimeSettings = settings(); + let seen: unknown; + const app = routeApp( + runtimeSettings, + service({ + async addParticipant(input) { + seen = input; + return { + added: true, + participants: { + scope: "participants", + participants: [ + { user_id: userId, nickname: "阿曼", role: "owner" }, + { user_id: otherUserId, nickname: "小赵", role: "member" } + ] + } + }; + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings), "content-type": "application/json" }; + + const response = await app.request(`/api/conversations/${conversationId}/participants`, { + method: "POST", + headers, + body: JSON.stringify({ user_id: otherUserId }) + }); + + assert.equal(response.status, 200); + const body = (await response.json()) as { ok: boolean; data: { added: boolean } }; + assert.equal(body.ok, true); + assert.equal(body.data.added, true); + assert.deepEqual(seen, { actor: (seen as { actor: unknown }).actor, conversationId, addUserId: otherUserId }); +}); + +test("POST participants rejects a malformed body with 422 before the service", async () => { + const runtimeSettings = settings(); + let calls = 0; + const app = routeApp( + runtimeSettings, + service({ + async addParticipant() { + calls += 1; + throw new Error("invalid body must not reach the service"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings), "content-type": "application/json" }; + + const response = await app.request(`/api/conversations/${conversationId}/participants`, { + method: "POST", + headers, + body: JSON.stringify({ user_id: "not-a-uuid" }) + }); + + assert.equal(response.status, 422); + assert.equal(calls, 0); +}); + +test("POST participants preserves the service's typed 409 for a dm/main conversation", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + service({ + async addParticipant() { + throw new ConversationServiceError(409, "conversation_dm_no_add", "私聊只能是两个人,不能再加人。"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings), "content-type": "application/json" }; + + const response = await app.request(`/api/conversations/${conversationId}/participants`, { + method: "POST", + headers, + body: JSON.stringify({ user_id: otherUserId }) + }); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { + ok: false, + error: { code: "conversation_dm_no_add", message: "私聊只能是两个人,不能再加人。" } + }); +}); + +// ── R17 批 G1(#16 退群/移出)DELETE /participants/:userId ──────────────────────────────────── + +test("DELETE participant requires authentication before reaching the service", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + service({ + async removeParticipant() { + throw new Error("anonymous request must not reach the service"); + } + }) + ); + + const response = await app.request(`/api/conversations/${conversationId}/participants/${otherUserId}`, { + method: "DELETE" + }); + + assert.equal(response.status, 401); +}); + +test("DELETE participant 404s a malformed target user id without entering the service", async () => { + const runtimeSettings = settings(); + let calls = 0; + const app = routeApp( + runtimeSettings, + service({ + async removeParticipant() { + calls += 1; + throw new Error("invalid target must not reach the service"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request(`/api/conversations/${conversationId}/participants/not-a-uuid`, { + method: "DELETE", + headers + }); + + assert.equal(response.status, 404); + assert.equal(calls, 0); +}); + +test("DELETE participant forwards the target and returns the self_left / new owner result", async () => { + const runtimeSettings = settings(); + let seen: unknown; + const app = routeApp( + runtimeSettings, + service({ + async removeParticipant(input) { + seen = input; + return { removed_user_id: otherUserId, self_left: false, new_owner_user_id: null }; + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request(`/api/conversations/${conversationId}/participants/${otherUserId}`, { + method: "DELETE", + headers + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + ok: true, + data: { removed_user_id: otherUserId, self_left: false, new_owner_user_id: null } + }); + assert.equal((seen as { conversationId: string }).conversationId, conversationId); + assert.equal((seen as { targetUserId: string }).targetUserId, otherUserId); +}); + +test("DELETE participant preserves the service's typed 409 for the last remaining member", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + service({ + async removeParticipant() { + throw new ConversationServiceError(409, "conversation_last_participant", "你是最后一名成员,不能退出。"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request(`/api/conversations/${conversationId}/participants/${otherUserId}`, { + method: "DELETE", + headers + }); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { + ok: false, + error: { code: "conversation_last_participant", message: "你是最后一名成员,不能退出。" } + }); +}); diff --git a/apps/api/src/routes/conversation-participants.ts b/apps/api/src/routes/conversation-participants.ts index 49f39b39..c3134616 100644 --- a/apps/api/src/routes/conversation-participants.ts +++ b/apps/api/src/routes/conversation-participants.ts @@ -1,5 +1,7 @@ import { Hono } from "hono"; +import { addConversationParticipantRequestSchema } from "@workhub/contracts"; + import { createCurrentUserMiddleware, getDefaultAuthDependencies, @@ -11,12 +13,18 @@ import { getDefaultConversationService, type ConversationService } from "../services/conversations.js"; +import { readJsonObject } from "./json-body.js"; import { isUuidParam } from "./uuid-param.js"; // R15 批 cuu-toggle:会话参与者列表——GET /api/conversations/:id/participants。main 诚实回 // scope:"workspace" + 空列表(它没有 conversation_participants 行,全员可见);collab(含 DM)回 // scope:"participants" + 真实参与者(user_id/昵称/角色)。参与者门控与消息可见性同口径——非参与者的 // collab 在 services/conversations.ts 的 visibleConversation() 就已经 404,路由层不重复判断。 +// +// R17 批 G1(群成员管理):新增两条写路由——POST /participants(建群后加人,body {user_id})与 +// DELETE /participants/:userId(退群/移出)。语义红线(main 全员/DM 2 人各自 409、加人上限、owner 退群 +// 继任、最后一人 409、移出他人仅 owner)全在 services/conversations.ts 的 addParticipant/removeParticipant +// 里做,路由层只做 body/param 校验与成形响应。错误经 app.onError 的 ConversationServiceError 分支映射。 export type ConversationParticipantsRoutesDependencies = { auth?: AuthDependencySource; @@ -30,6 +38,15 @@ function requireConversationId(value: string) { return value; } +// 被加/移出的目标 user id 必须是形如 UUID 的合法值——非法 param 直接 404(不进服务层),与 +// requireConversationId 同款 fail-closed,避免把垃圾串带进仓库查询。 +function requireTargetUserId(value: string) { + if (!isUuidParam(value)) { + throw new ConversationServiceError(404, "conversation_participant_not_found", "这个人不在这个会话里。"); + } + return value; +} + export function createConversationParticipantsRoutes(deps: ConversationParticipantsRoutesDependencies = {}) { const routes = new Hono(); const authSource = deps.auth ?? getDefaultAuthDependencies; @@ -42,5 +59,27 @@ export function createConversationParticipantsRoutes(deps: ConversationParticipa return c.json({ ok: true, data }); }); + routes.post("/conversations/:id/participants", requireCurrentUser, async (c) => { + const conversationId = requireConversationId(c.req.param("id")); + const payload = addConversationParticipantRequestSchema.parse(await readJsonObject(c)); + const data = await conversations.addParticipant({ + actor: c.var.actor, + conversationId, + addUserId: payload.user_id + }); + return c.json({ ok: true, data }); + }); + + routes.delete("/conversations/:id/participants/:userId", requireCurrentUser, async (c) => { + const conversationId = requireConversationId(c.req.param("id")); + const targetUserId = requireTargetUserId(c.req.param("userId")); + const data = await conversations.removeParticipant({ + actor: c.var.actor, + conversationId, + targetUserId + }); + return c.json({ ok: true, data }); + }); + return routes; } diff --git a/apps/api/src/routes/conversation-typing.test.ts b/apps/api/src/routes/conversation-typing.test.ts index ed004668..9217755c 100644 --- a/apps/api/src/routes/conversation-typing.test.ts +++ b/apps/api/src/routes/conversation-typing.test.ts @@ -158,6 +158,12 @@ function conversationsService(overrides: Partial = {}): Con async listParticipants() { throw new Error("not needed"); }, + async addParticipant() { + throw new Error("not needed"); + }, + async removeParticipant() { + throw new Error("not needed"); + }, async listMessages(): Promise { throw new Error("not needed"); }, diff --git a/apps/api/src/services/conversations.ts b/apps/api/src/services/conversations.ts index 17d0c1d8..4ad3cd0d 100644 --- a/apps/api/src/services/conversations.ts +++ b/apps/api/src/services/conversations.ts @@ -1,6 +1,10 @@ import { ConversationAccessDeniedError, ConversationDmTargetError, + ConversationNotGroupError, + ConversationParticipantCapError, + ConversationParticipantNotFoundError, + ConversationLastParticipantError, ConversationMessageActorMismatchError, ConversationMessageDeletedError, ConversationMessageEditWindowError, @@ -32,6 +36,9 @@ import { } from "@workhub/db"; import { conversationCuuUpdatedEventSchema, + conversationParticipantsUpdatedEventSchema, + addConversationParticipantResultVmSchema, + removeConversationParticipantResultVmSchema, conversationFileCardContentSchema, conversationMessageCreatedEventSchema, conversationMessageUpdatedEventSchema, @@ -53,6 +60,8 @@ import { updateConversationCuuResultVmSchema, eventTypes, MAX_CONVERSATION_REPLY_PREVIEW_CODE_UNITS, + type AddConversationParticipantResultVM, + type RemoveConversationParticipantResultVM, type AdvanceReadCursorRequest, type ConversationListPageVM, type ConversationListQuery, @@ -77,6 +86,20 @@ import { } from "@workhub/contracts"; import { makeWorkHubEvent, topics } from "@workhub/events"; +// ── R17 批 G1(群成员管理 · 拍板 A:项目成员=会话参与者的可管理化) ───────────────────────── +// 决策(06-gap-fix-plan.md 拍板 A,负责人代决可逆):当前产品单工作区形态下不新建「项目团队」数据层。 +// 「项目成员」不作为独立概念存在——工作区花名册(workspace_members)≈项目候选池,「谁在这个项目里」以 +// 「该项目各协同会话的参与者集合」的可管理化落地。本服务因此把参与者集合从「建群那一刻冻结的快照」升级 +// 为可增删的管理对象: +// * addParticipant(POST /participants)—— 建群后往普通协同会话加人(main 全员语义/DM 2 人不变量各自 +// 409); +// * removeParticipant(DELETE /participants/:userId)—— 退群(自删)/移出(owner 删他人),owner 退群 +// 时最早加入者自动升 owner,最后一人 409。 +// 未来扩展点:若多项目隔离需求变强需要独立「项目团队」层(project_members 表 + 项目级角色/邀请),本批的 +// 参与者增删端点可整体复用为「会话级成员」子概念,届时在其上叠一层「项目成员 → 默认拉进主区/各协同会话」 +// 的物化规则即可,不必推翻这里的会话参与者模型。 +// ─────────────────────────────────────────────────────────────────────────────────────────── + // R14 批 CHAT:编辑窗(15 分钟)——服务端策略常量,传给仓库层并进同一次原子判定。 const CONVERSATION_EDIT_WINDOW_MS = 15 * 60 * 1000; // R14 批 CHAT:置顶清单读取上限(设计 §3:seq 降序 cap 50)。 @@ -153,6 +176,21 @@ export type ConversationService = { // R15 批 cuu-toggle:会话参与者列表——main 诚实回 scope:"workspace" + 空列表,collab(含 DM)回真实 // 参与者。参与者门控与消息可见性同口径(非参与者在 visibleConversation() 就已经 404)。 listParticipants(input: { actor: AuthActor; conversationId: string }): Promise; + // R17 批 G1(#1 建群后加人):往普通协同会话加人——仅 collab 非 DM(main/DM 各自 409)、调用者须为 + // 该会话参与者(visibleConversation 已挡住非参与者)、目标须同工作区活跃成员、上限沿用建群 cap、 + // 唯一约束幂等(已在群里回 added=false + 现有列表)。回刷新后的完整参与者列表。 + addParticipant(input: { + actor: AuthActor; + conversationId: string; + addUserId: string; + }): Promise; + // R17 批 G1(#16 退群/移出):自删=退群(任何参与者可)、owner 删他人=移出;main/DM 各自 409;owner + // 退群且群里还有他人 → 最早加入者自动升 owner;只剩自己 409(最后一人不能退出)。 + removeParticipant(input: { + actor: AuthActor; + conversationId: string; + targetUserId: string; + }): Promise; listMessages(input: { actor: AuthActor; conversationId: string; @@ -466,6 +504,19 @@ function mapRepositoryError(error: unknown): never { if (error instanceof ConversationDmTargetError) { throw new ConversationServiceError(404, "conversation_dm_target_not_found", "没有找到这个工作区里的这个人。"); } + // R17 批 G1(群成员管理):加人/退群/移出的结构性失败映射。 + if (error instanceof ConversationNotGroupError) { + throw new ConversationServiceError(409, "conversation_not_group", "这个会话不支持单独增删成员。"); + } + if (error instanceof ConversationParticipantCapError) { + throw new ConversationServiceError(409, "conversation_participant_cap", "这个群的成员已经满了。"); + } + if (error instanceof ConversationParticipantNotFoundError) { + throw new ConversationServiceError(404, "conversation_participant_not_found", "这个人不在这个会话里。"); + } + if (error instanceof ConversationLastParticipantError) { + throw new ConversationServiceError(409, "conversation_last_participant", "你是最后一名成员,不能退出。"); + } if (error instanceof ConversationSequenceExhaustedError) { throw new ConversationServiceError(409, "conversation_sequence_exhausted", "这个会话的消息序号已经耗尽。"); } @@ -688,6 +739,38 @@ export function createConversationService( } } + // R17 批 G1(群成员管理):参与者集合变化后广播 conversation.participants.updated(best-effort,同 + // publishConversationCuuUpdated 的既有取舍——加人/移出本身已经落库成功,不因广播失败回滚)。让别的开着 + // 这个会话的客户端就地重拉参与者(成员条 / 已读 N/M 分母),接不上就等下次重挂时用 GET /participants 兜底。 + async function publishParticipantsUpdated( + access: ConversationRow, + change: "added" | "removed", + userId: string + ) { + const conversationTopic = topics.conversation(access.id).topic; + const event = parseOutputContract( + conversationParticipantsUpdatedEventSchema, + makeWorkHubEvent({ + type: eventTypes.conversationParticipantsUpdated, + topic: conversationTopic, + ts: now(), + data: { conversation_id: access.id, change, user_id: userId.toLowerCase() } + }), + "conversations.participants.event.updated" + ); + try { + await bus.publish(conversationTopic, eventTypes.conversationParticipantsUpdated, event); + } catch (error) { + logger.warn("conversation_participants_updated_publish_failed", { + event_id: event.event_id, + topic: conversationTopic, + conversation_id: access.id, + broker_backend: bus.backend, + error + }); + } + } + function parseReactionKey(rawKey: string): ConversationReactionKey { const parsed = conversationReactionKeySchema.safeParse(rawKey); if (!parsed.success) { @@ -978,6 +1061,116 @@ export function createConversationService( ); }, + // ── R17 批 G1(#1 建群后加人) ──────────────────────────────────────────────────── + // 红线: + // 1. 会话可见(visibleConversation 已 404 挡住不可见者——collab 只对参与者可见,因此调用者天然是 + // 参与者,无需再判 participantRole); + // 2. 仅 collab 非 DM——main(全员语义)409,DM(2 人不变量)409; + // 3. 目标须同工作区活跃成员(仓库层 lockActiveMembershipSet 校验,映射到 400); + // 4. 上限沿用建群 cap、唯一约束幂等(已在群里回 added=false + 现有列表)——仓库层。 + // 真加人才广播 participants.updated(幂等命中既有成员不广播,避免无意义刷新)。回刷新后完整列表。 + async addParticipant(input) { + const { human, access } = await visibleConversation(input); + const conversation = access.conversation; + if (conversation.kind !== "collab") { + throw new ConversationServiceError( + 409, + "conversation_not_group", + "主区是全员会话,不需要也不能单独加人。" + ); + } + if (conversation.dmKey) { + throw new ConversationServiceError(409, "conversation_dm_no_add", "私聊只能是两个人,不能再加人。"); + } + const addUserId = input.addUserId.trim().toLowerCase(); + if (!addUserId) { + throw new ConversationServiceError(400, "conversation_add_target_required", "请选择要加入的成员。"); + } + let result: Awaited>; + try { + result = await repository.addParticipant({ + workspaceId: human.workspaceId, + conversationId: conversation.id, + addedUserId: addUserId, + at: now() + }); + } catch (error) { + mapRepositoryError(error); + } + if (result.added) { + await publishParticipantsUpdated(conversation, "added", result.participant.userId); + } + const rows = await repository.listParticipantsWithNickname({ conversationId: conversation.id }); + return parseOutputContract( + addConversationParticipantResultVmSchema, + { + added: result.added, + participants: { + scope: "participants" as const, + participants: rows.map((row) => ({ user_id: row.userId, nickname: row.nickname, role: row.role })) + } + }, + "conversations.participants.add" + ); + }, + + // ── R17 批 G1(#16 退群/移出) ──────────────────────────────────────────────────── + // 红线: + // 1. 会话可见(visibleConversation 已 404 挡住不可见者,collab 调用者天然是参与者); + // 2. 仅 collab 非 DM——main(全员语义)409,DM(2 人不变量)409; + // 3. 自己删自己=退群(任何参与者可);删别人=移出,仅 owner 可做(access.participantRole==='owner'); + // 4. owner 退群且群里还有他人 → 最早加入者自动升 owner(仓库层事务内);只剩自己 409(最后一人)。 + // 被移出者的读游标/消息保留(仓库层只删 participant 行)。真移除才广播 participants.updated。 + async removeParticipant(input) { + const { human, access } = await visibleConversation(input); + const conversation = access.conversation; + if (conversation.kind !== "collab") { + throw new ConversationServiceError( + 409, + "conversation_not_group", + "主区是全员会话,不能退出或移出成员。" + ); + } + if (conversation.dmKey) { + throw new ConversationServiceError(409, "conversation_dm_no_remove", "私聊只能是两个人,不能移出或退出。"); + } + const targetUserId = input.targetUserId.trim().toLowerCase(); + if (!targetUserId) { + throw new ConversationServiceError(400, "conversation_remove_target_required", "请选择要移出的成员。"); + } + const selfLeft = targetUserId === human.userId.toLowerCase(); + if (!selfLeft && access.participantRole !== "owner") { + throw new ConversationServiceError( + 403, + "conversation_remove_forbidden", + "只有群主可以移出其他成员。" + ); + } + let result: Awaited>; + try { + result = await repository.removeParticipant({ + workspaceId: human.workspaceId, + conversationId: conversation.id, + targetUserId, + at: now() + }); + } catch (error) { + mapRepositoryError(error); + } + if (result.removed) { + await publishParticipantsUpdated(conversation, "removed", targetUserId); + } + return parseOutputContract( + removeConversationParticipantResultVmSchema, + { + removed_user_id: targetUserId, + self_left: selfLeft, + new_owner_user_id: result.newOwnerUserId + }, + "conversations.participants.remove" + ); + }, + async listMessages(input) { const human = requireHumanActor(input.actor); // R12 批8:beforeSeq(反向翻页)和 afterSeq(正向,批 0 既有)在契约层互斥—— diff --git a/packages/contracts/src/domain/conversation.ts b/packages/contracts/src/domain/conversation.ts index 243b5fd4..2020e618 100644 --- a/packages/contracts/src/domain/conversation.ts +++ b/packages/contracts/src/domain/conversation.ts @@ -715,6 +715,39 @@ export const conversationParticipantVmSchema = z .strict(); export type ConversationParticipantVM = z.infer; +// R17 批 G1(群成员管理 · #1 建群后加人):POST /api/conversations/:id/participants 的请求体——只带要 +// 加入的成员 user_id。语义红线(仅 collab 非 DM、调用者须为该会话参与者、目标须同工作区活跃成员、上限 +// 沿用建群 cap、唯一约束幂等)在服务/仓库层强制,见 apps/api/src/services/conversations.ts addParticipant。 +export const addConversationParticipantRequestSchema = z + .object({ + user_id: idSchema + }) + .strict(); +export type AddConversationParticipantRequest = z.infer; + +// 加人成功的响应——回刷新后的完整参与者列表(scope:"participants"),客户端据此直接替换成员条。 +// 幂等:目标已在群里时也回 200 + 现有列表(added=false),不报错(撞唯一约束 = 既有成员)。 +export const addConversationParticipantResultVmSchema = z + .object({ + added: z.boolean(), + participants: conversationParticipantsVmSchema + }) + .strict(); +export type AddConversationParticipantResultVM = z.infer; + +// R17 批 G1(#16 退群/移出):DELETE /api/conversations/:id/participants/:userId 的响应。 +// * self_left:这次是不是自己退群(客户端据此关会话/回列表 vs 就地刷新成员条)。 +// * new_owner_user_id:owner 退群且群里还有他人时,最早加入的 member 自动升 owner(事务内),带上 +// 继任者 id;其余情况(普通 member 退群、owner 移出别人、群里只剩自己已被 409 挡住)为 null。 +export const removeConversationParticipantResultVmSchema = z + .object({ + removed_user_id: idSchema, + self_left: z.boolean(), + new_owner_user_id: idSchema.nullable() + }) + .strict(); +export type RemoveConversationParticipantResultVM = z.infer; + export const createConversationResultVmSchema = z .object({ conversation: conversationVmSchema, diff --git a/packages/contracts/src/enums.ts b/packages/contracts/src/enums.ts index 03aeba38..6a972bc6 100644 --- a/packages/contracts/src/enums.ts +++ b/packages/contracts/src/enums.ts @@ -199,6 +199,11 @@ export const eventTypes = { // R15 批 cuu-toggle:会话级 Cuu 参与开关翻转后广播(PATCH /cuu),让其它开着这个会话的客户端头部 // 同步开关状态,不必等下次重挂才看到。 conversationCuuUpdated: "conversation.cuu.updated", + // R17 批 G1(群成员管理):会话参与者集合变化后广播——加人(POST /participants)、退群/移出(DELETE + // /participants/:userId)。让其它开着这个会话的客户端就地重拉参与者(成员条 / 已读 N/M 分母);同 + // cuu.updated 的既有取舍——只带 conversation_id + 变化类型 + 受影响 user_id,客户端据此按需重拉 + // GET /participants,接不上就等下次重挂时兜底,不强求必达。 + conversationParticipantsUpdated: "conversation.participants.updated", conversationToolBegin: "conversation.tool.begin", conversationToolOutputDelta: "conversation.tool.output_delta", conversationToolEnd: "conversation.tool.end", diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 9b9e4366..a0a14586 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -458,3 +458,32 @@ export const conversationCuuUpdatedEventSchema = z } }); export type ConversationCuuUpdatedEvent = z.infer; + +// R17 批 G1(群成员管理):conversation.participants.updated——参与者集合变化后广播(加人/退群/移出)。 +// data 只带 conversation_id + 变化类型 + 受影响 user_id(不带全量参与者列表,客户端据此按需重拉 GET +// /participants,同 cuu.updated 只带布尔值、不带全量会话 VM 的既有取舍:接不上就等下次重挂时兜底)。 +export const conversationParticipantsUpdatedEventSchema = z + .object({ + event_id: idSchema, + type: z.literal("conversation.participants.updated"), + topic: z.string().min(1), + ts: isoDateTimeSchema, + data: z + .object({ + conversation_id: idSchema, + change: z.enum(["added", "removed"]), + user_id: idSchema + }) + .strict() + }) + .strict() + .superRefine((event, ctx) => { + if (event.topic !== `conversation:${event.data.conversation_id}`) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["topic"], + message: "participants-updated topic must match data.conversation_id" + }); + } + }); +export type ConversationParticipantsUpdatedEvent = z.infer; diff --git a/packages/contracts/src/r14-chat.test.ts b/packages/contracts/src/r14-chat.test.ts index 065dd429..ed959002 100644 --- a/packages/contracts/src/r14-chat.test.ts +++ b/packages/contracts/src/r14-chat.test.ts @@ -321,3 +321,74 @@ test("R15 conversation participants VM: main is scope=workspace + empty, collab/ ); assert.equal(schema.safeParse({ scope: "workspace", participants: [], extra: true }).success, false); }); + +// ── R17 批 G1(群成员管理)契约 ───────────────────────────────────────────────────────────── + +test("R17 G1 add participant request: only user_id, rejects extras and missing", () => { + const schema = requiredSchema>("addConversationParticipantRequestSchema"); + assert.equal(schema.safeParse({ user_id: userId }).success, true); + assert.equal(schema.safeParse({}).success, false); + assert.equal(schema.safeParse({ user_id: "not-a-uuid" }).success, false); + assert.equal(schema.safeParse({ user_id: userId, role: "member" }).success, false); +}); + +test("R17 G1 add participant result: added flag + nested participants VM, extras rejected", () => { + const schema = requiredSchema>("addConversationParticipantResultVmSchema"); + const ok = { + added: true, + participants: { + scope: "participants", + participants: [{ user_id: userId, nickname: "阿曼", role: "owner" }] + } + }; + assert.equal(schema.safeParse(ok).success, true); + assert.equal(schema.safeParse({ ...ok, extra: 1 }).success, false); + assert.equal(schema.safeParse({ participants: ok.participants }).success, false); +}); + +test("R17 G1 remove participant result: self_left + nullable new owner, extras rejected", () => { + const schema = requiredSchema>("removeConversationParticipantResultVmSchema"); + assert.equal( + schema.safeParse({ removed_user_id: userId, self_left: true, new_owner_user_id: null }).success, + true + ); + assert.equal( + schema.safeParse({ removed_user_id: userId, self_left: false, new_owner_user_id: otherUserId }).success, + true + ); + assert.equal(schema.safeParse({ removed_user_id: userId, self_left: true }).success, false); + assert.equal( + schema.safeParse({ removed_user_id: userId, self_left: true, new_owner_user_id: null, extra: 1 }).success, + false + ); +}); + +test("R17 G1 participants.updated event: topic must match conversation_id, change is an enum", () => { + const schema = requiredSchema>("conversationParticipantsUpdatedEventSchema"); + const base = { + event_id: eventId, + type: "conversation.participants.updated", + topic: `conversation:${conversationId}`, + ts, + data: { conversation_id: conversationId, change: "added", user_id: userId } + }; + assert.equal(schema.safeParse(base).success, true); + assert.equal(schema.safeParse({ ...base, data: { ...base.data, change: "removed" } }).success, true); + assert.equal(schema.safeParse({ ...base, topic: "conversation:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }).success, false); + assert.equal(schema.safeParse({ ...base, data: { ...base.data, change: "joined" } }).success, false); +}); + +test("R17 G1 workspace member role request + result schemas", () => { + const roleReq = requiredSchema>("updateWorkspaceMemberRoleRequestSchema"); + assert.equal(roleReq.safeParse({ role: "admin" }).success, true); + assert.equal(roleReq.safeParse({ role: "superuser" }).success, false); + assert.equal(roleReq.safeParse({ role: "member", extra: 1 }).success, false); + + const removeVm = requiredSchema>("removeWorkspaceMemberResultVmSchema"); + assert.equal(removeVm.safeParse({ removed_user_id: userId }).success, true); + assert.equal(removeVm.safeParse({ removed_user_id: userId, extra: 1 }).success, false); + + const roleVm = requiredSchema>("updateWorkspaceMemberRoleResultVmSchema"); + assert.equal(roleVm.safeParse({ user_id: userId, role: "owner" }).success, true); + assert.equal(roleVm.safeParse({ user_id: userId, role: "boss" }).success, false); +}); diff --git a/packages/db/src/conversation-repository.test.ts b/packages/db/src/conversation-repository.test.ts index d27e69aa..a17e2baf 100644 --- a/packages/db/src/conversation-repository.test.ts +++ b/packages/db/src/conversation-repository.test.ts @@ -6,6 +6,10 @@ import { ConversationAccessDeniedError, ConversationDmTargetError, ConversationInsertFailedError, + ConversationLastParticipantError, + ConversationNotGroupError, + ConversationParticipantCapError, + ConversationParticipantNotFoundError, ConversationMessageActorMismatchError, ConversationMessageDeletedError, ConversationMessageEditWindowError, @@ -2356,3 +2360,249 @@ test("R15 A5 listParticipantUserIds lowercases and reads the conversation's part assert.ok(queryReferences(query?.where, conversationParticipants.conversationId)); assert.equal(query?.limit, 500); }); + +// ── R17 批 G1(群成员管理 · #1 建群后加人 / #16 退群/移出) ───────────────────────────────── + +test("R17 G1 addParticipant inserts a member into a non-dm collab and returns added=true", async () => { + const created = participant(secondMemberUserId, "member"); + const { db, queries, transactions } = createQueryRecorder([ + [{ projectId }], // readConversationProjectId + [conversation()], // lockActiveConversation (collab, dm_key null) + [{ id: "43000000-0000-4000-8000-000000000009", userId: secondMemberUserId }], // lockActiveMembershipSet + [], // existing participant (not present) + [{ value: 2 }], // participant count + [created] // insert returning + ]); + + const result = await createConversationRepository(db).addParticipant({ + workspaceId, + conversationId, + addedUserId: secondMemberUserId.toUpperCase(), + at: now + }); + + assert.deepEqual(result, { participant: created, added: true }); + assert.deepEqual(transactions, [{ outcome: "resolved" }]); + const insert = queries.find((query) => query.operation === "insert"); + assert.equal(insert?.targetTable, conversationParticipants); + assert.equal(insert?.returningCalled, true); + const values = insert?.valuesValue as Record; + assert.equal(values.role, "member"); + assert.equal(values.userId, secondMemberUserId.toLowerCase()); +}); + +test("R17 G1 addParticipant is idempotent — an existing participant returns added=false without inserting", async () => { + const existing = participant(secondMemberUserId, "member"); + const { db, queries } = createQueryRecorder([ + [{ projectId }], + [conversation()], + [{ id: "43000000-0000-4000-8000-000000000009", userId: secondMemberUserId }], + [existing] // existing participant present → short-circuit + ]); + + const result = await createConversationRepository(db).addParticipant({ + workspaceId, + conversationId, + addedUserId: secondMemberUserId, + at: now + }); + + assert.deepEqual(result, { participant: existing, added: false }); + assert.equal(queries.some((query) => query.operation === "insert"), false); +}); + +test("R17 G1 addParticipant rejects a main conversation (all-member semantics)", async () => { + const { db, queries, transactions } = createQueryRecorder([ + [{ projectId }], + [conversation({ kind: "main" })] + ]); + + await assert.rejects( + createConversationRepository(db).addParticipant({ + workspaceId, + conversationId, + addedUserId: secondMemberUserId, + at: now + }), + (error: unknown) => error instanceof ConversationNotGroupError + ); + assert.deepEqual(transactions, [{ outcome: "rejected", errorName: "ConversationNotGroupError" }]); + assert.equal(queries.some((query) => query.operation === "insert"), false); +}); + +test("R17 G1 addParticipant rejects a dm conversation (2-person invariant)", async () => { + const { db } = createQueryRecorder([ + [{ projectId }], + [conversation({ dmKey: normalizeDmKey(creatorUserId, memberUserId) })] + ]); + + await assert.rejects( + createConversationRepository(db).addParticipant({ + workspaceId, + conversationId, + addedUserId: secondMemberUserId, + at: now + }), + (error: unknown) => error instanceof ConversationNotGroupError + ); +}); + +test("R17 G1 addParticipant rejects a target that is not an active workspace member", async () => { + const { db } = createQueryRecorder([ + [{ projectId }], + [conversation()], + [] // membership lock returns nothing → not a member + ]); + + await assert.rejects( + createConversationRepository(db).addParticipant({ + workspaceId, + conversationId, + addedUserId: secondMemberUserId, + at: now + }), + (error: unknown) => error instanceof ConversationParticipantMembershipError + ); +}); + +test("R17 G1 addParticipant rejects once the participant cap (100) is reached", async () => { + const { db } = createQueryRecorder([ + [{ projectId }], + [conversation()], + [{ id: "43000000-0000-4000-8000-000000000009", userId: secondMemberUserId }], + [], // not an existing participant + [{ value: 100 }] // already at cap + ]); + + await assert.rejects( + createConversationRepository(db).addParticipant({ + workspaceId, + conversationId, + addedUserId: secondMemberUserId, + at: now + }), + (error: unknown) => error instanceof ConversationParticipantCapError + ); +}); + +test("R17 G1 removeParticipant lets a plain member leave without promoting a new owner", async () => { + const owner = participant(creatorUserId, "owner"); + const leaving = participant(memberUserId, "member"); + const { db, queries, transactions } = createQueryRecorder([ + [{ projectId }], + [conversation()], + [owner, leaving], // participants lock (createdAt asc) + [] // delete target + ]); + + const result = await createConversationRepository(db).removeParticipant({ + workspaceId, + conversationId, + targetUserId: memberUserId, + at: now + }); + + assert.deepEqual(result, { removed: true, newOwnerUserId: null }); + assert.deepEqual(transactions, [{ outcome: "resolved" }]); + assert.equal(queries.some((query) => query.operation === "update"), false, "no owner handover for a member leaving"); + const del = queries.find((query) => query.operation === "delete"); + assert.equal(del?.targetTable, conversationParticipants); +}); + +test("R17 G1 removeParticipant promotes the earliest-joined remaining participant when the owner leaves", async () => { + const owner = participant(creatorUserId, "owner"); + const firstMember = participant(memberUserId, "member", { + id: "23000000-0000-4000-8000-000000000041", + createdAt: new Date("2026-07-12T09:01:00.000Z") + }); + const secondMember = participant(secondMemberUserId, "member", { + id: "23000000-0000-4000-8000-000000000042", + createdAt: new Date("2026-07-12T09:02:00.000Z") + }); + const { db, queries } = createQueryRecorder([ + [{ projectId }], + [conversation()], + [owner, firstMember, secondMember], // ordered createdAt asc; owner is the target below + [], // update successor + [] // delete target + ]); + + const result = await createConversationRepository(db).removeParticipant({ + workspaceId, + conversationId, + targetUserId: creatorUserId, + at: now + }); + + assert.deepEqual(result, { removed: true, newOwnerUserId: memberUserId }); + const update = queries.find((query) => query.operation === "update"); + assert.equal(update?.targetTable, conversationParticipants); + assert.deepEqual(update?.setValue, { role: "owner", updatedAt: now }); +}); + +test("R17 G1 removeParticipant refuses to remove the last remaining member", async () => { + const soleOwner = participant(creatorUserId, "owner"); + const { db, transactions } = createQueryRecorder([ + [{ projectId }], + [conversation()], + [soleOwner] // only one participant + ]); + + await assert.rejects( + createConversationRepository(db).removeParticipant({ + workspaceId, + conversationId, + targetUserId: creatorUserId, + at: now + }), + (error: unknown) => error instanceof ConversationLastParticipantError + ); + assert.deepEqual(transactions, [{ outcome: "rejected", errorName: "ConversationLastParticipantError" }]); +}); + +test("R17 G1 removeParticipant 404s a target that is not a participant", async () => { + const owner = participant(creatorUserId, "owner"); + const other = participant(memberUserId, "member"); + const { db } = createQueryRecorder([ + [{ projectId }], + [conversation()], + [owner, other] + ]); + + await assert.rejects( + createConversationRepository(db).removeParticipant({ + workspaceId, + conversationId, + targetUserId: secondMemberUserId, + at: now + }), + (error: unknown) => error instanceof ConversationParticipantNotFoundError + ); +}); + +test("R17 G1 removeParticipant rejects main and dm conversations", async () => { + const mainRecorder = createQueryRecorder([[{ projectId }], [conversation({ kind: "main" })]]); + await assert.rejects( + createConversationRepository(mainRecorder.db).removeParticipant({ + workspaceId, + conversationId, + targetUserId: creatorUserId, + at: now + }), + (error: unknown) => error instanceof ConversationNotGroupError + ); + + const dmRecorder = createQueryRecorder([ + [{ projectId }], + [conversation({ dmKey: normalizeDmKey(creatorUserId, memberUserId) })] + ]); + await assert.rejects( + createConversationRepository(dmRecorder.db).removeParticipant({ + workspaceId, + conversationId, + targetUserId: creatorUserId, + at: now + }), + (error: unknown) => error instanceof ConversationNotGroupError + ); +}); diff --git a/packages/db/src/repositories/conversations.ts b/packages/db/src/repositories/conversations.ts index 5e43ac20..955f7b65 100644 --- a/packages/db/src/repositories/conversations.ts +++ b/packages/db/src/repositories/conversations.ts @@ -265,6 +265,38 @@ export type DmListForUserRow = { participants: DmParticipantRow[]; }; +// R17 批 G1(群成员管理 · #1 建群后加人):POST /participants 的仓库输入。语义红线(仅 collab 非 DM、 +// 调用者须为参与者、目标须同工作区活跃成员)由服务层先行把关;仓库只负责租户安全的原子写 + 上限 + +// 唯一约束幂等。addedUserId 加入后角色恒 member(owner 只在建群/DM 首建时产生)。 +export type AddConversationParticipantInput = { + workspaceId: string; + conversationId: string; + addedUserId: string; + at?: Date; +}; + +// added=true 表示这次真的新增了参与者;false 表示目标已在群里(撞唯一约束 → 幂等回既有行)。 +export type AddConversationParticipantResult = { + participant: ConversationParticipantRow; + added: boolean; +}; + +// R17 批 G1(#16 退群/移出):DELETE /participants/:userId 的仓库输入。actorUserId=发起人; +// targetUserId=被移出者(自删=退群、owner 删他人=移出)。owner 退群且群里还有他人时事务内把最早加入的 +// member 升 owner(见实现)。语义红线(main/DM 409、owner 才能移出他人、最后一人 409)在服务层强制。 +export type RemoveConversationParticipantInput = { + workspaceId: string; + conversationId: string; + targetUserId: string; + at?: Date; +}; + +// removed=true 表示这次真的移除了一行;newOwnerUserId 在「owner 退群 → 继任者升 owner」时带继任者 id。 +export type RemoveConversationParticipantResult = { + removed: boolean; + newOwnerUserId: string | null; +}; + // R15 批 B:DM 会话查重键——两个 user id 归一化(小写)后排序,再拼 'dm:' 前缀。同一对用户与顺序无关 // 得到同一个 key(workspace 维度由容器项目 id 天然限定,见 dmKey 列/唯一索引注释)。导出供单测直接覆盖。 export function normalizeDmKey(userIdA: string, userIdB: string): string { @@ -435,6 +467,11 @@ export type ConversationRepository = { listParticipantsWithNickname: (input: { conversationId: string; }) => Promise; + // R17 批 G1(群成员管理):新增,不改动上面任何既有方法——加人/退群/移出。语义红线在服务层强制 + // (见 apps/api/src/services/conversations.ts),仓库层负责租户安全的原子写、上限、唯一约束幂等与 + // owner 退群时的继任者升迁(都在同一事务里做,不跨调用留 TOCTOU 缝)。 + addParticipant: (input: AddConversationParticipantInput) => Promise; + removeParticipant: (input: RemoveConversationParticipantInput) => Promise; // R14 批 CHAT:以下全部新增,不改动上面任何既有方法。语义红线在服务层强制(见 apps/api/src/ // services/conversations.ts),仓库层负责租户安全的原子写与页级无 N+1 聚合。 editMessage: (input: EditMessageInput) => Promise; @@ -532,6 +569,15 @@ export class ConversationReplyTargetError extends NamedConversationRepositoryErr export class ConversationDmTargetError extends NamedConversationRepositoryError {} // R15 批 B:DM 容器项目惰性创建/回查都落空的兜底(理论上不该发生:唯一索引兜底并发后必能回查到)。 export class ConversationDmContainerError extends NamedConversationRepositoryError {} +// R17 批 G1(群成员管理):目标会话不是可加人的普通协同会话——main(全员语义)/DM(2 人不变量) +// /已删除会话命中这条,服务层映射到 409。 +export class ConversationNotGroupError extends NamedConversationRepositoryError {} +// 加人后总人数会超过建群上限(99 名成员 + 1 名 owner = 100)——服务层映射到 409。 +export class ConversationParticipantCapError extends NamedConversationRepositoryError {} +// 退群/移出时目标不是这个会话的参与者——服务层映射到 404。 +export class ConversationParticipantNotFoundError extends NamedConversationRepositoryError {} +// 退群时群里只剩自己一名参与者(最后一人不能退出,取窄不做归档旁路)——服务层映射到 409。 +export class ConversationLastParticipantError extends NamedConversationRepositoryError {} const conversationSelection = { id: projectConversations.id, @@ -2014,6 +2060,168 @@ export function createConversationRepository(db: WorkHubDb): ConversationReposit })); }, + // ── R17 批 G1(群成员管理 · #1 建群后加人) ─────────────────────────────────────── + // 只普通协同会话可加人:main(全员语义,无 participant 行)与 DM(dm_key 非空、2 人不变量)都拒。 + // 目标须为同工作区活跃成员;上限沿用建群 cap(99 名成员 + 1 名 owner = 100 总人数);唯一约束 + // (conversation_id, user_id) 兜重复——目标已在群里则幂等回既有行(added=false),不受上限影响。 + // 语义红线里的「调用者须为参与者」由服务层的 visibleConversation() 先行把关(collab 只对参与者 + // 可见),仓库层不重复判定;这里只负责租户安全的原子写。 + async addParticipant(input) { + const at = input.at ?? new Date(); + const addedUserId = input.addedUserId.trim().toLowerCase(); + if (!addedUserId) { + throw new ConversationRepositoryInputError("add participant requires a target user id"); + } + return db.transaction(async (tx) => { + const locator = await readConversationProjectId(tx, { + workspaceId: input.workspaceId, + conversationId: input.conversationId + }); + if (!locator) { + throw new ConversationAccessDeniedError("conversation is not active"); + } + const conversation = await lockActiveConversation(tx, { + workspaceId: input.workspaceId, + projectId: locator.projectId, + conversationId: input.conversationId + }); + if (!conversation) { + throw new ConversationAccessDeniedError("conversation is not active"); + } + if (conversation.kind !== "collab" || conversation.dmKey !== null) { + throw new ConversationNotGroupError("only non-dm collab conversations accept new participants"); + } + const memberships = await lockActiveMembershipSet(tx, { + workspaceId: input.workspaceId, + userIds: [addedUserId] + }); + if (!memberships.some((row) => row.userId.toLowerCase() === addedUserId)) { + throw new ConversationParticipantMembershipError("target user must be an active workspace member"); + } + // 幂等短路:目标已在群里 → 回既有行(不受上限影响)。for update 与并发加人/移出串行化。 + const [existing] = await tx + .select() + .from(conversationParticipants) + .where( + and( + eq(conversationParticipants.conversationId, input.conversationId), + eq(conversationParticipants.userId, addedUserId) + ) + ) + .for("update", { of: conversationParticipants }) + .limit(1); + if (existing) { + return { participant: existing, added: false }; + } + const [countRow] = await tx + .select({ value: sql`count(*)::int` }) + .from(conversationParticipants) + .where(eq(conversationParticipants.conversationId, input.conversationId)); + if ((countRow?.value ?? 0) >= CONVERSATION_PARTICIPANTS_LIST_CAP) { + throw new ConversationParticipantCapError("conversation participant cap reached"); + } + const [created] = await tx + .insert(conversationParticipants) + .values({ + id: randomUUID(), + conversationId: input.conversationId, + userId: addedUserId, + role: "member" as const, + createdAt: at, + updatedAt: at + }) + .onConflictDoNothing({ + target: [conversationParticipants.conversationId, conversationParticipants.userId] + }) + .returning(); + if (!created) { + // 并发双加:另一发抢先插入 → 回查既有(幂等,不再插)。 + const [raced] = await tx + .select() + .from(conversationParticipants) + .where( + and( + eq(conversationParticipants.conversationId, input.conversationId), + eq(conversationParticipants.userId, addedUserId) + ) + ) + .limit(1); + if (!raced) { + throw new ConversationParticipantInsertFailedError( + "add participant insert conflict could not be resolved" + ); + } + return { participant: raced, added: false }; + } + return { participant: created, added: true }; + }); + }, + + // ── R17 批 G1(#16 退群/移出) ──────────────────────────────────────────────────── + // 只普通协同会话可退群/移出:main(全员语义)与 DM(2 人不变量)都拒。锁全部参与者行(人数 ≤ cap + // 100)一次判存在/最后一人/继任者,避免多次查询留竞态。owner 退群且群里还有他人 → 最早加入的 + // 参与者自动升 owner(仅当没有其它 owner 残留时);只剩自己一人则 409(最后一人不能退出,取窄不做 + // 归档旁路)。被移出者的读游标/消息保留(留痕,只删 participant 行)。语义红线里的「自己删自己=退群、 + // owner 才能移出他人」由服务层把关,仓库只负责租户安全的原子写与继任者升迁。 + async removeParticipant(input) { + const at = input.at ?? new Date(); + const targetUserId = input.targetUserId.trim().toLowerCase(); + if (!targetUserId) { + throw new ConversationRepositoryInputError("remove participant requires a target user id"); + } + return db.transaction(async (tx) => { + const locator = await readConversationProjectId(tx, { + workspaceId: input.workspaceId, + conversationId: input.conversationId + }); + if (!locator) { + throw new ConversationAccessDeniedError("conversation is not active"); + } + const conversation = await lockActiveConversation(tx, { + workspaceId: input.workspaceId, + projectId: locator.projectId, + conversationId: input.conversationId + }); + if (!conversation) { + throw new ConversationAccessDeniedError("conversation is not active"); + } + if (conversation.kind !== "collab" || conversation.dmKey !== null) { + throw new ConversationNotGroupError( + "only non-dm collab conversations support leaving or removing members" + ); + } + const participants = await tx + .select() + .from(conversationParticipants) + .where(eq(conversationParticipants.conversationId, input.conversationId)) + .for("update", { of: conversationParticipants }) + .orderBy(asc(conversationParticipants.createdAt), asc(conversationParticipants.id)); + const target = participants.find((row) => row.userId.toLowerCase() === targetUserId); + if (!target) { + throw new ConversationParticipantNotFoundError("target is not a participant of this conversation"); + } + if (participants.length <= 1) { + throw new ConversationLastParticipantError("the last remaining member cannot leave"); + } + const remaining = participants.filter((row) => row.id !== target.id); + let newOwnerUserId: string | null = null; + // 继任只在 owner 退群、且没有其它 owner 残留时发生——正常协同会话恰好一名 owner,此分支即 + // 「最早加入的参与者升 owner」;万一存在多 owner(历史数据),有其它 owner 就不再另立。 + if (target.role === "owner" && !remaining.some((row) => row.role === "owner")) { + const successor = remaining[0]; + if (successor) { + await tx + .update(conversationParticipants) + .set({ role: "owner" as const, updatedAt: at }) + .where(eq(conversationParticipants.id, successor.id)); + newOwnerUserId = successor.userId.toLowerCase(); + } + } + await tx.delete(conversationParticipants).where(eq(conversationParticipants.id, target.id)); + return { removed: true, newOwnerUserId }; + }); + }, + // ── R14 批 CHAT:编辑 ──────────────────────────────────────────────────────────── // 仅本人(sender_type='user' 且 sender_user_id=自己)、仅 kind='text'、created_at+window 内、 // 目标未删除。四道判定在锁住消息行后的同一次事务里做,不跨调用留 TOCTOU 缝。 From 3fa58688d8eaa372169e0feef9d93e97733f474b Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:50:53 +0800 Subject: [PATCH 05/23] =?UTF-8?q?feat(members):=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E6=88=90=E5=91=98=E7=A7=BB=E5=87=BA/=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=8F=98=E6=9B=B4=E8=B7=AF=E7=94=B1=20(R17-G1=20#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspaceMemberships.softDelete/updateRole 之前只有 QA 脚本调用、无路由暴露 (05-gap-review #15)。收进薄服务层并落权限红线后暴露: - DELETE /api/workspace/members/:userId 移出、PATCH 改角色。 - 权限取窄:仅工作区 admin/owner(actor.isAdmin 或成员行 role∈{admin,owner}); 不能对自己动手;不能移出/降级最后一名特权成员(admin∪owner 计数≤1 时拒)。 - memberships 仓库补 listActiveByWorkspace/updateRole;成员移出/角色变更各写一笔审计。 - 角色概念确实存在,故一并补 PATCH(侦察结论:memberships.role 有 member/admin/owner)。 契约 additive(角色枚举 + 请求/结果 VM);服务 10 例、路由 6 例测试覆盖权限矩阵 (admin/owner/普通成员/非成员/自己/最后一名 admin);openapi additive。 Co-Authored-By: Claude Fable 5 --- apps/api/src/app.ts | 17 ++ apps/api/src/auth.test.ts | 14 + apps/api/src/openapi.ts | 163 +++++++++++ apps/api/src/routes/workspace-members.test.ts | 263 ++++++++++++++++++ apps/api/src/routes/workspace-members.ts | 56 ++++ .../src/services/workspace-members.test.ts | 230 +++++++++++++++ apps/api/src/services/workspace-members.ts | 190 +++++++++++++ packages/contracts/src/auth.ts | 32 +++ packages/db/src/repositories/memberships.ts | 23 ++ 9 files changed, 988 insertions(+) create mode 100644 apps/api/src/routes/workspace-members.test.ts create mode 100644 apps/api/src/routes/workspace-members.ts create mode 100644 apps/api/src/services/workspace-members.test.ts create mode 100644 apps/api/src/services/workspace-members.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 02d63e45..c06c486a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -38,6 +38,7 @@ import { createProposalRoutes, createWorkItemProposalRoutes } from "./routes/pro import { createCostRoutes } from "./routes/cost.js"; import { createConversationRoutes } from "./routes/conversations.js"; import { createDmRoutes } from "./routes/dm.js"; +import { createWorkspaceMemberRoutes } from "./routes/workspace-members.js"; import { createAiSettingsRoutes } from "./routes/ai-settings.js"; import { createUserProfileRoutes } from "./routes/user-profile.js"; import { createUserAvatarRoutes } from "./routes/user-avatar.js"; @@ -83,6 +84,7 @@ import { ProjectInstructionsServiceError } from "./services/project-instructions import { ProjectPlannerServiceError } from "./services/project-planner.js"; import { InternalContractError } from "./pages/output-contract.js"; import { ConversationServiceError } from "./services/conversations.js"; +import { WorkspaceMemberServiceError } from "./services/workspace-members.js"; import { AiSettingsServiceError } from "./services/ai-settings.js"; import { UserProfileServiceError } from "./services/user-profile.js"; import { UserAvatarServiceError } from "./services/user-avatar.js"; @@ -276,6 +278,8 @@ app.route("/api", createProjectInstructionsRoutes()); app.route("/api", createProjectPlannerRoutes()); app.route("/api", createConversationRoutes()); app.route("/api/dm", createDmRoutes()); +// R17 批 G1(群成员管理):工作区成员移出/角色变更(DELETE/PATCH /api/workspace/members/:userId)。 +app.route("/api", createWorkspaceMemberRoutes()); app.route("/api", createAiSettingsRoutes()); // R13 批 A2(派人推荐 v2):GET/PATCH /me/profile ——「我是谁」资料面(title/bio/技能标签)。 app.route("/api", createUserProfileRoutes()); @@ -432,6 +436,19 @@ app.onError((error, c) => { ); } + if (error instanceof WorkspaceMemberServiceError) { + return c.json( + { + ok: false, + error: { + code: error.code, + message: error.message + } + }, + error.status as 400 + ); + } + if (error instanceof AiSettingsServiceError) { return c.json( { diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index 5c0206ef..b1e9b968 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -474,6 +474,20 @@ class MemoryMemberships implements WorkspaceMembershipRepository { row.updatedAt = at; return row; } + + async listActiveByWorkspace(workspaceId: string) { + return this.rows.filter((row) => row.workspaceId === workspaceId && row.deletedAt === null); + } + + async updateRole(id: string, role: MembershipRole, at: Date) { + const row = this.rows.find((candidate) => candidate.id === id && candidate.deletedAt === null); + if (!row) { + return null; + } + row.role = role; + row.updatedAt = at; + return row; + } } function inviteRow(input: CreateInviteInput, seq = 1): UserInviteRow { diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 7c83c82c..2e86a77c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -5713,6 +5713,139 @@ const conversationParticipantsResponses = { } } as const; +// R17 批 G1(群成员管理):POST /participants(加人)与 DELETE /participants/:userId(退群/移出)。 +const addConversationParticipantRequestBodySchema = { + type: "object", + required: ["user_id"], + properties: { user_id: uuidStringSchema }, + additionalProperties: false +} as const; +const addConversationParticipantResponses = { + responses: { + "200": jsonDataResponse( + { + type: "object", + required: ["added", "participants"], + properties: { + added: { type: "boolean" }, + participants: { + type: "object", + required: ["scope", "participants"], + properties: { + scope: { type: "string", enum: ["workspace", "participants"] }, + participants: { type: "array", maxItems: 100, items: conversationParticipantListItemResponseSchema } + }, + additionalProperties: false + } + }, + additionalProperties: false + }, + "Refreshed participant list after adding a member (added=false when already present)" + ).responses["200"], + "401": conversationAuthRequiredResponse, + "403": conversationForbiddenResponse, + "404": jsonErrorStatusResponse("404", "Conversation was not found", ["conversation_not_found"]).responses[ + "404" + ], + "409": jsonErrorStatusResponse("409", "Only non-dm collab conversations accept new participants", [ + "conversation_not_group", + "conversation_dm_no_add", + "conversation_participant_cap" + ]).responses["409"], + "422": conversationValidationResponse, + "500": conversationInternalResponse + } +} as const; +const removeConversationParticipantResponses = { + responses: { + "200": jsonDataResponse( + { + type: "object", + required: ["removed_user_id", "self_left", "new_owner_user_id"], + properties: { + removed_user_id: uuidStringSchema, + self_left: { type: "boolean" }, + new_owner_user_id: { ...uuidStringSchema, nullable: true } + }, + additionalProperties: false + }, + "Result of leaving/removing a member (new_owner_user_id set when the owner left and a successor was promoted)" + ).responses["200"], + "401": conversationAuthRequiredResponse, + "403": jsonErrorStatusResponse("403", "Only the owner may remove other members", [ + "conversation_remove_forbidden" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Conversation or participant was not found", [ + "conversation_not_found", + "conversation_participant_not_found" + ]).responses["404"], + "409": jsonErrorStatusResponse("409", "Main/DM conversations and the last member cannot leave", [ + "conversation_not_group", + "conversation_dm_no_remove", + "conversation_last_participant" + ]).responses["409"], + "500": conversationInternalResponse + } +} as const; + +// R17 批 G1(#15 工作区成员移出/角色变更):DELETE / PATCH /api/workspace/members/:userId。 +const workspaceMemberRoleSchema = { type: "string", enum: ["member", "admin", "owner"] } as const; +const updateWorkspaceMemberRoleRequestBodySchema = { + type: "object", + required: ["role"], + properties: { role: workspaceMemberRoleSchema }, + additionalProperties: false +} as const; +const removeWorkspaceMemberResponses = { + responses: { + "200": jsonDataResponse( + { + type: "object", + required: ["removed_user_id"], + properties: { removed_user_id: uuidStringSchema }, + additionalProperties: false + }, + "The removed member's user id" + ).responses["200"], + "401": conversationAuthRequiredResponse, + "403": jsonErrorStatusResponse("403", "Only workspace admins/owners may manage members", [ + "member_manage_forbidden", + "human_required" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Member was not found", ["member_not_found"]).responses["404"], + "409": jsonErrorStatusResponse("409", "Cannot remove yourself or the last admin", [ + "member_manage_self", + "member_last_admin" + ]).responses["409"], + "500": conversationInternalResponse + } +} as const; +const updateWorkspaceMemberRoleResponses = { + responses: { + "200": jsonDataResponse( + { + type: "object", + required: ["user_id", "role"], + properties: { user_id: uuidStringSchema, role: workspaceMemberRoleSchema }, + additionalProperties: false + }, + "The member's new role" + ).responses["200"], + "401": conversationAuthRequiredResponse, + "403": jsonErrorStatusResponse("403", "Only workspace admins/owners may manage members", [ + "member_manage_forbidden", + "human_required" + ]).responses["403"], + "404": jsonErrorStatusResponse("404", "Member was not found", ["member_not_found"]).responses["404"], + "409": jsonErrorStatusResponse("409", "Cannot change your own role or demote the last admin", [ + "member_manage_self", + "member_last_admin" + ]).responses["409"], + "422": conversationValidationResponse, + "500": conversationInternalResponse + } +} as const; + const presenceUserIdsQueryParameter = { name: "user_ids", in: "query", @@ -7974,6 +8107,36 @@ export function getOpenApiDocument() { summary: "List conversation participants (main: scope=workspace + empty list; collab/DM: real rows)", parameters: [pathUuidParameter("id")], ...conversationParticipantsResponses + }, + post: { + tags: ["conversations"], + summary: "Add a member to a non-dm collab conversation (main/DM are 409, idempotent on duplicates)", + parameters: [pathUuidParameter("id")], + ...jsonRequestBody(addConversationParticipantRequestBodySchema), + ...addConversationParticipantResponses + } + }, + "/api/conversations/{id}/participants/{userId}": { + delete: { + tags: ["conversations"], + summary: "Leave (self) or remove (owner) a member; owner leaving promotes the earliest successor", + parameters: [pathUuidParameter("id"), pathUuidParameter("userId")], + ...removeConversationParticipantResponses + } + }, + "/api/workspace/members/{userId}": { + delete: { + tags: ["conversations"], + summary: "Remove a workspace member (admin/owner only; cannot remove self or the last admin)", + parameters: [pathUuidParameter("userId")], + ...removeWorkspaceMemberResponses + }, + patch: { + tags: ["conversations"], + summary: "Change a workspace member's role (admin/owner only; cannot demote the last admin)", + parameters: [pathUuidParameter("userId")], + ...jsonRequestBody(updateWorkspaceMemberRoleRequestBodySchema), + ...updateWorkspaceMemberRoleResponses } }, "/api/presence": { diff --git a/apps/api/src/routes/workspace-members.test.ts b/apps/api/src/routes/workspace-members.test.ts new file mode 100644 index 00000000..e9665fba --- /dev/null +++ b/apps/api/src/routes/workspace-members.test.ts @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Hono } from "hono"; +import { generateSignedCookie } from "hono/cookie"; +import { HTTPException } from "hono/http-exception"; +import { ZodError } from "zod"; + +import { loadSettings, type Settings } from "@workhub/config"; +import type { + ClientDeviceAuthRow, + ClientDeviceRepository, + UserAuthRow, + UserRepository +} from "@workhub/db"; + +import { COOKIE_NAME, type AuthDependencies, type AuthEnv } from "../middleware/auth.js"; +import { + WorkspaceMemberServiceError, + type WorkspaceMemberService +} from "../services/workspace-members.js"; +import { createWorkspaceMemberRoutes } from "./workspace-members.js"; + +const now = new Date("2026-07-16T09:00:00.000Z"); +const adminUserId = "70000000-0000-4000-8000-0000000000a1"; +const targetUserId = "70000000-0000-4000-8000-0000000000b1"; + +function settings(): Settings { + return loadSettings({ APP_ENV: "test", COOKIE_SECRET: "r17-workspace-members-secret" }); +} + +function user(): UserAuthRow { + return { + id: adminUserId, + nickname: "r17-admin", + cookieToken: "cookie-r17-admin", + preferredLocale: "zh-CN", + availabilityStatus: "free", + availabilityText: null, + availabilityUpdatedAt: null, + mutedNotificationTypes: [], + avatarWebp: null, + avatarUpdatedAt: null, + isAdmin: true, + deletedAt: null, + deletedByUserId: null, + createdAt: now, + updatedAt: now + }; +} + +class MemoryUsers implements UserRepository { + async findActiveById(id: string) { + return id === adminUserId ? user() : null; + } + async findActiveByCookieToken(token: string) { + return token === "cookie-r17-admin" ? user() : null; + } + async findActiveByNickname() { + return null; + } + async createUser(): Promise { + throw new Error("not needed"); + } + async getOrCreateActiveByNickname(): Promise<{ user: UserAuthRow; created: boolean }> { + throw new Error("not needed"); + } + async rotateCookieToken() { + return null; + } +} + +class MemoryDevices implements ClientDeviceRepository { + async findActiveByTokenHash() { + return null; + } + async findActiveByTokenHashForUser() { + return null; + } + async createClientDevice(): Promise { + throw new Error("not needed"); + } + async listByUser() { + return []; + } + async touchLastSeen() { + return null; + } + async revokeByIdForUser() { + return null; + } + async revokeByTokenHash() { + return null; + } +} + +function authDeps(runtimeSettings: Settings): AuthDependencies { + return { users: new MemoryUsers(), devices: new MemoryDevices(), settings: runtimeSettings, now: () => now }; +} + +async function cookie(runtimeSettings: Settings) { + return generateSignedCookie(COOKIE_NAME, "cookie-r17-admin", runtimeSettings.auth.cookieSecret); +} + +function memberService(overrides: Partial = {}): WorkspaceMemberService { + const reject = (name: string) => async () => { + throw new Error(`${name} not expected`); + }; + return { + removeMember: reject("removeMember"), + updateMemberRole: reject("updateMemberRole"), + ...overrides + } as WorkspaceMemberService; +} + +function withErrors(app: Hono) { + app.onError((error, c) => { + if (error instanceof ZodError) { + return c.json({ ok: false, error: { code: "validation_error", message: "invalid payload" } }, 422); + } + if (error instanceof WorkspaceMemberServiceError) { + return c.json({ ok: false, error: { code: error.code, message: error.message } }, error.status); + } + if (error instanceof HTTPException) { + return c.json({ ok: false, error: { code: "unauthorized", message: error.message } }, error.status); + } + return c.json({ ok: false, error: { code: "internal_error", message: "internal" } }, 500); + }); + return app; +} + +function routeApp(runtimeSettings: Settings, members: WorkspaceMemberService) { + const app = withErrors(new Hono()); + app.route("/api", createWorkspaceMemberRoutes({ auth: authDeps(runtimeSettings), members })); + return app; +} + +test("DELETE member requires authentication before reaching the service", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + memberService({ + async removeMember() { + throw new Error("anonymous request must not reach the service"); + } + }) + ); + + const response = await app.request(`/api/workspace/members/${targetUserId}`, { method: "DELETE" }); + + assert.equal(response.status, 401); +}); + +test("DELETE member 404s a malformed user id without entering the service", async () => { + const runtimeSettings = settings(); + let calls = 0; + const app = routeApp( + runtimeSettings, + memberService({ + async removeMember() { + calls += 1; + throw new Error("invalid id must not reach the service"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/workspace/members/not-a-uuid", { method: "DELETE", headers }); + + assert.equal(response.status, 404); + assert.equal(calls, 0); +}); + +test("DELETE member forwards the target and returns the removed id", async () => { + const runtimeSettings = settings(); + let seen: unknown; + const app = routeApp( + runtimeSettings, + memberService({ + async removeMember(input) { + seen = input; + return { removed_user_id: targetUserId }; + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request(`/api/workspace/members/${targetUserId}`, { method: "DELETE", headers }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, data: { removed_user_id: targetUserId } }); + assert.equal((seen as { targetUserId: string }).targetUserId, targetUserId); +}); + +test("DELETE member preserves the service's typed 409 for the last admin", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + memberService({ + async removeMember() { + throw new WorkspaceMemberServiceError(409, "member_last_admin", "不能移出最后一名管理员。"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request(`/api/workspace/members/${targetUserId}`, { method: "DELETE", headers }); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { + ok: false, + error: { code: "member_last_admin", message: "不能移出最后一名管理员。" } + }); +}); + +test("PATCH member role forwards the role and returns the updated pair", async () => { + const runtimeSettings = settings(); + let seen: unknown; + const app = routeApp( + runtimeSettings, + memberService({ + async updateMemberRole(input) { + seen = input; + return { user_id: targetUserId, role: "admin" }; + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings), "content-type": "application/json" }; + + const response = await app.request(`/api/workspace/members/${targetUserId}`, { + method: "PATCH", + headers, + body: JSON.stringify({ role: "admin" }) + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, data: { user_id: targetUserId, role: "admin" } }); + assert.equal((seen as { role: string }).role, "admin"); +}); + +test("PATCH member role rejects an unknown role with 422 before the service", async () => { + const runtimeSettings = settings(); + let calls = 0; + const app = routeApp( + runtimeSettings, + memberService({ + async updateMemberRole() { + calls += 1; + throw new Error("invalid role must not reach the service"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings), "content-type": "application/json" }; + + const response = await app.request(`/api/workspace/members/${targetUserId}`, { + method: "PATCH", + headers, + body: JSON.stringify({ role: "superuser" }) + }); + + assert.equal(response.status, 422); + assert.equal(calls, 0); +}); diff --git a/apps/api/src/routes/workspace-members.ts b/apps/api/src/routes/workspace-members.ts new file mode 100644 index 00000000..58e0eaaf --- /dev/null +++ b/apps/api/src/routes/workspace-members.ts @@ -0,0 +1,56 @@ +import { Hono } from "hono"; + +import { updateWorkspaceMemberRoleRequestSchema } from "@workhub/contracts"; + +import { + createCurrentUserMiddleware, + getDefaultAuthDependencies, + type AuthDependencySource, + type AuthEnv +} from "../middleware/auth.js"; +import { + WorkspaceMemberServiceError, + getDefaultWorkspaceMemberService, + type WorkspaceMemberService +} from "../services/workspace-members.js"; +import { readJsonObject } from "./json-body.js"; +import { isUuidParam } from "./uuid-param.js"; + +// R17 批 G1(群成员管理 · #15 工作区成员移出/角色变更):DELETE /api/workspace/members/:userId 移出、 +// PATCH /api/workspace/members/:userId 改角色。权限红线(仅 admin/owner、不能对自己动手、不能移出/降级 +// 最后一名特权成员)全在 services/workspace-members.ts 里做,路由层只做 param/body 校验与成形响应。 +// 错误经 app.onError 的 WorkspaceMemberServiceError 分支映射。 + +export type WorkspaceMemberRoutesDependencies = { + auth?: AuthDependencySource; + members?: WorkspaceMemberService; +}; + +function requireTargetUserId(value: string) { + if (!isUuidParam(value)) { + throw new WorkspaceMemberServiceError(404, "member_not_found", "没有找到这个工作区里的这个成员。"); + } + return value; +} + +export function createWorkspaceMemberRoutes(deps: WorkspaceMemberRoutesDependencies = {}) { + const routes = new Hono(); + const authSource = deps.auth ?? getDefaultAuthDependencies; + const members = deps.members ?? getDefaultWorkspaceMemberService(); + const requireCurrentUser = createCurrentUserMiddleware(authSource); + + routes.delete("/workspace/members/:userId", requireCurrentUser, async (c) => { + const targetUserId = requireTargetUserId(c.req.param("userId")); + const data = await members.removeMember({ actor: c.var.actor, targetUserId }); + return c.json({ ok: true, data }); + }); + + routes.patch("/workspace/members/:userId", requireCurrentUser, async (c) => { + const targetUserId = requireTargetUserId(c.req.param("userId")); + const payload = updateWorkspaceMemberRoleRequestSchema.parse(await readJsonObject(c)); + const data = await members.updateMemberRole({ actor: c.var.actor, targetUserId, role: payload.role }); + return c.json({ ok: true, data }); + }); + + return routes; +} diff --git a/apps/api/src/services/workspace-members.test.ts b/apps/api/src/services/workspace-members.test.ts new file mode 100644 index 00000000..1ee4e749 --- /dev/null +++ b/apps/api/src/services/workspace-members.test.ts @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { MembershipRole, WorkspaceMembershipRepository, WorkspaceMembershipRow } from "@workhub/db"; + +import type { AuthActor } from "../middleware/auth.js"; +import { + WorkspaceMemberServiceError, + createWorkspaceMemberService +} from "./workspace-members.js"; + +const now = new Date("2026-07-16T09:00:00.000Z"); +const workspaceId = "70000000-0000-4000-8000-000000000001"; +const adminUserId = "70000000-0000-4000-8000-0000000000a1"; +const ownerUserId = "70000000-0000-4000-8000-0000000000a2"; +const memberUserId = "70000000-0000-4000-8000-0000000000b1"; +const secondMemberUserId = "70000000-0000-4000-8000-0000000000b2"; + +function membershipRow(overrides: Partial & { userId: string; role: MembershipRole }): WorkspaceMembershipRow { + return { + id: `71000000-0000-4000-8000-${overrides.userId.slice(-12)}`, + workspaceId, + defaultWorkspace: true, + deletedAt: null, + createdAt: now, + updatedAt: now, + ...overrides + }; +} + +// 极简内存花名册——支持权限矩阵所需的四个读写方法。 +function memberships(rows: WorkspaceMembershipRow[]): WorkspaceMembershipRepository { + const store = rows.map((row) => ({ ...row })); + const notNeeded = (name: string) => async () => { + throw new Error(`${name} not expected`); + }; + return { + listForUser: notNeeded("listForUser") as never, + resolveDefaultWorkspace: notNeeded("resolveDefaultWorkspace") as never, + resolveDefaultTenant: notNeeded("resolveDefaultTenant") as never, + create: notNeeded("create") as never, + async findActiveForUserWorkspace(userId, ws) { + return store.find((row) => row.userId === userId && row.workspaceId === ws && row.deletedAt === null) ?? null; + }, + async listActiveByWorkspace(ws) { + return store.filter((row) => row.workspaceId === ws && row.deletedAt === null); + }, + async softDelete(id, at) { + const row = store.find((candidate) => candidate.id === id && candidate.deletedAt === null); + if (!row) { + return null; + } + row.deletedAt = at; + row.updatedAt = at; + return row; + }, + async updateRole(id, role, at) { + const row = store.find((candidate) => candidate.id === id && candidate.deletedAt === null); + if (!row) { + return null; + } + row.role = role; + row.updatedAt = at; + return row; + } + } as WorkspaceMembershipRepository; +} + +function actor(overrides: Partial = {}): AuthActor { + return { + kind: "human", + id: adminUserId, + label: "admin", + userId: adminUserId, + isAdmin: true, + orgId: "70000000-0000-4000-8000-0000000000ff", + workspaceId, + ...overrides + }; +} + +test("removeMember lets a user-level admin soft-delete a plain member", async () => { + const repo = memberships([ + membershipRow({ userId: adminUserId, role: "admin" }), + membershipRow({ userId: memberUserId, role: "member" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + const result = await service.removeMember({ actor: actor(), targetUserId: memberUserId }); + + assert.deepEqual(result, { removed_user_id: memberUserId }); + assert.equal(await repo.findActiveForUserWorkspace(memberUserId, workspaceId), null); +}); + +test("removeMember lets a workspace owner (role, not user-admin) remove a member", async () => { + const repo = memberships([ + membershipRow({ userId: ownerUserId, role: "owner" }), + membershipRow({ userId: memberUserId, role: "member" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + const result = await service.removeMember({ + actor: actor({ id: ownerUserId, userId: ownerUserId, isAdmin: false }), + targetUserId: memberUserId + }); + + assert.deepEqual(result, { removed_user_id: memberUserId }); +}); + +test("removeMember refuses a plain member (403) and never writes", async () => { + const repo = memberships([ + membershipRow({ userId: memberUserId, role: "member" }), + membershipRow({ userId: secondMemberUserId, role: "member" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + await assert.rejects( + () => + service.removeMember({ + actor: actor({ id: memberUserId, userId: memberUserId, isAdmin: false }), + targetUserId: secondMemberUserId + }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 403 && error.code === "member_manage_forbidden" + ); + assert.notEqual(await repo.findActiveForUserWorkspace(secondMemberUserId, workspaceId), null); +}); + +test("removeMember refuses a non-member actor with 403", async () => { + const repo = memberships([membershipRow({ userId: memberUserId, role: "member" })]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + await assert.rejects( + () => service.removeMember({ actor: actor(), targetUserId: memberUserId }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 403 && error.code === "member_manage_forbidden" + ); +}); + +test("removeMember refuses removing yourself with 409", async () => { + const repo = memberships([membershipRow({ userId: adminUserId, role: "admin" })]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + await assert.rejects( + () => service.removeMember({ actor: actor(), targetUserId: adminUserId }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 409 && error.code === "member_manage_self" + ); +}); + +test("removeMember 404s a target that is not an active member", async () => { + const repo = memberships([membershipRow({ userId: adminUserId, role: "admin" })]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + await assert.rejects( + () => service.removeMember({ actor: actor(), targetUserId: memberUserId }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 404 && error.code === "member_not_found" + ); +}); + +test("removeMember refuses removing the last admin/owner with 409", async () => { + // Acting manager qualifies via user-level isAdmin (membership role only "member"), so the sole owner is the + // only privileged membership — removing them would orphan the workspace of managers → 409. + const repo = memberships([ + membershipRow({ userId: adminUserId, role: "member" }), + membershipRow({ userId: ownerUserId, role: "owner" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + await assert.rejects( + () => service.removeMember({ actor: actor(), targetUserId: ownerUserId }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 409 && error.code === "member_last_admin" + ); + assert.notEqual(await repo.findActiveForUserWorkspace(ownerUserId, workspaceId), null); +}); + +test("updateMemberRole lets an admin promote a member to admin", async () => { + const repo = memberships([ + membershipRow({ userId: adminUserId, role: "admin" }), + membershipRow({ userId: memberUserId, role: "member" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + const result = await service.updateMemberRole({ actor: actor(), targetUserId: memberUserId, role: "admin" }); + + assert.deepEqual(result, { user_id: memberUserId, role: "admin" }); + const updated = await repo.findActiveForUserWorkspace(memberUserId, workspaceId); + assert.equal(updated?.role, "admin"); +}); + +test("updateMemberRole refuses demoting the last admin/owner with 409", async () => { + const repo = memberships([ + membershipRow({ userId: ownerUserId, role: "owner" }), + membershipRow({ userId: adminUserId, role: "admin" }), + membershipRow({ userId: memberUserId, role: "member" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + // Demote admin while an owner remains → allowed (still privileged left). + await assert.doesNotReject(() => service.updateMemberRole({ actor: actor({ id: ownerUserId, userId: ownerUserId, isAdmin: false }), targetUserId: adminUserId, role: "member" })); + + // Now only the owner is privileged; a user-admin manager (membership role "member") demoting them is blocked. + const soleRepo = memberships([ + membershipRow({ userId: adminUserId, role: "member" }), + membershipRow({ userId: ownerUserId, role: "owner" }) + ]); + const soleService = createWorkspaceMemberService({ memberships: soleRepo, now: () => now }); + await assert.rejects( + () => + soleService.updateMemberRole({ + actor: actor({ isAdmin: true }), + targetUserId: ownerUserId, + role: "member" + }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 409 && error.code === "member_last_admin" + ); +}); + +test("updateMemberRole refuses changing your own role with 409", async () => { + const repo = memberships([membershipRow({ userId: adminUserId, role: "admin" })]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + await assert.rejects( + () => service.updateMemberRole({ actor: actor(), targetUserId: adminUserId, role: "member" }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 409 && error.code === "member_manage_self" + ); +}); diff --git a/apps/api/src/services/workspace-members.ts b/apps/api/src/services/workspace-members.ts new file mode 100644 index 00000000..7f97ab0e --- /dev/null +++ b/apps/api/src/services/workspace-members.ts @@ -0,0 +1,190 @@ +import { + createAuditLogRepository, + createWorkspaceMembershipRepository, + getSharedDatabaseClient, + type AuditLogRepository, + type MembershipRole, + type WorkspaceMembershipRepository, + type WorkHubDatabaseClient +} from "@workhub/db"; + +import type { AuthActor } from "../middleware/auth.js"; + +// ── R17 批 G1(群成员管理 · #15 工作区成员移出/角色变更) ───────────────────────────────────── +// workspaceMemberships.softDelete / updateRole 之前只有 QA 脚本调用、无路由暴露。这里把它们收进一个薄 +// 服务层,落权限红线(取窄,见下)后暴露为 DELETE / PATCH /api/workspace/members/:userId。 +// * 权限:仅工作区 admin/owner(actor.isAdmin 或本人成员行 role∈{admin,owner}); +// * 不能移出/改自己(避免自锁或误删自己); +// * 不能移出/降级「最后一名特权成员」(admin∪owner 计数 ≤ 1 时拒,避免把工作区变成无人管理的孤儿)。 +// 被移出者的会话参与/消息/游标不在本服务处理范围(会话层各自留痕)——这里只软删工作区成员行。 + +export class WorkspaceMemberServiceError extends Error { + constructor( + public readonly status: 400 | 403 | 404 | 409, + public readonly code: string, + message: string + ) { + super(message); + this.name = "WorkspaceMemberServiceError"; + } +} + +const PRIVILEGED_ROLES: ReadonlySet = new Set(["admin", "owner"]); + +export type WorkspaceMemberService = { + removeMember(input: { actor: AuthActor; targetUserId: string }): Promise<{ removed_user_id: string }>; + updateMemberRole(input: { + actor: AuthActor; + targetUserId: string; + role: MembershipRole; + }): Promise<{ user_id: string; role: MembershipRole }>; +}; + +export type WorkspaceMemberServiceDependencies = { + memberships: Pick< + WorkspaceMembershipRepository, + "findActiveForUserWorkspace" | "listActiveByWorkspace" | "softDelete" | "updateRole" + >; + auditLogs?: Pick | undefined; + now?: () => Date; +}; + +type ResolvedActor = { actorUserId: string; workspaceId: string; isAdmin: boolean }; + +function requireHumanActor(actor: AuthActor): ResolvedActor { + const workspaceId = actor.workspaceId.trim(); + const actorUserId = actor.userId?.trim(); + if (actor.kind !== "human" || !actorUserId || !workspaceId) { + throw new WorkspaceMemberServiceError(403, "human_required", "需要已加入工作区的真人用户才能管理成员。"); + } + return { actorUserId, workspaceId, isAdmin: actor.isAdmin }; +} + +export function createWorkspaceMemberService(deps: WorkspaceMemberServiceDependencies): WorkspaceMemberService { + const now = deps.now ?? (() => new Date()); + const memberships = deps.memberships; + + // 权限闸:解析发起人在这个工作区的活跃成员行,判定其为 admin/owner(或 user 级 isAdmin)。非成员 → 403。 + async function assertManagerAndResolveTarget(input: { + actor: AuthActor; + targetUserId: string; + }): Promise<{ resolved: ResolvedActor; targetUserId: string; targetRole: MembershipRole; targetMembershipId: string }> { + const resolved = requireHumanActor(input.actor); + const targetUserId = input.targetUserId.trim().toLowerCase(); + if (!targetUserId) { + throw new WorkspaceMemberServiceError(400, "member_target_required", "请选择要管理的成员。"); + } + const actingMembership = await memberships.findActiveForUserWorkspace(resolved.actorUserId, resolved.workspaceId); + if (!actingMembership) { + throw new WorkspaceMemberServiceError(403, "member_manage_forbidden", "只有工作区管理员可以管理成员。"); + } + const isManager = resolved.isAdmin || PRIVILEGED_ROLES.has(actingMembership.role as MembershipRole); + if (!isManager) { + throw new WorkspaceMemberServiceError(403, "member_manage_forbidden", "只有工作区管理员可以管理成员。"); + } + // 不能对自己动手(移出=自锁、降级=自我夺权)——取窄,两条路径共用同一条守卫。 + if (targetUserId === resolved.actorUserId.toLowerCase()) { + throw new WorkspaceMemberServiceError(409, "member_manage_self", "不能对自己执行这个操作。"); + } + const targetMembership = await memberships.findActiveForUserWorkspace(targetUserId, resolved.workspaceId); + if (!targetMembership) { + throw new WorkspaceMemberServiceError(404, "member_not_found", "没有找到这个工作区里的这个成员。"); + } + return { + resolved, + targetUserId, + targetRole: targetMembership.role as MembershipRole, + targetMembershipId: targetMembership.id + }; + } + + // 特权成员计数(admin∪owner),供「不能移出/降级最后一名特权成员」判定。 + async function countPrivileged(workspaceId: string): Promise { + const rows = await memberships.listActiveByWorkspace(workspaceId); + return rows.filter((row) => PRIVILEGED_ROLES.has(row.role as MembershipRole)).length; + } + + return { + async removeMember(input) { + const { resolved, targetUserId, targetRole, targetMembershipId } = await assertManagerAndResolveTarget(input); + // 移出的目标若是特权成员,且它是最后一名特权成员 → 拒(否则工作区没人能再管理)。 + if (PRIVILEGED_ROLES.has(targetRole)) { + const privileged = await countPrivileged(resolved.workspaceId); + if (privileged <= 1) { + throw new WorkspaceMemberServiceError( + 409, + "member_last_admin", + "不能移出最后一名管理员。" + ); + } + } + const removed = await memberships.softDelete(targetMembershipId, now()); + if (!removed) { + // 并发:被别处先软删了——幂等当作已移出,不 500。 + throw new WorkspaceMemberServiceError(404, "member_not_found", "没有找到这个工作区里的这个成员。"); + } + await deps.auditLogs + ?.createAuditLog({ + actorKind: "human", + actorUserId: resolved.actorUserId, + workspaceId: resolved.workspaceId, + entityType: "workspace_membership", + entityId: targetMembershipId, + action: "workspace.member_removed", + detailJson: { workspace_id: resolved.workspaceId, target_user_id: targetUserId, role: targetRole } + }) + .catch(() => {}); + return { removed_user_id: targetUserId }; + }, + + async updateMemberRole(input) { + const { resolved, targetUserId, targetRole, targetMembershipId } = await assertManagerAndResolveTarget(input); + // 把最后一名特权成员降级为普通 member → 拒(同 removeMember 的孤儿工作区防线)。 + if (PRIVILEGED_ROLES.has(targetRole) && !PRIVILEGED_ROLES.has(input.role)) { + const privileged = await countPrivileged(resolved.workspaceId); + if (privileged <= 1) { + throw new WorkspaceMemberServiceError( + 409, + "member_last_admin", + "不能降级最后一名管理员。" + ); + } + } + const updated = await memberships.updateRole(targetMembershipId, input.role, now()); + if (!updated) { + throw new WorkspaceMemberServiceError(404, "member_not_found", "没有找到这个工作区里的这个成员。"); + } + await deps.auditLogs + ?.createAuditLog({ + actorKind: "human", + actorUserId: resolved.actorUserId, + workspaceId: resolved.workspaceId, + entityType: "workspace_membership", + entityId: targetMembershipId, + action: "workspace.member_role_changed", + detailJson: { + workspace_id: resolved.workspaceId, + target_user_id: targetUserId, + from_role: targetRole, + to_role: input.role + } + }) + .catch(() => {}); + return { user_id: targetUserId, role: updated.role as MembershipRole }; + } + }; +} + +let defaultDbClient: WorkHubDatabaseClient | undefined; +let defaultWorkspaceMemberService: WorkspaceMemberService | undefined; + +export function getDefaultWorkspaceMemberService(): WorkspaceMemberService { + if (!defaultWorkspaceMemberService) { + defaultDbClient = getSharedDatabaseClient(); + defaultWorkspaceMemberService = createWorkspaceMemberService({ + memberships: createWorkspaceMembershipRepository(defaultDbClient.db), + auditLogs: createAuditLogRepository(defaultDbClient.db) + }); + } + return defaultWorkspaceMemberService; +} diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index db36e73d..2ae41bed 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { idSchema } from "./domain/common.js"; import { clientDeviceSchema, userSchema } from "./domain/identity.js"; import { identityContextSchema } from "./identity.js"; import { workHubLocaleInputSchema, workHubLocaleSchema } from "./locale.js"; @@ -55,6 +56,37 @@ export const inviteAcceptRequestSchema = z.object({ }); export type InviteAcceptRequest = z.infer; +// R17 批 G1(群成员管理 · #15 工作区成员移出/角色变更):工作区成员角色枚举——与 db +// memberships.role('member'|'admin'|'owner')对齐。 +export const workspaceMemberRoleSchema = z.enum(["member", "admin", "owner"]); +export type WorkspaceMemberRole = z.infer; + +// PATCH /api/workspace/members/:userId 的请求体——只带目标角色。权限红线(仅 admin/owner;不能改自己; +// 不能把最后一名 admin/owner 降级)在服务层强制,见 apps/api/src/services/workspace-members.ts。 +export const updateWorkspaceMemberRoleRequestSchema = z + .object({ + role: workspaceMemberRoleSchema + }) + .strict(); +export type UpdateWorkspaceMemberRoleRequest = z.infer; + +// DELETE /api/workspace/members/:userId 移出成功的响应——回被移出者 user_id(客户端据此就地从 roster +// 剔除),角色变更则额外回新角色。both additive,纯为客户端就地刷新用。 +export const removeWorkspaceMemberResultVmSchema = z + .object({ + removed_user_id: idSchema + }) + .strict(); +export type RemoveWorkspaceMemberResultVM = z.infer; + +export const updateWorkspaceMemberRoleResultVmSchema = z + .object({ + user_id: idSchema, + role: workspaceMemberRoleSchema + }) + .strict(); +export type UpdateWorkspaceMemberRoleResultVM = z.infer; + export const identifyResponseSchema = userSchema.pick({ id: true, nickname: true, diff --git a/packages/db/src/repositories/memberships.ts b/packages/db/src/repositories/memberships.ts index ccf175f9..b1f5f0a0 100644 --- a/packages/db/src/repositories/memberships.ts +++ b/packages/db/src/repositories/memberships.ts @@ -38,6 +38,11 @@ export type WorkspaceMembershipRepository = { create: (input: CreateWorkspaceMembershipInput) => Promise; /** 软删成员(退出工作区 / 停用)——释放 (ws,user) 与 default 的 partial unique,可重新加入。 */ softDelete: (id: string, at: Date) => Promise; + /** R17 批 G1(#15 成员移出):某工作区全部 active 成员行——供移出/角色变更前统计特权成员数(防移出/ + * 降级最后一名 admin/owner),成员规模对内部团队远低于任何实际上限,无需分页。 */ + listActiveByWorkspace: (workspaceId: string) => Promise; + /** R17 批 G1(#15 角色变更):更新一条 active 成员行的角色(幂等:改到同值也照常前进 updated_at)。 */ + updateRole: (id: string, role: MembershipRole, at: Date) => Promise; }; export function createWorkspaceMembershipRepository(db: WorkHubDb): WorkspaceMembershipRepository { @@ -121,6 +126,24 @@ export function createWorkspaceMembershipRepository(db: WorkHubDb): WorkspaceMem .where(and(eq(workspaceMemberships.id, id), isNull(workspaceMemberships.deletedAt))) .returning(); return rows[0] ?? null; + }, + + async listActiveByWorkspace(workspaceId) { + return db + .select() + .from(workspaceMemberships) + .where( + and(eq(workspaceMemberships.workspaceId, workspaceId), isNull(workspaceMemberships.deletedAt)) + ); + }, + + async updateRole(id, role, at) { + const rows = await db + .update(workspaceMemberships) + .set({ role, updatedAt: at }) + .where(and(eq(workspaceMemberships.id, id), isNull(workspaceMemberships.deletedAt))) + .returning(); + return rows[0] ?? null; } }; } From 5c04392ac61dee8ac852de58ae3f8d98b5d0782b Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:53:42 +0800 Subject: [PATCH 06/23] fix(observer): #4 open a real escalation when execute dispatch fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Execute-class action-card items that couldn't be dispatched (enqueue/notify failed after the work item existed) only marked the row `escalated` and never wrote an ai_decisions row — so the failure never reached the decision inbox and had no recovery entry, while decide-class items always fell an escalation. Separate work-item creation from dispatch so a failure still has a work_item to hang an escalation on: create a real escalation_event (trigger unqualified, suggested lead = assignee, handoff tagged execute_dispatch_failed), push the work item to escalated, and post a hand-off system note. The owner-less !assignee corner stays escalated-without-work-item, matching the decide path. Co-Authored-By: Claude Fable 5 --- apps/api/src/conversation-observer.test.ts | 73 +++++++++++ apps/api/src/workers/conversation-observer.ts | 118 ++++++++++++++++-- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/apps/api/src/conversation-observer.test.ts b/apps/api/src/conversation-observer.test.ts index b61d9021..7f19f3ec 100644 --- a/apps/api/src/conversation-observer.test.ts +++ b/apps/api/src/conversation-observer.test.ts @@ -719,6 +719,79 @@ test("a per-item work-item creation failure escalates only that item and does no assert.equal(items[0]?.status, "escalated"); }); +// #4:execute 类派发失败(工作项已建成、enqueue 抛错)时补落真实 escalation_event,让这次失败进决策收件箱、 +// 有恢复入口——对齐 decide 类总会落 ai_decisions 行的语义。同时把工作项推进 escalated、往会话里发一条转人系统提示。 +test("execute dispatch failure after the work item exists opens a real escalation so the failure reaches the decision inbox", async () => { + let cardInput: unknown; + let escalationInput: unknown; + let transitionInput: unknown; + let systemNoteInput: unknown; + const deps = baseDeps({ + actionCards: { + ...baseDeps().actionCards, + async listObserverCandidates() { + return [candidate()]; + }, + async createOrAppendCard(input) { + cardInput = input; + return baseDeps().actionCards.createOrAppendCard(input); + }, + async postSystemMessage(input) { + systemNoteInput = input; + return cardMessageRow({ id: "system-message-escalated", kind: "system_event" }); + } + }, + workItems: { + async createWorkItem(input) { + return workItemRow({ id: "work-item-execute-failed", submitterUserId: input.submitterUserId }); + }, + async findProjectById() { + return projectRow(); + }, + async transitionWorkItemStatus(input) { + transitionInput = input; + return { id: input.workItemId, status: input.to, transitioned: true }; + } + }, + agentRuns: { + async enqueue() { + throw new Error("queue is down"); + } + }, + aiSettings: { + async findUserProfileAccessRecord() { + return { membershipRole: "member", profile: { workspaceId, userId: assigneeUserId, defaultMode: 3, granularJson: {}, dispatchPolicy: "auto", cuuProactivity: "balanced", modelTierPref: null, createdAt: now, updatedAt: now } as UserAiProfileRow }; + } + }, + decisions: { + async createEscalationEvent(input) { + escalationInput = input; + return escalationRow(); + } + }, + client: llmClientReturning({ + items: [{ kind: "execute", title_md: "重写第三节", confidence: "high", suggested_assignee_nickname: "张三" }] + }) + }); + + const result = await createConversationObserverScheduler(deps).tick(); + assert.equal(result.failed, 0, "a dispatch failure that we recover into an escalation is not a tick-level analysis failure"); + assert.equal(result.cards_created, 1); + const items = (cardInput as { items: Array> }).items; + assert.equal(items[0]?.status, "escalated"); + assert.equal(items[0]?.workItemId, "work-item-execute-failed", "the item keeps the work-item lineage so the inbox can resolve it"); + // 真落了一条 escalation(进决策收件箱的驱动数据),指向失败工作项、带负责人与来源溯源。 + assert.equal((escalationInput as { workItemId: string }).workItemId, "work-item-execute-failed"); + assert.equal((escalationInput as { trigger: string }).trigger, "unqualified"); + assert.equal((escalationInput as { suggestedLeadUserId: string }).suggestedLeadUserId, assigneeUserId); + assert.equal((escalationInput as { handoffJson: { execute_dispatch_failed?: boolean } }).handoffJson.execute_dispatch_failed, true); + // 工作项被推进 escalated(状态诚实反映"在等人")。 + assert.equal((transitionInput as { workItemId: string }).workItemId, "work-item-execute-failed"); + assert.equal((transitionInput as { to: string }).to, "escalated"); + // 会话里落一条转人系统提示。 + assert.equal((systemNoteInput as { content: { event?: string } }).content.event, "execute_item_escalated"); +}); + // ── tick: decide dispatch ──────────────────────────────────────────────────────────── test("decide items open a pm-mode work item, create an escalation, and post a threaded @-mention", async () => { diff --git a/apps/api/src/workers/conversation-observer.ts b/apps/api/src/workers/conversation-observer.ts index 4378ae92..8bd08644 100644 --- a/apps/api/src/workers/conversation-observer.ts +++ b/apps/api/src/workers/conversation-observer.ts @@ -150,7 +150,11 @@ export type ConversationObserverDeps = { | "recordAnalysisFailure" | "postSystemMessage" >; - workItems: Pick; + // #4:execute 类派发失败时也要能把工作项推进 escalated(与 createEscalationEvent 一起让失败进决策收件箱)。 + // transitionWorkItemStatus 声明为可选:生产默认注入完整仓库(见 getDefaultConversationObserverScheduler), + // 纯读单测不提供时优雅跳过状态回写(escalation_event 已落库,仍会进收件箱,只是工作项状态不同步)。 + workItems: Pick & + Partial>; agentRuns: Pick; notifications: Pick; decisions: Pick; @@ -364,6 +368,81 @@ function assigneeAutoSelectedNote(itemId: string, assignee: AssigneeResolution): }; } +// #4(execute 失败进决策收件箱):execute 类派发失败但工作项已建成时——落一条真实 escalation_event 并把 +// 工作项推进 escalated,让这次失败像 decide 类一样出现在决策收件箱、且有恢复入口(resolve/retry)。此前 +// execute 失败只把行标 escalated、从不落 ai_decisions 行,彻底不进队列也无恢复入口(decide 类却总会落)。 +// 全程 best-effort:escalation 落库自身失败也不把这次分析算成 tick 级 failed(返回无 systemNote 的 escalated +// 行,与既有优雅降级一致),避免一次派发失败连累整条会话的观察者分析。 +async function escalateExecuteDispatchFailure( + deps: ConversationObserverDeps, + candidate: ObserverCandidateRow, + planItem: ObserverPlanItem, + itemId: string, + at: Date, + assignee: AssigneeResolution, + workItemId: string +): Promise { + try { + await deps.decisions.createEscalationEvent({ + workItemId, + trigger: "unqualified", + reasonMd: planItem.title_md, + suggestedLeadUserId: assignee.userId, + handoffJson: { + source: "conversation_observer", + conversation_id: candidate.conversationId, + action_card_item_id: itemId, + // 与 decide 类升级区分:这条是 execute 自动派发没跑通后转人的失败升级,供后续遥测/UI 需要时辨识。 + execute_dispatch_failed: true + } + }); + // 工作项从 ai_working/spec_ready 推进 escalated(CAS 守卫在仓库层,非法前驱 no-op)——让工作项状态诚实反映 + // "在等人",同时 escalated 是 pm_mode/ai_working/cancelled 的合法前驱,resolve 升级时的状态迁移才走得通。 + // fire-and-forget:状态写失败不回滚已落库的 escalation(下一次 resolve 仍按 escalation 走)。 + await deps.workItems.transitionWorkItemStatus?.({ workItemId, to: "escalated" satisfies WorkItemStatus, at }).catch((error) => { + deps.logger?.warn?.("conversation_observer_execute_escalate_transition_failed", { + conversationId: candidate.conversationId, + error + }); + }); + return { + item: { + id: itemId, + kind: "execute", + titleMd: planItem.title_md, + confidence: planItem.confidence, + assigneeUserId: assignee.userId, + workItemId, + status: "escalated" + }, + systemNote: { + content: { + event: "execute_item_escalated", + action_card_item_id: itemId, + assignee_user_id: assignee.userId, + summary: `@${assignee.nickname} 这件事我没能自动派下去,转给你决定:${planItem.title_md}` + } + } + }; + } catch (error) { + deps.logger?.warn?.("conversation_observer_execute_escalation_create_failed", { + conversationId: candidate.conversationId, + error + }); + return { + item: { + id: itemId, + kind: "execute", + titleMd: planItem.title_md, + confidence: planItem.confidence, + assigneeUserId: assignee.userId, + workItemId, + status: "escalated" + } + }; + } +} + async function dispatchExecuteItem( deps: ConversationObserverDeps, candidate: ObserverCandidateRow, @@ -374,6 +453,9 @@ async function dispatchExecuteItem( ): Promise { const assignee = await resolveAssignee(deps, candidate, planItem, roster); if (!assignee) { + // 连项目负责人都解析不出(owner-less 项目的退化角落)——没有可当 submitter 的真人,建不出 work_item, + // 因而也落不了 escalation_event(escalation_events.work_item_id NOT NULL)。这与 decide 类的 !assignee + // 分支行为一致(同样只标 escalated),故不算 execute 相对 decide 的分叉;#4 修的是"有负责人但派发失败"那支。 return { item: { id: itemId, @@ -393,8 +475,11 @@ async function dispatchExecuteItem( }); const dispatchPolicy: DispatchPolicy = profileAccess?.profile?.dispatchPolicy ?? DEFAULT_USER_AI_PROFILE.dispatch_policy; + // #4:先把工作项建出来、与"派发"(enqueue/notify)分离——这样派发失败时手里仍有 work_item.id 可挂 escalation。 + // 工作项创建本身失败则和既有行为一致(标 escalated、无 work_item,无处可挂 escalation)。 + let workItem: Awaited>; try { - const workItem = await deps.workItems.createWorkItem({ + workItem = await deps.workItems.createWorkItem({ projectId: candidate.projectId, workspaceId: candidate.workspaceId, submitterUserId: assignee.userId, @@ -405,7 +490,24 @@ async function dispatchExecuteItem( mode: "worker" satisfies WorkItemMode, at }); + } catch (error) { + deps.logger?.warn?.("conversation_observer_execute_work_item_failed", { + conversationId: candidate.conversationId, + error + }); + return { + item: { + id: itemId, + kind: "execute", + titleMd: planItem.title_md, + confidence: planItem.confidence, + assigneeUserId: assignee.userId, + status: "escalated" + } + }; + } + try { if (dispatchPolicy === "auto") { const run = await deps.agentRuns.enqueue({ workItemId: workItem.id, @@ -467,16 +569,8 @@ async function dispatchExecuteItem( conversationId: candidate.conversationId, error }); - return { - item: { - id: itemId, - kind: "execute", - titleMd: planItem.title_md, - confidence: planItem.confidence, - assigneeUserId: assignee.userId, - status: "escalated" - } - }; + // #4:派发失败但工作项已存在——落 escalation 让这次失败进决策收件箱(对齐 decide 类总会落 ai_decisions 行)。 + return escalateExecuteDispatchFailure(deps, candidate, planItem, itemId, at, assignee, workItem.id); } } From 0794f542ddd5a19bd9b7091ff2362794dfa5067c Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:53:50 +0800 Subject: [PATCH 07/23] fix(escalations): #5 publish escalation.opened to the target user's /me stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit human-reserved escalations only reached workitem + workspace-global topics, so a non-admin owner (who doesn't subscribe all:) saw a new escalation only if they happened to be parked on that work item's detail page — the desktop workbench badge/inbox never learned of it in real time. Also publish escalation.opened to topics.user of the escalation target — the work item's current human owner (claimant first, else submitter), the person who takes over pm mode. That user always has read access to the work item, so this matches their GET /attention queue. Budget escalations already reach topics.user via emitBudgetNotice, so they were already covered. Co-Authored-By: Claude Fable 5 --- apps/api/src/agent-runs.test.ts | 53 ++++++++++++++++--- apps/api/src/services/human-reserved-guard.ts | 9 ++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/api/src/agent-runs.test.ts b/apps/api/src/agent-runs.test.ts index 81c3b4c0..41925eb8 100644 --- a/apps/api/src/agent-runs.test.ts +++ b/apps/api/src/agent-runs.test.ts @@ -3610,9 +3610,11 @@ test("agent run enqueue opens user_forbidden escalation for human-reserved worke assert.equal(auditLogs.rows.some((row) => row.action === "escalation.opened"), true); // findings[#tenancy]:全局 escalation 发到按工作区隔离的话题 `all:`(不再裸 'all'), // 与订阅侧对齐。单租户下解析到默认工作区。 + // #5:升级除工作项流 + 工作区全局流外,也发到升级目标人(认领人优先、否则提交人=userId)的 per-user /me 流。 assert.deepEqual(events.map((event) => [event.topic, event.type]), [ [`workitem:${workItemId}`, "escalation.opened"], - [`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"] + [`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"], + [`user:${userId}`, "escalation.opened"] ]); assert.equal(workItems.rows.get(workItemId)?.status, "pm_mode"); assert.equal(workItems.rows.get(workItemId)?.mode, "pm"); @@ -3665,7 +3667,42 @@ test("human-reserved guard audits and publishes escalation in the work item's wo assert.equal(audit?.orgId, runtimeSettings.auth.defaultOrgId); assert.deepEqual(events.map((event) => [event.topic, event.type]), [ [`workitem:${workItemId}`, "escalation.opened"], - [`all:${nonDefaultWorkspaceId}`, "escalation.opened"] + [`all:${nonDefaultWorkspaceId}`, "escalation.opened"], + // #5:目标人取该工作项的当前真人负责人(提交人=userId,认领人为空)。 + [`user:${userId}`, "escalation.opened"] + ]); +}); + +test("#5 human-reserved escalation routes the per-user /me publish to the claimant when the work item is claimed", async () => { + const runtimeSettings = settings(); + const claimantUserId = "10000000-0000-4000-8000-000000000099"; + assert.notEqual(claimantUserId, userId); + const workItems = new MemoryWorkItems([ + humanReservedWorkItemRow({ claimedByUserId: claimantUserId }) + ]); + const decisions = new MemoryAiDecisions(); + const auditLogs = new MemoryAuditLogs(); + const events: { topic: string; type: string }[] = []; + const guard = createHumanReservedGuard({ + workItems, + decisions, + auditLogs, + settings: runtimeSettings, + now: () => now, + bus: { + async publish(topic, type) { + events.push({ topic, type }); + } + } + }); + + const result = await guard({ workItemId, actorId: userId, mode: "worker", settings: runtimeSettings }); + assert.equal(result?.trigger, "user_forbidden"); + // 目标人:认领人优先于提交人——被转人接手的正是当前认领人。 + assert.deepEqual(events.map((event) => [event.topic, event.type]), [ + [`workitem:${workItemId}`, "escalation.opened"], + [`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"], + [`user:${claimantUserId}`, "escalation.opened"] ]); }); @@ -3787,7 +3824,9 @@ test("R9.7 high-risk legal, finance, identity, and publishing tool calls are sto assert.equal(auditLogs.rows.some((row) => row.action === "escalation.opened"), true); assert.deepEqual(events.map((event) => [event.topic, event.type]), [ [`workitem:${caseWorkItemId}`, "escalation.opened"], - [`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"] + [`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened"], + // #5:高风险工具调用升级同样发到目标人(提交人=userId)的 per-user /me 流。 + [`user:${userId}`, "escalation.opened"] ]); } }); @@ -3971,10 +4010,12 @@ test("R9.7 high-risk tool calls create durable evidence instead of reusing a gen tool_id: "finance_payment_release", risk_category: "finance" }); - assert.equal(events.length, 4, "the tool-specific escalation is published alongside the existing generic card"); - assert.deepEqual(events.slice(2).map((event) => [event.topic, event.type, event.data.source, event.data.tool_id]), [ + // #5:每次升级现发 3 条(工作项流 + 工作区全局流 + 目标人 per-user /me 流)——generic + tool-call 各 3 条 = 6。 + assert.equal(events.length, 6, "the tool-specific escalation is published alongside the existing generic card"); + assert.deepEqual(events.slice(3).map((event) => [event.topic, event.type, event.data.source, event.data.tool_id]), [ [`workitem:${workItemId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"], - [`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"] + [`all:${runtimeSettings.auth.defaultWorkspaceId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"], + [`user:${userId}`, "escalation.opened", "human_reserved_tool_call", "finance_payment_release"] ]); }); diff --git a/apps/api/src/services/human-reserved-guard.ts b/apps/api/src/services/human-reserved-guard.ts index 77853926..225649f6 100644 --- a/apps/api/src/services/human-reserved-guard.ts +++ b/apps/api/src/services/human-reserved-guard.ts @@ -328,6 +328,15 @@ export function createHumanReservedGuard(options: HumanReservedGuardOptions = {} topics.all(workItem.workspaceId ?? settings.auth.defaultWorkspaceId).topic, eventPayload ); + // #5(决策推送链):除工作项流 + 工作区全局流外,把升级也发到"升级目标人"的 per-user /me 流—— + // 否则被转人处理的负责人(普通成员,非 admin,收不到 all: 全局流)只有恰好停在该工作项详情页 + // 才收得到,桌面工作台的「待拍板」角标/收件箱无从实时感知这条升级。目标人取窄:人工预留升级本就没有 + // suggested_lead,落到该工作项的当前真人负责人——认领人优先、否则提交人,正是这次要接手 pm 模式的人。 + // 该人对自己的工作项恒有读权限,故这条升级也确实会出现在其 GET /attention 队列里,两处口径一致。 + const escalationTargetUserId = workItem.claimedByUserId ?? workItem.submitterUserId; + if (escalationTargetUserId) { + await publishEscalationEvent(bus, topics.user(escalationTargetUserId).topic, eventPayload); + } } return { From 87012e6205256d7c0b3ef8d3730deb7fff43011a Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:54:01 +0800 Subject: [PATCH 08/23] fix(approval-digest): #18 count all project-scoped pending decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-conversation "待你拍板 N 件" digest card only counted approval_requests, so its number was systematically smaller than the decision inbox (GET /attention), which aggregates escalations (incl. budget) + memory conflicts + approvals + reviewable proposals. Union the three project-attributable sources (pending approvals + unresolved escalations + opened/reviewed proposals, each via work_item -> project) so the digest count matches the inbox. Scope stays narrow: memory conflicts are per-user (no project) and personal/cross-project approvals without a work_item can't be attributed to a project, so neither is stuffed into a project card. The digest is a project-level count (all members, one card), so it is not per-actor filtered — documented in the repository. Adds unionAll passthrough to the test query recorder. Co-Authored-By: Claude Fable 5 --- .../db/src/approval-digest-repository.test.ts | 23 +++++--- .../db/src/repositories/approval-digest.ts | 56 +++++++++++++++---- packages/db/src/test-query-recorder.ts | 8 +++ 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/db/src/approval-digest-repository.test.ts b/packages/db/src/approval-digest-repository.test.ts index bfcd5c62..4c2dadc6 100644 --- a/packages/db/src/approval-digest-repository.test.ts +++ b/packages/db/src/approval-digest-repository.test.ts @@ -6,7 +6,7 @@ import { listProjectsWithPendingApprovals, tombstonePendingDigestCard } from "./repositories/approval-digest.js"; -import { approvalRequests, conversationMessages } from "./schema/index.js"; +import { approvalRequests, conversationMessages, escalationEvents, proposals } from "./schema/index.js"; import { createQueryRecorder, queryReferences, queryTextFragments } from "./test-query-recorder.js"; const projectId = "18000000-0000-4000-8000-000000000001"; @@ -15,8 +15,13 @@ const conversationId = "18000000-0000-4000-8000-000000000003"; const messageId = "18000000-0000-4000-8000-000000000004"; const oldestPendingAt = new Date("2026-07-13T00:00:00.000Z"); -test("listProjectsWithPendingApprovals groups pending approvals per project with the main conversation", async () => { +test("listProjectsWithPendingApprovals aggregates the three decision sources per project with the main conversation", async () => { + // R17 #18:现在是「三源 union 子查询 → 按项目聚合」。前 3 条 db.select() 是 union 分支(审批/升级/提议), + // 各消费一条空响应;第 4 条是外层聚合查询,消费真正的候选行。 const { db, queries } = createQueryRecorder([ + [], + [], + [], [ { projectId, workspaceId, mainConversationId: conversationId, pendingCount: 4, oldestPendingAt }, // 结构上不可达的空 workspace 行——应被丢弃而不是断言。 @@ -28,11 +33,15 @@ test("listProjectsWithPendingApprovals groups pending approvals per project with assert.deepEqual(result, [ { projectId, workspaceId, mainConversationId: conversationId, pendingCount: 4, oldestPendingAt } ]); - const query = queries[0]; - assert.equal(query?.joins.length, 3, "approvals → work items → projects → main conversation"); - assert.ok(query?.joins.every((join) => join.kind === "inner")); - assert.ok(queryReferences(query?.where, approvalRequests.status), "filters pending status"); - assert.ok((query?.groupBy.length ?? 0) >= 1); + // 三条 union 分支各按对应源过滤(审批 pending / 升级未 resolve / 提议 opened|reviewed)。 + const [approvalsBranch, escalationsBranch, proposalsBranch, aggregate] = queries; + assert.ok(queryReferences(approvalsBranch?.where, approvalRequests.status), "approvals branch filters pending status"); + assert.ok(queryReferences(escalationsBranch?.where, escalationEvents.resolvedAt), "escalations branch filters unresolved"); + assert.ok(queryReferences(proposalsBranch?.where, proposals.status), "proposals branch filters reviewable status"); + // 外层聚合查询:从 union 子查询出发,只 join projects + main conversation(work_items 的 join 在各分支里)。 + assert.equal(aggregate?.joins.length, 2, "pending_decisions → projects → main conversation"); + assert.ok(aggregate?.joins.every((join) => join.kind === "inner")); + assert.ok((aggregate?.groupBy.length ?? 0) >= 1); }); test("listActivePendingDigestCards filters system_event pending_digest tombstone-free cards", async () => { diff --git a/packages/db/src/repositories/approval-digest.ts b/packages/db/src/repositories/approval-digest.ts index c3783d0c..d9f67f88 100644 --- a/packages/db/src/repositories/approval-digest.ts +++ b/packages/db/src/repositories/approval-digest.ts @@ -1,11 +1,13 @@ -import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import type { WorkHubDb } from "../client.js"; import { approvalRequests, conversationMessages, + escalationEvents, projectConversations, projects, + proposals, workItems } from "../schema/index.js"; @@ -23,27 +25,62 @@ export type PendingApprovalDigestRow = { oldestPendingAt: Date; }; -// §A3:每个「有待办审批」的项目一行——待审批数 + 最久一条的创建时刻 + main 会话 id。个人空间 -// (is_personal=true)、已归档/已删除、无待办审批的项目天然不出现(不是巡检时跳过,是压根不产出候选)。 -// 只统计挂在工作项上、进而挂在项目上的 pending 审批(work_item_id 为空的审批无法归到项目,不计)。 +// §A3 + R17 #18:每个「有待拍板决策」的项目一行——待决总数 + 最久一条的创建时刻 + main 会话 id。 +// +// ── 计数口径(务必读懂再改)────────────────────────────────────────────────────────────── +// digest 卡是项目 main 会话里的一张项目级卡片(该项目所有成员都看得到),数的是「这个项目里还有几件事 +// 在等人拍板」。此前只数 approval_requests,系统性小于决策收件箱(GET /attention)真实条数——收件箱聚合 +// 四源:升级(含预算) + 偏好冲突 + 审批 + 待评审提议。这里把 digest 扩到与收件箱**同源**,但按「能否归到本 +// 项目」取窄: +// * 审批 approval_requests(status='pending') —— 经 work_item 挂到项目。(work_item_id 为空的个人 +// 审批无法归属项目,天然不计——正是"跨项目个人审批不塞进项目 digest"。) +// * 升级 escalation_events(resolved_at is null) —— 经 work_item 挂到项目;含预算类升级(trigger/handoff)。 +// * 待评审提议 proposals(status in opened/reviewed) —— 经 work_item 挂到项目。 +// * 偏好冲突 memory conflicts 不计——它是 per-user(agent 记忆冲突)、无 work_item/project 归属,塞进项目 +// 卡会张冠李戴。这与收件箱按 actor 可见性过滤的差异是**有意的**:digest 是项目级计数(全员一张卡), +// 不做 per-actor 收窄,故它数的是"本项目待决总数"而非"某个人能看到的条数"。 +// 个人空间(is_personal)、已归档/已删除、无任何待决的项目天然不出现(压根不产出候选)。 export async function listProjectsWithPendingApprovals( db: WorkHubDb, input: { limit: number } ): Promise { + // 三源统一成 (projectId, createdAt) 的行流后再按项目聚合——每一行是一件待决。work_items.project_id + // NOT NULL,故 count(projectId)=count(*)=真实待决条数;min(createdAt) 取全部三源里最久的一条。 + const pendingDecisions = db + .select({ projectId: workItems.projectId, createdAt: approvalRequests.createdAt }) + .from(approvalRequests) + .innerJoin(workItems, and(eq(workItems.id, approvalRequests.workItemId), isNull(workItems.deletedAt))) + .where(eq(approvalRequests.status, "pending")) + .unionAll( + db + .select({ projectId: workItems.projectId, createdAt: escalationEvents.createdAt }) + .from(escalationEvents) + .innerJoin(workItems, and(eq(workItems.id, escalationEvents.workItemId), isNull(workItems.deletedAt))) + .where(isNull(escalationEvents.resolvedAt)) + ) + .unionAll( + db + .select({ projectId: workItems.projectId, createdAt: proposals.createdAt }) + .from(proposals) + .innerJoin(workItems, and(eq(workItems.id, proposals.workItemId), isNull(workItems.deletedAt))) + // 待评审 = opened/reviewed(与 GET /attention 的 proposals 源、listReviewable 同口径)。 + .where(inArray(proposals.status, ["opened", "reviewed"])) + ) + .as("pending_decisions"); + const rows = await db .select({ projectId: projects.id, workspaceId: projects.workspaceId, mainConversationId: projectConversations.id, - pendingCount: sql`count(${approvalRequests.id})::int`, - oldestPendingAt: sql`min(${approvalRequests.createdAt})` + pendingCount: sql`count(${pendingDecisions.projectId})::int`, + oldestPendingAt: sql`min(${pendingDecisions.createdAt})` }) - .from(approvalRequests) - .innerJoin(workItems, and(eq(workItems.id, approvalRequests.workItemId), isNull(workItems.deletedAt))) + .from(pendingDecisions) .innerJoin( projects, and( - eq(projects.id, workItems.projectId), + eq(projects.id, pendingDecisions.projectId), isNull(projects.deletedAt), eq(projects.archived, false), eq(projects.isPersonal, false) @@ -57,7 +94,6 @@ export async function listProjectsWithPendingApprovals( isNull(projectConversations.deletedAt) ) ) - .where(eq(approvalRequests.status, "pending")) .groupBy(projects.id, projects.workspaceId, projectConversations.id) .orderBy(asc(projects.id)) .limit(input.limit); diff --git a/packages/db/src/test-query-recorder.ts b/packages/db/src/test-query-recorder.ts index a59f60cc..3b1115bc 100644 --- a/packages/db/src/test-query-recorder.ts +++ b/packages/db/src/test-query-recorder.ts @@ -52,6 +52,14 @@ class RecordedQueryBuilder implements PromiseLike { return this; } + // R17 #18(approval-digest 扩源):unionAll 用来把审批/升级/提议三源统一成一个待决行流子查询。 + // 每个 union 分支本身是独立的 db.select()(各自已作为一条记录进 queries),这里只记一步、返回 this 让链 + // 继续(后续 .as() 把整个 union 收成子查询别名)。 + unionAll(_subquery: unknown): this { + this.query.steps.push("unionAll"); + return this; + } + where(condition: unknown): this { this.query.where = condition; this.query.steps.push("where"); From 72039605762ea5d75e89b5938b06a4575a6d9664 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:55:42 +0800 Subject: [PATCH 09/23] =?UTF-8?q?feat(g4=20#9):=20E3=20project-plan=20draf?= =?UTF-8?q?ts=20=E2=80=94=20desktop=20schedule=20tab=20double-entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 E3 项目规划草案(起草/列表/详情/批准/驳回/物化五端点)接成桌面日程左栏可用: - 「用 Cuu 起草计划」按钮 → 起草表单(规划意图 textarea)→ POST /plan-drafts; LLM 未配置服务端回 503(人话 message 直接透传到错误横幅)。 - 草案列表:列全部草案(含 pending_review),状态 chip(待审阅/已批准/已驳回/已物化); 点开进详情面板(里程碑/工作项/理由/审阅意见)。 - 详情动作接现成端点:pending_review→批准/驳回(带理由);approved→物化到时间线; materialized→结果计数。忙态全禁用,物化成功后重拉时间线 VM,新里程碑/工作项立即落到周历。 - 无管理权(草案 GET 403)→ 左栏退回里程碑回落、不给起草假入口(04 §4 铁律 3)。 无需改 shell(自判权限),mountScheduleView 签名不变。 新增 schedule/api.ts 五个薄封装 + render 计划面板 + view 状态机 + css;render.test 19 用例; 桌面全量 1330 测试绿 + 全仓 typecheck 绿。 Co-Authored-By: Claude Fable 5 --- apps/desktop-webview/src/workbench/css.ts | 38 +++ .../src/workbench/schedule/api.ts | 112 ++++-- .../src/workbench/schedule/render.test.ts | 141 ++++++-- .../src/workbench/schedule/render.ts | 313 ++++++++++++++--- .../src/workbench/schedule/view.ts | 319 +++++++++++++++++- 5 files changed, 805 insertions(+), 118 deletions(-) diff --git a/apps/desktop-webview/src/workbench/css.ts b/apps/desktop-webview/src/workbench/css.ts index 598f236f..8114280d 100644 --- a/apps/desktop-webview/src/workbench/css.ts +++ b/apps/desktop-webview/src/workbench/css.ts @@ -1197,6 +1197,44 @@ export const workbenchCss = [ ".wh-wb-sc-plan-due{font:600 10.5px/1.3 var(--ds-mono,ui-monospace,monospace);color:var(--ds-ink-faint);flex:none}", ".wh-wb-sc-plan-due--none{font-style:italic}", ".wh-wb-sc-plan-done{font:700 9px/1.3 var(--ds-font);color:var(--ds-success);background:var(--ds-success-soft);padding:1px 6px;border-radius:99px;flex:none}", + // G4 #9(E3 计划草案左栏)——起草表单 / 草案列表 / 详情 / 审批动作。 + ".wh-wb-tl-btn--danger{background:var(--ds-danger-soft,rgba(255,69,58,.12));border-color:rgba(255,69,58,.28);color:var(--ds-danger)}", + ".wh-wb-sc-plan-new{font-size:11px;padding:5px 10px}", + ".wh-wb-sc-plan-notice{font:600 11.5px/1.5 var(--ds-font);color:var(--ds-success);background:var(--ds-success-soft);border-radius:8px;padding:7px 10px;margin:0 16px 6px}", + ".wh-wb-sc-plan-err{font:600 11.5px/1.5 var(--ds-font);color:var(--ds-danger);background:var(--ds-danger-soft,rgba(255,69,58,.1));border-radius:8px;padding:7px 10px;margin:0 16px 6px}", + ".wh-wb-sc-plan-hint{font:500 11px/1.6 var(--ds-font);color:var(--ds-ink-faint);margin:8px 0 0}", + ".wh-wb-sc-plan-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}", + ".wh-wb-sc-plan-compose{display:flex;flex-direction:column;gap:8px}", + ".wh-wb-sc-plan-compose-label{font:600 11px/1.3 var(--ds-font);color:var(--ds-ink-muted)}", + ".wh-wb-sc-plan-compose-intent,.wh-wb-sc-plan-reject-reason{width:100%;box-sizing:border-box;resize:vertical;font:500 12px/1.6 var(--ds-font);" + + "color:var(--ds-ink);background:var(--ds-glass);border:1px solid var(--ds-glass-border);border-radius:9px;padding:8px 10px}", + ".wh-wb-sc-plan-list{display:flex;flex-direction:column;gap:8px}", + ".wh-wb-sc-plan-draft-row{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--ds-glass-border);" + + "border-radius:10px;background:var(--ds-glass);cursor:pointer}", + ".wh-wb-sc-plan-draft-row:hover{background:var(--ds-glass-strong)}", + ".wh-wb-sc-plan-draft-main{flex:1;min-width:0}", + ".wh-wb-sc-plan-draft-title{font:600 12px/1.4 var(--ds-font);color:var(--ds-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", + ".wh-wb-sc-plan-draft-meta{font:500 10.5px/1.4 var(--ds-font);color:var(--ds-ink-faint);margin-top:2px}", + ".wh-wb-sc-plan-chip{font:700 9.5px/1.3 var(--ds-font);padding:2px 7px;border-radius:99px;flex:none;white-space:nowrap}", + ".wh-wb-sc-plan-chip--approval{color:var(--ds-warn);background:var(--ds-warn-soft,rgba(255,159,10,.14))}", + ".wh-wb-sc-plan-chip--info{color:var(--ds-accent);background:var(--ds-accent-soft)}", + ".wh-wb-sc-plan-chip--handoff{color:var(--ds-danger);background:var(--ds-danger-soft,rgba(255,69,58,.12))}", + ".wh-wb-sc-plan-back{display:inline-flex;align-items:center;gap:4px;font:600 11px/1.3 var(--ds-font);color:var(--ds-ink-muted);" + + "background:none;border:none;cursor:pointer;padding:2px 0;margin-bottom:6px}", + ".wh-wb-sc-plan-back svg{width:13px;height:13px}", + ".wh-wb-sc-plan-back:hover{color:var(--ds-ink)}", + ".wh-wb-sc-plan-detail-head{margin:2px 0 6px}", + ".wh-wb-sc-plan-intent{font-style:italic}", + ".wh-wb-sc-plan-review{color:var(--ds-danger)}", + ".wh-wb-sc-plan-items{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px}", + ".wh-wb-sc-plan-item{border-left:2px solid var(--ds-glass-border);padding:1px 0 1px 9px}", + ".wh-wb-sc-plan-item-title{display:flex;align-items:center;gap:7px;font:600 12px/1.4 var(--ds-font);color:var(--ds-ink)}", + ".wh-wb-sc-plan-item-title .wh-wb-sc-plan-due{margin-left:auto}", + ".wh-wb-sc-plan-item-obj{font:500 11px/1.55 var(--ds-font);color:var(--ds-ink-muted);margin-top:2px}", + ".wh-wb-sc-plan-item-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}", + ".wh-wb-sc-plan-item-dep,.wh-wb-sc-plan-item-owner{font:600 9.5px/1.3 var(--ds-font);color:var(--ds-ink-faint);" + + "background:var(--ds-glass);border-radius:99px;padding:1px 7px}", + ".wh-wb-sc-plan-reject{display:flex;flex-direction:column;gap:8px;margin-top:10px}", ".wh-wb-sc-cal{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;min-height:0}", ".wh-wb-sc-cal-head{flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:12px 18px 10px}", ".wh-wb-sc-range{font:650 13.5px/1 var(--ds-font);color:var(--ds-ink);flex:1;min-width:0}", diff --git a/apps/desktop-webview/src/workbench/schedule/api.ts b/apps/desktop-webview/src/workbench/schedule/api.ts index 7060f7eb..21db97b5 100644 --- a/apps/desktop-webview/src/workbench/schedule/api.ts +++ b/apps/desktop-webview/src/workbench/schedule/api.ts @@ -1,13 +1,12 @@ -// WorkHub 桌面 · 工作台「日程」标签的数据访问薄层——照 timeline/api.ts 的取舍。日程是纯只读视图: +// WorkHub 桌面 · 工作台「日程」标签的数据访问薄层——照 timeline/api.ts 的取舍。 // 右栏周历:复用 E1 已上线的项目时间线 VM(工作项按 due_at 落卡、里程碑 due_at 标在列头), // 与看板同一份取数,不另起端点。 -// 左栏计划摘要:取 E3 已上线的项目计划草案(GET /api/projects/:id/plan-drafts),挑「最新已批准 / -// 已物化」那份,展示它的 rationale + 里程碑列表;拿不到(无草案 / 无权 / 出错)时静默降级, -// render 会回落到时间线里程碑列表——绝不因为左栏取数失败拖垮整个日程(同 army pill 的降级手法)。 +// 左栏项目计划:G4 #9 把 E3 项目规划草案(起草 / 列表 / 详情 / 批准 / 驳回 / 物化五端点, +// apps/api/src/routes/project-planner.ts 全通)接成双端可用——桌面日程左栏起草、发现、审批。 // // 说明:项目计划草案 VM(ProjectPlanDraftVM)定义在 apps/api 的 service 里、未经 contracts 导出,这里只 -// 为单一消费者(这个日程标签)声明一个「读形状」最小类型,只挑我们真正读的字段——同 timeline/api.ts 给 -// 依赖/挂里程碑响应用内联类型的既有先例,不为一个工作台特性去扩 packages/contracts 的公共面。 +// 为单一消费者(这个日程标签)声明「读形状」最小类型——同 timeline/api.ts 给依赖/挂里程碑响应用内联 +// 类型的既有先例,不为一个工作台特性去扩 packages/contracts 的公共面;写路径同理走 client.request。 import type { WorkHubApiClient } from "@workhub/api-client"; @@ -19,39 +18,94 @@ type Locale = "zh-CN" | "en-US"; export type ScheduleApiClient = Pick; -// 计划草案的最小读形状——只挑左栏摘要真正用到的字段。 +// 计划草案的读形状——挑左栏起草/列表/详情/审批真正用到的字段(与服务端 ProjectPlanDraftVM 子集对齐)。 +export type SchedulePlanDraftMilestone = { ref: string; title: string; due_at: string | null; sort: number }; +export type SchedulePlanDraftItem = { + ref: string; + title: string; + objective_md: string; + due_at: string | null; + milestone_ref: string | null; + depends_on_refs: string[]; + assignee_suggestion: string | null; +}; +export type SchedulePlanDraftStatus = "draft" | "pending_review" | "approved" | "rejected" | "materialized"; export type SchedulePlanDraft = { id: string; - status: string; + status: SchedulePlanDraftStatus | string; + intent_md: string; rationale_md: string | null; + review_reason_md: string | null; + milestones: SchedulePlanDraftMilestone[]; + items: SchedulePlanDraftItem[]; + result: { milestone_ids: string[]; work_item_ids: string[]; dependency_count: number } | null; + created_at: string; updated_at: string; - milestones: Array<{ ref: string; title: string; due_at: string | null; sort: number }>; +}; + +export type SchedulePlanMaterializeResult = { + draft: SchedulePlanDraft; + result: { milestone_ids: string[]; work_item_ids: string[]; dependency_count: number }; }; type PlanDraftListResponse = { drafts?: SchedulePlanDraft[] }; -// 已「落地」的计划:已批准(approved)或已物化(materialized,物化后仍留着 rationale/里程碑)。草稿 / -// 待审 / 已驳回都不算——那些还不是拍板的计划,不该冒充「项目计划」展示在日程左栏。 -const SETTLED_PLAN_STATUSES = new Set(["approved", "materialized"]); +function draftsPath(projectId: string, locale: Locale): string { + return `/api/projects/${encodeURIComponent(projectId)}/plan-drafts?locale=${encodeURIComponent(locale)}`; +} + +function draftPath(draftId: string, suffix: string, locale: Locale): string { + return `/api/plan-drafts/${encodeURIComponent(draftId)}${suffix}?locale=${encodeURIComponent(locale)}`; +} + +// 列出项目全部计划草案(含 pending_review)。抛错交给调用方分流:403=无管理权(左栏退回里程碑回落)。 +export async function fetchPlanDrafts( + client: ScheduleApiClient, + projectId: string, + locale: Locale +): Promise { + const data = await client.request(draftsPath(projectId, locale)); + return Array.isArray(data.drafts) ? data.drafts : []; +} -// 取最新一份已落地的计划草案;没有 / 取数失败 → undefined(render 回落到时间线里程碑列表,不报错)。 -export async function fetchLatestSettledPlanDraft( +// 起草:Cuu 拟计划草案 → 落库待审。LLM 未配置服务端回 503(人话消息随 WorkHubApiError.message 抛出)。 +export function createPlanDraft( client: ScheduleApiClient, projectId: string, + intent: string, + locale: Locale +): Promise { + return client.request( + `/api/projects/${encodeURIComponent(projectId)}/plan-drafts?locale=${encodeURIComponent(locale)}`, + { method: "POST", body: JSON.stringify({ intent }) } + ); +} + +export function approvePlanDraft( + client: ScheduleApiClient, + draftId: string, + locale: Locale +): Promise { + return client.request(draftPath(draftId, "/approve", locale), { method: "POST" }); +} + +export function rejectPlanDraft( + client: ScheduleApiClient, + draftId: string, + reason: string, + locale: Locale +): Promise { + return client.request(draftPath(draftId, "/reject", locale), { + method: "POST", + body: JSON.stringify({ reason }) + }); +} + +// 物化:把已批准草案落成里程碑 + 工作项 + 依赖(时间线/日历读路径立即可见)。返回更新后的草案 + 结果计数。 +export function materializePlanDraft( + client: ScheduleApiClient, + draftId: string, locale: Locale -): Promise { - try { - const path = `/api/projects/${encodeURIComponent(projectId)}/plan-drafts?locale=${encodeURIComponent(locale)}`; - const data = await client.request(path); - const drafts = Array.isArray(data.drafts) ? data.drafts : []; - const settled = drafts.filter((draft) => SETTLED_PLAN_STATUSES.has(draft.status)); - if (settled.length === 0) { - return undefined; - } - // 最新 = updated_at 倒序第一份。 - return [...settled].sort((a, b) => b.updated_at.localeCompare(a.updated_at))[0]; - } catch { - // 无权 / 端点报错 / 网络问题——静默降级,日程右栏照常渲染,左栏回落到里程碑列表。 - return undefined; - } +): Promise { + return client.request(draftPath(draftId, "/materialize", locale), { method: "POST" }); } diff --git a/apps/desktop-webview/src/workbench/schedule/render.test.ts b/apps/desktop-webview/src/workbench/schedule/render.test.ts index a52ce5d7..533d3c4b 100644 --- a/apps/desktop-webview/src/workbench/schedule/render.test.ts +++ b/apps/desktop-webview/src/workbench/schedule/render.test.ts @@ -7,9 +7,12 @@ import type { SchedulePlanDraft } from "./api.js"; import { computeScheduleWeek, emptyScheduleUiState, + emptySchedulePlanUiState, renderScheduleErrorHtml, renderScheduleHtml, renderScheduleLoadingHtml, + type SchedulePlanDraftsState, + type SchedulePlanUiState, type ScheduleUiState } from "./render.js"; @@ -44,15 +47,39 @@ function ui(overrides: Partial = {}): ScheduleUiState { return { ...emptyScheduleUiState(), ...overrides }; } +function draft(overrides: Partial = {}): SchedulePlanDraft { + return { + id: "d1", + status: "pending_review", + intent_md: "两周内完成首页改版", + rationale_md: null, + review_reason_md: null, + milestones: [], + items: [], + result: null, + created_at: "2026-07-14T00:00:00Z", + updated_at: "2026-07-14T00:00:00Z", + ...overrides + }; +} + +function planUi(overrides: Partial = {}): SchedulePlanUiState { + return { ...emptySchedulePlanUiState(), ...overrides }; +} + function render(input: { vm?: ProjectTimelinePageVM; - planDraft?: SchedulePlanDraft; + drafts?: SchedulePlanDraft[]; + draftsState?: SchedulePlanDraftsState; + plan?: SchedulePlanUiState; locale?: "zh-CN" | "en-US"; ui?: ScheduleUiState; }) { return renderScheduleHtml({ vm: input.vm ?? vm(), - planDraft: input.planDraft, + drafts: input.drafts ?? [], + draftsState: input.draftsState ?? "forbidden", + plan: input.plan ?? planUi(), locale: input.locale ?? "zh-CN", ui: input.ui ?? ui() }); @@ -121,34 +148,104 @@ test("milestone due in the visible week is flagged in the day column head", () = assert.equal(html.includes("Beta 里程碑"), true); }); -test("plan doc: an approved draft shows its milestones + rationale and an Approved tag", () => { - const planDraft: SchedulePlanDraft = { - id: "d1", - status: "approved", - rationale_md: "两周内完成首页改版。\n先做 Hero,再做隐私区。", - updated_at: "2026-07-14T00:00:00Z", - milestones: [{ ref: "m1", title: "首页上线", due_at: "2026-07-20T00:00:00Z", sort: 0 }] - }; - const html = render({ planDraft }); - assert.equal(html.includes("已批准"), true); - assert.equal(html.includes("首页上线"), true); - assert.equal(html.includes("两周内完成首页改版"), true); - assert.equal(html.includes("先做 Hero"), true); -}); - -test("plan doc: no approved draft → falls back to timeline milestones with an honest note", () => { +test("plan (forbidden): no manage permission → milestone fallback, no draft/compose affordance", () => { const html = render({ + draftsState: "forbidden", vm: vm({ milestones: [{ id: "m1", project_id: "p1", title: "M1 里程碑", due_at: "2026-07-20T00:00:00Z", sort: 0, status: "open" }] }) }); - assert.equal(html.includes("还没有已批准的项目计划"), true); assert.equal(html.includes("M1 里程碑"), true); + // 无管理权:不给「起草」按钮,也不列草案。 + assert.equal(html.includes("data-wb-sc-plan-new"), false); + assert.equal(html.includes("data-wb-sc-plan-list"), false); +}); + +test("plan (ready, empty list): shows the draft-with-Cuu entry button and an empty hint", () => { + const html = render({ draftsState: "ready", drafts: [], plan: planUi({ mode: "list" }) }); + assert.equal(html.includes("data-wb-sc-plan-new"), true); + assert.equal(html.includes("用 Cuu 起草计划"), true); + assert.equal(html.includes("data-wb-sc-plan-list-empty"), true); +}); + +test("plan (ready, list): drafts render with status chips incl. pending_review, clickable", () => { + const html = render({ + draftsState: "ready", + drafts: [draft({ id: "d1", status: "pending_review", intent_md: "两周内完成首页改版", milestones: [{ ref: "m1", title: "首页上线", due_at: null, sort: 0 }] })], + plan: planUi({ mode: "list" }) + }); + assert.equal(html.includes('data-wb-sc-plan-draft="d1"'), true); + assert.equal(html.includes('data-wb-sc-plan-draft-status="pending_review"'), true); + assert.equal(html.includes("待审阅"), true); + assert.equal(html.includes("两周内完成首页改版"), true); +}); + +test("plan (ready, compose): compose form has the intent textarea + submit/cancel", () => { + const html = render({ draftsState: "ready", plan: planUi({ mode: "compose", intentDraft: "做个注册流程" }) }); + assert.equal(html.includes("data-wb-sc-plan-compose"), true); + assert.equal(html.includes("data-wb-sc-plan-compose-intent"), true); + assert.equal(html.includes("data-wb-sc-plan-compose-submit"), true); + assert.equal(html.includes("做个注册流程"), true); +}); + +test("plan (ready, detail pending_review): shows milestones/items/rationale + approve & reject actions", () => { + const html = render({ + draftsState: "ready", + drafts: [draft({ + id: "d1", + status: "pending_review", + rationale_md: "先做 Hero,再做隐私区。", + milestones: [{ ref: "m1", title: "首页上线", due_at: "2026-07-20T00:00:00Z", sort: 0 }], + items: [{ ref: "i1", title: "Hero 联调", objective_md: "把首页 Hero 联调好", due_at: null, milestone_ref: "m1", depends_on_refs: [], assignee_suggestion: null }] + })], + plan: planUi({ mode: "detail", selectedDraftId: "d1" }) + }); + assert.equal(html.includes("首页上线"), true); + assert.equal(html.includes("Hero 联调"), true); + assert.equal(html.includes("先做 Hero"), true); + assert.equal(html.includes("data-wb-sc-plan-approve"), true); + assert.equal(html.includes("data-wb-sc-plan-reject"), true); + assert.equal(html.includes("data-wb-sc-plan-back"), true); +}); + +test("plan (ready, detail approved): shows the materialize action", () => { + const html = render({ + draftsState: "ready", + drafts: [draft({ id: "d1", status: "approved" })], + plan: planUi({ mode: "detail", selectedDraftId: "d1" }) + }); + assert.equal(html.includes("data-wb-sc-plan-materialize"), true); + assert.equal(html.includes("物化到时间线"), true); +}); + +test("plan (ready, detail rejecting): reject reason box + confirm/cancel", () => { + const html = render({ + draftsState: "ready", + drafts: [draft({ id: "d1", status: "pending_review" })], + plan: planUi({ mode: "detail", selectedDraftId: "d1", rejecting: true }) + }); + assert.equal(html.includes("data-wb-sc-plan-reject-reason"), true); + assert.equal(html.includes("data-wb-sc-plan-reject-confirm"), true); + assert.equal(html.includes("data-wb-sc-plan-reject-cancel"), true); +}); + +test("plan (ready, detail materialized): shows the result summary, no actions", () => { + const html = render({ + draftsState: "ready", + drafts: [draft({ id: "d1", status: "materialized", result: { milestone_ids: ["m1", "m2"], work_item_ids: ["w1"], dependency_count: 1 } })], + plan: planUi({ mode: "detail", selectedDraftId: "d1" }) + }); + assert.equal(html.includes("data-wb-sc-plan-result"), true); + assert.equal(html.includes("已物化到时间线"), true); + assert.equal(html.includes("data-wb-sc-plan-approve"), false); }); -test("plan doc: neither approved plan nor milestones → guidance, not a blank pane", () => { - const html = render({ vm: vm({ milestones: [], items: [] }) }); - assert.equal(html.includes("也还没有里程碑") || html.includes("还没有已批准的项目计划"), true); +test("plan: notice and error banners render when present", () => { + const html = render({ draftsState: "ready", plan: planUi({ notice: "草案已生成,待你审批。", error: "AI 项目规划尚未配置。" }) }); + assert.equal(html.includes("data-wb-sc-plan-notice"), true); + assert.equal(html.includes("草案已生成"), true); + assert.equal(html.includes("data-wb-sc-plan-error"), true); + assert.equal(html.includes("AI 项目规划尚未配置"), true); }); test("week navigation controls (prev / today / next) are present", () => { diff --git a/apps/desktop-webview/src/workbench/schedule/render.ts b/apps/desktop-webview/src/workbench/schedule/render.ts index 78ec9eed..fde80da5 100644 --- a/apps/desktop-webview/src/workbench/schedule/render.ts +++ b/apps/desktop-webview/src/workbench/schedule/render.ts @@ -24,6 +24,54 @@ export function emptyScheduleUiState(): ScheduleUiState { return { weekOffset: 0 }; } +// G4 #9(E3 计划草案左栏):左栏计划面板的本地 UI 态——由 view.ts 持有,render 只读。 +// mode: list=草案列表 / compose=起草表单 / detail=某草案详情 +// draftsState: ready=能管项目(可起草/审批)/ forbidden=无管理权(退回里程碑回落)/ loading +// intentDraft/rejectDraft: 失败重渲时回填 textarea 用(不在每次击键都重渲,只在提交时快照)。 +export type SchedulePlanMode = "list" | "compose" | "detail"; +export type SchedulePlanDraftsState = "loading" | "ready" | "forbidden"; +export type SchedulePlanUiState = { + mode: SchedulePlanMode; + selectedDraftId?: string | undefined; + rejecting: boolean; + busy: boolean; + error?: string | undefined; + notice?: string | undefined; + intentDraft: string; + rejectDraft: string; +}; + +export function emptySchedulePlanUiState(): SchedulePlanUiState { + return { mode: "list", rejecting: false, busy: false, intentDraft: "", rejectDraft: "" }; +} + +function planDraftStatusLabel(status: string, zh: boolean): string { + const map: Record = { + draft: { zh: "草稿", en: "Draft" }, + pending_review: { zh: "待审阅", en: "Pending review" }, + approved: { zh: "已批准", en: "Approved" }, + rejected: { zh: "已驳回", en: "Rejected" }, + materialized: { zh: "已物化", en: "Materialized" } + }; + const entry = map[status]; + return entry ? (zh ? entry.zh : entry.en) : status; +} + +function planDraftStatusTone(status: string): string { + switch (status) { + case "pending_review": + return "approval"; + case "approved": + return "info"; + case "materialized": + return "info"; + case "rejected": + return "handoff"; + default: + return "info"; + } +} + function parseDate(value: string | undefined | null): Date | undefined { if (!value) { return undefined; @@ -139,67 +187,222 @@ function renderTaskCardHtml(item: TimelineWorkItemVM, zh: boolean): string {
`; } -function renderPlanDocHtml( - vm: ProjectTimelinePageVM, - planDraft: SchedulePlanDraft | undefined, - zh: boolean -): string { - const head = `
${ - zh ? "项目计划" : "Project plan" - }${ - planDraft - ? `${zh ? "已批准" : "Approved"}` - : "" - }
`; - - if (planDraft) { - const milestones = [...planDraft.milestones].sort((a, b) => a.sort - b.sort); - const milestoneList = milestones.length - ? `

${zh ? "里程碑" : "Milestones"}

    ${milestones - .map((m) => { - const due = parseDate(m.due_at); - const dueLabel = due - ? `${due.getUTCMonth() + 1}/${due.getUTCDate()}` - : `${zh ? "未定期" : "TBD"}`; - return `
  • ${workbenchIcons.pin}${escapeHtml(m.title)}${dueLabel}
  • `; - }) - .join("")}
` - : ""; - const rationale = planDraft.rationale_md - ? `

${zh ? "计划说明" : "Rationale"}

${planDraft.rationale_md - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => `

${escapeHtml(line)}

`) - .join("")}` - : ""; - const body = milestoneList + rationale || `

${zh ? "这份计划暂无内容。" : "This plan has no content yet."}

`; - return `
${head}
${body}
`; +function planDueLabel(value: string | null, zh: boolean): string { + const due = parseDate(value); + return due + ? `${due.getUTCMonth() + 1}/${due.getUTCDate()}` + : `${zh ? "未定期" : "TBD"}`; +} + +function planShortDate(value: string, zh: boolean): string { + const d = parseDate(value); + if (!d) { + return ""; } + return zh ? `${d.getUTCMonth() + 1}月${d.getUTCDate()}日` : `${EN_MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; +} - // 回落:没有已批准的计划草案(E3)——直接列时间线里程碑,并诚实说明这是回落。 +// 里程碑回落(无管理权 / 无草案时):直接列时间线里程碑,诚实说明这是回落。 +function renderMilestoneFallbackBody(vm: ProjectTimelinePageVM, zh: boolean, canDraft: boolean): string { const milestones = [...vm.milestones].sort((a, b) => a.sort - b.sort); if (milestones.length === 0) { - return `
${head}

${ - zh - ? "还没有已批准的项目计划,也还没有里程碑。让 Cuu 起草计划(时间线里的入口)后,这里会显示计划摘要。" - : "No approved plan and no milestones yet. Once a plan is drafted (from the timeline), its summary shows here." - }

`; + return `

${ + canDraft + ? (zh + ? "还没有项目计划,也还没有里程碑。点上方「用 Cuu 起草计划」让 Cuu 拟一份。" + : "No plan and no milestones yet. Use “Draft a plan with Cuu” above to have Cuu propose one.") + : (zh + ? "还没有已批准的项目计划,也还没有里程碑。" + : "No approved plan and no milestones yet.") + }

`; } const list = milestones .map((m) => { - const due = parseDate(m.due_at); - const dueLabel = due - ? `${due.getUTCMonth() + 1}/${due.getUTCDate()}` - : `${zh ? "未定期" : "TBD"}`; const doneTag = m.status === "done" ? `${zh ? "已达成" : "Reached"}` : ""; - return `
  • ${workbenchIcons.pin}${escapeHtml(m.title)}${dueLabel}${doneTag}
  • `; + return `
  • ${workbenchIcons.pin}${escapeHtml(m.title)}${planDueLabel(m.due_at, zh)}${doneTag}
  • `; + }) + .join(""); + return `

    ${zh ? "当前里程碑:" : "Current milestones:"}

    +
      ${list}
    `; +} + +// 起草表单(mode=compose)。intentDraft 只在提交时快照回填,不逐字重渲——textarea 的 DOM 值自然保留。 +function renderPlanComposeBody(plan: SchedulePlanUiState, zh: boolean): string { + return `
    + + +
    + + +
    +

    ${zh ? "Cuu 会据此拟里程碑与工作项草案,落库后交你审批。" : "Cuu drafts milestones and work items from this, saved for your review."}

    +
    `; +} + +// 草案列表(mode=list)。每条可点进详情;状态 chip 直观区分 pending_review/approved/…。 +function renderPlanListBody(drafts: SchedulePlanDraft[], zh: boolean): string { + if (drafts.length === 0) { + return `

    ${ + zh + ? "还没有计划草案。点上方「用 Cuu 起草计划」让 Cuu 拟一份。" + : "No plan drafts yet. Use “Draft a plan with Cuu” above to have Cuu propose one." + }

    `; + } + const rows = [...drafts] + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + .map((draft) => { + const firstLine = (draft.intent_md.split("\n").find((line) => line.trim().length > 0) ?? "").trim(); + const title = firstLine || (zh ? "(无意图描述)" : "(no intent)"); + return `
    +
    +
    ${escapeHtml(title)}
    +
    ${escapeHtml(planShortDate(draft.updated_at, zh))} · ${escapeHtml( + zh ? `${draft.milestones.length} 里程碑 · ${draft.items.length} 工作项` : `${draft.milestones.length} milestones · ${draft.items.length} items` + )}
    +
    + ${escapeHtml(planDraftStatusLabel(draft.status, zh))} +
    `; }) .join(""); - return `
    ${head}
    -

    ${zh ? "还没有已批准的项目计划,先列出里程碑:" : "No approved plan yet — showing milestones:"}

    -
      ${list}
    -
    `; + return `
    ${rows}
    `; +} + +// 草案详情(mode=detail):里程碑 / 工作项 / 理由 / 审阅意见 + 按状态给动作按钮。 +function renderPlanDetailBody(draft: SchedulePlanDraft, plan: SchedulePlanUiState, zh: boolean): string { + const back = ``; + const statusChip = `${escapeHtml( + planDraftStatusLabel(draft.status, zh) + )}`; + + const milestones = [...draft.milestones].sort((a, b) => a.sort - b.sort); + const milestoneList = milestones.length + ? `

    ${zh ? "里程碑" : "Milestones"}

      ${milestones + .map((m) => `
    • ${workbenchIcons.pin}${escapeHtml(m.title)}${planDueLabel(m.due_at, zh)}
    • `) + .join("")}
    ` + : ""; + const itemList = draft.items.length + ? `

    ${zh ? "工作项" : "Work items"}

      ${draft.items + .map((it) => { + const deps = it.depends_on_refs.length + ? `${escapeHtml(zh ? `依赖 ${it.depends_on_refs.join("、")}` : `needs ${it.depends_on_refs.join(", ")}`)}` + : ""; + const assignee = it.assignee_suggestion + ? `${escapeHtml(zh ? `建议:${it.assignee_suggestion}` : `suggest: ${it.assignee_suggestion}`)}` + : ""; + return `
    • +
      ${escapeHtml(it.title)}${planDueLabel(it.due_at, zh)}
      +
      ${escapeHtml(it.objective_md)}
      +
      ${deps}${assignee}
      +
    • `; + }) + .join("")}
    ` + : ""; + const rationale = draft.rationale_md + ? `

    ${zh ? "计划说明" : "Rationale"}

    ${draft.rationale_md + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => `

    ${escapeHtml(line)}

    `) + .join("")}` + : ""; + const reviewReason = draft.review_reason_md + ? `

    ${zh ? "审阅意见" : "Review note"}

    ${escapeHtml( + draft.review_reason_md + )}

    ` + : ""; + const resultSummary = draft.status === "materialized" && draft.result + ? `

    ${escapeHtml( + zh + ? `已物化到时间线:${draft.result.milestone_ids.length} 里程碑 · ${draft.result.work_item_ids.length} 工作项 · ${draft.result.dependency_count} 依赖。` + : `Materialized: ${draft.result.milestone_ids.length} milestones · ${draft.result.work_item_ids.length} items · ${draft.result.dependency_count} dependencies.` + )}

    ` + : ""; + + // 动作按钮(按状态;忙态全禁用)。驳回展开时露理由输入。 + const busyAttr = plan.busy ? " disabled" : ""; + let actions = ""; + if (plan.rejecting) { + actions = `
    + +
    + + +
    +
    `; + } else if (draft.status === "pending_review") { + actions = `
    + + +
    `; + } else if (draft.status === "approved") { + actions = `
    + +
    `; + } + + return `${back} +
    ${statusChip}
    +

    ${zh ? "规划意图" : "Intent"}

    +

    ${escapeHtml(draft.intent_md)}

    + ${milestoneList}${itemList}${rationale}${reviewReason}${resultSummary}${actions}`; +} + +function renderPlanPanelHtml(input: { + vm: ProjectTimelinePageVM; + drafts: SchedulePlanDraft[]; + draftsState: SchedulePlanDraftsState; + plan: SchedulePlanUiState; + zh: boolean; +}): string { + const { vm, drafts, draftsState, plan, zh } = input; + const canDraft = draftsState === "ready"; + const newBtn = canDraft && plan.mode === "list" + ? `` + : ""; + const head = `
    ${zh ? "项目计划" : "Project plan"}${newBtn}
    `; + const notice = plan.notice + ? `

    ${escapeHtml(plan.notice)}

    ` + : ""; + const error = plan.error + ? `

    ${escapeHtml(plan.error)}

    ` + : ""; + + let body: string; + if (draftsState !== "ready") { + // 无管理权(forbidden)或还在加载草案——退回里程碑回落,不给「起草」假入口。 + body = renderMilestoneFallbackBody(vm, zh, false); + } else if (plan.mode === "compose") { + body = renderPlanComposeBody(plan, zh); + } else if (plan.mode === "detail") { + const selected = drafts.find((d) => d.id === plan.selectedDraftId); + body = selected + ? renderPlanDetailBody(selected, plan, zh) + : renderPlanListBody(drafts, zh); // 选中项没了(被物化后重拉等)——退回列表 + } else { + body = renderPlanListBody(drafts, zh); + } + + return `
    ${head}${notice}${error}
    ${body}
    `; } export function renderScheduleLoadingHtml(locale: Locale): string { @@ -218,11 +421,13 @@ export function renderScheduleErrorHtml(locale: Locale): string { export function renderScheduleHtml(input: { vm: ProjectTimelinePageVM; - planDraft: SchedulePlanDraft | undefined; + drafts: SchedulePlanDraft[]; + draftsState: SchedulePlanDraftsState; + plan: SchedulePlanUiState; locale: Locale; ui: ScheduleUiState; }): string { - const { vm, planDraft, ui } = input; + const { vm, drafts, draftsState, plan, ui } = input; const zh = input.locale === "zh-CN"; const week = computeScheduleWeek(vm.generated_at, ui.weekOffset); @@ -293,5 +498,5 @@ export function renderScheduleHtml(input: {
    ${columns}
    `; - return `
    ${renderPlanDocHtml(vm, planDraft, zh)}${cal}
    `; + return `
    ${renderPlanPanelHtml({ vm, drafts, draftsState, plan, zh })}${cal}
    `; } diff --git a/apps/desktop-webview/src/workbench/schedule/view.ts b/apps/desktop-webview/src/workbench/schedule/view.ts index dd69d544..0b7d3a00 100644 --- a/apps/desktop-webview/src/workbench/schedule/view.ts +++ b/apps/desktop-webview/src/workbench/schedule/view.ts @@ -1,19 +1,30 @@ // WorkHub 桌面 · 工作台「日程」标签——imperative 挂载 / 事件绑定层(照 timeline/view.ts 的分工:纯渲染 -// 在 render.ts,这里负责拉数据、绑 DOM 事件、维护标签内的瞬态 UI 态:本周偏移)。纯只读——数据复用 E1 -// 时间线 VM(右栏周历)+ E3 计划草案(左栏摘要,取不到静默降级),没有任何写动作(工作项创建走 intake -// 现状,日程不造旁路)。周历切周只改本地 weekOffset 重渲,不重新取数(VM 已含全部工作项/里程碑)。 +// 在 render.ts,这里负责拉数据、绑 DOM 事件、维护标签内的瞬态 UI 态)。 +// 右栏周历:纯只读,复用 E1 时间线 VM(切周只改本地 weekOffset 重渲,不重新取数)。 +// 左栏项目计划(G4 #9,E3 双端入口):能管项目的人可「用 Cuu 起草计划」→ 生成草案 → 列表/详情 → +// 批准 / 驳回(带理由)/ 物化。写动作走 project-planner 五端点;无管理权(草案 GET 403)时左栏退回 +// 里程碑回落、不给起草假入口(04 §4 铁律 3)。物化成功后重拉时间线,新里程碑/工作项立即落到周历上。 + +import { WorkHubApiError } from "@workhub/api-client"; import { - fetchLatestSettledPlanDraft, + approvePlanDraft, + createPlanDraft, + fetchPlanDrafts, fetchProjectTimeline, + materializePlanDraft, + rejectPlanDraft, type ScheduleApiClient, type SchedulePlanDraft } from "./api.js"; import { emptyScheduleUiState, + emptySchedulePlanUiState, renderScheduleErrorHtml, renderScheduleHtml, renderScheduleLoadingHtml, + type SchedulePlanDraftsState, + type SchedulePlanUiState, type ScheduleUiState } from "./render.js"; import type { ProjectTimelinePageVM } from "@workhub/contracts"; @@ -38,11 +49,14 @@ export function mountScheduleView( onOpenTimelineRow?: (workItemId: string) => void; } ): ScheduleViewHandle { + const zh = input.locale === "zh-CN"; let disposed = false; let vm: ProjectTimelinePageVM | undefined; - let planDraft: SchedulePlanDraft | undefined; + let drafts: SchedulePlanDraft[] = []; + let draftsState: SchedulePlanDraftsState = "loading"; let vmLoad: "loading" | "ready" | "error" = "loading"; let ui: ScheduleUiState = emptyScheduleUiState(); + let plan: SchedulePlanUiState = emptySchedulePlanUiState(); let loadGeneration = 0; function render(): void { @@ -57,16 +71,35 @@ export function mountScheduleView( container.innerHTML = renderScheduleErrorHtml(input.locale); return; } - container.innerHTML = renderScheduleHtml({ vm, planDraft, locale: input.locale, ui }); + container.innerHTML = renderScheduleHtml({ vm, drafts, draftsState, plan, locale: input.locale, ui }); + } + + // 拉草案:成功=能管项目(ready);403=无管理权(forbidden,退回里程碑回落);其它错误也降级成 forbidden + // (不给假入口,不报错拖垮日程)。 + async function loadDrafts(generation: number): Promise { + try { + const list = await fetchPlanDrafts(input.client, input.projectId, input.locale); + if (disposed || generation !== loadGeneration) { + return; + } + drafts = list; + draftsState = "ready"; + } catch { + if (disposed || generation !== loadGeneration) { + return; + } + drafts = []; + draftsState = "forbidden"; + } } async function load(): Promise { const generation = ++loadGeneration; vmLoad = "loading"; + draftsState = "loading"; render(); - // 周历 VM 是主数据(失败=整页 error);计划草案是次要装饰(失败=静默降级,左栏回落里程碑)。 - // 并行发车,各自处理。 - const planPromise = fetchLatestSettledPlanDraft(input.client, input.projectId, input.locale); + // 周历 VM 是主数据(失败=整页 error);计划草案是左栏(失败=降级 forbidden)。并行发车。 + const draftsPromise = loadDrafts(generation); try { const page = await fetchProjectTimeline(input.client, input.projectId, input.locale); if (disposed || generation !== loadGeneration) { @@ -83,15 +116,35 @@ export function mountScheduleView( render(); return; } - // 计划草案回来后补渲左栏(fetchLatestSettledPlanDraft 内部已吞错回 undefined,这里不再 try)。 - const draft = await planPromise; + await draftsPromise; if (disposed || generation !== loadGeneration) { return; } - planDraft = draft; render(); } + // 重拉草案(审批/驳回后用;不动周历 VM)。保持当前 plan mode/selection。 + async function reloadDrafts(): Promise { + const generation = loadGeneration; + try { + const list = await fetchPlanDrafts(input.client, input.projectId, input.locale); + if (disposed || generation !== loadGeneration) { + return; + } + drafts = list; + draftsState = "ready"; + } catch { + // 保持现有 drafts(这是审批后的重拉,权限不该突然消失;失败就沿用上一份)。 + } + } + + function apiErrorText(error: unknown, fallback: string): string { + if (error instanceof WorkHubApiError && error.message) { + return error.message; + } + return fallback; + } + function shiftWeek(delta: number): void { ui = { ...ui, weekOffset: ui.weekOffset + delta }; render(); @@ -105,11 +158,202 @@ export function mountScheduleView( render(); } + // —— 计划草案动作 —— // + + function openCompose(): void { + plan = { ...plan, mode: "compose", intentDraft: "", error: undefined, notice: undefined }; + render(); + } + + function cancelCompose(): void { + plan = { ...plan, mode: "list", error: undefined }; + render(); + } + + function submitCompose(): void { + if (plan.busy) { + return; + } + const textarea = container.querySelector("[data-wb-sc-plan-compose-intent]"); + const intent = (textarea?.value ?? "").trim(); + if (!intent) { + plan = { ...plan, error: zh ? "请先填写规划意图。" : "Please describe the planning intent first." }; + render(); + return; + } + plan = { ...plan, busy: true, intentDraft: intent, error: undefined, notice: undefined }; + render(); + void createPlanDraft(input.client, input.projectId, intent, input.locale) + .then(async (created) => { + if (disposed) { + return; + } + await reloadDrafts(); + if (disposed) { + return; + } + plan = { + ...plan, + busy: false, + mode: "detail", + selectedDraftId: created.id, + intentDraft: "", + notice: zh ? "草案已生成,待你审批。" : "Draft created — ready for your review.", + error: undefined + }; + render(); + }) + .catch((error: unknown) => { + if (disposed) { + return; + } + // 503(LLM 未配置)/409(需人细化)等,服务端 message 已是人话,直接透传。 + plan = { ...plan, busy: false, error: apiErrorText(error, zh ? "起草失败,稍后重试。" : "Couldn't draft — try again later.") }; + render(); + }); + } + + function openDraft(draftId: string): void { + plan = { ...plan, mode: "detail", selectedDraftId: draftId, rejecting: false, error: undefined, notice: undefined }; + render(); + } + + function backToList(): void { + plan = { ...plan, mode: "list", selectedDraftId: undefined, rejecting: false, error: undefined }; + render(); + } + + function approveSelected(): void { + if (plan.busy || !plan.selectedDraftId) { + return; + } + const draftId = plan.selectedDraftId; + plan = { ...plan, busy: true, error: undefined, notice: undefined }; + render(); + void approvePlanDraft(input.client, draftId, input.locale) + .then(async () => { + if (disposed) { + return; + } + await reloadDrafts(); + if (disposed) { + return; + } + plan = { ...plan, busy: false, notice: zh ? "已批准,可物化到时间线。" : "Approved — you can materialize it to the timeline." }; + render(); + }) + .catch((error: unknown) => { + if (disposed) { + return; + } + plan = { ...plan, busy: false, error: apiErrorText(error, zh ? "批准失败,稍后重试。" : "Couldn't approve — try again.") }; + render(); + }); + } + + function openReject(): void { + plan = { ...plan, rejecting: true, rejectDraft: "", error: undefined, notice: undefined }; + render(); + } + + function cancelReject(): void { + plan = { ...plan, rejecting: false, error: undefined }; + render(); + } + + function confirmReject(): void { + if (plan.busy || !plan.selectedDraftId) { + return; + } + const textarea = container.querySelector("[data-wb-sc-plan-reject-reason]"); + const reason = (textarea?.value ?? "").trim(); + if (!reason) { + plan = { ...plan, error: zh ? "驳回需要填写理由。" : "A reason is required to reject." }; + render(); + return; + } + const draftId = plan.selectedDraftId; + plan = { ...plan, busy: true, rejectDraft: reason, error: undefined, notice: undefined }; + render(); + void rejectPlanDraft(input.client, draftId, reason, input.locale) + .then(async () => { + if (disposed) { + return; + } + await reloadDrafts(); + if (disposed) { + return; + } + plan = { ...plan, busy: false, rejecting: false, rejectDraft: "", notice: zh ? "已驳回,理由会带入下一次重拟。" : "Rejected — the reason feeds the next redraft." }; + render(); + }) + .catch((error: unknown) => { + if (disposed) { + return; + } + plan = { ...plan, busy: false, error: apiErrorText(error, zh ? "驳回失败,稍后重试。" : "Couldn't reject — try again.") }; + render(); + }); + } + + function materializeSelected(): void { + if (plan.busy || !plan.selectedDraftId) { + return; + } + const draftId = plan.selectedDraftId; + plan = { ...plan, busy: true, error: undefined, notice: undefined }; + render(); + void materializePlanDraft(input.client, draftId, input.locale) + .then(async (outcome) => { + if (disposed) { + return; + } + // 物化后周历要显新里程碑/工作项——重拉时间线 VM + 草案。 + try { + const page = await fetchProjectTimeline(input.client, input.projectId, input.locale); + if (!disposed) { + vm = page; + } + } catch { + // 时间线重拉失败不阻断——草案状态已更新,周历沿用旧数据(下次进标签会刷新)。 + } + await reloadDrafts(); + if (disposed) { + return; + } + const r = outcome.result; + plan = { + ...plan, + busy: false, + notice: zh + ? `已物化到时间线:${r.milestone_ids.length} 里程碑 · ${r.work_item_ids.length} 工作项。` + : `Materialized: ${r.milestone_ids.length} milestones · ${r.work_item_ids.length} items.` + }; + render(); + }) + .catch((error: unknown) => { + if (disposed) { + return; + } + plan = { ...plan, busy: false, error: apiErrorText(error, zh ? "物化失败,稍后重试。" : "Couldn't materialize — try again.") }; + render(); + }); + } + + // 起草表单是
    ,拦截原生 submit(否则会触发导航)。 + container.addEventListener("submit", (event) => { + if (event.target instanceof HTMLElement && event.target.closest("[data-wb-sc-plan-compose]")) { + event.preventDefault(); + submitCompose(); + } + }); + container.addEventListener("click", (event) => { if (!(event.target instanceof HTMLElement)) { return; } const target = event.target; + // 周历控件(沿用原行为)。 if (target.closest("[data-wb-sc-retry]")) { void load(); return; @@ -126,13 +370,56 @@ export function mountScheduleView( goToday(); return; } + // 计划草案控件。 + if (target.closest("[data-wb-sc-plan-new]")) { + openCompose(); + return; + } + if (target.closest("[data-wb-sc-plan-compose-submit]")) { + submitCompose(); + return; + } + if (target.closest("[data-wb-sc-plan-compose-cancel]")) { + cancelCompose(); + return; + } + if (target.closest("[data-wb-sc-plan-back]")) { + backToList(); + return; + } + if (target.closest("[data-wb-sc-plan-approve]")) { + approveSelected(); + return; + } + if (target.closest("[data-wb-sc-plan-reject-confirm]")) { + confirmReject(); + return; + } + if (target.closest("[data-wb-sc-plan-reject-cancel]")) { + cancelReject(); + return; + } + if (target.closest("[data-wb-sc-plan-reject]")) { + openReject(); + return; + } + if (target.closest("[data-wb-sc-plan-materialize]")) { + materializeSelected(); + return; + } + const draftRow = target.closest("[data-wb-sc-plan-draft]"); + if (draftRow?.dataset.wbScPlanDraft) { + openDraft(draftRow.dataset.wbScPlanDraft); + return; + } + // 周历卡片点击 → 跳时间线。 const card = target.closest("[data-wb-sc-card]"); if (card?.dataset.wbScId) { input.onOpenTimelineRow?.(card.dataset.wbScId); } }); - // 键盘可达:卡片是 role=button tabindex=0,回车/空格等同点击(跳时间线)。 + // 键盘可达:周历卡片与草案行都是 role=button tabindex=0,回车/空格等同点击。 container.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") { return; @@ -140,6 +427,12 @@ export function mountScheduleView( if (!(event.target instanceof HTMLElement)) { return; } + const draftRow = event.target.closest("[data-wb-sc-plan-draft]"); + if (draftRow?.dataset.wbScPlanDraft) { + event.preventDefault(); + openDraft(draftRow.dataset.wbScPlanDraft); + return; + } const card = event.target.closest("[data-wb-sc-card]"); if (card?.dataset.wbScId) { event.preventDefault(); From e707972dbb6ab35cbd2c4dc6f3438d00154e8e12 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:58:53 +0800 Subject: [PATCH 10/23] feat(inbox): #17 give the shared attention inbox a refresh() handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mountAttentionInbox now returns { dispose, refresh } instead of a bare dispose function. Spotlight is unchanged — createAttentionView unwraps to .dispose, so the focus box behaves verbatim. The workbench inbox view forwards refresh() so its host (shell) can re-pull the list when the badge refreshes, letting an open decision inbox update live instead of needing a tab switch away and back. Co-Authored-By: Claude Fable 5 --- .../src/spotlight/views/attention.test.ts | 4 +-- .../src/spotlight/views/attention.ts | 28 +++++++++++++++---- .../src/workbench/inbox/view.ts | 11 ++++++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/desktop-webview/src/spotlight/views/attention.test.ts b/apps/desktop-webview/src/spotlight/views/attention.test.ts index 6ffdbf83..9a0f03b1 100644 --- a/apps/desktop-webview/src/spotlight/views/attention.test.ts +++ b/apps/desktop-webview/src/spotlight/views/attention.test.ts @@ -280,7 +280,7 @@ test("mountAttentionInbox is a decoupled two-window entry: renders the queue and } as unknown as AttentionHomeVM; try { - const dispose = mountAttentionInbox({ + const handle = mountAttentionInbox({ body: body as unknown as HTMLElement, locale: "zh-CN", client: { @@ -314,7 +314,7 @@ test("mountAttentionInbox is a decoupled two-window entry: renders the queue and await tick(); assert.deepEqual(opened, [{ view: "proposals", target: { id: "p-1", route: "/proposals/p-1" } }]); - dispose(); + handle.dispose(); } finally { globals.HTMLElement = previousHTMLElement; } diff --git a/apps/desktop-webview/src/spotlight/views/attention.ts b/apps/desktop-webview/src/spotlight/views/attention.ts index e530a548..f351d7cd 100644 --- a/apps/desktop-webview/src/spotlight/views/attention.ts +++ b/apps/desktop-webview/src/spotlight/views/attention.ts @@ -346,8 +346,15 @@ export type AttentionInboxContext = { onActionSettled?: (() => void) | undefined; }; -// 两窗共用的收件箱挂载逻辑(S1 证明片的 mount 主体,行为逐字保持)。返回卸载清理函数。 -export function mountAttentionInbox(ctx: AttentionInboxContext): () => void { +// R17 #17:mount 句柄——dispose 卸载 + refresh 重拉列表。spotlight 侧只取 .dispose(见 createAttentionView), +// 行为零变化;工作台收件箱额外用 refresh() 让开着的列表随角标一起活(不必切走再切回)。 +export type AttentionInboxHandle = { + dispose: () => void; + refresh: () => void; +}; + +// 两窗共用的收件箱挂载逻辑(S1 证明片的 mount 主体,行为逐字保持)。返回卸载清理 + 重拉句柄。 +export function mountAttentionInbox(ctx: AttentionInboxContext): AttentionInboxHandle { { const zh = ctx.locale === "zh-CN"; const { body, client } = ctx; @@ -715,8 +722,18 @@ export function mountAttentionInbox(ctx: AttentionInboxContext): () => void { ctx.requestResize(); void refresh(); - return () => { - disposed = true; + return { + dispose: () => { + disposed = true; + }, + // #17:宿主在角标随 SSE/轮询刷新时同步调这个重拉列表——单飞守卫(busy)与 disposed 保护都在 refresh + // 内部,已卸载后再调是无害空操作。 + refresh: () => { + if (disposed) { + return; + } + void refresh(); + } }; } } @@ -728,7 +745,8 @@ export function createAttentionView(): SpotlightCapabilityView { return { id: "approvals", mount(ctx: SpotlightViewContext) { - return mountAttentionInbox(ctx); + // spotlight 只需要卸载清理函数——取 .dispose,refresh 句柄不外露,聚焦盒行为逐字不变。 + return mountAttentionInbox(ctx).dispose; } }; } diff --git a/apps/desktop-webview/src/workbench/inbox/view.ts b/apps/desktop-webview/src/workbench/inbox/view.ts index cc32f040..b9b431c7 100644 --- a/apps/desktop-webview/src/workbench/inbox/view.ts +++ b/apps/desktop-webview/src/workbench/inbox/view.ts @@ -23,6 +23,9 @@ export type InboxViewApiClient = AttentionInboxApiClient; export type InboxViewHandle = { dispose: () => void; + // #17:宿主(shell.refreshInboxBadge,当 centerTab==='inbox' 时)在角标随 SSE/轮询刷新时同步调这个, + // 让开着的收件箱列表随角标一起活——不必切走再切回。 + refresh: () => void; }; export function mountInboxView( @@ -86,7 +89,7 @@ export function mountInboxView( toast(zh ? "这项去主窗口对应能力查看" : "Open this in its capability in the main window", "info"); }; - const disposeMount = mountAttentionInbox({ + const mountHandle = mountAttentionInbox({ client: input.client, locale: input.locale, body: bodyEl, @@ -104,11 +107,13 @@ export function mountInboxView( return { dispose: () => { - disposeMount(); + mountHandle.dispose(); if (toastTimer) { clearTimeout(toastTimer); toastTimer = undefined; } - } + }, + // #17:透传共用 mount 的重拉句柄——shell 在角标刷新且中栏正是收件箱时调它同步列表。 + refresh: () => mountHandle.refresh() }; } From 84d0fceabc79e9d7353a2543f36579f9c8e40e27 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 18:59:04 +0800 Subject: [PATCH 11/23] feat(workbench): #6/#17/#37/#38 decision-class me-stream + live inbox + tab UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6: the /me stream handler hard-filtered on `event.type !== "notification.created"`, dropping escalation/budget/sync_conflict (and permission/proposal) events that really reach topics.user and feed GET /attention — the badge only caught up via 30s polling, while the same /me topic drove cards fine on the pet channel (desktop-cuu-runtime). Replace with a decision-class whitelist so any of those refreshes the badge, aligning the two consumers. #17: refreshInboxBadge also calls inboxHandle.refresh() when centerTab==='inbox', so an open decision inbox re-pulls its list alongside the badge. #37: middle-click (auxclick button 1) on a session tab closes it. #38: Cmd/Ctrl+W closes the current session tab; Cmd/Ctrl+1..9 switch to the Nth open tab. Narrowed: only intercepts (preventDefault) when the center tab is a conversation and a matching tab exists — otherwise browser/system semantics pass through. The doc-level keydown listener is removed on dispose/logout. Co-Authored-By: Claude Fable 5 --- apps/desktop-webview/src/workbench/shell.ts | 95 +++++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/apps/desktop-webview/src/workbench/shell.ts b/apps/desktop-webview/src/workbench/shell.ts index d36e619e..1e1b090b 100644 --- a/apps/desktop-webview/src/workbench/shell.ts +++ b/apps/desktop-webview/src/workbench/shell.ts @@ -8,6 +8,7 @@ // 不是 CSS 藏起来,两套控件叠一起是 bug 不是冗余保险。非 macOS 维持 decorations:false 全套自绘。 import type { WorkHubApiClient } from "@workhub/api-client"; +import { eventTypes } from "@workhub/contracts"; import { escapeHtml } from "@workhub/web-runtime"; import { appleGlassDesignSystemCss } from "../design-system.js"; @@ -608,6 +609,11 @@ export function mountWorkbenchShell( .catch(() => { // 角标尽力而为——拉不到保持上一次的计数,不清零骚扰。 }); + // #17:中栏正开着决策收件箱时,随角标一起重拉列表——让开着的列表随实时事件/轮询同步刷新,不必切走再 + // 切回。inboxHandle.refresh 内部有 disposed/单飞守卫,中栏不是 inbox / 未挂载时是无害空操作。 + if (store.getState().centerTab === "inbox") { + inboxHandle?.refresh(); + } }; // R15 批 I1/I2:切中栏到决策收件箱——rail 顶部「待拍板」入口与聊天流 digest 卡「打开收件箱」共用一处, @@ -617,16 +623,41 @@ export function mountWorkbenchShell( refreshInboxBadge(); }; - // R15 批 A6(rail 未读红点 · 实时性):workbench 订一条 /api/push/stream/me,只消费会话消息类 - // notification.created(带 conversation_id)——收到就给对应会话的未读本地 +1(近似,不知道服务端精确 - // 聚合数,30s DM 兜底刷新 + 打开清零把它拉回权威),让左栏红点在 workbench 只开着、没打开那条会话时 - // 也能动。当前正打开的那条会话不加(见 currentlyOpenConversationId)。断线重连语义复用会话流同一套 - // connectConversationStream(指数退避 + 心跳看门狗)。浏览器 dev 无 fetch 时 connect 内部优雅降级。 + // R15 批 A6(rail 未读红点 · 实时性):workbench 订一条 /api/push/stream/me,消费两类信号——会话消息类 + // notification.created(带 conversation_id)动左栏未读红点,决策类事件刷「待拍板」角标(见下)。断线重连 + // 语义复用会话流同一套 connectConversationStream(指数退避 + 心跳看门狗)。浏览器 dev 无 fetch 时优雅降级。 + // + // R17 #6(决策推送链):此前 onEvent 用 `event.type !== "notification.created"` 硬过滤——escalation/budget/ + // sync_conflict 这些真会到 /me 流的决策类事件被整条丢弃,角标只能靠 30s 轮询兜底;而同一 /me 话题在桌宠 + // 气泡通道(desktop-cuu-runtime 的 cardFromEvent/toAttentionItem)却能正常出卡,两个消费者行为不一致。改成 + // 决策类白名单:下面这些正是 push 路由里真会发到 topics.user(=/me) 且进 GET /attention 决策队列的类型 + //(notifications.ts / approvals.ts / agent-memory.ts / agent-runner.ts / human-reserved-guard[#5] 的 + // topics.user 发布面)——收到任一即刷角标(+ 中栏开着收件箱时随之刷列表,见 refreshInboxBadge)。刷新是 + // 幂等的 GET attention,权威数仍以响应为准;决策事件稀疏,不像聊天消息高频。 + const decisionRefreshEventTypes = new Set([ + eventTypes.escalationOpened, + eventTypes.budgetWarning, + eventTypes.budgetExhausted, + eventTypes.syncConflict, + eventTypes.permissionAsk, + eventTypes.permissionDecided, + eventTypes.permissionReassigned, + eventTypes.permissionExpired, + eventTypes.proposalOpened + ]); const meStream: ConversationStreamHandle = connectConversationStream({ url: input.client.streams.me(), getClientToken, onEvent: (event) => { - if (disposed || event.type !== "notification.created") { + if (disposed) { + return; + } + // 决策类事件(升级/预算/偏好冲突/审批/提议):全是进决策队列的类型,收到即刷角标——它们不带会话未读语义。 + if (decisionRefreshEventTypes.has(event.type)) { + refreshInboxBadge(); + return; + } + if (event.type !== eventTypes.notificationCreated) { return; } const data = event.data as { type?: string; conversation_id?: string } | null | undefined; @@ -1526,6 +1557,56 @@ export function mountWorkbenchShell( activateSessTab(openTarget.dataset.wbTab); } }); + // #37:会话 tab 中键点击关闭(浏览器/编辑器通用手感)——auxclick 的 button===1 即中键;点在 tab 任意处 + // (不必命中那个小 x)都关掉整条 tab。 + sessStripEl?.addEventListener("auxclick", (event) => { + if (event.button !== 1 || !(event.target instanceof HTMLElement)) { + return; + } + const tabTarget = event.target.closest("[data-wb-tab]"); + if (tabTarget?.dataset.wbTab) { + event.preventDefault(); + closeSessTab(tabTarget.dataset.wbTab); + } + }); + + // #38:会话 tab 的应用级快捷键。Cmd/Ctrl+W 关当前会话 tab;Cmd/Ctrl+1..9 切到第 N 个已开 tab。取窄: + // 只在「会话类中栏 + 确有对应 tab」时才 preventDefault 拦下——否则一律放行,绝不抢浏览器/系统在其它场景 + // (非会话中栏、没有可关的 tab、组合键带 Shift/Alt)的关窗/切标签语义。桌面壳里这正是原生 tab 手感,浏览器 + // dev 下也只有真有会话 tab 可操作时才接管。 + const isConversationCenterTab = (tab: WorkbenchStoreState["centerTab"]): boolean => + tab === "chat" || tab === "collab" || tab === "dm"; + const onShellTabKeydown = (event: KeyboardEvent) => { + if (disposed || loggedOut) { + return; + } + // 主修饰键:mac 上是 Cmd(metaKey)、其它平台是 Ctrl;两者都收下以兼容桌面壳与浏览器 dev。带 Shift/Alt + // 的组合(如 Cmd+Shift+W 关整窗)不拦,留给系统。 + const primaryModifier = event.metaKey || event.ctrlKey; + if (!primaryModifier || event.altKey || event.shiftKey) { + return; + } + const state = store.getState(); + if (!isConversationCenterTab(state.centerTab)) { + return; + } + if (event.key === "w" || event.key === "W") { + const current = currentlyOpenConversationId(); + if (current && state.openConversationTabs.some((tab) => tab.conversationId === current)) { + event.preventDefault(); + closeSessTab(current); + } + return; + } + if (/^[1-9]$/u.test(event.key)) { + const tab = state.openConversationTabs[Number.parseInt(event.key, 10) - 1]; + if (tab) { + event.preventDefault(); + activateSessTab(tab.conversationId); + } + } + }; + doc.addEventListener("keydown", onShellTabKeydown); const unsubscribe = store.subscribe((state) => { renderCenter(state); @@ -1702,6 +1783,8 @@ export function mountWorkbenchShell( // 不再是各写一份、两处容易悄悄漂移。 const disposeActiveSubviews = () => { railHandle.dispose(); + // #38:撤下文档级 tab 快捷键监听(doc 生命周期长于本 shell 实例,登出/卸载都要摘掉防泄漏)。 + doc.removeEventListener("keydown", onShellTabKeydown); // R15 批 A6:停掉 /api/push/stream/me 未读订阅(登出/卸载都要放手,否则登出后仍会拿废 token 重连)。 meStream.close(); // R15 批 I1:停掉「待拍板」计数轮询定时器。 From 1ce1aeea6c48d761596ab864b5a87d2ba6e9920b Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:02:52 +0800 Subject: [PATCH 12/23] chore(events): #25/#31 mark zero-producer event types + prune dead per-run subs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified every SSE event type against its production publishers. 9 are never published outside test fixtures: confidence.scored, knowledge.evidence.ready, sync.progress, usage.recorded, conversation.tool.{begin,output_delta,end}, conversation.item.{started,completed}. Marked each @deprecated in enums.ts — values are KEPT (eventTypeSchema is built from Object.values; deleting breaks contracts compat), the notes just warn against picking them for new producers. #31: documented the clarification / knowledge_result AttentionItem kinds as producer-less dead branches — enum values and the double-sided UI switch arms stay (exhaustive rendering), per instruction. #25 per-run subs: removed budget.*/proposal.* from desktopCuuRunStreamEventNames. They publish only to workitem/user topics, never topics.run, so on the per-run SSE stream they never fired — dead subscriptions. Budget/proposal cards still reach the desktop via the /me channel. Co-Authored-By: Claude Fable 5 --- apps/desktop-webview/src/desktop-cuu-runtime.ts | 11 ++++++----- packages/contracts/src/enums.ts | 13 +++++++++++++ packages/contracts/src/experience.ts | 4 ++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/desktop-webview/src/desktop-cuu-runtime.ts b/apps/desktop-webview/src/desktop-cuu-runtime.ts index e2dcb2e8..125b64e8 100644 --- a/apps/desktop-webview/src/desktop-cuu-runtime.ts +++ b/apps/desktop-webview/src/desktop-cuu-runtime.ts @@ -1699,6 +1699,11 @@ function startModeFromUnknown(value: unknown): StartAgentRunRequest["mode"] | un return value === "worker" || value === "pm" ? value : undefined; } +// 每个 run 的 SSE 流(/api/push/stream/run/:id)只承载 topics.run(runId) 上发布的事件——即 agent-runner +// 的 emitRunEvent 发到 run 话题的生命周期类。R17 #25(桌面 per-run 死订阅清理):budget.* / proposal.* 曾 +// 在此订阅,但它们只发到 topics.workitem / topics.user(emitBudgetNotice / proposalOpenedTopics / 提议合并), +// **从不发到 topics.run**——在这条 per-run 流上是永不触发的死订阅,删除。桌面仍从 /me 通道(经 +// bindDesktopShellCuuRuntime 的 push-event 桥)收到这些预算/提议卡,不受影响。 const desktopCuuRunStreamEventNames = [ "message", eventTypes.agentRunStarted, @@ -1706,11 +1711,7 @@ const desktopCuuRunStreamEventNames = [ eventTypes.stepToolResult, eventTypes.agentRunCompacting, eventTypes.agentRunFailed, - eventTypes.agentRunEscalated, - eventTypes.budgetWarning, - eventTypes.budgetExhausted, - eventTypes.proposalOpened, - eventTypes.proposalMerged + eventTypes.agentRunEscalated ] as const; function resolveDesktopCuuEventSource(): DesktopCuuEventSourceConstructor | undefined { diff --git a/packages/contracts/src/enums.ts b/packages/contracts/src/enums.ts index 03aeba38..f75ada75 100644 --- a/packages/contracts/src/enums.ts +++ b/packages/contracts/src/enums.ts @@ -162,6 +162,10 @@ export const deliverableTargetKinds = [ export const deliverableTargetKindSchema = z.enum(deliverableTargetKinds); export type DeliverableTargetKind = z.infer; +// R17 #25(零生产者事件类型核查):下列标 @deprecated 的成员在生产路径从未被 publish(仅测试夹具/ +// schema/UI 消费分支引用)。**枚举值一律保留**——eventTypeSchema 由 Object.values 生成,删值会破坏 +// contracts 兼容面(旧事件反序列化、跨版本客户端)。仅打注释示警,别新写生产者时误用;确要恢复某类, +// 补上真实 publish 点后移除本注释即可。核查依据见 06-gap-fix-plan.md G2 行。 export const eventTypes = { agentRunStarted: "agent_run.started", agentRunStep: "agent_run.step", @@ -170,6 +174,7 @@ export const eventTypes = { agentRunFailed: "agent_run.failed", agentRunEscalated: "agent_run.escalated", sessionQuestion: "session.question", + /** @deprecated 无生产者:`confidence.scored` 只作审计动作名与 toCuuState 映射存在,从不作为 SSE 事件 publish。 */ confidenceScored: "confidence.scored", escalationOpened: "escalation.opened", permissionAsk: "permission.ask", @@ -181,9 +186,12 @@ export const eventTypes = { proposalMerged: "proposal.merged", revisionFedback: "revision.fedback", stepSnapshot: "step.snapshot", + /** @deprecated 无生产者:喂 knowledge_result AttentionItem 死分支,仅 toAttentionItem/toCuuState/夹具引用,无 publish。 */ knowledgeEvidenceReady: "knowledge.evidence.ready", + /** @deprecated 无生产者:仅 toCuuState 映射引用(兄弟 sync.conflict 有真实生产者,本类无)。 */ syncProgress: "sync.progress", syncConflict: "sync.conflict", + /** @deprecated 无生产者也无消费者:仅 gold-path 夹具引用,整类惰性。 */ usageRecorded: "usage.recorded", budgetWarning: "budget.warning", budgetExhausted: "budget.exhausted", @@ -199,11 +207,16 @@ export const eventTypes = { // R15 批 cuu-toggle:会话级 Cuu 参与开关翻转后广播(PATCH /cuu),让其它开着这个会话的客户端头部 // 同步开关状态,不必等下次重挂才看到。 conversationCuuUpdated: "conversation.cuu.updated", + /** @deprecated 无生产者也无消费者:仅 r12-workbench 契约测试快照枚举,工具流实际走 conversation.tool.* 之外的既有事件。 */ conversationToolBegin: "conversation.tool.begin", + /** @deprecated 无生产者也无消费者:同上,仅契约测试引用。 */ conversationToolOutputDelta: "conversation.tool.output_delta", + /** @deprecated 无生产者也无消费者:同上,仅契约测试引用。 */ conversationToolEnd: "conversation.tool.end", conversationActionCardUpdated: "conversation.action_card.updated", + /** @deprecated 无生产者也无消费者:仅契约测试引用。 */ conversationItemStarted: "conversation.item.started", + /** @deprecated 无生产者也无消费者:仅契约测试引用。 */ conversationItemCompleted: "conversation.item.completed", conversationPresenceTyping: "conversation.presence.typing", // R14 CHAT 批(presence-observer 工包):观察者 worker 真正调用 LLM 分析某会话消息窗之前发布的 diff --git a/packages/contracts/src/experience.ts b/packages/contracts/src/experience.ts index cd0307f4..49593b22 100644 --- a/packages/contracts/src/experience.ts +++ b/packages/contracts/src/experience.ts @@ -63,6 +63,10 @@ export type AttentionAction = z.infer; export const attentionItemSchema = z.object({ id: idSchema, + // R17 #31(死 kind 核查):`clarification` 与 `knowledge_result` 两个 kind schema/UI 齐备但**无真实生产者** + // ——决策收件箱四源(升级/偏好冲突/审批/提议)都不产出这两类,映射它们的 knowledge.evidence.ready 事件也 + // 无 publish(见 enums.ts 该事件的 @deprecated 注释)。枚举值保留(contracts 兼容),双端 UI 的 kind 分支也 + // 保留——那是穷举渲染(toneForKind/tagLabel/route-components 的 switch 覆盖全枚举),删掉反而会让穷举漏项。 kind: z.enum([ "clarification", "approval", From 81737a4c59bcfb22cbfaa84d3c8b34a529f9e7e6 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:06:58 +0800 Subject: [PATCH 13/23] =?UTF-8?q?feat(members):=20roster=20=E9=82=80?= =?UTF-8?q?=E8=AF=B7/=E7=A7=BB=E5=87=BA=E5=85=A5=E5=8F=A3=20+=20=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=AE=BE=E7=BD=AE=E6=88=90=E5=91=98=E5=88=86=E5=8C=BA?= =?UTF-8?q?=20+=20=E5=85=91=E7=8E=B0=E5=BC=B9=E7=AA=97=E6=96=87=E6=A1=88?= =?UTF-8?q?=20(R17-G1=20#14/#15/#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面 UI 把已就绪的后端能力接真、把无法兑现的承诺改成真话(05-gap-review #14/#15/#2)。 - #14 邀请成员:admin/owner 在成员 roster 头部出「邀请」入口——填邮箱 → POST /api/auth/invites 生成一次性 token → 复制到剪贴板 + 提示。无未过期邀请列表读端点, 故只做生成 + 复制(报告注明)。 - #15 成员移出:roster「管理」模式每行出「移出」(内联确认 + 忙态)→ DELETE /api/workspace/members/:userId → 乐观就地从花名册剔除。管理/邀请入口仅 admin/owner (viewer.membership_role !== 'member')才渲。 - #2 项目设置「成员」分区:主区=全员(工作区总数)+ 各协同会话概览(会话名 + 「管理成员」 跳到该会话成员条);把新建项目弹窗「成员邀请之后在项目设置里调」改成与现实一致的文案 (成员在各协同会话里加人/退群)。 - chat/api.ts 补 post/deleteConversationParticipant 客户端薄封装(供会话成员条用)。 rail 5 例 + settings 2 例渲染测试覆盖 canManage 门控、管理模式、内联确认、邀请框、成员概览。 CSS 样式与会话成员条(#1/#16)合并在后续一笔补齐。 Co-Authored-By: Claude Fable 5 --- .../desktop-webview/src/workbench/chat/api.ts | 29 ++ .../src/workbench/rail.test.ts | 64 ++++ apps/desktop-webview/src/workbench/rail.ts | 304 +++++++++++++++++- .../src/workbench/settings/render.test.ts | 32 ++ .../src/workbench/settings/render.ts | 61 ++++ .../src/workbench/settings/view.ts | 29 +- apps/desktop-webview/src/workbench/shell.ts | 14 +- 7 files changed, 521 insertions(+), 12 deletions(-) diff --git a/apps/desktop-webview/src/workbench/chat/api.ts b/apps/desktop-webview/src/workbench/chat/api.ts index ef170ca3..69c7e38b 100644 --- a/apps/desktop-webview/src/workbench/chat/api.ts +++ b/apps/desktop-webview/src/workbench/chat/api.ts @@ -6,6 +6,8 @@ // 的薄封装 + 首屏/向上翻页策略。 import type { + AddConversationParticipantResultVM, + RemoveConversationParticipantResultVM, AiFeedbackVerdict, AiMode, ConversationMessagePageVM, @@ -381,6 +383,33 @@ export function fetchConversationParticipants( return client.request(conversationPath(conversationId, "participants")); } +// R17 批 G1(群成员管理 · #1 建群后加人):POST /participants {user_id} → 200 { added, participants } +// (刷新后的完整参与者列表)。仅非 DM collab 会话可加人(main/DM 各自 409);已在群里幂等回 added=false。 +export function postConversationParticipant( + client: ChatApiClient, + conversationId: string, + userId: string +): Promise { + return client.request(conversationPath(conversationId, "participants"), { + method: "POST", + body: JSON.stringify({ user_id: userId }) + }); +} + +// R17 批 G1(#16 退群/移出):DELETE /participants/:userId → 200 { removed_user_id, self_left, +// new_owner_user_id }。自删=退群、owner 删他人=移出;owner 退群时 new_owner_user_id 带继任者; +// main/DM/最后一人各自 409;非 owner 删他人 403。 +export function deleteConversationParticipant( + client: ChatApiClient, + conversationId: string, + userId: string +): Promise { + return client.request( + conversationPath(conversationId, `participants/${encodeURIComponent(userId)}`), + { method: "DELETE" } + ); +} + // 小群「已读 N/M」分母修复的纯逻辑:从 GET /participants 的真实参与者集合里去掉自己,得到"他人成员 id" // 列表——otherMemberIds 的口径(同 view.ts 挂载时从 input.members 算的既有分母公式一致,只是数据源从 // "整个工作区成员" 换成 "这条会话的真实参与者")。scope:"workspace"(main)时调用方本就不该调这个函数 diff --git a/apps/desktop-webview/src/workbench/rail.test.ts b/apps/desktop-webview/src/workbench/rail.test.ts index aef839e9..df0303a5 100644 --- a/apps/desktop-webview/src/workbench/rail.test.ts +++ b/apps/desktop-webview/src/workbench/rail.test.ts @@ -989,6 +989,70 @@ test("renderRosterGroupHtml renders nothing when there is no VM member data (no assert.equal(renderRosterGroupHtml({ members: [], currentUserId: undefined, onlineUserIds: new Set(), locale: "zh-CN" }), ""); }); +// ── R17 批 G1(#14 邀请 / #15 成员移出)roster 管理入口 ───────────────────────────────────── + +test("renderRosterGroupHtml shows Manage + Invite entries only when the viewer canManage", () => { + const base = { + members: [rosterMember(R15_SELF, "阿曼", true), rosterMember(R15_PEER_A, "甲", false)], + currentUserId: R15_SELF, + onlineUserIds: new Set(), + locale: "zh-CN" as const + }; + const plain = renderRosterGroupHtml(base); + assert.doesNotMatch(plain, /data-wb-roster-manage-toggle/); + assert.doesNotMatch(plain, /data-wb-invite-member/); + + const managed = renderRosterGroupHtml({ ...base, canManage: true }); + assert.match(managed, /data-wb-roster-manage-toggle/); + assert.match(managed, /data-wb-invite-member/); + // 未进管理模式时不渲移出按钮。 + assert.doesNotMatch(managed, /data-wb-remove-member=/); +}); + +test("renderRosterGroupHtml in manage mode renders a per-member Remove control (never for self)", () => { + const html = renderRosterGroupHtml({ + members: [rosterMember(R15_SELF, "阿曼", true), rosterMember(R15_PEER_A, "甲", false)], + currentUserId: R15_SELF, + onlineUserIds: new Set(), + locale: "zh-CN", + canManage: true, + manage: true + }); + assert.match(html, /data-wb-remove-member="90000000-0000-4000-8000-0000000000a1"/); + // 自己不在 roster,所以永远没有对自己的移出控件。 + assert.doesNotMatch(html, /data-wb-remove-member="90000000-0000-4000-8000-000000000009"/); +}); + +test("renderRosterGroupHtml shows an inline confirm (with busy state) for the pending removal target", () => { + const html = renderRosterGroupHtml({ + members: [rosterMember(R15_SELF, "阿曼", true), rosterMember(R15_PEER_A, "甲", false)], + currentUserId: R15_SELF, + onlineUserIds: new Set(), + locale: "zh-CN", + canManage: true, + manage: true, + confirmRemoveUserId: R15_PEER_A, + busyUserId: R15_PEER_A + }); + assert.match(html, /data-wb-remove-member-confirm="90000000-0000-4000-8000-0000000000a1"/); + assert.match(html, /移出中…/); +}); + +test("renderRosterGroupHtml renders the invite box only when canManage and invite.open", () => { + const base = { + members: [rosterMember(R15_SELF, "阿曼", true), rosterMember(R15_PEER_A, "甲", false)], + currentUserId: R15_SELF, + onlineUserIds: new Set(), + locale: "zh-CN" as const, + invite: { open: true, email: "new@team.dev", submitting: false } + }; + // canManage 缺省 → 邀请框不渲(即便 invite.open)。 + assert.doesNotMatch(renderRosterGroupHtml(base), /data-wb-invite-email/); + const managed = renderRosterGroupHtml({ ...base, canManage: true }); + assert.match(managed, /data-wb-invite-email/); + assert.match(managed, /value="new@team.dev"/); +}); + test("renderDmGroupHtml lists DMs with peer name + open hook, marking the active one", () => { const html = renderDmGroupHtml({ dmList: [r15DmItem("30000000-0000-4000-8000-000000000031", R15_PEER_A, "甲")], diff --git a/apps/desktop-webview/src/workbench/rail.ts b/apps/desktop-webview/src/workbench/rail.ts index 3d8be90c..a17a41dc 100644 --- a/apps/desktop-webview/src/workbench/rail.ts +++ b/apps/desktop-webview/src/workbench/rail.ts @@ -459,31 +459,125 @@ export function sortRosterMembers( .map((entry) => entry.member); } +// R17 批 G1(#14 邀请 / #15 成员移出):roster「管理」模式与「邀请成员」入口的瞬态 UI 状态。canManage= +// 当前 viewer 是工作区 admin/owner(viewer.membership_role !== 'member')时才渲这两个入口;invite 是内联 +// 生成邀请的表单态(无未过期邀请列表读端点,只做生成 + 复制 token)。都是 additive optional——不传时 +// renderRosterGroupHtml 行为与本批之前逐字一致(既有测试/调用点零回归)。 +export type RosterInviteUiState = { + open: boolean; + email: string; + submitting: boolean; + note?: string | undefined; + error?: string | undefined; +}; + // 成员 roster 分组:workspace_members(工作台 VM 已带,工作区级、cap 100)+ 在线绿点,点成员行 → 头像 // 资料卡(data-wb-open-profile,popover 在 shell 根委托打开)。VM 还没就绪(没选任何项目)时不渲染这个 // 分组(成员列表来自 VM,没有 VM 就没有可靠成员数据,不摆空壳)。 +// R17 批 G1:admin/owner 额外渲「管理」开关 + 「邀请成员」入口——管理模式下每行末尾出「移出」(走 +// DELETE /api/workspace/members/:userId,带内联确认 + 忙态 + 刷新)。 export function renderRosterGroupHtml(input: { members: readonly WorkbenchMemberVM[]; currentUserId: string | undefined; onlineUserIds: ReadonlySet; locale: Locale; + canManage?: boolean; + manage?: boolean; + busyUserId?: string | undefined; + confirmRemoveUserId?: string | undefined; + manageError?: string | undefined; + invite?: RosterInviteUiState | undefined; }): string { const zh = input.locale === "zh-CN"; const sorted = sortRosterMembers(input.members, input.currentUserId, input.onlineUserIds); if (sorted.length === 0) { return ""; } + const canManage = input.canManage ?? false; + const manage = canManage && (input.manage ?? false); const rows = sorted .map((member) => { const online = input.onlineUserIds.has(member.user_id); - return ``; + const tile = avatarTileHtml({ label: member.nickname, id: member.user_id, ...(online ? { online: true } : {}) }); + const profileBtn = ``; + if (!manage) { + return profileBtn; + } + const busy = input.busyUserId === member.user_id; + let control: string; + if (input.confirmRemoveUserId === member.user_id) { + control = `${zh ? "确认移出?" : "Remove?"}`; + } else { + control = ``; + } + return `
    ${profileBtn}${control}
    `; }) .join(""); - return `
    ${ - zh ? "成员" : "Members" - }
    ${rows}
    `; + let head = `
    ${zh ? "成员" : "Members"}`; + if (canManage) { + head += ``; + } + head += "
    "; + const inviteBox = canManage && input.invite?.open ? renderRosterInviteBoxHtml(input.invite, zh) : ""; + const errorLine = + manage && input.manageError ? `

    ${escapeHtml(input.manageError)}

    ` : ""; + return `
    ${head}${inviteBox}${errorLine}${rows}
    `; +} + +// R17 批 G1(#14 邀请成员):内联邀请表单——填被邀请人邮箱 → 生成邀请(POST /invites)→ 一次性 token +// 复制到剪贴板 + 提示。没有未过期邀请列表读端点,故只做生成 + 复制,不渲列表(见交付报告)。 +function renderRosterInviteBoxHtml(state: RosterInviteUiState, zh: boolean): string { + const note = state.error + ? `

    ${escapeHtml(state.error)}

    ` + : state.note + ? `

    ${escapeHtml(state.note)}

    ` + : ""; + return `
    + +
    + + +
    + ${note} +
    `; +} + +// R17 批 G1(#14 邀请成员):POST /api/auth/invites {email} → 201 { invite_id, token, email, expires_at }。 +// 一次性明文 token 只返回这一次(服务端只存 hash)。管理员据 token 自行拼 out-of-band 链接分发(无 SMTP)。 +// 仅 admin + 密码模式可用(否则 403/404,UI 诚实透传服务端错误)。 +export function createWorkspaceInvite( + client: Pick, + email: string +): Promise<{ invite_id: string; token: string; email: string; expires_at: string }> { + return client.request<{ invite_id: string; token: string; email: string; expires_at: string }>("/api/auth/invites", { + method: "POST", + body: JSON.stringify({ email }) + }); +} + +// R17 批 G1(#15 成员移出):DELETE /api/workspace/members/:userId → 200 { removed_user_id }。仅 admin/ +// owner 可调(否则 403);不能移出自己/最后一名 admin(409)。UI 诚实透传服务端错误。 +export function removeWorkspaceMember( + client: Pick, + userId: string +): Promise<{ removed_user_id: string }> { + return client.request<{ removed_user_id: string }>(`/api/workspace/members/${encodeURIComponent(userId)}`, { + method: "DELETE" + }); } // 私聊分组:actor 参与的 DM 会话(GET /api/dm/list → store.dmList)。每行 = 对方头像(在线绿点)+ 对方 @@ -545,8 +639,8 @@ export function renderNewProjectModalHtml(input: { />

    ${ zh - ? "创建即自动配好:主区群聊(全员可聊)· 项目网盘 · Cuu 入驻 · 模式默认「分级自动」。成员邀请和权限之后在项目设置里调。" - : "Creating it wires up a team chat, project drive, and Cuu — mode defaults to \"tiered auto\". Invite members and adjust permissions later in project settings." + ? "创建即自动配好:主区群聊(全员可聊)· 项目网盘 · Cuu 入驻 · 模式默认「分级自动」。项目成员在设置的「成员」分区查看,加人/退群在各协同会话里管理。" + : "Creating it wires up a team chat, project drive, and Cuu — mode defaults to \"tiered auto\". See project members under Settings; add or remove people inside each collab chat." }

    ${input.error ? `

    ${escapeHtml(input.error)}

    ` : ""}
    @@ -788,6 +882,13 @@ export function mountWorkbenchRail( let renameModalTitle = ""; let renameSubmitting = false; let renameError: string | undefined; + // R17 批 G1(#14 邀请 / #15 成员移出):roster 管理模式 + 邀请表单的瞬态本地状态(同其它弹窗,不进 + // 共享 store——只有这个组件的渲染读它)。canManage 由 VM 的 viewer.membership_role 派生(见 render)。 + let rosterManage = false; + let rosterBusyUserId: string | undefined; + let rosterConfirmRemoveUserId: string | undefined; + let rosterManageError: string | undefined; + let rosterInvite: RosterInviteUiState = { open: false, email: "", submitting: false }; let disposed = false; const render = () => { @@ -826,7 +927,14 @@ export function mountWorkbenchRail( members: state.vm?.workspace_members.items ?? [], currentUserId, onlineUserIds, - locale: input.locale + locale: input.locale, + // R17 批 G1:只有工作区 admin/owner 才渲「管理」/「邀请」入口(viewer.membership_role !== 'member')。 + canManage: state.vm ? state.vm.viewer.membership_role !== "member" : false, + manage: rosterManage, + busyUserId: rosterBusyUserId, + confirmRemoveUserId: rosterConfirmRemoveUserId, + manageError: rosterManageError, + invite: rosterInvite })}${renderDmGroupHtml({ dmList: state.dmList, currentUserId, @@ -1223,6 +1331,137 @@ export function mountWorkbenchRail( } }; + // ── R17 批 G1(#14 邀请 / #15 成员移出):roster 管理/邀请处理器 ──────────────────────────── + const toggleRosterManage = () => { + rosterManage = !rosterManage; + rosterConfirmRemoveUserId = undefined; + rosterManageError = undefined; + render(); + }; + + const openRosterInvite = () => { + rosterInvite = { open: true, email: "", submitting: false }; + render(); + const emailInput = container.querySelector("[data-wb-invite-email]"); + emailInput?.focus(); + }; + + const closeRosterInvite = () => { + rosterInvite = { open: false, email: "", submitting: false }; + render(); + }; + + const submitRosterInvite = async () => { + if (rosterInvite.submitting) { + return; + } + const email = rosterInvite.email.trim(); + if (!email) { + return; + } + rosterInvite = { ...rosterInvite, submitting: true, note: undefined, error: undefined }; + render(); + try { + const result = await createWorkspaceInvite(input.client, email); + if (disposed) { + return; + } + // 一次性 token 复制到剪贴板——复制失败就把 token 落进提示文案,绝不让它丢失(只能取回这一次)。 + let copied = false; + try { + await globalThis.navigator?.clipboard?.writeText(result.token); + copied = true; + } catch { + copied = false; + } + const zh = input.locale === "zh-CN"; + const note = copied + ? zh + ? `已生成邀请并复制 token,发给 ${result.email} 即可加入。` + : `Invite created and token copied — send it to ${result.email}.` + : zh + ? `邀请 token(复制失败,请手动复制):${result.token}` + : `Invite token (copy failed, copy manually): ${result.token}`; + rosterInvite = { open: true, email: "", submitting: false, note }; + render(); + } catch (error) { + if (disposed) { + return; + } + rosterInvite = { + ...rosterInvite, + submitting: false, + error: + error instanceof Error + ? error.message + : input.locale === "zh-CN" + ? "生成邀请失败,请重试" + : "Couldn't create the invite — retry" + }; + render(); + } + }; + + const requestRemoveMember = (userId: string) => { + rosterConfirmRemoveUserId = userId; + rosterManageError = undefined; + render(); + }; + + const cancelRemoveMember = () => { + rosterConfirmRemoveUserId = undefined; + render(); + }; + + const confirmRemoveMember = async (userId: string) => { + if (rosterBusyUserId) { + return; + } + rosterBusyUserId = userId; + rosterManageError = undefined; + render(); + try { + await removeWorkspaceMember(input.client, userId); + if (disposed) { + return; + } + rosterBusyUserId = undefined; + rosterConfirmRemoveUserId = undefined; + // 乐观就地从 roster 剔除(工作区花名册在 VM 里,无独立轻量重拉端点)——顺带把计数对齐。 + const vm = input.store.getState().vm; + if (vm) { + const items = vm.workspace_members.items.filter((member) => member.user_id !== userId); + if (items.length !== vm.workspace_members.items.length) { + input.store.setState({ + vm: { + ...vm, + workspace_members: { + ...vm.workspace_members, + items, + total: Math.max(items.length, vm.workspace_members.total - 1), + returned: items.length + } + } + }); + } + } + render(); + } catch (error) { + if (disposed) { + return; + } + rosterBusyUserId = undefined; + rosterConfirmRemoveUserId = undefined; + rosterManageError = + error instanceof Error + ? error.message + : input.locale === "zh-CN" + ? "移出失败,请重试" + : "Couldn't remove them — retry"; + render(); + } + }; + container.addEventListener("click", (event) => { if (!(event.target instanceof HTMLElement)) { return; @@ -1300,6 +1539,37 @@ export function mountWorkbenchRail( input.onOpenProjectSettings?.(); return; } + // R17 批 G1(#14 邀请 / #15 成员移出):roster 管理模式切换 / 邀请入口 / 移出确认。 + if (target.closest("[data-wb-roster-manage-toggle]")) { + toggleRosterManage(); + return; + } + if (target.closest("[data-wb-invite-member]")) { + openRosterInvite(); + return; + } + if (target.closest("[data-wb-invite-cancel]")) { + closeRosterInvite(); + return; + } + if (target.closest("[data-wb-invite-submit]")) { + void submitRosterInvite(); + return; + } + const removeConfirmBtn = target.closest("[data-wb-remove-member-confirm]"); + if (removeConfirmBtn?.dataset.wbRemoveMemberConfirm) { + void confirmRemoveMember(removeConfirmBtn.dataset.wbRemoveMemberConfirm); + return; + } + if (target.closest("[data-wb-remove-member-cancel]")) { + cancelRemoveMember(); + return; + } + const removeBtn = target.closest("[data-wb-remove-member]"); + if (removeBtn?.dataset.wbRemoveMember) { + requestRemoveMember(removeBtn.dataset.wbRemoveMember); + return; + } // 取消按钮,或直接点在遮罩背景上(不是点在模态框内容里冒泡出来的)都关闭模态。 const clickedOverlayBackdrop = target.hasAttribute("data-wb-new-project-overlay"); if (target.closest("[data-wb-new-project-cancel]") || clickedOverlayBackdrop) { @@ -1404,6 +1674,22 @@ export function mountWorkbenchRail( // ignore: some input rendering modes reject setSelectionRange. } } + return; + } + // R17 批 G1(#14 邀请成员):邀请邮箱输入——重渲后把焦点/光标位置还给输入框(innerHTML 重建会丢焦点)。 + if (target instanceof HTMLInputElement && target.matches("[data-wb-invite-email]")) { + rosterInvite = { ...rosterInvite, email: target.value }; + render(); + const emailInput = container.querySelector("[data-wb-invite-email]"); + if (emailInput) { + emailInput.focus(); + const end = emailInput.value.length; + try { + emailInput.setSelectionRange(end, end); + } catch { + // ignore: some input rendering modes reject setSelectionRange. + } + } } }); diff --git a/apps/desktop-webview/src/workbench/settings/render.test.ts b/apps/desktop-webview/src/workbench/settings/render.test.ts index c47dcee4..aa3a29d2 100644 --- a/apps/desktop-webview/src/workbench/settings/render.test.ts +++ b/apps/desktop-webview/src/workbench/settings/render.test.ts @@ -9,6 +9,7 @@ import { projectInstructionsCounterState, renderGithubBindingSectionHtml, renderProjectInstructionsSectionHtml, + renderProjectMembersSectionHtml, renderProjectSettingsHtml, renderProjectSettingsOwnerOnlyHtml, resolveRiskMonitorForDisplay @@ -511,3 +512,34 @@ test("saving disables the textarea and shows a saving indicator instead of the i assert.match(html, /data-wb-instr-saving="true"/u); assert.match(html, /保存中…/u); }); + +// ── R17 批 G1(#2 项目设置成员分区)───────────────────────────────────────────────────────── + +test("renderProjectMembersSectionHtml shows main=everyone plus per-collab jump-to-manage rows", () => { + const html = renderProjectMembersSectionHtml({ + locale: "zh-CN", + overview: { + totalMembers: 7, + collabConversations: [ + { id: "30000000-0000-4000-8000-000000000001", title: "改第三幕" }, + { id: "30000000-0000-4000-8000-000000000002", title: "发布评审" } + ] + } + }); + assert.match(html, /data-wb-pset-members-section="true"/u); + // 主区=全员,带真实总数。 + assert.match(html, /全部 7 名成员/u); + // 每条协同会话一行「管理成员」跳转按钮,带会话 id。 + assert.match(html, /data-wb-pset-open-conversation="30000000-0000-4000-8000-000000000001"/u); + assert.match(html, /data-wb-pset-open-conversation="30000000-0000-4000-8000-000000000002"/u); + assert.match(html, /改第三幕/u); +}); + +test("renderProjectMembersSectionHtml explains the empty state when there are no collab chats", () => { + const html = renderProjectMembersSectionHtml({ + locale: "zh-CN", + overview: { totalMembers: 3, collabConversations: [] } + }); + assert.doesNotMatch(html, /data-wb-pset-open-conversation=/u); + assert.match(html, /还没有协同会话/u); +}); diff --git a/apps/desktop-webview/src/workbench/settings/render.ts b/apps/desktop-webview/src/workbench/settings/render.ts index 11dc6f0d..70c95e28 100644 --- a/apps/desktop-webview/src/workbench/settings/render.ts +++ b/apps/desktop-webview/src/workbench/settings/render.ts @@ -295,6 +295,67 @@ export function renderProjectSettingsHtml(input: {
    `; } +// —— R17 批 G1(#2 兑现「项目设置里调成员」的承诺):项目成员分区 —— +// 拍板 A(项目成员=会话参与者的可管理化)下,这个分区是「概览 + 跳转」而非独立成员数据层: +// * 主区 = 全员(工作区所有成员都能进主区群聊)——诚实说明,不摆一份假的「项目成员名单」; +// * 该项目各协同会话概览(会话名 + 跳到该会话成员条管理)——加人/退群/移出在会话成员条里操作。 +// 数据由 shell 从已就绪的 workbench VM 直接传入(全员数 + collab 会话列表),本分区不额外取数。 +export type ProjectMembersOverview = { + totalMembers: number; + collabConversations: ReadonlyArray<{ id: string; title: string }>; +}; + +export function renderProjectMembersSectionHtml(input: { + locale: Locale; + overview: ProjectMembersOverview; +}): string { + const zh = input.locale === "zh-CN"; + const { totalMembers, collabConversations } = input.overview; + const mainRow = `
    +
    +
    ${zh ? "主区群聊 · 全员" : "Main chat · everyone"}
    +
    ${ + zh + ? `工作区全部 ${totalMembers} 名成员都能进主区群聊,无需单独拉人。` + : `All ${totalMembers} workspace members can join the main chat — no invite needed.` + }
    +
    +
    `; + const collabRows = collabConversations.length + ? collabConversations + .map( + (conversation) => `
    +
    +
    ${escapeHtml(conversation.title)}
    +
    ${zh ? "协同会话——在会话成员条里加人/退群/移出" : "Collab chat — add or remove members from its member bar"}
    +
    + +
    ` + ) + .join("") + : `

    ${ + zh + ? "还没有协同会话——在左栏「新建协同会话」建群时选人,之后可在会话成员条里加人/退群。" + : "No collab chats yet — create one from the sidebar and pick members; manage them later in its member bar." + }

    `; + return `
    +
    +
    +
    ${zh ? "成员" : "Members"}
    +
    ${ + zh + ? "谁在这个项目里:主区对全员开放,协同会话各有参与者。" + : "Who's in this project: the main chat is open to everyone; each collab chat has its own participants." + }
    +
    +
    + ${mainRow} + ${collabRows} +
    `; +} + export function renderProjectSettingsLoadingHtml(locale: Locale): string { const zh = locale === "zh-CN"; return `
    ${zh ? "正在拉项目设置…" : "Loading project settings…"}
    `; diff --git a/apps/desktop-webview/src/workbench/settings/view.ts b/apps/desktop-webview/src/workbench/settings/view.ts index 2ff65c01..ac4f1b71 100644 --- a/apps/desktop-webview/src/workbench/settings/view.ts +++ b/apps/desktop-webview/src/workbench/settings/view.ts @@ -39,12 +39,14 @@ import { projectGranularEffective, renderGithubBindingSectionHtml, renderProjectInstructionsSectionHtml, + renderProjectMembersSectionHtml, renderProjectSettingsErrorHtml, renderProjectSettingsHtml, renderProjectSettingsLoadingHtml, renderProjectSettingsOwnerOnlyHtml, resolveRiskMonitorForDisplay, - type ProjectInstructionsSectionLoadState + type ProjectInstructionsSectionLoadState, + type ProjectMembersOverview } from "./render.js"; // R14 批 GH 解绑武装态自动复原时长——同 drive/side-panel.ts 版本回滚两段式确认的既有先例(5 秒)。 @@ -89,6 +91,11 @@ export function mountProjectSettingsView( // vm.viewer.is_project_owner——非负责人理论上到不了这个视图(rail 只对负责人渲染入口),但所有权 // 可能在会话中途变更,这里仍按只读渲染兜底(服务端 404 时另有 owner-only 诚实态)。 editable: boolean; + // R17 批 G1(#2):项目成员分区数据——由 shell 从已就绪的 workbench VM 直接传入(全员数 + collab + // 会话列表),本视图不额外取数。缺省时不渲成员分区(兼容存量测试/调用点)。 + memberOverview?: ProjectMembersOverview | undefined; + // 成员分区里「管理成员」按钮点击——跳到对应协同会话(shell 切 centerTab=collab)。缺省则按钮无效。 + onOpenConversation?: ((conversationId: string) => void) | undefined; } ): ProjectSettingsViewHandle { const zh = input.locale === "zh-CN"; @@ -293,11 +300,20 @@ export function mountProjectSettingsView( }); } + // R17 批 G1(#2):项目成员分区——独立块(同 GitHub 分区),由传入的 memberOverview 渲染,不依赖 + // governance 的 loadState(非负责人也能看到成员概览)。缺省 memberOverview 时不渲这个分区。 + function memberSectionHtml(): string { + if (!input.memberOverview) { + return ""; + } + return renderProjectMembersSectionHtml({ locale: input.locale, overview: input.memberOverview }); + } + function render(): void { if (disposed) { return; } - container.innerHTML = `${governanceSectionHtml()}${instructionsSectionHtml()}${renderGithubBindingSectionHtml({ + container.innerHTML = `${governanceSectionHtml()}${memberSectionHtml()}${instructionsSectionHtml()}${renderGithubBindingSectionHtml({ locale: input.locale, editable: input.editable, loadState: githubLoadState, @@ -514,6 +530,15 @@ export function mountProjectSettingsView( } container.addEventListener("click", (event) => { + // R17 批 G1(#2):成员分区「管理成员」跳转——navigation only,任何 viewer 都可用(不受 governance/ + // editable 门控),所以放在下面「非负责人早退」判断之前。 + if (event.target instanceof HTMLElement) { + const openConversationBtn = event.target.closest("[data-wb-pset-open-conversation]"); + if (openConversationBtn?.dataset.wbPsetOpenConversation) { + input.onOpenConversation?.(openConversationBtn.dataset.wbPsetOpenConversation); + return; + } + } if (!(event.target instanceof HTMLElement) || !governance || !input.editable) { if (event.target instanceof HTMLElement && event.target.closest("[data-wb-pset-retry]")) { load(); diff --git a/apps/desktop-webview/src/workbench/shell.ts b/apps/desktop-webview/src/workbench/shell.ts index d36e619e..e032c169 100644 --- a/apps/desktop-webview/src/workbench/shell.ts +++ b/apps/desktop-webview/src/workbench/shell.ts @@ -1125,7 +1125,19 @@ export function mountWorkbenchShell( locale: input.locale, projectId: vm.project.id, projectName: vm.project.name, - editable: vm.viewer.is_project_owner + editable: vm.viewer.is_project_owner, + // R17 批 G1(#2):成员分区数据从已就绪的 workbench VM 直接派生——全员数 + 本项目的协同会话列表 + // (主区不列,主区=全员;DM 容器被围栏,本就不在 workbench 会话里)。不额外取数。 + memberOverview: { + totalMembers: vm.workspace_members.total, + collabConversations: vm.conversations.conversations + .filter((conversation) => conversation.kind === "collab") + .map((conversation) => ({ id: conversation.id, title: conversation.title })) + }, + // 「管理成员」按钮 → 跳到该协同会话(同 rail 打开协同会话的既有路径)。 + onOpenConversation: (conversationId) => { + store.setState({ centerTab: "collab", activeConversationId: conversationId }); + } }); projectSettingsMountKey = key; return; From 08d9bacae5591adfbf8fc43c667e102deb0ce5b3 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:09:04 +0800 Subject: [PATCH 14/23] feat(army): dual-publish run lifecycle events to the source conversation topic (#7) emitRunEvent now also publishes the status-level lifecycle subset (started/failed/escalated + the succeeded terminal agent_run.step[kind:'done']) to topics.conversation(source_conversation_id) when a run carries one. The army context panel subscribes to that conversation stream, so a purely-executing run no longer stalls on the snapshot captured when the conversation was opened. Per-step high-frequency increments are NOT dual-published (same trade-off as the workitem stream) to avoid fan-out amplification. Co-Authored-By: Claude Fable 5 --- apps/api/src/agent-runs.test.ts | 84 ++++++++++++++++++++++++++++ apps/api/src/workers/agent-runner.ts | 10 ++++ 2 files changed, 94 insertions(+) diff --git a/apps/api/src/agent-runs.test.ts b/apps/api/src/agent-runs.test.ts index 81c3b4c0..b340303f 100644 --- a/apps/api/src/agent-runs.test.ts +++ b/apps/api/src/agent-runs.test.ts @@ -6596,6 +6596,90 @@ test("FIX#4 + R14: a run with no conversation source omits conversationId from t assert.equal("conversationId" in (milestoneNotifications[0] ?? {}), false); }); +// R17 G3(#7 军团面板实时性):一个带 source_conversation_id 的 run,其【状态级】生命周期事件 +// (started/failed/escalated + 成功终态 agent_run.step[kind:'done'])除了发 topics.run/topics.workitem, +// 还要双发到血缘会话 topic——军团会话情境面板订的是会话流,据此局部重拉,纯执行推进不再停在旧快照。 +// 逐 step 高频增量(step.tool_result / step.snapshot / 非 done 的 agent_run.step)**不**双发到会话, +// 避免放大扇出/触发整面刷新(与 workitem 流同一取舍)。 +test("R17 G3 #7: a run sourced from a conversation dual-publishes only status-level lifecycle events to the conversation topic", async () => { + const runtimeSettings = settings(); + const workdir = await mkdtemp(path.join(os.tmpdir(), "workhub-g3-conversation-run-test-")); + const snapshotRoot = await mkdtemp(path.join(os.tmpdir(), "workhub-g3-conversation-snapshot-test-")); + const snapshots = new MemorySnapshots(); + const auditLogs = new MemoryAuditLogs(); + const decisions = new MemoryAiDecisions(); + const publishedEvents: { topic: string; type: string; data: WorkHubEvent> }[] = []; + const sourceConversationId = "60000000-0000-4000-8000-000000000701"; + const queue = createInMemoryAgentRunQueue({ + settings: runtimeSettings, + now: () => now, + id: () => "40000000-0000-4000-8000-000000000045", + workdir: () => workdir, + client: () => executableAgentClient(), + snapshotRoot, + snapshotId: () => snapshotId, + snapshots, + auditLogs, + confidence: createAgentRunConfidenceRecorder({ decisions, auditLogs, settings: runtimeSettings }), + proposals: false, + notificationWorkItem: async () => ({ + id: workItemId, + code: "WH-21", + title: "Conversation-sourced worker run", + projectId: "50000000-0000-4000-8000-000000000099", + submitterUserId: userId, + projectOwnerUserId: projectOwnerId + }), + eventBus: { + async publish(topic, type, data) { + publishedEvents.push({ topic, type, data: data as WorkHubEvent> }); + } + } + }); + + const queued = await queue.enqueue({ + workItemId, + actorId: userId, + title: "Conversation-sourced worker run", + sourceConversationId + }); + const executed = await queue.runNext(); + assert.equal(executed?.run_id, queued.run_id); + assert.equal(executed?.status, "succeeded", JSON.stringify(executed?.trace.at(-1))); + + const runTopic = topics.run(queued.run_id).topic; + const workItemTopic = topics.workitem(workItemId).topic; + const conversationTopic = topics.conversation(sourceConversationId).topic; + + const isLifecycleKey = (event: { type: string; data: WorkHubEvent> }) => + /(?:started|failed|escalated)/u.test(event.type) + || (event.type === eventTypes.agentRunStep && event.data.data["kind"] === "done"); + + const conversationEvents = publishedEvents.filter((event) => event.topic === conversationTopic); + // 会话 topic 只该收到状态级子集:至少 started + 成功终态;且【每一条】都是 lifecycle-key。 + assert.equal(conversationEvents.length > 0, true); + assert.equal(conversationEvents.every(isLifecycleKey), true); + assert.equal(conversationEvents.some((event) => event.type === eventTypes.agentRunStarted), true); + assert.equal( + conversationEvents.some((event) => event.type === eventTypes.agentRunStep && event.data.data["kind"] === "done"), + true + ); + // 逐 step 增量(tool_result / snapshot)只在 run topic 上,绝不出现在会话 topic。 + assert.equal(publishedEvents.some((event) => event.topic === runTopic && event.type === eventTypes.stepToolResult), true); + assert.equal(conversationEvents.some((event) => event.type === eventTypes.stepToolResult), false); + assert.equal(conversationEvents.some((event) => event.type === eventTypes.stepSnapshot), false); + // 会话 topic 收到的条数严格少于 run topic(证明高频增量被过滤,没有整流照搬)。 + const runEvents = publishedEvents.filter((event) => event.topic === runTopic); + assert.equal(conversationEvents.length < runEvents.length, true); + // envelope.topic 与投递 topic 一致(与 workitem 双发同一改写)。 + for (const event of conversationEvents) { + assert.equal(event.data.topic, conversationTopic); + assert.equal(event.data.run_id, queued.run_id); + } + // 对照:workitem topic 仍照旧双发(本改动不动它)。 + assert.equal(publishedEvents.some((event) => event.topic === workItemTopic && event.type === eventTypes.agentRunStarted), true); +}); + test("FIX#7: POST /workitems/:id/agent-runs on a spec_ready item kicks it to ai_working and reaches in_review (not stuck)", async () => { const runtimeSettings = settings(); const workdir = await mkdtemp(path.join(os.tmpdir(), "workhub-fix7-test-")); diff --git a/apps/api/src/workers/agent-runner.ts b/apps/api/src/workers/agent-runner.ts index a712b983..ddf1ca9d 100644 --- a/apps/api/src/workers/agent-runner.ts +++ b/apps/api/src/workers/agent-runner.ts @@ -1330,6 +1330,16 @@ export function createInMemoryAgentRunQueue(options: { const workItemTopic = topics.workitem(run.work_item_id).topic; await eventBus.publish(workItemTopic, event.type, { ...envelope, topic: workItemTopic }); } + // R17 G3(#7 军团面板实时性):run 生命周期事件此前只发 topics.run/topics.workitem——军团【会话情境 + // 面板】订的是所属会话流(chat/view.ts 转发给 armyPanel.handleRawConversationEvent),纯执行推进(未触发 + // 任何人工决策/action_card.updated)时那条面板会长期停在打开会话那一刻的旧快照。把与工作项流同一个 + // 状态级子集(started/failed/escalated + 成功终态 agent_run.step[kind:'done'],**不含**逐 step 高频增量) + // 双发到 run 血缘会话(source_conversation_id)的 topic,面板收到即局部后台重拉对应卡。逐 step 不双发: + // 避免把高频增量灌进会话流、放大扇出/触发整面刷新(与上面 workitem 流同一取舍)。 + if (run.source_conversation_id && isRunLifecycleKeyEvent) { + const conversationTopic = topics.conversation(run.source_conversation_id).topic; + await eventBus.publish(conversationTopic, event.type, { ...envelope, topic: conversationTopic }); + } } catch (error) { getDefaultStructuredLogger().warn("agent_run_event_emit_failed", { error }); } From 3491f5815be699021ed37cb8e1fdae129350175b Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:09:13 +0800 Subject: [PATCH 15/23] feat(army): read-only GET /api/army/background endpoint (#8, fork B) Surfaces the previously-invisible background machinery on the army panel: - scheduler: pulse scheduler per-task heartbeat (name / interval / last tick / tick+skip+error counts). Process-level, no per-workspace data, and error text (last_error_message) is deliberately dropped from the VM so non-admin members don't see internal error detail. enabled=false when PULSE_SCHEDULER_ENABLED is off (never instantiates the scheduler). - proactive: the current user's most recent proactive intents (listRecentProactiveIntentsForUser, scoped to workspace + target_user), delivered and suppressed alike, capped at 10. Auth = workspace member (requireCurrentUser + humanScope). Contracts/openapi additive. Co-Authored-By: Claude Fable 5 --- apps/api/src/openapi.ts | 90 +++++++++++ apps/api/src/routes/conversation-army.test.ts | 59 ++++++- apps/api/src/routes/conversation-army.ts | 8 + .../src/services/conversation-army.test.ts | 144 +++++++++++++++++- apps/api/src/services/conversation-army.ts | 94 +++++++++++- packages/contracts/src/pages.ts | 56 +++++++ .../db/src/repositories/proactive-intents.ts | 56 ++++++- 7 files changed, 502 insertions(+), 5 deletions(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 7c83c82c..bb726148 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -6276,6 +6276,89 @@ const armyOverviewResponses = { "500": conversationInternalResponse } } as const; +// R17 G3(#8 后台任务区接真)——与 packages/contracts/src/pages.ts 的 armyBackgroundPageVmSchema 逐字段对齐。 +const armyBackgroundPageResponseSchema = { + type: "object", + properties: { + generated_at: { type: "string", format: "date-time" }, + scheduler: { + type: "object", + properties: { + enabled: { type: "boolean" }, + tasks: { + type: "array", + maxItems: 32, + items: { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 64 }, + interval_ms: { type: "integer", minimum: 0 }, + running: { type: "boolean" }, + tick_count: { type: "integer", minimum: 0 }, + skipped_count: { type: "integer", minimum: 0 }, + error_count: { type: "integer", minimum: 0 }, + last_tick_at: { type: ["string", "null"], format: "date-time" } + }, + required: [ + "name", + "interval_ms", + "running", + "tick_count", + "skipped_count", + "error_count", + "last_tick_at" + ], + additionalProperties: false + } + } + }, + required: ["enabled", "tasks"], + additionalProperties: false + }, + proactive: { + type: "object", + properties: { + items: { + type: "array", + maxItems: 10, + items: { + type: "object", + properties: { + id: uuidStringSchema, + kind: { type: "string", minLength: 1, maxLength: 64 }, + stage: { type: ["string", "null"], minLength: 1, maxLength: 64 }, + status: { type: "string", enum: ["delivered", "suppressed"] }, + delivered_via: { type: ["string", "null"], minLength: 1, maxLength: 64 }, + created_at: { type: "string", format: "date-time" } + }, + required: ["id", "kind", "stage", "status", "delivered_via", "created_at"], + additionalProperties: false + } + }, + capped: { type: "boolean" } + }, + required: ["items", "capped"], + additionalProperties: false + } + }, + required: ["generated_at", "scheduler", "proactive"], + additionalProperties: false, + description: + "Read-only army background machinery: pulse scheduler heartbeat (process-level, no per-workspace data, no error text) plus the current user's most recent proactive intents (workspace + target-user scoped)." +} as const; +const armyBackgroundResponses = { + responses: { + "200": jsonDataResponse(armyBackgroundPageResponseSchema, "Army background tasks and proactivity feed").responses[ + "200" + ], + "401": conversationAuthRequiredResponse, + "403": conversationForbiddenResponse, + "404": jsonErrorStatusResponse("404", "Army background is inaccessible or was not found", [ + "conversation_army_not_found" + ]).responses["404"], + "500": conversationInternalResponse + } +} as const; // R12 批3(行动卡 decide/undo)——与 routes/action-cards.ts 的 zod 请求与 ActionCardItemVM 对齐。 const actionCardItemVmJsonSchema = { @@ -8343,6 +8426,13 @@ export function getOpenApiDocument() { ...armyOverviewResponses } }, + "/api/army/background": { + get: { + tags: ["conversations"], + summary: "Army background: pulse scheduler heartbeat + current user's recent proactive intents", + ...armyBackgroundResponses + } + }, "/api/drive/projects/{projectId}/items/{itemId}/versions": { get: { tags: ["drive"], diff --git a/apps/api/src/routes/conversation-army.test.ts b/apps/api/src/routes/conversation-army.test.ts index fe944a7e..8c9560f3 100644 --- a/apps/api/src/routes/conversation-army.test.ts +++ b/apps/api/src/routes/conversation-army.test.ts @@ -13,7 +13,7 @@ import type { UserAuthRow, UserRepository } from "@workhub/db"; -import type { ArmyOverviewPageVM, ArmyRunCardVM, ConversationArmyPanelVM } from "@workhub/contracts"; +import type { ArmyBackgroundPageVM, ArmyOverviewPageVM, ArmyRunCardVM, ConversationArmyPanelVM } from "@workhub/contracts"; import { httpErrorCodeFor } from "../http-error-codes.js"; import { @@ -170,6 +170,35 @@ function armyOverviewVm(): ArmyOverviewPageVM { }; } +function armyBackgroundVm(): ArmyBackgroundPageVM { + return { + generated_at: now.toISOString(), + scheduler: { + enabled: true, + tasks: [{ + name: "approval-sla", + interval_ms: 60_000, + running: false, + tick_count: 7, + skipped_count: 1, + error_count: 0, + last_tick_at: now.toISOString() + }] + }, + proactive: { + items: [{ + id: "31000000-0000-4000-8000-000000000101", + kind: "care", + stage: "high_load", + status: "delivered", + delivered_via: "conversation_message", + created_at: now.toISOString() + }], + capped: false + } + }; +} + function service(overrides: Partial = {}): ConversationArmyService { return { async conversationArmyPanel() { @@ -178,6 +207,9 @@ function service(overrides: Partial = {}): Conversation async armyOverview() { return armyOverviewVm(); }, + async armyBackground() { + return armyBackgroundVm(); + }, ...overrides }; } @@ -215,10 +247,13 @@ test("conversation army routes require authentication before reaching the servic }, async armyOverview() { throw new Error("anonymous request must not reach the service"); + }, + async armyBackground() { + throw new Error("anonymous request must not reach the service"); } })); - for (const path of [`/api/conversations/${conversationId}/army`, "/api/me/army"]) { + for (const path of [`/api/conversations/${conversationId}/army`, "/api/me/army", "/api/army/background"]) { const response = await app.request(path); assert.equal(response.status, 401); } @@ -290,6 +325,26 @@ test("army overview route defaults limit to 20 and returns the service VM verbat assert.deepEqual(call.query, { limit: 20 }); }); +test("army background route forwards the authed actor to the service and returns its VM verbatim", async () => { + const runtimeSettings = settings(); + const seen: unknown[] = []; + const app = routeApp(runtimeSettings, service({ + async armyBackground(input) { + seen.push(input); + return armyBackgroundVm(); + } + })); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/army/background", { headers }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, data: armyBackgroundVm() }); + assert.equal(seen.length, 1); + const call = seen[0] as { actor: { id: string } }; + assert.equal(call.actor.id, userId); +}); + test("army routes reject an out-of-range limit with a 422 validation envelope before the service runs", async () => { const runtimeSettings = settings(); let calls = 0; diff --git a/apps/api/src/routes/conversation-army.ts b/apps/api/src/routes/conversation-army.ts index 8ee5ff42..cf378fba 100644 --- a/apps/api/src/routes/conversation-army.ts +++ b/apps/api/src/routes/conversation-army.ts @@ -52,5 +52,13 @@ export function createConversationArmyRoutes(deps: ConversationArmyRoutesDepende return c.json({ ok: true, data }); }); + // R17 G3(#8 后台任务区接真 · 拍板 B):军团面板「后台任务」区的只读数据源——pulse 统一调度器的每任务 + // 运行统计 + 最近投向当前用户的主动性动态。权限=工作区成员(requireCurrentUser 已保证;service 的 + // humanScope 再收窄 human/workspace/user)。无查询参数、无写副作用。 + routes.get("/army/background", requireCurrentUser, async (c) => { + const data = await conversationArmy.armyBackground({ actor: c.var.actor }); + return c.json({ ok: true, data }); + }); + return routes; } diff --git a/apps/api/src/services/conversation-army.test.ts b/apps/api/src/services/conversation-army.test.ts index 2041fb7f..e4f18447 100644 --- a/apps/api/src/services/conversation-army.test.ts +++ b/apps/api/src/services/conversation-army.test.ts @@ -6,15 +6,18 @@ import type { ArmyOverviewRunCardRow, ConversationRunCardRow, ConversationRunListResult, - ConversationRunOutputLinkResult + ConversationRunOutputLinkResult, + RecentProactiveIntentRow } from "@workhub/db"; import { catCodename } from "@workhub/contracts"; import type { AuthActor } from "../middleware/auth.js"; import { InternalContractError } from "../pages/output-contract.js"; +import type { PulseSchedulerStats } from "../workers/pulse-scheduler.js"; import { ConversationArmyServiceError, createConversationArmyService, + type ConversationArmyBackgroundSources, type ConversationArmyRepositorySources } from "./conversation-army.js"; @@ -311,3 +314,142 @@ test("a malformed repository row fails closed as an InternalContractError, not a (error: unknown) => error instanceof InternalContractError ); }); + +// ── R17 G3(#8 后台任务区接真 · 拍板 B) ────────────────────────────────────────────────── + +function pulseStats(overrides: Partial = {}): PulseSchedulerStats { + return { + running: false, + tasks: { + "approval-sla": { + running: false, + interval_ms: 60_000, + tick_count: 7, + skipped_count: 1, + error_count: 0, + last_tick_at: now.toISOString(), + last_error_message: "secret internal detail that must not leak", + ...overrides + }, + "care-scan": { + running: false, + interval_ms: 3_600_000, + tick_count: 3, + skipped_count: 0, + error_count: 2, + last_error_message: "another internal error" + } + } + }; +} + +function proactiveRow(overrides: Partial = {}): RecentProactiveIntentRow { + return { + id: "32000000-0000-4000-8000-000000000101", + kind: "care", + stage: "high_load", + status: "delivered", + deliveredVia: "conversation_message", + projectId: null, + workItemId: null, + createdAt: now, + ...overrides + }; +} + +function background(overrides: Partial = {}): ConversationArmyBackgroundSources { + return { + pulse: { enabled: true, stats: () => pulseStats() }, + async listRecentProactiveIntents() { + return [proactiveRow()]; + }, + ...overrides + }; +} + +test("armyBackground maps pulse stats to tasks (dropping last_error_message) and lists the user's recent proactive intents", async () => { + const service = createConversationArmyService({ repo: repo(), background: background(), now: () => now }); + + const page = await service.armyBackground({ actor: humanActor() }); + + assert.equal(page.generated_at, now.toISOString()); + assert.equal(page.scheduler.enabled, true); + assert.equal(page.scheduler.tasks.length, 2); + const slaTask = page.scheduler.tasks.find((task) => task.name === "approval-sla"); + assert.equal(slaTask?.interval_ms, 60_000); + assert.equal(slaTask?.tick_count, 7); + assert.equal(slaTask?.skipped_count, 1); + assert.equal(slaTask?.error_count, 0); + assert.equal(slaTask?.last_tick_at, now.toISOString()); + // last_error_message 有意不进 VM——普通成员看不到内部错误细节。 + assert.equal("last_error_message" in (slaTask ?? {}), false); + const careTask = page.scheduler.tasks.find((task) => task.name === "care-scan"); + assert.equal(careTask?.error_count, 2); + assert.equal(careTask?.last_tick_at, null, "a task that never ticked reports null, not a fabricated timestamp"); + + assert.equal(page.proactive.items.length, 1); + assert.deepEqual(page.proactive.items[0], { + id: "32000000-0000-4000-8000-000000000101", + kind: "care", + stage: "high_load", + status: "delivered", + delivered_via: "conversation_message", + created_at: now.toISOString() + }); + assert.equal(page.proactive.capped, false); +}); + +test("armyBackground reports enabled=false with an empty task list and never instantiates the scheduler when pulse is off", async () => { + let statsCalled = false; + const service = createConversationArmyService({ + repo: repo(), + background: background({ + pulse: { + enabled: false, + stats: () => { + statsCalled = true; + return pulseStats(); + } + } + }), + now: () => now + }); + + const page = await service.armyBackground({ actor: humanActor() }); + assert.equal(page.scheduler.enabled, false); + assert.deepEqual(page.scheduler.tasks, []); + assert.equal(statsCalled, false, "pulse disabled → stats() must not be called (no scheduler instantiation)"); +}); + +test("armyBackground scopes proactive intents to the caller's workspace and user, and caps at 10", async () => { + const captured: { workspaceId: string; targetUserId: string; limit: number }[] = []; + const service = createConversationArmyService({ + repo: repo(), + background: background({ + async listRecentProactiveIntents(input) { + captured.push(input); + // 仓库拿到 limit+1=11 条 → capped=true,VM 只回前 10。 + return Array.from({ length: input.limit }, (_unused, index) => + proactiveRow({ id: `32000000-0000-4000-8000-0000000001${(index + 10).toString().padStart(2, "0")}` }) + ); + } + }), + now: () => now + }); + + const page = await service.armyBackground({ actor: humanActor() }); + assert.equal(captured.length, 1); + assert.equal(captured[0]?.workspaceId, workspaceId); + assert.equal(captured[0]?.targetUserId, userId); + assert.equal(captured[0]?.limit, 11, "fetches one extra row to precisely determine capped"); + assert.equal(page.proactive.items.length, 10); + assert.equal(page.proactive.capped, true); +}); + +test("armyBackground fails closed (404) for a non-human actor with no workspace scope", async () => { + const service = createConversationArmyService({ repo: repo(), background: background(), now: () => now }); + await assert.rejects( + service.armyBackground({ actor: nonHumanActor() }), + (error: unknown) => error instanceof ConversationArmyServiceError && error.status === 404 + ); +}); diff --git a/apps/api/src/services/conversation-army.ts b/apps/api/src/services/conversation-army.ts index 80a8b1fd..88502bfc 100644 --- a/apps/api/src/services/conversation-army.ts +++ b/apps/api/src/services/conversation-army.ts @@ -1,16 +1,21 @@ +import { settings } from "@workhub/config"; import { createConversationRunRepository, getSharedDatabaseClient, + listRecentProactiveIntentsForUser, type ArmyOverviewRunCardRow, type ConversationRunCardRow, type ConversationRunRepository, type ProposalDiffStatsFile, + type RecentProactiveIntentRow, type WorkHubDatabaseClient } from "@workhub/db"; import { + armyBackgroundPageVmSchema, armyOverviewPageVmSchema, catCodename, conversationArmyPanelVmSchema, + type ArmyBackgroundPageVM, type ArmyChangedFileVM, type ArmyOverviewPageVM, type ArmyRunListQuery, @@ -19,6 +24,11 @@ import { import type { AuthActor } from "../middleware/auth.js"; import { parseOutputContract } from "../pages/output-contract.js"; +import { getDefaultPulseScheduler, type PulseSchedulerStats } from "../workers/pulse-scheduler.js"; + +// R17 G3(#8 后台任务区接真):最近主动性动态展示上限(拍板 B)。多取 1 条用来精确判定 capped(拿到 +// 第 11 条即说明「还有更多没显示」),VM 只回前 10。 +const ARMY_BACKGROUND_PROACTIVE_LIMIT = 10; // R12 批 5(军团面板,服务端读侧切片):只读聚合,不碰派发写路径。 // GET /conversations/:id/army 与 GET /me/army 两个 handler 消费这个 service(见 routes/conversation-army.ts)。 @@ -56,6 +66,18 @@ export type ConversationArmyRepositorySources = Pick< "listRunsForConversation" | "listArmyOverviewForUser" | "listOutputLinksForConversation" >; +// R17 G3(#8):后台任务区的两个只读数据源端口——都可注入,便于单测不碰真库/真调度器。 +// * pulse.stats() —— 统一调度器每任务运行统计(进程级心跳,无工作区/用户数据)。enabled=false 时不调用。 +// * listRecentProactiveIntents —— 最近投向某用户的主动性动态(workspace+target_user 收窄,见仓库函数注释)。 +export type ConversationArmyBackgroundSources = { + pulse: { enabled: boolean; stats: () => PulseSchedulerStats }; + listRecentProactiveIntents: (input: { + workspaceId: string; + targetUserId: string; + limit: number; + }) => Promise; +}; + export type ConversationArmyService = { conversationArmyPanel(input: { actor: AuthActor; @@ -63,10 +85,14 @@ export type ConversationArmyService = { query: ArmyRunListQuery; }): Promise; armyOverview(input: { actor: AuthActor; query: ArmyRunListQuery }): Promise; + armyBackground(input: { actor: AuthActor }): Promise; }; export type ConversationArmyServiceDependencies = { repo: ConversationArmyRepositorySources; + // 可选:只有 armyBackground() 用得到(#8)。conversationArmyPanel/armyOverview 不依赖它——不注入时 + // 那两个方法照常工作,只有 armyBackground 会在缺依赖时抛错(默认工厂恒注入,生产永不缺)。 + background?: ConversationArmyBackgroundSources; now?: () => Date; }; @@ -132,6 +158,31 @@ function cursorFromQuery(query: ArmyRunListQuery) { : undefined; } +// R17 G3(#8):pulse stats() 的 Record → VM 数组(保留注册顺序)。有意丢弃 +// last_error_message——它可能带内部错误细节,不该泄漏给普通成员;error_count 数字足够表达「健康度」。 +function schedulerStatsToVm(stats: PulseSchedulerStats): ArmyBackgroundPageVM["scheduler"]["tasks"] { + return Object.entries(stats.tasks).map(([name, task]) => ({ + name, + interval_ms: task.interval_ms, + running: task.running, + tick_count: task.tick_count, + skipped_count: task.skipped_count, + error_count: task.error_count, + last_tick_at: task.last_tick_at ?? null + })); +} + +function proactiveIntentToVm(row: RecentProactiveIntentRow): ArmyBackgroundPageVM["proactive"]["items"][number] { + return { + id: row.id, + kind: row.kind, + stage: row.stage, + status: row.status, + delivered_via: row.deliveredVia, + created_at: row.createdAt.toISOString() + }; +} + export function createConversationArmyService(deps: ConversationArmyServiceDependencies): ConversationArmyService { const now = deps.now ?? (() => new Date()); @@ -220,6 +271,37 @@ export function createConversationArmyService(deps: ConversationArmyServiceDepen }, "conversation-army.overview" ); + }, + + async armyBackground(input) { + const human = humanScope(input.actor); + const background = deps.background; + if (!background) { + // 只有当调用方漏注入 background 端口时才会到这(默认工厂恒注入)——大声报错,不静默返回假空。 + throw new Error("conversation army service missing background sources for armyBackground()"); + } + // 定时任务:总开关未开时不去实例化调度器(避免副作用),诚实回 enabled=false + 空表。 + const scheduler = background.pulse.enabled + ? { enabled: true, tasks: schedulerStatsToVm(background.pulse.stats()) } + : { enabled: false, tasks: [] }; + // 主动性动态:多取 1 条精确判定 capped(拿到第 11 条即「还有更多」),VM 只回前 10。 + const proactiveRows = await background.listRecentProactiveIntents({ + workspaceId: human.workspaceId, + targetUserId: human.userId, + limit: ARMY_BACKGROUND_PROACTIVE_LIMIT + 1 + }); + const capped = proactiveRows.length > ARMY_BACKGROUND_PROACTIVE_LIMIT; + const items = proactiveRows.slice(0, ARMY_BACKGROUND_PROACTIVE_LIMIT).map(proactiveIntentToVm); + + return parseOutputContract( + armyBackgroundPageVmSchema, + { + generated_at: now().toISOString(), + scheduler, + proactive: { items, capped } + }, + "conversation-army.background" + ); } }; } @@ -230,8 +312,18 @@ let defaultConversationArmyService: ConversationArmyService | undefined; export function getDefaultConversationArmyService(): ConversationArmyService { if (!defaultConversationArmyService) { defaultConversationArmyDbClient = getSharedDatabaseClient(); + const db = defaultConversationArmyDbClient.db; defaultConversationArmyService = createConversationArmyService({ - repo: createConversationRunRepository(defaultConversationArmyDbClient.db) + repo: createConversationRunRepository(db), + background: { + // pulse 总开关未开时不调 getDefaultPulseScheduler()(避免为一个只读端点实例化调度器 + 其依赖的 + // 审批/通知服务)。开启时拿到的正是 server.ts 已 start 的同一个 memoized 单例,stats 反映真实 tick。 + pulse: { + enabled: settings.pulse.enabled, + stats: () => getDefaultPulseScheduler().stats() + }, + listRecentProactiveIntents: (input) => listRecentProactiveIntentsForUser(db, input) + } }); } return defaultConversationArmyService; diff --git a/packages/contracts/src/pages.ts b/packages/contracts/src/pages.ts index 8569d452..8ddfc4db 100644 --- a/packages/contracts/src/pages.ts +++ b/packages/contracts/src/pages.ts @@ -1788,3 +1788,59 @@ export const armyOverviewPageVmSchema = z }) .strict(); export type ArmyOverviewPageVM = z.infer; + +// R17 G3(#8 军团后台任务区接真 · 拍板 B):军团面板「后台任务」区此前契约锁死 not_yet_available 永远空 +// (见上 armyBackgroundTasksVmSchema 注释——那块仍保留,用于会话情境面板旧字段的向后兼容)。真实的 pulse +// 统一调度器(审批 SLA / 通知提醒 / 审批 digest / 追 DDL / 关怀)与主动性投递(proactive_intents)一直在跑 +// 却完全不可见。GET /api/army/background 把两块摆上台面: +// * scheduler —— pulse 调度器的每任务运行统计(name/间隔/上次 tick/tick 计数/跳过计数/错误计数)。这是 +// 进程级心跳,不含任何工作区/用户数据、不含错误文本(last_error_message 有意不进 VM,避免把内部错误 +// 细节泄漏给普通成员),任何已登录工作区成员都可看。enabled=false 表示总开关(PULSE_SCHEDULER_ENABLED) +// 未开,tasks 为空。 +// * proactive —— 最近投向【当前用户】的主动性动态(listRecentProactiveIntentsForUser 的 workspace+target_user +// 双重收窄口径,见该仓库函数注释)。delivered/suppressed 都展示,诚实反映「机器动过 / 克制住了」。 +// 定时任务区永不为「空态谎言」:enabled=true 但 tasks 空只会发生在还没注册任何任务的极端情况;proactive +// 空数组是诚实的「最近没有主动性投向你」。 +export const armyBackgroundSchedulerTaskVmSchema = z + .object({ + name: z.string().min(1).max(64), + interval_ms: z.number().int().nonnegative(), + running: z.boolean(), + tick_count: z.number().int().nonnegative(), + skipped_count: z.number().int().nonnegative(), + error_count: z.number().int().nonnegative(), + last_tick_at: isoDateTimeSchema.nullable() + }) + .strict(); +export type ArmyBackgroundSchedulerTaskVM = z.infer; + +export const armyBackgroundProactiveItemVmSchema = z + .object({ + id: idSchema, + kind: z.string().min(1).max(64), + stage: z.string().min(1).max(64).nullable(), + status: z.enum(["delivered", "suppressed"]), + delivered_via: z.string().min(1).max(64).nullable(), + created_at: isoDateTimeSchema + }) + .strict(); +export type ArmyBackgroundProactiveItemVM = z.infer; + +export const armyBackgroundPageVmSchema = z + .object({ + generated_at: isoDateTimeSchema, + scheduler: z + .object({ + enabled: z.boolean(), + tasks: z.array(armyBackgroundSchedulerTaskVmSchema).max(32) + }) + .strict(), + proactive: z + .object({ + items: z.array(armyBackgroundProactiveItemVmSchema).max(10), + capped: z.boolean() + }) + .strict() + }) + .strict(); +export type ArmyBackgroundPageVM = z.infer; diff --git a/packages/db/src/repositories/proactive-intents.ts b/packages/db/src/repositories/proactive-intents.ts index 05975867..ca3c2dfd 100644 --- a/packages/db/src/repositories/proactive-intents.ts +++ b/packages/db/src/repositories/proactive-intents.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { and, asc, eq, inArray, isNull, lte, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, lte, notInArray, sql } from "drizzle-orm"; import type { WorkItemStatus } from "@workhub/contracts"; @@ -106,6 +106,60 @@ export async function countDeliveredCareIntentsForUser( return rows[0]?.value ?? 0; } +// R17 G3(#8 军团后台任务区接真 · 主动性动态):某工作区内、投给某个用户的最近【已落终态】主动性动态 +// (delivered / suppressed,不含 created 这个瞬态中间态)——军团面板「后台任务」区据此展示 Cuu 的主动性 +// 机器最近为「你」做了什么(追 DDL / 关怀 / 找人),delivered/suppressed 都展示(被频控/静音挡下的也是 +// 一种诚实的「机器动过、但克制住了」)。可见口径:workspace + target_user 双重收窄——每个成员只看到投向 +// 自己的动态,无需 admin 概念;target_user 为空的 intent(不针对具体人)天然不在此口径内。只读,无副作用。 +export type RecentProactiveIntentRow = { + id: string; + kind: string; + stage: string | null; + status: "delivered" | "suppressed"; + deliveredVia: string | null; + projectId: string | null; + workItemId: string | null; + createdAt: Date; +}; + +export async function listRecentProactiveIntentsForUser( + db: WorkHubDb, + input: { workspaceId: string; targetUserId: string; limit: number } +): Promise { + const rows = await db + .select({ + id: proactiveIntents.id, + kind: proactiveIntents.kind, + stage: proactiveIntents.stage, + status: proactiveIntents.status, + deliveredVia: proactiveIntents.deliveredVia, + projectId: proactiveIntents.projectId, + workItemId: proactiveIntents.workItemId, + createdAt: proactiveIntents.createdAt + }) + .from(proactiveIntents) + .where( + and( + eq(proactiveIntents.workspaceId, input.workspaceId), + eq(proactiveIntents.targetUserId, input.targetUserId), + inArray(proactiveIntents.status, ["delivered", "suppressed"]) + ) + ) + .orderBy(desc(proactiveIntents.createdAt), desc(proactiveIntents.id)) + .limit(input.limit); + return rows.map((row) => ({ + id: row.id, + kind: row.kind, + stage: row.stage, + // status 列有 check 约束限定 created/delivered/suppressed,WHERE 又只取后两者——收窄类型。 + status: row.status === "suppressed" ? "suppressed" : "delivered", + deliveredVia: row.deliveredVia, + projectId: row.projectId, + workItemId: row.workItemId, + createdAt: row.createdAt + })); +} + // 投递闸走完后把 intent 从 created 顶到终态:delivered(附 delivered_via)或 suppressed(频控/静音挡下)。 export async function markProactiveIntentStatus( db: WorkHubDb, From 67574ebe5b4860242a3c939e819fde225825929c Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:09:20 +0800 Subject: [PATCH 16/23] fix(army): drop the unreachable budget_exhausted run-status label (#34) agentRunStatuses only has queued/running/succeeded/failed/escalated/cancelled; budget_exhausted is a budget-notice reason, never a run status, so this label matched nothing. Dead entry removed. Co-Authored-By: Claude Fable 5 --- apps/desktop-webview/src/spotlight/labels.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop-webview/src/spotlight/labels.ts b/apps/desktop-webview/src/spotlight/labels.ts index 5ebfcc66..7b994766 100644 --- a/apps/desktop-webview/src/spotlight/labels.ts +++ b/apps/desktop-webview/src/spotlight/labels.ts @@ -29,13 +29,15 @@ const workItemPriorityMap: Record = { urgent: ["紧急", "Urgent"] }; +// R17 G3(#34):agentRunStatuses 契约只有 queued/running/succeeded/failed/escalated/cancelled—— +// budget_exhausted 从来不是合法的 AgentRunStatus(那是 budget notice reason 的枚举,不是 run 状态), +// 这条标签永远匹配不到任何真实 run 状态,是死标签,删掉。 const agentRunStatusMap: Record = { queued: ["排队中", "Queued"], running: ["进行中", "In progress"], succeeded: ["已完成", "Succeeded"], failed: ["失败", "Failed"], escalated: ["已升级", "Escalated"], - budget_exhausted: ["预算已用尽", "Budget exhausted"], cancelled: ["已取消", "Cancelled"] }; From 7103b07043d5ce1a649995fa8eac0b97f2a1f152 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:09:33 +0800 Subject: [PATCH 17/23] =?UTF-8?q?feat(army):=20panel=20realtime=20+=20oper?= =?UTF-8?q?ations=20=E2=80=94=20abort,=20escalated=20badge,=20drilldown,?= =?UTF-8?q?=20background=20(#7/#8/#19/#20/#21/#32/#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop army panel wiring for the G3 gaps: - #7 panel refreshes on run lifecycle events forwarded through the conversation stream (isIncomingAgentRunLifecycleEvent; new army/events.ts). - #8 background-tasks section re-rendered from GET /api/army/background (scheduled tasks + proactivity feed), honest loading/error/empty states; lazy-loaded once. - #19 abort entry on cancellable run cards + a confirm→busy→status flow in the run detail, wired to the existing abortAgentRun (/agent-runs/:id/abort). - #20 escalated split out of the wait bucket into its own danger "waiting on you" badge + a "handle" action that opens the decision inbox. - #21 army-overview cards drill down: selectProject + auto-open the run detail in the right pane (openRunId threaded through showForConversation). - #32 overview header "loaded N minutes ago" annotation next to the refresh button. - #33 returning from run detail to the list re-fetches the conversation panel. Interactive run cards are restructured to div + inner hit-button + footer actions to keep action buttons out of nested buttons. Co-Authored-By: Claude Fable 5 --- .../src/workbench/army/api.test.ts | 38 ++- .../desktop-webview/src/workbench/army/api.ts | 21 +- .../src/workbench/army/events.test.ts | 29 ++ .../src/workbench/army/events.ts | 48 +++ .../src/workbench/army/overview.ts | 29 +- .../src/workbench/army/panel.ts | 182 ++++++++++- .../src/workbench/army/render.test.ts | 168 ++++++++-- .../src/workbench/army/render.ts | 302 ++++++++++++++++-- apps/desktop-webview/src/workbench/css.ts | 33 ++ apps/desktop-webview/src/workbench/shell.ts | 45 ++- 10 files changed, 821 insertions(+), 74 deletions(-) create mode 100644 apps/desktop-webview/src/workbench/army/events.test.ts create mode 100644 apps/desktop-webview/src/workbench/army/events.ts diff --git a/apps/desktop-webview/src/workbench/army/api.test.ts b/apps/desktop-webview/src/workbench/army/api.test.ts index 2e71dbcb..6fa3d557 100644 --- a/apps/desktop-webview/src/workbench/army/api.test.ts +++ b/apps/desktop-webview/src/workbench/army/api.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { AgentRunLiveVM, ArmyOverviewPageVM, ConversationArmyPanelVM } from "@workhub/contracts"; +import type { AgentRunLiveVM, ArmyBackgroundPageVM, ArmyOverviewPageVM, ConversationArmyPanelVM } from "@workhub/contracts"; -import { fetchAgentRunTrace, fetchArmyOverview, fetchConversationArmyPanel } from "./api.js"; +import { abortAgentRun, fetchAgentRunTrace, fetchArmyBackground, fetchArmyOverview, fetchConversationArmyPanel } from "./api.js"; function fakePanel(): ConversationArmyPanelVM { return { @@ -103,3 +103,37 @@ test("fetchAgentRunTrace forwards to the client's existing getAgentRun method (t assert.deepEqual(calls, ["run-1"]); assert.equal(result, trace); }); + +test("fetchArmyBackground requests the read-only background endpoint", async () => { + const calls: string[] = []; + const vm: ArmyBackgroundPageVM = { + generated_at: "2026-07-13T00:00:00.000000Z", + scheduler: { enabled: true, tasks: [] }, + proactive: { items: [], capped: false } + }; + const client = fakeRequestClient((path) => { + calls.push(path); + return vm; + }); + + const result = await fetchArmyBackground(client); + + assert.deepEqual(calls, ["/api/army/background"]); + assert.deepEqual(result, vm); +}); + +test("abortAgentRun forwards to the client's existing abortAgentRun method (POST /agent-runs/:id/abort)", async () => { + const calls: string[] = []; + const cancelled = { run_id: "run-1", status: "cancelled" } as unknown as AgentRunLiveVM; + const client = { + abortAgentRun: async (runId: string) => { + calls.push(runId); + return cancelled; + } + }; + + const result = await abortAgentRun(client, "run-1"); + + assert.deepEqual(calls, ["run-1"]); + assert.equal(result, cancelled); +}); diff --git a/apps/desktop-webview/src/workbench/army/api.ts b/apps/desktop-webview/src/workbench/army/api.ts index 4c8b7930..77836b0a 100644 --- a/apps/desktop-webview/src/workbench/army/api.ts +++ b/apps/desktop-webview/src/workbench/army/api.ts @@ -7,7 +7,7 @@ // 视图(spotlight/views/replay.ts)就是靠同一个端点/同一份 AgentRunLiveVM 渲出时间线的,这里只是第二个 // 消费方。「看回放」按钮因此可以是一次真实调用,而不是一个假装能深链却没接线的按钮(04 §4 铁律 3)。 -import type { AgentRunLiveVM, ArmyOverviewPageVM, ConversationArmyPanelVM } from "@workhub/contracts"; +import type { AgentRunLiveVM, ArmyBackgroundPageVM, ArmyOverviewPageVM, ConversationArmyPanelVM } from "@workhub/contracts"; export type ArmyRunListQueryInput = { afterCreatedAt?: string; @@ -18,6 +18,8 @@ export type ArmyRunListQueryInput = { export type ArmyPanelApiClient = { request: (path: string, init?: RequestInit) => Promise; getAgentRun: (runId: string) => Promise; + // R17 G3(#19):既有具名方法(POST /agent-runs/:id/abort)——军团面板取消一个 queued/running 的 run。 + abortAgentRun: (runId: string) => Promise; }; function buildArmyRunListQuery(input: ArmyRunListQueryInput): string { @@ -52,9 +54,26 @@ export function fetchArmyOverview( return client.request(`/api/me/army${buildArmyRunListQuery(input)}`); } +// R17 G3(#8):后台任务区(定时任务 + 主动性动态)只读端点——走 client.request 既有转发口(同上,不为 +// 一个特性扩大 api-client 公共方法面)。无查询参数。 +export function fetchArmyBackground( + client: Pick +): Promise { + return client.request("/api/army/background"); +} + export function fetchAgentRunTrace( client: Pick, runId: string ): Promise { return client.getAgentRun(runId); } + +// R17 G3(#19):取消一个 run——既有具名方法 client.abortAgentRun(runId)(POST /agent-runs/:id/abort), +// 与 fetchAgentRunTrace 同款薄封装,返回落定后的 run 快照(status 通常翻成 cancelled)。 +export function abortAgentRun( + client: Pick, + runId: string +): Promise { + return client.abortAgentRun(runId); +} diff --git a/apps/desktop-webview/src/workbench/army/events.test.ts b/apps/desktop-webview/src/workbench/army/events.test.ts new file mode 100644 index 00000000..16467c25 --- /dev/null +++ b/apps/desktop-webview/src/workbench/army/events.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isIncomingAgentRunLifecycleEvent } from "./events.js"; + +function envelope(type: string, data: Record): unknown { + return { event_id: "e-1", type, topic: "conversation:conv-1", ts: "2026-07-16T00:00:00.000Z", data }; +} + +test("isIncomingAgentRunLifecycleEvent matches started/failed/escalated", () => { + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("agent_run.started", { run_id: "r-1" })), true); + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("agent_run.failed", { run_id: "r-1", reason: "x" })), true); + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("agent_run.escalated", { run_id: "r-1" })), true); +}); + +test("isIncomingAgentRunLifecycleEvent matches agent_run.step only for the terminal done kind (not high-frequency mid steps)", () => { + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("agent_run.step", { run_id: "r-1", kind: "done", status: "succeeded" })), true); + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("agent_run.step", { run_id: "r-1", kind: "requeued" })), false); + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("agent_run.step", { run_id: "r-1" })), false); +}); + +test("isIncomingAgentRunLifecycleEvent ignores unrelated conversation events and malformed input", () => { + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("conversation.message.created", { conversation_id: "conv-1" })), false); + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("conversation.action_card.updated", { conversation_id: "conv-1" })), false); + assert.equal(isIncomingAgentRunLifecycleEvent(envelope("agent_run.started", {})), false, "missing run_id"); + assert.equal(isIncomingAgentRunLifecycleEvent(null), false); + assert.equal(isIncomingAgentRunLifecycleEvent("agent_run.started"), false); + assert.equal(isIncomingAgentRunLifecycleEvent({ type: "agent_run.started" }), false, "missing data"); +}); diff --git a/apps/desktop-webview/src/workbench/army/events.ts b/apps/desktop-webview/src/workbench/army/events.ts new file mode 100644 index 00000000..a7aebdff --- /dev/null +++ b/apps/desktop-webview/src/workbench/army/events.ts @@ -0,0 +1,48 @@ +// WorkHub 桌面 · 军团面板从会话 SSE 流里识别 run 生命周期事件——纯函数,未过校验一律返回 false, +// 调用方(panel.ts)静默丢弃。 +// +// R17 G3(#7):服务端 emitRunEvent 现在把 run 的【状态级】生命周期事件(started/failed/escalated + 成功终态 +// agent_run.step[kind:'done'])除了发 topics.run/topics.workitem,还双发到 run 血缘会话(source_conversation_id) +// 的 topic。军团会话情境面板订的正是这条会话流(chat/view.ts 把每条 SSE data 转发给 handleRawConversationEvent)—— +// 收到这类事件即后台重拉对应会话的军团面板,让纯执行推进的 run 卡不再停在打开会话那一刻的旧快照。 +// 逐 step 高频增量不会双发到会话流(见 agent-runner.ts 注释),所以这里即便宽松匹配也不会被高频事件刷爆。 +// +// 校验用 eventTypes 常量比对 + 窄字段守卫(desktop-webview 不直接依赖 zod,会话消息类事件也是走 contracts +// 已有 schema;run 生命周期事件目前没有对应 schema,这里做精确的最小类型守卫,不是松散地摸字段)。 + +import { eventTypes } from "@workhub/contracts"; + +type LooseEventEnvelope = { + type: string; + data: { run_id: string; kind?: unknown }; +}; + +function isLooseRunEnvelope(raw: unknown): raw is LooseEventEnvelope { + if (typeof raw !== "object" || raw === null) { + return false; + } + const envelope = raw as { type?: unknown; data?: unknown }; + if (typeof envelope.type !== "string") { + return false; + } + if (typeof envelope.data !== "object" || envelope.data === null) { + return false; + } + return typeof (envelope.data as { run_id?: unknown }).run_id === "string"; +} + +// 判定一条会话流事件是不是 run 的状态级生命周期事件。started/failed/escalated 恒是;agent_run.step 只有 +// kind==='done'(成功/失败终态)才算——中间步骤增量本就不会双发到会话流,这里再收一道,避免误判。 +export function isIncomingAgentRunLifecycleEvent(raw: unknown): boolean { + if (!isLooseRunEnvelope(raw)) { + return false; + } + const { type, data } = raw; + if (type === eventTypes.agentRunStarted || type === eventTypes.agentRunFailed || type === eventTypes.agentRunEscalated) { + return true; + } + if (type === eventTypes.agentRunStep) { + return data.kind === "done"; + } + return false; +} diff --git a/apps/desktop-webview/src/workbench/army/overview.ts b/apps/desktop-webview/src/workbench/army/overview.ts index 18b2272f..b5ef9986 100644 --- a/apps/desktop-webview/src/workbench/army/overview.ts +++ b/apps/desktop-webview/src/workbench/army/overview.ts @@ -9,14 +9,23 @@ import type { WorkHubApiClient } from "@workhub/api-client"; import type { ArmyOverviewPageVM } from "@workhub/contracts"; +import { escapeHtml } from "@workhub/web-runtime"; import { fetchArmyOverview } from "./api.js"; -import { mergeArmyRunPages, renderArmyOverviewHtml, type ArmyOverviewViewState } from "./render.js"; +import { armyDataAgeLabel, mergeArmyRunPages, renderArmyOverviewHtml, type ArmyOverviewViewState } from "./render.js"; type Locale = "zh-CN" | "en-US"; export type ArmyOverviewApiClient = Pick; +// R17 G3(#21):总览卡片下钻——把项目/会话/run id 抛给 shell,由它 selectProject(该项目) + 右栏定位该 run +// 详情。conversationId 可选:系统派发、无血缘会话的 run 只能 selectProject,无从定位右栏详情。 +export type ArmyOverviewOpenRunInput = { + projectId: string; + runId: string; + conversationId?: string; +}; + export type ArmyOverviewViewHandle = { dispose: () => void; refresh: () => void; @@ -31,7 +40,7 @@ function errorMessage(error: unknown, locale: Locale): string { export function mountArmyOverviewView( container: HTMLElement, - input: { client: ArmyOverviewApiClient; locale: Locale } + input: { client: ArmyOverviewApiClient; locale: Locale; onOpenRun?: (input: ArmyOverviewOpenRunInput) => void } ): ArmyOverviewViewHandle { let disposed = false; let state: ArmyOverviewViewState = { mode: "loading" }; @@ -43,9 +52,14 @@ export function mountArmyOverviewView( } const zh = input.locale === "zh-CN"; const refreshing = state.mode === "loading"; + // R17 G3(#32):头部标注「数据加载于 N 分钟前」——总览是跨项目聚合、无天然 SSE 刷新,让用户知道手里 + // 这份是多久前的快照(配合既有的手动刷新按钮)。只在有数据时标注。 + const ageLabel = state.mode === "ready" ? armyDataAgeLabel(state.vm.generated_at, Date.now(), zh) : ""; + const ageHtml = ageLabel ? `${escapeHtml(ageLabel)}` : ""; container.innerHTML = `
    ${zh ? "军团总览" : "Army overview"}
    + ${ageHtml}
    ${renderArmyOverviewHtml(state, input.locale)}
    @@ -116,6 +130,17 @@ export function mountArmyOverviewView( } if (el.closest("[data-wb-army-ov-load-more]")) { loadMore(); + return; + } + // R17 G3(#21):卡片下钻——把 project/run/conversation id 抛给 shell(selectProject + 右栏定位该 run 详情)。 + const drilldown = el.closest("[data-wb-army-ov-drilldown]"); + if (drilldown) { + const projectId = drilldown.dataset.wbArmyProjectId; + const runId = drilldown.dataset.wbArmyRunId; + const conversationId = drilldown.dataset.wbArmyConversationId; + if (projectId && runId) { + input.onOpenRun?.({ projectId, runId, ...(conversationId ? { conversationId } : {}) }); + } } }); diff --git a/apps/desktop-webview/src/workbench/army/panel.ts b/apps/desktop-webview/src/workbench/army/panel.ts index 96348601..4c2a4023 100644 --- a/apps/desktop-webview/src/workbench/army/panel.ts +++ b/apps/desktop-webview/src/workbench/army/panel.ts @@ -16,16 +16,23 @@ import type { ArmyRunCardVM, ConversationArmyPanelVM } from "@workhub/contracts" import { parseIncomingActionCardUpdated } from "../chat/events.js"; import { membersById } from "../chat/render.js"; import type { WorkbenchStore } from "../store.js"; -import { fetchAgentRunTrace, fetchConversationArmyPanel } from "./api.js"; -import { mergeArmyRunPages, renderArmyPanelHtml, type ArmyPanelViewState } from "./render.js"; +import { abortAgentRun, fetchAgentRunTrace, fetchArmyBackground, fetchConversationArmyPanel } from "./api.js"; +import { isIncomingAgentRunLifecycleEvent } from "./events.js"; +import { + mergeArmyRunPages, + renderArmyPanelHtml, + type ArmyBackgroundViewState, + type ArmyPanelViewState +} from "./render.js"; type Locale = "zh-CN" | "en-US"; -export type ArmyContextPanelApiClient = Pick; +export type ArmyContextPanelApiClient = Pick; export type ArmyContextPanelHandle = { // rail/chat 树叶点击、深链落地——用户主动把焦点定到某个会话上。总是强制刷新+发布。 - showForConversation: (input: { projectId: string; conversationId: string }) => void; + // R17 G3(#21):openRunId 可选——从军团总览下钻进来时携带,会话面板加载完后自动打开该 run 的详情。 + showForConversation: (input: { projectId: string; conversationId: string; openRunId?: string }) => void; // chat/view.ts 的 onConversationEvent 转发口——只有匹配当前目标会话的 conversation.action_card.updated // 才会触发一次后台静默重拉(不打断用户正在看的文件预览,见顶部注释)。 handleRawConversationEvent: (raw: unknown) => void; @@ -66,6 +73,8 @@ export function mountArmyContextPanel( // R14 批 APPROVE-CHAT:军团输出行点击 → 把 proposal_id 往外抛给 shell(军团面板不认识提议详情, // 由 shell 转交给 proposalPanel.showForProposal)。可选:测试/未来消费者不接就是纯本地渲染。 onOpenProposal?: (proposalId: string) => void; + // R17 G3(#20):escalated run 卡/详情「去处理」→ 打开决策收件箱(I1 的 openInbox 通道)。可选。 + onHandleEscalation?: () => void; } ): ArmyContextPanelHandle { let disposed = false; @@ -73,6 +82,12 @@ export function mountArmyContextPanel( let state: ArmyPanelViewState | undefined; let loadGeneration = 0; let savedListScrollTop: number | undefined; + // R17 G3(#8):后台任务区(定时任务 + 主动性动态)——全局机器状态,与具体会话无关,懒加载一次并缓存, + // 跨会话切换复用;只有手动/被动刷新时重拉。undefined = 还没拉过。 + let backgroundState: ArmyBackgroundViewState | undefined; + let backgroundGeneration = 0; + // R17 G3(#21):从总览下钻带来的「加载完成后要打开的 run 详情 id」——用后即清(consumeOpenRunId)。 + let pendingOpenRunId: string | undefined; function resolveMembers(): ReadonlyMap { const vm = store.getState().vm; @@ -92,10 +107,57 @@ export function mountArmyContextPanel( // / 正在看的变动文件列表挤掉(drive/proposal/files 都是用户主动点开的,优先级高于军团的被动后台刷新)。 return; } - const html = renderArmyPanelHtml(state, input.locale, resolveMembers()); + const html = renderArmyPanelHtml(state, input.locale, resolveMembers(), backgroundState); store.setState({ sidePanelContent: { ownerId: ARMY_OWNER_ID, html } }); } + function backgroundErrorMessage(): string { + return input.locale === "zh-CN" ? "后台任务没拉到" : "Couldn't load background tasks"; + } + + // R17 G3(#8):拉后台任务区数据(懒加载 + 手动/被动刷新重拉)。best-effort:失败落 error 态(诚实标注), + // 不影响会话面板其它区。已经在拉时(loading)不叠拉;force=false 时若已有 ready 快照则跳过(懒加载语义)。 + function loadBackground(opts: { force?: boolean } = {}): void { + if (disposed) { + return; + } + if (!opts.force && (backgroundState?.status === "ready" || backgroundState?.status === "loading")) { + return; + } + const generation = ++backgroundGeneration; + backgroundState = { status: "loading" }; + publish({ background: true }); + void fetchArmyBackground(input.client) + .then((vm) => { + if (disposed || generation !== backgroundGeneration) { + return; + } + backgroundState = { status: "ready", vm }; + publish({ background: true }); + }) + .catch((error: unknown) => { + if (disposed || generation !== backgroundGeneration) { + return; + } + backgroundState = { status: "error", message: errorMessage(error, input.locale) || backgroundErrorMessage() }; + publish({ background: true }); + }); + } + + // R17 G3(#21):会话面板加载完(list 就绪)后,若还挂着一个从总览下钻带来的 openRunId 且能在当前 + // 列表里找到这条 run,就自动打开它的详情。找不到(run 在更后的分页/已消失)就诚实停在列表,不假装。 + function maybeOpenPendingRunDetail(): void { + if (!pendingOpenRunId || state?.mode !== "list") { + return; + } + const runId = pendingOpenRunId; + pendingOpenRunId = undefined; + const run = state.vm.runs.runs.find((candidate) => candidate.id === runId); + if (run) { + state = { mode: "detail", vm: state.vm, run, trace: { status: "idle" }, abort: { status: "idle" } }; + } + } + function loadPanel(next: { projectId: string; conversationId: string }, opts: { background?: boolean } = {}): void { const generation = ++loadGeneration; if (!opts.background) { @@ -108,6 +170,7 @@ export function mountArmyContextPanel( return; } state = { mode: "list", vm, loadingMore: false }; + maybeOpenPendingRunDetail(); publish(opts); }) .catch((error: unknown) => { @@ -123,9 +186,12 @@ export function mountArmyContextPanel( }); } - function showForConversation(next: { projectId: string; conversationId: string }): void { - target = next; - loadPanel(next); + function showForConversation(next: { projectId: string; conversationId: string; openRunId?: string }): void { + target = { projectId: next.projectId, conversationId: next.conversationId }; + pendingOpenRunId = next.openRunId; + loadPanel(target); + // R17 G3(#8):切到某个会话情境时顺带懒加载一次后台任务区(全局数据,只在没拉过时拉)。 + loadBackground(); } function refreshInBackground(): void { @@ -139,9 +205,16 @@ export function mountArmyContextPanel( if (!target) { return; } + // 既有触发器:人工决策落定(action_card.updated)时后台重拉。 const update = parseIncomingActionCardUpdated(raw, target.conversationId); if (update) { refreshInBackground(); + return; + } + // R17 G3(#7):run 状态级生命周期事件(started/failed/escalated/成功终态)双发到了本会话流——纯执行 + // 推进也能触发一次后台重拉,run 卡状态不再停在旧快照。会话流只会送到本会话的事件,无需再比 id。 + if (isIncomingAgentRunLifecycleEvent(raw)) { + refreshInBackground(); } } @@ -180,12 +253,14 @@ export function mountArmyContextPanel( }); } - function openRunDetail(run: ArmyRunCardVM): void { + function openRunDetail(run: ArmyRunCardVM, opts: { confirmAbort?: boolean } = {}): void { if (state?.mode !== "list") { return; } savedListScrollTop = sideBodyEl.scrollTop; - state = { mode: "detail", vm: state.vm, run, trace: { status: "idle" } }; + // R17 G3(#19):从卡片「取消」按钮进来时直接落在取消确认态;从卡主体点进来是普通 idle。 + const abort = opts.confirmAbort ? { status: "confirming" as const } : { status: "idle" as const }; + state = { mode: "detail", vm: state.vm, run, trace: { status: "idle" }, abort }; publish(); } @@ -199,6 +274,57 @@ export function mountArmyContextPanel( sideBodyEl.scrollTop = savedListScrollTop; savedListScrollTop = undefined; } + // R17 G3(#33):从详情返回列表时后台重拉——停留在详情期间(可能几十秒看回放)run 状态/新 run 都可能 + // 变过,缓存的列表快照会过期。取窄:回列表即触发一次后台刷新(失败静默保留旧快照,见 loadPanel)。 + refreshInBackground(); + } + + // R17 G3(#19):取消确认层的状态切换(只在 detail 模式、run 处于可取消态时有意义)。 + function enterAbortConfirm(): void { + if (state?.mode !== "detail") { + return; + } + state = { ...state, abort: { status: "confirming" } }; + publish(); + } + + function dismissAbort(): void { + if (state?.mode !== "detail") { + return; + } + state = { ...state, abort: { status: "idle" } }; + publish(); + } + + function confirmAbort(): void { + if (state?.mode !== "detail" || state.abort.status === "aborting") { + return; + } + const run = state.run; + const generation = ++loadGeneration; + state = { ...state, abort: { status: "aborting" } }; + publish(); + void abortAgentRun(input.client, run.id) + .then((live) => { + if (disposed || generation !== loadGeneration || state?.mode !== "detail") { + return; + } + // 状态回流:把落定后的 run 状态并回详情卡(通常翻成 cancelled),取消控件随之消失。用户仍停在 + // 详情看结果,不主动跳走;列表卡的翻新交给 #33(返回列表时重拉),避免后台重拉把用户从详情弹回列表。 + state = { + ...state, + run: { ...state.run, status: live.status }, + abort: { status: "idle" } + }; + publish(); + }) + .catch((error: unknown) => { + if (disposed || generation !== loadGeneration || state?.mode !== "detail") { + return; + } + state = { ...state, abort: { status: "error", message: errorMessage(error, input.locale) } }; + publish(); + }); } function openReplay(): void { @@ -249,6 +375,32 @@ export function mountArmyContextPanel( input.onOpenProposal?.(proposalBtn.dataset.wbArmyOpenProposal); return; } + // R17 G3(#20):escalated run 卡/详情「去处理」→ 打开决策收件箱(往外抛给 shell 的 openInbox)。 + if (el.closest("[data-wb-army-handle-escalation]")) { + input.onHandleEscalation?.(); + return; + } + // R17 G3(#19):取消(abort)——卡片按钮打开详情并进确认态;详情里的确认层三个动作各自切态/执行。 + const abortCardBtn = el.closest("[data-wb-army-abort-run]"); + if (abortCardBtn?.dataset.wbArmyAbortRun && state?.mode === "list") { + const run = state.vm.runs.runs.find((r) => r.id === abortCardBtn.dataset.wbArmyAbortRun); + if (run) { + openRunDetail(run, { confirmAbort: true }); + } + return; + } + if (el.closest("[data-wb-army-abort-open]")) { + enterAbortConfirm(); + return; + } + if (el.closest("[data-wb-army-abort-confirm]")) { + confirmAbort(); + return; + } + if (el.closest("[data-wb-army-abort-dismiss]")) { + dismissAbort(); + return; + } const runBtn = el.closest("[data-wb-army-open-run]"); if (runBtn?.dataset.wbArmyOpenRun && state?.mode === "list") { const run = state.vm.runs.runs.find((r) => r.id === runBtn.dataset.wbArmyOpenRun); @@ -276,10 +428,18 @@ export function mountArmyContextPanel( publish(); } + // 显式刷新(shell 在提议落定/审批动作后调)——会话面板 + 后台任务区一并翻新(后台强制重拉,取到最新 + // pulse tick / 主动性动态)。SSE 被动触发(handleRawConversationEvent)只刷会话面板、不重拉后台,避免 + // 高频事件把后台端点打爆。 + function refreshAll(): void { + refreshInBackground(); + loadBackground({ force: true }); + } + return { showForConversation, handleRawConversationEvent, - refresh: refreshInBackground, + refresh: refreshAll, reshow, clear, dispose: () => { diff --git a/apps/desktop-webview/src/workbench/army/render.test.ts b/apps/desktop-webview/src/workbench/army/render.test.ts index 2154dca9..9c2b8ab6 100644 --- a/apps/desktop-webview/src/workbench/army/render.test.ts +++ b/apps/desktop-webview/src/workbench/army/render.test.ts @@ -12,6 +12,7 @@ import type { import type { ChatRenderMembers } from "../chat/render.js"; import { + armyDataAgeLabel, collectArmyChangedFiles, groupArmyOverviewRunsByProject, mergeArmyRunPages, @@ -93,7 +94,7 @@ function outputWithFiles( const noMembers: ChatRenderMembers = new Map(); test("renderArmyRunCardHtml renders an interactive button carrying the run id when interactive", () => { - const html = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interactive: true }); + const html = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); assert.match(html, /]*data-wb-army-open-run="run-1"[^>]*>/u); assert.match(html, /阿墨/u); assert.match(html, /云端/u); @@ -101,53 +102,53 @@ test("renderArmyRunCardHtml renders an interactive button carrying the run id wh }); test("renderArmyRunCardHtml renders a non-interactive, non-clickable div when interactive is false (04 rule 3: no fake wiring)", () => { - const html = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interactive: false }); + const html = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interaction: { mode: "none" } }); assert.doesNotMatch(html, /data-wb-army-open-run/u); assert.match(html, /
    /u); }); test("renderArmyRunCardHtml shows a project badge only when showProject is true and the run carries a project_name", () => { - const withBadge = renderArmyRunCardHtml(overviewRun(), "zh-CN", { showProject: true, interactive: false }); + const withBadge = renderArmyRunCardHtml(overviewRun(), "zh-CN", { showProject: true, interaction: { mode: "none" } }); assert.match(withBadge, /星尘短剧/u); - const withoutBadge = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: true, interactive: true }); + const withoutBadge = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: true, interaction: { mode: "open-run" } }); assert.doesNotMatch(withoutBadge, /wh-wb-army-rc-project/u); }); test("renderArmyRunCardHtml distinguishes null cost (no billing yet) from a confirmed zero cost", () => { - const noCost = renderArmyRunCardHtml(baseRun({ cost_cny: null }), "zh-CN", { showProject: false, interactive: true }); + const noCost = renderArmyRunCardHtml(baseRun({ cost_cny: null }), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); assert.match(noCost, /还没有花费/u); - const zeroCost = renderArmyRunCardHtml(baseRun({ cost_cny: "0" }), "zh-CN", { showProject: false, interactive: true }); + const zeroCost = renderArmyRunCardHtml(baseRun({ cost_cny: "0" }), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); assert.match(zeroCost, /¥0(?!\.021)/u); }); test("renderArmyRunCardHtml labels execution_hint as cloud vs local", () => { - const serverHtml = renderArmyRunCardHtml(baseRun({ execution_hint: "server" }), "zh-CN", { showProject: false, interactive: true }); + const serverHtml = renderArmyRunCardHtml(baseRun({ execution_hint: "server" }), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); assert.match(serverHtml, /云端/u); - const localHtml = renderArmyRunCardHtml(baseRun({ execution_hint: "local" }), "zh-CN", { showProject: false, interactive: true }); + const localHtml = renderArmyRunCardHtml(baseRun({ execution_hint: "local" }), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); assert.match(localHtml, /本机/u); }); test("renderArmyRunCardHtml surfaces the assignee nickname only when one was resolved", () => { - const withAssignee = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interactive: true, assigneeNickname: "阿曼" }); + const withAssignee = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interaction: { mode: "open-run" }, assigneeNickname: "阿曼" }); assert.match(withAssignee, /执行身份/u); assert.match(withAssignee, /阿曼/u); - const withoutAssignee = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interactive: true }); + const withoutAssignee = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); assert.doesNotMatch(withoutAssignee, /wh-wb-army-rc-assignee/u); }); test("renderArmyRunCardHtml labels provenance honestly from the fields it actually has (no invented action-card titles)", () => { - const fromActionCard = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interactive: true }); + const fromActionCard = renderArmyRunCardHtml(baseRun(), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); assert.match(fromActionCard, /来自本会话行动卡/u); const fromConversationOnly = renderArmyRunCardHtml( baseRun({ source_action_card_item_id: null }), "zh-CN", - { showProject: false, interactive: true } + { showProject: false, interaction: { mode: "open-run" } } ); assert.match(fromConversationOnly, /来自本会话对话/u); const systemDispatched = renderArmyRunCardHtml( baseRun({ source_action_card_item_id: null, source_conversation_id: null }), "zh-CN", - { showProject: false, interactive: true } + { showProject: false, interaction: { mode: "open-run" } } ); assert.match(systemDispatched, /系统派发/u); }); @@ -165,14 +166,59 @@ test("renderArmyPanelHtml renders outputs and runs in list mode", () => { assert.match(html, /阿墨/u); }); -// G-desktop 止血批 2:后台任务区契约锁死 not_yet_available——items 永远是空数组,这是一块永远不会有 -// 内容的固定区块,不是偶尔为空的正常态。之前这里渲染一条永远显示的「即将上线」空态,现在整块不渲染 -// (renderArmyBackgroundTasksSectionHtml 这个函数还在,只是没人调用它了,等真数据源接上再放开)。 -test("renderArmyPanelHtml no longer renders the background-tasks section at all (it's a permanently empty, contract-locked block)", () => { +// R17 G3(#8 拍板 B):后台任务区接真数据源后恢复渲染。没传 background(还没拉到)时诚实显示「正在拉」; +// ready 时渲两块(定时任务 + 主动性动态)。此前那条「永远不渲染」的止血断言随本批接活作废。 +test("renderArmyPanelHtml shows a loading note for the background section when background data hasn't arrived yet", () => { const state: ArmyPanelViewState = { mode: "list", vm: panelVm(), loadingMore: false }; const html = renderArmyPanelHtml(state, "zh-CN", noMembers); - assert.doesNotMatch(html, /后台任务/u); - assert.doesNotMatch(html, /Background tasks/u); + assert.match(html, /后台任务/u); + assert.match(html, /正在拉后台任务/u); +}); + +test("renderArmyPanelHtml renders the scheduled-tasks and proactivity blocks from real background data", () => { + const state: ArmyPanelViewState = { mode: "list", vm: panelVm(), loadingMore: false }; + const html = renderArmyPanelHtml(state, "zh-CN", noMembers, { + status: "ready", + vm: { + generated_at: "2026-07-13T00:00:00.000000Z", + scheduler: { + enabled: true, + tasks: [ + { name: "approval-sla", interval_ms: 60_000, running: false, tick_count: 7, skipped_count: 0, error_count: 0, last_tick_at: "2026-07-13T00:00:00.000000Z" }, + { name: "care-scan", interval_ms: 3_600_000, running: false, tick_count: 0, skipped_count: 0, error_count: 2, last_tick_at: null } + ] + }, + proactive: { + items: [ + { id: "pi-1", kind: "care", stage: "high_load", status: "delivered", delivered_via: "conversation_message", created_at: "2026-07-13T00:00:00.000000Z" }, + { id: "pi-2", kind: "ddl_chase", stage: "overdue", status: "suppressed", delivered_via: null, created_at: "2026-07-12T23:00:00.000000Z" } + ], + capped: false + } + } + }); + assert.match(html, /定时任务/u); + assert.match(html, /审批超时巡检/u); + assert.match(html, /主动关怀扫描/u); + assert.match(html, /还没跑过/u); // care-scan last_tick_at null + assert.match(html, /主动性动态/u); + assert.match(html, /主动关怀/u); + assert.match(html, /已送达/u); + assert.match(html, /已克制/u); // suppressed +}); + +test("renderArmyPanelHtml background section honestly reports a disabled scheduler instead of faking activity", () => { + const state: ArmyPanelViewState = { mode: "list", vm: panelVm(), loadingMore: false }; + const html = renderArmyPanelHtml(state, "zh-CN", noMembers, { + status: "ready", + vm: { + generated_at: "2026-07-13T00:00:00.000000Z", + scheduler: { enabled: false, tasks: [] }, + proactive: { items: [], capped: false } + } + }); + assert.match(html, /后台调度器当前未启用/u); + assert.match(html, /最近没有主动性动态/u); }); // R14 批 APPROVE-CHAT:输出行从「
    折叠 + 深链死文本(跳转后续批次开放)」翻成真接线的可点按钮—— @@ -305,7 +351,8 @@ test("renderArmyPanelHtml detail mode enlarges the recent step and offers a real mode: "detail", vm: panelVm(), run: baseRun(), - trace: { status: "idle" } + trace: { status: "idle" }, + abort: { status: "idle" } }; const html = renderArmyPanelHtml(state, "zh-CN", noMembers); assert.match(html, /data-wb-army-back/u); @@ -325,7 +372,8 @@ test("renderArmyPanelHtml detail mode renders the full trace timeline once the r mode: "detail", vm: panelVm(), run: baseRun(), - trace: { status: "ready", data: trace } + trace: { status: "ready", data: trace }, + abort: { status: "idle" } }; const html = renderArmyPanelHtml(state, "zh-CN", noMembers); assert.match(html, /时间线/u); @@ -364,21 +412,43 @@ test("groupArmyOverviewRunsByProject groups by project_id, preserving first-seen assert.deepEqual(groups[1]!.runs.map((r) => r.id), ["r2"]); }); -test("renderArmyOverviewHtml renders a group heading per project and non-interactive cards (overview has no drill-in yet)", () => { +// R17 G3(#21):总览卡片现在可下钻——每张卡携 project/run/conversation id 的 drilldown 数据属性 +// (不是会话面板的 open-run,是跨项目下钻)。 +test("renderArmyOverviewHtml renders a group heading per project and drilldown cards carrying project/run/conversation ids", () => { const state: ArmyOverviewViewState = { mode: "ready", vm: { generated_at: "2026-07-13T00:00:00.000000Z", viewer_user_id: "user-1", - runs: { runs: [overviewRun()], capped: false, next_cursor: null } + runs: { runs: [overviewRun({ id: "run-9", project_id: "proj-9", source_conversation_id: "conv-9" })], capped: false, next_cursor: null } }, loadingMore: false }; const html = renderArmyOverviewHtml(state, "zh-CN"); assert.match(html, /星尘短剧/u); + assert.match(html, /data-wb-army-ov-drilldown/u); + assert.match(html, /data-wb-army-run-id="run-9"/u); + assert.match(html, /data-wb-army-project-id="proj-9"/u); + assert.match(html, /data-wb-army-conversation-id="conv-9"/u); + // 下钻不是会话面板的 open-run。 assert.doesNotMatch(html, /data-wb-army-open-run/u); }); +test("renderArmyOverviewHtml omits the conversation-id attribute for a system-dispatched run with no source conversation", () => { + const state: ArmyOverviewViewState = { + mode: "ready", + vm: { + generated_at: "2026-07-13T00:00:00.000000Z", + viewer_user_id: "user-1", + runs: { runs: [overviewRun({ source_conversation_id: null, source_action_card_item_id: null })], capped: false, next_cursor: null } + }, + loadingMore: false + }; + const html = renderArmyOverviewHtml(state, "zh-CN"); + assert.match(html, /data-wb-army-ov-drilldown/u); + assert.doesNotMatch(html, /data-wb-army-conversation-id/u); +}); + test("renderArmyOverviewHtml shows the honest empty state when the viewer has no army runs across any project", () => { const state: ArmyOverviewViewState = { mode: "ready", @@ -401,3 +471,55 @@ test("renderArmyOverviewHtml renders a load-more control exactly when capped", ( }; assert.match(renderArmyOverviewHtml(cappedState, "zh-CN"), /data-wb-army-ov-load-more/u); }); + +// —— R17 G3 新增覆盖 —— // + +test("renderArmyRunCardHtml gives an escalated run its own danger 'waiting on you' badge, not the generic wait bucket", () => { + const html = renderArmyRunCardHtml(baseRun({ status: "escalated" }), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); + assert.match(html, /wh-wb-army-rc-status--escalated/u); + assert.match(html, /等你拍板/u); + // 不再和 queued 同归 wait 桶。 + assert.doesNotMatch(html, /wh-wb-army-rc-status--wait/u); +}); + +test("renderArmyRunCardHtml open-run card offers a cancel entry for a running run and a 'handle' entry for an escalated one (no button nesting)", () => { + const running = renderArmyRunCardHtml(baseRun({ status: "running" }), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); + assert.match(running, /data-wb-army-abort-run="run-1"/u); + // 主体是内层按钮、动作在外层同级 foot——open-run 卡外层是 div 不是 button。 + assert.match(running, /^
    { + const done = renderArmyRunCardHtml(baseRun({ status: "succeeded" }), "zh-CN", { showProject: false, interaction: { mode: "open-run" } }); + assert.doesNotMatch(done, /data-wb-army-abort-run/u); + assert.doesNotMatch(done, /data-wb-army-handle-escalation/u); +}); + +test("renderArmyPanelHtml detail mode offers a cancel control for a running run and steps through the confirm/aborting states", () => { + const idle = renderArmyPanelHtml({ mode: "detail", vm: panelVm(), run: baseRun({ status: "running" }), trace: { status: "idle" }, abort: { status: "idle" } }, "zh-CN", noMembers); + assert.match(idle, /data-wb-army-abort-open/u); + const confirming = renderArmyPanelHtml({ mode: "detail", vm: panelVm(), run: baseRun({ status: "running" }), trace: { status: "idle" }, abort: { status: "confirming" } }, "zh-CN", noMembers); + assert.match(confirming, /确定取消这次执行/u); + assert.match(confirming, /data-wb-army-abort-confirm/u); + assert.match(confirming, /data-wb-army-abort-dismiss/u); + const aborting = renderArmyPanelHtml({ mode: "detail", vm: panelVm(), run: baseRun({ status: "running" }), trace: { status: "idle" }, abort: { status: "aborting" } }, "zh-CN", noMembers); + assert.match(aborting, /正在取消/u); +}); + +test("renderArmyPanelHtml detail mode offers 'handle' (not cancel) for an escalated run", () => { + const html = renderArmyPanelHtml({ mode: "detail", vm: panelVm(), run: baseRun({ status: "escalated" }), trace: { status: "idle" }, abort: { status: "idle" } }, "zh-CN", noMembers); + assert.match(html, /data-wb-army-handle-escalation/u); + assert.match(html, /等你拍板/u); + assert.doesNotMatch(html, /data-wb-army-abort-open/u); +}); + +test("armyDataAgeLabel renders honest minute/hour ages and a 'just now' floor", () => { + const base = Date.parse("2026-07-13T00:00:00.000000Z"); + assert.match(armyDataAgeLabel("2026-07-13T00:00:00.000000Z", base + 30 * 1000, true), /刚刚/u); + assert.match(armyDataAgeLabel("2026-07-13T00:00:00.000000Z", base + 3 * 60 * 1000, true), /3 分钟前/u); + assert.match(armyDataAgeLabel("2026-07-13T00:00:00.000000Z", base + 2 * 60 * 60 * 1000, true), /2 小时前/u); + assert.equal(armyDataAgeLabel("not-a-date", base, true), ""); +}); diff --git a/apps/desktop-webview/src/workbench/army/render.ts b/apps/desktop-webview/src/workbench/army/render.ts index 2745af9d..7cdf26b0 100644 --- a/apps/desktop-webview/src/workbench/army/render.ts +++ b/apps/desktop-webview/src/workbench/army/render.ts @@ -7,7 +7,7 @@ import type { AgentRunLiveVM, - ArmyBackgroundTasksVM, + ArmyBackgroundPageVM, ArmyChangedFileVM, ArmyOutputsVM, ArmyOverviewPageVM, @@ -42,11 +42,26 @@ export type ArmyRunTraceState = | { status: "ready"; data: AgentRunLiveVM } | { status: "error"; message: string }; +// R17 G3(#19):run 详情里「取消(abort)」的多步态——idle(还没点)→confirming(确认弹层)→aborting(忙态)→ +// error(失败可重试)。成功后 run 直接落 cancelled 终态,abort 控件随之消失(见 renderArmyRunActionsHtml)。 +export type ArmyRunAbortState = + | { status: "idle" } + | { status: "confirming" } + | { status: "aborting" } + | { status: "error"; message: string }; + +// R17 G3(#8):后台任务区(定时任务 + 主动性动态)——独立于会话 run 列表的全局机器状态,懒加载一次并缓存 +// (见 panel.ts backgroundState)。渲染在会话面板 list 模式的最底部。 +export type ArmyBackgroundViewState = + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; vm: ArmyBackgroundPageVM }; + export type ArmyPanelViewState = | { mode: "loading" } | { mode: "error"; message: string } | { mode: "list"; vm: ConversationArmyPanelVM; loadingMore: boolean; loadMoreError?: string } - | { mode: "detail"; vm: ConversationArmyPanelVM; run: ArmyRunCardVM; trace: ArmyRunTraceState }; + | { mode: "detail"; vm: ConversationArmyPanelVM; run: ArmyRunCardVM; trace: ArmyRunTraceState; abort: ArmyRunAbortState }; function executionHintLabel(hint: string, zh: boolean): string { if (hint === "server") { @@ -59,14 +74,17 @@ function executionHintLabel(hint: string, zh: boolean): string { } // 00 §9/契约不变量:succeeded/cancelled 都是"已经不再动了"的终态,视觉上归一为中性(done); -// failed 是唯一的危险态;queued/escalated 是"等待"态(escalated 等的是人拍板,不是系统故障)。 -function armyRunStatusVariant(status: string): "run" | "wait" | "done" | "fail" { +// failed 是唯一的系统危险态;queued 是正常排队(wait)。 +// R17 G3(#20):escalated 从 wait 桶拆出独立徽标——它等的是【人】拍板(不是系统故障也不是普通排队), +// 视觉上要一眼看出「这条在等你」,故给它独立的危色变体,不再和 queued 同归 wait。 +function armyRunStatusVariant(status: string): "run" | "wait" | "done" | "fail" | "escalated" { switch (status) { case "running": return "run"; case "queued": - case "escalated": return "wait"; + case "escalated": + return "escalated"; case "failed": return "fail"; default: @@ -74,6 +92,31 @@ function armyRunStatusVariant(status: string): "run" | "wait" | "done" | "fail" } } +// R17 G3(#20):状态徽标——escalated 单独渲成「等你拍板」(危色),其余用通用状态标签。 +function armyRunStatusBadgeHtml(status: string, zh: boolean): string { + const variant = armyRunStatusVariant(status); + const text = status === "escalated" + ? (zh ? "等你拍板" : "Waiting on you") + : agentRunStatusLabel(status, zh); + return `${escapeHtml(text)}`; +} + +// R17 G3(#19/#20):卡片底部内联动作—— +// * escalated → 「去处理」(打开决策收件箱,data-wb-army-handle-escalation)。 +// * queued/running → 「取消」(打开该 run 详情并进入取消确认,data-wb-army-abort-run)。取消是有状态的 +// 多步动作(确认→忙态→回流),实际确认流在 run 详情里做(见 renderArmyRunAbortControlHtml)——卡上这个 +// 按钮是入口,点了就把详情打开到确认态,避免在紧凑的卡片里塞一整套确认 UI。 +// * 其它终态无内联动作。 +function armyRunCardActionsHtml(run: ArmyRunCardLike, zh: boolean): string { + if (run.status === "escalated") { + return ``; + } + if (run.status === "queued" || run.status === "running") { + return ``; + } + return ""; +} + function armySourceLabelHtml(run: { source_conversation_id: string | null; source_action_card_item_id: string | null }, zh: boolean): string { const text = run.source_action_card_item_id ? zh @@ -95,10 +138,21 @@ function armyRunCostLabel(costCny: string | null, zh: boolean): string { return costCny === null ? (zh ? "还没有花费" : "No cost yet") : `¥${escapeHtml(costCny)}`; } +// R17 G3(#19/#20/#21):卡片的三种交互形态—— +// * open-run —— 会话情境面板:点主体打开右栏 run 详情;底部内联「取消/去处理」动作(见 armyRunCardActionsHtml)。 +// 主体是内层
    ` : ""; - const body = `
    + const bodyInner = `
    ${workbenchIcons.cat} ${escapeHtml(run.cat_codename)} ${projectBadge} ${escapeHtml(executionHintLabel(run.execution_hint, zh))} - ${escapeHtml(agentRunStatusLabel(run.status, zh))} + ${armyRunStatusBadgeHtml(run.status, zh)}
    ${escapeHtml(run.goal_summary)}
    ${assigneeLine}${armySourceLabelHtml(run, zh)}
    - ${stepLine} -
    ${armyRunCostLabel(run.cost_cny, zh)}
    `; - return opts.interactive - ? `` - : `
    ${body}
    `; + ${stepLine}`; + const costHtml = `${armyRunCostLabel(run.cost_cny, zh)}`; + + if (opts.interaction.mode === "open-run") { + const actions = armyRunCardActionsHtml(run, zh); + return `
    + +
    ${costHtml}${actions}
    +
    `; + } + if (opts.interaction.mode === "drilldown") { + const conversationAttr = run.source_conversation_id + ? ` data-wb-army-conversation-id="${escapeHtml(run.source_conversation_id)}"` + : ""; + return ``; + } + return `
    ${bodyInner} +
    ${costHtml}
    +
    `; } const PROPOSAL_STATUS_LABEL: Record = { @@ -272,7 +342,7 @@ function renderArmyRunsSectionHtml( renderArmyRunCardHtml(run, locale, { assigneeNickname: run.assignee_user_id ? members.get(run.assignee_user_id)?.nickname : undefined, showProject: opts.showProject, - interactive: true + interaction: { mode: "open-run" } }) ) .join(""); @@ -287,15 +357,122 @@ function renderArmyRunsSectionHtml( return `${header}
    ${cards}
    ${loadMore}${loadMoreErr}`; } -// 后台任务区:契约锁死 not_yet_available(00 §9/批5)——items 恒为空数组,永远没有真内容可看。之前这里 -// 照实渲染一条「即将上线」空态,但 G-desktop 止血批 2 发现这在实践里是一块永远存在、永远空的固定区块 -// (不是偶尔为空的正常态,是契约保证的恒空),跟composer 的死 chip 是同一类问题——不留一个只会说 -// 「即将上线」的区块占位置。renderArmyPanelListHtml 现在不再调用这个函数,整块区不渲染;函数本身连同 -// ArmyBackgroundTasksVM 类型都保留,等真的接上后台任务数据源(定时任务/后台巡检等)那天,把 -// renderArmyPanelListHtml 里的调用加回来即可,不用重新设计这块 UI。 -function renderArmyBackgroundTasksSectionHtml(_tasks: ArmyBackgroundTasksVM, zh: boolean): string { +// R17 G3(#8 拍板 B):后台任务区接真——两块真实数据(定时任务 pulse 统计 + 最近主动性动态)。此前这块 +// 契约锁死 not_yet_available 永远空、已停止渲染;现在 GET /api/army/background 提供真数据源,恢复渲染。 +// 定时任务:每条一行(名称 + 间隔 + 上次 tick + tick/错误计数),pulse 总开关未开时诚实标注「未启用」。 +// 主动性动态:Cuu 最近为「你」做的主动性(追 DDL/关怀/找人),delivered/suppressed 都展示,空态诚实。 + +function backgroundIntervalLabel(intervalMs: number, zh: boolean): string { + if (intervalMs <= 0) { + return zh ? "手动触发" : "Manual"; + } + const seconds = Math.round(intervalMs / 1000); + if (seconds < 60) { + return zh ? `每 ${seconds} 秒` : `every ${seconds}s`; + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return zh ? `每 ${minutes} 分钟` : `every ${minutes}m`; + } + const hours = Math.round(minutes / 60); + return zh ? `每 ${hours} 小时` : `every ${hours}h`; +} + +// 定时任务名 → 人话(与 pulse-scheduler.ts 注册的 name 一一对应)。未映射的名字诚实回退原串,不编造。 +const BACKGROUND_TASK_LABEL: Record = { + "approval-sla": ["审批超时巡检", "Approval SLA sweep"], + "notification-reminder": ["通知提醒阶梯", "Notification reminders"], + "approval-digest": ["待拍板汇总卡", "Approval digest"], + "ddl-chase": ["追截止日期", "Deadline chase"], + "care-scan": ["主动关怀扫描", "Care scan"] +}; + +function backgroundTaskLabel(name: string, zh: boolean): string { + const entry = BACKGROUND_TASK_LABEL[name]; + return entry ? (zh ? entry[0] : entry[1]) : name; +} + +// 主动性 kind → 人话(与 proactive-intents.ts 的 ProactiveIntentKind 对应)。 +const PROACTIVE_KIND_LABEL: Record = { + ddl_chase: ["追截止日期", "Deadline chase"], + find_owner: ["找负责人", "Find owner"], + care: ["主动关怀", "Care"] +}; + +function proactiveKindLabel(kind: string, zh: boolean): string { + const entry = PROACTIVE_KIND_LABEL[kind]; + return entry ? (zh ? entry[0] : entry[1]) : kind; +} + +function proactiveStatusLabel(status: "delivered" | "suppressed", zh: boolean): string { + return status === "delivered" ? (zh ? "已送达" : "Delivered") : zh ? "已克制" : "Held back"; +} + +function renderArmyBackgroundSchedulerHtml(scheduler: ArmyBackgroundPageVM["scheduler"], locale: Locale): string { + const zh = locale === "zh-CN"; + const header = `
    ${zh ? "定时任务" : "Scheduled tasks"}${scheduler.tasks.length}
    `; + if (!scheduler.enabled) { + return `${header}

    ${zh ? "后台调度器当前未启用。" : "The background scheduler is currently disabled."}

    `; + } + if (scheduler.tasks.length === 0) { + return `${header}

    ${zh ? "还没有登记任何定时任务。" : "No scheduled tasks registered yet."}

    `; + } + const rows = scheduler.tasks + .map((task) => { + const lastTick = task.last_tick_at + ? (zh ? `上次 ${formatMessageTime(task.last_tick_at, locale)}` : `last ${formatMessageTime(task.last_tick_at, locale)}`) + : (zh ? "还没跑过" : "never run"); + const counts = zh + ? `跑了 ${task.tick_count} 次${task.error_count > 0 ? ` · ${task.error_count} 次出错` : ""}` + : `${task.tick_count} ticks${task.error_count > 0 ? ` · ${task.error_count} errors` : ""}`; + return `
    +
    + ${escapeHtml(backgroundTaskLabel(task.name, zh))} + ${escapeHtml(backgroundIntervalLabel(task.interval_ms, zh))} +
    +
    ${escapeHtml(lastTick)} · ${escapeHtml(counts)}
    +
    `; + }) + .join(""); + return `${header}
    ${rows}
    `; +} + +function renderArmyBackgroundProactiveHtml(proactive: ArmyBackgroundPageVM["proactive"], locale: Locale): string { + const zh = locale === "zh-CN"; + const header = `
    ${zh ? "主动性动态" : "Proactivity"}${proactive.items.length}
    `; + if (proactive.items.length === 0) { + return `${header}

    ${zh ? "最近没有主动性动态。" : "No recent proactivity."}

    `; + } + const rows = proactive.items + .map((item) => { + const statusClass = item.status === "suppressed" ? " wh-wb-army-bg-status--muted" : ""; + const stage = item.stage ? ` · ${escapeHtml(item.stage)}` : ""; + return `
    +
    + ${escapeHtml(proactiveKindLabel(item.kind, zh))}${stage} + ${escapeHtml(proactiveStatusLabel(item.status, zh))} +
    +
    ${escapeHtml(formatMessageTime(item.created_at, locale))}
    +
    `; + }) + .join(""); + const cappedNote = proactive.capped + ? `

    ${zh ? "还有更多主动性动态没有在这里显示。" : "There is more proactivity than shown here."}

    ` + : ""; + return `${header}
    ${rows}
    ${cappedNote}`; +} + +// 后台任务区外壳——懒加载态。loading/error 诚实标注(不拿空态冒充);ready 时渲染两块。 +export function renderArmyBackgroundSectionHtml(background: ArmyBackgroundViewState | undefined, locale: Locale): string { + const zh = locale === "zh-CN"; const header = `
    ${zh ? "后台任务" : "Background tasks"}
    `; - return `${header}

    ${zh ? "后台任务还没有接入真实数据源,即将上线。" : "Background tasks aren't wired to a real data source yet — coming soon."}

    `; + if (!background || background.status === "loading") { + return `${header}

    ${zh ? "正在拉后台任务…" : "Loading background tasks…"}

    `; + } + if (background.status === "error") { + return `${header}

    ${escapeHtml(background.message)}

    `; + } + return `${renderArmyBackgroundSchedulerHtml(background.vm.scheduler, locale)}${renderArmyBackgroundProactiveHtml(background.vm.proactive, locale)}`; } export function renderArmyPanelLoadingHtml(locale: Locale): string { @@ -311,7 +488,8 @@ export function renderArmyPanelErrorHtml(message: string, locale: Locale): strin function renderArmyPanelListHtml( state: Extract, locale: Locale, - members: ChatRenderMembers + members: ChatRenderMembers, + background: ArmyBackgroundViewState | undefined ): string { const zh = locale === "zh-CN"; const outputsHtml = renderArmyOutputsSectionHtml(state.vm.outputs, zh); @@ -323,9 +501,9 @@ function renderArmyPanelListHtml( scopeLabel: zh ? `本会话 ${state.vm.runs.runs.length}` : `${state.vm.runs.runs.length} here`, loadMoreDataAttr: "data-wb-army-load-more" }); - // G-desktop 止血批 2:后台任务区永远空(契约锁死 not_yet_available),不再渲染这块永远说「即将上线」 - // 的固定区块——见 renderArmyBackgroundTasksSectionHtml 顶部注释,函数留着,真数据源接上再放开。 - return `
    ${outputsHtml}${changedFilesHtml}${runsHtml}
    `; + // R17 G3(#8):后台任务区接真数据源——恢复渲染(定时任务 + 主动性动态),懒加载态由 panel.ts 传入。 + const backgroundHtml = renderArmyBackgroundSectionHtml(background, locale); + return `
    ${outputsHtml}${changedFilesHtml}${runsHtml}${backgroundHtml}
    `; } function renderArmyReplaySectionHtml(trace: ArmyRunTraceState, locale: Locale): string { @@ -354,11 +532,49 @@ function renderArmyReplaySectionHtml(trace: ArmyRunTraceState, locale: Locale): return `
    ${zh ? "时间线" : "Timeline"}
    ${items}
    `; } +// R17 G3(#19/#20):run 详情的动作区——escalated 给「去处理」(→ 决策收件箱);queued/running 给带确认的 +// 「取消」(→ /agent-runs/:id/abort)。其它终态无动作。 +function renderArmyRunActionsHtml(run: ArmyRunCardVM, abort: ArmyRunAbortState, zh: boolean): string { + if (run.status === "escalated") { + return `
    + +
    `; + } + if (run.status !== "queued" && run.status !== "running") { + return ""; + } + if (abort.status === "confirming") { + return `
    +

    ${zh ? "确定取消这次执行?取消后它会立即停下。" : "Cancel this run? It will stop immediately."}

    +
    + + +
    +
    `; + } + if (abort.status === "aborting") { + return `
    ${zh ? "正在取消…" : "Cancelling…"}
    `; + } + if (abort.status === "error") { + return `
    +

    ${escapeHtml(abort.message)}

    +
    + + +
    +
    `; + } + return `
    + +
    `; +} + function renderArmyRunDetailHtml(state: Extract, locale: Locale): string { const zh = locale === "zh-CN"; - const { run, trace } = state; + const { run, trace, abort } = state; const chips = [ - agentRunStatusLabel(run.status, zh), + // R17 G3(#20):escalated 详情头也用「等你拍板」口径,与卡片徽标一致,不再裸显「已升级」。 + run.status === "escalated" ? (zh ? "等你拍板" : "Waiting on you") : agentRunStatusLabel(run.status, zh), executionHintLabel(run.execution_hint, zh), armyRunCostLabel(run.cost_cny, zh) ]; @@ -380,6 +596,7 @@ function renderArmyRunDetailHtml(state: Extract${escapeHtml(run.goal_summary)}
    ${chipsHtml}
    ${stepHtml} + ${renderArmyRunActionsHtml(run, abort, zh)} ${renderArmyReplaySectionHtml(trace, locale)}
    `; } @@ -391,7 +608,12 @@ export function renderArmySidePanelIdleHtml(locale: Locale): string { return `

    ${zh ? "选一个会话查看输出与军团。" : "Pick a conversation to see its outputs and army."}

    `; } -export function renderArmyPanelHtml(state: ArmyPanelViewState | undefined, locale: Locale, members: ChatRenderMembers): string { +export function renderArmyPanelHtml( + state: ArmyPanelViewState | undefined, + locale: Locale, + members: ChatRenderMembers, + background?: ArmyBackgroundViewState | undefined +): string { if (!state) { return renderArmySidePanelIdleHtml(locale); } @@ -401,7 +623,7 @@ export function renderArmyPanelHtml(state: ArmyPanelViewState | undefined, local case "error": return renderArmyPanelErrorHtml(state.message, locale); case "list": - return renderArmyPanelListHtml(state, locale, members); + return renderArmyPanelListHtml(state, locale, members, background); case "detail": return renderArmyRunDetailHtml(state, locale); default: @@ -439,6 +661,24 @@ export function groupArmyOverviewRunsByProject(runs: readonly ArmyOverviewRunCar return order.map((id) => byProject.get(id)!); } +// R17 G3(#32):总览是跨项目聚合、无天然 SSE 刷新——头部标注「数据加载于 N 分钟前」,让用户知道手里 +// 这份是多久前的快照(配合既有的手动刷新按钮)。纯函数,now 注入便于单测。 +export function armyDataAgeLabel(generatedAtIso: string, nowMs: number, zh: boolean): string { + const genMs = Date.parse(generatedAtIso); + if (!Number.isFinite(genMs)) { + return ""; + } + const diffMin = Math.max(0, Math.floor((nowMs - genMs) / 60000)); + if (diffMin < 1) { + return zh ? "数据刚刚加载" : "Loaded just now"; + } + if (diffMin < 60) { + return zh ? `数据加载于 ${diffMin} 分钟前` : `Loaded ${diffMin}m ago`; + } + const diffHr = Math.floor(diffMin / 60); + return zh ? `数据加载于 ${diffHr} 小时前` : `Loaded ${diffHr}h ago`; +} + export function renderArmyOverviewLoadingHtml(locale: Locale): string { const zh = locale === "zh-CN"; return `
    ${zh ? "正在拉军团总览…" : "Loading the army overview…"}
    `; @@ -475,7 +715,7 @@ export function renderArmyOverviewHtml(state: ArmyOverviewViewState, locale: Loc (group) => `
    ${escapeHtml(group.projectName)}${group.runs.length}
    ${group.runs - .map((run) => renderArmyRunCardHtml(run, locale, { showProject: false, interactive: false })) + .map((run) => renderArmyRunCardHtml(run, locale, { showProject: false, interaction: { mode: "drilldown", projectId: group.projectId } })) .join("")}
    ` ) diff --git a/apps/desktop-webview/src/workbench/css.ts b/apps/desktop-webview/src/workbench/css.ts index 598f236f..d5e19625 100644 --- a/apps/desktop-webview/src/workbench/css.ts +++ b/apps/desktop-webview/src/workbench/css.ts @@ -277,6 +277,35 @@ export const workbenchCss = [ ".wh-wb-army-tl-phase{font:700 10px/1 var(--ds-font);letter-spacing:.05em;text-transform:uppercase;color:var(--ds-ink-faint);margin-bottom:2px}", ".wh-wb-army-tl-out{color:var(--ds-ink)}", ".wh-wb-army-tl-tm{margin-left:6px;font:500 10.5px/1 var(--ds-font);color:var(--ds-ink-faint)}", + // R17 G3(#20):escalated 独立危色徽标 + 卡片描边——「等你拍板」一眼可辨,不再与 queued(wait) 同色。 + ".wh-wb-army-rc-status--escalated{color:var(--ds-danger);background:var(--ds-danger-soft)}", + ".wh-wb-army-rc--escalated{border-color:rgba(255,69,58,.34)}", + // R17 G3(#19/#20):交互卡拆成「主体内层按钮 + 底部动作行」——避免 button 套 button。主体按钮铺满、无边、 + // 透明、继承排版;卡片本身接管 padding/border/hover。 + ".wh-wb-army-rc-hit{display:block;width:100%;box-sizing:border-box;text-align:left;font:inherit;color:inherit;" + + "border:0;background:transparent;padding:0;cursor:pointer}", + ".wh-wb-army-rc-act{font:600 11px/1 var(--ds-font);color:var(--ds-ink-muted);background:var(--ds-glass);" + + "border:1px solid var(--ds-glass-border);border-radius:99px;padding:4px 11px;cursor:pointer;margin-left:auto}", + ".wh-wb-army-rc-act:hover{background:var(--ds-glass-strong)}", + ".wh-wb-army-rc-act--danger{color:var(--ds-danger);background:var(--ds-danger-soft);border-color:transparent}", + ".wh-wb-army-rc-act--danger:hover{background:var(--ds-danger-soft);border-color:rgba(255,69,58,.34)}", + // R17 G3(#19/#20):run 详情动作区(取消确认层 / 去处理)。 + ".wh-wb-army-rd-actions{margin:0 0 14px;display:flex;flex-direction:column;gap:8px}", + ".wh-wb-army-abort-note{margin:0;font:600 12px/1.5 var(--ds-font);color:var(--ds-ink)}", + ".wh-wb-army-abort-error{margin:0;font:500 12px/1.5 var(--ds-font);color:var(--ds-danger)}", + ".wh-wb-army-abort-btns{display:flex;gap:8px}", + // R17 G3(#8):后台任务区(定时任务 + 主动性动态)行样式。 + ".wh-wb-army-bg-list{display:flex;flex-direction:column;gap:7px;margin-bottom:8px}", + ".wh-wb-army-bg-row{border:1px solid var(--ds-glass-border);background:var(--ds-glass);border-radius:var(--ds-radius-md);padding:9px 11px}", + ".wh-wb-army-bg-row--warn{border-color:rgba(255,159,10,.34)}", + ".wh-wb-army-bg-main{display:flex;align-items:center;gap:8px;margin-bottom:3px}", + ".wh-wb-army-bg-name{font:700 11.5px/1.3 var(--ds-font);color:var(--ds-ink)}", + ".wh-wb-army-bg-int{font:600 10px/1 var(--ds-font);color:var(--ds-ink-muted);border:1px solid var(--ds-glass-border);padding:2px 7px;border-radius:99px}", + ".wh-wb-army-bg-status{margin-left:auto;font:700 10px/1 var(--ds-font);color:var(--ds-success);background:var(--ds-success-soft);padding:2px 8px;border-radius:99px}", + ".wh-wb-army-bg-status--muted{color:var(--ds-ink-muted);background:var(--ds-glass)}", + ".wh-wb-army-bg-meta{font:500 10.5px/1.4 var(--ds-font);color:var(--ds-ink-faint)}", + // R17 G3(#32):总览头部「数据加载于 N 分钟前」。 + ".wh-wb-army-ov-age{font:500 11px/1 var(--ds-font);color:var(--ds-ink-faint)}", // —— R13 批 P1:军团总览(跨项目卡片流,中栏 centerTab === "army-overview")—— // ".wh-wb-center.wh-wb-center--army-overview{padding:0;display:flex;flex-direction:column;overflow:hidden}", @@ -431,6 +460,10 @@ export const workbenchCss = [ ".wh-wb-btn:disabled{opacity:.5;cursor:default}", ".wh-wb-btn--primary{border:0;color:#fff;background:linear-gradient(135deg,#0a84ff,#64d2ff);box-shadow:var(--ds-shadow-glow);font-weight:700}", ".wh-wb-btn--ghost{color:var(--ds-ink-muted);background:transparent;border-color:transparent}", + // R17 G3(#19):危险动作按钮(取消 run / 确认取消)——危色描边填充,与 primary 的中性蓝区分开。 + ".wh-wb-btn--danger{color:var(--ds-danger);background:var(--ds-danger-soft);border-color:transparent}", + ".wh-wb-btn--danger:hover{background:var(--ds-danger)}", + "@media(prefers-color-scheme:light){.wh-wb-btn--danger:hover{color:#fff}}", // —— 批 2:主区群聊。中栏在渲染群聊时切成 flex 列布局,自己管内部滚动区(composer 常驻底部), // 不再吃 .wh-wb-center 默认的整体滚动+内边距。 —— // diff --git a/apps/desktop-webview/src/workbench/shell.ts b/apps/desktop-webview/src/workbench/shell.ts index d36e619e..efd1818e 100644 --- a/apps/desktop-webview/src/workbench/shell.ts +++ b/apps/desktop-webview/src/workbench/shell.ts @@ -269,6 +269,9 @@ export function mountWorkbenchShell( let scheduleHandle: ScheduleViewHandle | undefined; let scheduleMountKey: string | undefined; let armyOverviewHandle: ArmyOverviewViewHandle | undefined; + // R17 G3(#21):从军团总览下钻带来的「加载完该会话军团面板后要打开的 run 详情 id」——drilldown 时写入, + // 会话情境挂载时透传给 armyPanel.showForConversation 并即清(consumeArmyRunDetailId)。 + let pendingArmyRunDetailId: string | undefined; let projectSettingsHandle: ProjectSettingsViewHandle | undefined; let projectSettingsMountKey: string | undefined; // R15 批 I1(决策收件箱):中栏收件箱视图(跨项目,不依赖 selectedProjectId,同 army-overview)。 @@ -392,9 +395,18 @@ export function mountWorkbenchShell( client: input.client, locale: input.locale, // R14 批 APPROVE-CHAT:军团输出行点击 → 右栏打开提议详情(军团面板不认识提议详情,把 id 抛给这里)。 - onOpenProposal: (proposalId) => proposalPanel.showForProposal({ proposalId }) + onOpenProposal: (proposalId) => proposalPanel.showForProposal({ proposalId }), + // R17 G3(#20):escalated run 卡/详情「去处理」→ 决策收件箱(openInbox 是下方 const,闭包内延迟引用不触 TDZ)。 + onHandleEscalation: () => openInbox() }); + // R17 G3(#21):会话情境挂载时取一次下钻带来的 run 详情 id(用后即清,避免误用到下一次普通导航)。 + const consumeArmyRunDetailId = (): string | undefined => { + const id = pendingArmyRunDetailId; + pendingArmyRunDetailId = undefined; + return id; + }; + // R14 批 APPROVE-CHAT:情境面板的第四个 owner——提议详情控制器(M1 只读 + M2 通过/打回/合并)。同样挂载 // 一次、活过项目/会话切换,与 drive/army 共用同一个 store.sidePanelContent 插槽,靠 ownerId 互斥(见 // proposal/panel.ts 顶部注释)。onBack 交回军团面板;onSettled 把本机审批结果回流给当前 chat view(产出卡 @@ -864,7 +876,20 @@ export function mountWorkbenchShell( clearContextPanels(); centerEl.className = "wh-wb-center wh-wb-center--army-overview"; if (!armyOverviewHandle) { - armyOverviewHandle = mountArmyOverviewView(centerEl, { client: input.client, locale: input.locale }); + armyOverviewHandle = mountArmyOverviewView(centerEl, { + client: input.client, + locale: input.locale, + // R17 G3(#21):卡片下钻——selectProject(该项目);带血缘会话就顺带 stash run id,会话情境挂载时 + // armyPanel 加载完自动打开该 run 详情(consumeArmyRunDetailId)。无血缘会话的 run 只 selectProject。 + onOpenRun: ({ projectId, runId, conversationId }) => { + if (conversationId) { + pendingArmyRunDetailId = runId; + selectProject(projectId, conversationId); + } else { + selectProject(projectId); + } + } + }); } return; } @@ -1185,7 +1210,13 @@ export function mountWorkbenchShell( chatMountKey = key; // R13 批 P1:情境面板默认态挂军团三区——会话情境存在时(这里是刚挂上这个协同会话的 chat 视图) // 就该拉这个会话的军团面板,取代批 5 之前的通用占位文案。 - armyPanel.showForConversation({ projectId: vm.project.id, conversationId: collabConversation.id }); + // R17 G3(#21):若是从军团总览下钻进来的,透传 openRunId,面板加载完自动打开该 run 详情。 + const collabOpenRunId = consumeArmyRunDetailId(); + armyPanel.showForConversation({ + projectId: vm.project.id, + conversationId: collabConversation.id, + ...(collabOpenRunId ? { openRunId: collabOpenRunId } : {}) + }); enterSideContext({ projectId: vm.project.id, conversationId: collabConversation.id }); return; } @@ -1240,7 +1271,13 @@ export function mountWorkbenchShell( }); chatMountKey = key; // R13 批 P1:见上面协同会话分支同款注释——主区会话情境存在时,情境面板默认态挂军团三区。 - armyPanel.showForConversation({ projectId: vm.project.id, conversationId: mainConversation.id }); + // R17 G3(#21):主区会话也可能是下钻目标(run 血缘会话就是项目主区)——同样透传 openRunId。 + const mainOpenRunId = consumeArmyRunDetailId(); + armyPanel.showForConversation({ + projectId: vm.project.id, + conversationId: mainConversation.id, + ...(mainOpenRunId ? { openRunId: mainOpenRunId } : {}) + }); enterSideContext({ projectId: vm.project.id, conversationId: mainConversation.id }); return; } From b60c4a81cb054ba9682ac4163d899f95b6386a28 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:13:09 +0800 Subject: [PATCH 18/23] =?UTF-8?q?feat(members):=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=88=90=E5=91=98=E6=9D=A1=E5=8A=A0=E4=BA=BA/=E9=80=80?= =?UTF-8?q?=E7=BE=A4/=E7=A7=BB=E5=87=BA=20UI=20+=20participants.updated=20?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=20(R17-G1=20#1/#16=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把新端点接进会话头部成员条——仅非 DM 的 collab 会话渲「成员」「加人」两个入口 (main/DM 不变): - 加人:管理面板内联候选清单(工作区成员 - 既有参与者 - Cuu 哨兵),逐个添加, 忙态「添加中…」+ 全候选禁用防并发;成功用返回的权威列表替换本地参与者 + 已读 N/M 分母(otherMemberIds)。 - 退群/移出:参与者名单每行按权限渲控件——自己那行恒「退出」;别人那行仅群主 (participant_role=owner)渲「移出」。行内确认(无 window.confirm)+ 忙态。 自己退群成功 → 关流 + 「你已退出」终态(不再渲头部/滚动区/输入框)。 - SSE:消费 conversation.participants.updated(镜像 cuu.updated 接线)——别端加/退/ 移后按需重拉 GET /participants;本人被移出直接切终态(不再打注定 404 的重拉)。 - 错误映射 memberManageErrorText:cap/权限/最后一人等已知 code 双语文案,未知 code 落通用重试语,不泄内部错误码。 render.test.ts 新增 11 例纯函数测试(候选排除/权限矩阵/行内确认/忙态/空态/错误映射/ 终态);chat 全套 510 例、desktop-webview 全套 1341 例、全仓 typecheck 绿。 已知缝(诚实记录):成员条头像行与「N 位成员」计数仍渲工作区全员(保持既有行为与 测试不动),子集群的权威名单以管理面板为准。 Co-Authored-By: Claude Fable 5 --- .../src/workbench/chat/events.ts | 21 ++ .../src/workbench/chat/render.test.ts | 185 ++++++++++++ .../src/workbench/chat/render.ts | 215 +++++++++++++- .../src/workbench/chat/view.ts | 276 +++++++++++++++++- apps/desktop-webview/src/workbench/css.ts | 42 +++ 5 files changed, 735 insertions(+), 4 deletions(-) diff --git a/apps/desktop-webview/src/workbench/chat/events.ts b/apps/desktop-webview/src/workbench/chat/events.ts index 9f6a71ff..d8d303a6 100644 --- a/apps/desktop-webview/src/workbench/chat/events.ts +++ b/apps/desktop-webview/src/workbench/chat/events.ts @@ -5,6 +5,7 @@ import { conversationActionCardUpdatedEventSchema, conversationCuuUpdatedEventSchema, + conversationParticipantsUpdatedEventSchema, conversationMessageCreatedEventSchema, conversationMessageDeltaEventSchema, conversationMessageUpdatedEventSchema, @@ -178,6 +179,26 @@ export function parseIncomingConversationCuuUpdated(raw: unknown, conversationId return parsed.data.data.cuu_enabled; } +// R17 批 G1(群成员管理):conversation.participants.updated——参与者集合变化(加人/退群/移出)后广播。 +// data 只带 change + 受影响 user_id(不带全量参与者列表),调用方(view.ts)据此按需重拉 GET /participants +// (同 parseIncomingConversationCuuUpdated 只带布尔值、不带全量会话 VM 的既有取舍:接不上就等下次重挂兜底)。 +// 未过 zod 校验/会话 id 不匹配一律 undefined,静默丢弃。 +export type IncomingParticipantsUpdate = { change: "added" | "removed"; userId: string }; + +export function parseIncomingConversationParticipantsUpdated( + raw: unknown, + conversationId: string +): IncomingParticipantsUpdate | undefined { + const parsed = conversationParticipantsUpdatedEventSchema.safeParse(raw); + if (!parsed.success) { + return undefined; + } + if (parsed.data.data.conversation_id !== conversationId) { + return undefined; + } + return { change: parsed.data.data.change, userId: parsed.data.data.user_id }; +} + // R14 批 CHAT:conversation.observer.analyzing——瞬态(照 typing 模式,ttl 30s),观察者开始整理讨论时 // 发布。同 parseIncomingTyping:只回过期时刻毫秒,调用方在指示灯行区域渲「Cuu 正在整理刚才的讨论…」, // TTL 过期或收到行动卡事件即消。 diff --git a/apps/desktop-webview/src/workbench/chat/render.test.ts b/apps/desktop-webview/src/workbench/chat/render.test.ts index a1d5bdb5..4cb57574 100644 --- a/apps/desktop-webview/src/workbench/chat/render.test.ts +++ b/apps/desktop-webview/src/workbench/chat/render.test.ts @@ -23,6 +23,11 @@ import { renderJumpToUnreadHtml, renderLoadEarlierHtml, renderMemberBarHtml, + renderMemberManageModalHtml, + renderConversationLeftHtml, + memberManageCandidates, + memberManageErrorText, + participantRemoveKind, renderDmHeadBarHtml, renderMentionPickerHtml, renderMessageHtml, @@ -40,6 +45,7 @@ import { renderTypingIndicatorHtml, renderUnreadDividerHtml, type ChatRenderContext, + type MemberManageParticipantRow, type WorkbenchMemberVM } from "./render.js"; @@ -158,6 +164,185 @@ test("renderCuuToggleHtml disables the control and swaps in a working label whil assert.match(busyEn, /Working/u); }); +// —— R17 批 G1(群成员管理 · #1 加人 / #16 退群·移出) —— // + +test("renderMemberBarHtml renders the manage entries only when manage is enabled (non-DM collab)", () => { + const members = [member({ user_id: "u1", nickname: "张三" })]; + const plain = renderMemberBarHtml({ members, locale: "zh-CN" }); + assert.doesNotMatch(plain, /data-wb-chat-member-add-open/u); + assert.doesNotMatch(plain, /data-wb-chat-member-manage-open/u); + + const managed = renderMemberBarHtml({ members, locale: "zh-CN", manage: true }); + assert.match(managed, /data-wb-chat-member-manage-open/u); + assert.match(managed, /data-wb-chat-member-add-open/u); + assert.match(managed, /加人/u); + // 既有头像/计数标签不受 manage 影响。 + assert.match(managed, /1 位成员 \+ Cuu/u); +}); + +test("memberManageCandidates excludes members already in the participant set and Cuu", () => { + const workspace = [ + member({ user_id: "u1", nickname: "张三" }), + member({ user_id: "u2", nickname: "李四" }), + member({ user_id: "u3", nickname: "王五" }), + member({ user_id: "cuu", nickname: "Cuu" }) + ]; + const candidates = memberManageCandidates(workspace, new Set(["u1", "u2"])); + // u1/u2 已在群里被排除;u3 保留;哨兵 id "cuu" 永远排除。 + assert.deepEqual(candidates, [{ userId: "u3", nickname: "王五" }]); +}); + +test("memberManageCandidates returns everyone (minus Cuu) when nobody is a participant yet", () => { + const workspace = [member({ user_id: "u1", nickname: "张三" }), member({ user_id: "u2", nickname: "李四" })]; + const candidates = memberManageCandidates(workspace, new Set()); + assert.deepEqual(candidates.map((candidate) => candidate.userId), ["u1", "u2"]); +}); + +test("participantRemoveKind renders the remove control per permission (self=leave, others owner-only)", () => { + const me = { userId: "me", role: "member" as const }; + const other = { userId: "other", role: "member" as const }; + // 自己那行永远是「退出」,跟自己是不是 owner 无关。 + assert.equal(participantRemoveKind(me, "me", false), "leave"); + assert.equal(participantRemoveKind(me, "me", true), "leave"); + // 别人那行:当前查看者是 owner 才能「移出」,否则没有控件。 + assert.equal(participantRemoveKind(other, "me", true), "remove"); + assert.equal(participantRemoveKind(other, "me", false), "none"); +}); + +function participantRow(over: Partial & { userId: string }): MemberManageParticipantRow { + return { nickname: over.userId, role: "member", ...over }; +} + +test("renderMemberManageModalHtml lists participants and marks the owner, gating remove controls by viewer role", () => { + const participants = [ + participantRow({ userId: "owner", nickname: "群主甲", role: "owner" }), + participantRow({ userId: "me", nickname: "我", role: "member" }), + participantRow({ userId: "other", nickname: "他人", role: "member" }) + ]; + // 查看者是普通成员:只有自己那行有「退出」,别人两行都没有移出控件。 + const asMember = renderMemberManageModalHtml({ + locale: "zh-CN", + open: true, + participants, + candidates: [], + viewerUserId: "me", + viewerIsOwner: false + }); + assert.match(asMember, /群主/u); // owner 角色标 + assert.match(asMember, /data-wb-chat-member-remove-open="me"/u); + assert.doesNotMatch(asMember, /data-wb-chat-member-remove-open="other"/u); + assert.doesNotMatch(asMember, /data-wb-chat-member-remove-open="owner"/u); + + // 查看者是群主:自己那行「退出」,别人两行「移出」。 + const asOwner = renderMemberManageModalHtml({ + locale: "zh-CN", + open: true, + participants, + candidates: [], + viewerUserId: "owner", + viewerIsOwner: true + }); + assert.match(asOwner, /data-wb-chat-member-remove-open="owner"/u); + assert.match(asOwner, /data-wb-chat-member-remove-open="me"/u); + assert.match(asOwner, /data-wb-chat-member-remove-open="other"/u); +}); + +test("renderMemberManageModalHtml shows an inline confirm (not window.confirm) with a busy label while removing", () => { + const participants = [participantRow({ userId: "other", nickname: "他人", role: "member" })]; + const confirming = renderMemberManageModalHtml({ + locale: "zh-CN", + open: true, + participants, + candidates: [], + viewerUserId: "owner", + viewerIsOwner: true, + removeConfirmUserId: "other" + }); + // 确认层出现,原「移出」触发按钮被替换。 + assert.match(confirming, /data-wb-chat-member-remove-confirm="other"/u); + assert.match(confirming, /data-wb-chat-member-remove-cancel/u); + assert.doesNotMatch(confirming, /data-wb-chat-member-remove-open="other"/u); + + const busy = renderMemberManageModalHtml({ + locale: "zh-CN", + open: true, + participants, + candidates: [], + viewerUserId: "owner", + viewerIsOwner: true, + removeConfirmUserId: "other", + removeBusyUserId: "other" + }); + assert.match(busy, /移出中/u); + assert.match(busy, /disabled/u); +}); + +test("renderMemberManageModalHtml self row confirm uses a leave (退出) label, not remove", () => { + const participants = [participantRow({ userId: "me", nickname: "我", role: "member" })]; + const html = renderMemberManageModalHtml({ + locale: "en-US", + open: true, + participants, + candidates: [], + viewerUserId: "me", + viewerIsOwner: false, + removeConfirmUserId: "me" + }); + assert.match(html, /Leave this chat\?/u); + assert.doesNotMatch(html, /Remove them\?/u); +}); + +test("renderMemberManageModalHtml add list marks the busy candidate and disables the rest (one at a time)", () => { + const candidates = [ + { userId: "c1", nickname: "候选甲" }, + { userId: "c2", nickname: "候选乙" } + ]; + const html = renderMemberManageModalHtml({ + locale: "zh-CN", + open: true, + participants: [], + candidates, + viewerUserId: "me", + viewerIsOwner: false, + addBusyUserId: "c1" + }); + assert.match(html, /data-wb-chat-member-add="c1"/u); + assert.match(html, /data-wb-chat-member-add="c2"/u); + assert.match(html, /添加中/u); + // 任一加人在飞时所有候选一并禁用(防并发)。 + assert.equal((html.match(/wh-wb-member-add-row[^>]*disabled/gu) ?? []).length, 2); +}); + +test("renderMemberManageModalHtml shows an empty state when everyone is already in the chat", () => { + const html = renderMemberManageModalHtml({ + locale: "en-US", + open: false, + participants: [participantRow({ userId: "me", nickname: "我", role: "owner" })], + candidates: [], + viewerUserId: "me", + viewerIsOwner: true + }); + assert.match(html, /Everyone in the workspace is already here/u); + assert.match(html, /data-open="false"/u); +}); + +test("memberManageErrorText maps known participant error codes and falls back for the unknown", () => { + assert.match(memberManageErrorText({ status: 409, code: "conversation_participant_cap" }, "zh-CN"), /满了/u); + assert.match(memberManageErrorText({ status: 403, code: "conversation_remove_forbidden" }, "en-US"), /owner/u); + assert.match(memberManageErrorText({ status: 409, code: "conversation_last_participant" }, "zh-CN"), /最后一名/u); + // 未识别的 code 落通用重试文案,不暴露内部错误码。 + const fallback = memberManageErrorText({ status: 500, code: "boom_internal" }, "zh-CN"); + assert.doesNotMatch(fallback, /boom_internal/u); + assert.match(fallback, /再试/u); + assert.match(memberManageErrorText(undefined, "en-US"), /try again/u); +}); + +test("renderConversationLeftHtml shows a terminal 'you left' state in both locales", () => { + assert.match(renderConversationLeftHtml("zh-CN"), /你已退出/u); + // 撇号被 escapeHtml 转成 ',断言避开它只匹配无歧义子串。 + assert.match(renderConversationLeftHtml("en-US"), /left this chat/u); +}); + // R13 批 G1(小群):senderLabel/renderMessageHtml 按 sender_user_id 查 ctx.members(一个纯 Map 查找, // 见 render.ts 的 senderLabel),对小群(3+ 位真人参与者)天然成立,不需要为群聊场景额外改造——这条 // 补一条回归测试钉死这个事实(同批设计稿"多人昵称解析不是风险点"一节的结论)。 diff --git a/apps/desktop-webview/src/workbench/chat/render.ts b/apps/desktop-webview/src/workbench/chat/render.ts index 21403adb..d0c6fc93 100644 --- a/apps/desktop-webview/src/workbench/chat/render.ts +++ b/apps/desktop-webview/src/workbench/chat/render.ts @@ -95,6 +95,11 @@ export function renderMemberBarHtml(input: { // R14 批 CHAT:在线成员 id 集合(GET /api/presence 拉回,view.ts 30s 轮询 + 重连刷新后重渲成员条)。 // 缺省/空集 = 谁都不在线(或还没拉到)——一个点都不画,不编造在线态。 onlineUserIds?: ReadonlySet; + // R17 批 G1(群成员管理):仅非 DM 的 collab 会话为 true——成员条追加「成员」「加人」两个入口按钮 + // (main/DM 不渲,由 view.ts 挂载点 canManageMembers 把关,见 renderHead)。缺省 false:既有调用点/ + // 既有测试完全不受影响(bar 只多两个按钮,头像行/计数标签一字不改)。 + // 加人 / manage members: only non-DM collab conversations render these two entry buttons. + manage?: boolean; }): string { const zh = input.locale === "zh-CN"; const avatars = input.members @@ -110,7 +115,215 @@ export function renderMemberBarHtml(input: { const cuuAvatar = avatarTileHtml({ label: "Cuu", id: "cuu", variant: "cuu" }); const count = input.members.length; const label = zh ? `${count} 位成员 + Cuu · 全员群聊` : `${count} member${count === 1 ? "" : "s"} + Cuu · everyone`; - return `
    ${avatars}${cuuAvatar}
    ${escapeHtml(label)}
    `; + // R17 批 G1:「成员」打开成员管理面板(看名单 / 退出 / 移出),「加人」打开同一面板(加人区已内联在面板里)。 + // 两个按钮都只是入口,真正的名单 + 加人候选由 renderMemberManageModalHtml 渲染(挂在成员条旁,见 renderHead)。 + const manageHtml = input.manage + ? `` + : ""; + return `
    ${avatars}${cuuAvatar}
    ${escapeHtml( + label + )}${manageHtml}
    `; +} + +// —— R17 批 G1(群成员管理 · #1 建群后加人 / #16 退群·移出) —— // + +export type MemberManageParticipantRow = { userId: string; nickname: string; role: "owner" | "member" }; +export type MemberManageCandidate = { userId: string; nickname: string }; + +// Cuu 的哨兵 id(同 view.ts CUU_MENTION_SENTINEL_USER_ID)——她不是真实工作区成员,本就不该出现在 +// input.members 里;这里对这个 id 再做一道防御性排除,万一将来 members 混进 Cuu 也不会把她列成可加成员。 +const CUU_SENTINEL_USER_ID = "cuu"; + +// R17 批 G1(#1 建群后加人):加人候选 = 工作区成员里「还不在这条会话参与者集合里」的那些人(排除 Cuu)。 +// 纯 map/filter,可单测("candidates exclude existing participants")。 +export function memberManageCandidates( + workspaceMembers: readonly WorkbenchMemberVM[], + participantUserIds: ReadonlySet +): MemberManageCandidate[] { + return workspaceMembers + .filter((member) => member.user_id !== CUU_SENTINEL_USER_ID && !participantUserIds.has(member.user_id)) + .map((member) => ({ userId: member.user_id, nickname: member.nickname })); +} + +// R17 批 G1(#16 退群/移出):某一行参与者该渲什么退出/移出控件——自己那行永远是「退出」(leave); +// 别人那行只有当前查看者是群主(owner)才渲「移出」(remove),非群主看别人那行没有任何控件(none)。 +// 纯决策函数,可单测("remove control renders per permission")。服务端仍有 403(非 owner 删他人)/409 +// (最后一人)兜底,前端这里更窄一步:不摆一个点了会被拒的按钮(04 §4 铁律 3)。 +export function participantRemoveKind( + row: { userId: string; role: "owner" | "member" }, + viewerUserId: string, + viewerIsOwner: boolean +): "leave" | "remove" | "none" { + if (row.userId === viewerUserId) { + return "leave"; + } + return viewerIsOwner ? "remove" : "none"; +} + +// 加人/退群/移出失败后的温和行内提示——照 action-card-decision.ts 的 mapActionCardDecisionError 同款取舍: +// 维护一份口语化的桌面端文案(服务端 message 是给 API 消费者的通用文案),未识别的 code 落通用重试句, +// 不暴露内部错误码。错误码来源见 apps/api/src/services/conversations.ts 的 addParticipant/removeParticipant。 +export type MemberManageErrorSource = { status?: number; code?: string } | undefined; + +const MEMBER_MANAGE_ERROR_TEXT: Record> = { + "zh-CN": { + conversation_participant_cap: "这个群的成员已经满了。", + conversation_participant_invalid: "这个人不是当前工作区的活跃成员。", + conversation_dm_target_not_found: "没找到工作区里的这个人。", + conversation_add_target_required: "先选一个要加入的人。", + conversation_remove_forbidden: "只有群主可以移出其他成员。", + conversation_last_participant: "你是最后一名成员,不能退出。", + conversation_participant_not_found: "这个人已经不在这个群里了。", + conversation_not_group: "这个会话不支持单独增删成员。", + human_required: "这个操作需要你用真实账号登录。" + }, + "en-US": { + conversation_participant_cap: "This chat is already full.", + conversation_participant_invalid: "That person isn't an active member of this workspace.", + conversation_dm_target_not_found: "Couldn't find that person in the workspace.", + conversation_add_target_required: "Pick someone to add first.", + conversation_remove_forbidden: "Only the owner can remove other members.", + conversation_last_participant: "You're the last member — you can't leave.", + conversation_participant_not_found: "That person is no longer in this chat.", + conversation_not_group: "This chat doesn't support adding or removing members one by one.", + human_required: "This action needs you to be signed in as a real user." + } +}; + +const MEMBER_MANAGE_FALLBACK_ERROR_TEXT: Record = { + "zh-CN": "没弄成,稍后再试一次。", + "en-US": "That didn't go through — try again in a moment." +}; + +export function memberManageErrorText(source: MemberManageErrorSource, locale: Locale): string { + const table = MEMBER_MANAGE_ERROR_TEXT[locale]; + const code = source?.code; + if (code && Object.prototype.hasOwnProperty.call(table, code)) { + return table[code]!; + } + return MEMBER_MANAGE_FALLBACK_ERROR_TEXT[locale]; +} + +// R17 批 G1:成员管理面板——复用 .wh-wb-modal-overlay/.wh-wb-modal 弹窗底盘(不新造),上半是当前参与者 +// 名单(每行按权限渲「退出/移出」控件;点后就地内联确认,不弹 window.confirm),下半是「加人」候选清单 +// (点一个加一个,飞行中该行忙态、其余候选一并禁用防并发)。纯函数,imperative 的 POST/DELETE 在 view.ts。 +export function renderMemberManageModalHtml(input: { + locale: Locale; + open: boolean; + participants: readonly MemberManageParticipantRow[]; + candidates: readonly MemberManageCandidate[]; + viewerUserId: string; + viewerIsOwner: boolean; + // #1 加人:正在飞的候选 user_id(markBusy 手感——该行换「添加中…」,其余候选一并禁用,见下)+ 失败行内提示。 + addBusyUserId?: string | undefined; + addError?: string | undefined; + // #16 退群/移出:当前展开了行内确认的那一行 user_id(一次只确认一行)+ 正在飞的删除 user_id + 失败提示。 + removeConfirmUserId?: string | undefined; + removeBusyUserId?: string | undefined; + removeError?: string | undefined; +}): string { + const zh = input.locale === "zh-CN"; + const rosterRows = input.participants + .map((row) => { + const kind = participantRemoveKind(row, input.viewerUserId, input.viewerIsOwner); + const roleTag = + row.role === "owner" ? `${escapeHtml(zh ? "群主" : "Owner")}` : ""; + let control = ""; + if (kind !== "none") { + const confirming = input.removeConfirmUserId === row.userId; + const busy = input.removeBusyUserId === row.userId; + if (confirming) { + const question = + kind === "leave" ? (zh ? "退出这个群?" : "Leave this chat?") : zh ? "把 TA 移出?" : "Remove them?"; + const yesLabel = busy + ? kind === "leave" + ? zh + ? "退出中…" + : "Leaving…" + : zh + ? "移出中…" + : "Removing…" + : kind === "leave" + ? zh + ? "退出" + : "Leave" + : zh + ? "移出" + : "Remove"; + control = + `${escapeHtml(question)}` + + `` + + ``; + } else { + const triggerLabel = kind === "leave" ? (zh ? "退出" : "Leave") : zh ? "移出" : "Remove"; + control = ``; + } + } + return `
    ${avatarTileHtml({ label: row.nickname, id: row.userId })}${escapeHtml( + row.nickname + )}${roleTag}${control}
    `; + }) + .join(""); + const addDisabled = input.addBusyUserId !== undefined; // 一次加一个:任一加人在飞时,所有候选一并禁用防并发。 + const candidateRows = + input.candidates.length > 0 + ? input.candidates + .map((candidate) => { + const busy = input.addBusyUserId === candidate.userId; + const hint = busy ? (zh ? "添加中…" : "Adding…") : zh ? "加入" : "Add"; + return ``; + }) + .join("") + : `

    ${escapeHtml( + zh ? "工作区里的人都已经在这个群里了" : "Everyone in the workspace is already here" + )}

    `; + const addErrorHtml = input.addError + ? `

    ${escapeHtml(input.addError)}

    ` + : ""; + const removeErrorHtml = input.removeError + ? `

    ${escapeHtml(input.removeError)}

    ` + : ""; + return `
    + +
    `; +} + +// R17 批 G1(#16 退群):自己退出会话后的终态——本人已不再是参与者,会话对本人不再可见。桌面 shell 没有 +// 给 chat 视图传「退出后回到哪」的回调(见 view.ts mountChatView 的 input 形状),least-surprising:就地 +// 用一句温和的终态替换中栏内容(不弹窗、不跳转),用户点左栏其它会话/项目即自然重挂。 +export function renderConversationLeftHtml(locale: Locale): string { + const zh = locale === "zh-CN"; + return `

    ${escapeHtml( + zh ? "你已退出这个群聊" : "You've left this chat" + )}

    ${escapeHtml( + zh ? "选择左侧其它会话继续。" : "Pick another chat on the left to continue." + )}

    `; } // R15 批 B(人对人私聊):DM 会话头——对方头像(在线绿点)+ 对方昵称 + 在线两态文字。不渲染「+ Cuu」 diff --git a/apps/desktop-webview/src/workbench/chat/view.ts b/apps/desktop-webview/src/workbench/chat/view.ts index 92314c24..ec1e48d0 100644 --- a/apps/desktop-webview/src/workbench/chat/view.ts +++ b/apps/desktop-webview/src/workbench/chat/view.ts @@ -20,6 +20,7 @@ import type { AiMode, ConversationKind, ConversationMessageVM, + ConversationParticipantsVM, ConversationReactionKey, Notification } from "@workhub/contracts"; @@ -48,6 +49,8 @@ import { patchMyAiMode, pinConversationMessage, pingConversationTyping, + postConversationParticipant, + deleteConversationParticipant, putActionCardItemFeedback, putConversationMessageFeedback, removeConversationReaction, @@ -66,6 +69,7 @@ import { pickDispatchAskCatchupNotification, renderDispatchAskCatchupBannerHtml import { parseIncomingActionCardUpdated, parseIncomingConversationCuuUpdated, + parseIncomingConversationParticipantsUpdated, parseIncomingMessageCreated, parseIncomingMessageDelta, parseIncomingMessageUpdated, @@ -95,13 +99,17 @@ import { } from "./read-state.js"; import { membersById, + memberManageCandidates, + memberManageErrorText, modePatchFailedText, reassignPickerMemberIds, renderChatEmptyStateHtml, renderComposerHtml, renderConnectionBannerHtml, renderConversationAccessDeniedHtml, + renderConversationLeftHtml, renderCuuToggleHtml, + renderMemberManageModalHtml, renderCuuTurnErrorHtml, renderCuuTurnPendingHtml, renderDaySeparatorHtml, @@ -130,6 +138,7 @@ import { type ComposerAttachmentChip, type ConnectionBannerState, type LoadEarlierState, + type MemberManageParticipantRow, type MentionPickerFile, type MentionPickerMember, type PendingOutgoingMessage, @@ -655,6 +664,29 @@ export function mountChatView( let otherMemberIds = input.members.map((member) => member.user_id).filter((id) => id !== input.currentUserId); const memberUserIds = input.members.map((member) => member.user_id); + // R17 批 G1(群成员管理):只有非 DM 的 collab 会话才渲加人/退群/移出(main=全员语义、DM=2 人不变量, + // 服务端各自 409;前端这里就不摆入口,04 §4 铁律 3)。isDm 缺省 undefined 视为非 DM——普通协同群 shell.ts + // 不传 isDm(见 shell.ts 的 collab 挂载点),恰好落在这条闸内。main 由 conversationKind 挡在外面。 + const canManageMembers = input.conversationKind === "collab" && input.isDm !== true; + // 这条会话的真实参与者集合(GET /participants 拉回,加人/退群/移出后就地更新,SSE + // conversation.participants.updated 触发重拉)——带 role(owner/member),据此判断当前查看者是不是群主。 + let conversationParticipants: MemberManageParticipantRow[] = []; + // 成员管理面板的瞬态 UI 状态(都不落库,重挂视图自然清空): + // - memberManageOpen:面板是否打开(成员条「成员」/「加人」按钮开、关闭/点遮罩背景/退群成功关); + // - memberAddBusyUserId:正在飞的加人候选 id(markBusy——该行「添加中…」,其余候选一并禁用防并发); + // - memberAddError:加人失败的温和行内提示(cap/非成员/权限),下一次加人前清掉; + // - memberRemoveConfirmUserId:当前展开行内确认的参与者 id(一次一行); + // - memberRemoveBusyUserId:正在飞的删除 id(markBusy);memberRemoveError:删除/退群失败提示。 + let memberManageOpen = false; + let memberAddBusyUserId: string | undefined; + let memberAddError: string | undefined; + let memberRemoveConfirmUserId: string | undefined; + let memberRemoveBusyUserId: string | undefined; + let memberRemoveError: string | undefined; + // #16 退群成功(self_left)后的终态——本人已不再可见这条会话,中栏改渲「你已退出」(见 renderScroll/ + // renderHead/renderComposerChrome 顶部的 leftConversation 早退),SSE 也停掉,等用户切走自然重挂。 + let leftConversation = false; + const membersMap = membersById(input.members); const renderCtx = (): ChatRenderContext => ({ locale: input.locale, @@ -732,6 +764,11 @@ export function mountChatView( } function renderHead(): void { + // R17 批 G1(#16 退群):自己退出后头部收成空——不再渲成员条/开关(会话对本人已不可见)。 + if (leftConversation) { + headEl!.innerHTML = ""; + return; + } let headBarHtml: string; if (input.isDm) { // DM:会话头显示对方昵称 + 在线点(对方 = 成员集合里非本人那一位)。 @@ -743,7 +780,13 @@ export function mountChatView( locale: input.locale }); } else { - headBarHtml = renderMemberBarHtml({ members: input.members, locale: input.locale, onlineUserIds }); + // R17 批 G1:非 DM 的 collab 成员条追加「成员」「加人」两个入口按钮(manage);main/DM 不给。 + headBarHtml = renderMemberBarHtml({ + members: input.members, + locale: input.locale, + onlineUserIds, + ...(canManageMembers ? { manage: true } : {}) + }); } // R15 批 cuu-toggle:main 一律不渲这个开关(主区的 Cuu 参与语义不一样,包括个人空间单聊——保持 // "main 一律不渲"这一条简单心智模型,不额外特判个人空间,同服务端 updateCuuEnabled 的取舍对称); @@ -752,11 +795,36 @@ export function mountChatView( input.conversationKind === "collab" ? renderCuuToggleHtml({ enabled: cuuEnabled, busy: cuuToggleBusy, locale: input.locale }) : ""; - headEl!.innerHTML = headBarHtml + toggleHtml; + // R17 批 G1(群成员管理面板):仅非 DM 的 collab 会话渲染这个弹层(position:fixed,不影响成员条 flex + // 布局)——始终在 DOM 里、由 data-open 控制显隐(同 rail.ts 建群弹窗的既有取舍),memberManageOpen 等 + // 瞬态状态跨 renderHead 重渲(含 presence 30s 轮询触发的重渲)不丢,因为都是闭包变量。 + const manageModalHtml = canManageMembers + ? renderMemberManageModalHtml({ + locale: input.locale, + open: memberManageOpen, + participants: conversationParticipants, + candidates: memberManageCandidates(input.members, new Set(conversationParticipants.map((row) => row.userId))), + viewerUserId: input.currentUserId, + viewerIsOwner: viewerIsConversationOwner(), + addBusyUserId: memberAddBusyUserId, + addError: memberAddError, + removeConfirmUserId: memberRemoveConfirmUserId, + removeBusyUserId: memberRemoveBusyUserId, + removeError: memberRemoveError + }) + : ""; + headEl!.innerHTML = headBarHtml + toggleHtml + manageModalHtml; hydrateAvatarPhotos(headEl!); } renderHead(); + // R17 批 G1(#16 移出):当前查看者是不是这条会话的群主(owner)——决定别人那行能不能渲「移出」控件。 + // 参与者集合还没拉到(conversationParticipants 为空)时返回 false:诚实地不摆移出控件,等 loadParticipants + // 拉回真实角色后重渲(服务端 access.participantRole 才是权威,前端这里只是不摆点了会被 403 拒的按钮)。 + function viewerIsConversationOwner(): boolean { + return conversationParticipants.some((row) => row.userId === input.currentUserId && row.role === "owner"); + } + // R15 批 cuu-toggle:cuuEnabled 翻转后的统一收尾——重算 isCollabConversation、重渲头部开关 + 依赖它的 // composer 模式 chip(renderComposerChrome 内部按 isCollabConversation 决定要不要渲模式 chip; // renderModeHint 在非协同会话本就清空,见两者各自顶部注释)。注意:isCollabConversation 只影响这两处 @@ -806,6 +874,141 @@ export function mountChatView( }); } + // —— R17 批 G1(群成员管理 · #1 加人 / #16 退群·移出) —— // + + // 加人/退群/移出的 HTTP 响应回来的权威参与者集合(POST /participants 回 { added, participants }; + // GET /participants 回同款 { scope, participants })——就地替换本地参与者快照 + otherMemberIds(已读 N/M + // 分母),重渲成员条 + 滚动区。scope 不是 "participants"(理论上非 collab 才会,这里到不了)时只重渲头部。 + function applyParticipantsVM(vm: ConversationParticipantsVM): void { + if (vm.scope !== "participants") { + renderHead(); + return; + } + conversationParticipants = vm.participants.map((row) => ({ + userId: row.user_id, + nickname: row.nickname, + role: row.role + })); + otherMemberIds = otherParticipantUserIds(vm.participants, input.currentUserId); + renderHead(); + renderScroll(); + } + + function openMemberManage(): void { + if (!canManageMembers) { + return; + } + memberManageOpen = true; + memberAddError = undefined; + memberRemoveError = undefined; + memberRemoveConfirmUserId = undefined; + renderHead(); + } + + function closeMemberManage(): void { + memberManageOpen = false; + memberRemoveConfirmUserId = undefined; + memberAddError = undefined; + memberRemoveError = undefined; + renderHead(); + } + + // #16:点「退出/移出」→ 展开行内确认(不弹 window.confirm,同 R14 批 CHAT 删除二次确认的既有手感)。 + function openRemoveConfirm(userId: string): void { + memberRemoveConfirmUserId = userId; + memberRemoveError = undefined; + renderHead(); + } + + function cancelRemoveConfirm(): void { + memberRemoveConfirmUserId = undefined; + renderHead(); + } + + // #1 加人:点一个候选加一个(markBusy——飞行中该行「添加中…」+ 其余候选一并禁用,见渲染层 addDisabled)。 + // 成功即用响应里刷新后的完整参与者列表就地替换;失败落温和行内提示(cap/非成员/权限),不弹阻断。 + function addMember(userId: string): void { + if (!canManageMembers || memberAddBusyUserId !== undefined) { + return; + } + memberAddBusyUserId = userId; + memberAddError = undefined; + renderHead(); + postConversationParticipant(input.client, input.conversationId, userId) + .then((result) => { + if (disposed) { + return; + } + memberAddBusyUserId = undefined; + applyParticipantsVM(result.participants); + }) + .catch((error) => { + if (disposed) { + return; + } + memberAddBusyUserId = undefined; + memberAddError = memberManageErrorText( + error instanceof WorkHubApiError ? { status: error.status, code: error.code } : undefined, + input.locale + ); + renderHead(); + }); + } + + // #16 退群/移出:行内确认的「退出/移出」被点后发 DELETE。self_left=true(自己退群,含 owner 退群触发继任) + // → 切到「你已退出」终态;否则(owner 移出别人)就地把这个人从参与者集合去掉并重渲。失败落温和行内提示。 + function removeMember(userId: string): void { + if (!canManageMembers || memberRemoveBusyUserId !== undefined) { + return; + } + memberRemoveBusyUserId = userId; + memberRemoveError = undefined; + renderHead(); + deleteConversationParticipant(input.client, input.conversationId, userId) + .then((result) => { + if (disposed) { + return; + } + memberRemoveBusyUserId = undefined; + memberRemoveConfirmUserId = undefined; + if (result.self_left) { + enterLeftConversationTerminalState(); + return; + } + conversationParticipants = conversationParticipants.filter((row) => row.userId !== result.removed_user_id); + otherMemberIds = otherMemberIds.filter((id) => id !== result.removed_user_id); + renderHead(); + renderScroll(); + }) + .catch((error) => { + if (disposed) { + return; + } + memberRemoveBusyUserId = undefined; + memberRemoveError = memberManageErrorText( + error instanceof WorkHubApiError ? { status: error.status, code: error.code } : undefined, + input.locale + ); + renderHead(); + }); + } + + // 自己退出(或被别的客户端移出,见 SSE 分支)后的终态收尾:停 SSE、关面板、把中栏/头部/输入区都切到 + // 「你已退出」态。least-surprising——桌面 shell 没有给 chat 视图「退出后回到哪」的回调(见 mountChatView + // input 形状),就地告诉用户已退出,切走即自然重挂。 + function enterLeftConversationTerminalState(): void { + if (leftConversation) { + return; + } + leftConversation = true; + memberManageOpen = false; + streamHandle?.close(); + streamHandle = undefined; + renderHead(); + renderScroll(); + renderComposerChrome(); + } + function textareaEl(): HTMLTextAreaElement | null { return composerWrapEl!.querySelector("[data-wb-chat-input]"); } @@ -1040,6 +1243,12 @@ export function mountChatView( // 合并后不管触发源是 loadOlderHistory / reaction 回流 / turn delta,不贴底时都做同一套补偿。 function renderScroll(): void { const el = scrollEl!; + // R17 批 G1(#16 退群):自己退出后中栏改渲「你已退出」终态——早于历史加载态判定(会话对本人已不可见, + // 不该再渲消息流/加载态)。 + if (leftConversation) { + el.innerHTML = renderConversationLeftHtml(input.locale); + return; + } const wasNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48; const beforeHeight = el.scrollHeight; const beforeTop = el.scrollTop; @@ -1162,6 +1371,12 @@ export function mountChatView( // 重建整个 composer chrome(attachments 行/send 禁用态变化时才需要)——重建前后原样保留 textarea // 的当前值/光标/焦点,innerHTML 重建本身会把它们清空(rail.ts 的新建项目输入框踩过同一个坑)。 function renderComposerChrome(): void { + // R17 批 G1(#16 退群):自己退出后清空输入区——不能再往已退出的会话里发消息(同 renderHead/renderScroll + // 的 leftConversation 早退)。 + if (leftConversation) { + composerWrapEl!.innerHTML = ""; + return; + } const previousTa = textareaEl(); const savedValue = previousTa?.value ?? draftFallback; const savedStart = previousTa?.selectionStart ?? savedValue.length; @@ -1371,7 +1586,15 @@ export function mountChatView( if (disposed || result.scope !== "participants") { return; } + // R17 批 G1:留住完整参与者集合(带 role)——成员管理面板的名单 + 加人候选 + 「移出」权限判定都靠它, + // 不再像旧版只算 otherMemberIds 就把列表丢掉。renderHead 一并重渲(成员条按钮/面板随真实名单更新)。 + conversationParticipants = result.participants.map((row) => ({ + userId: row.user_id, + nickname: row.nickname, + role: row.role + })); otherMemberIds = otherParticipantUserIds(result.participants, input.currentUserId); + renderHead(); renderScroll(); } catch { // best-effort:拉不到就保持挂载时算的降级分母(workspace_members),不重试轰炸(同 @@ -2359,6 +2582,21 @@ export function mountChatView( applyCuuEnabledChange(cuuUpdate); return; } + // R17 批 G1(群成员管理):conversation.participants.updated——别的客户端加/退/移了成员。镜像 + // cuu.updated 的接线(只带增量信号、不带全量列表):按需重拉一次 GET /participants,更新参与者名单 + + // otherMemberIds(已读 N/M 分母)+ 重渲成员条/面板。若这条增量说的是「本人被移出」,直接切「你已退出」 + // 终态(owner 从另一台客户端把我踢了)——不必再去重拉一个我已经看不到的会话(那次 GET 会 404)。不缓冲: + // 只是参与者状态同步,不产生消息/气泡。main/DM 收不到这个事件(服务端只在非 DM collab 广播), + // loadParticipants 内部也短路,双保险。 + const participantsUpdate = parseIncomingConversationParticipantsUpdated(event.data, input.conversationId); + if (participantsUpdate !== undefined) { + if (participantsUpdate.change === "removed" && participantsUpdate.userId === input.currentUserId) { + enterLeftConversationTerminalState(); + return; + } + void loadParticipants(); + return; + } // R14 批 CHAT:conversation.observer.analyzing(瞬态指示灯)——不缓冲(渲在独立指示行区域,不进 // 滚动区),照 typing 静默丢弃模式,设过期时刻并渲指示灯,TTL 到期或收到行动卡事件即消。 const observerAnalyzing = parseIncomingObserverAnalyzing(event.data, input.conversationId); @@ -3455,8 +3693,40 @@ export function mountChatView( if (!(event.target instanceof HTMLElement)) { return; } - if (event.target.closest("[data-wb-chat-cuu-toggle]")) { + const target = event.target; + if (target.closest("[data-wb-chat-cuu-toggle]")) { toggleCuuParticipation(); + return; + } + // R17 批 G1(群成员管理):成员条「成员」/「加人」入口、面板关闭/点遮罩背景关闭、加人候选、退群/移出 + // 的行内确认——都是 headEl 内的委托监听(成员管理面板随 renderHead() 整块重渲,绑元素上的监听器会丢)。 + if (target.closest("[data-wb-chat-member-manage-open]") || target.closest("[data-wb-chat-member-add-open]")) { + openMemberManage(); + return; + } + // 点遮罩背景(不是点到面板本体)也关——hasAttribute 判定命中的是遮罩层本身,同 rail.ts 建群弹窗的取舍。 + if (target.closest("[data-wb-chat-member-manage-close]") || target.hasAttribute("data-wb-chat-member-manage-overlay")) { + closeMemberManage(); + return; + } + const addEl = target.closest("[data-wb-chat-member-add]"); + if (addEl?.dataset.wbChatMemberAdd) { + addMember(addEl.dataset.wbChatMemberAdd); + return; + } + const removeOpenEl = target.closest("[data-wb-chat-member-remove-open]"); + if (removeOpenEl?.dataset.wbChatMemberRemoveOpen) { + openRemoveConfirm(removeOpenEl.dataset.wbChatMemberRemoveOpen); + return; + } + if (target.closest("[data-wb-chat-member-remove-cancel]")) { + cancelRemoveConfirm(); + return; + } + const removeConfirmEl = target.closest("[data-wb-chat-member-remove-confirm]"); + if (removeConfirmEl?.dataset.wbChatMemberRemoveConfirm) { + removeMember(removeConfirmEl.dataset.wbChatMemberRemoveConfirm); + return; } }); diff --git a/apps/desktop-webview/src/workbench/css.ts b/apps/desktop-webview/src/workbench/css.ts index 598f236f..ee717048 100644 --- a/apps/desktop-webview/src/workbench/css.ts +++ b/apps/desktop-webview/src/workbench/css.ts @@ -450,6 +450,48 @@ export const workbenchCss = [ "color:var(--ds-ink-muted);cursor:pointer}", ".wh-wb-chat-cuu-toggle--on{border-color:rgba(255,171,94,.4);background:var(--wb-cuu-soft);color:var(--wb-cuu)}", ".wh-wb-chat-cuu-toggle:disabled{opacity:.6;cursor:default}", + // R17 批 G1(群成员管理):成员条右侧「成员」「加人」两个 pill 入口(仅非 DM collab 渲染,见 render.ts + // renderMemberBarHtml 的 manage)——复用 cuu-toggle 同款胶囊外观,「加人」用强调色描边区分主次。 + ".wh-wb-chat-members-btn{flex:none;padding:4px 10px;border-radius:999px;border:1px solid var(--ds-glass-border);" + + "background:var(--ds-glass);font:600 11px/1.2 var(--ds-font);color:var(--ds-ink-muted);cursor:pointer}", + ".wh-wb-chat-members-btn:hover{color:var(--ds-ink)}", + ".wh-wb-chat-members-btn--add{border-color:rgba(10,132,255,.35);color:var(--ds-accent)}", + // 成员管理面板(弹在 .wh-wb-modal 里):上半名单行 + 下半加人候选行,都照建群选人器 .wh-wb-new-collab-member-row + // 同款 flex 行手感(头像 tile + 名字省略 + 右侧控件)。 + ".wh-wb-member-roster{display:flex;flex-direction:column;gap:1px;max-height:220px;overflow-y:auto;margin:6px 0 4px}", + ".wh-wb-member-row{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:9px;" + + "font:500 13px/1.3 var(--ds-font);color:var(--ds-ink)}", + ".wh-wb-member-row:hover{background:rgba(20,30,50,.05)}", + ".wh-wb-member-row .wh-wb-chat-avatar{margin-right:0}", + ".wh-wb-member-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", + ".wh-wb-member-role{flex:0 0 auto;padding:1px 7px;border-radius:999px;background:var(--wb-cuu-soft);color:var(--wb-cuu);" + + "font:600 10px/1.5 var(--ds-font)}", + ".wh-wb-member-remove{flex:0 0 auto;padding:3px 10px;border-radius:999px;border:1px solid var(--ds-glass-border);" + + "background:transparent;font:600 11px/1.2 var(--ds-font);color:var(--ds-ink-muted);cursor:pointer}", + ".wh-wb-member-remove:hover{border-color:var(--ds-danger);color:var(--ds-danger)}", + ".wh-wb-member-confirm{flex:0 0 auto;display:flex;align-items:center;gap:6px}", + ".wh-wb-member-confirm-q{font:600 11px/1.2 var(--ds-font);color:var(--ds-ink-muted)}", + ".wh-wb-member-confirm-yes{padding:3px 10px;border-radius:999px;border:1px solid var(--ds-danger);" + + "background:var(--ds-danger-soft);color:var(--ds-danger);font:600 11px/1.2 var(--ds-font);cursor:pointer}", + ".wh-wb-member-confirm-yes:disabled{opacity:.6;cursor:default}", + ".wh-wb-member-confirm-no{padding:3px 10px;border-radius:999px;border:1px solid var(--ds-glass-border);" + + "background:transparent;color:var(--ds-ink-muted);font:600 11px/1.2 var(--ds-font);cursor:pointer}", + ".wh-wb-member-confirm-no:disabled{opacity:.6;cursor:default}", + ".wh-wb-member-add-label{margin:10px 0 4px;font:600 11.5px/1.5 var(--ds-font);color:var(--ds-ink-muted)}", + ".wh-wb-member-add-list{display:flex;flex-direction:column;gap:1px;max-height:180px;overflow-y:auto}", + ".wh-wb-member-add-row{display:flex;align-items:center;gap:9px;width:100%;box-sizing:border-box;padding:6px 8px;" + + "border-radius:9px;border:0;background:transparent;text-align:left;font:500 13px/1.3 var(--ds-font);" + + "color:var(--ds-ink);cursor:pointer}", + ".wh-wb-member-add-row:hover{background:rgba(20,30,50,.05)}", + ".wh-wb-member-add-row:disabled{opacity:.55;cursor:default}", + ".wh-wb-member-add-row .wh-wb-chat-avatar{margin-right:0}", + ".wh-wb-member-add-hint{flex:0 0 auto;font:600 11px/1.2 var(--ds-font);color:var(--ds-accent)}", + ".wh-wb-member-empty{margin:2px 0;padding:6px 8px;font:500 12px/1.5 var(--ds-font);color:var(--ds-ink-faint)}", + // R17 批 G1(#16 退群):自己退出后的中栏终态——一句温和的居中说明(不弹窗、不跳转,见 view.ts leftConversation)。 + ".wh-wb-chat-left-terminal{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;" + + "height:100%;padding:40px 20px;text-align:center}", + ".wh-wb-chat-left-terminal-title{margin:0;font:700 14px/1.3 var(--ds-font);color:var(--ds-ink)}", + ".wh-wb-chat-left-terminal-note{margin:0;font:500 12.5px/1.5 var(--ds-font);color:var(--ds-ink-muted)}", ".wh-wb-chat-avs{display:flex}", // 头像堆叠的描边用白色(新壳体底色本就是近白的浅色玻璃),照 .wh-wb-chat-avatar--cuu/render.ts // avatarTileHtml 的深底色块配白字在浅底上依然成立——这条边框只是让重叠头像有「切出来」的轮廓感。 From c598dd17dc9b53d6dd8f1ad0df1f8f0a6354f136 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:29:56 +0800 Subject: [PATCH 19/23] feat(editor): #12 merge_conflict panel reuse + #13 multi-file switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #12 变更编辑器合并撞真实冲突(WorkHubApiError code=merge_conflict)时,分支 识别并复用 Spotlight 既有的逐冲突解决面板(proposalMergeConflictHtml + classifyProposalConflictActionHref + web-runtime 载荷解析 + applyMergeProposalCandidate/ mergeProposal),不再一律渲「状态变了刷新看看」;其它 409 保持原文案。冲突态 新增 conflict 视图模式,面板动作/返回把手就地接管,成功后 onSettled + reload 回 diff。 #13 同一提议含多个文本变动文件时,编辑器顶部加文件切换条(文件名 chips + diffstat + 上一个/下一个按钮)。文件集合取自 manifest.changes 里可逐行对照的文本变更(口径同 openProposalInEditor);diff 逐访问缓存 + 首屏后台预取(文件数由 manifest 有界,非无 上限翻页),加载过的文件显真实 +新增/−删除,未加载的退回 change_type 短标签;动作条 是提议级、切文件不变。 editor scoped CSS:切换条 + 冲突卡族(工作台窗口不注入 proposalCss,就地补齐)。 render.test.ts +2(多文件切换条 / 冲突面板)。 Co-Authored-By: Claude Fable 5 --- apps/desktop-webview/src/workbench/css.ts | 38 ++ .../src/workbench/editor/render.test.ts | 47 +++ .../src/workbench/editor/render.ts | 82 ++++- .../src/workbench/editor/view.ts | 329 +++++++++++++++++- 4 files changed, 481 insertions(+), 15 deletions(-) diff --git a/apps/desktop-webview/src/workbench/css.ts b/apps/desktop-webview/src/workbench/css.ts index 8114280d..9be81747 100644 --- a/apps/desktop-webview/src/workbench/css.ts +++ b/apps/desktop-webview/src/workbench/css.ts @@ -999,6 +999,44 @@ export const workbenchCss = [ ".wh-wb-ed-status--reviewed{background:var(--ds-accent-soft);color:var(--ds-accent)}", ".wh-wb-ed-status--merged{background:var(--ds-success-soft);color:#1f8f3f}", ".wh-wb-ed-status--rejected{background:var(--ds-danger-soft);color:var(--ds-danger)}", + // R17-G5 #13:同提议多文件切换条(文件名 chips + diffstat + 上一个/下一个)。 + ".wh-wb-ed-files{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:8px 16px;border-bottom:1px solid var(--ds-glass-border);background:var(--ds-glass-quiet)}", + ".wh-wb-ed-fnav{flex:0 0 auto;width:24px;height:24px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;color:var(--ds-ink-muted);border:1px solid var(--ds-glass-border);background:var(--ds-glass);cursor:pointer}", + ".wh-wb-ed-fnav svg{width:14px;height:14px}", + ".wh-wb-ed-fnav:hover:not(:disabled){background:var(--ds-glass-strong)}", + ".wh-wb-ed-fnav:disabled{opacity:.4;cursor:default}", + ".wh-wb-ed-ftabs{flex:1 1 auto;min-width:0;display:flex;gap:6px;overflow-x:auto;padding:1px}", + ".wh-wb-ed-ftab{flex:0 0 auto;display:inline-flex;align-items:center;gap:7px;max-width:230px;padding:5px 10px;border-radius:8px;" + + "border:1px solid var(--ds-glass-border);background:var(--ds-glass);color:var(--ds-ink-muted);cursor:pointer;" + + "font:600 11.5px/1.2 var(--ds-font);transition:background var(--ds-dur-fast,120ms) var(--ds-ease,ease)}", + ".wh-wb-ed-ftab:hover{background:var(--ds-glass-strong)}", + ".wh-wb-ed-ftab--active{background:var(--ds-accent-soft);border-color:rgba(10,132,255,.28);color:var(--ds-accent)}", + ".wh-wb-ed-ftab-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}", + ".wh-wb-ed-ftab-stat{flex:0 0 auto;font:700 10px/1 var(--ds-mono,ui-monospace,monospace);white-space:nowrap}", + ".wh-wb-ed-ftab-stat--type{color:var(--ds-ink-faint);font-family:var(--ds-font);font-weight:600}", + ".wh-wb-ed-ftab-add{color:#1f8f3f}", + ".wh-wb-ed-ftab-del{color:var(--ds-danger)}", + ".wh-wb-ed-ftab--active .wh-wb-ed-ftab-stat--type{color:var(--ds-accent)}", + ".wh-wb-ed-fcount{flex:0 0 auto;font:600 11px/1 var(--ds-mono,ui-monospace,monospace);color:var(--ds-ink-faint)}", + // R17-G5 #12:编辑器内合并冲突逐条解决面板(复用 proposalMergeConflictHtml 的 wh-spot-*/wh-conflict-* 结构, + // 这里把冲突卡族样式在编辑器作用域内重述一遍——工作台窗口不注入 proposalCss,故就地补齐)。 + ".wh-wb-ed-conflict{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:14px}", + ".wh-wb-ed-conflict .wh-spot-act{border:1px solid var(--ds-glass-border);border-radius:var(--ds-radius-md);background:transparent;color:var(--ds-ink-soft);font:700 12.5px/1 var(--ds-font);padding:8px 12px;cursor:pointer}", + ".wh-wb-ed-conflict .wh-spot-act--quiet{background:transparent;color:var(--ds-ink-muted)}", + ".wh-wb-ed-conflict .wh-spot-card-title{margin:0;font:800 15px/1.4 var(--ds-font);color:var(--ds-ink)}", + ".wh-wb-ed-conflict .wh-spot-action-note{margin:5px 0 0;font:600 12.5px/1.5 var(--ds-font);color:var(--ds-ink-muted)}", + ".wh-wb-ed-conflict .wh-conflict-list{display:flex;flex-direction:column;gap:10px;margin:0;min-width:0}", + ".wh-wb-ed-conflict .wh-conflict-head,.wh-wb-ed-conflict .wh-conflict-card,.wh-wb-ed-conflict .wh-conflict-workbench{border:1px solid var(--ds-glass-border);border-radius:var(--ds-radius-md);background:transparent;padding:12px 13px}", + ".wh-wb-ed-conflict .wh-conflict-head{border-color:rgba(255,69,58,.20)}", + ".wh-wb-ed-conflict .wh-conflict-head .wh-kicker,.wh-wb-ed-conflict .wh-conflict-card>strong{display:block;font:800 14px/1.35 var(--ds-font);color:var(--ds-ink);overflow-wrap:anywhere}", + ".wh-wb-ed-conflict .wh-conflict-head .wh-subtle,.wh-wb-ed-conflict .wh-conflict-summary,.wh-wb-ed-conflict .wh-conflict-workbench-body{margin:5px 0 0;font:600 12.5px/1.5 var(--ds-font);color:var(--ds-ink-muted);overflow-wrap:anywhere}", + ".wh-wb-ed-conflict .wh-conflict-meta,.wh-wb-ed-conflict .wh-conflict-options,.wh-wb-ed-conflict .wh-conflict-workbench-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start;margin-top:8px}", + ".wh-wb-ed-conflict .wh-pill{display:inline-flex;max-width:100%;border-radius:8px;background:var(--ds-glass);padding:5px 8px;color:var(--ds-ink-muted);font:700 11px/1.25 var(--ds-font);overflow-wrap:anywhere}", + ".wh-wb-ed-conflict .wh-btn{display:inline-flex;align-items:center;justify-content:center;min-height:32px;max-width:100%;border:1px solid var(--ds-glass-border);border-radius:var(--ds-radius-md);background:var(--ds-glass);padding:8px 11px;color:var(--ds-ink-soft);font:800 12.5px/1.15 var(--ds-font);text-align:center;text-decoration:none;white-space:normal;overflow-wrap:anywhere;word-break:break-word;cursor:pointer}", + ".wh-wb-ed-conflict .wh-btn:hover{filter:brightness(1.04)}", + ".wh-wb-ed-conflict .wh-btn-primary{border:0;color:#fff;background:linear-gradient(135deg,#0a84ff,#64d2ff);box-shadow:var(--ds-shadow-glow)}", + ".wh-wb-ed-conflict .wh-btn-danger{color:var(--ds-danger);border-color:rgba(255,69,58,.24);background:transparent}", + ".wh-wb-ed-conflict .wh-recommended{margin-left:6px;border-radius:999px;background:transparent;border:1px solid rgba(10,132,255,.18);padding:3px 6px;color:var(--ds-accent);font:800 10px/1 var(--ds-font)}", "@media (prefers-reduced-motion:reduce){.wh-wb-side,.wh-wb-winbtn,.wh-wb-project-row,.wh-wb-leaf,.wh-wb-btn," + ".wh-wb-chat-ctag,.wh-wb-chat-typing-dots i{transition-duration:.01ms!important;animation-duration:.01ms!important}}", diff --git a/apps/desktop-webview/src/workbench/editor/render.test.ts b/apps/desktop-webview/src/workbench/editor/render.test.ts index ee8b0602..abb1b127 100644 --- a/apps/desktop-webview/src/workbench/editor/render.test.ts +++ b/apps/desktop-webview/src/workbench/editor/render.test.ts @@ -95,3 +95,50 @@ test("renderEditorHtml renders the 415 unsupported state without action buttons" assert.match(html, /没有可逐行对照/u); assert.doesNotMatch(html, /data-wb-ed-approve/u); }); + +// R17-G5 #13:同提议多文件切换条。 +test("renderEditorHtml renders the file switcher only when a proposal has more than one text file", () => { + const single = renderEditorHtml( + ready({ files: { activePath: "/a.md", tabs: [{ path: "/a.md", filename: "a.md", changeType: "updated" }] } }), + "zh-CN" + ); + assert.doesNotMatch(single, /data-wb-ed-files/u); + + const multi = renderEditorHtml( + ready({ + diff: diff({ path: "/a.md", filename: "a.md" }), + files: { + activePath: "/a.md", + tabs: [ + { path: "/a.md", filename: "a.md", changeType: "updated", adds: 3, dels: 1 }, + { path: "/b.md", filename: "b.md", changeType: "created" } + ] + } + }), + "zh-CN" + ); + assert.match(multi, /data-wb-ed-files/u); + assert.match(multi, /data-wb-ed-file="\/a\.md"/u); + assert.match(multi, /data-wb-ed-file="\/b\.md"/u); + assert.match(multi, /data-wb-ed-file-prev/u); + assert.match(multi, /data-wb-ed-file-next/u); + // 加载过 diff 的文件显真实 +/−;没加载过的 b.md 退回 change_type 短标签。 + assert.match(multi, /\+3/u); + assert.match(multi, /−1/u); + assert.match(multi, /wh-wb-ed-ftab--active/u); + assert.match(multi, /1\/2/u); +}); + +// R17-G5 #12:合并冲突面板复用(conflict 模式渲传入的可信 HTML,无动作条)。 +test("renderEditorHtml renders the reused merge-conflict panel in conflict mode", () => { + const html = renderEditorHtml( + { mode: "conflict", filename: "privacy-section.md", conflictHtml: '
    冲突面板
    ' }, + "zh-CN" + ); + assert.match(html, /wh-wb-ed-conflict/u); + assert.match(html, /data-prop-conflict-panel/u); + assert.match(html, /冲突面板/u); + // 冲突态不渲常规批准/合并动作条。 + assert.doesNotMatch(html, /data-wb-ed-approve/u); + assert.doesNotMatch(html, /data-wb-ed-merge/u); +}); diff --git a/apps/desktop-webview/src/workbench/editor/render.ts b/apps/desktop-webview/src/workbench/editor/render.ts index efba90cc..eeff5926 100644 --- a/apps/desktop-webview/src/workbench/editor/render.ts +++ b/apps/desktop-webview/src/workbench/editor/render.ts @@ -32,11 +32,31 @@ export type EditorReadyUi = { expanded: ReadonlySet; }; +// #13(同提议多文件):编辑器顶部文件切换条的一格。adds/dels 只在该文件的 diff 被加载过后才有值 +// (view.ts 逐访问缓存,不预取全部 diff——避免一开编辑器就打 N 个 diff 请求);未加载时退回 change_type +// 短标签当轻量 diffstat 代理,诚实不编造行数。 +export type EditorFileTab = { + path: string; + filename: string; + changeType: ProposalChangeDiffVM["change_type"]; + adds?: number; + dels?: number; +}; + +// 同一份提议里所有可逐行对照的文本变动文件 + 当前激活的那个(view.ts 维护,render 只读)。 +export type EditorFilesState = { + tabs: EditorFileTab[]; + activePath: string; +}; + export type EditorViewState = | { mode: "loading"; filename: string } | { mode: "error"; filename: string; message: string } | { mode: "unsupported"; filename: string } - | { mode: "ready"; diff: ProposalChangeDiffVM; actions: EditorReadyActions; ui: EditorReadyUi }; + // #12(合并撞真实冲突):merge_conflict 分支——复用 Spotlight 既有逐冲突解决面板的 HTML + // (proposalMergeConflictHtml,view.ts 传入已构好的可信 HTML 串),不再一律渲「状态变了刷新看看」。 + | { mode: "conflict"; filename: string; conflictHtml: string } + | { mode: "ready"; diff: ProposalChangeDiffVM; actions: EditorReadyActions; ui: EditorReadyUi; files?: EditorFilesState }; const STATUS_LABEL: Record = { opened: ["变更待审", "Open"], @@ -55,6 +75,56 @@ function statusTone(status: EditorProposalStatus): string { return status === "merged" ? "merged" : status === "rejected" ? "rejected" : status === "reviewed" ? "reviewed" : "opened"; } +// #13:文件切换条的每格 diffstat。加载过 diff 的文件显真实 +adds/−dels;没加载过的退回 change_type 短标签。 +const CHANGE_TYPE_SHORT: Record = { + created: ["新增", "New"], + updated: ["修改", "Edit"], + deleted: ["删除", "Del"], + renamed: ["重命名", "Ren"], + moved: ["移动", "Move"], + replaced: ["替换", "Repl"], + generated: ["生成", "Gen"] +}; + +function fileTabStatHtml(tab: EditorFileTab, zh: boolean): string { + if (tab.adds !== undefined || tab.dels !== undefined) { + const adds = tab.adds ?? 0; + const dels = tab.dels ?? 0; + return `+${adds} −${dels}`; + } + const entry = CHANGE_TYPE_SHORT[tab.changeType]; + return `${escapeHtml(zh ? entry[0] : entry[1])}`; +} + +// #13:同提议含多个文本变动文件时的顶部切换条(文件名 chips + diffstat + 上一个/下一个)。单文件不渲。 +function filesBarHtml(files: EditorFilesState | undefined, zh: boolean): string { + if (!files || files.tabs.length <= 1) { + return ""; + } + const activeIdx = Math.max(0, files.tabs.findIndex((tab) => tab.path === files.activePath)); + const atFirst = activeIdx <= 0; + const atLast = activeIdx >= files.tabs.length - 1; + const chips = files.tabs + .map((tab) => { + const active = tab.path === files.activePath; + return ``; + }) + .join(""); + return `
    + +
    ${chips}
    + + ${activeIdx + 1}/${files.tabs.length} +
    `; +} + function headerHtml(filename: string, statusChip: string, zh: boolean): string { return `
    ${workbenchIcons.file}${escapeHtml(filename)} @@ -172,9 +242,19 @@ export function renderEditorHtml(state: EditorViewState, locale: Locale): string }
    `; } + // #12:合并撞真实冲突——渲复用的逐冲突解决面板(conflictHtml 是 proposalMergeConflictHtml 产出的可信 HTML, + // 内含 data-prop-back 返回把手 + 各冲突选项动作,view.ts 接管点击)。没有动作条:处理走面板内的冲突动作。 + if (state.mode === "conflict") { + const conflictChip = `${escapeHtml(zh ? "有冲突" : "Conflict")}`; + return `
    + ${headerHtml(state.filename, conflictChip, zh)} +
    ${state.conflictHtml}
    +
    `; + } const statusChip = `${escapeHtml(statusLabel(state.actions.status, zh))}`; return `
    ${headerHtml(state.diff.filename, statusChip, zh)} + ${filesBarHtml(state.files, zh)}
    ${bodyHtml(state.diff, state.ui, zh)}
    ${actionsHtml(state.actions, state.ui, zh)}
    `; diff --git a/apps/desktop-webview/src/workbench/editor/view.ts b/apps/desktop-webview/src/workbench/editor/view.ts index b8624e14..d9a05539 100644 --- a/apps/desktop-webview/src/workbench/editor/view.ts +++ b/apps/desktop-webview/src/workbench/editor/view.ts @@ -6,12 +6,19 @@ import type { PageRequestOptions, WorkHubApiClient } from "@workhub/api-client"; import { WorkHubApiError } from "@workhub/api-client"; -import type { MergeProposalRequest, ProposalDetailVM } from "@workhub/contracts"; +import type { MergeProposalRequest, ProposalChangeDiffVM, ProposalDetailVM } from "@workhub/contracts"; +import { actionElementApplyPayload, actionElementMergePayload, actionHrefFromElement } from "@workhub/web-runtime"; -import { reviewProposalWithoutMerge } from "../../spotlight/views/proposals.js"; +import { + classifyProposalConflictActionHref, + proposalMergeConflictHtml, + reviewProposalWithoutMerge +} from "../../spotlight/views/proposals.js"; import { fetchProposalChangeDiff, fetchProposalDetail } from "./api.js"; import { renderEditorHtml, + type EditorFileTab, + type EditorFilesState, type EditorProposalStatus, type EditorReadyActions, type EditorViewState @@ -19,7 +26,12 @@ import { type Locale = "zh-CN" | "en-US"; -export type EditorViewApiClient = Pick; +// #12 合并冲突逐条解决要用到 applyMergeProposalCandidate(apply 候选合入方案)——additive 扩客户端切面, +// shell 传的是全量 WorkHubApiClient,运行时本就有。 +export type EditorViewApiClient = Pick< + WorkHubApiClient, + "request" | "pages" | "mergeProposal" | "reviewProposal" | "applyMergeProposalCandidate" +>; export type EditorViewTarget = { proposalId: string; @@ -47,6 +59,41 @@ function mergePayloadFromDetail(detail: ProposalDetailVM): MergeProposalRequest return (detail.review_actions.merge?.request_json ?? undefined) as MergeProposalRequest | undefined; } +// #13:从 diff 的 segments 数出 +新增 / −删除 行数(context 段不计)。 +function diffStat(diff: ProposalChangeDiffVM): { adds: number; dels: number } { + let adds = 0; + let dels = 0; + for (const segment of diff.segments) { + if (segment.type === "add") { + adds += segment.lines.length; + } else if (segment.type === "del") { + dels += segment.lines.length; + } + } + return { adds, dels }; +} + +function filenameFromPath(path: string): string { + const parts = path.split("/").filter((part) => part.length > 0); + return parts.length > 0 ? parts[parts.length - 1]! : path; +} + +// #13:从提议详情的 manifest.changes 里挑出「可逐行对照的文本变动文件」(有 path + generated_content_md, +// 与 shell.openProposalInEditor 挑第一个可对照文件的口径一致),去重、保序,作为切换条的文件集合。 +function fileTabsFromDetail(detail: ProposalDetailVM): EditorFileTab[] { + const tabs: EditorFileTab[] = []; + const seen = new Set(); + for (const change of detail.manifest.changes) { + const path = change.target_ref.path; + if (!path || typeof change.machine_summary?.generated_content_md !== "string" || seen.has(path)) { + continue; + } + seen.add(path); + tabs.push({ path, filename: filenameFromPath(path), changeType: change.change_type }); + } + return tabs; +} + export function mountEditorView( container: HTMLElement, input: { @@ -69,6 +116,13 @@ export function mountEditorView( const expanded = new Set(); let busy = false; let loadGeneration = 0; + const proposalId = input.target.proposalId; + // #13:当前激活的变动文件路径(首屏=打开时那个),文件集合,以及每个文件 diff 的按需缓存 + // (逐访问 + 首屏后台预取——文件数由 manifest 天然有界,非无上限翻页)。 + let activePath = input.target.path; + let fileTabs: EditorFileTab[] = []; + let currentDiff: ProposalChangeDiffVM | undefined; + const diffCache = new Map(); function render(): void { if (disposed) { @@ -78,10 +132,40 @@ export function mountEditorView( container.innerHTML = renderEditorHtml(state, input.locale); } + function filesState(): EditorFilesState | undefined { + return fileTabs.length > 1 ? { tabs: fileTabs, activePath } : undefined; + } + + // 把某文件已加载的 diff 行数回填到它的切换条格子上(其它未加载的格子退回 change_type 短标签)。 + function applyStatToTab(path: string, diff: ProposalChangeDiffVM): void { + const tab = fileTabs.find((candidate) => candidate.path === path); + if (tab) { + const stat = diffStat(diff); + tab.adds = stat.adds; + tab.dels = stat.dels; + } + } + + // 依当前 currentDiff/detail/文件集合构建 ready 帧(合并/批准动作是提议级,与当前看哪个文件无关)。 + function toReady(): void { + if (!currentDiff || !detail) { + return; + } + const files = filesState(); + state = { + mode: "ready", + diff: currentDiff, + actions: actionsFromDetail(detail), + ui: { expanded }, + ...(files ? { files } : {}) + }; + } + function setReadyUi(patch: { busy?: "approve" | "merge"; notice?: string } = {}): void { if (state.mode !== "ready") { return; } + const files = state.files; state = { mode: "ready", diff: state.diff, @@ -90,19 +174,63 @@ export function mountEditorView( expanded, ...(patch.busy ? { busy: patch.busy } : {}), ...(patch.notice ? { notice: patch.notice } : {}) - } + }, + ...(files ? { files } : {}) }; render(); } + // 首屏后台预取其它文件的 diff——填满各 chip 的真实 diffstat + 让切换瞬时命中缓存。失败静默降级 + // (该 chip 退回 change_type 标签),不影响主流程。generation 绑当前一轮加载,切换/重拉后作废。 + function prefetchOtherDiffs(generation: number): void { + for (const tab of fileTabs) { + if (tab.path === activePath || diffCache.has(tab.path)) { + continue; + } + void fetchProposalChangeDiff(input.client, proposalId, tab.path) + .then((diff) => { + if (disposed || generation !== loadGeneration) { + return; + } + diffCache.set(tab.path, diff); + applyStatToTab(tab.path, diff); + if (state.mode === "ready") { + toReady(); + render(); + } + }) + .catch(() => undefined); + } + } + + function rebuildFileTabs(loadedDetail: ProposalDetailVM, activeDiff: ProposalChangeDiffVM): void { + const rebuilt = fileTabsFromDetail(loadedDetail); + fileTabs = rebuilt.length > 0 ? rebuilt : []; + // 打开的文件理应在 manifest 里;万一不在(历史提议口径差异),补一格保证切换条与实际一致。 + if (!fileTabs.some((tab) => tab.path === activePath)) { + fileTabs.unshift({ path: activePath, filename: input.target.filename, changeType: activeDiff.change_type }); + } + // 已缓存过 diff 的文件回填真实 diffstat(重拉后不丢已知统计)。 + for (const tab of fileTabs) { + const cached = diffCache.get(tab.path); + if (cached) { + applyStatToTab(tab.path, cached); + } + } + } + function load(): void { const generation = ++loadGeneration; busy = false; + activePath = input.target.path; + fileTabs = []; + currentDiff = undefined; + diffCache.clear(); state = { mode: "loading", filename: input.target.filename }; render(); void Promise.all([ - fetchProposalChangeDiff(input.client, input.target.proposalId, input.target.path), - fetchProposalDetail(input.client, input.target.proposalId, pageOptions(input.locale)) + fetchProposalChangeDiff(input.client, proposalId, activePath), + fetchProposalDetail(input.client, proposalId, pageOptions(input.locale)) ]) .then(([diff, loadedDetail]) => { if (disposed || generation !== loadGeneration) { @@ -110,8 +238,12 @@ export function mountEditorView( } detail = loadedDetail; mergePayload = mergePayloadFromDetail(loadedDetail); - state = { mode: "ready", diff, actions: actionsFromDetail(loadedDetail), ui: { expanded } }; + diffCache.set(activePath, diff); + currentDiff = diff; + rebuildFileTabs(loadedDetail, diff); + toReady(); render(); + prefetchOtherDiffs(generation); }) .catch((error: unknown) => { if (disposed || generation !== loadGeneration) { @@ -138,11 +270,12 @@ export function mountEditorView( // 批准/合并后重拉(status 翻新:opened→reviewed 出「合并」、reviewed→merged 终态只读),复用同一条 // loadGeneration 防竞态。不回退成错误页(动作已成功、onSettled 已触发),失败就保留当前一帧。 + // 保留当前激活文件(不重置回打开时那个),并刷新它的 diff。 function reload(): void { const generation = ++loadGeneration; void Promise.all([ - fetchProposalChangeDiff(input.client, input.target.proposalId, input.target.path), - fetchProposalDetail(input.client, input.target.proposalId, pageOptions(input.locale)) + fetchProposalChangeDiff(input.client, proposalId, activePath), + fetchProposalDetail(input.client, proposalId, pageOptions(input.locale)) ]) .then(([diff, loadedDetail]) => { if (disposed || generation !== loadGeneration) { @@ -150,17 +283,93 @@ export function mountEditorView( } detail = loadedDetail; mergePayload = mergePayloadFromDetail(loadedDetail); - state = { mode: "ready", diff, actions: actionsFromDetail(loadedDetail), ui: { expanded } }; + diffCache.set(activePath, diff); + currentDiff = diff; + rebuildFileTabs(loadedDetail, diff); + if (!fileTabs.some((tab) => tab.path === activePath) && fileTabs[0]) { + activePath = fileTabs[0].path; + const first = diffCache.get(activePath); + if (first) { + currentDiff = first; + } + } + toReady(); render(); + prefetchOtherDiffs(generation); }) .catch(() => undefined); } + // #13:切到另一个变动文件——命中缓存瞬时切;未命中则拉该文件 diff(loadGeneration 防竞态)。 + // 动作条不变(提议级);只换中栏 diff 与高亮的 chip。 + function switchFile(path: string): void { + if (disposed || path === activePath) { + return; + } + const tab = fileTabs.find((candidate) => candidate.path === path); + if (!tab) { + return; + } + activePath = path; + const cached = diffCache.get(path); + if (cached) { + currentDiff = cached; + applyStatToTab(path, cached); + toReady(); + render(); + return; + } + const generation = ++loadGeneration; + state = { mode: "loading", filename: tab.filename }; + render(); + void fetchProposalChangeDiff(input.client, proposalId, path) + .then((diff) => { + if (disposed || generation !== loadGeneration) { + return; + } + diffCache.set(path, diff); + currentDiff = diff; + applyStatToTab(path, diff); + toReady(); + render(); + }) + .catch((error: unknown) => { + if (disposed || generation !== loadGeneration) { + return; + } + state = { + mode: "error", + filename: tab.filename, + message: + error instanceof Error && error.message + ? error.message + : input.locale === "zh-CN" + ? "这个文件没打开,稍后重试" + : "Couldn't open this file — retry" + }; + render(); + }); + } + + function stepFile(delta: number): void { + if (fileTabs.length <= 1) { + return; + } + const idx = fileTabs.findIndex((tab) => tab.path === activePath); + const nextIdx = idx + delta; + if (nextIdx < 0 || nextIdx >= fileTabs.length) { + return; + } + const next = fileTabs[nextIdx]; + if (next) { + switchFile(next.path); + } + } + function approve(): void { if (busy || state.mode !== "ready" || state.actions.status !== "opened") { return; } - const proposalId = input.target.proposalId; busy = true; setReadyUi({ busy: "approve" }); void reviewProposalWithoutMerge(input.client, proposalId, pageOptions(input.locale)) @@ -181,11 +390,22 @@ export function mountEditorView( }); } + // #12:合并撞真实冲突时切到逐冲突解决面板(conflict 模式),其它 409 保持既有「状态变了刷新看看」文案。 + function showConflictOr(error: unknown, fallback: () => void): void { + const zh = input.locale === "zh-CN"; + const conflictHtml = proposalMergeConflictHtml(error, zh); + if (conflictHtml) { + state = { mode: "conflict", filename: currentDiff?.filename ?? input.target.filename, conflictHtml }; + render(); + return; + } + fallback(); + } + function merge(): void { if (busy || state.mode !== "ready" || !detail || state.actions.status !== "reviewed") { return; } - const proposalId = input.target.proposalId; busy = true; setReadyUi({ busy: "merge" }); void input.client @@ -200,13 +420,65 @@ export function mountEditorView( }) .catch((error: unknown) => { busy = false; - if (disposed || state.mode !== "ready") { + if (disposed) { return; } - setReadyUi({ notice: actionErrorText(error, input.locale) }); + showConflictOr(error, () => { + if (state.mode === "ready") { + setReadyUi({ notice: actionErrorText(error, input.locale) }); + } + }); }); } + // #12:冲突面板里的逐条动作(apply 候选 / 直接 merge / 看详情)——复用 Spotlight 那套 href 分类器 + + // web-runtime 载荷解析 + client 方法,行为与 proposals.ts/attention.ts 的 runConflictAction 一致。 + // 成功 → onSettled + reload 回到(已合并的)diff;再撞冲突 → 重渲面板;其它错误 → 回 diff 并给提示。 + async function runConflictAction(targetEl: HTMLElement): Promise { + const zh = input.locale === "zh-CN"; + const href = actionHrefFromElement(targetEl); + const action = classifyProposalConflictActionHref(href); + if (action.kind === "detail") { + // 编辑器本就在看这份提议——回到 diff 即可(不另开右栏详情)。 + reload(); + return; + } + if (busy) { + return; + } + busy = true; + try { + let result: unknown; + if (action.kind === "apply") { + const payload = actionElementApplyPayload(targetEl); + if (!payload.ok) { + return; + } + result = await input.client.applyMergeProposalCandidate(action.applyId, payload.payload, pageOptions(input.locale)); + } else if (action.kind === "merge") { + const payload = actionElementMergePayload(targetEl); + if (!payload.ok) { + return; + } + result = await input.client.mergeProposal(action.proposalId, payload.payload, pageOptions(input.locale)); + } + if (!result || disposed) { + return; + } + input.onSettled(proposalId); + reload(); + } catch (error) { + if (disposed) { + return; + } + showConflictOr(error, () => { + reload(); + }); + } finally { + busy = false; + } + } + container.addEventListener("click", (event) => { if (!(event.target instanceof HTMLElement)) { return; @@ -220,6 +492,35 @@ export function mountEditorView( load(); return; } + // #12:冲突面板内的动作——返回把手回到 diff;逐条冲突动作链接走 runConflictAction。 + if (state.mode === "conflict") { + if (el.closest("[data-prop-back]")) { + reload(); + return; + } + const conflictAction = el.closest( + "[data-prop-conflict-panel] a[href],[data-prop-conflict-panel] [data-action-href],[data-prop-conflict-panel] [data-href]" + ); + if (conflictAction) { + event.preventDefault(); + void runConflictAction(conflictAction); + } + return; + } + // #13:文件切换条——上一个/下一个 + 点 chip 直切。 + if (el.closest("[data-wb-ed-file-prev]")) { + stepFile(-1); + return; + } + if (el.closest("[data-wb-ed-file-next]")) { + stepFile(1); + return; + } + const fileTab = el.closest("[data-wb-ed-file]"); + if (fileTab?.dataset.wbEdFile) { + switchFile(fileTab.dataset.wbEdFile); + return; + } if (el.closest("[data-wb-ed-approve]")) { approve(); return; From c8f8a5171e30566e5d1f2a13c12a1daf2a3d5132 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:33:27 +0800 Subject: [PATCH 20/23] feat(kanban): #27 assignee + keyword front-end filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 看板顶部加负责人下拉(取自卡片集合 assignee 去重,含「未指派」)+ 关键词输入 (标题/编号大小写不敏感匹配)+ 清除按钮。纯前端过滤不重新取数,四列计数随过滤更新, 过滤后总数显「命中 / 总数(已筛选)」;过滤到零给「无匹配 / 清除筛选」提示而非四个空列。 关键词整窗重渲后还原输入框焦点与光标位置。render.test.ts +4(去重/过滤纯函数 + 计数 + 无匹配)。 Co-Authored-By: Claude Fable 5 --- apps/desktop-webview/src/workbench/css.ts | 10 ++ .../src/workbench/kanban/render.test.ts | 61 +++++++++++ .../src/workbench/kanban/render.ts | 101 +++++++++++++++++- .../src/workbench/kanban/view.ts | 33 ++++++ 4 files changed, 202 insertions(+), 3 deletions(-) diff --git a/apps/desktop-webview/src/workbench/css.ts b/apps/desktop-webview/src/workbench/css.ts index 9be81747..436097b5 100644 --- a/apps/desktop-webview/src/workbench/css.ts +++ b/apps/desktop-webview/src/workbench/css.ts @@ -1169,6 +1169,16 @@ export const workbenchCss = [ ".wh-wb-kb-top{flex:0 0 auto;display:flex;align-items:baseline;gap:12px;padding:12px 18px 10px;flex-wrap:wrap}", ".wh-wb-kb-total{font:700 15px/1.3 var(--ds-font);color:var(--ds-ink)}", ".wh-wb-kb-hint{font:500 11.5px/1.4 var(--ds-font);color:var(--ds-ink-faint)}", + // R17-G5 #27:看板顶部过滤条(负责人下拉 + 关键词 + 清除)。 + ".wh-wb-kb-filters{flex:0 0 auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:0 18px 10px}", + ".wh-wb-kb-select,.wh-wb-kb-search{font:500 12px/1.3 var(--ds-font);color:var(--ds-ink);background:var(--ds-glass);" + + "border:1px solid var(--ds-glass-border);border-radius:8px;padding:6px 10px;height:30px;box-sizing:border-box}", + ".wh-wb-kb-select{cursor:pointer;max-width:200px}", + ".wh-wb-kb-search{min-width:170px;flex:0 1 240px}", + ".wh-wb-kb-select:focus-visible,.wh-wb-kb-search:focus-visible{outline:2px solid var(--ds-accent);outline-offset:1px}", + ".wh-wb-kb-filter-clear{font:600 11.5px/1 var(--ds-font);color:var(--ds-ink-muted);background:transparent;" + + "border:1px solid var(--ds-glass-border);border-radius:8px;padding:7px 11px;cursor:pointer}", + ".wh-wb-kb-filter-clear:hover{background:var(--ds-glass-strong);color:var(--ds-ink)}", ".wh-wb-kb-notice{margin:0 18px 8px;padding:8px 12px;border-radius:var(--ds-radius-sm);font:600 12px/1.5 var(--ds-font)}", ".wh-wb-kb-notice--info{background:var(--ds-accent-soft);color:var(--ds-accent);border:1px solid rgba(10,132,255,.24)}", ".wh-wb-kb-notice--error{background:var(--ds-danger-soft);color:var(--ds-danger);border:1px solid rgba(255,69,58,.24)}", diff --git a/apps/desktop-webview/src/workbench/kanban/render.test.ts b/apps/desktop-webview/src/workbench/kanban/render.test.ts index a20d7811..25629174 100644 --- a/apps/desktop-webview/src/workbench/kanban/render.test.ts +++ b/apps/desktop-webview/src/workbench/kanban/render.test.ts @@ -6,6 +6,9 @@ import type { ProjectTimelinePageVM, TimelineWorkItemVM } from "@workhub/contrac import { columnOfStatus, emptyKanbanUiState, + filterKanbanItems, + kanbanAssigneeOptions, + KANBAN_UNASSIGNED_FILTER, renderKanbanErrorHtml, renderKanbanHtml, renderKanbanLoadingHtml, @@ -150,6 +153,64 @@ test("notice banner surfaces a humanized drag message with its tone", () => { assert.equal(html.includes("评审是 AI 跑完自动进入的"), true); }); +// —— R17-G5 #27:负责人 + 关键词纯前端过滤 —— +test("kanbanAssigneeOptions dedups assignees by user_id and sorts by label", () => { + const options = kanbanAssigneeOptions([ + item({ id: "a", assignee: { user_id: "u2", label: "Bob" } }), + item({ id: "b", assignee: { user_id: "u1", label: "Alice" } }), + item({ id: "c", assignee: { user_id: "u1", label: "Alice" } }), + item({ id: "d" }) + ]); + assert.deepEqual(options, [ + { userId: "u1", label: "Alice" }, + { userId: "u2", label: "Bob" } + ]); +}); + +test("filterKanbanItems narrows by assignee, unassigned sentinel, and keyword (title/code)", () => { + const items = [ + item({ id: "a", code: "WH-A", title: "打通登录", assignee: { user_id: "u1", label: "Alice" } }), + item({ id: "b", code: "WH-B", title: "支付回调", assignee: { user_id: "u2", label: "Bob" } }), + item({ id: "c", code: "WH-C", title: "登录埋点" }) + ]; + assert.deepEqual(filterKanbanItems(items, { assigneeUserId: "u1" }).map((i) => i.id), ["a"]); + assert.deepEqual(filterKanbanItems(items, { assigneeUserId: KANBAN_UNASSIGNED_FILTER }).map((i) => i.id), ["c"]); + assert.deepEqual(filterKanbanItems(items, { keyword: "登录" }).map((i) => i.id), ["a", "c"]); + assert.deepEqual(filterKanbanItems(items, { keyword: "wh-b" }).map((i) => i.id), ["b"]); + assert.deepEqual(filterKanbanItems(items, { assigneeUserId: "u1", keyword: "支付" }).map((i) => i.id), []); +}); + +test("board renders the filter bar and updates counts under an active filter", () => { + const built = renderKanbanHtml({ + locale: "zh-CN", + ui: ui({ assigneeUserId: "u1" }), + vm: vm({ + items: [ + item({ id: "a", code: "WH-A", status: "spec_ready", assignee: { user_id: "u1", label: "Alice" } }), + item({ id: "b", code: "WH-B", status: "ai_working", assignee: { user_id: "u2", label: "Bob" } }) + ] + }) + }); + assert.equal(built.includes("data-wb-kb-filter-assignee"), true); + assert.equal(built.includes("data-wb-kb-filter-keyword"), true); + assert.equal(built.includes("data-wb-kb-filter-clear"), true); + // 只剩 u1 的一张卡;总数显示「命中 / 总数(已筛选)」。 + assert.equal(built.includes('data-wb-kb-id="a"'), true); + assert.equal(built.includes('data-wb-kb-id="b"'), false); + assert.equal(built.includes("1 / 2 个任务(已筛选)"), true); +}); + +test("filter with no matches shows a clear-filter hint instead of four empty columns", () => { + const built = renderKanbanHtml({ + locale: "zh-CN", + ui: ui({ keyword: "zzz" }), + vm: vm({ items: [item({ id: "a", code: "WH-A", title: "打通登录", status: "spec_ready" })] }) + }); + assert.equal(built.includes("没有匹配的任务"), true); + assert.equal(built.includes("data-wb-kb-filter-keyword"), true); + assert.equal(built.includes("wh-wb-kb-board"), false); +}); + // —— 状态机映射(resolveKanbanDrop):唯一合法转移 = 待开工→进行中 = 派发;其余弹回 —— test("Ready → In progress resolves to a real dispatch (the one legal drag)", () => { diff --git a/apps/desktop-webview/src/workbench/kanban/render.ts b/apps/desktop-webview/src/workbench/kanban/render.ts index fb4b8240..81db915c 100644 --- a/apps/desktop-webview/src/workbench/kanban/render.ts +++ b/apps/desktop-webview/src/workbench/kanban/render.ts @@ -22,16 +22,63 @@ export type KanbanColumn = "todo" | "doing" | "review" | "done"; // 看板本地 UI 态——由 view.ts 持有,render 只读。busy = 正在派发;notice = 上一次拖拽的人话提示 // (合法转移的错误,或不合法转移的「该去哪儿办」说明),非空时在看板顶部渲一条可读横幅。 +// R17-G5 #27:assigneeUserId/keyword = 纯前端过滤态(不重新取数)——assigneeUserId 空=全部, +// UNASSIGNED_FILTER=只看未指派;keyword 匹配标题/编号(大小写不敏感)。列计数随过滤更新。 export type KanbanUiState = { busy: boolean; notice?: string | undefined; noticeTone?: "info" | "error" | undefined; + assigneeUserId?: string | undefined; + keyword?: string | undefined; }; export function emptyKanbanUiState(): KanbanUiState { return { busy: false }; } +// #27:负责人下拉里「只看未指派」的哨兵值(真实 user_id 不会是这个串)。 +export const KANBAN_UNASSIGNED_FILTER = "__unassigned__"; + +// #27:卡片集合去重出负责人下拉选项(按 label 排序,稳定)。 +export function kanbanAssigneeOptions(items: readonly TimelineWorkItemVM[]): { userId: string; label: string }[] { + const map = new Map(); + for (const item of items) { + if (item.assignee) { + map.set(item.assignee.user_id, item.assignee.label); + } + } + return [...map.entries()] + .map(([userId, label]) => ({ userId, label })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +// #27:纯前端过滤——负责人(含「未指派」)+ 关键词(标题/编号)。纯函数,便于单测。 +export function filterKanbanItems( + items: readonly TimelineWorkItemVM[], + filter: { assigneeUserId?: string | undefined; keyword?: string | undefined } +): TimelineWorkItemVM[] { + const keyword = (filter.keyword ?? "").trim().toLowerCase(); + return items.filter((item) => { + if (filter.assigneeUserId) { + if (filter.assigneeUserId === KANBAN_UNASSIGNED_FILTER) { + if (item.assignee) { + return false; + } + } else if (item.assignee?.user_id !== filter.assigneeUserId) { + return false; + } + } + if (keyword && !`${item.title} ${item.code}`.toLowerCase().includes(keyword)) { + return false; + } + return true; + }); +} + +export function isKanbanFilterActive(ui: KanbanUiState): boolean { + return Boolean(ui.assigneeUserId) || Boolean(ui.keyword && ui.keyword.trim()); +} + const COLUMN_OF_STATUS: Record = { intake: "todo", ai_clarifying: "todo", @@ -246,6 +293,32 @@ export function renderKanbanErrorHtml(locale: Locale): string { }`; } +// #27:负责人下拉 + 关键词输入 + 清除按钮(纯前端过滤条)。整个项目没工作项时不渲(无可筛内容)。 +function renderFilterBarHtml(vm: ProjectTimelinePageVM, ui: KanbanUiState, zh: boolean): string { + const options = kanbanAssigneeOptions(vm.items); + const hasUnassigned = vm.items.some((item) => !item.assignee); + const optionHtml = [ + ``, + hasUnassigned + ? `` + : "", + ...options.map( + (opt) => + `` + ) + ].join(""); + const clear = isKanbanFilterActive(ui) + ? `` + : ""; + return `
    + + + ${clear} +
    `; +} + export function renderKanbanHtml(input: { vm: ProjectTimelinePageVM; locale: Locale; @@ -254,11 +327,14 @@ export function renderKanbanHtml(input: { const { vm, ui } = input; const zh = input.locale === "zh-CN"; + const filterActive = isKanbanFilterActive(ui); + const filtered = filterActive ? filterKanbanItems(vm.items, ui) : [...vm.items]; + const byColumn = new Map(); for (const spec of COLUMNS) { byColumn.set(spec.key, []); } - for (const item of vm.items) { + for (const item of filtered) { byColumn.get(columnOfStatus(item.status))?.push(item); } @@ -266,8 +342,17 @@ export function renderKanbanHtml(input: { ? `
    ${escapeHtml(ui.notice)}
    ` : ""; + // 计数:无过滤照旧「N 个任务」;过滤时显「命中 / 总数」,诚实标已筛选。 + const total = vm.items.length; + const totalHtml = filterActive + ? zh + ? `${filtered.length} / ${total} 个任务(已筛选)` + : `${filtered.length} / ${total} tasks (filtered)` + : zh + ? `${total} 个任务` + : `${total} tasks`; const header = `
    - ${zh ? `${vm.items.length} 个任务` : `${vm.items.length} tasks`} + ${totalHtml} ${ zh ? "拖「待开工」卡到「进行中」即派 AI 开工;其它移动会说明该去哪儿办" @@ -288,6 +373,16 @@ export function renderKanbanHtml(input: {
    `; } + const filterBar = renderFilterBarHtml(vm, ui, zh); + + // 过滤后一条不剩(但项目本身有任务)——给「无匹配 / 清除筛选」提示,而不是四个空列让人以为没数据。 + if (filterActive && filtered.length === 0) { + return `
    ${header}${filterBar}${notice}
    +

    ${zh ? "没有匹配的任务" : "No matching tasks"}

    +

    ${zh ? "换个负责人或关键词,或清除筛选。" : "Try another assignee or keyword, or clear the filter."}

    +
    `; + } + const columns = COLUMNS.map((spec) => renderColumnHtml(spec, byColumn.get(spec.key) ?? [], zh, ui.busy)).join(""); - return `
    ${header}${notice}
    ${columns}
    `; + return `
    ${header}${filterBar}${notice}
    ${columns}
    `; } diff --git a/apps/desktop-webview/src/workbench/kanban/view.ts b/apps/desktop-webview/src/workbench/kanban/view.ts index 54045523..fa008263 100644 --- a/apps/desktop-webview/src/workbench/kanban/view.ts +++ b/apps/desktop-webview/src/workbench/kanban/view.ts @@ -245,12 +245,45 @@ export function mountKanbanView( void load(); return; } + // #27:清除筛选。 + if (target.closest("[data-wb-kb-filter-clear]")) { + ui = { ...ui, assigneeUserId: undefined, keyword: undefined }; + render(); + return; + } const card = target.closest("[data-wb-kb-card]"); if (card?.dataset.wbKbId) { input.onOpenTimelineRow?.(card.dataset.wbKbId); } }); + // #27:负责人下拉切换(纯前端过滤,不重新取数)。 + container.addEventListener("change", (event) => { + if (!(event.target instanceof HTMLSelectElement) || !event.target.closest("[data-wb-kb-filter-assignee]")) { + return; + } + ui = { ...ui, assigneeUserId: event.target.value || undefined }; + render(); + }); + + // #27:关键词输入(标题/编号)。整窗重渲会重建输入框,重渲后把焦点与光标位置还原回去,避免每敲一下就跳。 + container.addEventListener("input", (event) => { + if (!(event.target instanceof HTMLInputElement) || !event.target.closest("[data-wb-kb-filter-keyword]")) { + return; + } + const caret = event.target.selectionStart; + ui = { ...ui, keyword: event.target.value }; + render(); + const next = container.querySelector("[data-wb-kb-filter-keyword]"); + if (next) { + next.focus(); + if (caret !== null) { + const pos = Math.min(caret, next.value.length); + next.setSelectionRange(pos, pos); + } + } + }); + // 键盘可达:卡片是 role=button tabindex=0,回车/空格等同点击(跳时间线)。 container.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") { From f9d915077e01a7ba193d6b7ed193756e95a4ead5 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:40:12 +0800 Subject: [PATCH 21/23] feat(schedule): #26 undated strip + #28 month/week toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #26 无 due_at 的工作项此前在日程里被 if(!due) continue 静默丢弃(看板/时间线都诚实标 「未定期」)。周历/月历底部加「未定期」小列(计数 + 列表),扫描口径与看板一致;点击复用 日程卡 data-wb-sc-card 通道跳时间线该行。全部有 due_at 时不渲。 #28 日程加「周/月」切换 chip。月视图=当月 6×7 网格(周一起,溢出日淡显、今天高亮), 任务压缩成状态着色小点 + 超 4 折「+K」,里程碑标 pin;点某天切回周视图并定位到那周 (weekOffsetForDay 算整周偏移)。上一/下一按当前模式移动一周或一月;「今天」归零两个偏移。 两项同属日程 render 重构,合并提交。render.test.ts +7(月网格/切换/computeScheduleMonth/ weekOffsetForDay/未定期小列),并把旧「undated 静默丢弃」断言更新为「落未定期小列」。 Co-Authored-By: Claude Fable 5 --- apps/desktop-webview/src/workbench/css.ts | 37 +++ .../src/workbench/schedule/render.test.ts | 59 ++++- .../src/workbench/schedule/render.ts | 224 +++++++++++++++--- .../src/workbench/schedule/view.ts | 54 ++++- 4 files changed, 335 insertions(+), 39 deletions(-) diff --git a/apps/desktop-webview/src/workbench/css.ts b/apps/desktop-webview/src/workbench/css.ts index 436097b5..9ecfdd4b 100644 --- a/apps/desktop-webview/src/workbench/css.ts +++ b/apps/desktop-webview/src/workbench/css.ts @@ -1317,6 +1317,43 @@ export const workbenchCss = [ ".wh-wb-sc-overdot{width:6px;height:6px;border-radius:50%;background:var(--ds-danger);flex:none}", ".wh-wb-sc-state{padding:40px 20px;text-align:center;font:500 13px/1.6 var(--ds-font);color:var(--ds-ink-muted);" + "display:flex;flex-direction:column;align-items:center;gap:10px;flex:1}", + // R17-G5 #28:周/月切换 chip。 + ".wh-wb-sc-modes{display:inline-flex;gap:2px;background:var(--ds-glass);border:1px solid var(--ds-glass-border);border-radius:8px;padding:2px;flex:none}", + ".wh-wb-sc-modechip{font:600 11.5px/1 var(--ds-font);color:var(--ds-ink-muted);background:transparent;border:0;border-radius:6px;padding:5px 11px;cursor:pointer}", + ".wh-wb-sc-modechip--active{background:var(--ds-glass-strong);color:var(--ds-ink);box-shadow:var(--ds-shadow-1)}", + // #28:月视图网格(6 行 × 7 列,任务压缩成小点;溢出日淡显;今天高亮)。 + ".wh-wb-sc-month{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;border-top:1px solid var(--ds-glass-border)}", + ".wh-wb-sc-mhead{flex:0 0 auto;display:grid;grid-template-columns:repeat(7,minmax(0,1fr))}", + ".wh-wb-sc-mdow{padding:6px 8px;font:500 10.5px/1 var(--ds-font);color:var(--ds-ink-faint);border-right:1px solid var(--ds-glass-hairline,var(--ds-glass-border))}", + ".wh-wb-sc-mdow:last-child{border-right:none}", + ".wh-wb-sc-mbody{flex:1 1 auto;min-height:0;display:grid;grid-template-rows:repeat(6,minmax(0,1fr));overflow:hidden}", + ".wh-wb-sc-mrow{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));border-top:1px solid var(--ds-glass-hairline,var(--ds-glass-border))}", + ".wh-wb-sc-mcell{min-width:0;display:flex;flex-direction:column;gap:4px;padding:5px 6px;border-right:1px solid var(--ds-glass-hairline,var(--ds-glass-border));cursor:pointer;overflow:hidden}", + ".wh-wb-sc-mcell:last-child{border-right:none}", + ".wh-wb-sc-mcell:hover{background:var(--ds-glass)}", + ".wh-wb-sc-mcell:focus-visible{outline:2px solid var(--ds-accent);outline-offset:-2px}", + ".wh-wb-sc-mcell--out{background:rgba(30,42,74,.02)}", + ".wh-wb-sc-mcell--out .wh-wb-sc-mdate{color:var(--ds-ink-faint)}", + ".wh-wb-sc-mcell-top{display:flex;align-items:center;gap:4px}", + ".wh-wb-sc-mdate{font:600 12px/1 var(--ds-font);color:var(--ds-ink);display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;border-radius:50%}", + ".wh-wb-sc-mcell--today .wh-wb-sc-mdate{background:var(--ds-accent);color:#fff}", + ".wh-wb-sc-mms{display:inline-flex;color:var(--ds-accent)}.wh-wb-sc-mms svg{width:11px;height:11px}", + ".wh-wb-sc-mdots{display:flex;flex-wrap:wrap;align-items:center;gap:3px}", + ".wh-wb-sc-mdot{width:6px;height:6px;border-radius:50%;flex:none}", + ".wh-wb-sc-mmore{font:600 9px/1 var(--ds-font);color:var(--ds-ink-faint)}", + // R17-G5 #26:底部「未定期」小列(无 due_at 的工作项,点击跳时间线)。 + ".wh-wb-sc-undated{flex:0 0 auto;max-height:132px;display:flex;flex-direction:column;border-top:1px solid var(--ds-glass-border);background:var(--ds-glass-quiet)}", + ".wh-wb-sc-undated-head{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:7px 16px 5px}", + ".wh-wb-sc-undated-t{font:600 11px/1 var(--ds-font);color:var(--ds-ink-muted)}", + ".wh-wb-sc-undated-count{font:700 9.5px/1 var(--ds-font);color:var(--ds-ink-faint);background:var(--ds-glass);border-radius:99px;min-width:17px;height:16px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center}", + ".wh-wb-sc-undated-list{flex:1 1 auto;min-height:0;overflow-y:auto;padding:0 12px 10px;display:flex;flex-direction:column;gap:5px}", + ".wh-wb-sc-undated-item{display:flex;align-items:center;gap:9px;padding:6px 9px;border:1px solid var(--ds-glass-border);border-radius:8px;" + + "background:var(--ds-glass-strong);cursor:pointer;transition:box-shadow var(--ds-dur-fast,120ms) var(--ds-ease,ease)}", + ".wh-wb-sc-undated-item:hover{box-shadow:var(--ds-shadow-1)}", + ".wh-wb-sc-undated-item:focus-visible{outline:2px solid var(--ds-accent);outline-offset:1px}", + ".wh-wb-sc-undated-code{flex:none;font:600 10px/1.3 var(--ds-mono,ui-monospace,monospace);color:var(--ds-ink-faint)}", + ".wh-wb-sc-undated-title{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 11.5px/1.3 var(--ds-font);color:var(--ds-ink)}", + ".wh-wb-sc-undated-status{flex:none;font:500 9.5px/1.3 var(--ds-font);color:var(--ds-ink-faint)}", "@keyframes ds-flash{0%{background:var(--ds-accent-soft)}100%{background:transparent}}" ].join(""); diff --git a/apps/desktop-webview/src/workbench/schedule/render.test.ts b/apps/desktop-webview/src/workbench/schedule/render.test.ts index 533d3c4b..91c373ce 100644 --- a/apps/desktop-webview/src/workbench/schedule/render.test.ts +++ b/apps/desktop-webview/src/workbench/schedule/render.test.ts @@ -5,12 +5,14 @@ import type { ProjectTimelinePageVM, TimelineWorkItemVM } from "@workhub/contrac import type { SchedulePlanDraft } from "./api.js"; import { + computeScheduleMonth, computeScheduleWeek, emptyScheduleUiState, emptySchedulePlanUiState, renderScheduleErrorHtml, renderScheduleHtml, renderScheduleLoadingHtml, + weekOffsetForDay, type SchedulePlanDraftsState, type SchedulePlanUiState, type ScheduleUiState @@ -110,6 +112,46 @@ test("weekly grid: 7 day columns, today highlighted, weekend columns marked", () assert.equal(html.includes("7 月 13 日"), true); }); +// R17-G5 #28:月视图 + 周/月切换。 +test("computeScheduleMonth: 6x7 Monday-start grid covering the focused month", () => { + const month = computeScheduleMonth("2026-07-15T00:00:00.000Z", 0); + assert.equal(month.year, 2026); + assert.equal(month.monthIndex, 6); // July + assert.equal(month.weeks.length, 6); + assert.equal(month.weeks[0]!.length, 7); + // 2026-07-01 是周三 → 网格起于该周周一 2026-06-29。 + assert.equal(month.weeks[0]![0]!.toISOString().slice(0, 10), "2026-06-29"); + assert.equal(month.todayKey, "2026-07-15"); +}); + +test("computeScheduleMonth: monthOffset shifts whole months", () => { + const next = computeScheduleMonth("2026-07-15T00:00:00.000Z", 1); + assert.equal(next.monthIndex, 7); // August +}); + +test("weekOffsetForDay: day in a later week resolves to that week's offset", () => { + // 今天 2026-07-15(本周一 07-13);目标 2026-07-27(那周一 07-27)→ 偏移 +2 周。 + assert.equal(weekOffsetForDay("2026-07-15T00:00:00.000Z", "2026-07-27"), 2); + assert.equal(weekOffsetForDay("2026-07-15T00:00:00.000Z", "2026-07-15"), 0); +}); + +test("month view renders the 6x7 grid, mode chips, and compressed task dots", () => { + const html = render({ + ui: ui({ viewMode: "month" }), + vm: vm({ items: [item({ id: "a", due_at: "2026-07-16T02:00:00Z" })] }) + }); + assert.equal(html.includes("wh-wb-sc-month"), true); + assert.equal((html.match(/data-wb-sc-day="/gu) ?? []).length, 42); + assert.equal(html.includes('data-wb-sc-mode="week"'), true); + assert.equal(html.includes('data-wb-sc-mode="month"'), true); + // 有任务的那天渲一个小点 + 该单元格带 data-wb-sc-day 供点击回周视图。 + assert.equal(html.includes("wh-wb-sc-mdot"), true); + assert.equal(html.includes('data-wb-sc-day="2026-07-16"'), true); + // 月标题 + 不渲周网格。 + assert.equal(html.includes("2026 年 7 月"), true); + assert.equal(html.includes("wh-wb-sc-grid"), false); +}); + test("work items land on their due day; the card carries the id and status meta", () => { const html = render({ vm: vm({ @@ -126,9 +168,20 @@ test("work items land on their due day; the card carries the id and status meta" assert.equal(html.includes('data-wb-sc-id="b"'), false); }); -test("undated items are simply not placed (no fake day)", () => { - const html = render({ vm: vm({ items: [item({ id: "a", code: "WH-A" })] }) }); - assert.equal(html.includes('data-wb-sc-id="a"'), false); +// R17-G5 #26:无 due_at 的工作项不再静默丢弃(也不放到假的某一天)——落到底部「未定期」小列。 +test("undated items surface in the 未定期 strip (not silently dropped, not on a fake day)", () => { + const html = render({ vm: vm({ items: [item({ id: "a", code: "WH-A", title: "待排期项" })] }) }); + assert.equal(html.includes("wh-wb-sc-undated"), true); + assert.equal(html.includes("未定期"), true); + assert.equal(html.includes('data-wb-sc-id="a"'), true); + assert.equal(html.includes("待排期项"), true); + // 网格列里没有它(不放到假的某一天)——周历单元格是空的。 + assert.equal(/
    \s*<\/div>/u.test(html), true); +}); + +test("schedule with no undated items renders no 未定期 strip", () => { + const html = render({ vm: vm({ items: [item({ id: "a", due_at: "2026-07-16T02:00:00Z" })] }) }); + assert.equal(html.includes("wh-wb-sc-undated"), false); }); test("overdue task shows an overdue dot", () => { diff --git a/apps/desktop-webview/src/workbench/schedule/render.ts b/apps/desktop-webview/src/workbench/schedule/render.ts index fde80da5..9d5ee27f 100644 --- a/apps/desktop-webview/src/workbench/schedule/render.ts +++ b/apps/desktop-webview/src/workbench/schedule/render.ts @@ -15,13 +15,16 @@ type Locale = "zh-CN" | "en-US"; const DAY_MS = 86_400_000; // 周历本地 UI 态——由 view.ts 持有,render 只读。weekOffset=相对「今天所在周」的偏移(0=本周, -// -1=上一周,+1=下一周)。 +// -1=上一周,+1=下一周)。R17-G5 #28:viewMode 周/月切换;monthOffset=相对「今天所在月」的月偏移。 +export type ScheduleViewMode = "week" | "month"; export type ScheduleUiState = { weekOffset: number; + monthOffset: number; + viewMode: ScheduleViewMode; }; export function emptyScheduleUiState(): ScheduleUiState { - return { weekOffset: 0 }; + return { weekOffset: 0, monthOffset: 0, viewMode: "week" }; } // G4 #9(E3 计划草案左栏):左栏计划面板的本地 UI 态——由 view.ts 持有,render 只读。 @@ -120,6 +123,46 @@ export function computeScheduleWeek(referenceIso: string, weekOffset: number): S }; } +// #28:当月(含填满整周的溢出日)6×7 网格。周一起,UTC 口径同周历。 +export type ScheduleMonth = { + weeks: Date[][]; + year: number; + monthIndex: number; // 0 = 一月 + todayKey: string; +}; + +export function computeScheduleMonth(referenceIso: string, monthOffset: number): ScheduleMonth { + const now = parseDate(referenceIso) ?? new Date(); + const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + monthOffset, 1)); + const gridStart = startOfWeekUtc(firstOfMonth); + const weeks: Date[][] = []; + for (let w = 0; w < 6; w += 1) { + const days: Date[] = []; + for (let d = 0; d < 7; d += 1) { + days.push(new Date(gridStart.getTime() + (w * 7 + d) * DAY_MS)); + } + weeks.push(days); + } + return { + weeks, + year: firstOfMonth.getUTCFullYear(), + monthIndex: firstOfMonth.getUTCMonth(), + todayKey: dayKey(now) + }; +} + +// #28:点月历某天 → 切回周视图并定位到那周。算出该天相对今天所在周的整周偏移。 +export function weekOffsetForDay(referenceIso: string, dayKeyStr: string): number { + const now = parseDate(referenceIso) ?? new Date(); + const target = parseDate(dayKeyStr); + if (!target) { + return 0; + } + const base = startOfWeekUtc(now).getTime(); + const targetWeek = startOfWeekUtc(target).getTime(); + return Math.round((targetWeek - base) / (7 * DAY_MS)); +} + function statusBorder(item: TimelineWorkItemVM): string { if (item.overdue) { return "var(--ds-danger)"; @@ -419,19 +462,11 @@ export function renderScheduleErrorHtml(locale: Locale): string { }
    `; } -export function renderScheduleHtml(input: { - vm: ProjectTimelinePageVM; - drafts: SchedulePlanDraft[]; - draftsState: SchedulePlanDraftsState; - plan: SchedulePlanUiState; - locale: Locale; - ui: ScheduleUiState; -}): string { - const { vm, drafts, draftsState, plan, ui } = input; - const zh = input.locale === "zh-CN"; - const week = computeScheduleWeek(vm.generated_at, ui.weekOffset); - - // 工作项按 due_at 的 UTC 日归组。 +// due_at 按 UTC 日归组(周历/月历共用)。 +function bucketByDay(vm: ProjectTimelinePageVM): { + itemsByDay: Map; + milestonesByDay: Map; +} { const itemsByDay = new Map(); for (const item of vm.items) { const due = parseDate(item.due_at); @@ -443,7 +478,6 @@ export function renderScheduleHtml(input: { bucket.push(item); itemsByDay.set(key, bucket); } - // 里程碑按 due_at 的 UTC 日归组(标在列头)。 const milestonesByDay = new Map(); for (const milestone of vm.milestones) { const due = parseDate(milestone.due_at); @@ -455,7 +489,41 @@ export function renderScheduleHtml(input: { bucket.push(milestone.title); milestonesByDay.set(key, bucket); } + return { itemsByDay, milestonesByDay }; +} + +// #28:周/月切换 chip。 +function viewToggleHtml(mode: ScheduleViewMode, zh: boolean): string { + const chip = (m: ScheduleViewMode, label: string) => + ``; + return `
    ${chip( + "week", + zh ? "周" : "Week" + )}${chip("month", zh ? "月" : "Month")}
    `; +} +function calHeadHtml(title: string, mode: ScheduleViewMode, zh: boolean): string { + const prevLabel = mode === "month" ? (zh ? "上个月" : "Previous month") : zh ? "上一周" : "Previous week"; + const nextLabel = mode === "month" ? (zh ? "下个月" : "Next month") : zh ? "下一周" : "Next week"; + return `
    + ${escapeHtml(title)} + ${viewToggleHtml(mode, zh)} +
    + + + +
    +
    `; +} + +function renderWeekGridHtml( + week: ScheduleWeek, + itemsByDay: Map, + milestonesByDay: Map, + zh: boolean +): string { const columns = week.days .map((day, index) => { const key = dayKey(day); @@ -481,22 +549,120 @@ export function renderScheduleHtml(input: { `; }) .join(""); + return `
    ${columns}
    `; +} - const cal = `
    -
    - ${escapeHtml(formatRange(week, zh))} -
    - - - -
    +// #28:月视图单元格——日期 + 任务压缩成小点(按状态着色,超 4 个折成「+K」)+ 里程碑标记。 +const MONTH_DOT_CAP = 4; +function monthCellHtml( + day: Date, + month: ScheduleMonth, + itemsByDay: Map, + milestonesByDay: Map, + zh: boolean +): string { + const key = dayKey(day); + const inMonth = day.getUTCMonth() === month.monthIndex; + const isToday = key === month.todayKey; + const dayItems = itemsByDay.get(key) ?? []; + const ms = milestonesByDay.get(key) ?? []; + const dots = dayItems + .slice(0, MONTH_DOT_CAP) + .map((item) => ``) + .join(""); + const overflow = dayItems.length > MONTH_DOT_CAP ? `+${dayItems.length - MONTH_DOT_CAP}` : ""; + const msDot = ms.length + ? `${workbenchIcons.pin}` + : ""; + const title = dayItems.length + ? zh + ? `${day.getUTCMonth() + 1}/${day.getUTCDate()}:${dayItems.length} 项,点击查看这周` + : `${day.getUTCMonth() + 1}/${day.getUTCDate()}: ${dayItems.length} item(s) — open this week` + : zh + ? "点击查看这周" + : "Open this week"; + return `
    +
    ${day.getUTCDate()}${msDot}
    + ${dayItems.length ? `
    ${dots}${overflow}
    ` : ""} +
    `; +} + +function renderMonthGridHtml( + month: ScheduleMonth, + itemsByDay: Map, + milestonesByDay: Map, + zh: boolean +): string { + const dowRow = (zh ? ZH_DOW : EN_DOW).map((label) => `
    ${label}
    `).join(""); + const rows = month.weeks + .map( + (week) => + `
    ${week + .map((day) => monthCellHtml(day, month, itemsByDay, milestonesByDay, zh)) + .join("")}
    ` + ) + .join(""); + return `
    ${dowRow}
    ${rows}
    `; +} + +function monthTitle(month: ScheduleMonth, zh: boolean): string { + return zh ? `${month.year} 年 ${month.monthIndex + 1} 月` : `${EN_MONTHS[month.monthIndex]} ${month.year}`; +} + +// #26:无 due_at 的工作项此前在日程里被静默丢弃(看板/时间线都诚实标「未定期」)。这里在周历/月历底部加 +// 一条「未定期」小列(计数 + 列表),扫描口径与看板「未排期」一致(parseDate(due_at) 为空);点击复用 +// 日程卡的 data-wb-sc-card 通道跳到时间线该行(view.ts 无需另接)。全部有 due_at 时不渲,不占空间。 +function renderUndatedStripHtml(vm: ProjectTimelinePageVM, zh: boolean): string { + const undated = vm.items.filter((item) => !parseDate(item.due_at)); + if (undated.length === 0) { + return ""; + } + const rows = [...undated] + .sort((a, b) => a.code.localeCompare(b.code)) + .map( + (item) => + `
    + ${escapeHtml(item.code)} + ${escapeHtml(item.title)} + ${escapeHtml(statusLabelShort(item.status, zh))} +
    ` + ) + .join(""); + return `
    +
    + ${zh ? "未定期" : "Undated"} + ${undated.length}
    -
    ${columns}
    +
    ${rows}
    `; +} + +export function renderScheduleHtml(input: { + vm: ProjectTimelinePageVM; + drafts: SchedulePlanDraft[]; + draftsState: SchedulePlanDraftsState; + plan: SchedulePlanUiState; + locale: Locale; + ui: ScheduleUiState; +}): string { + const { vm, drafts, draftsState, plan, ui } = input; + const zh = input.locale === "zh-CN"; + const { itemsByDay, milestonesByDay } = bucketByDay(vm); + + let head: string; + let grid: string; + if (ui.viewMode === "month") { + const month = computeScheduleMonth(vm.generated_at, ui.monthOffset); + head = calHeadHtml(monthTitle(month, zh), "month", zh); + grid = renderMonthGridHtml(month, itemsByDay, milestonesByDay, zh); + } else { + const week = computeScheduleWeek(vm.generated_at, ui.weekOffset); + head = calHeadHtml(formatRange(week, zh), "week", zh); + grid = renderWeekGridHtml(week, itemsByDay, milestonesByDay, zh); + } + const cal = `
    ${head}${grid}${renderUndatedStripHtml(vm, zh)}
    `; return `
    ${renderPlanPanelHtml({ vm, drafts, draftsState, plan, zh })}${cal}
    `; } diff --git a/apps/desktop-webview/src/workbench/schedule/view.ts b/apps/desktop-webview/src/workbench/schedule/view.ts index 0b7d3a00..5f859e5d 100644 --- a/apps/desktop-webview/src/workbench/schedule/view.ts +++ b/apps/desktop-webview/src/workbench/schedule/view.ts @@ -23,9 +23,11 @@ import { renderScheduleErrorHtml, renderScheduleHtml, renderScheduleLoadingHtml, + weekOffsetForDay, type SchedulePlanDraftsState, type SchedulePlanUiState, - type ScheduleUiState + type ScheduleUiState, + type ScheduleViewMode } from "./render.js"; import type { ProjectTimelinePageVM } from "@workhub/contracts"; @@ -145,16 +147,35 @@ export function mountScheduleView( return fallback; } - function shiftWeek(delta: number): void { - ui = { ...ui, weekOffset: ui.weekOffset + delta }; + // #28:上一/下一——按当前视图模式移动一周或一个月。 + function shiftPeriod(delta: number): void { + ui = ui.viewMode === "month" ? { ...ui, monthOffset: ui.monthOffset + delta } : { ...ui, weekOffset: ui.weekOffset + delta }; render(); } function goToday(): void { - if (ui.weekOffset === 0) { + if (ui.weekOffset === 0 && ui.monthOffset === 0) { return; } - ui = { ...ui, weekOffset: 0 }; + ui = { ...ui, weekOffset: 0, monthOffset: 0 }; + render(); + } + + // #28:周/月切换。 + function setViewMode(mode: ScheduleViewMode): void { + if (ui.viewMode === mode) { + return; + } + ui = { ...ui, viewMode: mode }; + render(); + } + + // #28:点月历某天 → 切回周视图并定位到那一周。 + function openWeekForDay(dayKeyStr: string): void { + if (!vm) { + return; + } + ui = { ...ui, viewMode: "week", weekOffset: weekOffsetForDay(vm.generated_at, dayKeyStr) }; render(); } @@ -358,18 +379,30 @@ export function mountScheduleView( void load(); return; } + // #28:周/月切换 chip。 + const modeChip = target.closest("[data-wb-sc-mode]"); + if (modeChip?.dataset.wbScMode) { + setViewMode(modeChip.dataset.wbScMode as ScheduleViewMode); + return; + } if (target.closest("[data-wb-sc-prev]")) { - shiftWeek(-1); + shiftPeriod(-1); return; } if (target.closest("[data-wb-sc-next]")) { - shiftWeek(1); + shiftPeriod(1); return; } if (target.closest("[data-wb-sc-today]")) { goToday(); return; } + // #28:月历单元格点击 → 切回周视图定位到那周。放在通用卡片处理之前(月单元格没有 data-wb-sc-id)。 + const monthCell = target.closest("[data-wb-sc-day]"); + if (monthCell?.dataset.wbScDay) { + openWeekForDay(monthCell.dataset.wbScDay); + return; + } // 计划草案控件。 if (target.closest("[data-wb-sc-plan-new]")) { openCompose(); @@ -437,6 +470,13 @@ export function mountScheduleView( if (card?.dataset.wbScId) { event.preventDefault(); input.onOpenTimelineRow?.(card.dataset.wbScId); + return; + } + // #28:月历单元格键盘可达(role=button tabindex=0)。 + const monthCell = event.target.closest("[data-wb-sc-day]"); + if (monthCell?.dataset.wbScDay) { + event.preventDefault(); + openWeekForDay(monthCell.dataset.wbScDay); } }); From 1c4b0c37185e520c03d11f4cc8ac7eb41106d192 Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:46:05 +0800 Subject: [PATCH 22/23] feat(inbox): #29 kind filter chips + approval batch approve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收件箱顶部加按 kind 筛选 chips(全部/审批/提议/升级/预算/冲突,带计数,只渲队列里真实 出现的组)+ 批量「全部通过」(仅 approval 类,带二次确认,逐条调用现有 respondApproval 端点、失败逐条标注到对应卡)。其它 kind 语义不适合批量(升级选处理方式/提议看 diff/预算看 额度/冲突逐条挑方案),不做。 筛选/批量态只活在工作台壳层(activeFilter/batchState/failedItemIds),不进共享 mountAttentionInbox 内部状态——共享 mount 照旧只渲队列 + 动作,spotlight 聚焦盒零改。 壳层用 MutationObserver 观察共享 mount 每次重渲后的 body:重算各组计数、按筛选隐藏不匹配 的卡、把批量失败标注回贴到对应卡(refresh 后仍在);disconnect/observe 包住自身 DOM 改动 避免自激。attention.ts 仅加 benign data-att-kind 元数据供 DOM 过滤。 Co-Authored-By: Claude Fable 5 --- .../src/spotlight/views/attention.ts | 2 +- .../src/workbench/inbox/css.ts | 20 ++ .../src/workbench/inbox/view.test.ts | 29 ++ .../src/workbench/inbox/view.ts | 265 +++++++++++++++++- 4 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-webview/src/workbench/inbox/view.test.ts diff --git a/apps/desktop-webview/src/spotlight/views/attention.ts b/apps/desktop-webview/src/spotlight/views/attention.ts index f351d7cd..09830e29 100644 --- a/apps/desktop-webview/src/spotlight/views/attention.ts +++ b/apps/desktop-webview/src/spotlight/views/attention.ts @@ -200,7 +200,7 @@ function renderCard(item: AttentionItem, zh: boolean): string { const desc = item.reason_text ?? item.summary_text ?? ""; const actions = item.actions.filter((a) => !isUnsupportedDesktopAction(a.href)); const title = attentionCardDisplayTitle(item, zh); - return `
    + return `
    diff --git a/apps/desktop-webview/src/workbench/inbox/css.ts b/apps/desktop-webview/src/workbench/inbox/css.ts index dbb17c02..b6f10e99 100644 --- a/apps/desktop-webview/src/workbench/inbox/css.ts +++ b/apps/desktop-webview/src/workbench/inbox/css.ts @@ -15,6 +15,26 @@ export const workbenchInboxCss = [ ".wh-wb-inbox-toast--ok{border-color:rgba(52,199,89,.28);color:var(--ds-success)}", ".wh-wb-inbox-toast--error{border-color:rgba(255,69,58,.28);color:var(--ds-danger)}", ".wh-wb-inbox-toast[hidden]{display:none}", + // R17-G5 #29:按 kind 筛选 chips + 批量通过控件(壳层,不进共享卡片结构)。 + ".wh-wb-inbox-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}", + ".wh-wb-inbox-controls[hidden]{display:none}", + ".wh-wb-inbox-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1 1 auto;min-width:0}", + ".wh-wb-inbox-chip{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--ds-font);color:var(--ds-ink-muted);" + + "background:transparent;border:1px solid var(--ds-glass-border);border-radius:var(--ds-radius-pill);padding:6px 11px;cursor:pointer}", + ".wh-wb-inbox-chip:hover{background:var(--ds-glass)}", + ".wh-wb-inbox-chip--active{color:var(--ds-accent);border-color:rgba(10,132,255,.32);background:var(--ds-accent-soft)}", + ".wh-wb-inbox-chip-n{font:700 10px/1 var(--ds-font);color:var(--ds-ink-faint);background:var(--ds-glass);border-radius:99px;min-width:16px;height:15px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center}", + ".wh-wb-inbox-chip--active .wh-wb-inbox-chip-n{color:var(--ds-accent);background:transparent}", + ".wh-wb-inbox-batch{display:flex;align-items:center;gap:7px;flex:none}", + ".wh-wb-inbox-batch-q{font:600 11.5px/1.3 var(--ds-font);color:var(--ds-ink-muted)}", + ".wh-wb-inbox-batchbtn{font:700 12px/1 var(--ds-font);color:var(--ds-ink-soft);background:transparent;border:1px solid var(--ds-glass-border);border-radius:var(--ds-radius-md);padding:8px 13px;cursor:pointer}", + ".wh-wb-inbox-batchbtn:hover:not(:disabled){background:var(--ds-glass-strong)}", + ".wh-wb-inbox-batchbtn:disabled{opacity:.6;cursor:default}", + ".wh-wb-inbox-batchbtn--go{color:#fff;border:0;background:linear-gradient(135deg,#0a84ff,#64d2ff);box-shadow:var(--ds-shadow-glow)}", + ".wh-wb-inbox-filternote{margin:0;font:500 12.5px/1.5 var(--ds-font);color:var(--ds-ink-muted)}", + ".wh-wb-inbox-filternote[hidden]{display:none}", + ".wh-wb-inbox-cardhidden{display:none!important}", + ".wh-wb-inbox-failmark{margin:9px 0 0;font:600 12px/1.5 var(--ds-font);color:var(--ds-danger);background:var(--ds-danger-soft);border-radius:var(--ds-radius-sm);padding:6px 10px}", // —— 决策卡家族(与聚焦盒 css.ts 同款规则,只加 .wh-wb-inbox 作用域)—— // ".wh-wb-inbox .wh-spot-cards{display:flex;flex-direction:column;gap:10px}", ".wh-wb-inbox .wh-spot-card{position:relative;display:flex;gap:0;border-radius:var(--ds-radius-lg);overflow:hidden}", diff --git a/apps/desktop-webview/src/workbench/inbox/view.test.ts b/apps/desktop-webview/src/workbench/inbox/view.test.ts new file mode 100644 index 00000000..b4f73167 --- /dev/null +++ b/apps/desktop-webview/src/workbench/inbox/view.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { INBOX_FILTER_GROUPS, inboxFilterGroup, kindMatchesGroup } from "./view.js"; + +// R17-G5 #29:筛选分组 → kind 映射(纯逻辑,DOM 过滤在挂载层用它按 data-att-kind 隐藏卡)。 +test("INBOX_FILTER_GROUPS exposes the six chips with 全部 first and null kinds", () => { + assert.deepEqual( + INBOX_FILTER_GROUPS.map((group) => group.id), + ["all", "approval", "proposal", "escalation", "budget", "conflict"] + ); + assert.equal(inboxFilterGroup("all").kinds, null); +}); + +test("kindMatchesGroup routes each kind to its chip group; 全部 matches everything", () => { + assert.equal(kindMatchesGroup("approval", "approval"), true); + assert.equal(kindMatchesGroup("approval", "proposal"), false); + // 提议组含任务计划审阅与提议审阅两 kind。 + assert.equal(kindMatchesGroup("proposal_review", "proposal"), true); + assert.equal(kindMatchesGroup("plan_review", "proposal"), true); + assert.equal(kindMatchesGroup("escalation", "escalation"), true); + assert.equal(kindMatchesGroup("budget", "budget"), true); + // 偏好/同步冲突统一归 sync_conflict。 + assert.equal(kindMatchesGroup("sync_conflict", "conflict"), true); + // 无 chip 分组的兜底 kind 只在「全部」下出现。 + assert.equal(kindMatchesGroup("delivery_ready", "all"), true); + assert.equal(kindMatchesGroup("delivery_ready", "approval"), false); + assert.equal(kindMatchesGroup("system_health", "all"), true); +}); diff --git a/apps/desktop-webview/src/workbench/inbox/view.ts b/apps/desktop-webview/src/workbench/inbox/view.ts index b9b431c7..54eba4e3 100644 --- a/apps/desktop-webview/src/workbench/inbox/view.ts +++ b/apps/desktop-webview/src/workbench/inbox/view.ts @@ -1,4 +1,4 @@ -// WorkHub 桌面 · 工作台「决策收件箱」中栏视图(R15 批 I1)。 +// WorkHub 桌面 · 工作台「决策收件箱」中栏视图(R15 批 I1 + R17-G5 #29)。 // 双端零导流的收尾:Spotlight 的 attention 视图(approval/plan_review/proposal_review/budget/escalation/ // sync_conflict/memory_conflict 全类型 + 通过/打回理由/详情/评论/合并冲突编辑)此前是全桌面最完整的决策面, // 工作台却只能从聊天点开单个 proposal 右栏。这里把那套渲染 + 动作逻辑(已抽成 spotlight/views/attention.ts @@ -8,8 +8,16 @@ // * requestResize → no-op(工作台中栏是固定栏,不是会生长的聚焦盒); // * toast → 中栏顶部一条内联轻提示(工作台没有聚焦盒那套浮层 toast); // * onActionSettled → 上抛宿主(刷新左栏「待拍板」计数徽标 + 军团面板)。 +// +// R17-G5 #29(按 kind 筛选 + 批量通过):筛选态刻意**只活在工作台壳层**(activeFilter/batch),不进共享 +// mountAttentionInbox 的内部状态——共享 mount 照旧只负责渲队列 + 动作,spotlight 聚焦盒零改。壳层用一个 +// MutationObserver 观察共享 mount 每次重渲后的 body:重算各 kind 计数、按当前筛选隐藏不匹配的卡、把批量 +// 失败逐条标注回贴到对应卡(refresh 后仍在)。批量只对 approval 类提供「全部通过」——其它 kind 语义不适合 +// 批量(升级要选处理方式、提议要看 diff、预算要看额度、冲突要逐条挑方案),不做(见报告)。 // 样式随工作台玻璃体系(workbench/inbox/css.ts,全 --ds-* token 作用域在 .wh-wb-inbox)。 +import { approvalRespondIdFromHref } from "@workhub/web-runtime"; + import { mountAttentionInbox, type AttentionInboxApiClient, @@ -28,6 +36,34 @@ export type InboxViewHandle = { refresh: () => void; }; +// #29:筛选分组 → 归属的 AttentionItem kind 集合(kinds=null=「全部」)。memory-conflict 在契约里 +// 归 sync_conflict(无独立 memory_conflict kind),故「冲突」组只列 sync_conflict。clarification/ +// knowledge_result/delivery_ready/system_health 不落任一分组 chip——只在「全部」下出现(穷举兜底)。 +export type InboxFilterGroupId = "all" | "approval" | "proposal" | "escalation" | "budget" | "conflict"; + +export const INBOX_FILTER_GROUPS: { + id: InboxFilterGroupId; + zh: string; + en: string; + kinds: string[] | null; +}[] = [ + { id: "all", zh: "全部", en: "All", kinds: null }, + { id: "approval", zh: "审批", en: "Approvals", kinds: ["approval"] }, + { id: "proposal", zh: "提议", en: "Proposals", kinds: ["proposal_review", "plan_review"] }, + { id: "escalation", zh: "升级", en: "Escalations", kinds: ["escalation"] }, + { id: "budget", zh: "预算", en: "Budget", kinds: ["budget"] }, + { id: "conflict", zh: "冲突", en: "Conflicts", kinds: ["sync_conflict"] } +]; + +export function inboxFilterGroup(id: InboxFilterGroupId): { kinds: string[] | null } { + return INBOX_FILTER_GROUPS.find((group) => group.id === id) ?? { kinds: null }; +} + +export function kindMatchesGroup(kind: string, id: InboxFilterGroupId): boolean { + const kinds = inboxFilterGroup(id).kinds; + return kinds === null || kinds.includes(kind); +} + export function mountInboxView( container: HTMLElement, input: { @@ -49,6 +85,11 @@ export function mountInboxView(

    ${zh ? "待拍板" : "Decisions"}

    ${zh ? "所有要你拍板的,都汇到这里" : "Everything waiting on your call, in one place"}

    + +
    `; @@ -56,6 +97,10 @@ export function mountInboxView( const bodyEl = container.querySelector("[data-wb-inbox-body]"); const subEl = container.querySelector("[data-wb-inbox-sub]"); const toastEl = container.querySelector("[data-wb-inbox-toast]"); + const controlsEl = container.querySelector("[data-wb-inbox-controls]"); + const chipsEl = container.querySelector("[data-wb-inbox-chips]"); + const batchEl = container.querySelector("[data-wb-inbox-batch]"); + const filterNoteEl = container.querySelector("[data-wb-inbox-filternote]"); if (!bodyEl) { throw new Error("workbench inbox markup is missing its body mount point"); } @@ -105,8 +150,226 @@ export function mountInboxView( onActionSettled: () => input.onActionSettled?.() }); + // —— #29 壳层筛选 + 批量(不进共享 mount 内部状态)—— // + let activeFilter: InboxFilterGroupId = "all"; + let batchState: "idle" | "confirm" | "busy" = "idle"; + // 批量失败的 AttentionItem id(refresh 后共享 mount 重渲,观察器据此把「未通过」标注回贴到对应卡)。 + let failedItemIds = new Set(); + + const cardsInBody = (): HTMLElement[] => + Array.from(bodyEl.querySelectorAll("[data-att-id][data-att-kind]")); + + // 从一张 approval 卡里取出「通过」动作对应的审批 id(非 deny、非「需理由」的那颗,其 href 形如 + // /api/approvals/:id/respond)。取不到(结构异常)返回 undefined,批量时跳过、不误提交。 + const approvalIdFromCard = (card: HTMLElement): string | undefined => { + if (card.dataset.attKind !== "approval") { + return undefined; + } + for (const btn of card.querySelectorAll("[data-att-href]")) { + if (btn.dataset.attActionId === "deny" || btn.dataset.attRequiresReason === "true") { + continue; + } + const id = approvalRespondIdFromHref(btn.dataset.attHref ?? ""); + if (id) { + return id; + } + } + return undefined; + }; + + const renderChips = (cards: HTMLElement[]): void => { + if (!chipsEl) { + return; + } + const kinds = cards.map((card) => card.dataset.attKind ?? ""); + // 只渲当前队列里真实出现的分组(外加常驻「全部」),避免摆一堆 0 计数的死 chip。 + const chips = INBOX_FILTER_GROUPS.filter((group) => { + if (group.id === "all") { + return true; + } + return kinds.some((kind) => kindMatchesGroup(kind, group.id)); + }).map((group) => { + const count = group.kinds === null ? cards.length : kinds.filter((kind) => kindMatchesGroup(kind, group.id)).length; + const active = group.id === activeFilter; + return ``; + }); + chipsEl.innerHTML = chips.join(""); + }; + + const renderBatch = (cards: HTMLElement[]): void => { + if (!batchEl) { + return; + } + const approvalCount = cards.filter((card) => card.dataset.attKind === "approval").length; + // 批量只在「全部/审批」视图下、且有 approval 卡时出现(其它筛选下批量按钮与在看的内容不相干)。 + const relevant = approvalCount > 0 && (activeFilter === "all" || activeFilter === "approval"); + if (!relevant) { + batchEl.innerHTML = ""; + return; + } + if (batchState === "busy") { + batchEl.innerHTML = ``; + return; + } + if (batchState === "confirm") { + batchEl.innerHTML = `${ + zh ? `确认通过全部 ${approvalCount} 条审批?` : `Approve all ${approvalCount} approvals?` + } + + `; + return; + } + batchEl.innerHTML = ``; + }; + + // 观察共享 mount 每次重渲后的 body → 重算 chip 计数、按当前筛选隐藏不匹配的卡、回贴批量失败标注。 + // 自身的 DOM 改动会再触发观察器——用 disconnect/observe 包住,避免自激循环。 + let observer: MutationObserver | undefined; + let reconciling = false; + const reconcile = (): void => { + if (reconciling) { + return; + } + reconciling = true; + observer?.disconnect(); + try { + const cards = cardsInBody(); + if (cards.length === 0) { + // 加载 / 空态 / 错误 / 冲突面板——没有可筛的卡,收起筛选条与失败标注。 + if (controlsEl) { + controlsEl.hidden = true; + } + if (filterNoteEl) { + filterNoteEl.hidden = true; + } + return; + } + if (controlsEl) { + controlsEl.hidden = false; + } + // 当前筛选分组在队列里已无卡(都被处理掉了)——退回「全部」。 + if (activeFilter !== "all" && !cards.some((card) => kindMatchesGroup(card.dataset.attKind ?? "", activeFilter))) { + activeFilter = "all"; + } + let visible = 0; + for (const card of cards) { + const match = kindMatchesGroup(card.dataset.attKind ?? "", activeFilter); + card.classList.toggle("wh-wb-inbox-cardhidden", !match); + if (match) { + visible += 1; + } + // 批量失败标注:先清旧标,命中失败集则贴一条(refresh 后仍能重新贴上)。 + card.querySelector("[data-wb-inbox-failmark]")?.remove(); + if (failedItemIds.has(card.dataset.attId ?? "")) { + const host = card.querySelector(".wh-spot-card-main") ?? card; + host.insertAdjacentHTML( + "beforeend", + `

    ${ + zh ? "批量通过时这条没成功,单独再试一次。" : "This one didn't go through in the batch — try it individually." + }

    ` + ); + } + } + if (filterNoteEl) { + if (visible === 0) { + filterNoteEl.textContent = zh ? "当前筛选下没有待办。" : "Nothing under this filter."; + filterNoteEl.hidden = false; + } else { + filterNoteEl.hidden = true; + } + } + renderChips(cards); + renderBatch(cards); + } finally { + reconciling = false; + observer?.observe(bodyEl, { childList: true, subtree: true }); + } + }; + + const runBatchApprove = async (): Promise => { + const targets: { itemId: string; approvalId: string }[] = []; + for (const card of cardsInBody()) { + const approvalId = approvalIdFromCard(card); + const itemId = card.dataset.attId; + if (approvalId && itemId) { + targets.push({ itemId, approvalId }); + } + } + if (targets.length === 0) { + batchState = "idle"; + reconcile(); + return; + } + batchState = "busy"; + failedItemIds = new Set(); + reconcile(); + const failed = new Set(); + // 逐条调用现有单条端点(不用聚合的 respond-batch,好让失败逐条定位到卡);单条失败不翻整批。 + for (const target of targets) { + try { + await input.client.respondApproval(target.approvalId, { decision: "allow", remember: "once" }); + } catch { + failed.add(target.itemId); + } + } + failedItemIds = failed; + batchState = "idle"; + const okCount = targets.length - failed.size; + if (okCount > 0 && failed.size === 0) { + toast(zh ? `已通过 ${okCount} 条审批` : `Approved ${okCount} approval${okCount === 1 ? "" : "s"}`, "ok"); + } else if (okCount > 0) { + toast( + zh ? `已通过 ${okCount} 条,${failed.size} 条未通过` : `Approved ${okCount}, ${failed.size} failed`, + "info" + ); + } else { + toast(zh ? "没有审批通过(可能都已被处理)" : "None approved (they may already be handled)", "error"); + } + input.onActionSettled?.(); + // 重拉队列:通过的掉出、失败的仍在——观察器 reconcile 会把失败标注重新贴上。 + mountHandle.refresh(); + }; + + // 筛选/批量的点击(壳层控件,不落进共享 mount 的 body 监听)。 + container.addEventListener("click", (event) => { + if (!(event.target instanceof HTMLElement)) { + return; + } + const chip = event.target.closest("[data-wb-inbox-chip]"); + if (chip?.dataset.wbInboxChip) { + activeFilter = chip.dataset.wbInboxChip as InboxFilterGroupId; + batchState = "idle"; + reconcile(); + return; + } + if (event.target.closest("[data-wb-inbox-batch-start]")) { + batchState = "confirm"; + reconcile(); + return; + } + if (event.target.closest("[data-wb-inbox-batch-cancel]")) { + batchState = "idle"; + reconcile(); + return; + } + if (event.target.closest("[data-wb-inbox-batch-confirm]")) { + void runBatchApprove(); + } + }); + + observer = new MutationObserver(() => reconcile()); + observer.observe(bodyEl, { childList: true, subtree: true }); + // 首帧共享 mount 已同步写了 loading——先跑一次 reconcile(收起筛选条),队列到达时观察器再刷。 + reconcile(); + return { dispose: () => { + observer?.disconnect(); + observer = undefined; mountHandle.dispose(); if (toastTimer) { clearTimeout(toastTimer); From 38fd6e8f3b7ec84e36caf7bc6ea9ce45aecab46f Mon Sep 17 00:00:00 2001 From: mycyg Date: Thu, 16 Jul 2026 19:53:04 +0800 Subject: [PATCH 23/23] =?UTF-8?q?feat(search):=20#30=20deep-link=20seq=20?= =?UTF-8?q?=E2=80=94=20position=20+=20highlight=20the=20hit=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 搜索命中会话消息的 deep_link 已带 seq(后端契约既有)。前端全链路透传: - PendingWorkbenchDeepLinkTarget 加可选 seq(只在有 conversationId 时有意义,stash/consume round-trip,非法/无会话时退回会话级不带定位); - 搜索会话行加 data-search-seq,SearchOpenAction/resolveSearchRowAction 解析并透传, openInWorkbench 冷启动 stash 带 seq; - boot 消费后 selectProject(projectId, conversationId, seq);shell 存 pendingChatFocus, 打开对应会话的 chat 视图(主区/协同/DM 三处)时消费一次,传 focusSeq 给 mountChatView; - chat 视图首屏就绪后 jumpToMessageBySeq(seq):复用引用/置顶跳转的 jumpToMessage(本地无就 beforeSeq 翻页加载到为止 + 硬熔断),解析成 message id 后滚动到该消息并短暂高亮;翻不到就 诚实不定位(会话已在最新屏)。 shell 只动 deep-link/selectProject/chat-mount 接线,不碰 me-stream/快捷键区。 测试:pending-deep-link +3(seq round-trip / 无会话丢 seq / 非法 seq 兜底)、 search +seq 解析 3 例 + 行 data-search-seq + stash seq 断言。 Co-Authored-By: Claude Fable 5 --- .../src/spotlight/views/search.test.ts | 17 ++++++++-- .../src/spotlight/views/search.ts | 30 ++++++++++++---- apps/desktop-webview/src/workbench/boot.ts | 3 +- .../src/workbench/chat/view.ts | 28 +++++++++++++++ .../src/workbench/pending-deep-link.test.ts | 34 +++++++++++++++++++ .../src/workbench/pending-deep-link.ts | 13 ++++++- apps/desktop-webview/src/workbench/shell.ts | 27 +++++++++++++-- 7 files changed, 139 insertions(+), 13 deletions(-) diff --git a/apps/desktop-webview/src/spotlight/views/search.test.ts b/apps/desktop-webview/src/spotlight/views/search.test.ts index 500284ce..f8f2f9a2 100644 --- a/apps/desktop-webview/src/spotlight/views/search.test.ts +++ b/apps/desktop-webview/src/spotlight/views/search.test.ts @@ -276,7 +276,7 @@ test("searchResultsHtml gives meetings an honest degrade note (no per-meeting de test("searchResultsHtml wires drive/workitem/conversation rows with the exact dataset the click handler expects", () => { const html = searchResultsHtml(fixtureVm(), true); - assert.match(html, new RegExp(`data-search-kind="conversation"[^>]*data-search-project-id="${PROJECT_ID}"[^>]*data-search-conversation-id="${CONVERSATION_ID}"`, "u")); + assert.match(html, new RegExp(`data-search-kind="conversation"[^>]*data-search-project-id="${PROJECT_ID}"[^>]*data-search-conversation-id="${CONVERSATION_ID}"[^>]*data-search-seq="5"`, "u")); assert.match(html, new RegExp(`data-search-kind="drive"[^>]*data-search-project-id="${PROJECT_ID}"[^>]*data-search-item-id="${ITEM_ID}"`, "u")); assert.match(html, new RegExp(`data-search-kind="workitem"[^>]*data-search-work-item-id="${WORK_ITEM_ID}"`, "u")); }); @@ -288,6 +288,15 @@ test("resolveSearchRowAction resolves each kind from its dataset, and rejects in resolveSearchRowAction({ searchKind: "conversation", searchProjectId: "p1", searchConversationId: "c1" }), { kind: "conversation", projectId: "p1", conversationId: "c1" } ); + // R17-G5 #30:带合法 seq 时透传;缺省或非法 seq 退回会话级、不带定位。 + assert.deepEqual( + resolveSearchRowAction({ searchKind: "conversation", searchProjectId: "p1", searchConversationId: "c1", searchSeq: "7" }), + { kind: "conversation", projectId: "p1", conversationId: "c1", seq: 7 } + ); + assert.deepEqual( + resolveSearchRowAction({ searchKind: "conversation", searchProjectId: "p1", searchConversationId: "c1", searchSeq: "oops" }), + { kind: "conversation", projectId: "p1", conversationId: "c1" } + ); assert.equal(resolveSearchRowAction({ searchKind: "conversation", searchProjectId: "p1" }), undefined); assert.deepEqual( resolveSearchRowAction({ searchKind: "drive", searchProjectId: "p1", searchItemId: "i1" }), @@ -662,15 +671,19 @@ test("clicking a conversation row invokes open_workbench with a project+conversa searchRow: "true", searchKind: "conversation", searchProjectId: PROJECT_ID, - searchConversationId: CONVERSATION_ID + searchConversationId: CONVERSATION_ID, + searchSeq: "5" }; body.click(row); const stashed = JSON.parse(storageValues.get("workhub_workbench_pending_deep_link")!) as { projectId?: string; conversationId?: string; + seq?: number; }; assert.equal(stashed.projectId, PROJECT_ID); assert.equal(stashed.conversationId, CONVERSATION_ID); + // R17-G5 #30:命中消息的 seq 一并 stash,工作台打开会话后据此定位滚动 + 高亮。 + assert.equal(stashed.seq, 5); await tick(); assert.deepEqual(calls, [["open_workbench", { projectId: PROJECT_ID }]]); assert.equal(toasts.length, 1); diff --git a/apps/desktop-webview/src/spotlight/views/search.ts b/apps/desktop-webview/src/spotlight/views/search.ts index 9b51e21e..4862c64a 100644 --- a/apps/desktop-webview/src/spotlight/views/search.ts +++ b/apps/desktop-webview/src/spotlight/views/search.ts @@ -151,7 +151,7 @@ function conversationSenderLabel(result: ConversationSearchResult, zh: boolean): function conversationRowHtml(r: ConversationSearchResult, zh: boolean, query: string): string { const sender = conversationSenderLabel(r, zh); - return `