diff --git a/docs/architecture-audit-2026-07-29/TeamInboxCoreCollaboration.md b/docs/architecture-audit-2026-07-29/TeamInboxCoreCollaboration.md new file mode 100644 index 0000000000..eb6d618de4 --- /dev/null +++ b/docs/architecture-audit-2026-07-29/TeamInboxCoreCollaboration.md @@ -0,0 +1,73 @@ +# Architecture Audit — Team Inbox Core Collaboration + +**Date:** 2026-07-29 +**Auditor:** Codex +**Scope:** standalone Cloud Org Work Item collaboration, Session handoff creation, Work Item comment mentions, assignment receipts, and cross-instance detail refresh. + +## Completion checklist + +- [x] Standalone mutations are org-scoped and atomic. +- [x] Project and standalone Work Items share one partial-update shape and UI surface. +- [x] Assignment receipt reset and Return reassignment commit in the owning transaction. +- [x] Handoff transitions are validated by one state machine per storage scope. +- [x] Comment mentions persist stable member ids and become viewer-scoped Team Inbox targets. +- [x] Initial Session handoff creation persists status, priority, and target date. +- [x] Remote selection refresh is revision-driven and adds no polling. +- [x] TypeScript and Rust compile gates pass. +- [x] Targeted backend and frontend regression suites pass. + +## Production call-chain trace + +| User action | Frontend boundary | Tauri/backend boundary | Authoritative write / projection | +| ----------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Edit standalone property, To-Do, comment, or assignee | `useTeamInboxWorkItem.updateWorkItem` | `work_item_update_standalone_partial` | Org-scoped SQLite `work_items` row + history/revisions/receipt reset, then one collab outbox write | +| Accept or Return standalone handoff | `useTeamInboxWorkItem.transitionHandoff` | `work_item_transition_standalone_handoff` | Validated handoff state; Return also changes assignee and clears receipt in the same transaction | +| Create Session-derived Work Item | `TeamInboxSessionDropSurface` → `createWorkItemFromSession` | Existing project create or standalone insert/atomic reconcile | One canonical Work Item with provenance, selected properties, assignment, and optional pending handoff | +| Comment with `@` recipients | `useWorkItemContentState` | Canonical Work Item partial update | `comments[].mentioned_user_ids`; Team Inbox projects one row per addressed viewer | +| Receive a remote edit | Cloud Org push signal → coordinator refresh | Collaboration apply updates local SQLite | Inbox row revision advances; only the selected detail demand-reloads | + +## Ten-layer audit + +| Layer | Verdict | Evidence / decision | +| --------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation correctness | pass | TypeScript typecheck, Rust compilation, targeted Rust tests, and 141 focused Vitest tests pass. | +| 2. Dead code & structural deduplication | pass | Both project and standalone commands call the shared scoped atomic transaction helper; Team Inbox reuses `WorkItemThreadSurface` rather than maintaining a second content implementation. | +| 3. Naming consistency | pass | `standalone`, `partial`, `handoff`, and `mentioned_user_ids` describe storage scope and payload semantics explicitly; the prior project-only editability implication is removed. | +| 4. Semantic overloading | pass | `member id` means the stable Cloud Org identity throughout roster, assignment, handoff, and mention routing; display names are presentation only. | +| 5. Default branch analysis | pass | Standalone/project scope selection is an explicit enum; self-assignment versus team handoff is explicit and recipient validation has no permissive catch-all. | +| 6. Cross-domain leakage | pass | Cloud roster loading stays in the Team Inbox hook; the shared Work Item surface receives ordinary `Person[]` and mutation callbacks, not Cloud Org transport types. | +| 7. New developer confusion | pass | Command and helper names expose the standalone scope and atomic intent; the call-chain table above documents ownership and persistence. | +| 8. Wire protocol & serialization | pass | `mentioned_user_ids` is optional/empty-skipped on the wire, work-item mention targets are discriminated, and no display-name-derived identity crosses the boundary. | +| 9. Init parity | pass | Drag/drop and Session context-menu creation converge on the same request atom, composer, normalized form, idempotent creation function, and storage commands. | +| 10. Resolver symmetry | pass | Project and standalone detail loaders both resolve the Work Item, roster/current user, update callback, and handoff transition before rendering the same thread surface; degraded optional context does not replace a successfully loaded item. | + +## State and transaction invariants + +| Concern | Invariant | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Storage scope | A standalone Work Item is addressed by `org_id + short_id + project_id IS NULL`; cross-org updates fail. | +| Partial mutation | The transaction reads the current canonical row, applies only supplied fields, records history/revisions, and commits once. | +| Collaboration output | The outbox write occurs once after the SQLite transaction succeeds; a failed transaction emits nothing. | +| Reassignment | `assigned_human_id` and the assignment episode's read receipt change atomically. | +| Handoff | Only the addressed recipient may Accept/Return; Return requires a bounded reason and reassigns to the sender atomically. | +| Mentions | Only normalized active-roster ids are persisted; self, unknown, blank, and duplicate recipients are excluded. | +| Remote convergence | The coordinator's observed `updatedAt` invalidates the selected Work Item; a late prior load cannot replace a newer selection. | +| Mutation ordering | A bounded per-item promise queue preserves invocation order for rapid partial updates. | + +## Systematic sweeps + +- Swept all Team Inbox property/todo/comment writes for project-only persistence assumptions. +- Swept assignment changes for read-receipt reset parity. +- Swept Work Item comment serialization in Rust, TypeScript domain types, HTTP/Tauri payloads, conversion helpers, tests, and collaboration bridge fixtures. +- Swept Session handoff entry points so drag/drop and context-menu creation share the expanded status/priority/date form. +- Swept Team Inbox item discrimination, cursors, read/unread operations, row rendering, detail selection, and navigation for the new Work Item comment mention target. + +## Deliberately skipped or deferred + +- No historical comments are retroactively inferred as mentions; old comments lack authoritative recipient ids, and guessing from display text would violate identity invariants. +- Mention activation opens the owning Work Item discussion rather than introducing a separate comment permalink/anchor protocol. +- No destructive cleanup was required: existing standalone Work Items become editable through the corrected write boundary, while historical comments remain valid non-mention comments. + +## Verdict + +The first-group collaboration closure is architecturally coherent: canonical writes are atomic, identity is stable-id based, the two directions share one state machine and composer, and the UI is a projection of authoritative Work Item data rather than a parallel Team Inbox document. diff --git a/docs/frontend-ui-audit-2026-07-29/SessionHandoffComposerProperties.md b/docs/frontend-ui-audit-2026-07-29/SessionHandoffComposerProperties.md new file mode 100644 index 0000000000..0bc9b4535b --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-29/SessionHandoffComposerProperties.md @@ -0,0 +1,50 @@ +# Frontend UI Audit — SessionHandoffComposerProperties + +**Files:** + +- `src/modules/MainApp/TeamInbox/components/SessionHandoffComposer.tsx` +- `src/modules/MainApp/TeamInbox/sessionHandoffForm.ts` + +**Date:** 2026-07-29 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------- | --------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `SessionHandoffComposer.tsx` | Status, priority, and due-date controls | keep with reason | The composer now renders the canonical `WorkItemProperties` pill controls used by Work Item detail and creation surfaces. | — | +| `SessionHandoffComposer.tsx` | Destination and recipient controls | keep with reason | These remain design-system `Select` controls because handoff recipients are constrained to the authoritative destination roster; the general Work Item assignee picker also offers agents, orgs, and unassigned, which are invalid here. | — | +| `SessionHandoffComposer.tsx` | Work Item title and handoff note | keep with reason | Existing design-system `Input` and `Textarea` controls own focus, disabled, and character-limit behavior. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---------------------------- | --------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `SessionHandoffComposer.tsx` | Surface, border, and text classes | keep with reason | The composer uses semantic `bg-bg-*`, `border-border-*`, and `text-text-*` tokens; no arbitrary CSS-variable or raw color value is introduced. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---------------------------- | ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `SessionHandoffComposer.tsx` | `width={640}` | keep with reason | The modal scaffold requires a numeric desktop width and already owns responsive viewport clamping. | — | +| `SessionHandoffComposer.tsx` | 12–13 px metadata icons | keep with reason | These decorative icons are optically aligned to the existing `text-xs` metadata row and are hidden from assistive technology. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------- | ------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `SessionHandoffComposer.tsx` | Shared property fieldset | keep with reason | A screen-reader-only legend names the group; native `fieldset[disabled]` locks every shared property trigger during submission. | — | +| `SessionHandoffComposer.tsx` | Submission error | keep with reason | `role="alert"` announces an authoritative write failure instead of relying on color alone. | — | + +## D5 — Visual Patterns Observed + +- The duplicate `Select + DatePicker` property row was removed. +- Status, priority, and due date now inherit one visual and behavioral implementation from `WorkItemProperties`. +- The form-to-Work-Item adapter is pure and preserves title, destination, recipient, and note while applying only canonical property updates. +- No third independent property-control pattern remains in the Session handoff flow. + +## Summary + +- 0 fixes recommended +- 8 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-07-29/TeamInboxCoreCollaboration.md b/docs/frontend-ui-audit-2026-07-29/TeamInboxCoreCollaboration.md new file mode 100644 index 0000000000..a3431c2daa --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-29/TeamInboxCoreCollaboration.md @@ -0,0 +1,57 @@ +# Frontend UI Audit — TeamInboxCoreCollaboration + +**Files:** + +- `src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx` (217 LOC) +- `src/modules/MainApp/TeamInbox/components/SessionHandoffComposer.tsx` (291 LOC) +- `src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx` +- `src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemMentionPicker.tsx` (70 LOC) + +**Date:** 2026-07-29 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ------------------------------------ | --------------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `SessionHandoffComposer.tsx:97-117` | Session handoff modal | keep with reason | Uses the canonical Modal scaffold, including its design-system buttons and submission state. | — | +| `SessionHandoffComposer.tsx:168-273` | Destination, title, assignee, status, priority, date, and note fields | keep with reason | Uses the existing `Select`, `Input`, `DatePicker`, and `Textarea` controls; labels remain semantic wrappers. | — | +| `WorkItemMentionPicker.tsx:45-66` | Work Item comment mention control | keep with reason | A multi-select is the correct design-system primitive for explicit stable-id recipients and is shared by embedded and full-page threads. | — | +| `AssignedWorkItemDetail.tsx:75-118` | Editable Work Item content | keep with reason | Reuses `WorkItemThreadSurface` and its property/content controls instead of duplicating a Team Inbox-specific editor. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ------------------------------------ | ------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `AssignedWorkItemDetail.tsx:64-68` | warning/error semantic classes | keep with reason | All colors use existing semantic warning/danger tokens; no arbitrary CSS variable, hex, or RGB value was introduced. | — | +| `SessionHandoffComposer.tsx:122-284` | surface/text/border classes | keep with reason | The composer uses `bg-bg-*`, `text-text-*`, `border-border-*`, and `text-danger-*` design tokens only. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---------------------------------------- | ------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `SessionHandoffComposer.tsx:100` | `width={640}` | keep with reason | The modal scaffold requires a numeric desktop width; 640 px gives the three-property row adequate room while the body still collapses to one column at the `sm` breakpoint. | — | +| `SessionHandoffComposer.tsx:128,135,156` | 12–13 px decorative icons | keep with reason | These are optical icon sizes below the spacing scale and align with the surrounding `text-xs` metadata. | — | +| `WorkItemMentionPicker.tsx:52` | 13 px `@` icon | keep with reason | This is a sub-scale optical alignment inside the design-system mini Select prefix. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ------------------------------------ | --------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `SessionHandoffComposer.tsx:168-273` | Composer fields | keep with reason | Every visible field is wrapped by a text label; Select search and keyboard behavior remain owned by the design-system component. | — | +| `SessionHandoffComposer.tsx:280-284` | Submission error | keep with reason | Uses `role="alert"` so a failed authoritative write is announced rather than represented by color alone. | — | +| `WorkItemMentionPicker.tsx:46-66` | Mention picker | keep with reason | The localized placeholder supplies an accessible name; the `@` icon is decorative and hidden from assistive technology. | — | +| `AssignedWorkItemDetail.tsx:61-71` | Degraded-state banner | keep with reason | Uses `role="status"` and semantic warning/error tokens while preserving the usable Work Item below it. | — | + +## D5 — Visual Patterns Observed + +- The Team Inbox detail reuses the canonical Work Item thread for properties, To-Dos, comments, assignment, and handoff actions. +- The Session handoff modal owns one composable form for self-assignment and either handoff direction. +- `WorkItemMentionPicker` centralizes the explicit recipient pattern for both Work Item presentations; no second Team Inbox-only mention UI was introduced. +- No visual pattern reached the three-independent-implementation threshold. + +## Summary + +- 0 fixes recommended +- 14 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-07-29/TeamInboxSessionHandoffIdentity.md b/docs/frontend-ui-audit-2026-07-29/TeamInboxSessionHandoffIdentity.md new file mode 100644 index 0000000000..8246aaccdd --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-29/TeamInboxSessionHandoffIdentity.md @@ -0,0 +1,45 @@ +# Frontend UI Audit — TeamInboxSessionHandoffIdentity + +**Files:** + +- `src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx` (212 LOC) +- `src/modules/MainApp/TeamInbox/components/SessionHandoffComposer.tsx` (220 LOC) +- `src/modules/MainApp/TeamInbox/components/TeamInboxSessionDropSurface.tsx` (395 LOC) + +**Date:** 2026-07-29 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| — | No raw interactive HTML introduced or retained in the changed surfaces | keep with reason | The composer and drop surface continue to use the existing design-system controls | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| — | No arbitrary color/token values found | keep with reason | The changed surfaces use existing semantic classes and components | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| — | No new hardcoded pixel sizes or raw colors found | keep with reason | Identity and destination changes are expressed through existing layout primitives | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| — | Destination and recipient controls | keep with reason | Existing design-system controls preserve accessible names, keyboard behavior, and status semantics | — | + +## D5 — Visual Patterns Observed + +- No new repeated visual pattern was introduced. +- The Cloud Org destination reuses the existing Session handoff composer instead of creating a parallel modal. + +## Summary + +- 0 fixes recommended +- 4 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-07-30/NotificationLifecycle.md b/docs/frontend-ui-audit-2026-07-30/NotificationLifecycle.md new file mode 100644 index 0000000000..8af46e8186 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-30/NotificationLifecycle.md @@ -0,0 +1,48 @@ +# Frontend UI Audit — NotificationLifecycle + +**Files:** + +- `src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx` +- `src/modules/MainApp/Settings/renderer/slots/NotificationsMasterToggleRow.tsx` + +**Date:** 2026-07-30 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:150-261` | Notification settings groups | keep with reason | Uses the canonical `SectionContainer`, `SectionRow`, `Switch`, `Slider`, and `Button` primitives throughout. | — | +| `NotificationsMasterToggleRow.tsx:9` | Master notification toggle | keep with reason | Uses the shared `Switch` and the settings atom instead of introducing a local checkbox pattern. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:160` | `w-[160px]` | keep with reason | The fixed slider track width is an optical control dimension inside a responsive `max-w-full` wrapper; there is no matching design token. | — | +| `NotificationsAdvancedBlocks.tsx:207-214` | Permission status text classes | keep with reason | Uses existing spacing and semantic text tokens; no raw colors were introduced. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:160` | 160 px slider width | keep with reason | Keeps the volume control stable across conditional rows while `max-w-full` prevents overflow on narrow settings surfaces. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:151-260` | Settings controls | keep with reason | Every control is paired with a localized `SectionRow` label and preserves the shared primitives' keyboard behavior. | — | +| `NotificationsAdvancedBlocks.tsx:188-228` | Permission status and system-settings action | keep with reason | Disabled/requesting state is exposed on the switch and the status remains visible as localized text rather than color alone. | — | + +## D5 — Visual Patterns Observed + +- Notification categories share one data-driven `SectionRow` and `Switch` pattern. +- Sound, system permission, dock badge, and test actions reuse the existing Settings section hierarchy. +- No visual pattern reached the three-independent-implementation abstraction threshold. + +## Summary + +- 0 fixes recommended +- 7 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-07-30/TeamInboxHandoffComposerFollowup.md b/docs/frontend-ui-audit-2026-07-30/TeamInboxHandoffComposerFollowup.md new file mode 100644 index 0000000000..8c7afbb981 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-30/TeamInboxHandoffComposerFollowup.md @@ -0,0 +1,52 @@ +# Frontend UI Audit — TeamInboxHandoffComposerFollowup + +**Files:** + +- `src/modules/MainApp/TeamInbox/components/SessionHandoffComposer.tsx` +- `src/components/Select/index.tsx` +- `src/components/Select/types.ts` + +**Date:** 2026-07-30 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `SessionHandoffComposer.tsx:167-208` | Destination and recipient controls | keep with reason | Both controls use the canonical `Select`; their options remain constrained to the authoritative handoff destination and roster, so the general Work Item assignee picker is not a valid replacement. | — | +| `SessionHandoffComposer.tsx:220-228` | Status, priority, and due-date controls | keep with reason | The composer now delegates the complete pill UI and behavior to `WorkItemProperties`; the former parallel `Select` / date implementation is gone. | — | +| `SessionHandoffComposer.tsx:187-243` | Title and handoff note | keep with reason | Existing design-system `Input` and `Textarea` components own disabled, limit, and editing behavior. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `SessionHandoffComposer.tsx:119-255` | Surface, border, and text classes | keep with reason | The composer uses semantic `bg-bg-*`, `border-border-*`, `text-text-*`, and `text-danger-*` tokens; no raw color value is introduced. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `SessionHandoffComposer.tsx:97` | `width={640}` | keep with reason | The modal scaffold clamps its content to the viewport, while the shared property strip uses wrapping layout below the available width. | — | +| `SessionHandoffComposer.tsx:125-153` | 12–13 px metadata icons | keep with reason | These decorative icons are optically aligned with the existing `text-xs` metadata row and are hidden from assistive technology. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `SessionHandoffComposer.tsx:211-229` | Shared property fieldset | keep with reason | The screen-reader-only legend names the group, and native `fieldset[disabled]` locks its buttons during submission. | — | +| `SessionHandoffComposer.tsx:165-209`; `Select/index.tsx:338-349` | Destination and recipient accessible names | abstract | `Select` exposes a focusable `div`, but its public props do not accept an accessible name and wrapping that `div` in a native `label` does not associate the visible label. This affects every labeled `Select`, not only Team Inbox. | Add `aria-label` / `aria-labelledby` support and combobox semantics to the shared `Select`, then migrate labeled call sites as a component-level accessibility sweep. | +| `SessionHandoffComposer.tsx:107-110,251-260` | Client-side validation feedback | fix | The primary action becomes disabled for an invalid destination or recipient. The composer now renders the localized validation reason in the existing alert region, including stale-destination and stale-recipient states. | Completed. | + +## D5 — Visual Patterns Observed + +- Status, priority, and due date have one canonical implementation through `WorkItemProperties`. +- Destination and recipient intentionally remain handoff-specific `Select` fields because their domain options differ from the general Work Item property model. +- The shared property strip uses `pillLayout="wrap"` and its dropdowns portal above the modal layer, so the reused controls retain the intended compact layout without introducing another responsive pattern. +- The repository dev bundle renders the Team Inbox sidebar entry. The separately installed `/Applications/ORG2.app` is an older build and must not be used as visual evidence for the current branch. + +## Summary + +- 1 fix recommended +- 7 kept with documented reason +- 1 abstract candidate diff --git a/docs/testing/TeamInboxMultiUserManualTest.md b/docs/testing/TeamInboxMultiUserManualTest.md new file mode 100644 index 0000000000..46faa46987 --- /dev/null +++ b/docs/testing/TeamInboxMultiUserManualTest.md @@ -0,0 +1,473 @@ +# Team Inbox 双实例多人协作手动测试 + +本文档用于验证完整的: + +`Session → Work Item → 团队成员接收 → 协作修改 → @ 提及 → 重新分配 / 退回 → 反向交接` + +建议严格按顺序执行。每个用例都使用唯一标题,避免旧测试数据影响判断。 + +## 1. 测试环境 + +### 实例与身份 + +| 角色 | 实例 | 登录账号 | Cloud Org | +| --- | --- | --- | --- | +| A(发起人) | 实例 1 | `1106510024` | `ORG2–Invite–Test` | +| B(接收人) | 实例 2 | `ahanafish` | `ORG2–Invite–Test` | + +开始前分别打开左上角 Org 菜单,确认: + +- [ ] A 显示 `Signed in as 1106510024`。 +- [ ] B 显示 `Signed in as ahanafish`。 +- [ ] 两边当前都选中 `ORG2–Invite–Test`。 +- [ ] 两边都能看到 `Team Inbox`。 +- [ ] 不要使用 `/Applications/ORG2.app` 的旧安装版本;使用当前仓库构建的两个开发实例。 + +### 测试数据命名 + +先记录本轮测试编号,例如: + +```text +TI-20260730-01 +``` +后续标题统一使用: + +```text +[TI-20260730-01] 主交接 +[TI-20260730-01] 退回 +[TI-20260730-01] 反向交接 +``` + +### 结果记录 + +| 项目 | 填写 | +| --- | --- | +| 测试日期与时间 | | +| 实例 1 构建 / PID | | +| 实例 2 构建 / PID | | +| A 账号 | | +| B 账号 | | +| Cloud Org | | +| 测试编号 | | + +--- + +## 2. P0 主流程:A 将 Session 交接给 B + +### TC-01:创建 Session 并打开交接弹窗 + +在实例 A: + +1. 新建一个 Session。 +2. 输入一条容易识别的请求,例如: + + ```text + 请检查 Team Inbox 双实例协作,并整理测试结论 + ``` + +3. 将这个 Session 分享到 `ORG2–Invite–Test`。 +4. 把 Session 拖到 Team Inbox,或右键 Session 选择 `Create team Work Item…`。 + +预期: + +- [ ] Session 原卡片仍在原位置,没有被移动或关闭。 +- [ ] 出现 `Create from Session` 弹窗。 +- [ ] 弹窗显示来源 Session 的标题或请求摘要。 +- [ ] `Assign to` 同时包含 `1106510024` 和 `ahanafish`。 +- [ ] 默认接收人是当前账号 `1106510024 (me)`,不会自动交接给别人。 +- [ ] 状态、优先级、截止日期使用与正式 Work Item 一致的属性组件。 + +失败时记录: + +- `Assign to` 实际出现的账号: +- 是否缺少 B: +- 当前 Org: +- 截图: + +### TC-02:设置属性并交接给 B + +仍在实例 A 的交接弹窗: + +1. 标题改为 `[测试编号] 主交接`。 +2. `Assign to` 选择 `ahanafish`。 +3. 状态选择一个非默认值,例如 `In Progress`。 +4. 优先级选择 `High`。 +5. 截止日期选择 `Tomorrow`。 +6. Handoff note 输入: + + ```text + 请接收后更新属性、Todo,并 @ 我确认。 + ``` + +7. 点击 `Create & hand off`。 + +预期: + +- [ ] 提交期间按钮进入 loading/disabled,连续点击不会创建多份。 +- [ ] 提交成功后弹窗关闭。 +- [ ] A 的 Team Inbox 或正式 Work Item 中只出现一个对应 Work Item。 +- [ ] 标题、接收人、状态、优先级、日期与弹窗选择一致。 +- [ ] Work Item 保留来源 Session。 + +失败时记录: + +- 错误原文: +- 点击 Retry 是否成功: +- 是否产生重复 Work Item: +- 截图: + +### TC-03:B 自动收到分配 + +保持实例 B 打开 `Team Inbox → Assigned`,先不要手动刷新。 + +预期: + +- [ ] 新 Work Item 自动出现。 +- [ ] 左侧未读徽标增加。 +- [ ] 卡片显示标题和 `状态 · 优先级`,不显示原始枚举值。 +- [ ] 卡片不泄漏 Markdown 符号或 `\n` 转义字符。 +- [ ] 打开详情后,状态为 `In Progress`、优先级为 `High`、截止日期为 `Tomorrow`。 +- [ ] 接收人显示 `ahanafish`,而不是 UUID。 +- [ ] 显示“来自 1106510024 的交接”和交接备注。 +- [ ] Pending 状态下,B 可以看到 `Accept` 与 `Return`。 + +传播时间记录: + +| 项目 | 结果 | +| --- | --- | +| 从 A 创建成功到 B 自动出现 | 秒 | +| 是否出现系统通知 | 是 / 否 | +| 是否必须点击刷新才出现 | 是 / 否 | + +说明:自动到达时间目前作为观测值记录,不先假定固定 SLA。如果等待约 15 秒仍未出现,可点击一次刷新;“仅刷新后出现”应记为传播问题。 + +--- + +## 3. P0 接收与属性双向同步 + +### TC-04:B 接收交接 + +在实例 B: + +1. 打开 `[测试编号] 主交接`。 +2. 点击 `Accept`。 + +预期: + +- [ ] 状态从 Pending 变为 Accepted。 +- [ ] Work Item 仍分配给 B。 +- [ ] `Return` 不再作为可执行动作显示。 +- [ ] 重复触发 Accept 不会产生第二条交接或错误状态。 +- [ ] 实例 A 打开同一 Work Item 后也能看到 Accepted。 + +### TC-05:B 修改属性,A 实时同步 + +实例 A、B 同时打开同一个 Work Item。 + +在实例 B 依次修改: + +1. 状态:`In Progress → In Review`。 +2. 优先级:`High → Urgent`。 +3. 截止日期:`Tomorrow → Next week`。 + +预期: + +- [ ] B 每次修改后详情与左侧卡片一致。 +- [ ] A 无需重新打开 Work Item,最终看到 `In Review / Urgent / Next week`。 +- [ ] A 左侧卡片同步更新状态和优先级。 +- [ ] 快速连续修改不会被旧响应覆盖。 +- [ ] 刷新两个实例后,最终值仍保持一致。 +- [ ] Activity 中可以看到有意义的属性变更,而不是重复的无信息事件。 + +结果: + +| 字段 | B 最终值 | A 自动同步值 | 刷新后值 | +| --- | --- | --- | --- | +| Status | | | | +| Priority | | | | +| Due date | | | | + +--- + +## 4. P0 Todo 协作 + +### TC-06:B 新增和完成 Todo + +在实例 B: + +1. 新增 `B-检查同步`。 +2. 新增 `B-提交结论`。 +3. 勾选完成 `B-检查同步`。 + +预期: + +- [ ] B 显示 `1/2`。 +- [ ] 完成项有完成样式,未完成项仍可操作。 +- [ ] A 自动看到相同的两项和 `1/2`。 +- [ ] 刷新后仍保持相同状态。 + +### TC-07:A、B 连续修改 Todo + +1. A 新增 `A-补充验证`。 +2. B 勾选 `B-提交结论`。 +3. A 删除 `A-补充验证`。 + +预期: + +- [ ] 两边最终只保留两个 B 创建的 Todo。 +- [ ] 两项都完成,计数为 `2/2`。 +- [ ] 已删除的 Todo 不会因另一边的旧写入重新出现。 +- [ ] 不会丢失另一位成员刚完成的 Todo。 + +--- + +## 5. P0 评论与 @ 提及 + +### TC-08:B 评论并 @ A + +在实例 B 的同一个 Work Item: + +1. 点击评论输入区。 +2. 在 `@` 成员控件中选择 `1106510024`。 +3. 输入: + + ```text + 属性和 Todo 已更新,请确认。 + ``` + +4. 提交评论。 + +预期: + +- [ ] 评论立即出现在讨论区域,作者显示 `ahanafish`。 +- [ ] 同一作者的头像颜色保持一致。 +- [ ] 评论不会被普通 Activity 变更淹没;Discussion 中可直接找到。 +- [ ] 实例 A 的 `Team Inbox → Mentions` 自动出现一条未读提及。 +- [ ] 提及卡片指向正确 Work Item 和评论。 +- [ ] 实例 B 不会收到发给 A 的提及。 + +### TC-09:A 阅读与重新标记未读 + +在实例 A: + +1. 从 `Mentions` 打开这条提及。 +2. 返回列表。 +3. 在详情中执行 `Mark as unread`。 +4. 再次打开。 + +预期: + +- [ ] 第一次打开后 A 的 Mentions 未读数减少。 +- [ ] `Mark as unread` 后该行和 Sidebar 徽标重新变为未读。 +- [ ] 再次打开可以重新标记为已读。 +- [ ] B 的未读状态不受 A 操作影响。 +- [ ] 关闭并重开 Team Inbox 后,A 的最终读状态仍然保存。 + +--- + +## 6. P0 重新分配 + +### TC-10:B 将 Work Item 重新分配给 A + +在实例 B: + +1. 打开 Assignee 属性。 +2. 选择 `1106510024`。 + +预期: + +- [ ] Work Item assignee 立即显示 `1106510024`。 +- [ ] 该条目从 B 的 `Assigned` 中移除。 +- [ ] 该条目在 A 的 `Assigned` 中以未读状态出现。 +- [ ] A 打开后看到前面全部属性、Todo、评论,没有数据丢失。 +- [ ] Activity 只记录一次有效的重新分配。 + +--- + +## 7. P0 退回流程 + +Accepted Work Item 不再用于测试 Return。请创建第二个独立交接。 + +### TC-11:A 创建第二个交接,B 退回 + +1. A 再次从另一个 Session 创建 `[测试编号] 退回`。 +2. A 将它分配给 B。 +3. B 打开后不要 Accept,直接点击 `Return`。 +4. 输入退回原因: + + ```text + 请补充验收范围后再次交接。 + ``` + +5. 确认退回。 + +预期: + +- [ ] 空退回原因不能提交。 +- [ ] 提交后交接状态变为 Returned。 +- [ ] 退回原因在两边都可见。 +- [ ] Work Item 自动重新分配给 A。 +- [ ] 条目从 B 的 Assigned 移除。 +- [ ] A 的 Assigned 出现未读条目。 +- [ ] 属性、Todo、评论和来源 Session 均保留。 +- [ ] 刷新后 Returned 状态与退回原因仍存在。 + +--- + +## 8. P0 双向交接 + +### TC-12:B 的 Session 交接给 A + +在实例 B: + +1. 新建并分享到同一 Org 的 Session。 +2. 从该 Session 创建 `[测试编号] 反向交接`。 +3. `Assign to` 选择 `1106510024`。 +4. 选择非默认状态、优先级和日期。 +5. 提交。 + +预期: + +- [ ] 使用与 A → B 完全相同的交接弹窗。 +- [ ] Sender 是 `ahanafish`,Recipient 是 `1106510024`。 +- [ ] A 自动收到未读 Assigned 条目和 Pending 交接操作。 +- [ ] A 可以 Accept 或 Return。 +- [ ] 不出现方向专属的缺失字段或只读状态。 + +--- + +## 9. P1 幂等、恢复与隔离 + +### TC-13:重复拖入同一个 Session + +1. 选择已经成功创建过 Work Item 的 Session。 +2. 再次拖入 Team Inbox 并提交。 + +预期: + +- [ ] 复用已有链接 Work Item,或明确提示已存在。 +- [ ] 不产生第二个 Work Item。 +- [ ] 不产生第二份 Pending handoff。 + +### TC-14:提交失败后重试 + +如可安全模拟断网: + +1. 打开交接弹窗并填写标题、接收人、属性和备注。 +2. 暂时断网或让云端请求失败。 +3. 点击提交。 +4. 恢复网络后重试。 + +预期: + +- [ ] 失败时显示明确错误,不是静默关闭。 +- [ ] 标题、接收人、属性和备注仍保留。 +- [ ] 恢复后重试只创建一个 Work Item。 +- [ ] B 最终只收到一次分配。 + +### TC-15:切换 Org 的数据隔离 + +1. A 打开 Team Inbox。 +2. 切换到另一个 Org 或个人空间。 +3. 再切回 `ORG2–Invite–Test`。 + +预期: + +- [ ] 切出后不会继续显示前一个 Org 的 Team Inbox 数据。 +- [ ] 切回后重新加载正确数据。 +- [ ] 慢请求不会把旧 Org 的成员或 Work Item 写回当前界面。 + +### TC-16:搜索、筛选与空状态 + +在任一实例: + +1. 分别打开 `All / Mentions / Assigned`。 +2. 搜索本轮测试编号。 +3. 搜索一个不存在的文本。 +4. 清空搜索。 + +预期: + +- [ ] 每个 Tab 名称、列表内容和空状态语义一致。 +- [ ] 未读徽标只统计对应过滤器。 +- [ ] 无结果显示 `No matches`,而不是误报 Inbox 为空。 +- [ ] 清空搜索恢复原列表。 + +--- + +## 10. P1 UI 与响应式检查 + +### TC-17:属性组件一致性 + +对比交接弹窗、Team Inbox 详情、Open Work Item 正式详情: + +- [ ] Status、Priority、Due date 的图标、标签、颜色 token 一致。 +- [ ] 下拉选项和最终值映射一致。 +- [ ] `Tomorrow` 不会显示成挤压、重叠或原始 ISO 时间。 +- [ ] 缩窄窗口时属性 pills 自动换行,不横向溢出。 +- [ ] 下拉层位于 Modal 上方,不被裁剪。 + +### TC-18:键盘与提交锁 + +- [ ] Tab 可以按合理顺序到达标题、接收人、属性、备注和按钮。 +- [ ] Escape 在未提交时关闭弹窗。 +- [ ] 提交 loading 时 Escape、Cancel 和属性修改不会造成重复或中间态。 +- [ ] 下拉菜单可以用键盘打开、选择和关闭。 +- [ ] 如果选择的成员在提交前失效,界面显示可理解的原因,而不只是一个无法点击的按钮。 + +注意:共享 `Select` 的完整屏幕阅读器名称/combobox 语义仍是组件级待审项;本用例先记录实际键盘行为和可见标签,不把尚未完成的全站无障碍 sweep 标记为通过。 + +--- + +## 11. 最终通过标准 + +### 核心闭环 + +- [ ] A → B 创建、送达、读取成功。 +- [ ] B 能看到并操作 Pending handoff。 +- [ ] Accept 成功且跨实例同步。 +- [ ] Status / Priority / Due date 双向同步并持久化。 +- [ ] Todo 双向协作不丢数据、不复活已删除项。 +- [ ] 评论 @ A 后,仅 A 收到 Mentions 未读。 +- [ ] 读 / 未读状态按用户隔离并持久化。 +- [ ] 重新分配会移动 Assigned 投影并重置新接收人的未读。 +- [ ] Return 会保存原因并原子地重新分配给发送方。 +- [ ] B → A 反向交接使用同一套 UI 和状态机。 +- [ ] 重试或重复拖入不会创建重复 Work Item / handoff。 + +### 不允许出现 + +- [ ] 没有 UUID 代替已知成员名称。 +- [ ] 没有 raw enum,例如 `in_progress`、`in_review`。 +- [ ] 没有原始 ISO 日期出现在面向用户的文案中。 +- [ ] 没有 A、B 之间读状态串号。 +- [ ] 没有切换 Org 后显示旧 Org 数据。 +- [ ] 没有提交一次产生多份 Work Item。 + +--- + +## 12. 缺陷记录模板 + +复制下面模板,每个问题单独记录: + +```md +### [FAIL] TC-XX:问题标题 + +- 时间: +- 操作实例:A / B +- 登录账号: +- 当前 Org: +- Work Item 标题: +- 操作前状态: +- 操作步骤: + 1. + 2. + 3. +- 实际结果: +- 预期结果: +- 是否等待后自动恢复: +- 是否点击刷新后恢复: +- 是否重启后恢复: +- 截图: +- 相关错误原文: +``` diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs index 70cf848051..48a619e250 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs @@ -49,6 +49,19 @@ async fn persist_direct_user_intervention( .map_err(|err| format!("Agent Org intervention worker failed: {err}"))? } +pub(super) fn terminal_intent_status_override( + state: crate::session::DialogTurnState, +) -> Option { + match state { + crate::session::DialogTurnState::Cancelled => { + Some(crate::foundation::session_bridge::TurnIntentBridgeStatus::Cancelled) + } + crate::session::DialogTurnState::Running + | crate::session::DialogTurnState::Completed + | crate::session::DialogTurnState::Failed => None, + } +} + /// Implementation of agent_send_message. #[allow(clippy::too_many_arguments)] pub(crate) async fn send_message_impl( @@ -503,6 +516,18 @@ pub(crate) async fn send_message_impl( .unwrap_or_default(); session.end_turn(final_turn_state, stats).await; + // The turn processor can return Ok with an empty response after a + // user stop. Persist the authoritative cancelled terminal before + // handing control back to the scheduler; its generic Ok => + // completed write is then rejected by the intent state machine. + if let Some(status) = terminal_intent_status_override(final_turn_state) { + crate::foundation::session_bridge::update_turn_intent_status( + &sid, + &turn_intent_id, + status, + ); + } + let terminal_turn = response .as_ref() diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs index 3f167e9e9d..71711dbbd1 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs @@ -7,7 +7,7 @@ use super::exec_mode::{resolve_agent_mode, restore_mode_before_plan_entry}; use super::org_wake::{promote_agent_org_wake_session_to_running, resolve_agent_org_wake_mode}; -use super::send::should_divert_to_mid_turn_steering; +use super::send::{should_divert_to_mid_turn_steering, terminal_intent_status_override}; use crate::coordination::agent_inbox::{ AgentInboxStore, AgentMessage, InsertInboxParams, RequestId, }; @@ -185,6 +185,19 @@ fn force_send_never_enters_mid_turn_steering() { )); } +#[test] +fn cancelled_turn_overrides_scheduler_success_terminal() { + use crate::foundation::session_bridge::TurnIntentBridgeStatus; + use crate::session::DialogTurnState; + + assert!(matches!( + terminal_intent_status_override(DialogTurnState::Cancelled), + Some(TurnIntentBridgeStatus::Cancelled) + )); + assert!(terminal_intent_status_override(DialogTurnState::Completed).is_none()); + assert!(terminal_intent_status_override(DialogTurnState::Failed).is_none()); +} + /// Historical callers without a task-scoped mode keep Build semantics. #[test] fn wake_defaults_to_build() { diff --git a/src-tauri/crates/project-management/src/projects/commands/work_items.rs b/src-tauri/crates/project-management/src/projects/commands/work_items.rs index ad852f60f2..5e859d34f5 100644 --- a/src-tauri/crates/project-management/src/projects/commands/work_items.rs +++ b/src-tauri/crates/project-management/src/projects/commands/work_items.rs @@ -216,6 +216,20 @@ pub async fn project_update_work_item_partial( .map_err(|err| format!("Task join error: {}", err))? } +/// Atomic partial update for an org-scoped Work Item without a project row. +#[tauri::command] +pub async fn work_item_update_standalone_partial( + org_id: Option, + short_id: String, + updates: WorkItemPartialUpdate, +) -> Result { + tokio::task::spawn_blocking(move || { + io::update_standalone_work_item_partial(org_id.as_deref(), &short_id, &updates) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + /// Accept or return a pending human handoff using the Work Item's atomic /// mutation boundary. Read/unread is intentionally independent from this /// explicit response. @@ -232,6 +246,20 @@ pub async fn project_transition_work_item_handoff( .map_err(|err| format!("Task join error: {}", err))? } +/// Accept or return a pending org-scoped handoff without requiring a project. +#[tauri::command] +pub async fn work_item_transition_standalone_handoff( + org_id: Option, + short_id: String, + transition: WorkItemHandoffTransition, +) -> Result { + tokio::task::spawn_blocking(move || { + io::transition_standalone_work_item_handoff(org_id.as_deref(), &short_id, &transition) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + /// Move a work item from `from_project` to `to_project`. The /// short_id is preserved (it's globally unique under our prefix /// scheme), only the foreign key flips. diff --git a/src-tauri/crates/project-management/src/projects/io/mod.rs b/src-tauri/crates/project-management/src/projects/io/mod.rs index d5ab2437a0..9c080f4f20 100644 --- a/src-tauri/crates/project-management/src/projects/io/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/mod.rs @@ -54,9 +54,14 @@ pub use work_items::{ read_sync_metadata, read_work_item, read_work_item_by_row_id, read_work_item_enriched, read_work_item_enriched_scoped, read_work_item_scoped, read_work_items_view_data, read_work_items_view_data_scoped, release_execution_lock, restore_work_item, - transition_work_item_handoff, update_work_item_atomic, update_work_item_atomic_with_revisions, - update_work_item_partial, update_work_item_partial_enriched, - update_work_item_partial_with_revisions, write_standalone_work_item, write_work_item, - FieldRevision, SyncMetadata, REVISION_SOURCE_LOCAL, + transition_standalone_work_item_handoff, transition_work_item_handoff, + update_standalone_work_item_partial, update_work_item_atomic, + update_work_item_atomic_with_revisions, update_work_item_partial, + update_work_item_partial_enriched, update_work_item_partial_with_revisions, + write_standalone_work_item, write_work_item, FieldRevision, SyncMetadata, + REVISION_SOURCE_LOCAL, }; pub(crate) use work_items::{purge_work_item, write_work_item_remote}; +pub(crate) use work_items::{ + read_standalone_sync_metadata, update_standalone_work_item_partial_with_revisions, +}; diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs index 28274317cf..5a48e60174 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic.rs @@ -22,6 +22,12 @@ use super::extras::{ExtrasPayload, FieldRevision, REVISION_SOURCE_LOCAL}; use super::history::{append_mutation_event, WorkItemHistorySnapshot}; use crate::projects::types::{WorkItemData, WorkItemFrontmatter, WorkItemPartialUpdate}; +#[derive(Debug, Clone, Copy)] +enum AtomicWorkItemScope<'a> { + Project(&'a str), + Standalone { org_id: &'a str }, +} + /// Sync-relevant fields whose mutations are tracked in /// `workitem_extras.field_revisions`. The names match /// [`crate::sync::adapter::EntityField::as_local_name`] @@ -160,54 +166,111 @@ pub fn update_work_item_atomic_with_revisions( actor: Option<&crate::projects::types::WorkItemMutationActor>, mutator: F, ) -> Result<(T, Vec<&'static str>, bool), String> +where + F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result, +{ + update_work_item_atomic_with_revisions_scoped( + AtomicWorkItemScope::Project(project_slug), + short_id, + override_revisions, + actor, + mutator, + ) +} + +pub(super) fn update_standalone_work_item_atomic_as( + org_id: &str, + short_id: &str, + actor: Option<&crate::projects::types::WorkItemMutationActor>, + mutator: F, +) -> Result<(T, Vec<&'static str>, bool), String> +where + F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result, +{ + update_work_item_atomic_with_revisions_scoped( + AtomicWorkItemScope::Standalone { org_id }, + short_id, + HashMap::new(), + actor, + mutator, + ) +} + +fn update_work_item_atomic_with_revisions_scoped( + scope: AtomicWorkItemScope<'_>, + short_id: &str, + override_revisions: HashMap, + actor: Option<&crate::projects::types::WorkItemMutationActor>, + mutator: F, +) -> Result<(T, Vec<&'static str>, bool), String> where F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result, { let mut connection = conn()?; let tx = map_db(connection.transaction_with_behavior(TransactionBehavior::Immediate))?; - let project_id: String = map_db( - tx.query_row( - "SELECT id FROM projects WHERE slug = ?1", - params![project_slug], - |row| row.get(0), - ) - .optional(), - )? - .ok_or_else(|| format!("Project '{}' not found", project_slug))?; + let project_id = match scope { + AtomicWorkItemScope::Project(project_slug) => Some( + map_db( + tx.query_row( + "SELECT id FROM projects WHERE slug = ?1", + params![project_slug], + |row| row.get(0), + ) + .optional(), + )? + .ok_or_else(|| format!("Project '{}' not found", project_slug))?, + ), + AtomicWorkItemScope::Standalone { .. } => None, + }; - let core = map_db( - tx.query_row( - "SELECT id, short_id, title, body, status, priority, assignee, assignee_type, - milestone, parent, start_date, target_date, created_at, updated_at, - deleted_at, local_version, org_id - FROM workitems - WHERE project_id = ?1 AND short_id = ?2", - params![&project_id, short_id], - |row| { - Ok(AtomicCore { - work_item_id: row.get::<_, String>(0)?, - short_id: row.get::<_, String>(1)?, - title: row.get::<_, String>(2)?, - body: row.get::<_, Option>(3)?.unwrap_or_default(), - status: row.get::<_, String>(4)?, - priority: row.get::<_, String>(5)?, - assignee: row.get::<_, Option>(6)?, - assignee_type: row.get::<_, Option>(7)?, - milestone: row.get::<_, Option>(8)?, - parent: row.get::<_, Option>(9)?, - start_date: row.get::<_, Option>(10)?, - target_date: row.get::<_, Option>(11)?, - created_at_ms: row.get::<_, i64>(12)?, - updated_at_ms: row.get::<_, i64>(13)?, - deleted_at_ms: row.get::<_, Option>(14)?, - local_version: row.get::<_, i64>(15)?, - org_id: row.get::<_, String>(16)?, - }) - }, - ) - .optional(), - )? + let map_core = |row: &rusqlite::Row<'_>| { + Ok(AtomicCore { + work_item_id: row.get::<_, String>(0)?, + short_id: row.get::<_, String>(1)?, + title: row.get::<_, String>(2)?, + body: row.get::<_, Option>(3)?.unwrap_or_default(), + status: row.get::<_, String>(4)?, + priority: row.get::<_, String>(5)?, + assignee: row.get::<_, Option>(6)?, + assignee_type: row.get::<_, Option>(7)?, + milestone: row.get::<_, Option>(8)?, + parent: row.get::<_, Option>(9)?, + start_date: row.get::<_, Option>(10)?, + target_date: row.get::<_, Option>(11)?, + created_at_ms: row.get::<_, i64>(12)?, + updated_at_ms: row.get::<_, i64>(13)?, + deleted_at_ms: row.get::<_, Option>(14)?, + local_version: row.get::<_, i64>(15)?, + org_id: row.get::<_, String>(16)?, + }) + }; + let core = match scope { + AtomicWorkItemScope::Project(_) => map_db( + tx.query_row( + "SELECT id, short_id, title, body, status, priority, assignee, assignee_type, + milestone, parent, start_date, target_date, created_at, updated_at, + deleted_at, local_version, org_id + FROM workitems + WHERE project_id = ?1 AND short_id = ?2", + params![project_id.as_ref().expect("project scope id"), short_id], + map_core, + ) + .optional(), + )?, + AtomicWorkItemScope::Standalone { org_id } => map_db( + tx.query_row( + "SELECT id, short_id, title, body, status, priority, assignee, assignee_type, + milestone, parent, start_date, target_date, created_at, updated_at, + deleted_at, local_version, org_id + FROM workitems + WHERE org_id = ?1 AND project_id IS NULL AND short_id = ?2", + params![org_id, short_id], + map_core, + ) + .optional(), + )?, + } .ok_or_else(|| format!("Work item '{}' not found", short_id))?; // Read labels + extras inside the same tx so the snapshot is @@ -244,7 +307,7 @@ where None => ExtrasPayload::default(), }; - let mut frontmatter = build_frontmatter(Some(project_id.clone()), &core, labels, &extras); + let mut frontmatter = build_frontmatter(project_id.clone(), &core, labels, &extras); let mut body = core.body.clone(); // Snapshot every sync-tracked field's pre-mutation value so we can @@ -308,7 +371,7 @@ where } else { core.org_id.clone() }; - if next_project_id.as_deref() != Some(project_id.as_str()) { + if next_project_id != project_id { let exists_at_dest: bool = if let Some(next_project_id) = next_project_id.as_ref() { map_db( tx.query_row( @@ -515,6 +578,36 @@ pub fn update_work_item_partial( Ok(data) } +/// Standalone-org counterpart to [`update_work_item_partial`]. +/// +/// The mutation shares the same `BEGIN IMMEDIATE` boundary, history writer, +/// assignment-receipt reset, and field-revision logic as project-scoped work +/// items. A single collaboration outbox write is emitted after commit so +/// teammates receive status, priority, assignment, todo, and comment changes +/// without a frontend read-modify-write race. +pub fn update_standalone_work_item_partial( + org_id: Option<&str>, + short_id: &str, + updates: &WorkItemPartialUpdate, +) -> Result { + let org_id = org_id.unwrap_or("personal-org"); + let (data, changed_fields, payload_tail_changed) = update_work_item_partial_scoped( + AtomicWorkItemScope::Standalone { org_id }, + short_id, + HashMap::new(), + updates, + )?; + if !changed_fields.is_empty() || payload_tail_changed { + crate::sync::collab_bridge::record_work_item_write( + org_id, + None, + &data.frontmatter.id, + data.frontmatter.deleted_at.is_some(), + )?; + } + Ok(data) +} + /// True when the patch touches any field that lives only in the server /// payload jsonb (outside the sync-tracked field set). fn touches_payload_tail(updates: &WorkItemPartialUpdate) -> bool { @@ -597,8 +690,43 @@ pub fn update_work_item_partial_with_revisions( override_revisions: HashMap, updates: &WorkItemPartialUpdate, ) -> Result<(WorkItemData, Vec<&'static str>), String> { - let (data, changed_fields, _payload_tail_changed) = update_work_item_atomic_with_revisions( - project_slug, + let (data, changed_fields, _payload_tail_changed) = update_work_item_partial_scoped( + AtomicWorkItemScope::Project(project_slug), + short_id, + override_revisions, + updates, + )?; + Ok((data, changed_fields)) +} + +/// Standalone-org merge-cycle counterpart to +/// [`update_work_item_partial_with_revisions`]. +/// +/// This intentionally emits no outbox row: the caller is applying an inbound +/// remote snapshot and must not echo it back to the collaboration service. +pub(crate) fn update_standalone_work_item_partial_with_revisions( + org_id: &str, + short_id: &str, + override_revisions: HashMap, + updates: &WorkItemPartialUpdate, +) -> Result<(WorkItemData, Vec<&'static str>), String> { + let (data, changed_fields, _payload_tail_changed) = update_work_item_partial_scoped( + AtomicWorkItemScope::Standalone { org_id }, + short_id, + override_revisions, + updates, + )?; + Ok((data, changed_fields)) +} + +fn update_work_item_partial_scoped( + scope: AtomicWorkItemScope<'_>, + short_id: &str, + override_revisions: HashMap, + updates: &WorkItemPartialUpdate, +) -> Result<(WorkItemData, Vec<&'static str>, bool), String> { + update_work_item_atomic_with_revisions_scoped( + scope, short_id, override_revisions, updates.actor.as_ref(), @@ -683,8 +811,7 @@ pub fn update_work_item_partial_with_revisions( filename: short_id.to_string(), }) }, - )?; - Ok((data, changed_fields)) + ) } // --------------------------------------------------------------------- diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs index c9ac4eee8d..5830366b79 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/atomic_tests.rs @@ -2,12 +2,17 @@ use super::*; use crate::projects::io::projects::write_project; -use crate::projects::io::transition_work_item_handoff; -use crate::projects::io::work_items::{read_standalone_work_item, read_work_item, write_work_item}; +use crate::projects::io::work_items::{ + read_standalone_work_item, read_work_item, write_standalone_work_item, write_work_item, +}; +use crate::projects::io::{ + create_project_org, transition_standalone_work_item_handoff, transition_work_item_handoff, + update_standalone_work_item_partial, +}; use crate::projects::types::{ - CommentEntry, ProjectMeta, TodoEntry, WorkItemHandoff, WorkItemHandoffAction, - WorkItemHandoffStatus, WorkItemHandoffTransition, WorkItemHistoryAction, WorkItemMutationActor, - WorkItemPartialUpdate, WorkItemSchedule, + CommentEntry, CreateProjectOrgRequest, ProjectMeta, TodoEntry, WorkItemHandoff, + WorkItemHandoffAction, WorkItemHandoffStatus, WorkItemHandoffTransition, WorkItemHistoryAction, + WorkItemMutationActor, WorkItemPartialUpdate, WorkItemSchedule, }; use test_helpers::test_env; @@ -78,6 +83,17 @@ fn seed(slug: &str, project_id: &str) { write_work_item(slug, "AAA-0001", &fm, "body v1").expect("seed work item"); } +fn seed_standalone(org_id: &str) { + create_project_org(&CreateProjectOrgRequest { + name: "Team Org".to_string(), + id: Some(org_id.to_string()), + }) + .expect("create standalone org"); + let fm = work_item_fixture("standalone-w1", "ORG-0001", "Standalone"); + write_standalone_work_item(Some(org_id), "ORG-0001", &fm, "standalone body") + .expect("seed standalone work item"); +} + fn current_local_version(work_item_id: &str) -> i64 { let connection = conn().expect("conn"); connection @@ -147,6 +163,7 @@ fn partial_update_records_comment_history_event() { author: "member-1".to_string(), content: "Looks good".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), + mentioned_user_ids: Vec::new(), }]), actor: Some(WorkItemMutationActor { id: "member-1".to_string(), @@ -450,6 +467,122 @@ fn returned_handoff_atomically_reassigns_sender_and_resets_receipts() { assert_eq!(receipt_count, 0); } +#[test] +fn standalone_partial_update_persists_collaboration_fields_atomically() { + let _sandbox = test_env::sandbox(); + seed_standalone("org-team"); + + let result = update_standalone_work_item_partial( + Some("org-team"), + "ORG-0001", + &WorkItemPartialUpdate { + status: Some("in_progress".to_string()), + priority: Some("high".to_string()), + assignee: Some(Some("member-b".to_string())), + assignee_type: Some(Some("member".to_string())), + target_date: Some(Some("2026-08-01T00:00:00.000Z".to_string())), + todos: Some(vec![TodoEntry { + id: "todo-1".to_string(), + content: "Verify the handoff".to_string(), + status: "completed".to_string(), + }]), + comments: Some(vec![CommentEntry { + id: "comment-1".to_string(), + author: "member-b".to_string(), + content: "@Ada ready for review".to_string(), + created_at: "2026-07-29T09:00:00.000Z".to_string(), + mentioned_user_ids: vec!["member-a".to_string()], + }]), + actor: Some(WorkItemMutationActor { + id: "member-b".to_string(), + name: "Lin".to_string(), + }), + ..Default::default() + }, + ) + .expect("update standalone work item"); + + assert_eq!(result.frontmatter.status, "in_progress"); + assert_eq!(result.frontmatter.priority, "high"); + assert_eq!(result.frontmatter.assignee.as_deref(), Some("member-b")); + assert_eq!(result.frontmatter.todos[0].status, "completed"); + assert_eq!( + result.frontmatter.comments[0].mentioned_user_ids, + vec!["member-a".to_string()] + ); + + let persisted = + read_standalone_work_item(Some("org-team"), "ORG-0001").expect("read standalone"); + assert_eq!(persisted.frontmatter.status, "in_progress"); + assert_eq!(persisted.frontmatter.priority, "high"); + assert_eq!( + persisted.frontmatter.target_date.as_deref(), + Some("2026-08-01T00:00:00.000Z") + ); + assert_eq!(persisted.frontmatter.todos.len(), 1); + assert_eq!(persisted.frontmatter.comments.len(), 1); + assert!(persisted.frontmatter.history.iter().any(|event| { + event.actor_id.as_deref() == Some("member-b") + && event + .changes + .iter() + .any(|change| change.field == "assignee") + })); + assert_eq!(current_local_version("standalone-w1"), 1); +} + +#[test] +fn standalone_return_handoff_reassigns_to_original_sender() { + let _sandbox = test_env::sandbox(); + seed_standalone("org-team"); + + update_standalone_work_item_partial( + Some("org-team"), + "ORG-0001", + &WorkItemPartialUpdate { + assignee: Some(Some("member-b".to_string())), + assignee_type: Some(Some("member".to_string())), + handoff: Some(Some(WorkItemHandoff { + id: "standalone-handoff".to_string(), + status: WorkItemHandoffStatus::Pending, + sender_member_id: "member-a".to_string(), + sender_name: "Ada".to_string(), + recipient_member_id: "member-b".to_string(), + recipient_name: "Lin".to_string(), + note: Some("Please continue".to_string()), + requested_at: "2026-07-29T09:00:00.000Z".to_string(), + responded_at: None, + response_note: None, + })), + ..Default::default() + }, + ) + .expect("seed standalone handoff"); + + let result = transition_standalone_work_item_handoff( + Some("org-team"), + "ORG-0001", + &WorkItemHandoffTransition { + handoff_id: "standalone-handoff".to_string(), + action: WorkItemHandoffAction::Return, + actor: WorkItemMutationActor { + id: "member-b".to_string(), + name: "Lin".to_string(), + }, + note: Some("Need the original reproduction".to_string()), + }, + ) + .expect("return standalone handoff"); + + assert_eq!(result.frontmatter.assignee.as_deref(), Some("member-a")); + let handoff = result.frontmatter.handoff.expect("handoff"); + assert_eq!(handoff.status, WorkItemHandoffStatus::Returned); + assert_eq!( + handoff.response_note.as_deref(), + Some("Need the original reproduction") + ); +} + #[test] fn non_human_assignee_is_excluded_from_assigned_human_projection() { let _sandbox = test_env::sandbox(); @@ -627,6 +760,7 @@ fn partial_appends_comment_via_full_replace_semantics() { author: "alice".into(), content: "first".into(), created_at: "2026-01-01T00:00:00Z".into(), + mentioned_user_ids: Vec::new(), }]); update_work_item_partial("demo", "AAA-0001", &first).expect("first"); @@ -636,6 +770,7 @@ fn partial_appends_comment_via_full_replace_semantics() { author: "bob".into(), content: "replaced".into(), created_at: "2026-01-02T00:00:00Z".into(), + mentioned_user_ids: Vec::new(), }]); let result = update_work_item_partial("demo", "AAA-0001", &second).expect("second"); assert_eq!(result.frontmatter.comments.len(), 1); diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs index a10185922d..f948896057 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs @@ -348,6 +348,7 @@ fn extras_round_trip_carries_todos_and_comments() { author: "alice".into(), content: "lgtm".into(), created_at: "2026-01-01T00:00:00Z".into(), + mentioned_user_ids: Vec::new(), }]; write_work_item("demo", "AAA-0001", &fm, "").expect("write"); diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs b/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs index 018529f92b..46dafa65bb 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/handoff.rs @@ -3,7 +3,11 @@ use crate::projects::types::{ WorkItemHandoffTransition, }; -use super::{crud::read_work_item, update_work_item_atomic_as}; +use super::{ + atomic::update_standalone_work_item_atomic_as, + crud::{read_standalone_work_item, read_work_item}, + update_work_item_atomic_as, +}; const MAX_HANDOFF_RESPONSE_NOTE_CHARS: usize = 500; @@ -114,6 +118,49 @@ pub fn transition_work_item_handoff( read_work_item(project_slug, short_id) } +/// Standalone-org variant of [`transition_work_item_handoff`]. +/// +/// Cloud Team Inbox handoffs intentionally have no project row. They still +/// use the same atomic transition invariant and emit one org-scoped +/// collaboration write after the transaction commits. +pub fn transition_standalone_work_item_handoff( + org_id: Option<&str>, + short_id: &str, + transition: &WorkItemHandoffTransition, +) -> Result { + let org_id = org_id.unwrap_or("personal-org"); + let responded_at = chrono::Utc::now().to_rfc3339(); + let (_, changed_fields, payload_tail_changed) = update_standalone_work_item_atomic_as( + org_id, + short_id, + Some(&transition.actor), + |frontmatter, _body| { + let handoff = frontmatter + .handoff + .as_mut() + .ok_or_else(|| "This Work Item has no active handoff".to_string())?; + match apply_handoff_transition(handoff, transition, &responded_at)? { + AssigneeEffect::Keep => {} + AssigneeEffect::ReassignToSender(sender_id) => { + frontmatter.assignee = Some(sender_id); + frontmatter.assignee_type = Some("member".to_string()); + } + } + Ok(()) + }, + )?; + let data = read_standalone_work_item(Some(org_id), short_id)?; + if !changed_fields.is_empty() || payload_tail_changed { + crate::sync::collab_bridge::record_work_item_write( + org_id, + None, + &data.frontmatter.id, + false, + )?; + } + Ok(data) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs b/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs index b2b979c0a4..eefa11f2dd 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/mod.rs @@ -53,9 +53,11 @@ pub mod sync_metadata; mod views; pub use atomic::{ - update_work_item_atomic, update_work_item_atomic_as, update_work_item_atomic_with_revisions, - update_work_item_partial, update_work_item_partial_with_revisions, + update_standalone_work_item_partial, update_work_item_atomic, update_work_item_atomic_as, + update_work_item_atomic_with_revisions, update_work_item_partial, + update_work_item_partial_with_revisions, }; +pub(crate) use atomic::update_standalone_work_item_partial_with_revisions; pub use batch::{batch_delete_work_items, batch_update_work_items}; pub(crate) use crud::purge_work_item; pub(crate) use crud::write_work_item_remote; @@ -73,9 +75,10 @@ pub use enrichment::{ read_work_item_enriched_scoped, update_work_item_partial_enriched, }; pub use execution_lock::{acquire_execution_lock, release_execution_lock}; -pub use handoff::transition_work_item_handoff; +pub use handoff::{transition_standalone_work_item_handoff, transition_work_item_handoff}; pub use sync_metadata::{ apply_remote_merge, find_by_external_ref, read_sync_metadata, FieldRevision, SyncMetadata, REVISION_SOURCE_LOCAL, }; +pub(crate) use sync_metadata::read_standalone_sync_metadata; pub use views::{read_work_items_view_data, read_work_items_view_data_scoped}; diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs b/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs index ec4c25d1e4..6f23d27f7c 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/sync_metadata.rs @@ -74,11 +74,45 @@ pub fn read_sync_metadata( let Some(work_item_id) = work_item_id else { return Ok(None); }; + read_sync_metadata_for_work_item_id(&connection, &work_item_id) +} + +/// Standalone-org counterpart to [`read_sync_metadata`]. +/// +/// The collaboration bridge addresses standalone Work Items by their durable +/// row id. Reading metadata through that same identity avoids a short-id +/// ambiguity when different orgs use the same human-facing identifier. +pub(crate) fn read_standalone_sync_metadata( + org_id: &str, + work_item_id: &str, +) -> Result, String> { + let connection = conn()?; + let exists: bool = map_db( + connection + .query_row( + "SELECT EXISTS( + SELECT 1 FROM workitems + WHERE id = ?1 AND org_id = ?2 AND project_id IS NULL + )", + params![work_item_id, org_id], + |row| row.get(0), + ), + )?; + if !exists { + return Ok(None); + } + read_sync_metadata_for_work_item_id(&connection, work_item_id) +} + +fn read_sync_metadata_for_work_item_id( + connection: &rusqlite::Connection, + work_item_id: &str, +) -> Result, String> { let raw = map_db( connection .query_row( "SELECT extras_json FROM workitem_extras WHERE work_item_id = ?1", - params![&work_item_id], + params![work_item_id], |row| row.get::<_, String>(0), ) .optional(), diff --git a/src-tauri/crates/project-management/src/projects/schema.rs b/src-tauri/crates/project-management/src/projects/schema.rs index b016f96315..4656afd92b 100644 --- a/src-tauri/crates/project-management/src/projects/schema.rs +++ b/src-tauri/crates/project-management/src/projects/schema.rs @@ -425,6 +425,7 @@ fn init_local_tables(conn: &Connection) -> SqliteResult<()> { ensure_workitems_deleted_at_column(conn)?; ensure_projects_sync_columns(conn)?; ensure_collab_sync_columns(conn)?; + ensure_workitems_allow_standalone_scope(conn)?; ensure_routine_definitions_durable_columns(conn)?; ensure_routine_fires_durable_columns(conn)?; conn.execute( @@ -454,6 +455,142 @@ fn ensure_workitems_deleted_at_column(conn: &Connection) -> SqliteResult<()> { ensure_column(conn, "workitems", "deleted_at", "INTEGER") } +/// Rebuild legacy `workitems` tables whose `project_id` still requires a +/// project. Org-level Work Items intentionally have no project, so the +/// authoritative storage invariant is `(org_id, project_id = NULL)`. +/// +/// SQLite cannot remove a `NOT NULL` constraint or change a foreign-key +/// action in place. The migration therefore copies the rows into the current +/// table shape while foreign-key enforcement is temporarily suspended, then +/// restores the indexes and verifies the resulting graph before returning. +fn ensure_workitems_allow_standalone_scope(conn: &Connection) -> SqliteResult<()> { + let project_id_is_required = { + let mut statement = conn.prepare("PRAGMA table_info(workitems)")?; + let columns = statement.query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, i64>(3)?)) + })?; + let mut required = false; + for column in columns { + let (name, not_null) = column?; + if name == "project_id" { + required = not_null != 0; + break; + } + } + required + }; + + let project_delete_sets_null = { + let mut statement = conn.prepare("PRAGMA foreign_key_list(workitems)")?; + let foreign_keys = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(6)?, + )) + })?; + let mut sets_null = false; + for foreign_key in foreign_keys { + let (table, from, on_delete) = foreign_key?; + if table == "projects" && from == "project_id" { + sets_null = on_delete.eq_ignore_ascii_case("SET NULL"); + break; + } + } + sets_null + }; + + if !project_id_is_required && project_delete_sets_null { + return Ok(()); + } + + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + let migration = (|| -> SqliteResult<()> { + let transaction = conn.unchecked_transaction()?; + transaction.execute_batch( + r#" + CREATE TABLE workitems_standalone_migration ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL DEFAULT 'personal-org' REFERENCES project_orgs(id) ON DELETE RESTRICT, + project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, + short_id TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT NOT NULL DEFAULT 'none', + assigned_human_id TEXT, + assignee TEXT, + assignee_type TEXT, + milestone TEXT, + parent TEXT, + start_date TEXT, + target_date TEXT, + estimate REAL, + order_index INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + deleted_at INTEGER, + local_version INTEGER NOT NULL DEFAULT 0, + collab_remote_version INTEGER + ); + + INSERT INTO workitems_standalone_migration ( + id, org_id, project_id, short_id, title, body, status, priority, + assigned_human_id, assignee, assignee_type, milestone, parent, + start_date, target_date, estimate, order_index, created_at, + updated_at, completed_at, deleted_at, local_version, + collab_remote_version + ) + SELECT + id, org_id, project_id, short_id, title, body, status, priority, + assigned_human_id, assignee, assignee_type, milestone, parent, + start_date, target_date, estimate, order_index, created_at, + updated_at, completed_at, deleted_at, local_version, + collab_remote_version + FROM workitems; + + DROP TABLE workitems; + ALTER TABLE workitems_standalone_migration RENAME TO workitems; + + CREATE UNIQUE INDEX idx_workitems_project_short_id + ON workitems(project_id, short_id) + WHERE project_id IS NOT NULL; + CREATE UNIQUE INDEX idx_workitems_standalone_short_id + ON workitems(org_id, short_id) + WHERE project_id IS NULL; + CREATE INDEX idx_workitems_org ON workitems(org_id); + CREATE INDEX idx_workitems_org_status ON workitems(org_id, status); + CREATE INDEX idx_workitems_project_status ON workitems(project_id, status); + CREATE INDEX idx_workitems_assigned_human ON workitems(assigned_human_id); + CREATE INDEX idx_workitems_assignee ON workitems(assignee); + CREATE INDEX idx_workitems_parent ON workitems(parent); + CREATE INDEX idx_workitems_milestone ON workitems(milestone); + CREATE INDEX idx_workitems_updated_at ON workitems(updated_at); + CREATE INDEX idx_workitems_deleted_at ON workitems(deleted_at); + "#, + )?; + transaction.commit() + })(); + + if migration.is_err() { + let _ = conn.execute_batch("ROLLBACK;"); + } + let foreign_keys_result = conn.execute_batch("PRAGMA foreign_keys = ON;"); + migration?; + foreign_keys_result?; + + let foreign_key_violation: i64 = conn.query_row( + "SELECT COUNT(*) FROM pragma_foreign_key_check", + [], + |row| row.get(0), + )?; + if foreign_key_violation != 0 { + return Err(rusqlite::Error::ExecuteReturnedResults); + } + Ok(()) +} + /// Backfill the project-sync columns on DBs created before they were /// added to the `projects` CREATE TABLE. /// @@ -664,6 +801,111 @@ mod tests { init_project_tables(&conn).expect("second init should not fail"); } + #[test] + fn legacy_workitems_schema_is_rebuilt_for_org_level_items() { + let conn = open_in_memory(); + conn.execute_batch( + r#" + CREATE TABLE project_orgs ( + id TEXT PRIMARY KEY + ); + CREATE TABLE projects ( + id TEXT PRIMARY KEY + ); + CREATE TABLE workitems ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL DEFAULT 'personal-org' REFERENCES project_orgs(id), + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + short_id TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT NOT NULL DEFAULT 'none', + assigned_human_id TEXT, + assignee TEXT, + assignee_type TEXT, + milestone TEXT, + parent TEXT, + start_date TEXT, + target_date TEXT, + estimate REAL, + order_index INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + deleted_at INTEGER, + local_version INTEGER NOT NULL DEFAULT 0, + collab_remote_version INTEGER + ); + CREATE TABLE workitem_extras ( + work_item_id TEXT PRIMARY KEY REFERENCES workitems(id) ON DELETE CASCADE, + extras_json TEXT NOT NULL DEFAULT '{}' + ); + + INSERT INTO project_orgs (id) VALUES ('org-1'); + INSERT INTO projects (id) VALUES ('project-1'); + INSERT INTO workitems ( + id, org_id, project_id, short_id, title, created_at, updated_at + ) VALUES ( + 'item-1', 'org-1', 'project-1', 'PRJ-0001', 'Existing', 1, 1 + ); + INSERT INTO workitem_extras (work_item_id) VALUES ('item-1'); + "#, + ) + .expect("legacy fixture"); + + ensure_workitems_allow_standalone_scope(&conn).expect("migrate legacy workitems"); + + let project_id_not_null: i64 = conn + .query_row( + "SELECT \"notnull\" FROM pragma_table_info('workitems') WHERE name = 'project_id'", + [], + |row| row.get(0), + ) + .expect("project_id shape"); + assert_eq!( + project_id_not_null, 0, + "org-level Work Items must permit a NULL project_id" + ); + + conn.execute( + "INSERT INTO workitems ( + id, org_id, project_id, short_id, title, created_at, updated_at + ) VALUES ('item-2', 'org-1', NULL, 'WI-0001', 'Handoff', 2, 2)", + [], + ) + .expect("standalone Work Item"); + + let extras_preserved: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workitem_extras WHERE work_item_id = 'item-1'", + [], + |row| row.get(0), + ) + .expect("preserved child row"); + assert_eq!(extras_preserved, 1); + + conn.execute("DELETE FROM projects WHERE id = 'project-1'", []) + .expect("delete project"); + let detached_project_id: Option = conn + .query_row( + "SELECT project_id FROM workitems WHERE id = 'item-1'", + [], + |row| row.get(0), + ) + .expect("detached Work Item"); + assert_eq!(detached_project_id, None); + + let foreign_key_violations: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_foreign_key_check", + [], + |row| row.get(0), + ) + .expect("foreign-key check"); + assert_eq!(foreign_key_violations, 0); + } + #[test] fn init_migrates_legacy_routine_columns_before_index_creation() { let conn = open_in_memory(); diff --git a/src-tauri/crates/project-management/src/projects/types/project.rs b/src-tauri/crates/project-management/src/projects/types/project.rs index 1bf983be2a..84f9efc931 100644 --- a/src-tauri/crates/project-management/src/projects/types/project.rs +++ b/src-tauri/crates/project-management/src/projects/types/project.rs @@ -206,6 +206,8 @@ pub struct CommentEntry { pub author: String, pub content: String, pub created_at: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mentioned_user_ids: Vec, } /// A market delegation entry on a work item diff --git a/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs b/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs index f2d4ed4dfa..8427bfcef9 100644 --- a/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs +++ b/src-tauri/crates/project-management/src/sync/collab_bridge/apply.rs @@ -14,7 +14,8 @@ use super::wire::{iso_to_ms, now_ms, string_field}; use super::{COLLAB_ORG_SOURCE, COLLAB_SYNC_PROVIDER, KIND_PROJECT, KIND_WORK_ITEM}; use crate::projects::io::{ apply_remote_merge, create_project_org, read_project_field_revisions, read_project_org, - read_project_scoped, read_sync_metadata, read_work_item_by_row_id, + read_project_scoped, read_standalone_sync_metadata, read_sync_metadata, + read_work_item_by_row_id, update_standalone_work_item_partial_with_revisions, update_work_item_partial_with_revisions, write_project_remote, write_work_item_remote, FieldRevision, PROJECT_SYNC_FIELDS, }; @@ -848,28 +849,48 @@ fn apply_work_item(org_id: &str, entity: &CollabRemoteEntity) -> Result = + decision.adopted_fields.clone().into_iter().collect(); + let mut update = crate::sync::worker::partial_update_from_map(&adopted); + if !has_pending { + apply_wire_tail(&mut update, &entity.payload); + } else if let Some(local) = read_work_item_by_row_id(org_id, &work_item_id)? { + apply_wire_tail_union(&mut update, &entity.payload, &local.frontmatter); + } + if wire_project_id != local_project_id { + update.project = Some(wire_project_id.clone()); } drop(conn); - let frontmatter = - frontmatter_from_wire(&entity.payload, &work_item_id, wire_project_id.clone()); - let body = entity - .payload - .get("body") - .and_then(Value::as_str) - .unwrap_or_default(); - write_work_item_remote( - wire_project_id.clone(), + update_standalone_work_item_partial_with_revisions( org_id, - &frontmatter.short_id.clone(), - &frontmatter, - body, + &short_id, + decision.new_revisions, + &update, )?; let conn = io::conn()?; store_remote_version(&conn, KIND_WORK_ITEM, &work_item_id, entity.version)?; diff --git a/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs b/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs index 4e8524f1ef..cd69b45979 100644 --- a/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs +++ b/src-tauri/crates/project-management/src/sync/collab_bridge/tests.rs @@ -3,8 +3,8 @@ use super::wire::now_ms; use super::*; use crate::projects::io::{ acquire_execution_lock, configure_project_org_collab_sync, create_project_org, read_project, - read_work_item, release_execution_lock, update_work_item_partial, write_project, - write_work_item, + read_standalone_work_item, read_work_item, release_execution_lock, + update_standalone_work_item_partial, update_work_item_partial, write_project, write_work_item, }; use crate::projects::types::{ CommentEntry, CreateProjectOrgRequest, ProjectData, ProjectMeta, WorkItemExecutionLockReason, @@ -402,6 +402,116 @@ fn apply_remote_creates_entities_without_echo() { assert_eq!(item.frontmatter.title, "Remote item"); } +#[test] +fn standalone_pending_update_rebases_and_merges_remote_tail_without_conflict_loop() { + let _sandbox = test_env::sandbox(); + seed_collab_org(); + + apply_remote( + ORG, + None, + vec![CollabRemoteEntity { + kind: KIND_WORK_ITEM.to_string(), + payload: json!({ + "id": "standalone-row", + "shortId": "ORG-0001", + "title": "Original title", + "body": "Original body", + "status": "backlog", + "priority": "none", + "comments": [], + "updatedAt": "2026-07-01T00:00:00Z", + }), + version: 1, + updated_by: Some("member-a".to_string()), + deleted_at: None, + }], + ) + .expect("seed remote standalone"); + + let local_comment = CommentEntry { + id: "comment-local".to_string(), + author: "member-a".to_string(), + content: "local pending comment".to_string(), + created_at: "2026-07-29T01:00:00Z".to_string(), + mentioned_user_ids: vec![], + }; + update_standalone_work_item_partial( + Some(ORG), + "ORG-0001", + &WorkItemPartialUpdate { + status: Some("in_progress".to_string()), + comments: Some(vec![local_comment.clone()]), + ..WorkItemPartialUpdate::default() + }, + ) + .expect("local pending update"); + assert_eq!(pending_org_rows(), 1); + + let remote_comment = CommentEntry { + id: "comment-remote".to_string(), + author: "member-b".to_string(), + content: "remote teammate comment".to_string(), + created_at: "2026-07-29T01:00:01Z".to_string(), + mentioned_user_ids: vec!["member-a".to_string()], + }; + let applied = apply_remote( + ORG, + None, + vec![CollabRemoteEntity { + kind: KIND_WORK_ITEM.to_string(), + payload: json!({ + "id": "standalone-row", + "shortId": "ORG-0001", + "title": "Remote title", + "body": "Original body", + "status": "backlog", + "priority": "high", + "comments": [remote_comment], + "updatedAt": "2026-07-01T00:05:00Z", + }), + version: 2, + updated_by: Some("member-b".to_string()), + deleted_at: None, + }], + ) + .expect("apply conflicting remote standalone"); + assert_eq!(applied, 1); + + let item = + read_standalone_work_item(Some(ORG), "ORG-0001").expect("read merged standalone item"); + assert_eq!( + item.frontmatter.status, "in_progress", + "the newer local status watermark survives the older remote row" + ); + assert_eq!(item.frontmatter.title, "Remote title"); + assert_eq!(item.frontmatter.priority, "high"); + assert_eq!( + item.frontmatter + .comments + .iter() + .map(|comment| comment.id.as_str()) + .collect::>(), + vec!["comment-local", "comment-remote"], + "stable-id union preserves both independently appended comments" + ); + + let retried = drain_outbox(ORG, 50).expect("drain rebased retry"); + assert_eq!(retried.len(), 1); + assert_eq!( + retried[0].base_version, + Some(2), + "the retry must use the pulled remote version instead of conflicting again" + ); + let comments = retried[0] + .payload + .as_ref() + .and_then(|payload| payload.get("comments")) + .and_then(Value::as_array) + .expect("merged comments in outgoing snapshot"); + assert_eq!(comments.len(), 2); +} + #[test] fn apply_remote_updates_handoff_on_an_existing_project_work_item() { let _sandbox = test_env::sandbox(); @@ -749,6 +859,7 @@ fn pending_local_push_blocks_remote_tail_clobber_and_unions_lists() { author: "me".to_string(), content: "local pending comment".to_string(), created_at: "2026-07-01T00:01:00Z".to_string(), + mentioned_user_ids: Vec::new(), }]); update_work_item_partial("remote-project", "REM-0001", &update).expect("local comment"); assert!(pending_org_rows() >= 1, "local comment should be pending"); diff --git a/src-tauri/crates/project-management/src/team_inbox/store.rs b/src-tauri/crates/project-management/src/team_inbox/store.rs index ebeb36d164..ac549f3a6c 100644 --- a/src-tauri/crates/project-management/src/team_inbox/store.rs +++ b/src-tauri/crates/project-management/src/team_inbox/store.rs @@ -13,6 +13,7 @@ use super::{ use crate::projects::types::{WorkItemHandoff, WorkItemHandoffStatus}; const ASSIGNED_SOURCE_KIND: &str = "work_item_assigned"; +const COMMENT_MENTION_SOURCE_KIND: &str = "work_item_comment_mention"; const DEFAULT_PAGE_LIMIT: usize = 50; const MAX_PAGE_LIMIT: usize = 100; /// Upper bound on the assigned-item summary so a long Work Item body never @@ -122,31 +123,67 @@ pub(crate) fn list_page_with_connection( ) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(&options.viewer_member_ids)?; - if options.filter == TeamInboxFilter::Mentions { - return Ok(TeamInboxPage { - items: Vec::new(), - next_cursor: None, - unread_count: 0, - }); + let limit = options.limit.clamp(1, MAX_PAGE_LIMIT); + let fetch_limit = limit + 1; + let mut items = Vec::new(); + if options.filter != TeamInboxFilter::Mentions { + items.extend(list_assigned_items( + connection, + &viewer_ids, + options.cursor.as_ref(), + fetch_limit, + )?); + } + if options.filter != TeamInboxFilter::Assigned { + items.extend(list_work_item_comment_mentions( + connection, + &viewer_ids, + options.cursor.as_ref(), + fetch_limit, + )?); } + items.sort_by(|left, right| { + right + .occurred_at + .cmp(&left.occurred_at) + .then_with(|| right.id.cmp(&left.id)) + }); + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = has_more.then(|| { + let last = items + .last() + .expect("a paginated page with overflow is non-empty"); + TeamInboxCursor { + occurred_at: last.occurred_at, + item_id: last.id.clone(), + } + }); + let unread_count = unread_count_with_connection(connection, &viewer_ids, options.filter)?; - let limit = options.limit.clamp(1, MAX_PAGE_LIMIT); - let cursor_source_id = options - .cursor - .as_ref() - .map(|cursor| { - assigned_source_id(&cursor.item_id) - .map(ToOwned::to_owned) - .ok_or_else(|| format!("Unsupported Team Inbox cursor item id: {}", cursor.item_id)) - }) - .transpose()?; + Ok(TeamInboxPage { + items, + next_cursor, + unread_count, + }) +} + +fn list_assigned_items( + connection: &Connection, + viewer_ids: &[String], + cursor: Option<&TeamInboxCursor>, + limit: usize, +) -> Result, String> { let viewer_placeholders = sql_placeholders(viewer_ids.len()); let assignment_predicate = assignment_predicate(&viewer_placeholders); let receipt_viewer_predicate = format!("r.viewer_member_id IN ({viewer_placeholders})"); - let cursor_predicate = if options.cursor.is_some() { - "AND (w.updated_at < ? OR (w.updated_at = ? AND w.id < ?))" + let cursor_predicate = if cursor.is_some() { + format!( + "AND (w.updated_at < ? OR + (w.updated_at = ? AND ('{ASSIGNED_SOURCE_KIND}:' || w.id) < ?))" + ) } else { - "" + String::new() }; let sql = format!( "SELECT w.id, w.org_id, w.project_id, p.slug, w.short_id, w.title, @@ -165,14 +202,14 @@ pub(crate) fn list_page_with_connection( LIMIT ?" ); - let mut values = assignment_values(&viewer_ids); + let mut values = assignment_values(viewer_ids); values.extend(viewer_ids.iter().cloned().map(Value::from)); - if let (Some(cursor), Some(source_id)) = (options.cursor.as_ref(), cursor_source_id) { + if let Some(cursor) = cursor { values.push(Value::from(cursor.occurred_at)); values.push(Value::from(cursor.occurred_at)); - values.push(Value::from(source_id)); + values.push(Value::from(cursor.item_id.clone())); } - values.push(Value::from((limit + 1) as i64)); + values.push(Value::from(limit as i64)); let mut statement = connection.prepare(&sql).map_err(db_error)?; let rows = statement @@ -206,25 +243,101 @@ pub(crate) fn list_page_with_connection( }) }) .map_err(db_error)?; - let mut items = rows.collect::, _>>().map_err(db_error)?; - let has_more = items.len() > limit; - items.truncate(limit); - let next_cursor = has_more.then(|| { - let last = items - .last() - .expect("a paginated page with overflow is non-empty"); - TeamInboxCursor { - occurred_at: last.occurred_at, - item_id: last.id.clone(), - } - }); - let unread_count = unread_count_with_connection(connection, &viewer_ids, options.filter)?; + rows.collect::, _>>().map_err(db_error) +} - Ok(TeamInboxPage { - items, - next_cursor, - unread_count, - }) +fn list_work_item_comment_mentions( + connection: &Connection, + viewer_ids: &[String], + cursor: Option<&TeamInboxCursor>, + limit: usize, +) -> Result, String> { + let placeholders = sql_placeholders(viewer_ids.len()); + let receipt_viewer_predicate = format!("r.viewer_member_id IN ({placeholders})"); + let occurred_expression = + "CAST((julianday(json_extract(c.value, '$.created_at')) - 2440587.5) * 86400000 AS INTEGER)"; + let item_id_expression = + format!("'{COMMENT_MENTION_SOURCE_KIND}:' || w.id || ':' || json_extract(c.value, '$.id')"); + let cursor_predicate = if cursor.is_some() { + format!( + "AND ({occurred_expression} < ? OR + ({occurred_expression} = ? AND {item_id_expression} < ?))" + ) + } else { + String::new() + }; + let sql = format!( + "SELECT w.id, w.org_id, w.project_id, p.slug, w.short_id, w.title, + json_extract(c.value, '$.id'), + json_extract(c.value, '$.author'), + json_extract(c.value, '$.content'), + {occurred_expression} AS occurred_at, + (SELECT MAX(r.read_at) FROM team_inbox_read_receipts r + WHERE r.source_kind = '{COMMENT_MENTION_SOURCE_KIND}' + AND r.source_id = w.id || ':' || json_extract(c.value, '$.id') + AND {receipt_viewer_predicate}) AS read_at, + json_array_length(COALESCE(json_extract(e.extras_json, '$.comments'), '[]')) + FROM workitems w + JOIN workitem_extras e ON e.work_item_id = w.id + JOIN json_each(COALESCE(json_extract(e.extras_json, '$.comments'), '[]')) c + LEFT JOIN projects p ON p.id = w.project_id + WHERE w.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM json_each(COALESCE(json_extract(c.value, '$.mentioned_user_ids'), '[]')) m + WHERE CAST(m.value AS TEXT) IN ({placeholders}) + ) + {cursor_predicate} + ORDER BY occurred_at DESC, {item_id_expression} DESC + LIMIT ?" + ); + let mut values = viewer_ids + .iter() + .cloned() + .map(Value::from) + .collect::>(); + values.extend(viewer_ids.iter().cloned().map(Value::from)); + if let Some(cursor) = cursor { + values.push(Value::from(cursor.occurred_at)); + values.push(Value::from(cursor.occurred_at)); + values.push(Value::from(cursor.item_id.clone())); + } + values.push(Value::from(limit as i64)); + + let mut statement = connection.prepare(&sql).map_err(db_error)?; + let rows = statement + .query_map(params_from_iter(values), |row| { + let work_item_id: String = row.get(0)?; + let comment_id: String = row.get(6)?; + let author: String = row.get(7)?; + let content: String = row.get(8)?; + Ok(TeamInboxItem { + id: comment_mention_item_id(&work_item_id, &comment_id), + kind: TeamInboxItemKind::CommentMention, + occurred_at: row.get(9)?, + read_at: row.get(10)?, + actor: Some(TeamInboxActor { + id: author.clone(), + display_name: author, + avatar_url: None, + }), + target: TeamInboxTarget::WorkItemComment { + work_item_id, + org_id: row.get(1)?, + project_id: row.get(2)?, + project_slug: row.get(3)?, + short_id: row.get(4)?, + comment_id, + }, + payload: TeamInboxPayload::CommentMention { + session_title: row.get(5)?, + comment_excerpt: work_item_summary_excerpt(&content).unwrap_or_default(), + comment_count: row.get::<_, i64>(11)?.max(0) as u32, + }, + }) + }) + .map_err(db_error)?; + rows.collect::, _>>().map_err(db_error) } pub(crate) fn unread_count_with_connection( @@ -234,9 +347,20 @@ pub(crate) fn unread_count_with_connection( ) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; - if filter == TeamInboxFilter::Mentions { - return Ok(0); - } + let assigned_count = if filter == TeamInboxFilter::Mentions { + 0 + } else { + assigned_unread_count(connection, &viewer_ids)? + }; + let mention_count = if filter == TeamInboxFilter::Assigned { + 0 + } else { + comment_mention_unread_count(connection, &viewer_ids)? + }; + Ok(assigned_count + mention_count) +} + +fn assigned_unread_count(connection: &Connection, viewer_ids: &[String]) -> Result { let placeholders = sql_placeholders(viewer_ids.len()); let sql = format!( "SELECT COUNT(*) FROM workitems w @@ -250,12 +374,47 @@ pub(crate) fn unread_count_with_connection( )", assignment_predicate(&placeholders) ); - let mut values = assignment_values(&viewer_ids); - values.extend(viewer_ids.into_iter().map(Value::from)); + let mut values = assignment_values(viewer_ids); + values.extend(viewer_ids.iter().cloned().map(Value::from)); let count: i64 = connection .query_row(&sql, params_from_iter(values), |row| row.get(0)) .map_err(db_error)?; - Ok(count as u64) + Ok(count.max(0) as u64) +} + +fn comment_mention_unread_count( + connection: &Connection, + viewer_ids: &[String], +) -> Result { + let placeholders = sql_placeholders(viewer_ids.len()); + let sql = format!( + "SELECT COUNT(*) + FROM workitems w + JOIN workitem_extras e ON e.work_item_id = w.id + JOIN json_each(COALESCE(json_extract(e.extras_json, '$.comments'), '[]')) c + WHERE w.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM json_each(COALESCE(json_extract(c.value, '$.mentioned_user_ids'), '[]')) m + WHERE CAST(m.value AS TEXT) IN ({placeholders}) + ) + AND NOT EXISTS ( + SELECT 1 FROM team_inbox_read_receipts r + WHERE r.source_kind = '{COMMENT_MENTION_SOURCE_KIND}' + AND r.source_id = w.id || ':' || json_extract(c.value, '$.id') + AND r.viewer_member_id IN ({placeholders}) + )" + ); + let mut values = viewer_ids + .iter() + .chain(viewer_ids.iter()) + .cloned() + .map(Value::from) + .collect::>(); + let count: i64 = connection + .query_row(&sql, params_from_iter(values.drain(..)), |row| row.get(0)) + .map_err(db_error)?; + Ok(count.max(0) as u64) } pub(crate) fn mark_read_with_connection( @@ -266,15 +425,41 @@ pub(crate) fn mark_read_with_connection( ) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; - let source_id = assigned_source_id(item_id) - .ok_or_else(|| format!("Unsupported Team Inbox item id: {item_id}"))?; let placeholders = sql_placeholders(viewer_ids.len()); - let sql = format!( - "SELECT 1 FROM workitems w WHERE w.id = ? AND w.deleted_at IS NULL AND {}", - assignment_predicate(&placeholders) - ); - let mut values = vec![Value::from(source_id.to_string())]; - values.extend(assignment_values(&viewer_ids)); + let (source_kind, source_id, sql, values) = if let Some(source_id) = assigned_source_id(item_id) + { + let sql = format!( + "SELECT 1 FROM workitems w WHERE w.id = ? AND w.deleted_at IS NULL AND {}", + assignment_predicate(&placeholders) + ); + let mut values = vec![Value::from(source_id.to_string())]; + values.extend(assignment_values(&viewer_ids)); + (ASSIGNED_SOURCE_KIND, source_id.to_string(), sql, values) + } else if let Some(source_id) = comment_mention_source_id(item_id) { + let sql = format!( + "SELECT 1 + FROM workitems w + JOIN workitem_extras e ON e.work_item_id = w.id + JOIN json_each(COALESCE(json_extract(e.extras_json, '$.comments'), '[]')) c + WHERE w.deleted_at IS NULL + AND w.id || ':' || json_extract(c.value, '$.id') = ? + AND EXISTS ( + SELECT 1 + FROM json_each(COALESCE(json_extract(c.value, '$.mentioned_user_ids'), '[]')) m + WHERE CAST(m.value AS TEXT) IN ({placeholders}) + )" + ); + let mut values = vec![Value::from(source_id.to_string())]; + values.extend(viewer_ids.iter().cloned().map(Value::from)); + ( + COMMENT_MENTION_SOURCE_KIND, + source_id.to_string(), + sql, + values, + ) + } else { + return Err(format!("Unsupported Team Inbox item id: {item_id}")); + }; let tx = connection .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(db_error)?; @@ -295,7 +480,7 @@ pub(crate) fn mark_read_with_connection( VALUES (?1, ?2, ?3, ?4) ON CONFLICT(viewer_member_id, source_kind, source_id) DO UPDATE SET read_at = MAX(read_at, excluded.read_at)", - (viewer_id, ASSIGNED_SOURCE_KIND, source_id, read_at), + (viewer_id, source_kind, &source_id, read_at), ) .map_err(db_error)?; } @@ -311,41 +496,77 @@ pub(crate) fn mark_all_read_with_connection( ) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; - if filter == TeamInboxFilter::Mentions { - return Ok(0); - } let tx = connection .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(db_error)?; let before = unread_count_with_connection(&tx, &viewer_ids, filter)?; let placeholders = sql_placeholders(viewer_ids.len()); - // Only touch rows that are still unread for this viewer set. Re-stamping - // already-read receipts is wasted work (O(assigned × viewers) writes) and - // this predicate mirrors `unread_count_with_connection`, so the post-state - // is identical while the write set is bounded to what was actually unread. - let query = format!( - "SELECT w.id FROM workitems w - WHERE w.deleted_at IS NULL - AND {} - AND NOT EXISTS ( - SELECT 1 FROM team_inbox_read_receipts r - WHERE r.source_kind = '{ASSIGNED_SOURCE_KIND}' - AND r.source_id = w.id - AND r.viewer_member_id IN ({placeholders}) - )", - assignment_predicate(&placeholders) - ); - let source_ids = { + let mut sources = Vec::<(&'static str, String)>::new(); + if filter != TeamInboxFilter::Mentions { + // Only touch rows that are still unread for this viewer set. + let query = format!( + "SELECT w.id FROM workitems w + WHERE w.deleted_at IS NULL + AND {} + AND NOT EXISTS ( + SELECT 1 FROM team_inbox_read_receipts r + WHERE r.source_kind = '{ASSIGNED_SOURCE_KIND}' + AND r.source_id = w.id + AND r.viewer_member_id IN ({placeholders}) + )", + assignment_predicate(&placeholders) + ); let mut values = assignment_values(&viewer_ids); values.extend(viewer_ids.iter().cloned().map(Value::from)); let mut statement = tx.prepare(&query).map_err(db_error)?; let rows = statement .query_map(params_from_iter(values), |row| row.get::<_, String>(0)) .map_err(db_error)?; - rows.collect::, _>>().map_err(db_error)? - }; + sources.extend( + rows.collect::, _>>() + .map_err(db_error)? + .into_iter() + .map(|id| (ASSIGNED_SOURCE_KIND, id)), + ); + } + if filter != TeamInboxFilter::Assigned { + let query = format!( + "SELECT w.id || ':' || json_extract(c.value, '$.id') + FROM workitems w + JOIN workitem_extras e ON e.work_item_id = w.id + JOIN json_each(COALESCE(json_extract(e.extras_json, '$.comments'), '[]')) c + WHERE w.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM json_each(COALESCE(json_extract(c.value, '$.mentioned_user_ids'), '[]')) m + WHERE CAST(m.value AS TEXT) IN ({placeholders}) + ) + AND NOT EXISTS ( + SELECT 1 FROM team_inbox_read_receipts r + WHERE r.source_kind = '{COMMENT_MENTION_SOURCE_KIND}' + AND r.source_id = w.id || ':' || json_extract(c.value, '$.id') + AND r.viewer_member_id IN ({placeholders}) + )" + ); + let values = viewer_ids + .iter() + .chain(viewer_ids.iter()) + .cloned() + .map(Value::from) + .collect::>(); + let mut statement = tx.prepare(&query).map_err(db_error)?; + let rows = statement + .query_map(params_from_iter(values), |row| row.get::<_, String>(0)) + .map_err(db_error)?; + sources.extend( + rows.collect::, _>>() + .map_err(db_error)? + .into_iter() + .map(|id| (COMMENT_MENTION_SOURCE_KIND, id)), + ); + } - for source_id in source_ids { + for (source_kind, source_id) in sources { for viewer_id in &viewer_ids { tx.execute( "INSERT INTO team_inbox_read_receipts @@ -353,7 +574,7 @@ pub(crate) fn mark_all_read_with_connection( VALUES (?1, ?2, ?3, ?4) ON CONFLICT(viewer_member_id, source_kind, source_id) DO UPDATE SET read_at = MAX(read_at, excluded.read_at)", - (viewer_id, ASSIGNED_SOURCE_KIND, &source_id, read_at), + (viewer_id, source_kind, &source_id, read_at), ) .map_err(db_error)?; } @@ -369,16 +590,24 @@ pub(crate) fn mark_unread_with_connection( ) -> Result { init_team_inbox_tables(connection).map_err(db_error)?; let viewer_ids = normalized_viewer_ids(viewer_member_ids)?; - let source_id = assigned_source_id(item_id) - .ok_or_else(|| format!("Unsupported Team Inbox item id: {item_id}"))?; + let (source_kind, source_id) = if let Some(source_id) = assigned_source_id(item_id) { + (ASSIGNED_SOURCE_KIND, source_id) + } else if let Some(source_id) = comment_mention_source_id(item_id) { + (COMMENT_MENTION_SOURCE_KIND, source_id) + } else { + return Err(format!("Unsupported Team Inbox item id: {item_id}")); + }; let placeholders = sql_placeholders(viewer_ids.len()); let sql = format!( "DELETE FROM team_inbox_read_receipts - WHERE source_kind = '{ASSIGNED_SOURCE_KIND}' + WHERE source_kind = ? AND source_id = ? AND viewer_member_id IN ({placeholders})" ); - let mut values = vec![Value::from(source_id.to_string())]; + let mut values = vec![ + Value::from(source_kind.to_string()), + Value::from(source_id.to_string()), + ]; values.extend(viewer_ids.iter().cloned().map(Value::from)); let tx = connection .transaction_with_behavior(TransactionBehavior::Immediate) @@ -423,8 +652,7 @@ fn assignment_values(viewer_ids: &[String]) -> Vec { } fn sql_placeholders(count: usize) -> String { - std::iter::repeat("?") - .take(count) + std::iter::repeat_n("?", count) .collect::>() .join(", ") } @@ -440,6 +668,17 @@ fn assigned_source_id(item_id: &str) -> Option<&str> { .filter(|value| !value.is_empty()) } +fn comment_mention_item_id(work_item_id: &str, comment_id: &str) -> String { + format!("{COMMENT_MENTION_SOURCE_KIND}:{work_item_id}:{comment_id}") +} + +fn comment_mention_source_id(item_id: &str) -> Option<&str> { + item_id + .strip_prefix(COMMENT_MENTION_SOURCE_KIND) + .and_then(|value| value.strip_prefix(':')) + .filter(|value| value.split_once(':').is_some()) +} + fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src-tauri/crates/project-management/src/team_inbox/tests.rs b/src-tauri/crates/project-management/src/team_inbox/tests.rs index 08baa16989..10caa03d5f 100644 --- a/src-tauri/crates/project-management/src/team_inbox/tests.rs +++ b/src-tauri/crates/project-management/src/team_inbox/tests.rs @@ -371,8 +371,8 @@ fn read_receipts_and_bulk_read_are_viewer_scoped_and_idempotent() { } #[test] -fn mentions_filter_is_empty_for_local_work_item_source() { - let connection = database(); +fn work_item_comment_mentions_are_viewer_scoped_and_readable() { + let mut connection = database(); insert_work_item( &connection, WorkItemFixture { @@ -387,6 +387,25 @@ fn mentions_filter_is_empty_for_local_work_item_source() { deleted_at: None, }, ); + connection + .execute( + "INSERT INTO workitem_extras (work_item_id, extras_json) + VALUES (?1, ?2)", + ( + "work-a", + json!({ + "comments": [{ + "id": "comment-1", + "author": "member-b", + "content": "Please review this", + "created_at": "2026-07-29T08:00:00Z", + "mentioned_user_ids": ["member-a"] + }] + }) + .to_string(), + ), + ) + .expect("insert comment extras"); let page = list_page_with_connection( &connection, TeamInboxListOptions { @@ -395,8 +414,30 @@ fn mentions_filter_is_empty_for_local_work_item_source() { }, ) .expect("list mentions"); - assert!(page.items.is_empty()); + assert_eq!(page.items.len(), 1); + assert_eq!(page.unread_count, 1); + assert_eq!(page.items[0].kind, TeamInboxItemKind::CommentMention); + assert!(matches!( + page.items[0].target, + TeamInboxTarget::WorkItemComment { ref comment_id, .. } + if comment_id == "comment-1" + )); + + let item_id = page.items[0].id.clone(); + assert!( + mark_read_with_connection(&mut connection, &["member-a".into()], &item_id, 123) + .expect("mark mention read") + ); + let page = list_page_with_connection( + &connection, + TeamInboxListOptions { + filter: TeamInboxFilter::Mentions, + ..options(&["member-a"], 10) + }, + ) + .expect("list read mentions"); assert_eq!(page.unread_count, 0); + assert_eq!(page.items[0].read_at, Some(123)); } #[test] diff --git a/src-tauri/crates/project-management/src/team_inbox/types.rs b/src-tauri/crates/project-management/src/team_inbox/types.rs index 9e0f2c1818..e9fc91cb8b 100644 --- a/src-tauri/crates/project-management/src/team_inbox/types.rs +++ b/src-tauri/crates/project-management/src/team_inbox/types.rs @@ -3,20 +3,15 @@ use serde::{Deserialize, Serialize}; use crate::projects::types::WorkItemHandoff; /// Sources supported by the stable Team Inbox wire contract. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TeamInboxFilter { + #[default] All, Mentions, Assigned, } -impl Default for TeamInboxFilter { - fn default() -> Self { - Self::All - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TeamInboxItemKind { @@ -46,6 +41,16 @@ pub enum TeamInboxTarget { #[serde(skip_serializing_if = "Option::is_none")] anchor: Option, }, + WorkItemComment { + work_item_id: String, + short_id: String, + org_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_slug: Option, + comment_id: String, + }, WorkItem { work_item_id: String, short_id: String, @@ -57,6 +62,10 @@ pub enum TeamInboxTarget { }, } +// This wire DTO is constructed only for bounded result pages and immediately +// serialized. Keeping the fields inline preserves a simple, stable payload +// shape without adding heap indirection to every assigned row. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde( tag = "type", diff --git a/src-tauri/crates/system-services/src/notifications.rs b/src-tauri/crates/system-services/src/notifications.rs index f0df2c65cc..0e27f7bc8d 100644 --- a/src-tauri/crates/system-services/src/notifications.rs +++ b/src-tauri/crates/system-services/src/notifications.rs @@ -84,9 +84,3 @@ pub fn set_dock_badge(count: Option) -> Result<(), String> { Ok(()) } } - -/// Clear the dock badge on macOS -#[tauri::command] -pub fn clear_dock_badge() -> Result<(), String> { - set_dock_badge(None) -} diff --git a/src-tauri/src/agent_sessions/cli/commands/run.rs b/src-tauri/src/agent_sessions/cli/commands/run.rs index 5b51135611..a5d0ca16d5 100644 --- a/src-tauri/src/agent_sessions/cli/commands/run.rs +++ b/src-tauri/src/agent_sessions/cli/commands/run.rs @@ -34,6 +34,26 @@ fn inject_ide_context_into_prompt(user_input: &str, ide_context: Option<&IdeCont ) } +fn failed_status_message( + session_id: &str, + error_message: &str, + turn_intent_id: &str, + notification_context: Option<(bool, &str)>, +) -> serde_json::Value { + let mut message = serde_json::json!({ + "type": "code_session.status_changed", + "session_id": session_id, + "status": "failed", + "error_message": error_message, + "turn_intent_id": turn_intent_id, + }); + if let Some((background, session_name)) = notification_context { + message["background"] = serde_json::Value::Bool(background); + message["session_name"] = serde_json::Value::String(session_name.to_string()); + } + message +} + /// Park a TUI-hosted session when its terminal pane goes away (PTY exit or /// tab close). Non-TUI sessions and already-terminal rows are left alone. #[tauri::command] @@ -184,13 +204,38 @@ async fn cli_agent_run_internal( } integrations::proxy::server::stop_session_proxy(&sid).await; session_runner::release_proxy_token_for_session_pub(&sid).await; - let mut failed_msg = serde_json::json!({ - "type": "code_session.status_changed", - "session_id": sid, - "status": "failed", - "error_message": e, - }); - failed_msg["turn_intent_id"] = serde_json::Value::String(runner_turn_intent_id.clone()); + let notification_sid = sid.clone(); + let notification_context = match tokio::task::spawn_blocking(move || { + persistence::get_session(¬ification_sid) + }) + .await + { + Ok(Ok(session)) => session, + Ok(Err(error)) => { + tracing::warn!( + "[CodeSession] Failed to reload notification context for {}: {}", + sid, + error + ); + None + } + Err(error) => { + tracing::warn!( + "[CodeSession] Notification context task failed for {}: {}", + sid, + error + ); + None + } + }; + let failed_msg = failed_status_message( + &sid, + &e, + &runner_turn_intent_id, + notification_context + .as_ref() + .map(|session| (session.background, session.name.as_str())), + ); crate::api::websocket_handler::broadcast(failed_msg.to_string()); } // Remove finished entry from RUNNING_SESSIONS to prevent unbounded growth @@ -432,3 +477,23 @@ pub async fn cli_agent_approval_response( ) .await } + +#[cfg(test)] +mod tests { + use super::failed_status_message; + + #[test] + fn failed_background_status_keeps_notification_context() { + let message = failed_status_message( + "cli-session-1", + "provider failed", + "intent-1", + Some((true, "Background review")), + ); + + assert_eq!(message["status"], "failed"); + assert_eq!(message["background"], true); + assert_eq!(message["session_name"], "Background review"); + assert_eq!(message["turn_intent_id"], "intent-1"); + } +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index b06774cee9..bdc881402d 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -199,7 +199,6 @@ system_services::notifications::send_notification, system_services::notifications::check_notification_permission, system_services::notifications::request_notification_permission, system_services::notifications::set_dock_badge, -system_services::notifications::clear_dock_badge, // Platform commands - App Menu (File > Open Recent) system_services::app_menu::menu_add_recent, system_services::app_menu::menu_get_recent, @@ -589,7 +588,9 @@ project_management::projects::commands::project_delete_work_item, project_management::projects::commands::project_restore_work_item, project_management::projects::commands::project_purge_expired_deleted_work_items, project_management::projects::commands::project_update_work_item_partial, +project_management::projects::commands::work_item_update_standalone_partial, project_management::projects::commands::project_transition_work_item_handoff, +project_management::projects::commands::work_item_transition_standalone_handoff, project_management::projects::commands::project_move_work_item, project_management::projects::commands::project_allocate_work_item_id, project_management::projects::commands::work_item_allocate_standalone_id, diff --git a/src/api/http/project/client.ts b/src/api/http/project/client.ts index 5d7f740f6d..edd09d3741 100644 --- a/src/api/http/project/client.ts +++ b/src/api/http/project/client.ts @@ -532,6 +532,23 @@ export async function updateWorkItemPartial( return result; } +export async function updateStandaloneWorkItemPartial( + shortId: string, + updates: WorkItemPartialUpdate, + options?: ProjectScopeOptions +): Promise { + const result = await invoke( + "work_item_update_standalone_partial", + { + ...scopeInvokePayload(options), + shortId, + updates, + } + ); + invalidateCache(); + return result; +} + export async function transitionWorkItemHandoff( projectSlug: string, shortId: string, @@ -549,6 +566,23 @@ export async function transitionWorkItemHandoff( return result; } +export async function transitionStandaloneWorkItemHandoff( + shortId: string, + transition: WorkItemHandoffTransition, + options?: ProjectScopeOptions +): Promise { + const result = await invoke( + "work_item_transition_standalone_handoff", + { + ...scopeInvokePayload(options), + shortId, + transition, + } + ); + invalidateCache(); + return result; +} + export async function moveWorkItem( shortId: string, fromProject: string, diff --git a/src/api/http/project/index.ts b/src/api/http/project/index.ts index a9a0aead75..fc39aa19ba 100644 --- a/src/api/http/project/index.ts +++ b/src/api/http/project/index.ts @@ -85,7 +85,10 @@ export const projectApi = { restoreWorkItem: client.restoreWorkItem, purgeExpiredDeletedWorkItems: client.purgeExpiredDeletedWorkItems, updateWorkItemPartial: client.updateWorkItemPartial, + updateStandaloneWorkItemPartial: client.updateStandaloneWorkItemPartial, transitionWorkItemHandoff: client.transitionWorkItemHandoff, + transitionStandaloneWorkItemHandoff: + client.transitionStandaloneWorkItemHandoff, moveWorkItem: client.moveWorkItem, allocateWorkItemId: client.allocateWorkItemId, allocateStandaloneWorkItemId: client.allocateStandaloneWorkItemId, diff --git a/src/api/http/project/types/common.ts b/src/api/http/project/types/common.ts index a6eb8000ea..1ce40b1d0d 100644 --- a/src/api/http/project/types/common.ts +++ b/src/api/http/project/types/common.ts @@ -10,4 +10,6 @@ export interface CommentEntry { author: string; content: string; created_at: string; + /** Canonical member ids explicitly notified by this comment. */ + mentioned_user_ids?: string[]; } diff --git a/src/api/services/notification.test.ts b/src/api/services/notification.test.ts new file mode 100644 index 0000000000..c422ae4cb9 --- /dev/null +++ b/src/api/services/notification.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + checkNotificationPermission, + notifyTeamInbox, + sendSystemNotification, + setDockBadge, +} from "./notification"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + isPermissionGranted: vi.fn(), + requestPermission: vi.fn(), + sendNotification: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, +})); + +vi.mock("@tauri-apps/plugin-notification", () => ({ + isPermissionGranted: mocks.isPermissionGranted, + requestPermission: mocks.requestPermission, + sendNotification: mocks.sendNotification, +})); + +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + }), +})); + +const SETTINGS = { + enabled: true, + systemNotificationEnabled: true, + dockBadgeEnabled: true, + completionSound: false, + soundVolume: 70, + categories: { + taskCompletion: true, + errors: true, + teamInbox: true, + }, +}; + +describe("notification service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("preserves the Rust permission tri-state", async () => { + mocks.invoke.mockResolvedValueOnce("unknown"); + + await expect(checkNotificationPermission()).resolves.toBe("unknown"); + expect(mocks.invoke).toHaveBeenCalledWith("check_notification_permission"); + expect(mocks.isPermissionGranted).not.toHaveBeenCalled(); + }); + + it("does not mislabel a boolean fallback as denied", async () => { + mocks.invoke.mockRejectedValueOnce(new Error("IPC unavailable")); + mocks.isPermissionGranted.mockResolvedValueOnce(false); + + await expect(checkNotificationPermission()).resolves.toBe("unknown"); + }); + + it("gates Team Inbox delivery on the master and category settings", async () => { + await notifyTeamInbox("New assignment", "Review it", { + ...SETTINGS, + enabled: false, + }); + await notifyTeamInbox("New assignment", "Review it", { + ...SETTINGS, + categories: { ...SETTINGS.categories, teamInbox: false }, + }); + + expect(mocks.sendNotification).not.toHaveBeenCalled(); + }); + + it("falls back to the Rust send boundary exactly once", async () => { + mocks.sendNotification.mockRejectedValueOnce(new Error("plugin failed")); + mocks.invoke.mockResolvedValueOnce(undefined); + + await expect(sendSystemNotification("Title", "Body")).resolves.toBeTruthy(); + expect(mocks.invoke).toHaveBeenCalledWith("send_notification", { + title: "Title", + body: "Body", + }); + }); + + it("projects positive and cleared dock badge values", async () => { + mocks.invoke.mockResolvedValue(undefined); + + await setDockBadge(7.9); + await setDockBadge(0); + + expect(mocks.invoke).toHaveBeenNthCalledWith(1, "set_dock_badge", { + count: 7, + }); + expect(mocks.invoke).toHaveBeenNthCalledWith(2, "set_dock_badge", { + count: null, + }); + }); +}); diff --git a/src/api/services/notification.ts b/src/api/services/notification.ts index f1e31e99d2..200f744092 100644 --- a/src/api/services/notification.ts +++ b/src/api/services/notification.ts @@ -6,36 +6,30 @@ import { } from "@tauri-apps/plugin-notification"; import { createLogger } from "@src/hooks/logger"; -import { NotificationSettings } from "@src/store/ui/notificationAtom"; +import type { NotificationSettings } from "@src/store/ui/notificationAtom"; const log = createLogger("Notification"); -// Audio element for completion sounds -let audioElement: HTMLAudioElement | null = null; let audioContext: AudioContext | null = null; -// Initialize audio element -const getAudioElement = (): HTMLAudioElement => { - if (!audioElement) { - audioElement = new Audio("/sounds/completion.mp3"); - // Add error handler to fall back to generated sound - audioElement.addEventListener("error", () => { - log.warn("Sound file not found, using generated sound"); - }); - } - return audioElement; -}; +export type NotificationPermissionStatus = "granted" | "denied" | "unknown"; -// Generate a simple notification beep using Web Audio API as fallback -const playGeneratedSound = (volume: number): void => { +export interface NotificationDeliveryResult { + systemSent: boolean; + soundPlayed: boolean; +} + +const playGeneratedSound = async (volume: number): Promise => { try { if (!audioContext) { - audioContext = new ( + const AudioContextConstructor = window.AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }) - .webkitAudioContext - )(); + (window as unknown as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!AudioContextConstructor) return false; + audioContext = new AudioContextConstructor(); } + if (audioContext.state === "suspended") await audioContext.resume(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); @@ -57,10 +51,20 @@ const playGeneratedSound = (volume: number): void => { audioContext.currentTime + 0.3 ); + oscillator.addEventListener( + "ended", + () => { + oscillator.disconnect(); + gainNode.disconnect(); + }, + { once: true } + ); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.3); + return true; } catch (error) { log.error("Failed to play generated sound:", error); + return false; } }; @@ -77,48 +81,60 @@ export interface NotificationOptions { /** * Check notification permission status */ -export const checkNotificationPermission = async (): Promise => { - try { - const granted = await isPermissionGranted(); - return granted ? "granted" : "denied"; - } catch (error) { - log.error( - "[Notification] Permission check failed, trying Rust command:", - error - ); +export const checkNotificationPermission = + async (): Promise => { + // The Rust boundary exposes the full granted / denied / not-yet-requested + // state. The JS helper only returns a boolean and would collapse + // "unknown" into "denied". try { - return await invoke("check_notification_permission"); + return await invoke( + "check_notification_permission" + ); } catch (invokeError) { - log.error("[Notification] Rust command also failed:", invokeError); + log.warn( + "[Notification] Rust permission check failed, using boolean fallback:", + invokeError + ); + } + + try { + return (await isPermissionGranted()) ? "granted" : "unknown"; + } catch (error) { + log.error("[Notification] Permission check failed:", error); return "unknown"; } - } -}; + }; /** * Request notification permission */ -export const requestNotificationPermission = async (): Promise => { - try { - const permission = await requestPermission(); - return permission === "granted" - ? "granted" - : permission === "denied" - ? "denied" - : "unknown"; - } catch (error) { - log.error( - "[Notification] Permission request failed, trying Rust command:", - error - ); +export const requestNotificationPermission = + async (): Promise => { try { - return await invoke("request_notification_permission"); - } catch (invokeError) { - log.error("[Notification] Rust command also failed:", invokeError); - return "denied"; + const permission = await requestPermission(); + return permission === "granted" + ? "granted" + : permission === "denied" + ? "denied" + : "unknown"; + } catch (error) { + log.warn( + "[Notification] Permission request failed, trying Rust command:", + error + ); + try { + return await invoke( + "request_notification_permission" + ); + } catch (invokeError) { + log.error( + "[Notification] Rust permission request failed:", + invokeError + ); + return "unknown"; + } } - } -}; + }; /** * Send a system notification @@ -131,68 +147,67 @@ export const sendSystemNotification = async ( await sendNotification({ title, body }); return true; } catch (error) { - log.error("[Notification] Send failed, trying Rust command:", error); + log.warn("[Notification] Send failed, trying Rust command:", error); try { await invoke("send_notification", { title, body }); return true; } catch (invokeError) { - log.error("[Notification] Rust command also failed:", invokeError); + log.error("[Notification] Rust notification send failed:", invokeError); return false; } } }; /** - * Play completion sound + * Project the authoritative Team Inbox unread count into the dock badge. */ -export const playCompletionSound = (volume: number = 70): void => { +export const setDockBadge = async (count: number): Promise => { try { - const audio = getAudioElement(); - audio.volume = Math.max(0, Math.min(1, volume / 100)); - audio.currentTime = 0; - - const playPromise = audio.play(); - - if (playPromise !== undefined) { - playPromise.catch(() => { - // If the audio file fails to play (not found or error), use generated sound - playGeneratedSound(volume); - }); - } - } catch { - // Fallback to generated sound - playGeneratedSound(volume); + await invoke("set_dock_badge", { + count: Number.isFinite(count) && count > 0 ? Math.floor(count) : null, + }); + return true; + } catch (error) { + log.error("[Notification] Failed to update dock badge:", error); + return false; } }; +/** + * Play the generated notification tone. + */ +export const playCompletionSound = async ( + volume: number = 70 +): Promise => { + return playGeneratedSound(Math.max(0, Math.min(100, volume))); +}; + /** * Send a notification based on settings */ export const notify = async ( options: NotificationOptions, settings: NotificationSettings -): Promise => { +): Promise => { if (!settings.enabled) { - return false; + return { systemSent: false, soundPlayed: false }; } if (options.category && !settings.categories[options.category]) { - return false; + return { systemSent: false, soundPlayed: false }; } - let notificationSent = false; + let systemSent = false; if (settings.systemNotificationEnabled) { - notificationSent = await sendSystemNotification( - options.title, - options.body - ); + systemSent = await sendSystemNotification(options.title, options.body); } + let soundPlayed = false; if (options.playSound !== false && settings.completionSound) { - playCompletionSound(settings.soundVolume); + soundPlayed = await playCompletionSound(settings.soundVolume); } - return notificationSent; + return { systemSent, soundPlayed }; }; /** @@ -200,11 +215,12 @@ export const notify = async ( */ export const notifyTaskCompletion = async ( taskName: string, - settings: NotificationSettings -): Promise => { + settings: NotificationSettings, + title = "Task Completed" +): Promise => { return notify( { - title: "Task Completed", + title, body: taskName, category: "taskCompletion", playSound: true, @@ -213,34 +229,17 @@ export const notifyTaskCompletion = async ( ); }; -/** - * Notify agent approval needed - */ -export const notifyAgentApproval = async ( - actionName: string, - settings: NotificationSettings -): Promise => { - return notify( - { - title: "Action Requires Approval", - body: actionName, - category: "agentApproval", - playSound: true, - }, - settings - ); -}; - /** * Notify error */ export const notifyError = async ( errorMessage: string, - settings: NotificationSettings -): Promise => { + settings: NotificationSettings, + title = "Error" +): Promise => { return notify( { - title: "Error", + title, body: errorMessage, category: "errors", playSound: false, @@ -250,64 +249,29 @@ export const notifyError = async ( }; /** - * Notify session status change + * Notify a new Team Inbox assignment, mention, or handoff. */ -export const notifySessionStatus = async ( - status: string, - settings: NotificationSettings -): Promise => { - return notify( - { - title: "Session Status", - body: status, - category: "sessionStatus", - playSound: false, - }, - settings - ); -}; - -/** - * Notify git operation - */ -export const notifyGitOperation = async ( - operation: string, +export const notifyTeamInbox = async ( + title: string, + body: string, settings: NotificationSettings -): Promise => { +): Promise => { return notify( { - title: "Git Operation", - body: operation, - category: "gitOperations", - playSound: false, + title, + body, + category: "teamInbox", + playSound: true, }, settings ); }; /** - * Test notification - sends a test notification and plays sound + * Test the native notification channel without changing persisted settings. */ -export const sendTestNotification = async ( - settings: NotificationSettings -): Promise => { - const tempSettings = { - ...settings, - enabled: true, - systemNotificationEnabled: true, - categories: { - ...settings.categories, - taskCompletion: true, - }, - }; - - return notify( - { - title: "Test Notification", - body: "This is a test notification from ORGII", - category: "taskCompletion", - playSound: true, - }, - tempSettings +export const sendTestNotification = async (): Promise => + sendSystemNotification( + "Test Notification", + "This is a test notification from ORGII" ); -}; diff --git a/src/config/settingsSchema/registry/notifications.ts b/src/config/settingsSchema/registry/notifications.ts index 14cb403960..89c4618a0b 100644 --- a/src/config/settingsSchema/registry/notifications.ts +++ b/src/config/settingsSchema/registry/notifications.ts @@ -39,28 +39,17 @@ export const NOTIFICATIONS_SETTINGS_REGISTRY = { description: "Show notifications for task/session completion", category: "notifications", }, - "notifications.categories.agentApproval": { - schema: z.boolean(), - default: true, - description: "Show notifications when an agent action requires approval", - category: "notifications", - }, "notifications.categories.errors": { schema: z.boolean(), default: true, description: "Show notifications for errors and warnings", category: "notifications", }, - "notifications.categories.sessionStatus": { - schema: z.boolean(), - default: false, - description: "Show notifications for session status updates", - category: "notifications", - }, - "notifications.categories.gitOperations": { + "notifications.categories.teamInbox": { schema: z.boolean(), - default: false, - description: "Show notifications for git operations (push, pull, merge)", + default: true, + description: + "Show notifications for Team Inbox assignments, mentions, and handoffs", category: "notifications", }, } as const satisfies Record; diff --git a/src/features/Org2Cloud/org2CloudRealtimeSignalCoalescer.test.ts b/src/features/Org2Cloud/org2CloudRealtimeSignalCoalescer.test.ts new file mode 100644 index 0000000000..42237de8d1 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudRealtimeSignalCoalescer.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + Org2CloudRealtimeSignalCoalescer, + REALTIME_SIGNAL_COALESCE_MS, +} from "./org2CloudRealtimeSignalCoalescer"; + +describe("Org2CloudRealtimeSignalCoalescer", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("runs the first server invalidation immediately", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const refresh = vi.fn(); + const scheduler = new Org2CloudRealtimeSignalCoalescer(); + + scheduler.schedule("workItems", refresh); + + expect(refresh).toHaveBeenCalledTimes(1); + scheduler.reset(); + }); + + it("delivers a post-subscribe Work Item invalidation within the live window", () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const refresh = vi.fn(); + const scheduler = new Org2CloudRealtimeSignalCoalescer(); + scheduler.markHandled(["workItems"]); + + scheduler.schedule("workItems", refresh); + vi.advanceTimersByTime(REALTIME_SIGNAL_COALESCE_MS - 1); + expect(refresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(refresh).toHaveBeenCalledTimes(1); + + scheduler.reset(); + }); + + it("shares one trailing timer across a burst and disposes it on reset", () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const firstRefresh = vi.fn(); + const duplicateRefresh = vi.fn(); + const scheduler = new Org2CloudRealtimeSignalCoalescer(); + scheduler.markHandled(["workItems"]); + + scheduler.schedule("workItems", firstRefresh); + scheduler.schedule("workItems", duplicateRefresh); + expect(vi.getTimerCount()).toBe(1); + + scheduler.reset(); + vi.runAllTimers(); + expect(firstRefresh).not.toHaveBeenCalled(); + expect(duplicateRefresh).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudRealtimeSignalCoalescer.ts b/src/features/Org2Cloud/org2CloudRealtimeSignalCoalescer.ts new file mode 100644 index 0000000000..3cfa1a71b7 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudRealtimeSignalCoalescer.ts @@ -0,0 +1,76 @@ +/** + * A short leading/trailing window for server-pushed invalidations. + * + * The backend already collapses each org's durable change signal. This + * client-side window only absorbs the remaining transaction burst; it must + * stay below a human-visible notification delay. + */ +export const REALTIME_SIGNAL_COALESCE_MS = 750; + +interface TimerHost { + now(): number; + setTimeout( + callback: () => void, + delayMs: number + ): ReturnType; + clearTimeout(timer: ReturnType): void; +} + +const defaultTimerHost: TimerHost = { + now: () => Date.now(), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timer) => clearTimeout(timer), +}; + +/** + * Coalesces bursty push invalidations per fixed plane. There is no recurring + * timer: a plane is idle until a server signal schedules work. + */ +export class Org2CloudRealtimeSignalCoalescer { + private readonly handledAt = new Map(); + private readonly trailingTimers = new Map< + Plane, + ReturnType + >(); + + constructor( + private readonly windowMs = REALTIME_SIGNAL_COALESCE_MS, + private readonly timers: TimerHost = defaultTimerHost + ) {} + + markHandled(planes: Iterable): void { + const now = this.timers.now(); + for (const plane of planes) this.handledAt.set(plane, now); + } + + schedule(plane: Plane, refresh: () => void): void { + const now = this.timers.now(); + const lastHandledAt = this.handledAt.get(plane); + const elapsed = + lastHandledAt === undefined ? this.windowMs : now - lastHandledAt; + const run = () => { + this.handledAt.set(plane, this.timers.now()); + refresh(); + }; + if (elapsed >= this.windowMs) { + run(); + return; + } + if (this.trailingTimers.has(plane)) return; + this.trailingTimers.set( + plane, + this.timers.setTimeout(() => { + this.trailingTimers.delete(plane); + run(); + }, this.windowMs - elapsed) + ); + } + + reset(): void { + for (const timer of this.trailingTimers.values()) { + this.timers.clearTimeout(timer); + } + this.trailingTimers.clear(); + this.handledAt.clear(); + } +} diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.ts index aba2985f77..6bbdfa8d49 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.ts @@ -105,6 +105,7 @@ import { import { useOrg2CloudRealtimeLease } from "./org2CloudRealtimeLease"; import { decideSubscribedEdgeRecovery } from "./org2CloudRealtimeRecovery"; import { resolveActiveRealtimeOrgId } from "./org2CloudRealtimeScope"; +import { Org2CloudRealtimeSignalCoalescer } from "./org2CloudRealtimeSignalCoalescer"; import { bumpRemoteSessionsInvalidation, org2CloudRemoteSessionsAtom, @@ -129,12 +130,10 @@ const CHANGE_SIGNALS_TABLE = "org_change_signals"; /** * The backend's durable signal is intentionally coarse. Plane-specific * Presence broadcasts provide the live path; the durable coarse row is a - * secondary event source. These windows throttle event storms—they do not - * schedule polling when no signal arrives. On per-kind backends every plane - * keeps its own 60s window (one `SignalPlane` stamp/timer each) instead of - * sharing a single coarse stamp. + * secondary event source. A short leading/trailing window coalesces + * transaction bursts without delaying a teammate notification. It does not + * schedule polling when no signal arrives. */ -const COARSE_SIGNAL_THROTTLE_MS = 60_000; const CONTROL_PLANE_REFRESH_THROTTLE_MS = 5 * 60_000; /** @@ -271,9 +270,8 @@ export function useOrg2CloudRealtime(): void { // Stable refs so the per-org effect and status callbacks read current values // without forcing the connection to rebuild. const refetchRef = useRef(refetchOrgs); - const planeSignalHandledAtRef = useRef(new Map()); - const planeSignalTrailingTimersRef = useRef( - new Map>() + const signalCoalescerRef = useRef( + new Org2CloudRealtimeSignalCoalescer() ); const controlPlaneRefreshAtRef = useRef(0); const coarseSafetyNetTimerRef = useRef | null>( @@ -422,17 +420,17 @@ export function useOrg2CloudRealtime(): void { ); // Shared by the Slice B postgres_changes path (legacy backends) and the - // Slice C safety net (per-kind backends): identical throttle windows and + // Slice C safety net (per-kind backends): identical short coalescing and // refresh behavior regardless of transport. const runCoarseSignalRefresh = useCallback(() => { const orgId = activeRealtimeOrgId; if (!orgId) return; - const now = Date.now(); - const handledAt = planeSignalHandledAtRef.current; - handledAt.set("coarse", now); - handledAt.set("sessions", now); - handledAt.set("comments", now); - handledAt.set("inbound", now); + signalCoalescerRef.current.markHandled([ + "coarse", + "sessions", + "comments", + "inbound", + ]); // A blur/visibility event releases the connection. Ignore the tiny // event-delivery race during teardown; the next SUBSCRIBED true-edge // performs the authoritative full recovery. @@ -447,31 +445,15 @@ export function useOrg2CloudRealtime(): void { bumpOrgCommentsSignal, maybeRefreshControlPlane, ]); - // Per-plane trailing-edge throttler (the generalized coarse scheduler): - // leading run when the plane's window is clear, otherwise one trailing - // timer at the window's end. + // Per-plane leading/trailing coalescer. A successful subscribe edge marks + // the initial recovery as handled, so the next real server signal waits at + // most the short live window rather than the old 60-second throttle. const schedulePlaneSignalRefresh = useCallback( (plane: SignalPlane, refresh: () => void) => { - const now = Date.now(); - const elapsed = now - (planeSignalHandledAtRef.current.get(plane) ?? 0); - const run = () => { - planeSignalHandledAtRef.current.set(plane, Date.now()); + signalCoalescerRef.current.schedule(plane, () => { if (isDocumentHidden()) return; refresh(); - }; - if (elapsed >= COARSE_SIGNAL_THROTTLE_MS) { - run(); - return; - } - const timers = planeSignalTrailingTimersRef.current; - if (timers.has(plane)) return; - timers.set( - plane, - setTimeout(() => { - timers.delete(plane); - run(); - }, COARSE_SIGNAL_THROTTLE_MS - elapsed) - ); + }); }, [] ); @@ -540,9 +522,7 @@ export function useOrg2CloudRealtime(): void { const runSignalEdgeRecovery = useCallback( (orgId: string) => { const now = Date.now(); - for (const plane of ALL_SIGNAL_PLANES) { - planeSignalHandledAtRef.current.set(plane, now); - } + signalCoalescerRef.current.markHandled(ALL_SIGNAL_PLANES); controlPlaneRefreshAtRef.current = now; if (isDocumentHidden()) return; // A LONG gap forces complete listings so tombstone-free absences @@ -596,9 +576,9 @@ export function useOrg2CloudRealtime(): void { if (!connection || !userId || !activeRealtimeOrgId) return undefined; const unsubscribes: Array<() => void> = []; - const orgId = activeRealtimeOrgId; const orgTeardownAt = orgTeardownAtRef.current; - const planeSignalTrailingTimers = planeSignalTrailingTimersRef.current; + const signalCoalescer = signalCoalescerRef.current; + const orgId = activeRealtimeOrgId; if (!broadcastSignals) { unsubscribes.push( connection.subscribe({ @@ -651,10 +631,7 @@ export function useOrg2CloudRealtime(): void { delete next[orgId]; return next; }); - for (const timer of planeSignalTrailingTimers.values()) { - clearTimeout(timer); - } - planeSignalTrailingTimers.clear(); + signalCoalescer.reset(); if (coarseSafetyNetTimerRef.current) { clearTimeout(coarseSafetyNetTimerRef.current); coarseSafetyNetTimerRef.current = null; diff --git a/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts b/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts index 58dfd488ea..09914ae790 100644 --- a/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts +++ b/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts @@ -174,4 +174,22 @@ describe("CliTurnLifecycleCoordinator", () => { expect(loadBatch).not.toHaveBeenCalled(); vi.stubGlobal("document", originalDocument); }); + + it("returns only newly-applied statuses so reconnect consumers can recover side effects", async () => { + const terminal = { + sessionId: "cliagent-recovered", + status: "completed", + turnIntentId: "intent-recovered", + }; + const coordinator = new CliTurnLifecycleCoordinator( + vi.fn(async () => [terminal, terminal]) + ); + coordinator.handleStatus({ + sessionId: terminal.sessionId, + status: "running", + turnIntentId: terminal.turnIntentId, + }); + + await expect(coordinator.reconcile()).resolves.toEqual([terminal]); + }); }); diff --git a/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts index 6616b72387..6d3621ca66 100644 --- a/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts +++ b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts @@ -59,7 +59,7 @@ type BatchLoader = (input: { export class CliTurnLifecycleCoordinator { private readonly activeBySession = new Map(); private readonly recentTerminalIntents = new Set(); - private reconcilePromise: Promise | null = null; + private reconcilePromise: Promise | null = null; constructor(private readonly loadStatusBatch: BatchLoader) {} @@ -138,20 +138,20 @@ export class CliTurnLifecycleCoordinator { return true; } - reconcile(): Promise { + reconcile(): Promise { if ( typeof document !== "undefined" && document.visibilityState === "hidden" ) { - return Promise.resolve(); + return Promise.resolve([]); } if (this.reconcilePromise) return this.reconcilePromise; const sessionIds = this.collectReconcileSessionIds(); - if (sessionIds.length === 0) return Promise.resolve(); + if (sessionIds.length === 0) return Promise.resolve([]); this.reconcilePromise = this.loadStatusBatch({ sessionIds }) .then((statuses) => { - for (const status of statuses) this.handleStatus(status); + return statuses.filter((status) => this.handleStatus(status)); }) .finally(() => { this.reconcilePromise = null; diff --git a/src/hooks/cliSession/useBackgroundSessionMonitor.ts b/src/hooks/cliSession/useBackgroundSessionMonitor.ts index abb1b8a463..de2ee7fbd6 100644 --- a/src/hooks/cliSession/useBackgroundSessionMonitor.ts +++ b/src/hooks/cliSession/useBackgroundSessionMonitor.ts @@ -11,17 +11,23 @@ * Active adapters remain responsible for transcript/UI mirroring only; turn * finality for active and background sessions is owned here. */ +import type { TFunction } from "i18next"; import { useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { getCodeEditorWebSocket } from "@src/api/realtime/codeEditorWebSocket"; +import { deliverBackgroundSessionTerminalNotification } from "@src/hooks/session/backgroundSessionNotifications"; +import { sessionByIdAtom } from "@src/store/session"; import { - notifyError, - notifyTaskCompletion, -} from "@src/api/services/notification"; -import Message from "@src/components/Message"; -import { notificationSettingsAtom } from "@src/store/ui/notificationAtom"; + type NotificationSettings, + notificationSettingsAtom, +} from "@src/store/ui/notificationAtom"; import { isTerminalStatus } from "@src/types/session/session"; +import { + getInstrumentedStore, + isStoreInitialized, +} from "@src/util/core/state/instrumentedStore"; import { cliTurnLifecycleCoordinator } from "./cliTurnLifecycleCoordinator"; @@ -37,12 +43,17 @@ interface BackgroundStatusMessage { } export function useBackgroundSessionMonitor(): void { + const { t } = useTranslation(); const notificationSettings = useAtomValue(notificationSettingsAtom); const settingsRef = useRef(notificationSettings); useEffect(() => { settingsRef.current = notificationSettings; }, [notificationSettings]); + const translationRef = useRef(t); + useEffect(() => { + translationRef.current = t; + }, [t]); useEffect(() => { const wsClient = getCodeEditorWebSocket(); @@ -56,48 +67,31 @@ export function useBackgroundSessionMonitor(): void { turnIntentId: msg.turn_intent_id, }); - if (!msg.background) return; if (!isTerminalStatus(msg.status)) return; if (!applied) return; - - const sessionName = msg.session_name || "Background session"; - - if (msg.status === "completed") { - notifyTaskCompletion( - `"${sessionName}" completed — ready for review`, - settingsRef.current - ); - - Message.success({ - content: `"${sessionName}" completed. Click to review diff.`, - duration: 0, - closable: true, - }); - } else if (msg.status === "failed") { - const errorDetail = msg.error_message - ? `: ${msg.error_message.slice(0, 120)}` - : ""; - - notifyError( - `"${sessionName}" failed${errorDetail}`, - settingsRef.current - ); - - Message.error({ - content: `"${sessionName}" failed${errorDetail}`, - duration: 8000, - closable: true, - }); - } else if (msg.status === "cancelled") { - Message.warning({ - content: `"${sessionName}" was cancelled`, - duration: 5000, - }); - } + deliverBackgroundTerminal( + msg, + settingsRef.current, + translationRef.current + ); }); const reconcile = () => { - void cliTurnLifecycleCoordinator.reconcile(); + void cliTurnLifecycleCoordinator.reconcile().then((appliedStatuses) => { + for (const status of appliedStatuses) { + if (!isTerminalStatus(status.status)) continue; + deliverBackgroundTerminal( + { + type: "code_session.status_changed", + session_id: status.sessionId, + status: status.status, + turn_intent_id: status.turnIntentId, + }, + settingsRef.current, + translationRef.current + ); + } + }); }; const unsubscribeConnected = wsClient.on("connected", reconcile); const handleVisibilityChange = () => { @@ -114,3 +108,28 @@ export function useBackgroundSessionMonitor(): void { }; }, []); } + +function deliverBackgroundTerminal( + msg: BackgroundStatusMessage, + settings: NotificationSettings, + t: TFunction +): void { + const session = isStoreInitialized() + ? getInstrumentedStore().get(sessionByIdAtom(msg.session_id)) + : undefined; + const background = msg.background ?? session?.background ?? false; + if (!background) return; + + const sessionName = + msg.session_name || session?.name || t("notifications.backgroundSession"); + + deliverBackgroundSessionTerminalNotification( + { + status: msg.status, + sessionName, + errorMessage: msg.error_message ?? session?.error_message, + }, + settings, + t + ); +} diff --git a/src/hooks/session/backgroundSessionNotifications.test.ts b/src/hooks/session/backgroundSessionNotifications.test.ts new file mode 100644 index 0000000000..a259311e6b --- /dev/null +++ b/src/hooks/session/backgroundSessionNotifications.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { shouldDeliverBackgroundSessionTerminalNotification } from "./backgroundSessionNotifications"; + +describe("shouldDeliverBackgroundSessionTerminalNotification", () => { + it("delivers only a new terminal transition for a background session", () => { + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "running", + "completed", + true + ) + ).toBe(true); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "completed", + "completed", + true + ) + ).toBe(false); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "failed", + "completed", + true + ) + ).toBe(false); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "running", + "completed", + false + ) + ).toBe(false); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "running", + "working", + true + ) + ).toBe(false); + }); +}); diff --git a/src/hooks/session/backgroundSessionNotifications.ts b/src/hooks/session/backgroundSessionNotifications.ts new file mode 100644 index 0000000000..35554ffbf1 --- /dev/null +++ b/src/hooks/session/backgroundSessionNotifications.ts @@ -0,0 +1,78 @@ +import type { TFunction } from "i18next"; + +import { + notifyError, + notifyTaskCompletion, +} from "@src/api/services/notification"; +import Message from "@src/components/Message"; +import type { NotificationSettings } from "@src/store/ui/notificationAtom"; +import { isTerminalStatus } from "@src/types/session/session"; + +export interface BackgroundSessionTerminalNotification { + status: string; + sessionName: string; + errorMessage?: string; +} + +export function shouldDeliverBackgroundSessionTerminalNotification( + previousStatus: string | undefined, + nextStatus: string, + background: boolean +): boolean { + return ( + background && + isTerminalStatus(nextStatus) && + (previousStatus === undefined || !isTerminalStatus(previousStatus)) + ); +} + +export function deliverBackgroundSessionTerminalNotification( + event: BackgroundSessionTerminalNotification, + settings: NotificationSettings, + t: TFunction +): void { + if (event.status === "completed") { + const body = t("notifications.taskCompletedBody", { + name: event.sessionName, + }); + void notifyTaskCompletion( + body, + settings, + t("notifications.taskCompletedTitle") + ); + Message.success({ + content: t("notifications.taskCompletedToast", { + name: event.sessionName, + }), + duration: 0, + closable: true, + }); + return; + } + + if (event.status === "failed") { + const detail = event.errorMessage + ? `: ${event.errorMessage.slice(0, 120)}` + : ""; + const body = t("notifications.taskFailedBody", { + name: event.sessionName, + detail, + }); + void notifyError(body, settings, t("notifications.taskFailedTitle")); + Message.error({ + content: body, + duration: 8000, + closable: true, + }); + return; + } + + if (event.status === "cancelled") { + Message.warning({ + content: t("notifications.taskCancelledToast", { + name: event.sessionName, + }), + duration: 5000, + }); + } +} diff --git a/src/hooks/session/useNativeSessionStatusMonitor.ts b/src/hooks/session/useNativeSessionStatusMonitor.ts index 2d0d6d1c1a..f3e0559e1d 100644 --- a/src/hooks/session/useNativeSessionStatusMonitor.ts +++ b/src/hooks/session/useNativeSessionStatusMonitor.ts @@ -16,20 +16,35 @@ * backend-initiated switches reach `sessionsAtom` without relying on the * initiating window's optimistic update. * - * This intentionally does NOT trigger toasts or notifications: those are - * owned by `useBackgroundSessionMonitor` (CLI sessions) and individual - * session panels. This hook is the minimal "keep the store in sync" layer. + * This also owns terminal notifications for native background sessions. + * Delivery is transition-based so repeated native events and hydrated + * historical terminal state cannot replay notifications. */ import { listen } from "@tauri-apps/api/event"; -import { useEffect } from "react"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { markTurnRunning, markTurnTerminal, toTurnTerminalStatus, } from "@src/engines/SessionCore/control/turnLifecycle"; -import { type SessionStatus, updateSessionStatus } from "@src/store/session"; +import { + deliverBackgroundSessionTerminalNotification, + shouldDeliverBackgroundSessionTerminalNotification, +} from "@src/hooks/session/backgroundSessionNotifications"; +import { + type SessionStatus, + sessionByIdAtom, + updateSessionStatus, +} from "@src/store/session"; +import { notificationSettingsAtom } from "@src/store/ui/notificationAtom"; import { isTerminalStatus } from "@src/types/session/session"; +import { + getInstrumentedStore, + isStoreInitialized, +} from "@src/util/core/state/instrumentedStore"; import { isSessionRuntimeExecuting } from "@src/util/session/sessionRuntimeExecuting"; interface SessionStatusChangedPayload { @@ -50,13 +65,48 @@ interface SessionRenamedPayload { } export function useNativeSessionStatusMonitor(): void { + const { t } = useTranslation(); + const notificationSettings = useAtomValue(notificationSettingsAtom); + const settingsRef = useRef(notificationSettings); + const translationRef = useRef(t); + + useEffect(() => { + settingsRef.current = notificationSettings; + }, [notificationSettings]); + useEffect(() => { + translationRef.current = t; + }, [t]); + useEffect(() => { const unlistenPromise = listen( "session-status-changed", (event) => { const { sessionId, status } = event.payload; + const session = isStoreInitialized() + ? getInstrumentedStore().get(sessionByIdAtom(sessionId)) + : undefined; if (isTerminalStatus(status)) { markTurnTerminal(sessionId, toTurnTerminalStatus(status)); + if ( + session && + shouldDeliverBackgroundSessionTerminalNotification( + session.status, + status, + session.background === true + ) + ) { + deliverBackgroundSessionTerminalNotification( + { + status, + sessionName: + session.name || + translationRef.current("notifications.backgroundSession"), + errorMessage: session.error_message, + }, + settingsRef.current, + translationRef.current + ); + } } else if (isSessionRuntimeExecuting(status)) { markTurnRunning(sessionId); } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d5f7c7c21a..b37400c50e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -2464,6 +2464,15 @@ "noRepo": "No repo" } }, + "notifications": { + "backgroundSession": "Background Session", + "taskCompletedTitle": "Task completed", + "taskCompletedBody": "“{{name}}” completed — ready for review", + "taskCompletedToast": "“{{name}}” completed. Open the Session to review the result.", + "taskFailedTitle": "Task failed", + "taskFailedBody": "“{{name}}” failed{{detail}}", + "taskCancelledToast": "“{{name}}” was cancelled" + }, "teamInbox": { "title": "Team Inbox", "listLabel": "Team Inbox list", @@ -2480,6 +2489,13 @@ "read": "Read", "unread": "Unread" }, + "notifications": { + "assignmentTitle": "New Work Item assigned to you", + "mentionTitle": "{{name}} mentioned you", + "handoffTitle": "Handoff from {{name}}", + "multipleTitle": "{{count}} new Team Inbox updates", + "multipleBody": "Open Team Inbox to review assignments and mentions." + }, "row": { "assignedSummary": "{{status}} · {{priority}}", "ariaLabel": "{{title}}, {{status}}" @@ -2517,7 +2533,7 @@ "title": "Drop to create a Work Item", "subtitle": "Review the Session snapshot, then create or hand off the Work Item.", "processing": "Creating a Work Item from “{{title}}”…", - "processingHint": "Reading the Session and resolving project members.", + "processingHint": "Reading the Session and resolving destination members.", "success": "Work Item created", "reused": "Existing Work Item updated", "failed": "Couldn’t create the Work Item", @@ -2528,25 +2544,37 @@ "handoff": { "title": "Create from Session", "createFromSession": "Create team Work Item…", - "project": "Destination project", - "chooseProject": "Choose a project", + "destination": "Destination", + "chooseDestination": "Choose a destination", + "cloudDestination": "{{name}} · Team Inbox", "recipientSelf": "{{name}} (me)", "chooseRecipient": "Choose a recipient", "todoCount_one": "{{count}} to-do", "todoCount_other": "{{count}} to-dos", "workItemTitle": "Work Item title", "assignTo": "Assign to", + "status": "Status", + "priority": "Priority", + "dueDate": "Due date", + "noDueDate": "No due date", "note": "Handoff note", "notePlaceholder": "Share what is ready, what is unresolved, and what should happen next.", "selfHint": "Assigning this to yourself creates a normal Work Item without a handoff request.", "submitHandoff": "Create & hand off", "submitCreate": "Create Work Item", "preparing": "Preparing “{{title}}”…", + "validation": { + "title_required": "Enter a Work Item title.", + "project_required": "Choose a destination.", + "project_unavailable": "This destination is no longer available. Choose another destination.", + "recipient_required": "Choose a recipient.", + "recipient_unavailable": "This recipient is no longer available. Choose another recipient." + }, "preparationError": { "session_unavailable": "This Session is no longer available. Reopen it and try again.", "project_unavailable": "This Session’s project is no longer available.", - "identity_unavailable": "Your identity is not a member of this Session’s project.", - "no_project": "No eligible project is available. Create or join a project, then try again.", + "identity_unavailable": "Your signed-in identity is not an active member of this destination.", + "no_project": "No eligible destination is available. Join a team or project, then try again.", "unknown": "Unable to prepare this Session. Refresh Team Inbox and try again." }, "submitError": "The Work Item could not be created. Review the recipient and try again.", diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index ab018437a8..691bf50590 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -374,6 +374,7 @@ "subscribe": "Subscribe", "unsubscribe": "Unsubscribe", "commentPlaceholder": "Leave a comment...", + "mentionPeople": "Mention people", "you": "You", "createdWorkItem": "created the work item", "deletedWorkItem": "deleted the work item", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index b327815825..3df72693a0 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -707,7 +707,9 @@ "sent": "Test notification sent", "permissionWarning": "Failed to send test notification. Check permissions.", "sendFailed": "Unable to send test notification. Please check your notification permissions and try again." - } + }, + "teamInbox": "Team Inbox", + "teamInboxDesc": "Assignments, mentions, and handoffs from teammates" }, "editor": { "tabEditor": "Editor", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 5776ce79a0..d35a062456 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -2348,6 +2348,15 @@ "noRepo": "无仓库" } }, + "notifications": { + "backgroundSession": "后台会话", + "taskCompletedTitle": "任务已完成", + "taskCompletedBody": "“{{name}}”已完成,可以查看结果", + "taskCompletedToast": "“{{name}}”已完成,请打开会话查看结果。", + "taskFailedTitle": "任务失败", + "taskFailedBody": "“{{name}}”失败{{detail}}", + "taskCancelledToast": "“{{name}}”已取消" + }, "teamInbox": { "title": "团队收件箱", "listLabel": "团队收件箱列表", @@ -2364,6 +2373,13 @@ "read": "已读", "unread": "未读" }, + "notifications": { + "assignmentTitle": "有新的工作项分配给你", + "mentionTitle": "{{name}} 提及了你", + "handoffTitle": "来自 {{name}} 的交接", + "multipleTitle": "团队收件箱有 {{count}} 条新动态", + "multipleBody": "打开团队收件箱查看新的分配和提及。" + }, "row": { "assignedSummary": "{{status}} · {{priority}}", "ariaLabel": "{{title}},{{status}}" @@ -2401,7 +2417,7 @@ "title": "拖到这里创建工作项", "subtitle": "先确认会话摘要,再创建或交接工作项。", "processing": "正在从「{{title}}」创建工作项…", - "processingHint": "正在读取会话并解析项目成员。", + "processingHint": "正在读取会话并解析目标成员。", "success": "工作项已创建", "reused": "已更新现有工作项", "failed": "无法创建工作项", @@ -2412,24 +2428,36 @@ "handoff": { "title": "从会话创建", "createFromSession": "创建团队工作项…", - "project": "目标项目", - "chooseProject": "选择项目", + "destination": "交接到", + "chooseDestination": "选择交接位置", + "cloudDestination": "{{name}} · 团队收件箱", "recipientSelf": "{{name}}(我)", "chooseRecipient": "选择接收人", "todoCount": "{{count}} 个待办", "workItemTitle": "工作项标题", "assignTo": "分配给", + "status": "状态", + "priority": "优先级", + "dueDate": "截止日期", + "noDueDate": "无截止日期", "note": "交接说明", "notePlaceholder": "说明已经完成什么、还有哪些未决事项,以及下一步建议。", "selfHint": "分配给自己会创建普通工作项,不会发起交接请求。", "submitHandoff": "创建并交接", "submitCreate": "创建工作项", "preparing": "正在准备「{{title}}」…", + "validation": { + "title_required": "请输入工作项标题。", + "project_required": "请选择交接目标。", + "project_unavailable": "该交接目标已不可用,请选择其他目标。", + "recipient_required": "请选择接收人。", + "recipient_unavailable": "该接收人已不可用,请选择其他接收人。" + }, "preparationError": { "session_unavailable": "该会话已不可用,请重新打开后再试。", "project_unavailable": "该会话所属的项目已不可用。", - "identity_unavailable": "你的团队身份不属于该会话的项目。", - "no_project": "没有可用项目,请先创建或加入项目后再试。", + "identity_unavailable": "当前登录账号不是该交接目标的有效成员。", + "no_project": "没有可用交接目标,请先加入团队或项目后再试。", "unknown": "无法准备该会话,请刷新团队收件箱后重试。" }, "submitError": "无法创建工作项,请检查接收人后重试。", diff --git a/src/i18n/locales/zh/projects.json b/src/i18n/locales/zh/projects.json index cf9367225f..44645c75e1 100644 --- a/src/i18n/locales/zh/projects.json +++ b/src/i18n/locales/zh/projects.json @@ -375,6 +375,7 @@ "subscribe": "订阅", "unsubscribe": "取消订阅", "commentPlaceholder": "留下评论...", + "mentionPeople": "提及成员", "you": "你", "createdWorkItem": "创建了工作项", "deletedWorkItem": "删除了工作项", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index f685f82821..0966538ce6 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -707,7 +707,9 @@ "sent": "测试通知已发送", "permissionWarning": "测试通知发送失败,请检查权限。", "sendFailed": "无法发送测试通知。请检查通知权限后重试。" - } + }, + "teamInbox": "团队收件箱", + "teamInboxDesc": "来自队友的分配、提及和交接" }, "editor": { "tabEditor": "编辑器", diff --git a/src/modules/MainApp/Settings/__tests__/NotificationsSettings.test.ts b/src/modules/MainApp/Settings/__tests__/NotificationsSettings.test.ts new file mode 100644 index 0000000000..9107093a23 --- /dev/null +++ b/src/modules/MainApp/Settings/__tests__/NotificationsSettings.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import React, { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import NotificationsAdvancedBlocks from "../renderer/slots/NotificationsAdvancedBlocks"; +import NotificationsMasterToggleRow from "../renderer/slots/NotificationsMasterToggleRow"; + +const mocks = vi.hoisted(() => ({ + values: new Map(), + setters: new Map>(), + checkPermission: vi.fn(), + requestPermission: vi.fn(), + sendTest: vi.fn(), + playSound: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@src/store/settings", () => ({ + useSetting: (key: string) => [ + mocks.values.get(key), + mocks.setters.get(key) ?? vi.fn(), + ], +})); + +vi.mock("@src/api/services/notification", () => ({ + checkNotificationPermission: mocks.checkPermission, + requestNotificationPermission: mocks.requestPermission, + sendTestNotification: mocks.sendTest, + playCompletionSound: mocks.playSound, +})); + +vi.mock("@/src/modules/shared/layouts/SectionLayout", () => ({ + SectionContainer: ({ children }: { children?: React.ReactNode }) => + createElement("section", null, children), + SectionRow: ({ + children, + label, + }: { + children?: React.ReactNode; + label?: string; + }) => createElement("div", { "data-label": label }, children), +})); + +vi.mock("@src/components/Switch", () => ({ + default: ({ + checked, + disabled, + onChange, + }: { + checked?: boolean; + disabled?: boolean; + onChange?: () => void; + }) => + createElement("button", { + type: "button", + disabled, + "data-checked": String(Boolean(checked)), + onClick: onChange, + }), +})); + +vi.mock("@src/components/Button", () => ({ + default: ({ children }: { children?: React.ReactNode }) => + createElement("button", { type: "button" }, children), +})); + +vi.mock("@src/components/Slider", () => ({ + default: () => createElement("div"), +})); + +vi.mock("@src/components/Message", () => ({ + default: { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(), +})); + +vi.mock("@src/util/platform/tauri", () => ({ + isMacOS: () => false, +})); + +describe("notification settings lifecycle", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + mocks.values.clear(); + mocks.setters.clear(); + mocks.checkPermission.mockReset().mockResolvedValue("unknown"); + mocks.requestPermission.mockReset().mockResolvedValue("granted"); + mocks.sendTest.mockReset().mockResolvedValue(true); + mocks.playSound.mockReset().mockResolvedValue(true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("lets the master switch enable sound-only notifications without requesting OS permission", () => { + const setEnabled = vi.fn(); + mocks.values.set("notifications.enabled", false); + mocks.setters.set("notifications.enabled", setEnabled); + + act(() => root.render(createElement(NotificationsMasterToggleRow))); + act(() => container.querySelector("button")?.click()); + + expect(setEnabled).toHaveBeenCalledWith(true); + expect(mocks.requestPermission).not.toHaveBeenCalled(); + }); + + it("keeps categories visible with sound off and requests permission at the system toggle", async () => { + const setSystemEnabled = vi.fn(); + const defaults: Record = { + "notifications.enabled": true, + "notifications.completionSound": false, + "notifications.systemNotificationEnabled": false, + "notifications.dockBadgeEnabled": false, + "notifications.soundVolume": 70, + "notifications.categories.taskCompletion": true, + "notifications.categories.errors": true, + "notifications.categories.teamInbox": true, + }; + for (const [key, value] of Object.entries(defaults)) { + mocks.values.set(key, value); + mocks.setters.set(key, vi.fn()); + } + mocks.setters.set( + "notifications.systemNotificationEnabled", + setSystemEnabled + ); + + await act(async () => { + root.render(createElement(NotificationsAdvancedBlocks)); + }); + + expect( + container.querySelector('[data-label="notifications.teamInbox"]') + ).not.toBeNull(); + const systemRow = container.querySelector( + '[data-label="notifications.enableSystem"]' + ); + await act(async () => { + systemRow?.querySelector("button")?.click(); + }); + + expect(mocks.requestPermission).toHaveBeenCalledTimes(1); + expect(setSystemEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx b/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx index dfdab39be0..11ec6589dd 100644 --- a/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx +++ b/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx @@ -2,34 +2,27 @@ import { SectionContainer, SectionRow, } from "@/src/modules/shared/layouts/SectionLayout"; -import { invoke } from "@tauri-apps/api/core"; import { open as shellOpen } from "@tauri-apps/plugin-shell"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + type NotificationPermissionStatus, checkNotificationPermission, playCompletionSound, + requestNotificationPermission, sendTestNotification, } from "@src/api/services/notification"; import Button from "@src/components/Button"; import Message from "@src/components/Message"; import Slider from "@src/components/Slider"; import Switch from "@src/components/Switch"; -import { createLogger } from "@src/hooks/logger"; import { NAV_BUTTON_PROPS } from "@src/modules/MainApp/Settings/config"; import { useSetting } from "@src/store/settings"; import { isMacOS } from "@src/util/platform/tauri"; -const log = createLogger("Notifications"); - interface NotificationCategoryConfig { - key: - | "taskCompletion" - | "agentApproval" - | "errors" - | "sessionStatus" - | "gitOperations"; + key: "taskCompletion" | "errors" | "teamInbox"; labelKey: string; } @@ -38,21 +31,13 @@ const NOTIFICATION_CATEGORIES: NotificationCategoryConfig[] = [ key: "taskCompletion", labelKey: "notifications.taskCompletion", }, - { - key: "agentApproval", - labelKey: "notifications.agentApproval", - }, { key: "errors", labelKey: "notifications.errors", }, { - key: "sessionStatus", - labelKey: "notifications.sessionStatus", - }, - { - key: "gitOperations", - labelKey: "notifications.gitOperations", + key: "teamInbox", + labelKey: "notifications.teamInbox", }, ]; @@ -72,18 +57,14 @@ const NotificationsAdvancedBlocks: React.FC = () => { const [taskCompletion, setTaskCompletion] = useSetting( "notifications.categories.taskCompletion" ); - const [agentApproval, setAgentApproval] = useSetting( - "notifications.categories.agentApproval" - ); const [errors, setErrors] = useSetting("notifications.categories.errors"); - const [sessionStatus, setSessionStatus] = useSetting( - "notifications.categories.sessionStatus" - ); - const [gitOperations, setGitOperations] = useSetting( - "notifications.categories.gitOperations" + const [teamInbox, setTeamInbox] = useSetting( + "notifications.categories.teamInbox" ); - const [permissionStatus, setPermissionStatus] = useState("unknown"); + const [permissionStatus, setPermissionStatus] = + useState("unknown"); + const [isRequestingPermission, setIsRequestingPermission] = useState(false); const [isTesting, setIsTesting] = useState(false); useEffect(() => { @@ -98,22 +79,37 @@ const NotificationsAdvancedBlocks: React.FC = () => { }; }, []); + const ensureSystemPermission = + async (): Promise => { + if (permissionStatus === "granted") return permissionStatus; + setIsRequestingPermission(true); + try { + const result = await requestNotificationPermission(); + setPermissionStatus(result); + if (result !== "granted") { + Message.warning(t("notifications.permissionDenied")); + } + return result; + } finally { + setIsRequestingPermission(false); + } + }; + + const handleToggleSystemNotification = async () => { + if (systemNotificationEnabled) { + setSystemNotificationEnabled(false); + return; + } + if ((await ensureSystemPermission()) === "granted") { + setSystemNotificationEnabled(true); + } + }; + const handleTestNotification = async () => { setIsTesting(true); try { - const success = await sendTestNotification({ - enabled, - systemNotificationEnabled, - completionSound, - soundVolume, - categories: { - taskCompletion, - agentApproval, - errors, - sessionStatus, - gitOperations, - }, - }); + if ((await ensureSystemPermission()) !== "granted") return; + const success = await sendTestNotification(); if (success) { Message.success(t("notifications.test.sent")); } else { @@ -126,18 +122,6 @@ const NotificationsAdvancedBlocks: React.FC = () => { } }; - const handleToggleDockBadge = async () => { - const newEnabled = !dockBadgeEnabled; - setDockBadgeEnabled(newEnabled); - if (!newEnabled) { - try { - await invoke("clear_dock_badge"); - } catch (error) { - log.error("[Notifications] Failed to clear badge:", error); - } - } - }; - const handleVolumeChange: (value: number | [number, number]) => void = ( value ) => { @@ -147,18 +131,14 @@ const NotificationsAdvancedBlocks: React.FC = () => { const categoryValues = { taskCompletion, - agentApproval, errors, - sessionStatus, - gitOperations, + teamInbox, }; const categorySetters = { taskCompletion: setTaskCompletion, - agentApproval: setAgentApproval, errors: setErrors, - sessionStatus: setSessionStatus, - gitOperations: setGitOperations, + teamInbox: setTeamInbox, } as const; if (!enabled) { @@ -191,31 +171,28 @@ const NotificationsAdvancedBlocks: React.FC = () => { )} - {completionSound && ( - - {NOTIFICATION_CATEGORIES.map((category) => ( - - - categorySetters[category.key](!categoryValues[category.key]) - } - /> - - ))} - - )} + + {NOTIFICATION_CATEGORIES.map((category) => ( + + + categorySetters[category.key](!categoryValues[category.key]) + } + /> + + ))} + - setSystemNotificationEnabled(!systemNotificationEnabled) - } + disabled={isRequestingPermission} + onChange={() => void handleToggleSystemNotification()} /> - {systemNotificationEnabled && ( + {(systemNotificationEnabled || permissionStatus !== "unknown") && ( { - + setDockBadgeEnabled(!dockBadgeEnabled)} + /> @@ -265,13 +245,13 @@ const NotificationsAdvancedBlocks: React.FC = () => { size="default" onClick={handleTestNotification} loading={isTesting} - disabled={permissionStatus !== "granted"} + disabled={isRequestingPermission} > {t("notifications.notification")}