From e97aac62606d957e0528550c1ae52a157b0aedbb Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Mon, 27 Jul 2026 11:25:37 +0800
Subject: [PATCH 01/11] feat(team-inbox): unify assignments and mentions
Pre-commit hook ran. Total eslint: 10, total circular: 0
---
.../TeamInbox.md | 82 +++
.../frontend-ui-audit-2026-07-23/TeamInbox.md | 50 ++
.../crates/project-management/src/lib.rs | 2 +
.../project-management/src/projects/schema.rs | 1 +
.../src/team_inbox/commands.rs | 65 +++
.../project-management/src/team_inbox/mod.rs | 18 +
.../src/team_inbox/schema.rs | 23 +
.../src/team_inbox/store.rs | 424 ++++++++++++++
.../src/team_inbox/tests.rs | 510 +++++++++++++++++
.../src/team_inbox/types.rs | 108 ++++
src/api/realtime/websocket/schemas.ts | 1 +
src/engines/ChatPanel/ChatPanelTabBar.tsx | 10 +
src/engines/ChatPanel/TabContent/registry.ts | 6 +
.../ChatPanel/TabContent/surfaceRenderers.tsx | 11 +
.../ChatPanel/chatPanelTabDisplay.test.ts | 7 +
src/engines/ChatPanel/chatPanelTabDisplay.ts | 3 +
.../Org2Cloud/teamInboxMentionsClient.test.ts | 165 ++++++
.../Org2Cloud/teamInboxMentionsClient.ts | 101 ++++
src/i18n/locales/en/common.json | 94 ++++
src/i18n/locales/zh/common.json | 94 ++++
.../TeamInbox/ConnectedTeamInboxView.tsx | 13 +
src/modules/MainApp/TeamInbox/TEST_CASES.md | 52 ++
.../MainApp/TeamInbox/TeamInboxView.tsx | 356 ++++++++++++
.../MainApp/TeamInbox/__tests__/TEST_CASES.md | 77 +++
.../TeamInbox/__tests__/cursor.test.ts | 23 +
.../TeamInbox/__tests__/labels.test.ts | 42 ++
.../TeamInbox/__tests__/selectors.test.ts | 234 ++++++++
.../MainApp/TeamInbox/__tests__/store.test.ts | 63 +++
src/modules/MainApp/TeamInbox/api.ts | 201 +++++++
.../components/AssignedWorkItemDetail.tsx | 93 ++++
.../components/CommentMentionDetail.tsx | 94 ++++
.../components/TeamInboxDetailLayout.tsx | 100 ++++
.../TeamInbox/components/TeamInboxList.tsx | 311 +++++++++++
.../TeamInbox/components/TeamInboxRow.tsx | 100 ++++
.../MainApp/TeamInbox/components/index.ts | 10 +
.../MainApp/TeamInbox/domain/cursor.ts | 13 +
src/modules/MainApp/TeamInbox/domain/index.ts | 39 ++
.../MainApp/TeamInbox/domain/labels.ts | 37 ++
.../MainApp/TeamInbox/domain/selectors.ts | 247 ++++++++
src/modules/MainApp/TeamInbox/domain/types.ts | 109 ++++
src/modules/MainApp/TeamInbox/index.ts | 7 +
src/modules/MainApp/TeamInbox/store.ts | 87 +++
.../TeamInbox/useTeamInboxDataSource.ts | 527 ++++++++++++++++++
.../TeamInbox/useTeamInboxNavigation.ts | 84 +++
.../TeamInbox/useTeamInboxWorkItemBody.ts | 69 +++
.../WorkstationSidebarConnector/index.tsx | 10 +
.../menuSelection.test.ts | 17 +
.../menuSelection.ts | 5 +-
.../sidebarConnector.chatPanelAtoms.ts | 3 +
.../sidebarConnector.chrome.tsx | 6 +
.../sidebarConnector.labels.ts | 4 +
.../sidebarConnector.menuItemRouting.ts | 11 +
.../sidebarConnector.pinnedAndRevealData.ts | 6 +
.../sidebarMenuCollections.ts | 16 +-
.../useWorkstationSidebarReveal.ts | 124 +++++
.../connectors/sidebarConnectorUtils.ts | 1 +
.../workstationSidebarMenuItems.test.ts | 11 +-
.../workstationSidebarMenuItems.tsx | 23 +
.../__tests__/chatPanelTabsAtom.test.ts | 29 +
src/store/chatPanel/chatPanelTabFactories.ts | 14 +
src/store/chatPanel/chatPanelTabOpenAtoms.ts | 20 +
src/store/chatPanel/chatPanelTabsAtom.ts | 2 +
src/store/chatPanel/chatPanelTabsModel.ts | 7 +
63 files changed, 5068 insertions(+), 4 deletions(-)
create mode 100644 docs/architecture-audit-2026-07-23/TeamInbox.md
create mode 100644 docs/frontend-ui-audit-2026-07-23/TeamInbox.md
create mode 100644 src-tauri/crates/project-management/src/team_inbox/commands.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/mod.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/schema.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/store.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/tests.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/types.rs
create mode 100644 src/features/Org2Cloud/teamInboxMentionsClient.test.ts
create mode 100644 src/features/Org2Cloud/teamInboxMentionsClient.ts
create mode 100644 src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
create mode 100644 src/modules/MainApp/TeamInbox/TEST_CASES.md
create mode 100644 src/modules/MainApp/TeamInbox/TeamInboxView.tsx
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/store.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/api.ts
create mode 100644 src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/index.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/cursor.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/index.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/labels.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/selectors.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/types.ts
create mode 100644 src/modules/MainApp/TeamInbox/index.ts
create mode 100644 src/modules/MainApp/TeamInbox/store.ts
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
create mode 100644 src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts
diff --git a/docs/architecture-audit-2026-07-23/TeamInbox.md b/docs/architecture-audit-2026-07-23/TeamInbox.md
new file mode 100644
index 0000000000..7d4be898cb
--- /dev/null
+++ b/docs/architecture-audit-2026-07-23/TeamInbox.md
@@ -0,0 +1,82 @@
+# Architecture Audit — Team Inbox
+
+**Scope:** Team Inbox TypeScript domain/UI/data source, managed-cloud mention RPC client, project-management SQLite projection, Tauri commands, Sidebar and Chat Panel tab integration.
+**Date:** 2026-07-23
+
+## Layer 1 — Compilation correctness
+
+- TypeScript `tsc --noEmit`: passed.
+- Tauri application `cargo check -p org2`: passed.
+- Focused Rust Team Inbox tests: 7 passed.
+
+## Layer 2 — Dead code and structural deduplication
+
+- Production entry path is Sidebar row → singleton Team Inbox tab → connected view → shared cache/data source → local Tauri projection plus managed-cloud mention RPC.
+- Sidebar badge and rendered page consume the same cache; no second unread query implementation remains.
+- Local assignment reads remain in SQLite; the frontend does not rescan every project Work Item.
+- Mention response mapping is centralized in the Team Inbox data source; sorting/filtering/deduplication remain pure domain selectors.
+
+## Layer 3 — Naming consistency
+
+- Wire `work_item_assigned` is mapped once to UI `assigned_work_item`; names are explicit at the boundary.
+- `viewerMemberIds` is used consistently for the local viewer identity. The cloud RPC deliberately accepts no viewer ID because JWT identity is authoritative.
+- Sidebar/menu/tab terms consistently use `team-inbox` / `Team Inbox`.
+
+## Layer 4 — Semantic overloading
+
+| Term | Meaning in this change | Verdict |
+| ------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| viewer | Explicit local project member IDs, or managed-cloud JWT subject | Kept separate at transport boundaries; never inferred from an agent/session ID. |
+| read receipt | SQLite viewer-scoped receipt for local assignment; endpoint+user+org scoped persisted receipt for cloud mention | Separate storage owners with one UI read state. |
+| projectId | Project slug for project-store navigation; empty for standalone Work Items | Boundary is explicit and standalone navigation uses the standalone API. |
+
+## Layer 5 — Default branch analysis
+
+- Item-kind branching uses discriminated unions with explicit mention/assignment cases; unsupported wire combinations throw.
+- Local mentions filter returns an explicit empty page rather than falling through to assignments.
+- Cloud RPC failure degrades to local items only; it does not fabricate mention data or scan comment bodies.
+
+## Layer 6 — Cross-domain concept leakage
+
+- Project-management owns only local assigned Work Item projection and receipt DDL.
+- Managed-cloud mention transport remains under `features/Org2Cloud`.
+- Presentation consumes a transport-independent Team Inbox domain contract.
+
+## Layer 7 — New developer confusion test
+
+- `ConnectedTeamInboxView` identifies the production-wired surface; `TeamInboxView` remains injectable for tests/reuse.
+- `useTeamInboxDataSource` names local/cloud composition and identity resolution explicitly.
+- `useTeamInboxNavigation` separates Session comment navigation from project/standalone Work Item navigation.
+
+## Layer 8 — Wire protocol and serialization
+
+- Local DTOs use serde-tagged target/payload variants and camelCase fields, covered by Rust serialization tests.
+- Cloud request body contains only `p_org_id`, `p_cursor`, and `p_limit`; tests assert no caller-supplied viewer/user ID.
+- Cloud response is Zod-validated; malformed counts and pagination input are rejected.
+
+## Layer 9 — Init parity
+
+| Entry point | Canonical schema init | Explicit viewer | Blocking DB isolation |
+| ------------- | --------------------: | --------------: | --------------------: |
+| list page | yes | yes | `spawn_blocking` |
+| unread count | yes | yes | `spawn_blocking` |
+| mark read | yes | yes | `spawn_blocking` |
+| mark all read | yes | yes | `spawn_blocking` |
+| mark unread | yes | yes | `spawn_blocking` |
+
+All five commands (`team_inbox_list_page`, `team_inbox_unread_count`, `team_inbox_mark_read`, `team_inbox_mark_all_read`, `team_inbox_mark_unread`) are registered in the same Tauri handler list.
+
+## Layer 10 — Resolver symmetry
+
+- Local viewer identity uses the same current-user member resolver for list, single read, and bulk read.
+- Cloud cache and persisted receipt keys use the same endpoint + authenticated user + org scope.
+- Project and standalone navigation both resolve raw Work Item data through the same adapter chain before opening the canonical Chat Panel Work Item tab.
+
+## Completion verdict
+
+- Canonical DDL changed directly; no `ALTER TABLE` compatibility path was introduced.
+- Local cursor ordering and viewer-scoped receipt idempotence are tested.
+- Cloud receipt storage is bounded to 1,000 entries.
+- No timer or polling loop was introduced; refresh is driven by initial demand, existing project-change signals, cloud comment signals, and mutations.
+
+**Architecture verdict: pass for the audited Team Inbox scope.**
diff --git a/docs/frontend-ui-audit-2026-07-23/TeamInbox.md b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md
new file mode 100644
index 0000000000..09b75a4451
--- /dev/null
+++ b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md
@@ -0,0 +1,50 @@
+# Frontend UI Audit — Team Inbox
+
+**Files:** `src/modules/MainApp/TeamInbox/**/*.tsx`
+**Date:** 2026-07-23
+**Auditor:** ORGII implementation session
+
+## D1 — Raw HTML vs Design System
+
+| Line | Element | Verdict | Reason | Suggested change |
+|---|---|---|---|---|
+| `TeamInboxRow.tsx:42` | raw `` listbox option | keep with reason | The row implements a multi-line `role="option"` with roving tab index and a forwarded focus ref. `Button` does not cover this listbox-row contract; visual state is sourced from `getListItemClasses`. | — |
+| `TeamInboxList.tsx` | filter controls | fixed | The previous implementation manually mapped three DS Buttons into a segmented filter. | Replaced with shared `TabPill` inside `ListPanelTabPillRow`. |
+| `TeamInboxList.tsx` | list header and refresh action | fixed | The previous implementation rebuilt the panel header and used a custom labelled refresh button. | Replaced with `PanelHeader`, `PANEL_HEADER_TOKENS.actionButton`, and `PanelRefreshButton`. |
+| `AssignedWorkItemDetail.tsx`, `CommentMentionDetail.tsx` | duplicated detail shell | fixed | Both files rebuilt header, scroll area, width container, metadata, and bottom navigation independently. | Both now compose `TeamInboxDetailLayout`, which uses `DetailPanelContainer`, `PanelHeader`, `DETAIL_PANEL_TOKENS`, `InfoCard`, and `PanelFooter`. |
+
+## D2 — Arbitrary Tailwind Value vs Token
+
+| Line | Value | Verdict | Reason | Suggested change |
+|---|---|---|---|---|
+| All audited files | CSS-variable / raw-color arbitrary values | keep | No arbitrary CSS-variable, hex, rgb, or hsl Tailwind values remain. | — |
+
+## D3 — Hardcoded Sizes / Colors
+
+| Line | Value | Verdict | Reason | Suggested change |
+|---|---|---|---|---|
+| `TeamInboxView.tsx` | split widths `320/240/420` | fixed | The Team Inbox had invented wider master/detail geometry instead of matching the existing Inbox surface. | Replaced with the established Inbox `200/160` geometry and `SplitViewLayout` defaults. |
+| detail components | `p-5`, `max-w-3xl`, `gap-4`, bordered card shell | fixed | These duplicated spacing, width, and card decisions already encoded by detail-panel tokens. | Replaced with `DETAIL_PANEL_TOKENS`, `CARD_ROW_TOKENS`, and `InfoCard`. |
+| compact icons | `size={14}` | keep with reason | 14px is the established compact list/action optical size and is also used by the shared Inbox/ListPanel patterns. Header action icons use `PANEL_HEADER_TOKENS`. | — |
+| Team Inbox colors | semantic color classes | keep with reason | Remaining colors are semantic project tokens (`primary`, `success`, `text`, `border`) used to distinguish mention and assignment item kinds. No raw color values exist. | — |
+
+## D4 — Accessibility
+
+| Line | Element | Verdict | Reason | Suggested change |
+|---|---|---|---|---|
+| `TeamInboxList.tsx` | filter tabs | fixed | Shared `TabPill` now owns segmented-control semantics and interaction instead of a local button group. | — |
+| `TeamInboxList.tsx` | listbox | keep with reason | The list has an accessible name, active descendant, and Arrow/Home/End keyboard navigation. | — |
+| `TeamInboxRow.tsx` | option row | keep with reason | Each row has `role="option"`, `aria-selected`, an explicit read-state accessible name, and roving tab index. | — |
+| header actions | icon-only controls | fixed | Refresh and mark-all now use shared header action components/tokens with explicit titles and accessible labels. | — |
+
+## D5 — Visual Patterns Observed
+
+- The duplicated Work Item / mention detail shell occurred twice, below the global three-site abstraction threshold, but was abstracted locally because both implementations were in the same feature and already shared an identical contract.
+- The segmented filter, refresh action, panel header, detail scroll shell, metadata card, and footer are existing cross-repo patterns; Team Inbox now consumes those primitives rather than creating new variants.
+- No new global design-system abstraction is required.
+
+## Summary
+
+- 7 fixes completed
+- 4 kept with documented reason
+- 0 remaining abstract candidates
diff --git a/src-tauri/crates/project-management/src/lib.rs b/src-tauri/crates/project-management/src/lib.rs
index 32b2484c1d..6321884d5e 100644
--- a/src-tauri/crates/project-management/src/lib.rs
+++ b/src-tauri/crates/project-management/src/lib.rs
@@ -3,6 +3,7 @@
//! This crate contains project-management functionality:
//! - `projects`: Pure-SQLite project & work item store at
//! `~/.orgii/projects/projects.db`. Single source of truth.
+//! - `team_inbox`: Viewer-scoped projection of assigned Work Items.
//! - `orchestrator`: Workflow orchestration state machine.
//! - `lineage`: Code lineage tracking and analysis.
//! - `sync`: Pluggable sync framework — outbox + adapters draining through
@@ -12,6 +13,7 @@ pub mod lineage;
pub mod orchestrator;
pub mod projects;
pub mod sync;
+pub mod team_inbox;
#[cfg(test)]
mod test_support;
diff --git a/src-tauri/crates/project-management/src/projects/schema.rs b/src-tauri/crates/project-management/src/projects/schema.rs
index f5a6cd95ab..b016f96315 100644
--- a/src-tauri/crates/project-management/src/projects/schema.rs
+++ b/src-tauri/crates/project-management/src/projects/schema.rs
@@ -38,6 +38,7 @@ use rusqlite::{Connection, Result as SqliteResult};
/// connection pool. Safe to invoke against an existing DB.
pub fn init_project_tables(conn: &Connection) -> SqliteResult<()> {
init_local_tables(conn)?;
+ crate::team_inbox::schema::init_team_inbox_tables(conn)?;
init_outbox_table(conn)?;
init_webhook_secrets_table(conn)?;
init_import_progress_table(conn)?;
diff --git a/src-tauri/crates/project-management/src/team_inbox/commands.rs b/src-tauri/crates/project-management/src/team_inbox/commands.rs
new file mode 100644
index 0000000000..10fd20c5c4
--- /dev/null
+++ b/src-tauri/crates/project-management/src/team_inbox/commands.rs
@@ -0,0 +1,65 @@
+use super::{
+ list_page, mark_all_read, mark_read, mark_unread, unread_count, TeamInboxCursor,
+ TeamInboxFilter, TeamInboxListOptions, TeamInboxPage,
+};
+
+#[tauri::command]
+pub async fn team_inbox_list_page(
+ viewer_member_ids: Vec,
+ filter: Option,
+ cursor: Option,
+ limit: Option,
+) -> Result {
+ tokio::task::spawn_blocking(move || {
+ list_page(TeamInboxListOptions {
+ viewer_member_ids,
+ filter: filter.unwrap_or_default(),
+ cursor,
+ limit: limit.unwrap_or(50),
+ })
+ })
+ .await
+ .map_err(|error| format!("Task join error: {error}"))?
+}
+
+#[tauri::command]
+pub async fn team_inbox_unread_count(
+ viewer_member_ids: Vec,
+ filter: Option,
+) -> Result {
+ tokio::task::spawn_blocking(move || unread_count(viewer_member_ids, filter.unwrap_or_default()))
+ .await
+ .map_err(|error| format!("Task join error: {error}"))?
+}
+
+#[tauri::command]
+pub async fn team_inbox_mark_read(
+ viewer_member_ids: Vec,
+ item_id: String,
+) -> Result {
+ tokio::task::spawn_blocking(move || mark_read(viewer_member_ids, &item_id))
+ .await
+ .map_err(|error| format!("Task join error: {error}"))?
+}
+
+#[tauri::command]
+pub async fn team_inbox_mark_all_read(
+ viewer_member_ids: Vec,
+ filter: Option,
+) -> Result {
+ tokio::task::spawn_blocking(move || {
+ mark_all_read(viewer_member_ids, filter.unwrap_or_default())
+ })
+ .await
+ .map_err(|error| format!("Task join error: {error}"))?
+}
+
+#[tauri::command]
+pub async fn team_inbox_mark_unread(
+ viewer_member_ids: Vec,
+ item_id: String,
+) -> Result {
+ tokio::task::spawn_blocking(move || mark_unread(viewer_member_ids, &item_id))
+ .await
+ .map_err(|error| format!("Task join error: {error}"))?
+}
diff --git a/src-tauri/crates/project-management/src/team_inbox/mod.rs b/src-tauri/crates/project-management/src/team_inbox/mod.rs
new file mode 100644
index 0000000000..a20247eaa3
--- /dev/null
+++ b/src-tauri/crates/project-management/src/team_inbox/mod.rs
@@ -0,0 +1,18 @@
+//! Team Inbox read model for the global project store.
+//!
+//! The local project database currently contributes assigned Work Items. The
+//! wire contract also reserves the comment-mention variant so cloud/session
+//! comment sources can be merged by a higher layer without changing the DTO.
+
+pub mod commands;
+pub mod schema;
+mod store;
+mod types;
+
+pub use store::{
+ list_page, mark_all_read, mark_read, mark_unread, unread_count, TeamInboxListOptions,
+};
+pub use types::*;
+
+#[cfg(test)]
+mod tests;
diff --git a/src-tauri/crates/project-management/src/team_inbox/schema.rs b/src-tauri/crates/project-management/src/team_inbox/schema.rs
new file mode 100644
index 0000000000..ab40478089
--- /dev/null
+++ b/src-tauri/crates/project-management/src/team_inbox/schema.rs
@@ -0,0 +1,23 @@
+use rusqlite::{Connection, Result as SqliteResult};
+
+/// Canonical read receipts for Team Inbox projections.
+///
+/// A receipt belongs to an explicit viewer identity and a stable source item.
+/// Source rows remain authoritative; deleting a Work Item cascades neither
+/// business state nor unrelated viewers' receipts.
+pub fn init_team_inbox_tables(conn: &Connection) -> SqliteResult<()> {
+ conn.execute_batch(
+ r#"
+ CREATE TABLE IF NOT EXISTS team_inbox_read_receipts (
+ viewer_member_id TEXT NOT NULL,
+ source_kind TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ read_at INTEGER NOT NULL,
+ PRIMARY KEY (viewer_member_id, source_kind, source_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_team_inbox_receipts_source
+ ON team_inbox_read_receipts(source_kind, source_id);
+ "#,
+ )?;
+ Ok(())
+}
diff --git a/src-tauri/crates/project-management/src/team_inbox/store.rs b/src-tauri/crates/project-management/src/team_inbox/store.rs
new file mode 100644
index 0000000000..6c24afa73c
--- /dev/null
+++ b/src-tauri/crates/project-management/src/team_inbox/store.rs
@@ -0,0 +1,424 @@
+use std::collections::BTreeSet;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use database::db::get_projects_connection;
+use rusqlite::{
+ params_from_iter, types::Value, Connection, OptionalExtension, TransactionBehavior,
+};
+
+use super::{
+ schema::init_team_inbox_tables, TeamInboxCursor, TeamInboxFilter, TeamInboxItem,
+ TeamInboxItemKind, TeamInboxPage, TeamInboxPayload, TeamInboxTarget,
+};
+
+const ASSIGNED_SOURCE_KIND: &str = "work_item_assigned";
+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
+/// bloats the inbox payload; the detail surface links back to the full item.
+const SUMMARY_EXCERPT_MAX_CHARS: usize = 240;
+
+/// Collapses a Work Item body into a single-line inbox summary. Whitespace runs
+/// (including newlines) fold to single spaces, and the result is truncated on a
+/// char boundary with an ellipsis. Empty bodies yield `None` so the DTO omits
+/// the field entirely.
+pub(crate) fn work_item_summary_excerpt(body: &str) -> Option {
+ let normalized = body.split_whitespace().collect::>().join(" ");
+ if normalized.is_empty() {
+ return None;
+ }
+ let mut chars = normalized.chars();
+ let head: String = chars.by_ref().take(SUMMARY_EXCERPT_MAX_CHARS).collect();
+ if chars.next().is_some() {
+ Some(format!("{head}…"))
+ } else {
+ Some(head)
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct TeamInboxListOptions {
+ pub viewer_member_ids: Vec,
+ pub filter: TeamInboxFilter,
+ pub cursor: Option,
+ pub limit: usize,
+}
+
+impl TeamInboxListOptions {
+ pub fn new(viewer_member_ids: Vec) -> Self {
+ Self {
+ viewer_member_ids,
+ filter: TeamInboxFilter::All,
+ cursor: None,
+ limit: DEFAULT_PAGE_LIMIT,
+ }
+ }
+}
+
+pub fn list_page(options: TeamInboxListOptions) -> Result {
+ let connection = open_connection()?;
+ list_page_with_connection(&connection, options)
+}
+
+pub fn unread_count(
+ viewer_member_ids: Vec,
+ filter: TeamInboxFilter,
+) -> Result {
+ let connection = open_connection()?;
+ unread_count_with_connection(&connection, &viewer_member_ids, filter)
+}
+
+pub fn mark_read(viewer_member_ids: Vec, item_id: &str) -> Result {
+ let mut connection = open_connection()?;
+ mark_read_with_connection(&mut connection, &viewer_member_ids, item_id, now_ms())
+}
+
+pub fn mark_all_read(
+ viewer_member_ids: Vec,
+ filter: TeamInboxFilter,
+) -> Result {
+ let mut connection = open_connection()?;
+ mark_all_read_with_connection(&mut connection, &viewer_member_ids, filter, now_ms())
+}
+
+pub fn mark_unread(viewer_member_ids: Vec, item_id: &str) -> Result {
+ let mut connection = open_connection()?;
+ mark_unread_with_connection(&mut connection, &viewer_member_ids, item_id)
+}
+
+fn open_connection() -> Result {
+ let connection = get_projects_connection().map_err(db_error)?;
+ init_team_inbox_tables(&connection).map_err(db_error)?;
+ Ok(connection)
+}
+
+pub(crate) fn list_page_with_connection(
+ connection: &Connection,
+ options: TeamInboxListOptions,
+) -> 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 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()?;
+ 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 < ?))"
+ } else {
+ ""
+ };
+ let sql = format!(
+ "SELECT w.id, w.org_id, w.project_id, p.slug, w.short_id, w.title,
+ w.status, w.priority, COALESCE(w.assigned_human_id, w.assignee),
+ w.updated_at,
+ (SELECT MAX(r.read_at) FROM team_inbox_read_receipts r
+ WHERE r.source_kind = '{ASSIGNED_SOURCE_KIND}'
+ AND r.source_id = w.id AND {receipt_viewer_predicate}) AS read_at,
+ w.body
+ FROM workitems w
+ LEFT JOIN projects p ON p.id = w.project_id
+ WHERE w.deleted_at IS NULL AND {assignment_predicate}
+ {cursor_predicate}
+ ORDER BY w.updated_at DESC, w.id DESC
+ LIMIT ?"
+ );
+
+ 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) {
+ values.push(Value::from(cursor.occurred_at));
+ values.push(Value::from(cursor.occurred_at));
+ values.push(Value::from(source_id));
+ }
+ values.push(Value::from((limit + 1) 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 assignee_member_id: String = row.get(8)?;
+ let body: String = row.get(11)?;
+ Ok(TeamInboxItem {
+ id: assigned_item_id(&work_item_id),
+ kind: TeamInboxItemKind::WorkItemAssigned,
+ occurred_at: row.get(9)?,
+ read_at: row.get(10)?,
+ actor: None,
+ target: TeamInboxTarget::WorkItem {
+ work_item_id,
+ org_id: row.get(1)?,
+ project_id: row.get(2)?,
+ project_slug: row.get(3)?,
+ short_id: row.get(4)?,
+ },
+ payload: TeamInboxPayload::WorkItemAssigned {
+ title: row.get(5)?,
+ status: row.get(6)?,
+ priority: row.get(7)?,
+ assignee_member_id,
+ summary: work_item_summary_excerpt(&body),
+ },
+ })
+ })
+ .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)?;
+
+ Ok(TeamInboxPage {
+ items,
+ next_cursor,
+ unread_count,
+ })
+}
+
+pub(crate) fn unread_count_with_connection(
+ connection: &Connection,
+ viewer_member_ids: &[String],
+ filter: TeamInboxFilter,
+) -> 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 placeholders = sql_placeholders(viewer_ids.len());
+ let sql = format!(
+ "SELECT COUNT(*) 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.into_iter().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)
+}
+
+pub(crate) fn mark_read_with_connection(
+ connection: &mut Connection,
+ viewer_member_ids: &[String],
+ item_id: &str,
+ read_at: i64,
+) -> 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 tx = connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(db_error)?;
+ let exists = tx
+ .query_row(&sql, params_from_iter(values), |_| Ok(()))
+ .optional()
+ .map_err(db_error)?
+ .is_some();
+ if !exists {
+ tx.commit().map_err(db_error)?;
+ return Ok(false);
+ }
+
+ for viewer_id in &viewer_ids {
+ tx.execute(
+ "INSERT INTO team_inbox_read_receipts
+ (viewer_member_id, source_kind, source_id, read_at)
+ 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),
+ )
+ .map_err(db_error)?;
+ }
+ tx.commit().map_err(db_error)?;
+ Ok(true)
+}
+
+pub(crate) fn mark_all_read_with_connection(
+ connection: &mut Connection,
+ viewer_member_ids: &[String],
+ filter: TeamInboxFilter,
+ read_at: i64,
+) -> 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 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)?
+ };
+
+ for source_id in source_ids {
+ for viewer_id in &viewer_ids {
+ tx.execute(
+ "INSERT INTO team_inbox_read_receipts
+ (viewer_member_id, source_kind, source_id, read_at)
+ 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),
+ )
+ .map_err(db_error)?;
+ }
+ }
+ tx.commit().map_err(db_error)?;
+ Ok(before)
+}
+
+pub(crate) fn mark_unread_with_connection(
+ connection: &mut Connection,
+ viewer_member_ids: &[String],
+ item_id: &str,
+) -> 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!(
+ "DELETE FROM team_inbox_read_receipts
+ WHERE source_kind = '{ASSIGNED_SOURCE_KIND}'
+ AND source_id = ?
+ AND viewer_member_id IN ({placeholders})"
+ );
+ let mut values = vec![Value::from(source_id.to_string())];
+ values.extend(viewer_ids.iter().cloned().map(Value::from));
+ let tx = connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(db_error)?;
+ let affected = tx
+ .execute(&sql, params_from_iter(values))
+ .map_err(db_error)?;
+ tx.commit().map_err(db_error)?;
+ Ok(affected > 0)
+}
+
+fn normalized_viewer_ids(viewer_member_ids: &[String]) -> Result, String> {
+ let ids = viewer_member_ids
+ .iter()
+ .map(|value| value.trim())
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned)
+ .collect::>()
+ .into_iter()
+ .collect::>();
+ if ids.is_empty() {
+ return Err("viewerMemberIds must contain at least one non-empty member id".to_string());
+ }
+ Ok(ids)
+}
+
+fn assignment_predicate(placeholders: &str) -> String {
+ format!(
+ "(w.assigned_human_id IN ({placeholders}) OR
+ (w.assignee IN ({placeholders}) AND
+ (w.assignee_type IS NULL OR LOWER(w.assignee_type) IN ('member', 'human'))))"
+ )
+}
+
+fn assignment_values(viewer_ids: &[String]) -> Vec {
+ viewer_ids
+ .iter()
+ .chain(viewer_ids.iter())
+ .cloned()
+ .map(Value::from)
+ .collect()
+}
+
+fn sql_placeholders(count: usize) -> String {
+ std::iter::repeat("?")
+ .take(count)
+ .collect::>()
+ .join(", ")
+}
+
+fn assigned_item_id(source_id: &str) -> String {
+ format!("{ASSIGNED_SOURCE_KIND}:{source_id}")
+}
+
+fn assigned_source_id(item_id: &str) -> Option<&str> {
+ item_id
+ .strip_prefix(ASSIGNED_SOURCE_KIND)
+ .and_then(|value| value.strip_prefix(':'))
+ .filter(|value| !value.is_empty())
+}
+
+fn now_ms() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_millis() as i64)
+ .unwrap_or(0)
+}
+
+fn db_error(error: rusqlite::Error) -> String {
+ format!("DB error: {error}")
+}
diff --git a/src-tauri/crates/project-management/src/team_inbox/tests.rs b/src-tauri/crates/project-management/src/team_inbox/tests.rs
new file mode 100644
index 0000000000..f8841e22aa
--- /dev/null
+++ b/src-tauri/crates/project-management/src/team_inbox/tests.rs
@@ -0,0 +1,510 @@
+use rusqlite::Connection;
+use serde_json::json;
+
+use super::store::{
+ list_page_with_connection, mark_all_read_with_connection, mark_read_with_connection,
+ mark_unread_with_connection, unread_count_with_connection, work_item_summary_excerpt,
+};
+use super::{
+ schema::init_team_inbox_tables, TeamInboxActor, TeamInboxCursor, TeamInboxFilter,
+ TeamInboxItem, TeamInboxItemKind, TeamInboxListOptions, TeamInboxPayload, TeamInboxTarget,
+};
+use crate::projects::schema::init_project_tables;
+
+fn database() -> Connection {
+ let connection = Connection::open_in_memory().expect("open in-memory database");
+ connection
+ .execute_batch("PRAGMA foreign_keys = ON;")
+ .expect("enable foreign keys");
+ init_project_tables(&connection).expect("initialize project schema");
+ connection
+}
+
+fn insert_project(connection: &Connection, id: &str, slug: &str) {
+ connection
+ .execute(
+ "INSERT INTO projects
+ (id, name, slug, short_id_prefix, created_at, updated_at)
+ VALUES (?1, ?2, ?3, 'TST', 1, 1)",
+ (id, format!("Project {id}"), slug),
+ )
+ .expect("insert project");
+}
+
+struct WorkItemFixture<'a> {
+ id: &'a str,
+ short_id: &'a str,
+ title: &'a str,
+ project_id: Option<&'a str>,
+ assigned_human_id: Option<&'a str>,
+ assignee: Option<&'a str>,
+ assignee_type: Option<&'a str>,
+ updated_at: i64,
+ deleted_at: Option,
+}
+
+fn insert_work_item(connection: &Connection, item: WorkItemFixture<'_>) {
+ connection
+ .execute(
+ "INSERT INTO workitems
+ (id, org_id, project_id, short_id, title, status, priority,
+ assigned_human_id, assignee, assignee_type, created_at, updated_at, deleted_at)
+ VALUES (?1, 'personal-org', ?2, ?3, ?4, 'in_progress', 'high',
+ ?5, ?6, ?7, ?8, ?8, ?9)",
+ (
+ item.id,
+ item.project_id,
+ item.short_id,
+ item.title,
+ item.assigned_human_id,
+ item.assignee,
+ item.assignee_type,
+ item.updated_at,
+ item.deleted_at,
+ ),
+ )
+ .expect("insert work item");
+}
+
+fn options(viewers: &[&str], limit: usize) -> TeamInboxListOptions {
+ TeamInboxListOptions {
+ viewer_member_ids: viewers.iter().map(|value| (*value).to_string()).collect(),
+ filter: TeamInboxFilter::All,
+ cursor: None,
+ limit,
+ }
+}
+
+#[test]
+fn canonical_schema_creates_viewer_scoped_receipts_without_migration() {
+ let connection = Connection::open_in_memory().expect("open database");
+ init_team_inbox_tables(&connection).expect("initialize team inbox schema");
+ init_team_inbox_tables(&connection).expect("schema initialization is idempotent");
+
+ let columns = connection
+ .prepare("PRAGMA table_info(team_inbox_read_receipts)")
+ .expect("prepare columns")
+ .query_map([], |row| row.get::<_, String>(1))
+ .expect("query columns")
+ .collect::, _>>()
+ .expect("collect columns");
+ assert_eq!(
+ columns,
+ ["viewer_member_id", "source_kind", "source_id", "read_at"]
+ );
+}
+
+#[test]
+fn dto_contract_keeps_comment_mention_variant_stable() {
+ let item = TeamInboxItem {
+ id: "comment_mention:comment-1".into(),
+ kind: TeamInboxItemKind::CommentMention,
+ occurred_at: 42,
+ read_at: None,
+ actor: Some(TeamInboxActor {
+ id: "member-2".into(),
+ display_name: "Teammate".into(),
+ avatar_url: None,
+ }),
+ target: TeamInboxTarget::Comment {
+ session_id: "session-1".into(),
+ comment_id: "comment-1".into(),
+ anchor: Some("comment-comment-1".into()),
+ },
+ payload: TeamInboxPayload::CommentMention {
+ session_title: "Fix auth".into(),
+ comment_excerpt: "@me can you review?".into(),
+ comment_count: 3,
+ },
+ };
+
+ assert_eq!(
+ serde_json::to_value(item).expect("serialize DTO"),
+ json!({
+ "id": "comment_mention:comment-1",
+ "kind": "comment_mention",
+ "occurredAt": 42,
+ "actor": {"id": "member-2", "displayName": "Teammate"},
+ "target": {
+ "type": "comment",
+ "sessionId": "session-1",
+ "commentId": "comment-1",
+ "anchor": "comment-comment-1"
+ },
+ "payload": {
+ "type": "comment_mention",
+ "sessionTitle": "Fix auth",
+ "commentExcerpt": "@me can you review?",
+ "commentCount": 3
+ }
+ })
+ );
+}
+
+#[test]
+fn global_query_returns_only_local_items_assigned_to_explicit_viewers() {
+ let connection = database();
+ insert_project(&connection, "project-1", "alpha");
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id: "work-1",
+ short_id: "TST-1",
+ title: "Assigned by canonical human column",
+ project_id: Some("project-1"),
+ assigned_human_id: Some("member-a"),
+ assignee: None,
+ assignee_type: None,
+ updated_at: 30,
+ deleted_at: None,
+ },
+ );
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id: "work-2",
+ short_id: "TST-2",
+ title: "Standalone legacy member assignment",
+ project_id: None,
+ assigned_human_id: None,
+ assignee: Some("member-alias"),
+ assignee_type: Some("member"),
+ updated_at: 20,
+ deleted_at: None,
+ },
+ );
+ for (id, assignee, assignee_type, deleted_at) in [
+ ("work-agent", "member-a", Some("agent"), None),
+ ("work-other", "member-other", Some("member"), None),
+ ("work-deleted", "member-a", Some("member"), Some(99)),
+ ] {
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id,
+ short_id: id,
+ title: id,
+ project_id: Some("project-1"),
+ assigned_human_id: None,
+ assignee: Some(assignee),
+ assignee_type,
+ updated_at: 10,
+ deleted_at,
+ },
+ );
+ }
+
+ let page = list_page_with_connection(&connection, options(&["member-a", "member-alias"], 50))
+ .expect("list assigned items");
+ assert_eq!(
+ page.items
+ .iter()
+ .map(|item| item.id.as_str())
+ .collect::>(),
+ ["work_item_assigned:work-1", "work_item_assigned:work-2"]
+ );
+ assert_eq!(page.unread_count, 2);
+ assert!(matches!(
+ &page.items[0].target,
+ TeamInboxTarget::WorkItem {
+ project_slug: Some(slug),
+ ..
+ } if slug == "alpha"
+ ));
+ assert!(matches!(
+ &page.items[1].target,
+ TeamInboxTarget::WorkItem {
+ project_id: None,
+ project_slug: None,
+ ..
+ }
+ ));
+}
+
+#[test]
+fn cursor_is_stable_for_equal_timestamps_and_newer_insertions() {
+ let connection = database();
+ for id in ["work-c", "work-b", "work-a"] {
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id,
+ short_id: id,
+ title: id,
+ project_id: None,
+ assigned_human_id: Some("member-a"),
+ assignee: None,
+ assignee_type: None,
+ updated_at: 100,
+ deleted_at: None,
+ },
+ );
+ }
+ let first =
+ list_page_with_connection(&connection, options(&["member-a"], 2)).expect("first page");
+ assert_eq!(
+ first
+ .items
+ .iter()
+ .map(|item| item.id.as_str())
+ .collect::>(),
+ ["work_item_assigned:work-c", "work_item_assigned:work-b"]
+ );
+ assert_eq!(
+ first.next_cursor,
+ Some(TeamInboxCursor {
+ occurred_at: 100,
+ item_id: "work_item_assigned:work-b".into()
+ })
+ );
+
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id: "work-new",
+ short_id: "work-new",
+ title: "newer",
+ project_id: None,
+ assigned_human_id: Some("member-a"),
+ assignee: None,
+ assignee_type: None,
+ updated_at: 200,
+ deleted_at: None,
+ },
+ );
+ let second = list_page_with_connection(
+ &connection,
+ TeamInboxListOptions {
+ cursor: first.next_cursor,
+ ..options(&["member-a"], 2)
+ },
+ )
+ .expect("second page");
+ assert_eq!(
+ second
+ .items
+ .iter()
+ .map(|item| item.id.as_str())
+ .collect::>(),
+ ["work_item_assigned:work-a"]
+ );
+}
+
+#[test]
+fn read_receipts_and_bulk_read_are_viewer_scoped_and_idempotent() {
+ let mut connection = database();
+ for (id, assignee) in [("work-a", "member-a"), ("work-b", "member-b")] {
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id,
+ short_id: id,
+ title: id,
+ project_id: None,
+ assigned_human_id: Some(assignee),
+ assignee: None,
+ assignee_type: None,
+ updated_at: 100,
+ deleted_at: None,
+ },
+ );
+ }
+
+ assert!(mark_read_with_connection(
+ &mut connection,
+ &["member-a".into()],
+ "work_item_assigned:work-a",
+ 1000,
+ )
+ .expect("mark read"));
+ assert!(mark_read_with_connection(
+ &mut connection,
+ &["member-a".into()],
+ "work_item_assigned:work-a",
+ 900,
+ )
+ .expect("repeat mark read"));
+ let read_at: i64 = connection
+ .query_row(
+ "SELECT read_at FROM team_inbox_read_receipts
+ WHERE viewer_member_id = 'member-a' AND source_id = 'work-a'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read receipt");
+ assert_eq!(
+ read_at, 1000,
+ "older retries must not move read_at backward"
+ );
+ assert_eq!(
+ unread_count_with_connection(&connection, &["member-a".into()], TeamInboxFilter::Assigned)
+ .expect("member a unread"),
+ 0
+ );
+ assert_eq!(
+ unread_count_with_connection(&connection, &["member-b".into()], TeamInboxFilter::Assigned)
+ .expect("member b unread"),
+ 1
+ );
+
+ assert_eq!(
+ mark_all_read_with_connection(
+ &mut connection,
+ &["member-b".into()],
+ TeamInboxFilter::All,
+ 2000,
+ )
+ .expect("mark all"),
+ 1
+ );
+ assert_eq!(
+ mark_all_read_with_connection(
+ &mut connection,
+ &["member-b".into()],
+ TeamInboxFilter::All,
+ 2000,
+ )
+ .expect("repeat mark all"),
+ 0
+ );
+}
+
+#[test]
+fn mentions_filter_is_empty_for_local_work_item_source() {
+ let connection = database();
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id: "work-a",
+ short_id: "TST-1",
+ title: "Assigned",
+ project_id: None,
+ assigned_human_id: Some("member-a"),
+ assignee: None,
+ assignee_type: None,
+ updated_at: 10,
+ deleted_at: None,
+ },
+ );
+ let page = list_page_with_connection(
+ &connection,
+ TeamInboxListOptions {
+ filter: TeamInboxFilter::Mentions,
+ ..options(&["member-a"], 10)
+ },
+ )
+ .expect("list mentions");
+ assert!(page.items.is_empty());
+ assert_eq!(page.unread_count, 0);
+}
+
+#[test]
+fn explicit_viewer_ids_are_required() {
+ let connection = database();
+ let error = list_page_with_connection(&connection, options(&["", " "], 10))
+ .expect_err("empty viewer identities must fail");
+ assert!(error.contains("viewerMemberIds"));
+}
+
+#[test]
+fn mark_unread_clears_receipt_and_restores_unread_count() {
+ let mut connection = database();
+ insert_work_item(
+ &connection,
+ WorkItemFixture {
+ id: "work-a",
+ short_id: "TST-1",
+ title: "Assigned",
+ project_id: None,
+ assigned_human_id: Some("member-a"),
+ assignee: None,
+ assignee_type: None,
+ updated_at: 10,
+ deleted_at: None,
+ },
+ );
+
+ assert!(mark_read_with_connection(
+ &mut connection,
+ &["member-a".into()],
+ "work_item_assigned:work-a",
+ 1000,
+ )
+ .expect("mark read"));
+ assert_eq!(
+ unread_count_with_connection(&connection, &["member-a".into()], TeamInboxFilter::Assigned)
+ .expect("unread after read"),
+ 0
+ );
+
+ assert!(mark_unread_with_connection(
+ &mut connection,
+ &["member-a".into()],
+ "work_item_assigned:work-a",
+ )
+ .expect("mark unread"));
+ assert_eq!(
+ unread_count_with_connection(&connection, &["member-a".into()], TeamInboxFilter::Assigned)
+ .expect("unread after unread"),
+ 1
+ );
+
+ assert!(
+ !mark_unread_with_connection(
+ &mut connection,
+ &["member-a".into()],
+ "work_item_assigned:work-a",
+ )
+ .expect("repeat mark unread"),
+ "second mark-unread deletes nothing and reports no change"
+ );
+ assert_eq!(
+ unread_count_with_connection(&connection, &["member-a".into()], TeamInboxFilter::Assigned)
+ .expect("unread stays after idempotent unread"),
+ 1
+ );
+}
+
+#[test]
+fn summary_excerpt_folds_whitespace_and_trims() {
+ assert_eq!(
+ work_item_summary_excerpt(" Investigate the\n flaky auth test "),
+ Some("Investigate the flaky auth test".to_string())
+ );
+}
+
+#[test]
+fn summary_excerpt_is_none_for_blank_body() {
+ assert_eq!(work_item_summary_excerpt(""), None);
+ assert_eq!(work_item_summary_excerpt(" \n\t "), None);
+}
+
+#[test]
+fn summary_excerpt_truncates_long_body_on_char_boundary() {
+ let excerpt = work_item_summary_excerpt(&"x".repeat(300)).expect("non-empty excerpt");
+ assert_eq!(excerpt.chars().count(), 241);
+ assert!(excerpt.ends_with('…'));
+}
+
+#[test]
+fn assigned_item_carries_body_excerpt_as_summary() {
+ let connection = database();
+ connection
+ .execute(
+ "INSERT INTO workitems
+ (id, org_id, short_id, title, body, status, priority,
+ assigned_human_id, created_at, updated_at)
+ VALUES ('work-b', 'personal-org', 'TST-9', 'Body item',
+ ' Investigate the flaky auth test ',
+ 'in_progress', 'high', 'member-a', 5, 5)",
+ [],
+ )
+ .expect("insert work item with body");
+ let page =
+ list_page_with_connection(&connection, options(&["member-a"], 10)).expect("list page");
+ let summary = match &page.items[0].payload {
+ TeamInboxPayload::WorkItemAssigned { summary, .. } => summary.clone(),
+ other => panic!("expected assigned payload, got {other:?}"),
+ };
+ assert_eq!(summary.as_deref(), Some("Investigate the flaky auth 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
new file mode 100644
index 0000000000..97263b4aa4
--- /dev/null
+++ b/src-tauri/crates/project-management/src/team_inbox/types.rs
@@ -0,0 +1,108 @@
+use serde::{Deserialize, Serialize};
+
+/// Sources supported by the stable Team Inbox wire contract.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TeamInboxFilter {
+ 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 {
+ CommentMention,
+ WorkItemAssigned,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TeamInboxActor {
+ pub id: String,
+ pub display_name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub avatar_url: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(
+ tag = "type",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase"
+)]
+pub enum TeamInboxTarget {
+ Comment {
+ session_id: String,
+ comment_id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ anchor: Option,
+ },
+ WorkItem {
+ 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,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(
+ tag = "type",
+ rename_all = "snake_case",
+ rename_all_fields = "camelCase"
+)]
+pub enum TeamInboxPayload {
+ CommentMention {
+ session_title: String,
+ comment_excerpt: String,
+ comment_count: u32,
+ },
+ WorkItemAssigned {
+ title: String,
+ status: String,
+ priority: String,
+ assignee_member_id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ summary: Option,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TeamInboxItem {
+ pub id: String,
+ pub kind: TeamInboxItemKind,
+ pub occurred_at: i64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub read_at: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub actor: Option,
+ pub target: TeamInboxTarget,
+ pub payload: TeamInboxPayload,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TeamInboxCursor {
+ pub occurred_at: i64,
+ pub item_id: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TeamInboxPage {
+ pub items: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub next_cursor: Option,
+ pub unread_count: u64,
+}
diff --git a/src/api/realtime/websocket/schemas.ts b/src/api/realtime/websocket/schemas.ts
index 16d7693f47..2502df9ba4 100644
--- a/src/api/realtime/websocket/schemas.ts
+++ b/src/api/realtime/websocket/schemas.ts
@@ -317,6 +317,7 @@ export const WSMessageSchema = z.discriminatedUnion("type", [
]);
export const CODE_EDITOR_WEB_SOCKET_EVENT_TYPES = [
+ "repo:changed",
"repo:status_updated",
"file:changed",
"repo:git_operation",
diff --git a/src/engines/ChatPanel/ChatPanelTabBar.tsx b/src/engines/ChatPanel/ChatPanelTabBar.tsx
index 86bfc61afb..7dbe24c5c3 100644
--- a/src/engines/ChatPanel/ChatPanelTabBar.tsx
+++ b/src/engines/ChatPanel/ChatPanelTabBar.tsx
@@ -42,6 +42,7 @@ import {
Columns3,
Gauge,
GitPullRequest,
+ Inbox,
Info,
LayoutGrid,
MessageSquarePlus,
@@ -185,6 +186,7 @@ const TabPill = memo(function TabPill({
launchpad: t("navigation:routes.launchpad"),
runtime: t("sessions:chat.startPage.tabs.runtime"),
organization: t("navigation:collaboration.manageOrg"),
+ teamInbox: t("navigation:labels.teamInbox", "Team Inbox"),
workManagement: {
kanban: t("sessions:simulator.tabs.kanban"),
projects: t("navigation:labels.projects"),
@@ -226,6 +228,14 @@ const TabPill = memo(function TabPill({
className={`shrink-0 ${iconColorClass}`}
/>
);
+ } else if (tab.type === "team-inbox") {
+ icon = (
+
+ );
} else if (tab.type === "workspace") {
icon = (
import("../panels/WorkspaceExplorePanelView")
);
const RuntimePanelView = React.lazy(() => import("../panels/RuntimePanelView"));
+const TeamInboxView = React.lazy(
+ () => import("@src/modules/MainApp/TeamInbox")
+);
export interface ChatPanelSurfaceRendererProps {
tab: ChatPanelTab;
@@ -118,6 +121,14 @@ export function ExploreSurfaceRenderer(): React.ReactNode {
);
}
+export function TeamInboxSurfaceRenderer(): React.ReactNode {
+ return (
+
+
+
+ );
+}
+
export function RuntimeSurfaceRenderer(): React.ReactNode {
return (
diff --git a/src/engines/ChatPanel/chatPanelTabDisplay.test.ts b/src/engines/ChatPanel/chatPanelTabDisplay.test.ts
index aa48860a38..6f21de93aa 100644
--- a/src/engines/ChatPanel/chatPanelTabDisplay.test.ts
+++ b/src/engines/ChatPanel/chatPanelTabDisplay.test.ts
@@ -13,6 +13,7 @@ const labels: ChatPanelTabDisplayLabels = {
launchpad: "Launchpad",
runtime: "Runtime",
organization: "Manage ORG",
+ teamInbox: "Team Inbox",
workManagement: {
kanban: "Kanban",
projects: "Projects",
@@ -37,6 +38,12 @@ describe("resolveChatPanelTabDisplayTitle", () => {
);
});
+ it("uses the localized Team Inbox title", () => {
+ expect(
+ resolveChatPanelTabDisplayTitle(tab("team-inbox"), null, labels)
+ ).toBe("Team Inbox");
+ });
+
it("uses the active management destination as the localized tab title", () => {
expect(
resolveChatPanelTabDisplayTitle(tab("work-management"), null, labels)
diff --git a/src/engines/ChatPanel/chatPanelTabDisplay.ts b/src/engines/ChatPanel/chatPanelTabDisplay.ts
index 6d0ce73ca7..ec9df8ad4a 100644
--- a/src/engines/ChatPanel/chatPanelTabDisplay.ts
+++ b/src/engines/ChatPanel/chatPanelTabDisplay.ts
@@ -8,6 +8,7 @@ export interface ChatPanelTabDisplayLabels {
launchpad: string;
runtime: string;
organization: string;
+ teamInbox: string;
workManagement: {
kanban: string;
projects: string;
@@ -45,6 +46,8 @@ export function resolveChatPanelTabDisplayTitle(
return labels.launchpad;
case "runtime":
return labels.runtime;
+ case "team-inbox":
+ return labels.teamInbox;
case "work-management":
return resolveWorkManagementTabTitle(tab, labels.workManagement);
case "session": {
diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
new file mode 100644
index 0000000000..0618277eb8
--- /dev/null
+++ b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ZodError } from "zod/v4";
+
+import {
+ ORG2_CLOUD_OFFICIAL_ANON_KEY,
+ ORG2_CLOUD_OFFICIAL_SUPABASE_URL,
+ ORG2_CLOUD_POSTGREST_SCHEMA,
+} from "./config";
+import { Org2CloudCommentError } from "./org2CloudCommentsClient";
+import { listTeamInboxMentions } from "./teamInboxMentionsClient";
+
+const fetchMock = vi.fn();
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+function lastCall(): { url: string; init: RequestInit } {
+ const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
+ return { url, init };
+}
+
+function lastBody(): Record {
+ return JSON.parse(String(lastCall().init.body)) as Record;
+}
+
+const WIRE_MENTION = {
+ comment: { id: "comment-2", parentId: "comment-1" },
+ session: { id: "session-1", title: "Fix Team Inbox" },
+ author: { userId: "user-a", displayName: "Alice" },
+ body: "Please review this change",
+ createdAt: "2026-07-23T10:00:00.000Z",
+ commentCount: 4,
+ threadCount: 2,
+};
+
+beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ fetchMock.mockReset();
+});
+
+describe("listTeamInboxMentions", () => {
+ it("posts the managed-cloud wire contract without a viewer identity", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ mentions: [WIRE_MENTION], nextCursor: "cursor-2" })
+ );
+
+ await listTeamInboxMentions("jwt-viewer", "org-1", "cursor-1", 25);
+
+ const { url, init } = lastCall();
+ expect(url).toBe(
+ `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_list_team_inbox_mentions`
+ );
+ expect(init.method).toBe("POST");
+ expect(init.headers).toMatchObject({
+ apikey: ORG2_CLOUD_OFFICIAL_ANON_KEY,
+ authorization: "Bearer jwt-viewer",
+ "content-type": "application/json",
+ "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA,
+ });
+ expect(lastBody()).toEqual({
+ p_org_id: "org-1",
+ p_cursor: "cursor-1",
+ p_limit: 25,
+ });
+ expect(lastBody()).not.toHaveProperty("p_viewer_id");
+ expect(lastBody()).not.toHaveProperty("p_user_id");
+ });
+
+ it("sends a null cursor for the first page", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ mentions: [], nextCursor: null })
+ );
+
+ await listTeamInboxMentions("jwt-viewer", "org-1", null, 50);
+
+ expect(lastBody()).toEqual({
+ p_org_id: "org-1",
+ p_cursor: null,
+ p_limit: 50,
+ });
+ });
+
+ it("parses the stable mention response contract", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ mentions: [WIRE_MENTION], nextCursor: "cursor-2" })
+ );
+
+ const page = await listTeamInboxMentions("jwt-viewer", "org-1", null, 25);
+
+ expect(page).toEqual({
+ mentions: [WIRE_MENTION],
+ nextCursor: "cursor-2",
+ });
+ });
+
+ it("normalizes nullable optional fields and terminal cursor", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({
+ mentions: [
+ {
+ ...WIRE_MENTION,
+ comment: { id: "comment-2", parentId: null },
+ session: { id: "session-1", title: null },
+ author: { userId: "user-a", displayName: null },
+ },
+ ],
+ nextCursor: null,
+ })
+ );
+
+ const page = await listTeamInboxMentions("jwt-viewer", "org-1", null, 25);
+
+ expect(page.nextCursor).toBeUndefined();
+ expect(page.mentions[0]).toMatchObject({
+ comment: { id: "comment-2", parentId: undefined },
+ session: { id: "session-1", title: undefined },
+ author: { userId: "user-a", displayName: undefined },
+ });
+ });
+
+ it("rejects malformed response fields instead of leaking raw wire data", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({
+ mentions: [{ ...WIRE_MENTION, commentCount: -1 }],
+ nextCursor: null,
+ })
+ );
+
+ await expect(
+ listTeamInboxMentions("jwt-viewer", "org-1", null, 25)
+ ).rejects.toBeInstanceOf(ZodError);
+ });
+
+ it("validates pagination input before making a request", async () => {
+ await expect(
+ listTeamInboxMentions("jwt-viewer", "org-1", null, 0)
+ ).rejects.toBeInstanceOf(ZodError);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("throws the comments client RPC error without backend fallback", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ message: "ORG2_MEMBER_REQUIRED" }, 403)
+ );
+
+ const error = await listTeamInboxMentions(
+ "jwt-viewer",
+ "org-1",
+ null,
+ 25
+ ).catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(Org2CloudCommentError);
+ expect(error).toMatchObject({ code: "ORG2_MEMBER_REQUIRED", status: 403 });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.ts b/src/features/Org2Cloud/teamInboxMentionsClient.ts
new file mode 100644
index 0000000000..c298e0edc1
--- /dev/null
+++ b/src/features/Org2Cloud/teamInboxMentionsClient.ts
@@ -0,0 +1,101 @@
+import { z } from "zod/v4";
+
+import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config";
+import { Org2CloudCommentError } from "./org2CloudCommentsClient";
+
+const TEAM_INBOX_MENTIONS_RPC = "cloud_list_team_inbox_mentions";
+
+const TeamInboxMentionRequestSchema = z.object({
+ orgId: z.string().min(1),
+ cursor: z.string().min(1).nullable(),
+ limit: z.number().int().min(1).max(100),
+});
+
+const NullableStringSchema = z
+ .string()
+ .nullish()
+ .transform((value) => value ?? undefined)
+ .optional();
+
+const TeamInboxMentionSchema = z.object({
+ comment: z.object({
+ id: z.string(),
+ parentId: NullableStringSchema,
+ }),
+ session: z.object({
+ id: z.string(),
+ title: NullableStringSchema,
+ }),
+ author: z.object({
+ userId: z.string(),
+ displayName: NullableStringSchema,
+ }),
+ body: z.string(),
+ createdAt: z.string(),
+ commentCount: z.number().int().nonnegative(),
+ threadCount: z.number().int().nonnegative(),
+});
+
+const TeamInboxMentionsPageSchema = z.object({
+ mentions: z.array(TeamInboxMentionSchema).default([]),
+ nextCursor: NullableStringSchema,
+});
+
+export type TeamInboxMention = z.output;
+
+export interface TeamInboxMentionsPage {
+ mentions: TeamInboxMention[];
+ nextCursor?: string;
+}
+
+/**
+ * Lists managed-cloud comment mentions for the authenticated viewer.
+ *
+ * The viewer is derived by the RPC from the JWT bearer token. The client does
+ * not accept or send a viewer/user id, inspect comment bodies for mentions, or
+ * maintain a local projection of the result.
+ */
+export async function listTeamInboxMentions(
+ accessToken: string,
+ orgId: string,
+ cursor: string | null,
+ limit: number
+): Promise {
+ const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit });
+ const endpoint = getCloudEndpoint();
+ const response = await fetch(
+ `${endpoint.supabaseUrl}/rest/v1/rpc/${TEAM_INBOX_MENTIONS_RPC}`,
+ {
+ method: "POST",
+ headers: {
+ apikey: endpoint.anonKey,
+ authorization: `Bearer ${accessToken}`,
+ "content-type": "application/json",
+ "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA,
+ },
+ body: JSON.stringify({
+ p_org_id: input.orgId,
+ p_cursor: input.cursor,
+ p_limit: input.limit,
+ }),
+ }
+ );
+
+ const text = await response.text();
+ let payload: unknown = null;
+ try {
+ payload = text ? JSON.parse(text) : null;
+ } catch {
+ payload = null;
+ }
+
+ if (!response.ok) {
+ const message =
+ payload && typeof payload === "object" && "message" in payload
+ ? String((payload as { message: unknown }).message)
+ : `org2_cloud rpc ${TEAM_INBOX_MENTIONS_RPC} failed with ${response.status}`;
+ throw new Org2CloudCommentError(message, response.status);
+ }
+
+ return TeamInboxMentionsPageSchema.parse(payload);
+}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 391f3cc5bf..9f7edd9199 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -2449,6 +2449,100 @@
"noRepo": "No repo"
}
},
+ "teamInbox": {
+ "title": "Team Inbox",
+ "listLabel": "Team Inbox list",
+ "itemsLabel": "Team Inbox items",
+ "unreadCount": "{{count}} unread",
+ "allRead": "All caught up",
+ "loadMore": "Load more",
+ "filters": {
+ "all": "All",
+ "mentions": "Mentions",
+ "assigned": "Assigned"
+ },
+ "status": {
+ "read": "Read",
+ "unread": "Unread"
+ },
+ "row": {
+ "assignedSummary": "{{status}} · {{priority}}"
+ },
+ "search": {
+ "placeholder": "Search inbox",
+ "ariaLabel": "Search Team Inbox"
+ },
+ "groups": {
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "thisWeek": "This week",
+ "earlier": "Earlier"
+ },
+ "empty": {
+ "title": "Nothing here yet",
+ "subtitle": "Mentions and assigned work items will appear here.",
+ "selectTitle": "Select an item",
+ "selectSubtitle": "View its comment context or work item details.",
+ "mentions": {
+ "title": "No mentions",
+ "subtitle": "When a teammate @mentions you in a comment, it shows up here."
+ },
+ "assigned": {
+ "title": "Nothing assigned to you",
+ "subtitle": "Work items assigned to you will appear here."
+ },
+ "noResults": {
+ "title": "No matches",
+ "subtitle": "No items match “{{query}}”."
+ }
+ },
+ "loading": "Loading Team Inbox…",
+ "errors": {
+ "loadTitle": "Unable to load Team Inbox",
+ "load": "Unable to load Team Inbox",
+ "refresh": "Unable to refresh Team Inbox",
+ "markRead": "Unable to mark this item as read. Try again.",
+ "markUnread": "Unable to mark this item as unread. Try again.",
+ "markAllRead": "Unable to mark all items as read. Try again."
+ },
+ "detail": {
+ "assignedSubtitle": "Assigned work item",
+ "mentionSubtitle": "Mentioned in a comment",
+ "mentionedYou": "mentioned you"
+ },
+ "actions": {
+ "markRead": "Mark as read",
+ "markUnread": "Mark as unread",
+ "openWorkItem": "Open work item",
+ "openSession": "Open session"
+ },
+ "fields": {
+ "status": "Status",
+ "priority": "Priority",
+ "assignee": "Assignee",
+ "workItemId": "Work item ID",
+ "session": "Session",
+ "comments": "Comments",
+ "threadId": "Thread ID",
+ "commentId": "Comment ID"
+ },
+ "workItemStatus": {
+ "backlog": "Backlog",
+ "todo": "To do",
+ "in_progress": "In Progress",
+ "in_review": "In Review",
+ "blocked": "Blocked",
+ "done": "Done",
+ "cancelled": "Cancelled"
+ },
+ "priority": {
+ "none": "No priority",
+ "low": "Low",
+ "medium": "Medium",
+ "high": "High",
+ "urgent": "Urgent"
+ }
+ },
"globalToolbar": {
"selectWorkspaceToStart": "Select a workspace to start",
"selectRepoToStart": "Select a repo to start"
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
index dc5fa92e74..a3b70973f6 100644
--- a/src/i18n/locales/zh/common.json
+++ b/src/i18n/locales/zh/common.json
@@ -2329,6 +2329,100 @@
"noRepo": "无仓库"
}
},
+ "teamInbox": {
+ "title": "团队收件箱",
+ "listLabel": "团队收件箱列表",
+ "itemsLabel": "团队收件箱事项",
+ "unreadCount": "{{count}} 条未读",
+ "allRead": "已全部阅读",
+ "loadMore": "加载更多",
+ "filters": {
+ "all": "全部",
+ "mentions": "提及",
+ "assigned": "分配给我"
+ },
+ "status": {
+ "read": "已读",
+ "unread": "未读"
+ },
+ "row": {
+ "assignedSummary": "{{status}} · {{priority}}"
+ },
+ "search": {
+ "placeholder": "搜索收件箱",
+ "ariaLabel": "搜索团队收件箱"
+ },
+ "groups": {
+ "today": "今天",
+ "yesterday": "昨天",
+ "thisWeek": "本周",
+ "earlier": "更早"
+ },
+ "empty": {
+ "title": "暂无事项",
+ "subtitle": "新的提及和分配会显示在这里。",
+ "selectTitle": "选择一个事项",
+ "selectSubtitle": "查看评论上下文或工作项详情。",
+ "mentions": {
+ "title": "暂无提及",
+ "subtitle": "当同事在评论中 @ 你时,会显示在这里。"
+ },
+ "assigned": {
+ "title": "暂无分配给你的事项",
+ "subtitle": "分配给你的工作项会显示在这里。"
+ },
+ "noResults": {
+ "title": "无匹配结果",
+ "subtitle": "没有与「{{query}}」匹配的事项。"
+ }
+ },
+ "loading": "正在加载团队收件箱…",
+ "errors": {
+ "loadTitle": "无法加载团队收件箱",
+ "load": "无法加载团队收件箱",
+ "refresh": "无法刷新团队收件箱",
+ "markRead": "标记已读失败,请重试。",
+ "markUnread": "标记未读失败,请重试。",
+ "markAllRead": "全部标记已读失败,请重试。"
+ },
+ "detail": {
+ "assignedSubtitle": "分配给你的工作项",
+ "mentionSubtitle": "评论中提及了你",
+ "mentionedYou": "提及了你"
+ },
+ "actions": {
+ "markRead": "标记已读",
+ "markUnread": "标记未读",
+ "openWorkItem": "打开工作项",
+ "openSession": "打开会话"
+ },
+ "fields": {
+ "status": "状态",
+ "priority": "优先级",
+ "assignee": "负责人",
+ "workItemId": "工作项 ID",
+ "session": "会话",
+ "comments": "评论数",
+ "threadId": "话题 ID",
+ "commentId": "评论 ID"
+ },
+ "workItemStatus": {
+ "backlog": "待办池",
+ "todo": "待办",
+ "in_progress": "进行中",
+ "in_review": "审核中",
+ "blocked": "受阻",
+ "done": "已完成",
+ "cancelled": "已取消"
+ },
+ "priority": {
+ "none": "无优先级",
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "urgent": "紧急"
+ }
+ },
"globalToolbar": {
"selectWorkspaceToStart": "选择一个工作区以开始",
"selectRepoToStart": "选择一个 Repo 开始"
diff --git a/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
new file mode 100644
index 0000000000..e92b62f971
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+
+import TeamInboxView from "./TeamInboxView";
+import { useTeamInboxDataSource } from "./useTeamInboxDataSource";
+import { useTeamInboxNavigation } from "./useTeamInboxNavigation";
+
+const ConnectedTeamInboxView: React.FC = () => {
+ const { dataSource } = useTeamInboxDataSource();
+ const navigate = useTeamInboxNavigation();
+ return ;
+};
+
+export default ConnectedTeamInboxView;
diff --git a/src/modules/MainApp/TeamInbox/TEST_CASES.md b/src/modules/MainApp/TeamInbox/TEST_CASES.md
new file mode 100644
index 0000000000..79dbd57c5d
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/TEST_CASES.md
@@ -0,0 +1,52 @@
+# Team Inbox acceptance cases
+
+## Automated
+
+- The Sidebar pinned menu renders Team Inbox immediately below Runtime.
+- Opening Team Inbox twice focuses the same singleton Chat Panel tab.
+- `all`, `mentions`, and `assigned` filters operate on one discriminated item model.
+- Mixed items are deduplicated and sorted by `occurredAt`, then stable item identity.
+- Local assigned Work Items require explicit current-user member IDs.
+- Local cursor pagination is stable when timestamps tie and when newer rows arrive.
+- Single and bulk read receipts are viewer-scoped and idempotent.
+- Managed-cloud mention responses are Zod-validated and never accept a caller-supplied viewer ID.
+- Raw work-item status/priority enum tokens are humanized (`humanizeToken`) when no localized key exists, and never leak to the row or detail.
+- Per-filter unread counts (`countUnreadTeamInboxItemsByFilter`) de-duplicate before counting and back the filter-tab badges.
+- `filterItemKind` maps `all → null`, `mentions → comment_mention`, `assigned → assigned_work_item`.
+- `searchTeamInboxItems` is case-insensitive, matches title/body/summary/people, returns a fresh copy for empty queries, and empty for no match.
+- `groupTeamInboxItemsByRecency` buckets by local calendar day (Today/Yesterday/This week/Earlier), omits empty groups, keeps input order, and files unparseable timestamps under "earlier".
+- Assigned items carry a trimmed, whitespace-folded, 240-char body excerpt as `summary`; blank bodies omit the field (`work_item_summary_excerpt`).
+- `mark_unread` deletes the viewer-scoped receipt so the item returns to unread, and is idempotent (a second call changes nothing); `removeTeamInboxCloudReadReceipts` deletes cloud receipt keys and returns the same reference when nothing changes.
+- `toWireCursorItemId` preserves the backend `work_item_assigned:` source prefix (strips only the UI `assigned_work_item:` kind prefix) so `Load more` cursor pagination round-trips instead of erroring.
+
+## Presentation / polish
+
+1. Filter tabs (`All` / `Mentions` / `Assigned`) show a primary count badge only when that surface has unread items; badge clamps to `99+`.
+2. Unread rows render a leading primary dot and bold title; read rows drop the dot and use medium weight.
+3. Assigned rows show the resolved assignee **name** (not the raw member id) and a `status · priority` summary using localized labels.
+4. Assigned detail shows localized `Status` and `Priority` rows and no misleading `Assigned by` row when no assigner is known.
+5. `Mark all as read` in the header marks **only the active filter's** unread items (Mentions view never marks Assigned, and vice versa).
+6. Empty state copy is filter-specific (`No mentions` vs `Nothing assigned to you`), falling back to the generic empty copy for `All`.
+7. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list.
+8. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries.
+9. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes.
+10. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the local receipt). Re-marking read still works.
+11. When a source still has a next page, the list shows a `Load more` control; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more.
+
+## Rendered product path
+
+1. Seed or create a project member that matches the current Git identity.
+2. Assign a Work Item to that member through the normal Work Item UI.
+3. Click the real `Team Inbox` Sidebar row (`data-testid=sidebar-team-inbox`).
+4. Verify the assigned item appears and `分配给我` keeps it visible.
+5. Open its detail, mark it read, and verify the row and Sidebar unread badge update together.
+6. Close and reopen Team Inbox; verify the durable local receipt remains read.
+7. In a managed cloud org whose backend exposes `cloud_list_team_inbox_mentions`, create a comment mention through the normal Session comments UI.
+8. Verify `@ 提及` shows the stable comment/session target and source navigation opens the Session.
+
+## Degraded states
+
+- No member identity: show an explicit identity error; do not guess from an agent/session ID.
+- Signed out or local scope: skip the cloud RPC and retain local assigned items.
+- Cloud mention RPC unavailable: retain local assigned items; do not scan every Session body as a fallback.
+- Empty result: show the Team Inbox empty state without starting a poller.
diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
new file mode 100644
index 0000000000..cec6bc6d29
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
@@ -0,0 +1,356 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout";
+import { Placeholder } from "@src/modules/shared/layouts/blocks";
+
+import {
+ AssignedWorkItemDetail,
+ CommentMentionDetail,
+ TeamInboxList,
+} from "./components";
+import {
+ type TeamInboxDataSource,
+ type TeamInboxFilter,
+ type TeamInboxItem,
+ type TeamInboxNavigationIntent,
+ countUnreadTeamInboxItems,
+ countUnreadTeamInboxItemsByFilter,
+ filterItemKind,
+ getTeamInboxItemKey,
+ searchTeamInboxItems,
+ selectTeamInboxItems,
+ toTeamInboxNavigationIntent,
+} from "./domain";
+
+export interface TeamInboxViewProps {
+ dataSource?: TeamInboxDataSource;
+ onNavigate?: (intent: TeamInboxNavigationIntent) => void;
+ initialFilter?: TeamInboxFilter;
+ pageSize?: number;
+}
+
+const EMPTY_TEAM_INBOX_DATA_SOURCE: TeamInboxDataSource = {
+ async listPage() {
+ return { items: [], nextCursor: null };
+ },
+};
+
+interface LoadState {
+ status: "loading" | "ready" | "error";
+ message: string | null;
+}
+
+const TeamInboxView: React.FC = ({
+ dataSource = EMPTY_TEAM_INBOX_DATA_SOURCE,
+ onNavigate,
+ initialFilter = "all",
+ pageSize = 50,
+}) => {
+ const { t } = useTranslation();
+ const [filter, setFilter] = useState(initialFilter);
+ const [query, setQuery] = useState("");
+ const [items, setItems] = useState([]);
+ const [recencyAnchorMs, setRecencyAnchorMs] = useState(() => Date.now());
+ const [requestedItemId, setRequestedItemId] = useState(null);
+ const [loadState, setLoadState] = useState({
+ status: "loading",
+ message: null,
+ });
+ const [reloadRevision, setReloadRevision] = useState(0);
+ const [hasMore, setHasMore] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+
+ useEffect(() => {
+ const abortController = new AbortController();
+
+ void dataSource
+ .listPage({ limit: pageSize, signal: abortController.signal })
+ .then((page) => {
+ if (abortController.signal.aborted) return;
+ setItems(page.items);
+ setRecencyAnchorMs(Date.now());
+ setHasMore(page.nextCursor != null);
+ setLoadState({ status: "ready", message: null });
+ })
+ .catch((reason: unknown) => {
+ if (abortController.signal.aborted) return;
+ setLoadState({
+ status: "error",
+ message:
+ reason instanceof Error
+ ? reason.message
+ : t("teamInbox.errors.load"),
+ });
+ });
+
+ return () => abortController.abort();
+ }, [dataSource, pageSize, reloadRevision, t]);
+
+ useEffect(() => {
+ if (!dataSource.subscribe) return;
+ return dataSource.subscribe(() => {
+ setReloadRevision((value) => value + 1);
+ });
+ }, [dataSource]);
+
+ const visibleItems = useMemo(
+ () => searchTeamInboxItems(selectTeamInboxItems(items, filter), query),
+ [filter, items, query]
+ );
+ const totalUnread = useMemo(() => countUnreadTeamInboxItems(items), [items]);
+ const unreadCounts = useMemo(
+ () => countUnreadTeamInboxItemsByFilter(items),
+ [items]
+ );
+ const selectedItem = useMemo(() => {
+ if (visibleItems.length === 0) return null;
+ return (
+ visibleItems.find(
+ (item) => getTeamInboxItemKey(item) === requestedItemId
+ ) ?? visibleItems[0]
+ );
+ }, [requestedItemId, visibleItems]);
+ const selectedItemId = selectedItem
+ ? getTeamInboxItemKey(selectedItem)
+ : null;
+
+ const retry = () => {
+ setLoadState({ status: "loading", message: null });
+ setReloadRevision((value) => value + 1);
+ };
+
+ const handleLoadMore = () => {
+ if (!dataSource.loadMore || loadingMore) return;
+ setLoadingMore(true);
+ void dataSource
+ .loadMore()
+ .catch((reason: unknown) => {
+ setLoadState({
+ status: "error",
+ message:
+ reason instanceof Error
+ ? reason.message
+ : t("teamInbox.errors.load"),
+ });
+ })
+ .finally(() => setLoadingMore(false));
+ };
+
+ const handleRefresh = () => {
+ setLoadState({ status: "loading", message: null });
+ if (!dataSource.refresh) {
+ setReloadRevision((value) => value + 1);
+ return;
+ }
+ void dataSource.refresh().catch((reason: unknown) => {
+ setLoadState({
+ status: "error",
+ message:
+ reason instanceof Error
+ ? reason.message
+ : t("teamInbox.errors.refresh"),
+ });
+ });
+ };
+
+ const markLocallyRead = (item: TeamInboxItem) => {
+ const readAt = new Date().toISOString();
+ setItems((current) =>
+ current.map((candidate) =>
+ getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item)
+ ? { ...candidate, readAt }
+ : candidate
+ )
+ );
+ };
+
+ const handleSelect = (item: TeamInboxItem) => {
+ setRequestedItemId(getTeamInboxItemKey(item));
+ if (item.readAt !== null) return;
+ markLocallyRead(item);
+ void dataSource.markRead?.(item).catch(() => {
+ setLoadState({
+ status: "error",
+ message: t("teamInbox.errors.markRead"),
+ });
+ });
+ };
+
+ const handleMarkRead = (item: TeamInboxItem) => {
+ if (item.readAt !== null) return;
+ markLocallyRead(item);
+ void dataSource.markRead?.(item).catch(() => {
+ setLoadState({
+ status: "error",
+ message: t("teamInbox.errors.markRead"),
+ });
+ });
+ };
+
+ const handleMarkUnread = (item: TeamInboxItem) => {
+ if (item.readAt === null) return;
+ setItems((current) =>
+ current.map((candidate) =>
+ getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item)
+ ? { ...candidate, readAt: null }
+ : candidate
+ )
+ );
+ void dataSource.markUnread?.(item).catch(() => {
+ setLoadState({
+ status: "error",
+ message: t("teamInbox.errors.markUnread"),
+ });
+ });
+ };
+
+ const handleMarkAllRead = () => {
+ const targetKind = filterItemKind(filter);
+ const unreadItems = items.filter(
+ (item) =>
+ item.readAt === null &&
+ (targetKind === null || item.kind === targetKind)
+ );
+ if (unreadItems.length === 0) return;
+ const readAt = new Date().toISOString();
+ const markedIds = new Set(unreadItems.map((item) => item.id));
+ setItems((current) =>
+ current.map((item) =>
+ markedIds.has(item.id) ? { ...item, readAt } : item
+ )
+ );
+ void dataSource.markAllRead?.(unreadItems).catch(() => {
+ setLoadState({
+ status: "error",
+ message: t("teamInbox.errors.markAllRead"),
+ });
+ });
+ };
+
+ const detail = (() => {
+ if (loadState.status === "loading") {
+ return (
+
+ );
+ }
+ if (loadState.status === "error" && items.length === 0) {
+ return (
+
+ );
+ }
+ if (!selectedItem) {
+ return (
+
+ );
+ }
+ if (selectedItem.kind === "comment_mention") {
+ return (
+ onNavigate(toTeamInboxNavigationIntent(selectedItem))
+ : undefined
+ }
+ />
+ );
+ }
+ return (
+ onNavigate(toTeamInboxNavigationIntent(selectedItem))
+ : undefined
+ }
+ />
+ );
+ })();
+
+ return (
+
+ {loadState.status === "error" && items.length > 0 ? (
+
+ {loadState.message}
+
+ ) : null}
+
+ ) : loadState.status === "error" && items.length === 0 ? (
+
+ ) : (
+
+ )
+ }
+ mainContent={detail}
+ />
+
+ );
+};
+
+export default TeamInboxView;
diff --git a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
new file mode 100644
index 0000000000..d5b7fceef8
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
@@ -0,0 +1,77 @@
+# Test Cases: TeamInbox "Load more" pagination (A1)
+
+Covers the load-more pagination feature wired through
+`useTeamInboxDataSource.loadMore` → `TeamInboxView` → `TeamInboxList`.
+Behavior is derived from the shipped implementation, not aspirational.
+
+## Preconditions
+
+- Team Inbox tab is open and the connected data source (`ConnectedTeamInboxView`)
+ is mounted, or the injectable `TeamInboxView` is rendered with a
+ `dataSource` implementing `listPage` (+ optional `loadMore`).
+- Local source page size is 50 (`listLocalTeamInboxPage(..., 50)`); cloud
+ mentions page size is 50 (`listTeamInboxMentions(..., 50)`).
+- `hasMore` is surfaced to the view via `listPage().nextCursor != null`; the
+ cursor value itself is an inert sentinel — the data source owns the real
+ per-source cursors (`localCursorRef` / `cloudCursorRef`).
+- The load-more control renders only inside the non-empty list branch, at the
+ bottom of the scroll area, when `hasMore === true` and `onLoadMore` is defined.
+
+## Happy Path
+
+| # | Steps | Expected Result |
+|---|-------|-----------------|
+| 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. |
+| 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. |
+| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both `localCursorRef` and `cloudCursorRef` are null, `hasMore` becomes false and the button disappears. |
+| 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). |
+| 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. |
+
+## Edge Cases
+
+| # | Scenario | Steps | Expected Result |
+|---|----------|-------|-----------------|
+| 1 | Empty inbox | Open inbox with 0 items. | Empty `Placeholder` renders; **no** "Load more" button (it lives in the items-present branch). |
+| 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. |
+| 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. |
+| 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). |
+| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | `loadingMoreRef` guard + `disabled={loadingMore}` ensure only one in-flight load; extra clicks are no-ops; no duplicated/skipped pages. |
+| 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. |
+| 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. |
+| 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). |
+| 9 | Refresh after paginating | Load more, then trigger refresh (manual or project-change signal). | Cursors reset to page 1; `hasMore` recomputed from page-1 cursors; list resets to first page. |
+
+## Error / Degraded States
+
+| # | Scenario | Steps | Expected Result |
+|---|----------|-------|-----------------|
+| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Caught inside `loadMore` (`.catch(() => ({ mentions: [], nextCursor: undefined }))`); cloud cursor becomes null (cloud pagination stops); local page still appends; no crash. |
+| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | `Promise.all` rejects → `handleLoadMore` catch sets the error banner (`teamInbox.errors.load`); `loadingMore` resets via `finally`; existing items remain. |
+| 3 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. |
+| 4 | Signed-out / no active cloud org | Only local paginates. | Cloud branch resolves to empty; only local advances; behavior identical to Edge #3. |
+
+## Accessibility
+
+- [ ] "Load more" uses the design-system `Button` (keyboard focusable, Enter/Space activate).
+- [ ] While loading, the button is `disabled` and shows `loading` state (no double submit).
+- [ ] The button has a visible localized label (`teamInbox.loadMore`, defaultValue "Load more") — no raw i18n key leaks.
+- [ ] Load-more does not steal focus from the list; existing roving-tabindex list navigation is unaffected.
+
+## Acceptance Criteria
+
+- [ ] Items beyond the first 50 per source are reachable (no silent truncation) via load-more.
+- [ ] `hasMore` accurately reflects "either source has a next page" and the button visibility follows it.
+- [ ] Appended pages are de-duplicated and correctly ordered by the view selectors.
+- [ ] Concurrent/rapid load-more is guarded (single in-flight request).
+- [ ] A cloud failure degrades gracefully (local still paginates); a local failure surfaces a non-blocking error banner without losing loaded items.
+- [ ] Unread badge semantics are unchanged by load-more (the single-source-of-truth question, A2, is intentionally out of scope here and documented in code).
+- [ ] `pnpm test` for `src/modules/MainApp/TeamInbox` passes; no new TypeScript/lint errors in edited files.
+
+## Notes / Known limitations
+
+- The unread badge does **not** count unread mentions that only appear on page 2+
+ (badge = local full-DB unread + first-page unread mentions). This matches the
+ pre-A1 direction and is deferred to the A2 "unread single source of truth" task.
+- Hook-level behavior is not unit-tested (repo policy forbids `.tsx` / React
+ Testing Library tests); pure logic is covered by `selectors.test.ts`
+ (dedupe/sort/select) and `store.test.ts`.
diff --git a/src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts b/src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts
new file mode 100644
index 0000000000..a789b612f5
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+
+import { toWireCursorItemId } from "../domain/cursor";
+
+describe("toWireCursorItemId", () => {
+ it("preserves the backend source prefix so the cursor round-trips", () => {
+ // The backend returns this as the cursor item id and strips the
+ // `work_item_assigned:` prefix itself; dropping it here would break paging.
+ expect(toWireCursorItemId("work_item_assigned:work-1")).toBe(
+ "work_item_assigned:work-1"
+ );
+ });
+
+ it("strips only the UI kind prefix when a UI item key is passed", () => {
+ expect(
+ toWireCursorItemId("assigned_work_item:work_item_assigned:work-1")
+ ).toBe("work_item_assigned:work-1");
+ });
+
+ it("leaves an unprefixed id untouched", () => {
+ expect(toWireCursorItemId("work-1")).toBe("work-1");
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
new file mode 100644
index 0000000000..fd71a24346
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ humanizeToken,
+ workItemPriorityLabelKey,
+ workItemStatusLabelKey,
+} from "../domain/labels";
+
+describe("humanizeToken", () => {
+ it("sentence-cases a snake_case enum token", () => {
+ expect(humanizeToken("in_progress")).toBe("In progress");
+ });
+
+ it("normalizes dashes and mixed casing", () => {
+ expect(humanizeToken("IN-REVIEW")).toBe("In review");
+ });
+
+ it("capitalizes a single word", () => {
+ expect(humanizeToken("high")).toBe("High");
+ });
+
+ it("collapses repeated separators and surrounding whitespace", () => {
+ expect(humanizeToken(" to__do ")).toBe("To do");
+ });
+
+ it("returns an empty string for empty or whitespace input", () => {
+ expect(humanizeToken("")).toBe("");
+ expect(humanizeToken(" ")).toBe("");
+ });
+});
+
+describe("label key builders", () => {
+ it("namespaces status keys under teamInbox.workItemStatus", () => {
+ expect(workItemStatusLabelKey("in_progress")).toBe(
+ "teamInbox.workItemStatus.in_progress"
+ );
+ });
+
+ it("namespaces priority keys under teamInbox.priority", () => {
+ expect(workItemPriorityLabelKey("high")).toBe("teamInbox.priority.high");
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts b/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts
new file mode 100644
index 0000000000..725e817756
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ countUnreadTeamInboxItems,
+ countUnreadTeamInboxItemsByFilter,
+ dedupeTeamInboxItems,
+ filterItemKind,
+ filterTeamInboxItems,
+ getTeamInboxItemKey,
+ groupTeamInboxItemsByRecency,
+ searchTeamInboxItems,
+ selectTeamInboxItems,
+ sortTeamInboxItems,
+ toTeamInboxNavigationIntent,
+} from "../domain/selectors";
+import type {
+ AssignedWorkItem,
+ CommentMentionItem,
+ TeamInboxItem,
+} from "../domain/types";
+
+const mention = (
+ overrides: Partial = {}
+): CommentMentionItem => ({
+ id: "comment-1",
+ kind: "comment_mention",
+ occurredAt: "2026-07-23T09:00:00.000Z",
+ readAt: null,
+ actor: { id: "member-1", displayName: "Ada" },
+ target: {
+ kind: "session_comment",
+ sessionId: "session-1",
+ sessionTitle: "Fix canvas preview",
+ commentId: "comment-1",
+ threadId: "thread-1",
+ anchor: "comment-comment-1",
+ },
+ payload: {
+ commentBody: "@you Can you review this?",
+ context: "The first pass is ready.",
+ commentCount: 3,
+ },
+ ...overrides,
+});
+
+const assignment = (
+ overrides: Partial = {}
+): AssignedWorkItem => ({
+ id: "work-item-1",
+ kind: "assigned_work_item",
+ occurredAt: "2026-07-23T10:00:00.000Z",
+ readAt: "2026-07-23T10:05:00.000Z",
+ actor: { id: "member-2", displayName: "Lin" },
+ target: {
+ kind: "work_item",
+ projectId: "project-1",
+ workItemId: "work-item-1",
+ },
+ payload: {
+ title: "Add Team Inbox",
+ status: "in_progress",
+ priority: "high",
+ assigneeMemberId: "member-2",
+ assigneeName: "You",
+ summary: "Build the reusable feature surface.",
+ updatedAt: "2026-07-23T10:00:00.000Z",
+ },
+ ...overrides,
+});
+
+describe("Team Inbox selectors", () => {
+ it("builds identity from kind and canonical id", () => {
+ expect(getTeamInboxItemKey(mention())).toBe("comment_mention:comment-1");
+ });
+
+ it("dedupes repeated pages and keeps the freshest copy", () => {
+ const oldCopy = mention({
+ occurredAt: "2026-07-23T08:00:00.000Z",
+ payload: {
+ commentBody: "old",
+ commentCount: 1,
+ },
+ });
+ const freshCopy = mention({
+ occurredAt: "2026-07-23T11:00:00.000Z",
+ payload: {
+ commentBody: "fresh",
+ commentCount: 2,
+ },
+ });
+
+ expect(dedupeTeamInboxItems([oldCopy, freshCopy])).toEqual([freshCopy]);
+ });
+
+ it("sorts newest first with a deterministic identity tie-breaker", () => {
+ const sameTime = "2026-07-23T10:00:00.000Z";
+ const items: TeamInboxItem[] = [
+ assignment({ id: "z", occurredAt: sameTime }),
+ mention({ id: "a", occurredAt: sameTime }),
+ mention({ id: "older", occurredAt: "2026-07-22T10:00:00.000Z" }),
+ ];
+
+ expect(sortTeamInboxItems(items).map(getTeamInboxItemKey)).toEqual([
+ "assigned_work_item:z",
+ "comment_mention:a",
+ "comment_mention:older",
+ ]);
+ });
+
+ it("filters mentions and assignments without mutating the input", () => {
+ const items = [mention(), assignment()];
+
+ expect(filterTeamInboxItems(items, "mentions")).toEqual([items[0]]);
+ expect(filterTeamInboxItems(items, "assigned")).toEqual([items[1]]);
+ expect(filterTeamInboxItems(items, "all")).not.toBe(items);
+ });
+
+ it("dedupes, sorts, then filters through the composed selector", () => {
+ const duplicate = mention({ readAt: "2026-07-23T09:10:00.000Z" });
+ expect(
+ selectTeamInboxItems([mention(), assignment(), duplicate], "all")
+ ).toEqual([assignment(), mention()]);
+ });
+
+ it("counts unread canonical items only once", () => {
+ expect(
+ countUnreadTeamInboxItems([mention(), mention(), assignment()])
+ ).toBe(1);
+ });
+
+ it("splits unread counts per filter and de-duplicates first", () => {
+ const unreadAssignment = assignment({ id: "unread", readAt: null });
+ expect(
+ countUnreadTeamInboxItemsByFilter([
+ mention(),
+ mention(),
+ assignment(),
+ unreadAssignment,
+ ])
+ ).toEqual({ all: 2, mentions: 1, assigned: 1 });
+ });
+
+ it("returns zeroed counts for an empty inbox", () => {
+ expect(countUnreadTeamInboxItemsByFilter([])).toEqual({
+ all: 0,
+ mentions: 0,
+ assigned: 0,
+ });
+ });
+
+ it("maps filters to the item kind they expose", () => {
+ expect(filterItemKind("all")).toBeNull();
+ expect(filterItemKind("mentions")).toBe("comment_mention");
+ expect(filterItemKind("assigned")).toBe("assigned_work_item");
+ });
+
+ it("maps both targets to typed navigation intents", () => {
+ expect(toTeamInboxNavigationIntent(mention())).toEqual({
+ kind: "open_session_comment",
+ sessionId: "session-1",
+ commentId: "comment-1",
+ threadId: "thread-1",
+ anchor: "comment-comment-1",
+ });
+ expect(toTeamInboxNavigationIntent(assignment())).toEqual({
+ kind: "open_work_item",
+ projectId: "project-1",
+ workItemId: "work-item-1",
+ });
+ });
+
+ it("returns a fresh copy of all items for an empty query", () => {
+ const items = [mention(), assignment()];
+ expect(searchTeamInboxItems(items, "")).toEqual(items);
+ expect(searchTeamInboxItems(items, " ")).toEqual(items);
+ expect(searchTeamInboxItems(items, "")).not.toBe(items);
+ });
+
+ it("matches case-insensitively across title, body, summary and people", () => {
+ const items = [mention(), assignment()];
+ expect(
+ searchTeamInboxItems(items, "CANVAS").map(getTeamInboxItemKey)
+ ).toEqual(["comment_mention:comment-1"]);
+ expect(
+ searchTeamInboxItems(items, "team inbox").map(getTeamInboxItemKey)
+ ).toEqual(["assigned_work_item:work-item-1"]);
+ expect(
+ searchTeamInboxItems(items, "review").map(getTeamInboxItemKey)
+ ).toEqual(["comment_mention:comment-1"]);
+ expect(
+ searchTeamInboxItems(items, "reusable feature").map(getTeamInboxItemKey)
+ ).toEqual(["assigned_work_item:work-item-1"]);
+ });
+
+ it("returns no items when nothing matches", () => {
+ expect(searchTeamInboxItems([mention(), assignment()], "zzzz")).toEqual([]);
+ });
+
+ it("buckets items into ordered recency groups relative to now", () => {
+ const now = Date.parse("2026-07-24T12:00:00.000Z");
+ const DAY = 86_400_000;
+ const at = (offsetMs: number) => new Date(now - offsetMs).toISOString();
+ const items = [
+ mention({ id: "today", occurredAt: at(0) }),
+ assignment({ id: "yesterday", occurredAt: at(DAY) }),
+ mention({ id: "week", occurredAt: at(3 * DAY) }),
+ assignment({ id: "old", occurredAt: at(30 * DAY) }),
+ mention({ id: "bad", occurredAt: "not-a-date" }),
+ ];
+
+ const groups = groupTeamInboxItemsByRecency(items, now);
+ expect(groups.map((group) => group.key)).toEqual([
+ "today",
+ "yesterday",
+ "thisWeek",
+ "earlier",
+ ]);
+ expect(groups[3]!.items.map((item) => item.id)).toEqual(["old", "bad"]);
+ });
+
+ it("omits empty recency groups and keeps input order within a group", () => {
+ const now = Date.parse("2026-07-24T12:00:00.000Z");
+ const at = (offsetMs: number) => new Date(now - offsetMs).toISOString();
+ const groups = groupTeamInboxItemsByRecency(
+ [
+ mention({ id: "a", occurredAt: at(0) }),
+ mention({ id: "b", occurredAt: at(1000) }),
+ ],
+ now
+ );
+ expect(groups.map((group) => group.key)).toEqual(["today"]);
+ expect(groups[0]!.items.map((item) => item.id)).toEqual(["a", "b"]);
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/store.test.ts b/src/modules/MainApp/TeamInbox/__tests__/store.test.ts
new file mode 100644
index 0000000000..05f66b60be
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/store.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS,
+ addTeamInboxCloudReadReceipts,
+ removeTeamInboxCloudReadReceipts,
+} from "../store";
+
+describe("addTeamInboxCloudReadReceipts", () => {
+ it("keeps the persisted receipt map bounded", () => {
+ const current = Object.fromEntries(
+ Array.from({ length: MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS }, (_, index) => [
+ `receipt-${index}`,
+ new Date(index).toISOString(),
+ ])
+ );
+
+ const next = addTeamInboxCloudReadReceipts(current, {
+ "receipt-new": "2026-07-23T12:00:00.000Z",
+ });
+
+ expect(Object.keys(next)).toHaveLength(MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS);
+ expect(next).not.toHaveProperty("receipt-0");
+ expect(next["receipt-new"]).toBe("2026-07-23T12:00:00.000Z");
+ });
+
+ it("refreshes an existing receipt without evicting an extra entry", () => {
+ const next = addTeamInboxCloudReadReceipts(
+ {
+ first: "2026-07-23T10:00:00.000Z",
+ second: "2026-07-23T11:00:00.000Z",
+ },
+ { first: "2026-07-23T12:00:00.000Z" }
+ );
+
+ expect(next).toEqual({
+ second: "2026-07-23T11:00:00.000Z",
+ first: "2026-07-23T12:00:00.000Z",
+ });
+ });
+});
+
+describe("removeTeamInboxCloudReadReceipts", () => {
+ it("deletes the given receipt keys", () => {
+ const next = removeTeamInboxCloudReadReceipts(
+ {
+ keep: "2026-07-23T10:00:00.000Z",
+ drop: "2026-07-23T11:00:00.000Z",
+ },
+ ["drop"]
+ );
+
+ expect(next).toEqual({ keep: "2026-07-23T10:00:00.000Z" });
+ });
+
+ it("returns the same reference when nothing changes", () => {
+ const current = { keep: "2026-07-23T10:00:00.000Z" };
+ expect(removeTeamInboxCloudReadReceipts(current, [])).toBe(current);
+ expect(removeTeamInboxCloudReadReceipts(current, ["missing"])).toBe(
+ current
+ );
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/api.ts b/src/modules/MainApp/TeamInbox/api.ts
new file mode 100644
index 0000000000..db1787615f
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/api.ts
@@ -0,0 +1,201 @@
+import { invoke } from "@tauri-apps/api/core";
+
+import { toWireCursorItemId } from "./domain";
+import type {
+ TeamInboxCursor,
+ TeamInboxFilter,
+ TeamInboxItem,
+ TeamInboxPage,
+} from "./domain";
+
+interface TeamInboxWireCursor {
+ occurredAt: number;
+ itemId: string;
+}
+
+interface TeamInboxWireActor {
+ id: string;
+ displayName: string;
+ avatarUrl?: string;
+}
+
+type TeamInboxWireTarget =
+ | {
+ type: "comment";
+ sessionId: string;
+ commentId: string;
+ anchor?: string;
+ }
+ | {
+ type: "work_item";
+ workItemId: string;
+ shortId: string;
+ orgId: string;
+ projectId?: string;
+ projectSlug?: string;
+ };
+
+type TeamInboxWirePayload =
+ | {
+ type: "comment_mention";
+ sessionTitle: string;
+ commentExcerpt: string;
+ commentCount: number;
+ }
+ | {
+ type: "work_item_assigned";
+ title: string;
+ status: string;
+ priority: string;
+ assigneeMemberId: string;
+ summary?: string;
+ };
+
+interface TeamInboxWireItem {
+ id: string;
+ kind: "comment_mention" | "work_item_assigned";
+ occurredAt: number;
+ readAt?: number;
+ actor?: TeamInboxWireActor;
+ target: TeamInboxWireTarget;
+ payload: TeamInboxWirePayload;
+}
+
+interface TeamInboxWirePage {
+ items: TeamInboxWireItem[];
+ nextCursor?: TeamInboxWireCursor;
+ unreadCount: number;
+}
+
+function toIso(timestamp: number | undefined): string | null {
+ return timestamp === undefined ? null : new Date(timestamp).toISOString();
+}
+
+function mapWireItem(item: TeamInboxWireItem): TeamInboxItem {
+ const occurredAt = new Date(item.occurredAt).toISOString();
+ const actor = item.actor ?? {
+ id: "system",
+ displayName: "Team Inbox",
+ };
+
+ if (
+ item.kind === "comment_mention" &&
+ item.target.type === "comment" &&
+ item.payload.type === "comment_mention"
+ ) {
+ return {
+ id: item.id,
+ kind: "comment_mention",
+ occurredAt,
+ readAt: toIso(item.readAt),
+ actor,
+ target: {
+ kind: "session_comment",
+ sessionId: item.target.sessionId,
+ sessionTitle: item.payload.sessionTitle,
+ commentId: item.target.commentId,
+ threadId: item.target.commentId,
+ anchor: item.target.anchor,
+ },
+ payload: {
+ commentBody: item.payload.commentExcerpt,
+ commentCount: item.payload.commentCount,
+ },
+ };
+ }
+
+ if (
+ item.kind === "work_item_assigned" &&
+ item.target.type === "work_item" &&
+ item.payload.type === "work_item_assigned"
+ ) {
+ return {
+ id: item.id,
+ kind: "assigned_work_item",
+ occurredAt,
+ readAt: toIso(item.readAt),
+ actor,
+ target: {
+ kind: "work_item",
+ projectId: item.target.projectSlug ?? item.target.projectId ?? "",
+ workItemId: item.target.shortId,
+ },
+ payload: {
+ title: item.payload.title,
+ status: item.payload.status,
+ priority: item.payload.priority,
+ assigneeMemberId: item.payload.assigneeMemberId,
+ summary: item.payload.summary,
+ updatedAt: occurredAt,
+ },
+ };
+ }
+
+ throw new Error(`Unsupported Team Inbox wire item: ${item.id}`);
+}
+
+function toWireCursor(
+ cursor?: TeamInboxCursor | null
+): TeamInboxWireCursor | null {
+ if (!cursor) return null;
+ return {
+ occurredAt: Date.parse(cursor.occurredAt),
+ itemId: toWireCursorItemId(cursor.itemKey),
+ };
+}
+
+export async function listLocalTeamInboxPage(
+ viewerMemberIds: readonly string[],
+ filter: TeamInboxFilter,
+ cursor?: TeamInboxCursor | null,
+ limit = 50
+): Promise<{ page: TeamInboxPage; unreadCount: number }> {
+ const wire = await invoke("team_inbox_list_page", {
+ viewerMemberIds: [...viewerMemberIds],
+ filter,
+ cursor: toWireCursor(cursor),
+ limit,
+ });
+ return {
+ page: {
+ items: wire.items.map(mapWireItem),
+ nextCursor: wire.nextCursor
+ ? {
+ occurredAt: new Date(wire.nextCursor.occurredAt).toISOString(),
+ itemKey: wire.nextCursor.itemId,
+ }
+ : null,
+ },
+ unreadCount: wire.unreadCount,
+ };
+}
+
+export async function markLocalTeamInboxItemRead(
+ viewerMemberIds: readonly string[],
+ itemId: string
+): Promise {
+ return invoke("team_inbox_mark_read", {
+ viewerMemberIds: [...viewerMemberIds],
+ itemId,
+ });
+}
+
+export async function markAllLocalTeamInboxRead(
+ viewerMemberIds: readonly string[],
+ filter: TeamInboxFilter
+): Promise {
+ return invoke("team_inbox_mark_all_read", {
+ viewerMemberIds: [...viewerMemberIds],
+ filter,
+ });
+}
+
+export async function markLocalTeamInboxItemUnread(
+ viewerMemberIds: readonly string[],
+ itemId: string
+): Promise {
+ return invoke("team_inbox_mark_unread", {
+ viewerMemberIds: [...viewerMemberIds],
+ itemId,
+ });
+}
diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
new file mode 100644
index 0000000000..ab80ecbe4d
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
@@ -0,0 +1,93 @@
+import { ClipboardList, ExternalLink } from "lucide-react";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+import Markdown from "@src/components/MarkDown";
+import { CARD_ROW_TOKENS } from "@src/modules/shared/layouts/blocks";
+
+import {
+ type AssignedWorkItem,
+ type TeamInboxNavigationIntent,
+ humanizeToken,
+ workItemPriorityLabelKey,
+ workItemStatusLabelKey,
+} from "../domain";
+import { useTeamInboxWorkItemBody } from "../useTeamInboxWorkItemBody";
+import TeamInboxDetailLayout from "./TeamInboxDetailLayout";
+
+export interface AssignedWorkItemDetailProps {
+ item: AssignedWorkItem;
+ onNavigate?: (intent: TeamInboxNavigationIntent) => void;
+ onMarkRead?: (item: AssignedWorkItem) => void;
+ onMarkUnread?: (item: AssignedWorkItem) => void;
+}
+
+const AssignedWorkItemDetail: React.FC = ({
+ item,
+ onNavigate,
+ onMarkRead,
+ onMarkUnread,
+}) => {
+ const { t } = useTranslation();
+ const { body } = useTeamInboxWorkItemBody(item.target);
+ const excerpt = item.payload.summary ?? null;
+ const statusLabel = t(workItemStatusLabelKey(item.payload.status), {
+ defaultValue: humanizeToken(item.payload.status),
+ });
+ const priorityLabel = t(workItemPriorityLabelKey(item.payload.priority), {
+ defaultValue: humanizeToken(item.payload.priority),
+ });
+
+ return (
+ }
+ onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined}
+ onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined}
+ onOpen={
+ onNavigate
+ ? () =>
+ onNavigate({
+ kind: "open_work_item",
+ projectId: item.target.projectId,
+ workItemId: item.target.workItemId,
+ })
+ : undefined
+ }
+ metadata={[
+ { label: t("teamInbox.fields.status"), value: statusLabel },
+ { label: t("teamInbox.fields.priority"), value: priorityLabel },
+ {
+ label: t("teamInbox.fields.assignee"),
+ value: item.payload.assigneeName ?? item.payload.assigneeMemberId,
+ },
+ {
+ label: t("teamInbox.fields.workItemId"),
+ value: item.target.workItemId,
+ },
+ ]}
+ >
+ {body ? (
+
+ ) : excerpt ? (
+
+ ) : null}
+
+ );
+};
+
+export default AssignedWorkItemDetail;
diff --git a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
new file mode 100644
index 0000000000..b3291abe75
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
@@ -0,0 +1,94 @@
+import { AtSign, MessageSquare } from "lucide-react";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+import Markdown from "@src/components/MarkDown";
+import { CARD_ROW_TOKENS } from "@src/modules/shared/layouts/blocks";
+
+import type { CommentMentionItem, TeamInboxNavigationIntent } from "../domain";
+import TeamInboxDetailLayout from "./TeamInboxDetailLayout";
+
+export interface CommentMentionDetailProps {
+ item: CommentMentionItem;
+ onNavigate?: (intent: TeamInboxNavigationIntent) => void;
+ onMarkRead?: (item: CommentMentionItem) => void;
+ onMarkUnread?: (item: CommentMentionItem) => void;
+}
+
+const CommentMentionDetail: React.FC = ({
+ item,
+ onNavigate,
+ onMarkRead,
+ onMarkUnread,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+ }
+ onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined}
+ onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined}
+ onOpen={
+ onNavigate
+ ? () =>
+ onNavigate({
+ kind: "open_session_comment",
+ sessionId: item.target.sessionId,
+ commentId: item.target.commentId,
+ threadId: item.target.threadId,
+ ...(item.target.anchor ? { anchor: item.target.anchor } : {}),
+ })
+ : undefined
+ }
+ metadata={[
+ {
+ label: t("teamInbox.fields.session"),
+ value: item.target.sessionTitle,
+ },
+ {
+ label: t("teamInbox.fields.comments"),
+ value: item.payload.commentCount,
+ },
+ {
+ label: t("teamInbox.fields.threadId"),
+ value: item.target.threadId,
+ },
+ {
+ label: t("teamInbox.fields.commentId"),
+ value: item.target.commentId,
+ },
+ ]}
+ >
+
+
+
+ {item.actor.displayName}
+
+ {t("teamInbox.detail.mentionedYou")}
+ {item.readAt === null ? (
+
+ {t("teamInbox.status.unread")}
+
+ ) : null}
+
+ {item.payload.context ? (
+
+ {item.payload.context}
+
+ ) : null}
+
+
+
+
+
+ );
+};
+
+export default CommentMentionDetail;
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
new file mode 100644
index 0000000000..8a8ff5713e
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
@@ -0,0 +1,100 @@
+import type { LucideIcon } from "lucide-react";
+import { Check, Undo2 } from "lucide-react";
+import React from "react";
+
+import Button from "@src/components/Button";
+import {
+ DETAIL_PANEL_TOKENS,
+ DetailPanelContainer,
+ InfoCard,
+ PanelFooter,
+ PanelHeader,
+} from "@src/modules/shared/layouts/blocks";
+import type { InfoCardRow } from "@src/modules/shared/layouts/blocks";
+
+export interface TeamInboxDetailLayoutProps {
+ title: string;
+ subtitle: string;
+ icon: LucideIcon;
+ metadata: InfoCardRow[];
+ unread: boolean;
+ markReadLabel: string;
+ markUnreadLabel?: string;
+ openLabel: string;
+ openIcon: React.ReactNode;
+ onMarkRead?: () => void;
+ onMarkUnread?: () => void;
+ onOpen?: () => void;
+ children?: React.ReactNode;
+}
+
+const TeamInboxDetailLayout: React.FC = ({
+ title,
+ subtitle,
+ icon,
+ metadata,
+ unread,
+ markReadLabel,
+ markUnreadLabel,
+ openLabel,
+ openIcon,
+ onMarkRead,
+ onMarkUnread,
+ onOpen,
+ children,
+}) => (
+
+ }
+ onClick={onMarkRead}
+ >
+ {markReadLabel}
+
+ ) : undefined
+ ) : onMarkUnread && markUnreadLabel ? (
+ }
+ onClick={onMarkUnread}
+ >
+ {markUnreadLabel}
+
+ ) : undefined
+ }
+ />
+
+
+
+ {children ? (
+
{children}
+ ) : null}
+
+
+
+
+ {onOpen ? (
+
+ ) : null}
+
+);
+
+export default TeamInboxDetailLayout;
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
new file mode 100644
index 0000000000..54d94cbb15
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
@@ -0,0 +1,311 @@
+import { AtSign, CheckCheck, ClipboardList, Inbox } from "lucide-react";
+import React, { useCallback, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+
+import Button from "@src/components/Button";
+import {
+ LIST_PANEL_SECTIONS,
+ LIST_PANEL_SECTION_HEADER,
+} from "@src/components/ListPanel";
+import SearchInput from "@src/components/SearchInput";
+import TabPill, { type TabPillItem } from "@src/components/TabPill";
+import {
+ ListPanelScrollArea,
+ ListPanelTabPillRow,
+ PANEL_HEADER_TOKENS,
+ PanelHeader,
+ PanelRefreshButton,
+ Placeholder,
+} from "@src/modules/shared/layouts/blocks";
+
+import {
+ type TeamInboxFilter,
+ type TeamInboxItem,
+ type TeamInboxUnreadCounts,
+ getTeamInboxItemKey,
+ groupTeamInboxItemsByRecency,
+} from "../domain";
+import TeamInboxRow from "./TeamInboxRow";
+
+export interface TeamInboxListProps {
+ filter: TeamInboxFilter;
+ items: readonly TeamInboxItem[];
+ recencyAnchorMs: number;
+ selectedItemId: string | null;
+ totalUnread: number;
+ unreadCounts: TeamInboxUnreadCounts;
+ query: string;
+ loading: boolean;
+ onQueryChange: (query: string) => void;
+ onFilterChange: (filter: TeamInboxFilter) => void;
+ onSelectItem: (item: TeamInboxItem) => void;
+ onRefresh?: () => void;
+ onMarkAllRead?: () => void;
+ hasMore?: boolean;
+ loadingMore?: boolean;
+ onLoadMore?: () => void;
+}
+
+function filterCountBadge(count: number, ariaLabel: string): React.ReactNode {
+ if (count <= 0) return undefined;
+ return (
+
+ {count > 99 ? "99+" : count}
+
+ );
+}
+
+const TeamInboxList: React.FC = ({
+ filter,
+ items,
+ recencyAnchorMs,
+ selectedItemId,
+ totalUnread,
+ unreadCounts,
+ query,
+ loading,
+ onQueryChange,
+ onFilterChange,
+ onSelectItem,
+ onRefresh,
+ onMarkAllRead,
+ hasMore = false,
+ loadingMore = false,
+ onLoadMore,
+}) => {
+ const { t } = useTranslation();
+ const hasQuery = query.trim().length > 0;
+ const rowRefs = useRef(new Map());
+ const selectedIndex = useMemo(
+ () =>
+ items.findIndex((item) => getTeamInboxItemKey(item) === selectedItemId),
+ [items, selectedItemId]
+ );
+ const groups = useMemo(
+ () => groupTeamInboxItemsByRecency(items, recencyAnchorMs),
+ [items, recencyAnchorMs]
+ );
+ const activeFilterUnread = unreadCounts[filter];
+ const filterTabs = useMemo(
+ () => [
+ {
+ key: "all",
+ label: t("teamInbox.filters.all"),
+ icon: ,
+ badge: filterCountBadge(
+ unreadCounts.all,
+ t("teamInbox.unreadCount", { count: unreadCounts.all })
+ ),
+ },
+ {
+ key: "mentions",
+ label: t("teamInbox.filters.mentions"),
+ icon: ,
+ badge: filterCountBadge(
+ unreadCounts.mentions,
+ t("teamInbox.unreadCount", { count: unreadCounts.mentions })
+ ),
+ },
+ {
+ key: "assigned",
+ label: t("teamInbox.filters.assigned"),
+ icon: ,
+ badge: filterCountBadge(
+ unreadCounts.assigned,
+ t("teamInbox.unreadCount", { count: unreadCounts.assigned })
+ ),
+ },
+ ],
+ [t, unreadCounts.all, unreadCounts.mentions, unreadCounts.assigned]
+ );
+
+ const selectAt = useCallback(
+ (index: number) => {
+ const item = items[index];
+ if (!item) return;
+ onSelectItem(item);
+ rowRefs.current.get(getTeamInboxItemKey(item))?.focus();
+ },
+ [items, onSelectItem]
+ );
+
+ const handleListKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (items.length === 0) return;
+ const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
+ let nextIndex: number | null = null;
+ switch (event.key) {
+ case "ArrowDown":
+ nextIndex = Math.min(currentIndex + 1, items.length - 1);
+ break;
+ case "ArrowUp":
+ nextIndex = Math.max(currentIndex - 1, 0);
+ break;
+ case "Home":
+ nextIndex = 0;
+ break;
+ case "End":
+ nextIndex = items.length - 1;
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ selectAt(nextIndex);
+ },
+ [items.length, selectAt, selectedIndex]
+ );
+
+ return (
+
+ 0
+ ? t("teamInbox.unreadCount", { count: totalUnread })
+ : t("teamInbox.allRead")
+ }
+ variant="list"
+ actions={
+ <>
+ {activeFilterUnread > 0 && onMarkAllRead ? (
+
+ }
+ title={t("inbox.markAllAsRead")}
+ aria-label={t("inbox.markAllAsRead")}
+ onClick={onMarkAllRead}
+ />
+ ) : null}
+ {onRefresh ? (
+
+ ) : null}
+ >
+ }
+ />
+
+
+ onFilterChange(key as TeamInboxFilter)}
+ variant="pill"
+ colorScheme="ghost"
+ size="mini"
+ fillWidth
+ />
+
+
+
+
+
+
+ {items.length === 0 ? (
+ hasQuery ? (
+
+ ) : (
+
+ )
+ ) : (
+
+
+ {groups.map((group) => {
+ const groupLabel = t(`teamInbox.groups.${group.key}`);
+ return (
+
+
+ {groupLabel}
+
+
+ {group.items.map((item) => {
+ const key = getTeamInboxItemKey(item);
+ return (
+ {
+ if (node) rowRefs.current.set(key, node);
+ else rowRefs.current.delete(key);
+ }}
+ item={item}
+ itemKey={key}
+ selected={key === selectedItemId}
+ onSelect={onSelectItem}
+ />
+ );
+ })}
+
+
+ );
+ })}
+
+ {hasMore && onLoadMore ? (
+
+
+ {t("teamInbox.loadMore", { defaultValue: "Load more" })}
+
+
+ ) : null}
+
+ )}
+
+ );
+};
+
+export default TeamInboxList;
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
new file mode 100644
index 0000000000..1903b8bb60
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
@@ -0,0 +1,100 @@
+import { AtSign, ClipboardList } from "lucide-react";
+import { forwardRef, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
+import { getListItemClasses } from "@src/components/ListPanel";
+import { formatRelativeTime } from "@src/util/time/formatRelativeTime";
+
+import {
+ type TeamInboxItem,
+ humanizeToken,
+ workItemPriorityLabelKey,
+ workItemStatusLabelKey,
+} from "../domain";
+
+export interface TeamInboxRowProps {
+ item: TeamInboxItem;
+ itemKey: string;
+ selected: boolean;
+ onSelect: (item: TeamInboxItem) => void;
+}
+
+const TeamInboxRow = forwardRef(
+ ({ item, itemKey, selected, onSelect }, ref) => {
+ const { t } = useTranslation();
+ const isMention = item.kind === "comment_mention";
+ const title = isMention ? item.target.sessionTitle : item.payload.title;
+ const summary = useMemo(() => {
+ if (item.kind === "comment_mention") return item.payload.commentBody;
+ if (item.payload.summary) return item.payload.summary;
+ const status = t(workItemStatusLabelKey(item.payload.status), {
+ defaultValue: humanizeToken(item.payload.status),
+ });
+ const priority = t(workItemPriorityLabelKey(item.payload.priority), {
+ defaultValue: humanizeToken(item.payload.priority),
+ });
+ return t("teamInbox.row.assignedSummary", { status, priority });
+ }, [item, t]);
+ const personName = isMention
+ ? item.actor.displayName
+ : (item.payload.assigneeName ?? item.payload.assigneeMemberId);
+ const relativeTime = useMemo(
+ () => formatRelativeTime(item.occurredAt, "nano"),
+ [item.occurredAt]
+ );
+ const unread = item.readAt === null;
+ const readLabel = t(
+ unread ? "teamInbox.status.unread" : "teamInbox.status.read"
+ );
+
+ return (
+ onSelect(item)}
+ >
+
+ {isMention ? : }
+
+
+
+ {unread ? (
+
+ ) : null}
+
+ {title}
+
+
+ {relativeTime}
+
+
+
+ {summary}
+
+
+ {personName}
+
+
+
+ );
+ }
+);
+
+TeamInboxRow.displayName = "TeamInboxRow";
+
+export default TeamInboxRow;
diff --git a/src/modules/MainApp/TeamInbox/components/index.ts b/src/modules/MainApp/TeamInbox/components/index.ts
new file mode 100644
index 0000000000..c495c4303f
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/index.ts
@@ -0,0 +1,10 @@
+export { default as AssignedWorkItemDetail } from "./AssignedWorkItemDetail";
+export type { AssignedWorkItemDetailProps } from "./AssignedWorkItemDetail";
+export { default as CommentMentionDetail } from "./CommentMentionDetail";
+export type { CommentMentionDetailProps } from "./CommentMentionDetail";
+export { default as TeamInboxDetailLayout } from "./TeamInboxDetailLayout";
+export type { TeamInboxDetailLayoutProps } from "./TeamInboxDetailLayout";
+export { default as TeamInboxList } from "./TeamInboxList";
+export type { TeamInboxListProps } from "./TeamInboxList";
+export { default as TeamInboxRow } from "./TeamInboxRow";
+export type { TeamInboxRowProps } from "./TeamInboxRow";
diff --git a/src/modules/MainApp/TeamInbox/domain/cursor.ts b/src/modules/MainApp/TeamInbox/domain/cursor.ts
new file mode 100644
index 0000000000..bc92b14ad9
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/cursor.ts
@@ -0,0 +1,13 @@
+/**
+ * Encodes a Team Inbox cursor item key into the backend cursor `itemId`.
+ *
+ * The local read model's cursor already carries the backend source id
+ * (`work_item_assigned:`), and the Rust `list_page` command strips
+ * that `work_item_assigned:` source prefix itself. Only the UI kind prefix
+ * (`assigned_work_item:`) — if a UI item key is passed by mistake — must be
+ * removed here. The `work_item_assigned:` source prefix MUST be preserved, or
+ * the backend rejects the cursor with "Unsupported Team Inbox cursor item id".
+ */
+export function toWireCursorItemId(itemKey: string): string {
+ return itemKey.replace(/^assigned_work_item:/, "");
+}
diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts
new file mode 100644
index 0000000000..6c1dd973ee
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/index.ts
@@ -0,0 +1,39 @@
+export {
+ countUnreadTeamInboxItems,
+ countUnreadTeamInboxItemsByFilter,
+ dedupeTeamInboxItems,
+ filterItemKind,
+ filterTeamInboxItems,
+ getTeamInboxItemKey,
+ groupTeamInboxItemsByRecency,
+ searchTeamInboxItems,
+ selectTeamInboxItems,
+ sortTeamInboxItems,
+ toTeamInboxNavigationIntent,
+} from "./selectors";
+export type {
+ TeamInboxRecencyGroup,
+ TeamInboxRecencyGroupKey,
+ TeamInboxUnreadCounts,
+} from "./selectors";
+export {
+ humanizeToken,
+ workItemPriorityLabelKey,
+ workItemStatusLabelKey,
+} from "./labels";
+export { toWireCursorItemId } from "./cursor";
+export type {
+ AssignedWorkItem,
+ CommentMentionItem,
+ ListTeamInboxInput,
+ SessionCommentTarget,
+ TeamInboxActor,
+ TeamInboxCursor,
+ TeamInboxDataSource,
+ TeamInboxFilter,
+ TeamInboxItem,
+ TeamInboxNavigationIntent,
+ TeamInboxPage,
+ TeamInboxTarget,
+ WorkItemTarget,
+} from "./types";
diff --git a/src/modules/MainApp/TeamInbox/domain/labels.ts b/src/modules/MainApp/TeamInbox/domain/labels.ts
new file mode 100644
index 0000000000..f49ddb3a80
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/labels.ts
@@ -0,0 +1,37 @@
+/**
+ * Turns a raw enum token from the work-item read model (e.g. `in_progress`,
+ * `HIGH`, `in-review`) into a human sentence-cased label (`In progress`,
+ * `High`, `In review`).
+ *
+ * This is the deterministic fallback for values that have no explicit localized
+ * key; callers pass the result as the i18next `defaultValue` so a translated
+ * label wins when present and raw enum strings never leak to the UI.
+ */
+export function humanizeToken(value: string): string {
+ const normalized = value.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
+ if (!normalized) return "";
+ const lower = normalized.toLowerCase();
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
+}
+
+/**
+ * i18n key for a work-item status/priority token, with a humanized default.
+ *
+ * Team Inbox deliberately owns the `teamInbox.workItemStatus.*` /
+ * `teamInbox.priority.*` namespaces instead of reusing ProjectManager's
+ * `workItems.statusLabels.*` / `workItems.priorityLabels.*`. The two label sets
+ * model *different* status vocabularies — Team Inbox surfaces read-model tokens
+ * like `todo` / `done` / `blocked`, while ProjectManager uses `planned` /
+ * `completed` and omits `blocked` — so pointing at the shared keys would drop
+ * those labels to the humanized fallback. Keeping the namespaces separate is
+ * intentional isolation, not accidental duplication; the humanized default keeps
+ * any unmapped token readable.
+ */
+export function workItemStatusLabelKey(status: string): string {
+ return `teamInbox.workItemStatus.${status}`;
+}
+
+/** i18n key for a work-item priority token, with a humanized default value. */
+export function workItemPriorityLabelKey(priority: string): string {
+ return `teamInbox.priority.${priority}`;
+}
diff --git a/src/modules/MainApp/TeamInbox/domain/selectors.ts b/src/modules/MainApp/TeamInbox/domain/selectors.ts
new file mode 100644
index 0000000000..1d517dc252
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/selectors.ts
@@ -0,0 +1,247 @@
+import {
+ type SessionDateBucket,
+ getSessionDateBucketRanges,
+} from "@src/util/session/sessionDateBuckets";
+
+import type {
+ TeamInboxFilter,
+ TeamInboxItem,
+ TeamInboxNavigationIntent,
+} from "./types";
+
+const INVALID_TIMESTAMP = Number.NEGATIVE_INFINITY;
+
+function timestamp(value: string): number {
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? INVALID_TIMESTAMP : parsed;
+}
+
+export function getTeamInboxItemKey(item: TeamInboxItem): string {
+ return `${item.kind}:${item.id}`;
+}
+
+/**
+ * De-duplicates pages by canonical item identity. When a later page contains a
+ * fresher copy of the same item, the fresher copy wins.
+ */
+export function dedupeTeamInboxItems(
+ items: readonly TeamInboxItem[]
+): TeamInboxItem[] {
+ const byKey = new Map();
+
+ for (const item of items) {
+ const key = getTeamInboxItemKey(item);
+ const current = byKey.get(key);
+ if (
+ !current ||
+ timestamp(item.occurredAt) > timestamp(current.occurredAt)
+ ) {
+ byKey.set(key, item);
+ }
+ }
+
+ return [...byKey.values()];
+}
+
+/** Newest first; identity is a deterministic tie-breaker for cursor stability. */
+export function sortTeamInboxItems(
+ items: readonly TeamInboxItem[]
+): TeamInboxItem[] {
+ return [...items].sort((left, right) => {
+ const timeDifference =
+ timestamp(right.occurredAt) - timestamp(left.occurredAt);
+ if (timeDifference !== 0) return timeDifference;
+ return getTeamInboxItemKey(left).localeCompare(getTeamInboxItemKey(right));
+ });
+}
+
+export function filterTeamInboxItems(
+ items: readonly TeamInboxItem[],
+ filter: TeamInboxFilter
+): TeamInboxItem[] {
+ if (filter === "all") return [...items];
+ const kind = filter === "mentions" ? "comment_mention" : "assigned_work_item";
+ return items.filter((item) => item.kind === kind);
+}
+
+export function selectTeamInboxItems(
+ items: readonly TeamInboxItem[],
+ filter: TeamInboxFilter
+): TeamInboxItem[] {
+ return filterTeamInboxItems(
+ sortTeamInboxItems(dedupeTeamInboxItems(items)),
+ filter
+ );
+}
+
+/** Fields searched for each item kind, so the free-text query stays discoverable. */
+function searchableText(item: TeamInboxItem): string[] {
+ if (item.kind === "comment_mention") {
+ return [
+ item.target.sessionTitle,
+ item.payload.commentBody,
+ item.payload.context ?? "",
+ item.actor.displayName,
+ ];
+ }
+ return [
+ item.payload.title,
+ item.payload.summary ?? "",
+ item.payload.assigneeName ?? item.payload.assigneeMemberId,
+ item.payload.status,
+ item.payload.priority,
+ item.actor.displayName,
+ ];
+}
+
+/**
+ * Case-insensitive free-text filter over the already-loaded items. An empty or
+ * whitespace-only query returns every item unchanged; otherwise an item is kept
+ * when any of its searchable fields contains the query.
+ */
+export function searchTeamInboxItems(
+ items: readonly TeamInboxItem[],
+ query: string
+): TeamInboxItem[] {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return [...items];
+ return items.filter((item) =>
+ searchableText(item).some((text) => text.toLowerCase().includes(needle))
+ );
+}
+
+export type TeamInboxRecencyGroupKey =
+ | "today"
+ | "yesterday"
+ | "thisWeek"
+ | "earlier";
+
+export interface TeamInboxRecencyGroup {
+ key: TeamInboxRecencyGroupKey;
+ items: TeamInboxItem[];
+}
+
+const RECENCY_GROUP_ORDER: TeamInboxRecencyGroupKey[] = [
+ "today",
+ "yesterday",
+ "thisWeek",
+ "earlier",
+];
+
+/**
+ * Maps the shared session date-bucket keys onto the Team Inbox recency keys, so
+ * both surfaces derive day boundaries from one source of truth
+ * (`getSessionDateBucketRanges`). Only the presentation key name differs
+ * ("earlier" here vs "older" in the shared helper).
+ */
+const SESSION_BUCKET_TO_RECENCY: Record<
+ SessionDateBucket,
+ TeamInboxRecencyGroupKey
+> = {
+ today: "today",
+ yesterday: "yesterday",
+ thisWeek: "thisWeek",
+ older: "earlier",
+};
+
+/**
+ * Buckets already-ordered items into recency sections relative to `nowMs`
+ * (Today / Yesterday / This week / Earlier). Day boundaries are reused from the
+ * shared `getSessionDateBucketRanges` helper so the "this week" window stays
+ * consistent with the rest of the app. Empty groups are omitted and group order
+ * is stable; unparseable timestamps fall into "earlier".
+ */
+export function groupTeamInboxItemsByRecency(
+ items: readonly TeamInboxItem[],
+ nowMs: number
+): TeamInboxRecencyGroup[] {
+ const ranges = getSessionDateBucketRanges(new Date(nowMs));
+
+ const buckets: Record = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ earlier: [],
+ };
+
+ for (const item of items) {
+ const occurred = Date.parse(item.occurredAt);
+ let key: TeamInboxRecencyGroupKey = "earlier";
+ if (!Number.isNaN(occurred)) {
+ const match = ranges.find(
+ ({ startMs, endMs }) =>
+ (startMs === undefined || occurred >= startMs) &&
+ (endMs === undefined || occurred < endMs)
+ );
+ if (match) key = SESSION_BUCKET_TO_RECENCY[match.bucket];
+ }
+ buckets[key].push(item);
+ }
+
+ return RECENCY_GROUP_ORDER.filter((key) => buckets[key].length > 0).map(
+ (key) => ({ key, items: buckets[key] })
+ );
+}
+
+export function countUnreadTeamInboxItems(
+ items: readonly TeamInboxItem[]
+): number {
+ return dedupeTeamInboxItems(items).reduce(
+ (count, item) => count + (item.readAt === null ? 1 : 0),
+ 0
+ );
+}
+
+export interface TeamInboxUnreadCounts {
+ all: number;
+ mentions: number;
+ assigned: number;
+}
+
+/**
+ * Unread totals split by the surfaces the filter tabs expose. Canonical items
+ * are de-duplicated first so a duplicated page never double-counts a badge.
+ */
+export function countUnreadTeamInboxItemsByFilter(
+ items: readonly TeamInboxItem[]
+): TeamInboxUnreadCounts {
+ return dedupeTeamInboxItems(items).reduce(
+ (counts, item) => {
+ if (item.readAt !== null) return counts;
+ counts.all += 1;
+ if (item.kind === "comment_mention") counts.mentions += 1;
+ else counts.assigned += 1;
+ return counts;
+ },
+ { all: 0, mentions: 0, assigned: 0 }
+ );
+}
+
+/** Maps a filter tab to the item kind it exposes, or null for the combined view. */
+export function filterItemKind(
+ filter: TeamInboxFilter
+): TeamInboxItem["kind"] | null {
+ if (filter === "mentions") return "comment_mention";
+ if (filter === "assigned") return "assigned_work_item";
+ return null;
+}
+
+export function toTeamInboxNavigationIntent(
+ item: TeamInboxItem
+): TeamInboxNavigationIntent {
+ if (item.target.kind === "session_comment") {
+ return {
+ kind: "open_session_comment",
+ sessionId: item.target.sessionId,
+ commentId: item.target.commentId,
+ threadId: item.target.threadId,
+ ...(item.target.anchor ? { anchor: item.target.anchor } : {}),
+ };
+ }
+
+ return {
+ kind: "open_work_item",
+ projectId: item.target.projectId,
+ workItemId: item.target.workItemId,
+ };
+}
diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts
new file mode 100644
index 0000000000..7da20f9cc8
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/types.ts
@@ -0,0 +1,109 @@
+export type TeamInboxFilter = "all" | "mentions" | "assigned";
+
+export interface TeamInboxActor {
+ id: string;
+ displayName: string;
+ avatarUrl?: string;
+}
+
+export interface SessionCommentTarget {
+ kind: "session_comment";
+ sessionId: string;
+ sessionTitle: string;
+ commentId: string;
+ threadId: string;
+ anchor?: string;
+}
+
+export interface WorkItemTarget {
+ kind: "work_item";
+ projectId: string;
+ workItemId: string;
+}
+
+export type TeamInboxTarget = SessionCommentTarget | WorkItemTarget;
+
+interface TeamInboxItemBase {
+ id: string;
+ occurredAt: string;
+ readAt: string | null;
+ actor: TeamInboxActor;
+}
+
+export interface CommentMentionItem extends TeamInboxItemBase {
+ kind: "comment_mention";
+ target: SessionCommentTarget;
+ payload: {
+ commentBody: string;
+ context?: string;
+ commentCount: number;
+ };
+}
+
+export interface AssignedWorkItem extends TeamInboxItemBase {
+ kind: "assigned_work_item";
+ target: WorkItemTarget;
+ payload: {
+ title: string;
+ status: string;
+ priority: string;
+ /** Raw member id from the read model; the stable assignee identity. */
+ assigneeMemberId: string;
+ /** Display name resolved from project members; absent until resolved. */
+ assigneeName?: string;
+ summary?: string;
+ updatedAt: string;
+ };
+}
+
+export type TeamInboxItem = CommentMentionItem | AssignedWorkItem;
+
+export interface TeamInboxCursor {
+ occurredAt: string;
+ itemKey: string;
+}
+
+export interface TeamInboxPage {
+ items: TeamInboxItem[];
+ nextCursor: TeamInboxCursor | null;
+}
+
+export interface ListTeamInboxInput {
+ cursor?: TeamInboxCursor | null;
+ limit?: number;
+ signal?: AbortSignal;
+}
+
+/**
+ * Transport-independent Team Inbox boundary.
+ *
+ * The feature owns presentation and local selection only. Its host supplies an
+ * implementation backed by the canonical comment/work-item read model.
+ */
+export interface TeamInboxDataSource {
+ listPage(input: ListTeamInboxInput): Promise;
+ markRead?(item: TeamInboxItem): Promise;
+ markUnread?(item: TeamInboxItem): Promise;
+ markAllRead?(items: readonly TeamInboxItem[]): Promise;
+ refresh?(): Promise;
+ /**
+ * Loads the next page from every source that still has one and appends the
+ * results to the current page. A no-op when nothing more is available.
+ */
+ loadMore?(): Promise;
+ subscribe?(listener: () => void): () => void;
+}
+
+export type TeamInboxNavigationIntent =
+ | {
+ kind: "open_session_comment";
+ sessionId: string;
+ commentId: string;
+ threadId: string;
+ anchor?: string;
+ }
+ | {
+ kind: "open_work_item";
+ projectId: string;
+ workItemId: string;
+ };
diff --git a/src/modules/MainApp/TeamInbox/index.ts b/src/modules/MainApp/TeamInbox/index.ts
new file mode 100644
index 0000000000..afc88e3cd5
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/index.ts
@@ -0,0 +1,7 @@
+export { default } from "./ConnectedTeamInboxView";
+export { default as ConnectedTeamInboxView } from "./ConnectedTeamInboxView";
+export { default as TeamInboxView } from "./TeamInboxView";
+export type { TeamInboxViewProps } from "./TeamInboxView";
+export * from "./components";
+export * from "./domain";
+export { teamInboxUnreadCountAtom } from "./store";
diff --git a/src/modules/MainApp/TeamInbox/store.ts b/src/modules/MainApp/TeamInbox/store.ts
new file mode 100644
index 0000000000..e0b26f36e1
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/store.ts
@@ -0,0 +1,87 @@
+import { atom } from "jotai";
+import { atomWithStorage } from "jotai/utils";
+
+import type { TeamInboxItem } from "./domain";
+
+export interface TeamInboxCacheState {
+ items: TeamInboxItem[];
+ unreadCount: number;
+ loading: boolean;
+ error: string | null;
+ revision: number;
+ loadedForViewerKey: string | null;
+ /** True when either the local or cloud source still has a next page. */
+ hasMore: boolean;
+}
+
+export const teamInboxCacheAtom = atom({
+ items: [],
+ unreadCount: 0,
+ loading: false,
+ error: null,
+ revision: 0,
+ loadedForViewerKey: null,
+ hasMore: false,
+});
+teamInboxCacheAtom.debugLabel = "teamInboxCacheAtom";
+
+export const teamInboxUnreadCountAtom = atom(
+ (get) => get(teamInboxCacheAtom).unreadCount
+);
+teamInboxUnreadCountAtom.debugLabel = "teamInboxUnreadCountAtom";
+
+export const teamInboxInvalidationAtom = atom(0);
+teamInboxInvalidationAtom.debugLabel = "teamInboxInvalidationAtom";
+
+export type TeamInboxCloudReadReceipts = Record;
+export const MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS = 1_000;
+
+export function addTeamInboxCloudReadReceipts(
+ current: TeamInboxCloudReadReceipts,
+ additions: TeamInboxCloudReadReceipts
+): TeamInboxCloudReadReceipts {
+ const next = { ...current };
+ for (const [key, readAt] of Object.entries(additions)) {
+ delete next[key];
+ next[key] = readAt;
+ }
+ const keys = Object.keys(next);
+ for (
+ let index = 0;
+ index < keys.length - MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS;
+ index += 1
+ ) {
+ delete next[keys[index]!];
+ }
+ return next;
+}
+
+export function removeTeamInboxCloudReadReceipts(
+ current: TeamInboxCloudReadReceipts,
+ keys: readonly string[]
+): TeamInboxCloudReadReceipts {
+ if (keys.length === 0) return current;
+ let changed = false;
+ const next = { ...current };
+ for (const key of keys) {
+ if (key in next) {
+ delete next[key];
+ changed = true;
+ }
+ }
+ return changed ? next : current;
+}
+
+export const teamInboxCloudReadReceiptsAtom =
+ atomWithStorage(
+ "orgii:team-inbox:cloud-read-receipts",
+ {},
+ undefined,
+ { getOnInit: true }
+ );
+teamInboxCloudReadReceiptsAtom.debugLabel = "teamInboxCloudReadReceiptsAtom";
+
+export const invalidateTeamInboxAtom = atom(null, (get, set) => {
+ set(teamInboxInvalidationAtom, get(teamInboxInvalidationAtom) + 1);
+});
+invalidateTeamInboxAtom.debugLabel = "invalidateTeamInboxAtom";
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
new file mode 100644
index 0000000000..3812a06179
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
@@ -0,0 +1,527 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { invalidateProjectCache, projectApi } from "@src/api/http/project";
+import type { MemberEntry } from "@src/api/http/project";
+import {
+ org2CloudAuthAtom,
+ org2CloudAuthIdentityKey,
+} from "@src/features/Org2Cloud/org2CloudAuthAtom";
+import {
+ org2CloudCommentsSignalAtom,
+ orgCommentsKey,
+} from "@src/features/Org2Cloud/org2CloudCommentsBus";
+import { sidebarActiveCloudOrgIdAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom";
+import {
+ type TeamInboxMention,
+ listTeamInboxMentions,
+} from "@src/features/Org2Cloud/teamInboxMentionsClient";
+import { useProjectDataChanged } from "@src/hooks/project";
+import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId";
+
+import {
+ listLocalTeamInboxPage,
+ markAllLocalTeamInboxRead,
+ markLocalTeamInboxItemRead,
+ markLocalTeamInboxItemUnread,
+} from "./api";
+import { dedupeTeamInboxItems } from "./domain";
+import type {
+ TeamInboxCursor,
+ TeamInboxDataSource,
+ TeamInboxFilter,
+ TeamInboxItem,
+} from "./domain";
+import {
+ type TeamInboxCloudReadReceipts,
+ addTeamInboxCloudReadReceipts,
+ invalidateTeamInboxAtom,
+ removeTeamInboxCloudReadReceipts,
+ teamInboxCacheAtom,
+ teamInboxCloudReadReceiptsAtom,
+ teamInboxInvalidationAtom,
+} from "./store";
+
+const listeners = new Set<() => void>();
+let membersRequest: Promise | null = null;
+let inboxRequest: {
+ key: string;
+ promise: Promise<{
+ mentionItems: TeamInboxItem[];
+ localItems: TeamInboxItem[];
+ localUnread: number;
+ localNextCursor: TeamInboxCursor | null;
+ cloudNextCursor: string | null;
+ }>;
+} | null = null;
+
+function notifyTeamInboxListeners(): void {
+ for (const listener of listeners) listener();
+}
+
+/**
+ * Maps raw cloud mentions into Team Inbox items with `readAt` left unresolved;
+ * the caller overlays the latest local read receipts afterwards. Shared by the
+ * initial load and `loadMore` so both pages produce identical item shapes.
+ */
+function mapMentionsToItems(
+ mentions: readonly TeamInboxMention[],
+ activeCloudOrgId: string
+): TeamInboxItem[] {
+ return mentions.map((mention) => {
+ const itemId = `cloud-comment:${activeCloudOrgId}:${mention.comment.id}`;
+ return {
+ id: itemId,
+ kind: "comment_mention" as const,
+ occurredAt: mention.createdAt,
+ readAt: null,
+ actor: {
+ id: mention.author.userId,
+ displayName: mention.author.displayName ?? "Team member",
+ },
+ target: {
+ kind: "session_comment" as const,
+ sessionId: mention.session.id,
+ sessionTitle: mention.session.title ?? "Session",
+ commentId: mention.comment.id,
+ threadId: mention.comment.parentId ?? mention.comment.id,
+ anchor: mention.comment.id,
+ },
+ payload: {
+ commentBody: mention.body,
+ commentCount: mention.commentCount,
+ context: `${mention.threadCount} thread comments`,
+ },
+ };
+ });
+}
+
+/** Overlays the current cloud read receipts onto freshly-mapped mention items. */
+function overlayCloudReadReceipts(
+ mentionItems: readonly TeamInboxItem[],
+ cloudReadReceipts: TeamInboxCloudReadReceipts,
+ cloudScopeKey: string
+): TeamInboxItem[] {
+ return mentionItems.map((item) => ({
+ ...item,
+ readAt: cloudReadReceipts[`${cloudScopeKey}|${item.id}`] ?? null,
+ }));
+}
+
+/**
+ * Resolves each assigned item's display name from its stable `assigneeMemberId`
+ * into the optional `assigneeName` field. When the member cannot be resolved the
+ * name is left unset and consumers fall back to the id, so a row never renders
+ * blank.
+ */
+function resolveAssigneeDisplayNames(
+ items: readonly TeamInboxItem[],
+ members: readonly MemberEntry[]
+): TeamInboxItem[] {
+ if (members.length === 0) return [...items];
+ const nameById = new Map(members.map((member) => [member.id, member.name]));
+ return items.map((item) => {
+ if (item.kind !== "assigned_work_item") return item;
+ const resolved = nameById.get(item.payload.assigneeMemberId);
+ if (!resolved || resolved === item.payload.assigneeName) return item;
+ return {
+ ...item,
+ payload: { ...item.payload, assigneeName: resolved },
+ };
+ });
+}
+
+async function readAllProjectMembers(): Promise {
+ if (membersRequest) return membersRequest;
+ membersRequest = (async () => {
+ const projects = await projectApi.readProjects();
+ const memberFiles = await Promise.all(
+ projects.map((project) => projectApi.readMembers(project.slug))
+ );
+ const members = new Map();
+ for (const file of memberFiles) {
+ for (const member of file.members) members.set(member.id, member);
+ }
+ return [...members.values()];
+ })();
+ try {
+ return await membersRequest;
+ } finally {
+ membersRequest = null;
+ }
+}
+
+export function useTeamInboxDataSource(): {
+ dataSource: TeamInboxDataSource;
+ viewerMemberIds: readonly string[];
+} {
+ const [members, setMembers] = useState([]);
+ const membersRef = useRef([]);
+ const { memberIds } = useCurrentUserMemberIds(members);
+ const viewerMemberIds = useMemo(() => [...memberIds].sort(), [memberIds]);
+ const cache = useAtomValue(teamInboxCacheAtom);
+ const auth = useAtomValue(org2CloudAuthAtom);
+ const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null;
+ const activeCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom);
+ const viewerKey = `${viewerMemberIds.join("|")}::${authIdentityKey ?? "signed-out"}::${activeCloudOrgId ?? "local"}`;
+ const commentsSignals = useAtomValue(org2CloudCommentsSignalAtom);
+ const cloudReadReceipts = useAtomValue(teamInboxCloudReadReceiptsAtom);
+ const cloudReadReceiptsRef = useRef(cloudReadReceipts);
+ cloudReadReceiptsRef.current = cloudReadReceipts;
+ const setCloudReadReceipts = useSetAtom(teamInboxCloudReadReceiptsAtom);
+ const activeCloudCommentsRevision = activeCloudOrgId
+ ? (commentsSignals[orgCommentsKey(activeCloudOrgId)] ?? 0)
+ : 0;
+ const invalidation = useAtomValue(teamInboxInvalidationAtom);
+ const setCache = useSetAtom(teamInboxCacheAtom);
+ const invalidate = useSetAtom(invalidateTeamInboxAtom);
+ const loadGeneration = useRef(0);
+ const localCursorRef = useRef(null);
+ const cloudCursorRef = useRef(null);
+ const loadingMoreRef = useRef(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ void readAllProjectMembers()
+ .then((nextMembers) => {
+ if (!cancelled) {
+ membersRef.current = nextMembers;
+ setMembers(nextMembers);
+ }
+ })
+ .catch((error: unknown) => {
+ if (!cancelled) {
+ setCache((current) => ({
+ ...current,
+ error:
+ error instanceof Error
+ ? error.message
+ : "Failed to resolve current Team Inbox member identity",
+ }));
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [invalidation, setCache]);
+
+ const refresh = useCallback(async (): Promise => {
+ const canLoadLocalAssignments = viewerMemberIds.length > 0;
+ const canLoadCloudMentions = Boolean(auth && activeCloudOrgId);
+ if (!canLoadLocalAssignments && !canLoadCloudMentions) {
+ localCursorRef.current = null;
+ cloudCursorRef.current = null;
+ setCache((current) => ({
+ ...current,
+ items: [],
+ unreadCount: 0,
+ loading: false,
+ hasMore: false,
+ loadedForViewerKey: viewerKey,
+ error:
+ members.length > 0
+ ? "No project member matches the current Git identity"
+ : null,
+ }));
+ notifyTeamInboxListeners();
+ return;
+ }
+ const generation = ++loadGeneration.current;
+ setCache((current) => ({ ...current, loading: true, error: null }));
+ try {
+ const requestKey = viewerKey;
+ if (!inboxRequest || inboxRequest.key !== requestKey) {
+ const promise = Promise.all([
+ canLoadLocalAssignments
+ ? listLocalTeamInboxPage(viewerMemberIds, "all")
+ : Promise.resolve({
+ page: { items: [], nextCursor: null },
+ unreadCount: 0,
+ }),
+ auth && activeCloudOrgId
+ ? listTeamInboxMentions(
+ auth.accessToken,
+ activeCloudOrgId,
+ null,
+ 50
+ ).catch(() => ({ mentions: [], nextCursor: undefined }))
+ : Promise.resolve({ mentions: [], nextCursor: undefined }),
+ ]).then(([{ page, unreadCount }, mentionPage]) => {
+ // Read state is intentionally NOT baked in here: the cached request
+ // promise stays receipt-independent so a mention marked read while
+ // this request is in flight is not reverted when the page resolves.
+ // The current cloud read receipts are overlaid after the await below.
+ const mentionItems = mapMentionsToItems(
+ mentionPage.mentions,
+ activeCloudOrgId ?? ""
+ );
+ return {
+ mentionItems,
+ localItems: page.items,
+ localUnread: unreadCount,
+ localNextCursor: page.nextCursor,
+ cloudNextCursor: mentionPage.nextCursor ?? null,
+ };
+ });
+ inboxRequest = { key: requestKey, promise };
+ void promise.finally(() => {
+ if (inboxRequest?.promise === promise) inboxRequest = null;
+ });
+ }
+ const {
+ mentionItems,
+ localItems,
+ localUnread,
+ localNextCursor,
+ cloudNextCursor,
+ } = await inboxRequest.promise;
+ if (generation !== loadGeneration.current) return;
+ localCursorRef.current = localNextCursor;
+ cloudCursorRef.current = cloudNextCursor;
+ // Overlay the latest cloud read receipts here (not inside the cached
+ // request promise) so optimistic mark-read/unread survives a concurrent
+ // in-flight list request.
+ const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
+ const overlaidMentions = overlayCloudReadReceipts(
+ mentionItems,
+ cloudReadReceiptsRef.current,
+ cloudScopeKey
+ );
+ const mergedItems = [...overlaidMentions, ...localItems];
+ const unreadCount =
+ localUnread +
+ overlaidMentions.filter((item) => item.readAt === null).length;
+ const resolvedItems = resolveAssigneeDisplayNames(
+ mergedItems,
+ membersRef.current
+ );
+ setCache((current) => ({
+ ...current,
+ items: resolvedItems,
+ unreadCount,
+ loading: false,
+ error: null,
+ loadedForViewerKey: viewerKey,
+ hasMore: Boolean(localNextCursor || cloudNextCursor),
+ revision: current.revision + 1,
+ }));
+ notifyTeamInboxListeners();
+ } catch (error) {
+ if (generation !== loadGeneration.current) return;
+ setCache((current) => ({
+ ...current,
+ loading: false,
+ error:
+ error instanceof Error ? error.message : "Failed to load Team Inbox",
+ }));
+ notifyTeamInboxListeners();
+ }
+ }, [
+ activeCloudOrgId,
+ auth,
+ authIdentityKey,
+ cloudReadReceipts,
+ members.length,
+ setCache,
+ viewerKey,
+ viewerMemberIds,
+ ]);
+
+ useEffect(() => {
+ if (activeCloudCommentsRevision > 0) void refresh();
+ }, [activeCloudCommentsRevision, refresh]);
+ useEffect(() => {
+ if (cache.loadedForViewerKey === viewerKey && invalidation === 0) return;
+ void refresh();
+ }, [cache.loadedForViewerKey, invalidation, refresh, viewerKey]);
+
+ useProjectDataChanged(() => invalidate());
+
+ const dataSource = useMemo(
+ () => ({
+ listPage: async () => {
+ if (cache.error && cache.items.length === 0)
+ throw new Error(cache.error);
+ // A non-null nextCursor signals the view that a further page exists; the
+ // exact value is a sentinel because `loadMore` owns the real per-source
+ // cursors internally.
+ return {
+ items: cache.items,
+ nextCursor: cache.hasMore
+ ? { occurredAt: "", itemKey: "team-inbox-has-more" }
+ : null,
+ };
+ },
+ loadMore: async () => {
+ if (loadingMoreRef.current) return;
+ const localCursor = localCursorRef.current;
+ const cloudCursor = cloudCursorRef.current;
+ if (!localCursor && !cloudCursor) return;
+ loadingMoreRef.current = true;
+ try {
+ const [localResult, cloudResult] = await Promise.all([
+ localCursor && viewerMemberIds.length > 0
+ ? listLocalTeamInboxPage(viewerMemberIds, "all", localCursor)
+ : Promise.resolve({
+ page: { items: [], nextCursor: null },
+ unreadCount: 0,
+ }),
+ cloudCursor && auth && activeCloudOrgId
+ ? listTeamInboxMentions(
+ auth.accessToken,
+ activeCloudOrgId,
+ cloudCursor,
+ 50
+ ).catch(() => ({ mentions: [], nextCursor: undefined }))
+ : Promise.resolve({ mentions: [], nextCursor: undefined }),
+ ]);
+ localCursorRef.current = localResult.page.nextCursor ?? null;
+ cloudCursorRef.current = cloudResult.nextCursor ?? null;
+ const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
+ const appendedMentions = overlayCloudReadReceipts(
+ mapMentionsToItems(cloudResult.mentions, activeCloudOrgId ?? ""),
+ cloudReadReceiptsRef.current,
+ cloudScopeKey
+ );
+ const appended = resolveAssigneeDisplayNames(
+ [...appendedMentions, ...localResult.page.items],
+ membersRef.current
+ );
+ // Unread badge semantics are intentionally left unchanged here (the
+ // single-source-of-truth question is tracked separately); loadMore
+ // only extends the loaded window.
+ setCache((current) => ({
+ ...current,
+ items: dedupeTeamInboxItems([...current.items, ...appended]),
+ hasMore: Boolean(localCursorRef.current || cloudCursorRef.current),
+ revision: current.revision + 1,
+ }));
+ notifyTeamInboxListeners();
+ } finally {
+ loadingMoreRef.current = false;
+ }
+ },
+ refresh: async () => {
+ invalidateProjectCache();
+ membersRequest = null;
+ const nextMembers = await readAllProjectMembers();
+ membersRef.current = nextMembers;
+ setMembers(nextMembers);
+ setCache((current) => ({
+ ...current,
+ loadedForViewerKey: null,
+ loading: true,
+ error: null,
+ }));
+ invalidate();
+ },
+ markRead: async (item: TeamInboxItem) => {
+ const readAt = new Date().toISOString();
+ if (item.kind === "comment_mention") {
+ const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
+ setCloudReadReceipts((current) =>
+ addTeamInboxCloudReadReceipts(current, {
+ [`${cloudScopeKey}|${item.id}`]: readAt,
+ })
+ );
+ } else {
+ await markLocalTeamInboxItemRead(viewerMemberIds, item.id);
+ }
+ setCache((current) => ({
+ ...current,
+ items: current.items.map((candidate) =>
+ candidate.id === item.id ? { ...candidate, readAt } : candidate
+ ),
+ unreadCount: Math.max(0, current.unreadCount - 1),
+ revision: current.revision + 1,
+ }));
+ notifyTeamInboxListeners();
+ },
+ markUnread: async (item: TeamInboxItem) => {
+ if (item.kind === "comment_mention") {
+ const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
+ setCloudReadReceipts((current) =>
+ removeTeamInboxCloudReadReceipts(current, [
+ `${cloudScopeKey}|${item.id}`,
+ ])
+ );
+ } else {
+ await markLocalTeamInboxItemUnread(viewerMemberIds, item.id);
+ }
+ setCache((current) => ({
+ ...current,
+ items: current.items.map((candidate) =>
+ candidate.id === item.id
+ ? { ...candidate, readAt: null }
+ : candidate
+ ),
+ unreadCount: current.unreadCount + 1,
+ revision: current.revision + 1,
+ }));
+ notifyTeamInboxListeners();
+ },
+ markAllRead: async (items) => {
+ const assigned = items.filter(
+ (
+ item
+ ): item is Extract =>
+ item.kind === "assigned_work_item"
+ );
+ if (assigned.length > 0) {
+ await markAllLocalTeamInboxRead(viewerMemberIds, "assigned");
+ }
+ const readAt = new Date().toISOString();
+ const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
+ const mentionReceipts = items
+ .filter((item) => item.kind === "comment_mention")
+ .reduce>((next, item) => {
+ next[`${cloudScopeKey}|${item.id}`] = readAt;
+ return next;
+ }, {});
+ if (Object.keys(mentionReceipts).length > 0) {
+ setCloudReadReceipts((current) =>
+ addTeamInboxCloudReadReceipts(current, mentionReceipts)
+ );
+ }
+ const itemIds = new Set(items.map((item) => item.id));
+ // Decrement only by the items that were actually unread; counting the
+ // whole set would over-subtract when some passed items were already read.
+ const newlyReadCount = items.reduce(
+ (count, item) => count + (item.readAt === null ? 1 : 0),
+ 0
+ );
+ setCache((current) => ({
+ ...current,
+ items: current.items.map((item) =>
+ itemIds.has(item.id) ? { ...item, readAt } : item
+ ),
+ unreadCount: Math.max(0, current.unreadCount - newlyReadCount),
+ revision: current.revision + 1,
+ }));
+ notifyTeamInboxListeners();
+ },
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ }),
+ [
+ activeCloudOrgId,
+ auth,
+ authIdentityKey,
+ cache.error,
+ cache.hasMore,
+ cache.items,
+ invalidate,
+ setCache,
+ setCloudReadReceipts,
+ viewerMemberIds,
+ ]
+ );
+
+ return { dataSource, viewerMemberIds };
+}
+
+export function filterForItem(item: TeamInboxItem): TeamInboxFilter {
+ return item.kind === "comment_mention" ? "mentions" : "assigned";
+}
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts
new file mode 100644
index 0000000000..29d5dc1a3a
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts
@@ -0,0 +1,84 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useCallback } from "react";
+
+import {
+ enrichedWorkItemToUI,
+ projectApi,
+ standaloneWorkItemDataToEnriched,
+} from "@src/api/http/project";
+import { createLogger } from "@src/hooks/logger";
+import {
+ openOrFocusSessionInChatPanelTabAtom,
+ openWorkItemInChatPanelTabAtom,
+} from "@src/store/chatPanel/chatPanelTabsAtom";
+import { sessionsAtom } from "@src/store/session";
+
+import type { TeamInboxNavigationIntent } from "./domain";
+
+const log = createLogger("TeamInboxNavigation");
+
+export function useTeamInboxNavigation(): (
+ intent: TeamInboxNavigationIntent
+) => void {
+ const sessions = useAtomValue(sessionsAtom);
+ const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom);
+ const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom);
+
+ return useCallback(
+ (intent: TeamInboxNavigationIntent) => {
+ if (intent.kind === "open_session_comment") {
+ const session = sessions.find(
+ (candidate) => candidate.session_id === intent.sessionId
+ );
+ openSession({
+ sessionId: intent.sessionId,
+ sessionName: session?.name,
+ repoPath: session?.repoPath,
+ });
+ window.requestAnimationFrame(() => {
+ document
+ .getElementById(intent.anchor ?? `comment-${intent.commentId}`)
+ ?.scrollIntoView({ block: "center", behavior: "smooth" });
+ });
+ return;
+ }
+
+ const openResolvedWorkItem = (
+ workItem: Awaited>,
+ project?: Awaited>
+ ) => {
+ const shortId = workItem.frontmatter.short_id;
+ openWorkItem({
+ workItem: enrichedWorkItemToUI(
+ standaloneWorkItemDataToEnriched(workItem)
+ ),
+ shortId,
+ projectId: project?.meta.id ?? "",
+ projectSlug: project?.slug ?? "",
+ projectName: project?.meta.name ?? "Standalone",
+ orgId: project?.meta.org_id,
+ });
+ };
+
+ if (!intent.projectId) {
+ void projectApi
+ .readStandaloneWorkItem(intent.workItemId)
+ .then((workItem) => openResolvedWorkItem(workItem))
+ .catch((error: unknown) => {
+ log.warn("Failed to open standalone Team Inbox Work Item", error);
+ });
+ return;
+ }
+
+ void Promise.all([
+ projectApi.readProject(intent.projectId),
+ projectApi.readWorkItem(intent.projectId, intent.workItemId),
+ ])
+ .then(([project, workItem]) => openResolvedWorkItem(workItem, project))
+ .catch((error: unknown) => {
+ log.warn("Failed to open project Team Inbox Work Item", error);
+ });
+ },
+ [openSession, openWorkItem, sessions]
+ );
+}
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
new file mode 100644
index 0000000000..49bf105605
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
@@ -0,0 +1,69 @@
+import { useEffect, useState } from "react";
+
+import { projectApi } from "@src/api/http/project";
+import { createLogger } from "@src/hooks/logger";
+
+import type { WorkItemTarget } from "./domain";
+
+const log = createLogger("TeamInboxWorkItemBody");
+
+export interface TeamInboxWorkItemBodyState {
+ /** Full Markdown body once resolved, or null while loading / empty / failed. */
+ body: string | null;
+ loading: boolean;
+}
+
+interface ResolvedWorkItemBodyState extends TeamInboxWorkItemBodyState {
+ requestKey: string;
+}
+
+/**
+ * Lazily loads the full Work Item body for the selected assigned inbox item so
+ * the detail preview can render the real content instead of the short list
+ * excerpt. The fetch reuses the same project store adapters as navigation and is
+ * demand-driven (one read per selection, no polling); stale responses are
+ * discarded when the selection changes.
+ */
+export function useTeamInboxWorkItemBody(
+ target: WorkItemTarget
+): TeamInboxWorkItemBodyState {
+ const { projectId, workItemId } = target;
+ const requestKey = `${projectId ?? "standalone"}:${workItemId}`;
+ const [state, setState] = useState({
+ requestKey,
+ body: null,
+ loading: true,
+ });
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const request = projectId
+ ? projectApi.readWorkItem(projectId, workItemId)
+ : projectApi.readStandaloneWorkItem(workItemId);
+
+ void request
+ .then((workItem) => {
+ if (cancelled) return;
+ const body = workItem.body.trim();
+ setState({
+ requestKey,
+ body: body.length > 0 ? body : null,
+ loading: false,
+ });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ log.warn("Failed to load Team Inbox Work Item body", error);
+ setState({ requestKey, body: null, loading: false });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId, requestKey, workItemId]);
+
+ return state.requestKey === requestKey
+ ? state
+ : { body: null, loading: true };
+}
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx
index 022161f320..dcb3fb5133 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx
@@ -6,6 +6,8 @@ import { useLocation, useNavigate } from "react-router-dom";
import { useAppNavigation } from "@src/hooks/navigation/useAppNavigation";
import { useSessionView } from "@src/hooks/ui/tabs/useSessionView";
+import { teamInboxUnreadCountAtom } from "@src/modules/MainApp/TeamInbox/store";
+import { useTeamInboxDataSource } from "@src/modules/MainApp/TeamInbox/useTeamInboxDataSource";
import {
activeSessionCreatorDraftIdAtom,
deleteSessionCreatorDraftAtom,
@@ -65,6 +67,8 @@ export const WorkstationSidebarConnector: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const sessions = useAtomValue(sessionsAtom);
+ useTeamInboxDataSource();
+ const teamInboxUnreadCount = useAtomValue(teamInboxUnreadCountAtom);
const sessionsLoading = useAtomValue(sessionLoadingAtom);
const sessionPagination = useAtomValue(sessionPaginationAtom);
const sessionSidebarRevealRequest = useAtomValue(
@@ -103,6 +107,7 @@ export const WorkstationSidebarConnector: React.FC = () => {
openStartPageTab,
openCreateTargetInStartPage,
openRuntimeTab,
+ openTeamInboxTab,
closeAndDestroyChatPanelTab,
} = useWorkstationSidebarChatPanelAtoms();
@@ -203,6 +208,7 @@ export const WorkstationSidebarConnector: React.FC = () => {
createWorkItemLabel,
workItemsLabel,
runtimeLabel,
+ teamInboxLabel,
importGithubIssuesLabel,
addOrgLabel,
manageOrgLabel,
@@ -296,6 +302,8 @@ export const WorkstationSidebarConnector: React.FC = () => {
importGithubIssuesLabel,
newSessionLabel,
runtimeLabel,
+ teamInboxLabel,
+ teamInboxUnreadCount,
t,
tSessions,
});
@@ -487,6 +495,8 @@ export const WorkstationSidebarConnector: React.FC = () => {
openWorkManagementTab,
openRuntimeTab,
runtimeLabel,
+ openTeamInboxTab,
+ teamInboxLabel,
activateChatPanelTab,
handleMenuItemClick,
handleProjectsMenuItemClick,
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts
index 9c1b3e69ca..64745bac7e 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts
@@ -43,6 +43,23 @@ describe("resolveSelectedMenuItemIds", () => {
).toBe("runtime");
});
+ it("selects Team Inbox from the active team inbox tab", () => {
+ expect(
+ resolveSelectedMenuItemIds({
+ activeSessionCreatorDraftId: null,
+ activeSessionId: "session-1",
+ activeSidebarKey: "workstation",
+ activeChatPanelTabType: "team-inbox",
+ chatPanelContentMode: CHAT_PANEL_CONTENT_MODE.SESSION,
+ chatPanelCreateTarget: CHAT_PANEL_CREATE_TARGET.AGENT_SESSION,
+ chatPanelSelectedProject: null,
+ chatPanelSelectedWorkItem: null,
+ projectsSelectedMenuItemId: "",
+ sessionCreatorDrafts: [],
+ }).selectedMenuItemId
+ ).toBe("team-inbox");
+ });
+
it("selects Add Org by default on the projects sidebar for the collab org create target", () => {
expect(
resolveSelectedMenuItemIds({
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts
index d9afb4b70c..1f9dc89162 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts
@@ -13,6 +13,7 @@ import {
COLLAB_ADD_ORG_MENU_ITEM_ID,
KANBAN_MENU_ITEM_ID,
RUNTIME_MENU_ITEM_ID,
+ TEAM_INBOX_MENU_ITEM_ID,
} from "../sidebarConnectorUtils";
import {
getSelectedDraftMenuItemId,
@@ -59,7 +60,9 @@ export function resolveSelectedMenuItemIds({
? KANBAN_MENU_ITEM_ID
: activeChatPanelTabType === "runtime"
? RUNTIME_MENU_ITEM_ID
- : "";
+ : activeChatPanelTabType === "team-inbox"
+ ? TEAM_INBOX_MENU_ITEM_ID
+ : "";
const isChatPanelProjectsContentSelected =
chatPanelContentMode === CHAT_PANEL_CONTENT_MODE.NON_SESSION ||
Boolean(chatPanelSelectedWorkItem) ||
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts
index 56f15eba0d..8ee68e752c 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts
@@ -18,6 +18,7 @@ import {
openOrganizationInChatPanelTabAtom,
openRuntimeInChatPanelTabAtom,
openSessionInNewChatTabAtom,
+ openTeamInboxInChatPanelTabAtom,
openWorkManagementChatPanelTabAtom,
} from "@src/store/chatPanel/chatPanelTabsAtom";
import { openSessionInWorkstationAtom } from "@src/store/session/sessionTabPlacementAtom";
@@ -60,6 +61,7 @@ export function useWorkstationSidebarChatPanelAtoms() {
openCreateTargetInChatPanelStartPageAtom
);
const openRuntimeTab = useSetAtom(openRuntimeInChatPanelTabAtom);
+ const openTeamInboxTab = useSetAtom(openTeamInboxInChatPanelTabAtom);
const closeAndDestroyChatPanelTab = useSetAtom(
closeAndDestroyChatPanelTabAtom
);
@@ -85,6 +87,7 @@ export function useWorkstationSidebarChatPanelAtoms() {
openStartPageTab,
openCreateTargetInStartPage,
openRuntimeTab,
+ openTeamInboxTab,
closeAndDestroyChatPanelTab,
};
}
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx
index 84c6f8a42a..68312648d7 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx
@@ -67,6 +67,8 @@ interface UseWorkstationSidebarChromeParams {
openWorkManagementTab: MenuItemRoutingParams["openWorkManagementTab"];
openRuntimeTab: MenuItemRoutingParams["openRuntimeTab"];
runtimeLabel: string;
+ openTeamInboxTab: MenuItemRoutingParams["openTeamInboxTab"];
+ teamInboxLabel: string;
activateChatPanelTab: MenuItemRoutingParams["activateChatPanelTab"];
handleMenuItemClick: MenuItemRoutingParams["handleMenuItemClick"];
handleProjectsMenuItemClick: MenuItemRoutingParams["handleProjectsMenuItemClick"];
@@ -106,6 +108,8 @@ export function useWorkstationSidebarChrome({
openWorkManagementTab,
openRuntimeTab,
runtimeLabel,
+ openTeamInboxTab,
+ teamInboxLabel,
activateChatPanelTab,
handleMenuItemClick,
handleProjectsMenuItemClick,
@@ -144,6 +148,8 @@ export function useWorkstationSidebarChrome({
openWorkManagementTab,
openRuntimeTab,
runtimeLabel,
+ openTeamInboxTab,
+ teamInboxLabel,
activateChatPanelTab,
handleMenuItemClick,
workItemsContentVisible,
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts
index 02f86f1eb0..36207bf98e 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts
@@ -28,6 +28,9 @@ export function buildWorkstationSidebarLabels({
const createWorkItemLabel = tProjects("workItems.createWorkItem");
const workItemsLabel = t("labels.workItems");
const runtimeLabel = tSessions("chat.startPage.tabs.runtime");
+ const teamInboxLabel = t("labels.teamInbox", {
+ defaultValue: "Team Inbox",
+ });
const importGithubIssuesLabel = tProjects("githubIssuesImport.menuLabel");
const addOrgLabel = t("collaboration.addOrg");
const manageOrgLabel = t("collaboration.manageOrg");
@@ -43,6 +46,7 @@ export function buildWorkstationSidebarLabels({
createWorkItemLabel,
workItemsLabel,
runtimeLabel,
+ teamInboxLabel,
importGithubIssuesLabel,
addOrgLabel,
manageOrgLabel,
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts
index 651c9ff3cc..1d7b12c5ac 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts
@@ -22,6 +22,7 @@ import {
KANBAN_MENU_ITEM_ID,
NEW_SESSION_MENU_ITEM_ID,
RUNTIME_MENU_ITEM_ID,
+ TEAM_INBOX_MENU_ITEM_ID,
WORK_ITEMS_GITHUB_ISSUES_MENU_ITEM_ID,
WORK_ITEMS_GITHUB_PRS_MENU_ITEM_ID,
WORK_ITEMS_PROJECTS_MENU_ITEM_ID,
@@ -60,6 +61,8 @@ interface UseWorkstationSidebarMenuItemRoutingParams {
}) => void;
openRuntimeTab: (title: string) => void;
runtimeLabel: string;
+ openTeamInboxTab: (title: string) => void;
+ teamInboxLabel: string;
activateChatPanelTab: (tabId: string) => void;
handleMenuItemClick: (key: string, item: NavigationMenuItem) => void;
workItemsContentVisible: boolean;
@@ -79,6 +82,8 @@ export function useWorkstationSidebarMenuItemRouting({
openWorkManagementTab,
openRuntimeTab,
runtimeLabel,
+ openTeamInboxTab,
+ teamInboxLabel,
activateChatPanelTab,
handleMenuItemClick,
workItemsContentVisible,
@@ -129,6 +134,10 @@ export function useWorkstationSidebarMenuItemRouting({
openRuntimeTab(runtimeLabel);
return;
}
+ if (item.id === TEAM_INBOX_MENU_ITEM_ID) {
+ openTeamInboxTab(teamInboxLabel);
+ return;
+ }
if (isChatTerminalSidebarItem(item.id)) {
activateChatPanelTab(getChatTerminalTabId(item.id));
return;
@@ -161,7 +170,9 @@ export function useWorkstationSidebarMenuItemRouting({
handleProjectsMenuItemClick,
handleOpenInNewTab,
openRuntimeTab,
+ openTeamInboxTab,
runtimeLabel,
+ teamInboxLabel,
sessionMap,
workItemsContentVisible,
]
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts
index 86f7cd06a7..be1870d4b0 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts
@@ -35,6 +35,8 @@ interface UseWorkstationSidebarPinnedAndRevealDataParams {
importGithubIssuesLabel: string;
newSessionLabel: string;
runtimeLabel: string;
+ teamInboxLabel: string;
+ teamInboxUnreadCount: number;
t: TFunction<"navigation">;
tSessions: TFunction<"sessions">;
}
@@ -51,6 +53,8 @@ export function useWorkstationSidebarPinnedAndRevealData({
importGithubIssuesLabel,
newSessionLabel,
runtimeLabel,
+ teamInboxLabel,
+ teamInboxUnreadCount,
t,
tSessions,
}: UseWorkstationSidebarPinnedAndRevealDataParams) {
@@ -87,6 +91,8 @@ export function useWorkstationSidebarPinnedAndRevealData({
kanbanLabel: tSessions("simulator.tabs.kanban"),
newSessionLabel,
runtimeLabel,
+ teamInboxLabel,
+ teamInboxUnreadCount,
workItemDestinations: workItemsSidebarMenuItems,
t,
});
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts
index 678480c0b3..21dd547147 100644
--- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts
@@ -27,6 +27,8 @@ interface UsePinnedMenuItemsParams {
kanbanLabel: string;
newSessionLabel: string;
runtimeLabel: string;
+ teamInboxLabel: string;
+ teamInboxUnreadCount?: number;
workItemDestinations: NavigationMenuItem[];
t: TFunction<"navigation">;
}
@@ -44,6 +46,8 @@ export function usePinnedMenuItems({
kanbanLabel,
newSessionLabel,
runtimeLabel,
+ teamInboxLabel,
+ teamInboxUnreadCount,
workItemDestinations,
t,
}: UsePinnedMenuItemsParams): UsePinnedMenuItemsResult {
@@ -57,8 +61,18 @@ export function usePinnedMenuItems({
kanbanLabel,
kanbanShortcut: getShortcutKeys("open_kanban"),
runtimeLabel,
+ teamInboxLabel,
+ teamInboxUnreadCount,
}),
- [kanbanLabel, newSessionLabel, runtimeLabel, workItemDestinations, t]
+ [
+ kanbanLabel,
+ newSessionLabel,
+ runtimeLabel,
+ teamInboxLabel,
+ teamInboxUnreadCount,
+ workItemDestinations,
+ t,
+ ]
);
const projectsPinnedMenuItems = useMemo(
() =>
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts
new file mode 100644
index 0000000000..fe9c60b271
--- /dev/null
+++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts
@@ -0,0 +1,124 @@
+import React, { useEffect, useMemo } from "react";
+
+import { createLogger } from "@src/hooks/logger";
+import { loadSidebarSessionById } from "@src/store/session";
+import type { SessionSidebarRevealRequest } from "@src/store/ui/sidebarAtom";
+
+import type { WorkstationSidebarKey } from "./types";
+import { buildCloudOrgSelectorValue } from "./useSidebarOrgScope";
+
+const logger = createLogger("WorkstationSidebarReveal");
+
+interface UseWorkstationSidebarRevealParams {
+ activeSessionId: string;
+ request: SessionSidebarRevealRequest | null;
+ clearRequest: (requestId: number) => void;
+ setSidebarCollapsed: (collapsed: boolean) => void;
+ setActiveSidebarKey: React.Dispatch<
+ React.SetStateAction
+ >;
+ setWorkItemsOpen: React.Dispatch>;
+ setSelectedOrgId: (orgId: string) => void;
+ setSidebarSearchQueries: React.Dispatch<
+ React.SetStateAction>
+ >;
+ setExpandedSubagentParentIds: React.Dispatch<
+ React.SetStateAction>
+ >;
+}
+
+export function useWorkstationSidebarReveal({
+ activeSessionId,
+ request,
+ clearRequest,
+ setSidebarCollapsed,
+ setActiveSidebarKey,
+ setWorkItemsOpen,
+ setSelectedOrgId,
+ setSidebarSearchQueries,
+ setExpandedSubagentParentIds,
+}: UseWorkstationSidebarRevealParams): {
+ activeRequest: SessionSidebarRevealRequest | null;
+ revealedSessionIds: ReadonlySet;
+} {
+ const activatedRequestIdRef = React.useRef(null);
+ const activeRequest = request?.sessionId === activeSessionId ? request : null;
+
+ useEffect(() => {
+ if (!request) {
+ activatedRequestIdRef.current = null;
+ return;
+ }
+ if (request.sessionId === activeSessionId) {
+ activatedRequestIdRef.current = request.requestId;
+ return;
+ }
+ if (activatedRequestIdRef.current === request.requestId) {
+ clearRequest(request.requestId);
+ activatedRequestIdRef.current = null;
+ }
+ }, [activeSessionId, clearRequest, request]);
+
+ const revealedSessionIds = useMemo(() => {
+ const ids = new Set();
+ if (activeRequest?.sessionId) ids.add(activeRequest.sessionId);
+ if (activeRequest?.parentSessionId) ids.add(activeRequest.parentSessionId);
+ return ids;
+ }, [activeRequest]);
+
+ useEffect(() => {
+ if (!request) return;
+
+ setSidebarCollapsed(false);
+ const parentSessionId = request.parentSessionId ?? request.sessionId;
+ const revealFrame = window.requestAnimationFrame(() => {
+ setActiveSidebarKey("workstation");
+ setWorkItemsOpen(false);
+ if (request.cloudOrgId) {
+ setSelectedOrgId(buildCloudOrgSelectorValue(request.cloudOrgId));
+ }
+ setSidebarSearchQueries((currentQueries) =>
+ currentQueries.workstation
+ ? { ...currentQueries, workstation: "" }
+ : currentQueries
+ );
+ if (request.parentSessionId) {
+ setExpandedSubagentParentIds((previousIds) => {
+ if (previousIds.has(parentSessionId)) return previousIds;
+ const nextIds = new Set(previousIds);
+ nextIds.add(parentSessionId);
+ return nextIds;
+ });
+ }
+ });
+
+ for (const sessionId of new Set([parentSessionId, request.sessionId])) {
+ void loadSidebarSessionById(sessionId)
+ .then((session) => {
+ if (!session) {
+ logger.warn(
+ `Unable to hydrate sidebar row for session ${sessionId}`
+ );
+ }
+ })
+ .catch((error: unknown) => {
+ logger.warn(
+ `Failed to hydrate sidebar row for session ${sessionId}:`,
+ error
+ );
+ });
+ }
+
+ return () => window.cancelAnimationFrame(revealFrame);
+ }, [
+ request,
+ setActiveSidebarKey,
+ setExpandedSubagentParentIds,
+ setSelectedOrgId,
+ setSidebarCollapsed,
+ setSidebarSearchQueries,
+ setWorkItemsOpen,
+ ]);
+
+ return { activeRequest, revealedSessionIds };
+}
diff --git a/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts
index 4a71c6dddf..3c6990bbf3 100644
--- a/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts
+++ b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts
@@ -22,6 +22,7 @@ export const PROJECTS_NEW_WORK_ITEM_MENU_ITEM_ID = "projects-new-work-item";
export const WORK_ITEMS_MENU_ITEM_ID = "work-items";
export const KANBAN_MENU_ITEM_ID = "kanban";
export const RUNTIME_MENU_ITEM_ID = "runtime";
+export const TEAM_INBOX_MENU_ITEM_ID = "team-inbox";
export const WORK_ITEMS_PROJECTS_MENU_ITEM_ID = "work-items:projects";
export const WORK_ITEMS_GITHUB_ISSUES_MENU_ITEM_ID = "work-items:github-issues";
export const WORK_ITEMS_GITHUB_PRS_MENU_ITEM_ID = "work-items:github-prs";
diff --git a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts
index 6dac574da2..d65b85290e 100644
--- a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts
+++ b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
KANBAN_MENU_ITEM_ID,
RUNTIME_MENU_ITEM_ID,
+ TEAM_INBOX_MENU_ITEM_ID,
WORK_ITEMS_MENU_ITEM_ID,
WORK_ITEMS_PROJECTS_MENU_ITEM_ID,
} from "./sidebarConnectorUtils";
@@ -27,18 +28,24 @@ describe("buildPinnedMenuItems", () => {
kanbanLabel: "Kanban",
kanbanShortcut: "⌘O",
runtimeLabel: "Runtime",
+ teamInboxLabel: "Team Inbox",
});
expect(items.map((item) => item.id)).toEqual([
"new-session",
KANBAN_MENU_ITEM_ID,
RUNTIME_MENU_ITEM_ID,
+ TEAM_INBOX_MENU_ITEM_ID,
WORK_ITEMS_MENU_ITEM_ID,
]);
- expect(items[3]?.children?.map((item) => item.id)).toEqual([
+ expect(items[4]?.children?.map((item) => item.id)).toEqual([
WORK_ITEMS_PROJECTS_MENU_ITEM_ID,
]);
- expect(items[3]?.routePath).toBeUndefined();
+ expect(items[4]?.routePath).toBeUndefined();
+ expect(items[3]).toMatchObject({
+ label: "Team Inbox",
+ dataTestId: "sidebar-team-inbox",
+ });
expect(items[2]).toMatchObject({
label: "Runtime",
dataTestId: "sidebar-runtime",
diff --git a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx
index 30cc91c19f..afb89c26da 100644
--- a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx
+++ b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx
@@ -3,6 +3,7 @@ import {
Columns3,
Gauge,
Github,
+ Inbox,
ListTodo,
Plus,
SquarePen,
@@ -21,6 +22,7 @@ import {
PROJECTS_NEW_PROJECT_MENU_ITEM_ID,
PROJECTS_NEW_WORK_ITEM_MENU_ITEM_ID,
RUNTIME_MENU_ITEM_ID,
+ TEAM_INBOX_MENU_ITEM_ID,
WORK_ITEMS_MENU_ITEM_ID,
getDraftMenuItemId,
getDraftPreviewText,
@@ -34,6 +36,8 @@ interface BuildPinnedMenuItemsParams {
kanbanLabel: string;
kanbanShortcut: string;
runtimeLabel: string;
+ teamInboxLabel: string;
+ teamInboxUnreadCount?: number;
}
interface BuildProjectsPinnedMenuItemsParams {
@@ -51,6 +55,8 @@ export function buildPinnedMenuItems({
kanbanLabel,
kanbanShortcut,
runtimeLabel,
+ teamInboxLabel,
+ teamInboxUnreadCount = 0,
}: BuildPinnedMenuItemsParams): NavigationMenuItem[] {
return [
{
@@ -78,6 +84,23 @@ export function buildPinnedMenuItems({
iconName: "gauge",
dataTestId: "sidebar-runtime",
},
+ {
+ id: TEAM_INBOX_MENU_ITEM_ID,
+ key: TEAM_INBOX_MENU_ITEM_ID,
+ label: teamInboxLabel,
+ icon: Inbox,
+ iconName: "inbox",
+ dataTestId: "sidebar-team-inbox",
+ trailingElement:
+ teamInboxUnreadCount > 0 ? (
+
+ {teamInboxUnreadCount > 99 ? "99+" : teamInboxUnreadCount}
+
+ ) : undefined,
+ },
{
id: WORK_ITEMS_MENU_ITEM_ID,
key: WORK_ITEMS_MENU_ITEM_ID,
diff --git a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts
index c0dea68568..bfd60313af 100644
--- a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts
+++ b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts
@@ -52,6 +52,7 @@ async function loadChatPanelTabAtoms() {
openWorkManagementChatPanelTabAtom,
openOrFocusChatPanelStartPageTabAtom,
openRuntimeInChatPanelTabAtom,
+ openTeamInboxInChatPanelTabAtom,
openOrFocusSessionInChatPanelTabAtom,
openOrReplaceSessionInChatPanelTabAtom,
openProjectInChatPanelTabAtom,
@@ -118,6 +119,7 @@ async function loadChatPanelTabAtoms() {
openWorkManagementChatPanelTabAtom,
openOrFocusChatPanelStartPageTabAtom,
openRuntimeInChatPanelTabAtom,
+ openTeamInboxInChatPanelTabAtom,
openOrFocusSessionInChatPanelTabAtom,
openOrReplaceSessionInChatPanelTabAtom,
openProjectInChatPanelTabAtom,
@@ -828,6 +830,33 @@ describe("ChatPanel navigation tabs", () => {
).toHaveLength(1);
});
+ it("opens Team Inbox as its own singleton tab", async () => {
+ const { chatPanelTabsAtom, openTeamInboxInChatPanelTabAtom, store } =
+ await loadChatPanelTabAtoms();
+
+ const teamInboxTabId = store.set(
+ openTeamInboxInChatPanelTabAtom,
+ "Team Inbox"
+ );
+ const focusedTabId = store.set(
+ openTeamInboxInChatPanelTabAtom,
+ "Team Inbox"
+ );
+
+ expect(focusedTabId).toBe(teamInboxTabId);
+ expect(store.get(chatPanelTabsAtom).activeTabId).toBe(teamInboxTabId);
+ expect(
+ store
+ .get(chatPanelTabsAtom)
+ .tabs.filter((tab) => tab.type === "team-inbox")
+ ).toEqual([
+ expect.objectContaining({
+ id: teamInboxTabId,
+ title: "Team Inbox",
+ }),
+ ]);
+ });
+
it("opens org management in its own singleton tab and restores the selected org", async () => {
const {
activateChatPanelTabAtom,
diff --git a/src/store/chatPanel/chatPanelTabFactories.ts b/src/store/chatPanel/chatPanelTabFactories.ts
index 8a9119a113..8b8bec8a0d 100644
--- a/src/store/chatPanel/chatPanelTabFactories.ts
+++ b/src/store/chatPanel/chatPanelTabFactories.ts
@@ -30,6 +30,8 @@ export const DEFAULT_LAUNCHPAD_TAB_ID = "launchpad-default";
export const WORK_MANAGEMENT_TAB_ID_PREFIX = "chat-work-management";
/** Fixed id of the singleton Runtime tab. */
export const RUNTIME_TAB_ID = "chat-runtime";
+/** Fixed id of the singleton Team Inbox tab. */
+export const TEAM_INBOX_TAB_ID = "chat-team-inbox";
// ---------------------------------------------------------------------------
// start-page (Launchpad)
@@ -80,6 +82,18 @@ export const createRuntimeTab = defineChatPanelTabFactory<{ title?: string }>({
getTitle: (data) => data.title ?? "Runtime",
});
+// ---------------------------------------------------------------------------
+// team-inbox — singleton
+// ---------------------------------------------------------------------------
+
+export const createTeamInboxTab = defineChatPanelTabFactory<{ title?: string }>(
+ {
+ tabType: "team-inbox",
+ idStrategy: { type: "fixed", id: TEAM_INBOX_TAB_ID },
+ getTitle: (data) => data.title ?? "Team Inbox",
+ }
+);
+
// ---------------------------------------------------------------------------
// workspace (overview) — one pill per workspace, deduped by openers
// ---------------------------------------------------------------------------
diff --git a/src/store/chatPanel/chatPanelTabOpenAtoms.ts b/src/store/chatPanel/chatPanelTabOpenAtoms.ts
index b31cfb35a3..7cd0f2c5bc 100644
--- a/src/store/chatPanel/chatPanelTabOpenAtoms.ts
+++ b/src/store/chatPanel/chatPanelTabOpenAtoms.ts
@@ -26,6 +26,7 @@ import {
createProjectTab,
createRuntimeTab,
createSessionTab,
+ createTeamInboxTab,
createTerminalTab,
createWorkItemTab,
createWorkManagementTab,
@@ -123,6 +124,25 @@ export const openRuntimeInChatPanelTabAtom = atom(
);
openRuntimeInChatPanelTabAtom.debugLabel = "openRuntimeInChatPanelTab";
+/** Open or focus the singleton Team Inbox tab. */
+export const openTeamInboxInChatPanelTabAtom = atom(
+ null,
+ (get, set, title: string = "Team Inbox") => {
+ const existingTab = get(chatPanelTabsAtom).tabs.find(
+ (tab) => tab.type === "team-inbox"
+ );
+ if (existingTab) {
+ set(activateChatPanelTabAtom, existingTab.id);
+ return existingTab.id;
+ }
+
+ const tab = createTeamInboxTab({ title });
+ set(appendAndActivateChatPanelTabAtom, { tab });
+ return tab.id;
+ }
+);
+openTeamInboxInChatPanelTabAtom.debugLabel = "openTeamInboxInChatPanelTab";
+
interface OpenWorkManagementTabOptions {
section?: WorkManagementSection;
title?: string;
diff --git a/src/store/chatPanel/chatPanelTabsAtom.ts b/src/store/chatPanel/chatPanelTabsAtom.ts
index e892ffb4d7..f1ed721823 100644
--- a/src/store/chatPanel/chatPanelTabsAtom.ts
+++ b/src/store/chatPanel/chatPanelTabsAtom.ts
@@ -30,6 +30,7 @@ export {
openWorkManagementChatPanelTabAtom,
openOrFocusChatPanelStartPageTabAtom,
openRuntimeInChatPanelTabAtom,
+ openTeamInboxInChatPanelTabAtom,
openOrFocusSessionInChatPanelTabAtom,
openOrReplaceSessionInChatPanelTabAtom,
openProjectInChatPanelTabAtom,
@@ -44,6 +45,7 @@ export {
createLaunchpadTab,
createRuntimeTab,
createSessionTab,
+ createTeamInboxTab,
createTerminalTab,
createWorkManagementTab,
createWorkspaceTab,
diff --git a/src/store/chatPanel/chatPanelTabsModel.ts b/src/store/chatPanel/chatPanelTabsModel.ts
index bf4ee0e41c..c8d9ee78fa 100644
--- a/src/store/chatPanel/chatPanelTabsModel.ts
+++ b/src/store/chatPanel/chatPanelTabsModel.ts
@@ -16,6 +16,7 @@ export type ChatPanelTabType =
| "terminal"
| "start-page"
| "runtime"
+ | "team-inbox"
| "work-management"
| "workspace"
| "organization"
@@ -97,6 +98,7 @@ const PERSISTED_CHAT_PANEL_TAB_TYPES = new Set([
"session",
"start-page",
"runtime",
+ "team-inbox",
"work-management",
"workspace",
"organization",
@@ -209,6 +211,10 @@ export function normalizePersistedChatPanelTabsState(
activeMappedTab?.type === "runtime"
? activeMappedTab.id
: mappedTabs.find((tab) => tab.type === "runtime")?.id;
+ const preferredTeamInboxTabId =
+ activeMappedTab?.type === "team-inbox"
+ ? activeMappedTab.id
+ : mappedTabs.find((tab) => tab.type === "team-inbox")?.id;
const preferredOrganizationTab =
activeMappedTab?.type === "organization"
? activeMappedTab
@@ -228,6 +234,7 @@ export function normalizePersistedChatPanelTabsState(
tab.id ===
preferredWorkManagementTabIds.get(tab.managementSection))) &&
(tab.type !== "runtime" || tab.id === preferredRuntimeTabId) &&
+ (tab.type !== "team-inbox" || tab.id === preferredTeamInboxTabId) &&
(tab.type !== "organization" || tab === preferredOrganizationTab) &&
(tab.type !== "start-page" || tab.id === preferredStartPageTabId)
)
From 910253c11ddb625da650ec4b4dce793450d6f45d Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Mon, 27 Jul 2026 23:38:23 +0800
Subject: [PATCH 02/11] feat(team-inbox): close collaboration workflow
Pre-commit hook ran. Total eslint: 2, total circular: 0
---
.../ChatPanel/panels/WorkItemPanelView.tsx | 72 +--
.../usePendingWorkItemAction.test.ts | 123 ++++++
.../panels/usePendingWorkItemAction.ts | 40 ++
.../SessionComments/CommentThreadList.tsx | 174 ++++++--
.../SessionCommentsContext.tsx | 75 +++-
.../SessionCommentsHeaderExtras.tsx | 16 +-
.../SessionComments/TurnCommentChrome.tsx | 6 +-
.../Org2Cloud/org2CloudCapabilities.test.ts | 25 ++
.../Org2Cloud/org2CloudCapabilities.ts | 4 +
.../Org2Cloud/org2CloudCommentsClient.test.ts | 24 +
.../Org2Cloud/org2CloudCommentsClient.ts | 21 +-
.../Org2Cloud/org2CloudSessionCommentsAtom.ts | 1 +
.../org2CloudSessionCommentsAtom.types.ts | 2 +
.../Org2Cloud/org2CloudSyncClient.test.ts | 3 +
.../Org2Cloud/teamInboxMentionsClient.test.ts | 132 +++++-
.../Org2Cloud/teamInboxMentionsClient.ts | 122 ++++-
src/i18n/locales/de/navigation.json | 4 +-
src/i18n/locales/en/navigation.json | 4 +-
src/i18n/locales/es/navigation.json | 4 +-
src/i18n/locales/fr/navigation.json | 4 +-
src/i18n/locales/ja/navigation.json | 4 +-
src/i18n/locales/ko/navigation.json | 4 +-
src/i18n/locales/pl/navigation.json | 4 +-
src/i18n/locales/pt/navigation.json | 4 +-
src/i18n/locales/ru/navigation.json | 4 +-
src/i18n/locales/tr/navigation.json | 4 +-
src/i18n/locales/vi/navigation.json | 4 +-
src/i18n/locales/zh-Hant/navigation.json | 4 +-
src/i18n/locales/zh/navigation.json | 4 +-
src/modules/MainApp/TeamInbox/TEST_CASES.md | 54 ++-
.../MainApp/TeamInbox/TeamInboxView.tsx | 127 +++++-
.../__tests__/AssignedWorkItemDetail.test.ts | 213 +++++++++
.../MainApp/TeamInbox/__tests__/TEST_CASES.md | 58 +--
.../__tests__/TeamInboxView.layout.test.ts | 87 ++++
.../TeamInbox/__tests__/labels.test.ts | 15 +
.../MainApp/TeamInbox/__tests__/store.test.ts | 63 ---
.../components/AssignedWorkItemDetail.tsx | 185 ++++++--
.../components/TeamInboxDetailLayout.tsx | 147 +++---
.../TeamInbox/components/TeamInboxList.tsx | 1 +
.../TeamInbox/components/TeamInboxRow.tsx | 3 +
src/modules/MainApp/TeamInbox/domain/index.ts | 1 +
.../MainApp/TeamInbox/domain/labels.ts | 10 +
src/modules/MainApp/TeamInbox/domain/types.ts | 16 +-
src/modules/MainApp/TeamInbox/store.ts | 52 +--
.../TeamInbox/useTeamInboxDataSource.ts | 417 ++++++++++++------
.../TeamInbox/useTeamInboxNavigation.ts | 27 +-
.../MainApp/TeamInbox/useTeamInboxWorkItem.ts | 199 +++++++++
.../TeamInbox/useTeamInboxWorkItemBody.ts | 69 ---
.../__tests__/workItemPartialUpdate.test.ts | 45 ++
.../components/AgentWorkflow/PhaseStates.tsx | 29 ++
.../components/AgentWorkflow/index.tsx | 20 +-
.../components/WorkItemContent/HistoryTab.tsx | 307 ++++++++-----
.../WorkItemContent/ThreadTodoChecklist.tsx | 219 +++++++++
.../WorkItemDescriptionEditing.test.ts | 70 ++-
.../__tests__/presentation.test.ts | 31 ++
.../__tests__/threadTodos.test.ts | 27 ++
.../__tests__/useWorkItemTimeline.test.ts | 28 ++
.../hooks/useWorkItemContentState.tsx | 13 +-
.../components/WorkItemContent/index.tsx | 205 ++++++---
.../WorkItemContent/presentation.ts | 39 ++
.../components/WorkItemContent/threadTodos.ts | 28 ++
.../components/WorkItemContent/types.ts | 7 +
.../WorkItemContent/useWorkItemTimeline.ts | 27 +-
.../WorkItemProperties.pillLayout.test.ts | 112 +++++
.../components/WorkItemProperties/index.tsx | 13 +-
.../components/WorkItemProperties/types.ts | 5 +
.../WorkItemThread/__tests__/TEST_CASES.md | 47 ++
.../__tests__/presentation.test.ts | 34 ++
.../components/WorkItemThread/index.tsx | 102 +++++
.../components/WorkItemThread/presentation.ts | 14 +
.../components/WorkItemThread/tokens.ts | 11 +
.../WorkItems/workItemPartialUpdate.ts | 82 ++++
.../components/ProjectContentEditor/index.tsx | 4 +-
.../components/ActivityTimeline/index.tsx | 21 +-
.../chatPanelWorkItemActionAtoms.test.ts | 63 +++
src/store/chatPanel/chatPanelTabsAtom.ts | 7 +
.../chatPanel/chatPanelWorkItemActionAtoms.ts | 45 ++
77 files changed, 3480 insertions(+), 811 deletions(-)
create mode 100644 src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts
create mode 100644 src/engines/ChatPanel/panels/usePendingWorkItemAction.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts
delete mode 100644 src/modules/MainApp/TeamInbox/__tests__/store.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts
delete mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
create mode 100644 src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/TEST_CASES.md
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts
create mode 100644 src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
create mode 100644 src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts
create mode 100644 src/store/chatPanel/chatPanelWorkItemActionAtoms.ts
diff --git a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
index f1522a0656..894a1594bc 100644
--- a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
+++ b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { STORY_SYNC_ADAPTER } from "@src/api/http/integrations/syncConnections";
import {
type WorkItemFrontmatter,
- type WorkItemPartialUpdate,
enrichedWorkItemToUI,
projectApi,
standaloneWorkItemDataToEnriched,
@@ -26,6 +25,7 @@ import {
} from "@src/modules/ProjectManager/WorkItems/components";
import { WorkItemDetailHeaderBreadcrumb } from "@src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailHeader";
import { useWorkItemOrchestrator } from "@src/modules/ProjectManager/WorkItems/hooks";
+import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate";
import { PropertiesRailFrame } from "@src/modules/ProjectManager/shared";
import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared";
import { VerticalResizeHandle } from "@src/scaffold/Resize";
@@ -40,6 +40,7 @@ import { WORK_ITEM_STATUS, type WorkItem } from "@src/types/core/workItem";
import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction";
import SessionContentView from "../SessionContentView";
+import { usePendingWorkItemAction } from "./usePendingWorkItemAction";
const logger = createLogger("WorkItemPanelView");
const saveNoPendingWorkItemChanges = async (): Promise => undefined;
@@ -104,70 +105,6 @@ function applyWorkItemPatch(
};
}
-function toWorkItemPartialUpdate(
- updates: Partial
-): WorkItemPartialUpdate {
- const payload: WorkItemPartialUpdate = {};
-
- if (updates.name !== undefined) payload.title = updates.name;
- if (updates.spec !== undefined) payload.body = updates.spec;
- if (updates.workItemStatus !== undefined) {
- payload.status = updates.workItemStatus;
- }
- if (updates.priority !== undefined) payload.priority = updates.priority;
- if (updates.project?.id) payload.project = updates.project.id;
- if (updates.star !== undefined) payload.starred = updates.star;
- if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null;
- if ("assigneeType" in updates) {
- payload.assigneeType = updates.assigneeType ?? null;
- }
- if ("labels" in updates) {
- payload.labels = updates.labels?.map((label) => label.id) ?? [];
- }
- if ("milestone" in updates) {
- payload.milestone = updates.milestone?.id ?? null;
- }
- if ("startDate" in updates) payload.startDate = updates.startDate ?? null;
- if ("endDate" in updates) payload.targetDate = updates.endDate ?? null;
- if ("target_date" in updates) {
- payload.targetDate = updates.target_date ?? null;
- }
- if (updates.todos !== undefined) {
- payload.todos = updates.todos.map((todo) => ({
- id: todo.id,
- content: todo.content,
- status: todo.status,
- }));
- }
- if (updates.comments !== undefined) {
- payload.comments = updates.comments.map((comment) => ({
- id: comment.id,
- author: comment.author,
- content: comment.content,
- created_at: comment.created_at,
- }));
- }
- if (updates.linkedSessions !== undefined) {
- payload.linkedSessions = updates.linkedSessions;
- }
- if (updates.orchestratorConfig !== undefined) {
- payload.orchestratorConfig = updates.orchestratorConfig;
- }
- if (updates.orchestratorState !== undefined) {
- payload.orchestratorState = updates.orchestratorState;
- }
- if (updates.schedule !== undefined) payload.schedule = updates.schedule;
- if (updates.executionLock !== undefined) {
- payload.executionLock = updates.executionLock;
- }
- if (updates.closeOut !== undefined) payload.closeOut = updates.closeOut;
- if (updates.workProducts !== undefined) {
- payload.workProducts = updates.workProducts;
- }
-
- return payload;
-}
-
export const WorkItemPanelView: React.FC = ({
selectedWorkItem,
onUpdateWorkItem,
@@ -378,6 +315,11 @@ export const WorkItemPanelView: React.FC = ({
handleSave: saveNoPendingWorkItemChanges,
});
+ usePendingWorkItemAction({
+ workItemShortId: selectedWorkItem.shortId,
+ onStartAgent: handleStartAgent,
+ });
+
const handleOpenSession = useCallback(
(sessionId: string) => {
setFloatingSessionId(sessionId);
diff --git a/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts b/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts
new file mode 100644
index 0000000000..72e64d372e
--- /dev/null
+++ b/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts
@@ -0,0 +1,123 @@
+// @vitest-environment jsdom
+import { Provider, createStore } from "jotai";
+import { act, createElement } from "react";
+import { type Root, createRoot } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import {
+ pendingChatPanelWorkItemActionAtom,
+ requestChatPanelWorkItemActionAtom,
+} from "@src/store/chatPanel/chatPanelTabsAtom";
+
+import { usePendingWorkItemAction } from "../usePendingWorkItemAction";
+
+function Harness({
+ workItemShortId,
+ onStartAgent,
+}: {
+ workItemShortId: string;
+ onStartAgent: () => void;
+}) {
+ usePendingWorkItemAction({ workItemShortId, onStartAgent });
+ return null;
+}
+
+describe("usePendingWorkItemAction", () => {
+ 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(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ it("starts once after the canonical Work Item surface claims the request", () => {
+ const store = createStore();
+ const onStartAgent = vi.fn();
+ store.set(requestChatPanelWorkItemActionAtom, {
+ workItemShortId: "ORG-42",
+ action: "start_agent",
+ });
+
+ act(() => {
+ root.render(
+ createElement(
+ Provider,
+ { store },
+ createElement(Harness, {
+ workItemShortId: "ORG-42",
+ onStartAgent,
+ })
+ )
+ );
+ });
+
+ expect(onStartAgent).toHaveBeenCalledTimes(1);
+ expect(store.get(pendingChatPanelWorkItemActionAtom)).toBeNull();
+
+ act(() => {
+ root.render(
+ createElement(
+ Provider,
+ { store },
+ createElement(Harness, {
+ workItemShortId: "ORG-42",
+ onStartAgent,
+ })
+ )
+ );
+ });
+ expect(onStartAgent).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves a request pending for its owning Work Item", () => {
+ const store = createStore();
+ const onStartAgent = vi.fn();
+ const request = store.set(requestChatPanelWorkItemActionAtom, {
+ workItemShortId: "ORG-42",
+ action: "start_agent",
+ });
+
+ act(() => {
+ root.render(
+ createElement(
+ Provider,
+ { store },
+ createElement(Harness, {
+ workItemShortId: "ORG-43",
+ onStartAgent,
+ })
+ )
+ );
+ });
+
+ expect(onStartAgent).not.toHaveBeenCalled();
+ expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(request);
+ });
+});
diff --git a/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts b/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts
new file mode 100644
index 0000000000..c7c548ce92
--- /dev/null
+++ b/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts
@@ -0,0 +1,40 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useEffect } from "react";
+
+import {
+ consumeChatPanelWorkItemActionAtom,
+ pendingChatPanelWorkItemActionAtom,
+} from "@src/store/chatPanel/chatPanelTabsAtom";
+
+interface UsePendingWorkItemActionOptions {
+ workItemShortId: string;
+ onStartAgent: () => void | Promise;
+}
+
+export function usePendingWorkItemAction({
+ workItemShortId,
+ onStartAgent,
+}: UsePendingWorkItemActionOptions): void {
+ const pendingWorkItemAction = useAtomValue(
+ pendingChatPanelWorkItemActionAtom
+ );
+ const consumeWorkItemAction = useSetAtom(consumeChatPanelWorkItemActionAtom);
+
+ useEffect(() => {
+ if (
+ pendingWorkItemAction?.workItemShortId !== workItemShortId ||
+ pendingWorkItemAction.action !== "start_agent"
+ ) {
+ return;
+ }
+
+ const consumedRequest = consumeWorkItemAction(pendingWorkItemAction);
+ if (!consumedRequest) return;
+ void onStartAgent();
+ }, [
+ consumeWorkItemAction,
+ onStartAgent,
+ pendingWorkItemAction,
+ workItemShortId,
+ ]);
+}
diff --git a/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx
index f4073237ee..231e36da6e 100644
--- a/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx
+++ b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx
@@ -23,17 +23,19 @@
* ordinary replies with a tiny agent affix, and a thread whose round is live
* shows one minimal "Agent is addressing…" line.
*/
-import { Bot, Check, Loader2, Pencil, Trash2 } from "lucide-react";
-import React, { useCallback, useRef, useState } from "react";
+import { AtSign, Bot, Check, Loader2, Pencil, Trash2 } from "lucide-react";
+import React, { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Button from "@src/components/Button";
+import Dropdown from "@src/components/Dropdown";
import Message from "@src/components/Message";
import TextButton from "@src/components/TextButton";
import Textarea from "@src/components/Textarea";
import Tooltip from "@src/components/Tooltip";
import { formatRelativeTime } from "@src/util/time/formatRelativeTime";
+import type { CloudOrgMember } from "../org2CloudClient";
import {
CLOUD_COMMENT_MAX_BODY_LENGTH,
type CloudCommentResolution,
@@ -54,6 +56,38 @@ import {
export type CommentThreadStatus = "active" | CloudCommentResolution;
+interface ResolvedMention {
+ id: string;
+ name: string;
+}
+
+function resolveMentions(
+ mentionedUserIds: readonly string[],
+ members: readonly CloudOrgMember[]
+): ResolvedMention[] {
+ const nameById = new Map(
+ members.map((member) => [
+ member.userId,
+ member.displayName ?? member.userId,
+ ])
+ );
+ return mentionedUserIds.map((id) => ({
+ id,
+ name: nameById.get(id) ?? id,
+ }));
+}
+
+const MemberMentionChip: React.FC<
+ ResolvedMention & { dataTestId?: string }
+> = ({ name, dataTestId }) => (
+
+ @{name}
+
+);
+
const THREAD_STATUS_OPTIONS: readonly CommentThreadStatus[] = [
"active",
"resolved",
@@ -80,6 +114,8 @@ export interface CommentThreadListProps {
/** Optional top-level composer cancel action (inline panels use it to close). */
onComposerCancel?: () => void;
emptyLabel?: string;
+ /** Explicit override for header dialogs mounted outside the provider tree. */
+ mentionableMembers?: readonly CloudOrgMember[];
/**
* Resolves with the created row when the caller's add path returns it
* (context surfaces do) — the `@agent ` prefix needs the new comment's
@@ -88,7 +124,8 @@ export interface CommentThreadListProps {
*/
onAdd: (
body: string,
- parentId?: string
+ parentId?: string,
+ mentionedUserIds?: string[]
) => Promise;
onEdit: (commentId: string, body: string) => Promise;
onDelete: (commentId: string) => Promise;
@@ -105,7 +142,8 @@ interface ComposerProps {
autoFocus?: boolean;
disabled?: boolean;
allowAgentMention?: boolean;
- onSubmit: (body: string) => Promise;
+ mentionableMembers?: readonly CloudOrgMember[];
+ onSubmit: (body: string, mentionedUserIds: string[]) => Promise;
onCancel?: () => void;
testId?: string;
}
@@ -117,31 +155,47 @@ const CommentComposer: React.FC = ({
autoFocus = false,
disabled = false,
allowAgentMention = false,
+ mentionableMembers = [],
onSubmit,
onCancel,
testId,
}) => {
const { t } = useTranslation("navigation");
const [body, setBody] = useState("");
+ const [mentionedUserIds, setMentionedUserIds] = useState([]);
const [busy, setBusy] = useState(false);
const textareaRef = useRef(null);
const trimmed = body.trim();
const showAgentSuggestion =
allowAgentMention && shouldShowAgentSuggestion(body);
+ const mentionOptions = useMemo(
+ () =>
+ mentionableMembers.map((member) => ({
+ value: member.userId,
+ label: member.displayName ?? member.userId,
+ dataTestId: `session-comment-mention-${member.userId}`,
+ })),
+ [mentionableMembers]
+ );
+ const mentionedNames = useMemo(
+ () => resolveMentions(mentionedUserIds, mentionableMembers),
+ [mentionableMembers, mentionedUserIds]
+ );
const submit = useCallback(async () => {
if (!trimmed || busy || disabled) return;
setBusy(true);
try {
- await onSubmit(trimmed);
+ await onSubmit(trimmed, mentionedUserIds);
setBody("");
+ setMentionedUserIds([]);
} catch {
// Draft restore: the text stays in the composer.
Message.error(t("cloud.comments.addError"));
} finally {
setBusy(false);
}
- }, [trimmed, busy, disabled, onSubmit, t]);
+ }, [trimmed, busy, disabled, mentionedUserIds, onSubmit, t]);
return (
@@ -181,6 +235,39 @@ const CommentComposer: React.FC
= ({
) : null}
+ {mentionOptions.length > 0 ? (
+
+
+ setMentionedUserIds(Array.isArray(value) ? value.map(String) : [])
+ }
+ >
+ }
+ disabled={disabled || busy}
+ data-testid={testId ? `${testId}-mention-members` : undefined}
+ >
+ {t("cloud.comments.mentionMembers", "Mention")}
+
+
+ {mentionedNames.map((member) => (
+
+ ))}
+
+ ) : null}
{onCancel && (
= ({
interface CommentRowProps {
comment: CloudSessionComment;
+ mentionableMembers: readonly CloudOrgMember[];
isReply: boolean;
/** Thread-head verdict; null = active (and always null on replies). */
resolution: CloudCommentResolution | null;
@@ -224,6 +312,7 @@ interface CommentRowProps {
const CommentRow: React.FC = ({
comment,
+ mentionableMembers,
isReply,
resolution,
viewerUserId,
@@ -247,6 +336,10 @@ const CommentRow: React.FC = ({
const anyBusy = busy || rowBusy;
const currentStatus: CommentThreadStatus = resolution ?? "active";
const agentMention = isReply ? null : splitAgentMentionBody(comment.body);
+ const mentionedMembers = useMemo(
+ () => resolveMentions(comment.mentionedUserIds ?? [], mentionableMembers),
+ [comment.mentionedUserIds, mentionableMembers]
+ );
const run = useCallback(
async (operation: () => Promise, errorKey: string) => {
@@ -435,23 +528,36 @@ const CommentRow: React.FC = ({
{t("cloud.comments.deletedComment")}
) : (
-
- {agentMention ? (
- <>
-
-
- {agentMention.mention}
-
- {agentMention.brief}
- >
- ) : (
- comment.body
- )}
-
+ <>
+ {mentionedMembers.length > 0 ? (
+
+ {mentionedMembers.map((member) => (
+
+ ))}
+
+ ) : null}
+
+ {agentMention ? (
+ <>
+
+
+ {agentMention.mention}
+
+ {agentMention.brief}
+ >
+ ) : (
+ comment.body
+ )}
+
+ >
)}
);
@@ -461,6 +567,7 @@ interface ThreadBlockProps {
thread: CommentThread;
viewerUserId: string | null;
viewerIsAdmin: boolean;
+ mentionableMembers: readonly CloudOrgMember[];
onAdd: CommentThreadListProps["onAdd"];
onEdit: CommentThreadListProps["onEdit"];
onDelete: CommentThreadListProps["onDelete"];
@@ -471,6 +578,7 @@ const ThreadBlock: React.FC = ({
thread,
viewerUserId,
viewerIsAdmin,
+ mentionableMembers,
onAdd,
onEdit,
onDelete,
@@ -500,6 +608,7 @@ const ThreadBlock: React.FC = ({
= ({
= ({
placeholder={t("cloud.comments.replyPlaceholder")}
submitLabel={t("cloud.comments.reply")}
autoFocus
- onSubmit={async (body) => {
- await onAdd(body, thread.top.id);
+ mentionableMembers={mentionableMembers}
+ onSubmit={async (body, mentionedUserIds) => {
+ await onAdd(body, thread.top.id, mentionedUserIds);
setReplying(false);
}}
onCancel={() => setReplying(false)}
@@ -569,6 +680,7 @@ const CommentThreadList: React.FC = ({
composerPlaceholder,
onComposerCancel,
emptyLabel,
+ mentionableMembers: mentionableMembersOverride,
onAdd,
onEdit,
onDelete,
@@ -576,6 +688,11 @@ const CommentThreadList: React.FC = ({
}) => {
const { t } = useTranslation("navigation");
const context = useSessionCommentsContext();
+ const mentionableMembers = (
+ mentionableMembersOverride ??
+ context?.mentionableMembers ??
+ []
+ ).filter((member) => member.userId !== viewerUserId);
const [showResolved, setShowResolved] = useState(false);
const openThreads = threads.filter((thread) => !isThreadResolved(thread));
@@ -583,8 +700,8 @@ const CommentThreadList: React.FC = ({
const requestAgent = context?.requestAgent;
const submitTopLevel = useCallback(
- async (body: string): Promise => {
- const comment = await onAdd(body);
+ async (body: string, mentionedUserIds: string[]): Promise => {
+ const comment = await onAdd(body, undefined, mentionedUserIds);
// Beyond here the comment IS posted — never throw (a throw would
// trigger the composer's draft restore for a send that succeeded).
if (!comment || comment.parentId) return;
@@ -612,6 +729,7 @@ const CommentThreadList: React.FC = ({
submitLabel={t("cloud.comments.send")}
disabled={composerDisabled}
allowAgentMention={Boolean(requestAgent && context?.canRunAgent)}
+ mentionableMembers={mentionableMembers}
onSubmit={submitTopLevel}
onCancel={onComposerCancel}
testId="session-comment-composer"
@@ -629,6 +747,7 @@ const CommentThreadList: React.FC = ({
thread={thread}
viewerUserId={viewerUserId}
viewerIsAdmin={viewerIsAdmin}
+ mentionableMembers={mentionableMembers}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
@@ -653,6 +772,7 @@ const CommentThreadList: React.FC = ({
thread={thread}
viewerUserId={viewerUserId}
viewerIsAdmin={viewerIsAdmin}
+ mentionableMembers={mentionableMembers}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx
index 09e6e2ce37..e9c7dc7575 100644
--- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx
+++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx
@@ -16,7 +16,7 @@
* session-id-keyed registry atom written here and read by
* `SessionCommentsHeaderExtras` (the header renders outside ChatView).
*/
-import { atom, useAtomValue, useSetAtom } from "jotai";
+import { atom, useAtomValue, useSetAtom, useStore } from "jotai";
import React, {
createContext,
useCallback,
@@ -24,6 +24,7 @@ import React, {
useEffect,
useId,
useMemo,
+ useState,
} from "react";
import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types";
@@ -34,14 +35,21 @@ import { getSessionForkedFrom } from "../../TeamCollaboration/forkSession";
import { collectAddressableThreads } from "../addressComments";
import { addressRunActiveAtom } from "../addressCommentsRun";
import {
+ commitRefreshedAuth,
org2CloudAuthAtom,
org2CloudAuthIdentityKey,
} from "../org2CloudAuthAtom";
+import { getCloudCapabilities } from "../org2CloudCapabilities";
+import type { CloudOrgMember } from "../org2CloudClient";
import type {
CloudCommentResolution,
CloudSessionComment,
} from "../org2CloudCommentsClient";
-import { org2CloudOrgsAtom } from "../org2CloudOrgsAtom";
+import { loadCloudOrgMembers } from "../org2CloudMembersCoordinator";
+import {
+ org2CloudOrgsAtom,
+ org2CloudRosterVersionAtom,
+} from "../org2CloudOrgsAtom";
import {
org2CloudRemoteSessionsAtom,
remoteSessionsEntryForIdentity,
@@ -134,6 +142,8 @@ export interface SessionCommentsContextValue {
viewerUserId: string | null;
/** Org admin/owner — may delete any comment (moderation surface). */
viewerIsAdmin: boolean;
+ /** Active org members available for identity-stable mentions. */
+ mentionableMembers: readonly CloudOrgMember[];
refresh: () => void;
addComment: (input: AddCommentInput) => Promise;
/**
@@ -172,6 +182,64 @@ export function useSessionCommentsContext(): SessionCommentsContextValue | null
return useContext(SessionCommentsContext);
}
+/**
+ * Roster reads share the app-wide coordinator and are keyed by account,
+ * endpoint, org, and roster revision. Late identity responses are discarded.
+ */
+export function useSessionCommentMentionableMembers(
+ target: SessionCommentTarget | null
+): readonly CloudOrgMember[] {
+ const store = useStore();
+ const auth = useAtomValue(org2CloudAuthAtom);
+ const setAuth = useSetAtom(org2CloudAuthAtom);
+ const rosterVersions = useAtomValue(org2CloudRosterVersionAtom);
+ const identityKey = auth ? org2CloudAuthIdentityKey(auth) : null;
+ const orgId = target?.orgId ?? null;
+ const rosterVersion = orgId ? (rosterVersions[orgId] ?? 0) : 0;
+ const requestKey =
+ identityKey && orgId ? `${identityKey}|${orgId}|${rosterVersion}` : null;
+ const [resolved, setResolved] = useState<{
+ key: string;
+ members: CloudOrgMember[];
+ } | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ if (!auth || !identityKey || !orgId || !requestKey) return;
+ const requestAuth = auth;
+ void Promise.all([
+ loadCloudOrgMembers(store, requestAuth, orgId, rosterVersion),
+ getCloudCapabilities(requestAuth.accessToken),
+ ])
+ .then(([loaded, capabilities]) => {
+ if (!loaded || cancelled) return;
+ commitRefreshedAuth(setAuth, requestAuth, loaded.auth);
+ const latestAuth = store.get(org2CloudAuthAtom);
+ if (
+ !latestAuth ||
+ org2CloudAuthIdentityKey(latestAuth) !== identityKey ||
+ (store.get(org2CloudRosterVersionAtom)[orgId] ?? 0) > rosterVersion
+ ) {
+ return;
+ }
+ setResolved({
+ key: requestKey,
+ members: capabilities.teamInboxMentions
+ ? loaded.members.filter((member) => member.status === "active")
+ : [],
+ });
+ })
+ .catch(() => {
+ if (!cancelled) setResolved({ key: requestKey, members: [] });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [auth, identityKey, orgId, requestKey, rosterVersion, setAuth, store]);
+
+ return resolved?.key === requestKey ? resolved.members : [];
+}
+
/**
* Viewer-side capability probes shared by the provider and the header
* extras (which runs its own instance because it mounts outside ChatView).
@@ -284,6 +352,7 @@ export const SessionCommentsProvider: React.FC<
originSessionId
);
const viewer = useSessionCommentViewer(target);
+ const mentionableMembers = useSessionCommentMentionableMembers(target);
const setPresentRegistry = useSetAtom(sessionCommentPresentEventIdsAtom);
// Publish the replay stream's event ids for the header notes dialog —
@@ -376,6 +445,7 @@ export const SessionCommentsProvider: React.FC<
canAnchorTurns: viewer.canAnchorTurns,
viewerUserId: viewer.viewerUserId,
viewerIsAdmin: viewer.viewerIsAdmin,
+ mentionableMembers,
refresh,
addComment,
editComment,
@@ -395,6 +465,7 @@ export const SessionCommentsProvider: React.FC<
toSourceEventId,
turnAnchorsVisible,
viewer,
+ mentionableMembers,
refresh,
addComment,
editComment,
diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx
index a1a7ef10b7..6a4ea3fede 100644
--- a/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx
+++ b/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx
@@ -37,6 +37,7 @@ import { useSessionCommentTarget } from "../sessionCommentTarget";
import CommentThreadList from "./CommentThreadList";
import {
sessionCommentPresentEventIdsAtom,
+ useSessionCommentMentionableMembers,
useSessionCommentViewer,
} from "./SessionCommentsContext";
@@ -65,6 +66,7 @@ const SessionCommentsHeaderExtras: React.FC<
: null
);
const viewer = useSessionCommentViewer(target);
+ const mentionableMembers = useSessionCommentMentionableMembers(target);
const presentRegistry = useAtomValue(sessionCommentPresentEventIdsAtom);
const [open, setOpen] = useState(false);
@@ -84,18 +86,22 @@ const SessionCommentsHeaderExtras: React.FC<
);
const handleAddNote = useCallback(
- async (body: string, parentId?: string) =>
+ async (body: string, parentId?: string, mentionedUserIds?: string[]) =>
// Session-level notes carry NO anchor; replies inherit the parent's.
// Returning the row satisfies the list's onAdd contract; the agent
// affordances stay dormant here regardless (no provider ⇒ null
// context in this dialog's tree).
- addComment(parentId ? { body, parentId } : { body }),
+ addComment(
+ parentId
+ ? { body, parentId, mentionedUserIds }
+ : { body, mentionedUserIds }
+ ),
[addComment]
);
const handleReplyOnly = useCallback(
- async (body: string, parentId?: string) => {
+ async (body: string, parentId?: string, mentionedUserIds?: string[]) => {
if (!parentId) return undefined;
- return addComment({ body, parentId });
+ return addComment({ body, parentId, mentionedUserIds });
},
[addComment]
);
@@ -149,6 +155,7 @@ const SessionCommentsHeaderExtras: React.FC<
threads={grouped.sessionLevel}
viewerUserId={viewer.viewerUserId}
viewerIsAdmin={viewer.viewerIsAdmin}
+ mentionableMembers={mentionableMembers}
emptyLabel={
state === "error"
? t("cloud.comments.loadError")
@@ -171,6 +178,7 @@ const SessionCommentsHeaderExtras: React.FC<
threads={grouped.orphaned}
viewerUserId={viewer.viewerUserId}
viewerIsAdmin={viewer.viewerIsAdmin}
+ mentionableMembers={mentionableMembers}
// New top-level anchors into a dropped event would be
// meaningless — replies/resolve on existing threads stay.
showComposer={false}
diff --git a/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx b/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx
index 66a852aa5c..5ccea10a1e 100644
--- a/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx
+++ b/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx
@@ -44,7 +44,8 @@ const TurnCommentChrome: React.FC = ({
const handleAdd = useCallback(
async (
body: string,
- parentId?: string
+ parentId?: string,
+ mentionedUserIds?: string[]
): Promise => {
if (!addComment) return undefined;
// Replies inherit the parent's anchor — never send both (0014
@@ -53,9 +54,10 @@ const TurnCommentChrome: React.FC = ({
// the SOURCE plane, so a fork/import's namespaced local id is stripped.
return addComment(
parentId
- ? { body, parentId }
+ ? { body, parentId, mentionedUserIds }
: {
body,
+ mentionedUserIds,
eventId: toSourceEventId
? toSourceEventId(anchorEventId)
: anchorEventId,
diff --git a/src/features/Org2Cloud/org2CloudCapabilities.test.ts b/src/features/Org2Cloud/org2CloudCapabilities.test.ts
index f94e74b3fc..c739d929a3 100644
--- a/src/features/Org2Cloud/org2CloudCapabilities.test.ts
+++ b/src/features/Org2Cloud/org2CloudCapabilities.test.ts
@@ -27,11 +27,13 @@ describe("getCloudCapabilities", () => {
broadcastSignals: true,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
expect(await getCloudCapabilities("jwt-1")).toEqual({
broadcastSignals: true,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
expect(rawMock).toHaveBeenCalledTimes(1);
});
@@ -45,6 +47,7 @@ describe("getCloudCapabilities", () => {
broadcastSignals: true,
storageSegments: true,
homeEndpoints: false,
+ teamInboxMentions: false,
});
});
@@ -58,6 +61,22 @@ describe("getCloudCapabilities", () => {
broadcastSignals: true,
storageSegments: true,
homeEndpoints: true,
+ teamInboxMentions: false,
+ });
+ });
+
+ it("parses the 0010 Team Inbox mention capability", async () => {
+ rawMock.mockResolvedValueOnce({
+ broadcastSignals: true,
+ storageSegments: true,
+ homeEndpoints: true,
+ teamInboxMentions: true,
+ });
+ expect(await getCloudCapabilities("jwt-1")).toEqual({
+ broadcastSignals: true,
+ storageSegments: true,
+ homeEndpoints: true,
+ teamInboxMentions: true,
});
});
@@ -67,12 +86,14 @@ describe("getCloudCapabilities", () => {
broadcastSignals: false,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
rawMock.mockResolvedValueOnce({ broadcastSignals: true });
expect(await getCloudCapabilities("jwt-1")).toEqual({
broadcastSignals: true,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
expect(rawMock).toHaveBeenCalledTimes(2);
});
@@ -87,11 +108,13 @@ describe("getCloudCapabilities", () => {
broadcastSignals: false,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
expect(await getCloudCapabilities("jwt-1")).toEqual({
broadcastSignals: false,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
expect(rawMock).toHaveBeenCalledTimes(1);
});
@@ -110,11 +133,13 @@ describe("getCloudCapabilities", () => {
broadcastSignals: true,
storageSegments: true,
homeEndpoints: false,
+ teamInboxMentions: false,
});
expect(await second).toEqual({
broadcastSignals: true,
storageSegments: true,
homeEndpoints: false,
+ teamInboxMentions: false,
});
expect(rawMock).toHaveBeenCalledTimes(1);
});
diff --git a/src/features/Org2Cloud/org2CloudCapabilities.ts b/src/features/Org2Cloud/org2CloudCapabilities.ts
index b59d7b2e8c..c819b44592 100644
--- a/src/features/Org2Cloud/org2CloudCapabilities.ts
+++ b/src/features/Org2Cloud/org2CloudCapabilities.ts
@@ -14,18 +14,21 @@ const CloudCapabilitiesWireSchema = z.object({
broadcastSignals: z.boolean().nullish().catch(undefined),
storageSegments: z.boolean().nullish().catch(undefined),
homeEndpoints: z.boolean().nullish().catch(undefined),
+ teamInboxMentions: z.boolean().nullish().catch(undefined),
});
export interface CloudCapabilities {
broadcastSignals: boolean;
storageSegments: boolean;
homeEndpoints: boolean;
+ teamInboxMentions: boolean;
}
const LEGACY_CAPABILITIES: CloudCapabilities = {
broadcastSignals: false,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
};
const capabilitiesByEndpoint = new Map();
@@ -52,6 +55,7 @@ export async function getCloudCapabilities(
broadcastSignals: parsed.data.broadcastSignals ?? false,
storageSegments: parsed.data.storageSegments ?? false,
homeEndpoints: parsed.data.homeEndpoints ?? false,
+ teamInboxMentions: parsed.data.teamInboxMentions ?? false,
};
capabilitiesByEndpoint.set(endpointKey, capabilities);
return capabilities;
diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts
index 9b1b46e168..2842dfcec5 100644
--- a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts
+++ b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts
@@ -173,6 +173,30 @@ describe("addSessionComment", () => {
expect(lastBody().p_event_id).toBeNull();
});
+ it("uses the atomic mentions RPC with deduplicated member ids", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({
+ comment: {
+ ...WIRE_COMMENT,
+ mentionedUserIds: ["user-2", "user-3"],
+ },
+ })
+ );
+
+ const comment = await addSessionComment("jwt-1", {
+ orgId: "org-1",
+ sessionId: "sess-1",
+ body: "Please review",
+ mentionedUserIds: ["user-2", "user-2", "user-3"],
+ });
+
+ expect(lastCall().url).toBe(
+ `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_add_session_comment_with_mentions`
+ );
+ expect(lastBody().p_mentioned_user_ids).toEqual(["user-2", "user-3"]);
+ expect(comment.mentionedUserIds).toEqual(["user-2", "user-3"]);
+ });
+
it("sends JWT bearer + Content-Profile", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ comment: WIRE_COMMENT }));
await addSessionComment("jwt-9", {
diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.ts b/src/features/Org2Cloud/org2CloudCommentsClient.ts
index 98615ef918..55c8d625f2 100644
--- a/src/features/Org2Cloud/org2CloudCommentsClient.ts
+++ b/src/features/Org2Cloud/org2CloudCommentsClient.ts
@@ -173,6 +173,8 @@ const CloudSessionCommentWireSchema = z.object({
.nullish()
.transform((value) => value ?? undefined)
.optional(),
+ /** Explicit user ids targeted by the comment (0010 Team Inbox). */
+ mentionedUserIds: z.array(z.string()).max(50).optional(),
});
export type CloudSessionComment = z.output<
@@ -217,6 +219,11 @@ export interface AddSessionCommentInput {
parentId?: string;
/** 'agent_report' — accepted only from the cloud-session owner. */
kind?: "agent_report";
+ /**
+ * Explicit active org-member ids to notify. Display names are never parsed
+ * server-side because they are mutable and may not be unique.
+ */
+ mentionedUserIds?: string[];
/**
* Local session the comment ORIGINATED from (the fork the author is
* viewing). Stored server-side for per-fork count attribution; omitted /
@@ -248,10 +255,21 @@ export async function addSessionComment(
// pre-extension-compat rule as p_kind). Only forks/imports set it — a
// source-plane comment omits it and coalesces to the source at count time.
if (input.originSessionId) body.p_origin_session_id = input.originSessionId;
+ const mentionedUserIds = [
+ ...new Set(input.mentionedUserIds?.filter(Boolean) ?? []),
+ ];
+ if (mentionedUserIds.length > 50) {
+ throw new Org2CloudCommentError("ORG2_VALIDATION");
+ }
+ if (mentionedUserIds.length > 0) {
+ body.p_mentioned_user_ids = mentionedUserIds;
+ }
let payload: unknown;
try {
payload = await callCommentRpc(
- "cloud_add_session_comment",
+ mentionedUserIds.length > 0
+ ? "cloud_add_session_comment_with_mentions"
+ : "cloud_add_session_comment",
accessToken,
body
);
@@ -262,6 +280,7 @@ export async function addSessionComment(
// plane); per-fork attribution just waits for the migration.
if (
"p_origin_session_id" in body &&
+ mentionedUserIds.length === 0 &&
error instanceof Org2CloudCommentError &&
error.status === 404
) {
diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts
index 738777dbfe..7bdd577605 100644
--- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts
+++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts
@@ -489,6 +489,7 @@ export function useSessionComments(
body: input.body,
eventId: input.eventId,
parentId: input.parentId,
+ mentionedUserIds: input.mentionedUserIds,
...(originSessionId && originSessionId !== sessionId
? { originSessionId }
: {}),
diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts
index 847c457a54..6252dc2e53 100644
--- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts
+++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts
@@ -61,6 +61,8 @@ export interface AddCommentInput {
body: string;
eventId?: string;
parentId?: string;
+ /** Active cloud-org members explicitly notified by this comment. */
+ mentionedUserIds?: string[];
}
export interface UseSessionCommentsResult {
diff --git a/src/features/Org2Cloud/org2CloudSyncClient.test.ts b/src/features/Org2Cloud/org2CloudSyncClient.test.ts
index aead57f075..d3ebafd063 100644
--- a/src/features/Org2Cloud/org2CloudSyncClient.test.ts
+++ b/src/features/Org2Cloud/org2CloudSyncClient.test.ts
@@ -62,6 +62,7 @@ beforeEach(() => {
broadcastSignals: false,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
});
@@ -260,6 +261,7 @@ describe("storage segment offload (0006)", () => {
broadcastSignals: false,
storageSegments: true,
homeEndpoints: false,
+ teamInboxMentions: false,
});
});
@@ -340,6 +342,7 @@ describe("storage segment offload (0006)", () => {
broadcastSignals: false,
storageSegments: false,
homeEndpoints: false,
+ teamInboxMentions: false,
});
await appendSessionEvents("jwt-1", appendInput([makeEvent("f1")], null));
expect(fetchMock).toHaveBeenCalledTimes(1);
diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
index 0618277eb8..680cdc480f 100644
--- a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
+++ b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
@@ -6,8 +6,14 @@ import {
ORG2_CLOUD_OFFICIAL_SUPABASE_URL,
ORG2_CLOUD_POSTGREST_SCHEMA,
} from "./config";
+import { __CAPABILITIES_INTERNALS } from "./org2CloudCapabilities";
import { Org2CloudCommentError } from "./org2CloudCommentsClient";
-import { listTeamInboxMentions } from "./teamInboxMentionsClient";
+import {
+ listInitialTeamInboxMentions,
+ listTeamInboxMentions,
+ markAllTeamInboxMentionsRead,
+ setTeamInboxMentionRead,
+} from "./teamInboxMentionsClient";
const fetchMock = vi.fn();
@@ -33,6 +39,7 @@ const WIRE_MENTION = {
author: { userId: "user-a", displayName: "Alice" },
body: "Please review this change",
createdAt: "2026-07-23T10:00:00.000Z",
+ readAt: null,
commentCount: 4,
threadCount: 2,
};
@@ -44,12 +51,74 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
fetchMock.mockReset();
+ __CAPABILITIES_INTERNALS.reset();
+});
+
+describe("listInitialTeamInboxMentions", () => {
+ it("keeps older endpoints on the local-only path without probing a missing RPC", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({
+ broadcastSignals: true,
+ storageSegments: true,
+ teamInboxMentions: false,
+ })
+ );
+
+ await expect(
+ listInitialTeamInboxMentions("jwt-viewer", "org-1")
+ ).resolves.toEqual({ mentions: [], unreadCount: 0 });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(lastCall().url).toBe(
+ `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/get_cloud_capabilities`
+ );
+ });
+
+ it("loads the first page after the endpoint advertises mention support", async () => {
+ fetchMock
+ .mockResolvedValueOnce(
+ jsonResponse({
+ broadcastSignals: true,
+ storageSegments: true,
+ teamInboxMentions: true,
+ })
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ mentions: [WIRE_MENTION],
+ nextCursor: null,
+ unreadCount: 1,
+ })
+ );
+
+ await expect(
+ listInitialTeamInboxMentions("jwt-viewer", "org-1", 25)
+ ).resolves.toEqual({
+ mentions: [WIRE_MENTION],
+ nextCursor: undefined,
+ unreadCount: 1,
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(lastCall().url).toBe(
+ `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_list_team_inbox_mentions`
+ );
+ expect(lastBody()).toEqual({
+ p_org_id: "org-1",
+ p_cursor: null,
+ p_limit: 25,
+ });
+ });
});
describe("listTeamInboxMentions", () => {
it("posts the managed-cloud wire contract without a viewer identity", async () => {
fetchMock.mockResolvedValueOnce(
- jsonResponse({ mentions: [WIRE_MENTION], nextCursor: "cursor-2" })
+ jsonResponse({
+ mentions: [WIRE_MENTION],
+ nextCursor: "cursor-2",
+ unreadCount: 7,
+ })
);
await listTeamInboxMentions("jwt-viewer", "org-1", "cursor-1", 25);
@@ -76,7 +145,7 @@ describe("listTeamInboxMentions", () => {
it("sends a null cursor for the first page", async () => {
fetchMock.mockResolvedValueOnce(
- jsonResponse({ mentions: [], nextCursor: null })
+ jsonResponse({ mentions: [], nextCursor: null, unreadCount: 0 })
);
await listTeamInboxMentions("jwt-viewer", "org-1", null, 50);
@@ -90,7 +159,11 @@ describe("listTeamInboxMentions", () => {
it("parses the stable mention response contract", async () => {
fetchMock.mockResolvedValueOnce(
- jsonResponse({ mentions: [WIRE_MENTION], nextCursor: "cursor-2" })
+ jsonResponse({
+ mentions: [WIRE_MENTION],
+ nextCursor: "cursor-2",
+ unreadCount: 7,
+ })
);
const page = await listTeamInboxMentions("jwt-viewer", "org-1", null, 25);
@@ -98,6 +171,7 @@ describe("listTeamInboxMentions", () => {
expect(page).toEqual({
mentions: [WIRE_MENTION],
nextCursor: "cursor-2",
+ unreadCount: 7,
});
});
@@ -113,6 +187,7 @@ describe("listTeamInboxMentions", () => {
},
],
nextCursor: null,
+ unreadCount: 1,
})
);
@@ -131,6 +206,7 @@ describe("listTeamInboxMentions", () => {
jsonResponse({
mentions: [{ ...WIRE_MENTION, commentCount: -1 }],
nextCursor: null,
+ unreadCount: 1,
})
);
@@ -163,3 +239,51 @@ describe("listTeamInboxMentions", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+
+describe("Team Inbox read receipts", () => {
+ it("persists a single receipt without sending a viewer id", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({
+ readAt: "2026-07-27T12:00:00.000Z",
+ unreadCount: 2,
+ })
+ );
+
+ const result = await setTeamInboxMentionRead(
+ "jwt-viewer",
+ "org-1",
+ "comment-2",
+ true
+ );
+
+ expect(lastCall().url).toBe(
+ `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_set_team_inbox_mention_read`
+ );
+ expect(lastBody()).toEqual({
+ p_org_id: "org-1",
+ p_comment_id: "comment-2",
+ p_read: true,
+ });
+ expect(lastBody()).not.toHaveProperty("p_viewer_user_id");
+ expect(result).toEqual({
+ readAt: "2026-07-27T12:00:00.000Z",
+ unreadCount: 2,
+ });
+ });
+
+ it("marks all server-side, including unloaded pages", async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({
+ readAt: "2026-07-27T12:00:00.000Z",
+ unreadCount: 0,
+ })
+ );
+
+ await markAllTeamInboxMentionsRead("jwt-viewer", "org-1");
+
+ expect(lastCall().url).toBe(
+ `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_mark_all_team_inbox_mentions_read`
+ );
+ expect(lastBody()).toEqual({ p_org_id: "org-1" });
+ });
+});
diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.ts b/src/features/Org2Cloud/teamInboxMentionsClient.ts
index c298e0edc1..9b4af2ee56 100644
--- a/src/features/Org2Cloud/teamInboxMentionsClient.ts
+++ b/src/features/Org2Cloud/teamInboxMentionsClient.ts
@@ -1,9 +1,14 @@
import { z } from "zod/v4";
import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config";
+import { getCloudCapabilities } from "./org2CloudCapabilities";
import { Org2CloudCommentError } from "./org2CloudCommentsClient";
+import { fetchWithTransportRetry } from "./org2CloudFetchRetry";
const TEAM_INBOX_MENTIONS_RPC = "cloud_list_team_inbox_mentions";
+const SET_TEAM_INBOX_MENTION_READ_RPC = "cloud_set_team_inbox_mention_read";
+const MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC =
+ "cloud_mark_all_team_inbox_mentions_read";
const TeamInboxMentionRequestSchema = z.object({
orgId: z.string().min(1),
@@ -32,6 +37,7 @@ const TeamInboxMentionSchema = z.object({
}),
body: z.string(),
createdAt: z.string(),
+ readAt: z.string().nullable(),
commentCount: z.number().int().nonnegative(),
threadCount: z.number().int().nonnegative(),
});
@@ -39,6 +45,7 @@ const TeamInboxMentionSchema = z.object({
const TeamInboxMentionsPageSchema = z.object({
mentions: z.array(TeamInboxMentionSchema).default([]),
nextCursor: NullableStringSchema,
+ unreadCount: z.number().int().nonnegative(),
});
export type TeamInboxMention = z.output;
@@ -46,25 +53,32 @@ export type TeamInboxMention = z.output;
export interface TeamInboxMentionsPage {
mentions: TeamInboxMention[];
nextCursor?: string;
+ unreadCount: number;
}
-/**
- * Lists managed-cloud comment mentions for the authenticated viewer.
- *
- * The viewer is derived by the RPC from the JWT bearer token. The client does
- * not accept or send a viewer/user id, inspect comment bodies for mentions, or
- * maintain a local projection of the result.
- */
-export async function listTeamInboxMentions(
+const EMPTY_TEAM_INBOX_MENTIONS_PAGE: TeamInboxMentionsPage = {
+ mentions: [],
+ unreadCount: 0,
+};
+
+const TeamInboxReadMutationSchema = z.object({
+ readAt: z.string().nullable(),
+ unreadCount: z.number().int().nonnegative(),
+});
+
+export interface TeamInboxReadMutation {
+ readAt: string | null;
+ unreadCount: number;
+}
+
+async function callTeamInboxRpc(
+ functionName: string,
accessToken: string,
- orgId: string,
- cursor: string | null,
- limit: number
-): Promise {
- const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit });
+ body: Record
+): Promise {
const endpoint = getCloudEndpoint();
- const response = await fetch(
- `${endpoint.supabaseUrl}/rest/v1/rpc/${TEAM_INBOX_MENTIONS_RPC}`,
+ const response = await fetchWithTransportRetry(
+ `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`,
{
method: "POST",
headers: {
@@ -73,11 +87,7 @@ export async function listTeamInboxMentions(
"content-type": "application/json",
"content-profile": ORG2_CLOUD_POSTGREST_SCHEMA,
},
- body: JSON.stringify({
- p_org_id: input.orgId,
- p_cursor: input.cursor,
- p_limit: input.limit,
- }),
+ body: JSON.stringify(body),
}
);
@@ -93,9 +103,79 @@ export async function listTeamInboxMentions(
const message =
payload && typeof payload === "object" && "message" in payload
? String((payload as { message: unknown }).message)
- : `org2_cloud rpc ${TEAM_INBOX_MENTIONS_RPC} failed with ${response.status}`;
+ : `org2_cloud rpc ${functionName} failed with ${response.status}`;
throw new Org2CloudCommentError(message, response.status);
}
+ return payload;
+}
+/**
+ * Lists managed-cloud comment mentions for the authenticated viewer.
+ *
+ * The viewer is derived by the RPC from the JWT bearer token. The client does
+ * not accept or send a viewer/user id, inspect comment bodies for mentions, or
+ * maintain a local projection of the result.
+ */
+export async function listTeamInboxMentions(
+ accessToken: string,
+ orgId: string,
+ cursor: string | null,
+ limit: number
+): Promise {
+ const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit });
+ const payload = await callTeamInboxRpc(TEAM_INBOX_MENTIONS_RPC, accessToken, {
+ p_org_id: input.orgId,
+ p_cursor: input.cursor,
+ p_limit: input.limit,
+ });
return TeamInboxMentionsPageSchema.parse(payload);
}
+
+/**
+ * Lists the first mention page only when the endpoint advertises migration
+ * 0010. Older deployments keep local assigned work available without probing
+ * a missing RPC.
+ */
+export async function listInitialTeamInboxMentions(
+ accessToken: string,
+ orgId: string,
+ limit = 50
+): Promise {
+ const capabilities = await getCloudCapabilities(accessToken);
+ if (!capabilities.teamInboxMentions) {
+ return EMPTY_TEAM_INBOX_MENTIONS_PAGE;
+ }
+ return listTeamInboxMentions(accessToken, orgId, null, limit);
+}
+
+/** Persists one viewer-scoped mention receipt. The viewer comes from JWT. */
+export async function setTeamInboxMentionRead(
+ accessToken: string,
+ orgId: string,
+ commentId: string,
+ read: boolean
+): Promise {
+ const payload = await callTeamInboxRpc(
+ SET_TEAM_INBOX_MENTION_READ_RPC,
+ accessToken,
+ {
+ p_org_id: z.string().min(1).parse(orgId),
+ p_comment_id: z.string().min(1).parse(commentId),
+ p_read: read,
+ }
+ );
+ return TeamInboxReadMutationSchema.parse(payload);
+}
+
+/** Marks every currently visible mention read, including unloaded pages. */
+export async function markAllTeamInboxMentionsRead(
+ accessToken: string,
+ orgId: string
+): Promise {
+ const payload = await callTeamInboxRpc(
+ MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC,
+ accessToken,
+ { p_org_id: z.string().min(1).parse(orgId) }
+ );
+ return TeamInboxReadMutationSchema.parse(payload);
+}
diff --git a/src/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json
index 876781859c..6a295dc9b3 100644
--- a/src/i18n/locales/de/navigation.json
+++ b/src/i18n/locales/de/navigation.json
@@ -698,7 +698,9 @@
"addressConfirm_other": "{{count}} Kommentare bearbeiten",
"agentAuthor": "Agent @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Erwähnen",
+ "searchMembers": "Mitglieder suchen"
},
"sharingFloor": {
"label": "Minimale Freigabestufe",
diff --git a/src/i18n/locales/en/navigation.json b/src/i18n/locales/en/navigation.json
index 4669845aef..0347ede927 100644
--- a/src/i18n/locales/en/navigation.json
+++ b/src/i18n/locales/en/navigation.json
@@ -725,7 +725,9 @@
"addressRoundScope": "Round comments",
"addressConfirm_one": "Address {{count}} comment",
"addressConfirm_other": "Address {{count}} comments",
- "agentAuthor": "Agent @{{name}}"
+ "agentAuthor": "Agent @{{name}}",
+ "mentionMembers": "Mention",
+ "searchMembers": "Search members"
},
"billing": {
"openFailed": "Couldn't open billing. Please try again."
diff --git a/src/i18n/locales/es/navigation.json b/src/i18n/locales/es/navigation.json
index 9925098947..8c5c9494cf 100644
--- a/src/i18n/locales/es/navigation.json
+++ b/src/i18n/locales/es/navigation.json
@@ -698,7 +698,9 @@
"addressConfirm_other": "Atender {{count}} comentarios",
"agentAuthor": "Agente @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Mencionar",
+ "searchMembers": "Buscar miembros"
},
"sharingFloor": {
"label": "Nivel mínimo de uso compartido",
diff --git a/src/i18n/locales/fr/navigation.json b/src/i18n/locales/fr/navigation.json
index df12cf29f9..c79e823e33 100644
--- a/src/i18n/locales/fr/navigation.json
+++ b/src/i18n/locales/fr/navigation.json
@@ -698,7 +698,9 @@
"addressConfirm_other": "Traiter {{count}} commentaires",
"agentAuthor": "Agent @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Mentionner",
+ "searchMembers": "Rechercher des membres"
},
"sharingFloor": {
"label": "Niveau de partage minimal",
diff --git a/src/i18n/locales/ja/navigation.json b/src/i18n/locales/ja/navigation.json
index 7f5a875063..198f5bbf3d 100644
--- a/src/i18n/locales/ja/navigation.json
+++ b/src/i18n/locales/ja/navigation.json
@@ -696,7 +696,9 @@
"addressConfirm_other": "{{count}} 件のコメントに対応",
"agentAuthor": "エージェント @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "メンバーをメンション",
+ "searchMembers": "メンバーを検索"
},
"sharingFloor": {
"label": "最小共有レベル",
diff --git a/src/i18n/locales/ko/navigation.json b/src/i18n/locales/ko/navigation.json
index 5c84b31cbe..2912fb73d3 100644
--- a/src/i18n/locales/ko/navigation.json
+++ b/src/i18n/locales/ko/navigation.json
@@ -696,7 +696,9 @@
"addressConfirm_other": "댓글 {{count}}개 처리",
"agentAuthor": "에이전트 @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "멤버 언급",
+ "searchMembers": "멤버 검색"
},
"sharingFloor": {
"label": "최소 공유 수준",
diff --git a/src/i18n/locales/pl/navigation.json b/src/i18n/locales/pl/navigation.json
index fa8f216e6b..61c7651e32 100644
--- a/src/i18n/locales/pl/navigation.json
+++ b/src/i18n/locales/pl/navigation.json
@@ -696,7 +696,9 @@
"addressConfirm_other": "Obsłuż {{count}} komentarzy",
"agentAuthor": "Agent @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Wspomnij",
+ "searchMembers": "Szukaj członków"
},
"sharingFloor": {
"label": "Minimalny poziom udostępniania",
diff --git a/src/i18n/locales/pt/navigation.json b/src/i18n/locales/pt/navigation.json
index f519c6e91f..75b15754b3 100644
--- a/src/i18n/locales/pt/navigation.json
+++ b/src/i18n/locales/pt/navigation.json
@@ -698,7 +698,9 @@
"addressConfirm_other": "Atender {{count}} comentários",
"agentAuthor": "Agente @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Mencionar",
+ "searchMembers": "Pesquisar membros"
},
"sharingFloor": {
"label": "Nível mínimo de compartilhamento",
diff --git a/src/i18n/locales/ru/navigation.json b/src/i18n/locales/ru/navigation.json
index b9eae94ea3..7b4fd5c78b 100644
--- a/src/i18n/locales/ru/navigation.json
+++ b/src/i18n/locales/ru/navigation.json
@@ -696,7 +696,9 @@
"addressConfirm_other": "Обработать {{count}} комментариев",
"agentAuthor": "Агент @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Упомянуть",
+ "searchMembers": "Поиск участников"
},
"sharingFloor": {
"label": "Минимальный уровень доступа",
diff --git a/src/i18n/locales/tr/navigation.json b/src/i18n/locales/tr/navigation.json
index 1e28eee2f3..449dc253df 100644
--- a/src/i18n/locales/tr/navigation.json
+++ b/src/i18n/locales/tr/navigation.json
@@ -696,7 +696,9 @@
"addressConfirm_other": "{{count}} yorumu ele al",
"agentAuthor": "Ajan @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Bahset",
+ "searchMembers": "Üye ara"
},
"sharingFloor": {
"label": "En düşük paylaşım düzeyi",
diff --git a/src/i18n/locales/vi/navigation.json b/src/i18n/locales/vi/navigation.json
index 5ab9a985a3..48b3521ef0 100644
--- a/src/i18n/locales/vi/navigation.json
+++ b/src/i18n/locales/vi/navigation.json
@@ -696,7 +696,9 @@
"addressConfirm_other": "Xử lý {{count}} bình luận",
"agentAuthor": "Agent @{{name}}",
"addressSessionScope": "Session notes",
- "addressRoundScope": "Round comments"
+ "addressRoundScope": "Round comments",
+ "mentionMembers": "Nhắc đến",
+ "searchMembers": "Tìm thành viên"
},
"sharingFloor": {
"label": "Mức chia sẻ tối thiểu",
diff --git a/src/i18n/locales/zh-Hant/navigation.json b/src/i18n/locales/zh-Hant/navigation.json
index f0aacf6246..ecc6e12724 100644
--- a/src/i18n/locales/zh-Hant/navigation.json
+++ b/src/i18n/locales/zh-Hant/navigation.json
@@ -788,7 +788,9 @@
"addressRoundScope": "逐輪評論",
"addressConfirm_one": "處理 {{count}} 條評論",
"addressConfirm_other": "處理 {{count}} 條評論",
- "agentAuthor": "Agent @{{name}}"
+ "agentAuthor": "Agent @{{name}}",
+ "mentionMembers": "提及成員",
+ "searchMembers": "搜尋成員"
},
"billing": {
"openFailed": "無法開啟帳單頁,請重試。"
diff --git a/src/i18n/locales/zh/navigation.json b/src/i18n/locales/zh/navigation.json
index cc7ec75b11..a876f3e1df 100644
--- a/src/i18n/locales/zh/navigation.json
+++ b/src/i18n/locales/zh/navigation.json
@@ -788,7 +788,9 @@
"addressRoundScope": "逐轮评论",
"addressConfirm_one": "处理 {{count}} 条评论",
"addressConfirm_other": "处理 {{count}} 条评论",
- "agentAuthor": "Agent @{{name}}"
+ "agentAuthor": "Agent @{{name}}",
+ "mentionMembers": "提及成员",
+ "searchMembers": "搜索成员"
},
"billing": {
"openFailed": "无法打开账单页,请重试。"
diff --git a/src/modules/MainApp/TeamInbox/TEST_CASES.md b/src/modules/MainApp/TeamInbox/TEST_CASES.md
index 79dbd57c5d..421a7f689b 100644
--- a/src/modules/MainApp/TeamInbox/TEST_CASES.md
+++ b/src/modules/MainApp/TeamInbox/TEST_CASES.md
@@ -8,15 +8,16 @@
- Mixed items are deduplicated and sorted by `occurredAt`, then stable item identity.
- Local assigned Work Items require explicit current-user member IDs.
- Local cursor pagination is stable when timestamps tie and when newer rows arrive.
-- Single and bulk read receipts are viewer-scoped and idempotent.
-- Managed-cloud mention responses are Zod-validated and never accept a caller-supplied viewer ID.
+- Local assignment and managed-cloud mention receipts are viewer-scoped and idempotent.
+- Managed-cloud mention responses are Zod-validated, include server-owned `readAt` + full-page-independent unread totals, and never accept a caller-supplied viewer ID.
+- Structured comment mentions send stable cloud user ids selected from the active roster; mutable/non-unique display names are never parsed as identities.
- Raw work-item status/priority enum tokens are humanized (`humanizeToken`) when no localized key exists, and never leak to the row or detail.
- Per-filter unread counts (`countUnreadTeamInboxItemsByFilter`) de-duplicate before counting and back the filter-tab badges.
- `filterItemKind` maps `all → null`, `mentions → comment_mention`, `assigned → assigned_work_item`.
- `searchTeamInboxItems` is case-insensitive, matches title/body/summary/people, returns a fresh copy for empty queries, and empty for no match.
- `groupTeamInboxItemsByRecency` buckets by local calendar day (Today/Yesterday/This week/Earlier), omits empty groups, keeps input order, and files unparseable timestamps under "earlier".
- Assigned items carry a trimmed, whitespace-folded, 240-char body excerpt as `summary`; blank bodies omit the field (`work_item_summary_excerpt`).
-- `mark_unread` deletes the viewer-scoped receipt so the item returns to unread, and is idempotent (a second call changes nothing); `removeTeamInboxCloudReadReceipts` deletes cloud receipt keys and returns the same reference when nothing changes.
+- `mark_unread` deletes the viewer-scoped local or cloud receipt so the item returns to unread and remains idempotent; cloud receipts are not owned by localStorage.
- `toWireCursorItemId` preserves the backend `work_item_assigned:` source prefix (strips only the UI `assigned_work_item:` kind prefix) so `Load more` cursor pagination round-trips instead of erroring.
## Presentation / polish
@@ -30,9 +31,48 @@
7. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list.
8. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries.
9. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes.
-10. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the local receipt). Re-marking read still works.
+10. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the managed-cloud receipt). Re-marking read still works after refresh or on another device.
11. When a source still has a next page, the list shows a `Load more` control; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more.
+## Unified Work Item thread
+
+| # | Steps | Expected result |
+| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | Select a project-scoped assigned Work Item. | The full Work Item uses the shared content and property components; the reduced Markdown/metadata preview is not rendered. |
+| 2 | Inspect a Work Item with linked Sessions. | Workflow and Session run cards appear inline in one continuous thread. The legacy `Session / Output / History` tab strip and linked-Session table are absent in Team Inbox. |
+| 3 | Activate `View live chat` / `View conversation` on a Session card. | A separate Session Chat Panel tab opens or the existing tab for that Session is focused. Team Inbox remains open as its singleton tab. |
+| 4 | Inspect a Work Item with proof of work and comments/history. | Output and activity render inline after the workflow; no second nested detail surface is introduced. |
+| 5 | Switch assigned rows while the first full Work Item is still loading. | A late response from the first row never replaces the newly selected Work Item. |
+| 6 | Make two property changes in quick succession. | Only the newest response may replace the displayed Work Item snapshot; both writes use the canonical partial-update payload. |
+| 7 | Open a standalone assigned Work Item. | The thread remains readable, but edit controls/property rail are not exposed because standalone persistence requires the owning frontmatter round-trip. |
+| 8 | Fail the selected Work Item read. | A visible error placeholder is shown; the short list row remains available for retry/navigation. |
+| 9 | Open a project Work Item with a short description. | The description renders at its natural Markdown height. `Preview / Raw` and the editor are absent until `Edit` is activated. |
+| 10 | Activate `Edit`, change the description, then cancel. | A compact editor and Cancel/Save footer appear; Save is disabled until content changes, and Cancel restores the original Markdown. |
+| 11 | Inspect a Work Item containing a persisted blank To-Do row. | The blank row is not rendered. The add input appears only after `Add` / `Add a to-do item` is activated. |
+| 12 | Add a To-Do with Enter, then rapidly toggle and remove items. | Only committed, trimmed items persist; every change uses the canonical Work Item update boundary. |
+| 13 | Inspect activity and comments. | Activity has one heading with its subscription action; the current-user avatar is attached to the comment composer instead of occupying a separate subscription row. |
+| 14 | Activate `Start Agent` on an idle Inbox Work Item. | The canonical Work Item tab opens/focuses, claims the one-shot `start_agent` request, and starts through its existing orchestrator. The Inbox never mounts a second orchestrator. |
+| 15 | Resize the detail from narrow to wide. | The thread remains a centered single reading column; compact property pills scroll horizontally instead of creating a competing right rail. |
+| 16 | Rapidly activate `Start Agent`, remount the Work Item panel, or request another Work Item before the first is claimed. | A claimed request starts exactly once and cannot replay; the newest unclaimed navigation intent supersedes the older one, which can never start later. |
+| 17 | Compare the To-Do and Agent Workflow cards, then collapse Workflow. | Both cards share one Work Item thread visual shell; Workflow retains its existing collapse behavior and To-Do remains independently interactive. |
+| 18 | Open Assignee or Reviewer in a project-scoped Inbox Work Item. | The picker contains the complete active project roster, resolves stored member ids to names, and persists through the canonical partial-update boundary. |
+| 19 | Inspect creator, comments, and history written with stored member ids. | Known ids resolve to project-member names; unknown ids remain visible instead of being guessed or silently blanked. |
+
+### Unified thread acceptance criteria
+
+- [ ] Team Inbox uses `presentation="thread"` while ordinary Work Item surfaces retain their existing default tabs/table.
+- [ ] `data-testid="work-item-thread-section"` is present and `data-testid="work-item-lower-tabs-section"` / `data-testid="work-item-linked-sessions"` are absent in Team Inbox.
+- [ ] The description is read-first and enters edit mode only through `data-testid="work-item-description-edit"`.
+- [ ] Blank To-Do rows are removed from the thread projection; the To-Do composer is demand-mounted.
+- [ ] Properties use the shared pill fields in the thread header and no separate heavy property-card rail is rendered.
+- [ ] `Open work item`, read/unread, subscription, and comment actions are grouped with their owning header/composer instead of occupying disconnected footer rows.
+- [ ] Session-card navigation uses the explicit `open_session` intent and the canonical open-or-focus Session-tab atom.
+- [ ] Team Inbox does not mount a second Work Item orchestrator; `Start Agent` forwards a one-shot action to the canonical Work Item tab, where lock validation, start, failure recovery, and refresh remain owned.
+- [ ] The one-shot action is consumed only by its matching Work Item and is cleared before the async start begins, preventing remount/double-effect replay.
+- [ ] At most one unclaimed start intent exists; a newer Work Item request explicitly supersedes the older intent instead of leaving a delayed start behind.
+- [ ] The centered reading frame and metadata band are composed by `WorkItemThreadLayout`; static card shells use `WorkItemThreadSection`, while collapsible Workflow shares tokens without duplicating collapse state.
+- [ ] No Session/comment transcript scan or frontend-fabricated impact data is introduced.
+
## Rendered product path
1. Seed or create a project member that matches the current Git identity.
@@ -41,8 +81,10 @@
4. Verify the assigned item appears and `分配给我` keeps it visible.
5. Open its detail, mark it read, and verify the row and Sidebar unread badge update together.
6. Close and reopen Team Inbox; verify the durable local receipt remains read.
-7. In a managed cloud org whose backend exposes `cloud_list_team_inbox_mentions`, create a comment mention through the normal Session comments UI.
-8. Verify `@ 提及` shows the stable comment/session target and source navigation opens the Session.
+7. In a managed cloud org, use the normal Session comment member picker to mention user B.
+8. In user B's independent app instance, verify `@ 提及` shows the stable comment/session target and unread badge.
+9. Open the row and verify the production click persists `readAt`; list again with user B's JWT and observe `unreadCount = 0`.
+10. List with user A's JWT and verify B's targeted mention is absent; refresh/reopen B's Inbox and verify it remains read.
## Degraded states
diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
index cec6bc6d29..effb3d294e 100644
--- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
+++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout";
@@ -14,7 +14,7 @@ import {
type TeamInboxFilter,
type TeamInboxItem,
type TeamInboxNavigationIntent,
- countUnreadTeamInboxItems,
+ type TeamInboxUnreadCounts,
countUnreadTeamInboxItemsByFilter,
filterItemKind,
getTeamInboxItemKey,
@@ -51,6 +51,8 @@ const TeamInboxView: React.FC = ({
const [filter, setFilter] = useState(initialFilter);
const [query, setQuery] = useState("");
const [items, setItems] = useState([]);
+ const [authoritativeUnreadCounts, setAuthoritativeUnreadCounts] =
+ useState(null);
const [recencyAnchorMs, setRecencyAnchorMs] = useState(() => Date.now());
const [requestedItemId, setRequestedItemId] = useState(null);
const [loadState, setLoadState] = useState({
@@ -60,6 +62,8 @@ const TeamInboxView: React.FC = ({
const [reloadRevision, setReloadRevision] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
+ const mutationEpochRef = useRef(0);
+ const mutationByItemRef = useRef(new Map());
useEffect(() => {
const abortController = new AbortController();
@@ -69,6 +73,7 @@ const TeamInboxView: React.FC = ({
.then((page) => {
if (abortController.signal.aborted) return;
setItems(page.items);
+ setAuthoritativeUnreadCounts(page.unreadCounts ?? null);
setRecencyAnchorMs(Date.now());
setHasMore(page.nextCursor != null);
setLoadState({ status: "ready", message: null });
@@ -98,11 +103,12 @@ const TeamInboxView: React.FC = ({
() => searchTeamInboxItems(selectTeamInboxItems(items, filter), query),
[filter, items, query]
);
- const totalUnread = useMemo(() => countUnreadTeamInboxItems(items), [items]);
- const unreadCounts = useMemo(
+ const loadedUnreadCounts = useMemo(
() => countUnreadTeamInboxItemsByFilter(items),
[items]
);
+ const unreadCounts = authoritativeUnreadCounts ?? loadedUnreadCounts;
+ const totalUnread = unreadCounts.all;
const selectedItem = useMemo(() => {
if (visibleItems.length === 0) return null;
return (
@@ -154,6 +160,31 @@ const TeamInboxView: React.FC = ({
});
};
+ const beginItemMutations = (itemIds: readonly string[]): number => {
+ const epoch = ++mutationEpochRef.current;
+ for (const itemId of itemIds) mutationByItemRef.current.set(itemId, epoch);
+ return epoch;
+ };
+
+ const isCurrentItemMutation = (itemId: string, epoch: number): boolean =>
+ mutationByItemRef.current.get(itemId) === epoch;
+
+ const updateUnreadCount = (kind: TeamInboxItem["kind"], delta: number) => {
+ setAuthoritativeUnreadCounts((current) => {
+ if (!current) return null;
+ const key =
+ kind === "comment_mention"
+ ? ("mentions" as const)
+ : ("assigned" as const);
+ const nextForKind = Math.max(0, current[key] + delta);
+ return {
+ ...current,
+ [key]: nextForKind,
+ all: Math.max(0, current.all + delta),
+ };
+ });
+ };
+
const markLocallyRead = (item: TeamInboxItem) => {
const readAt = new Date().toISOString();
setItems((current) =>
@@ -168,8 +199,20 @@ const TeamInboxView: React.FC = ({
const handleSelect = (item: TeamInboxItem) => {
setRequestedItemId(getTeamInboxItemKey(item));
if (item.readAt !== null) return;
+ const epoch = beginItemMutations([item.id]);
markLocallyRead(item);
+ updateUnreadCount(item.kind, -1);
void dataSource.markRead?.(item).catch(() => {
+ if (isCurrentItemMutation(item.id, epoch)) {
+ setItems((current) =>
+ current.map((candidate) =>
+ candidate.id === item.id
+ ? { ...candidate, readAt: null }
+ : candidate
+ )
+ );
+ updateUnreadCount(item.kind, 1);
+ }
setLoadState({
status: "error",
message: t("teamInbox.errors.markRead"),
@@ -179,8 +222,20 @@ const TeamInboxView: React.FC = ({
const handleMarkRead = (item: TeamInboxItem) => {
if (item.readAt !== null) return;
+ const epoch = beginItemMutations([item.id]);
markLocallyRead(item);
+ updateUnreadCount(item.kind, -1);
void dataSource.markRead?.(item).catch(() => {
+ if (isCurrentItemMutation(item.id, epoch)) {
+ setItems((current) =>
+ current.map((candidate) =>
+ candidate.id === item.id
+ ? { ...candidate, readAt: null }
+ : candidate
+ )
+ );
+ updateUnreadCount(item.kind, 1);
+ }
setLoadState({
status: "error",
message: t("teamInbox.errors.markRead"),
@@ -190,6 +245,8 @@ const TeamInboxView: React.FC = ({
const handleMarkUnread = (item: TeamInboxItem) => {
if (item.readAt === null) return;
+ const previousReadAt = item.readAt;
+ const epoch = beginItemMutations([item.id]);
setItems((current) =>
current.map((candidate) =>
getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item)
@@ -197,7 +254,18 @@ const TeamInboxView: React.FC = ({
: candidate
)
);
+ updateUnreadCount(item.kind, 1);
void dataSource.markUnread?.(item).catch(() => {
+ if (isCurrentItemMutation(item.id, epoch)) {
+ setItems((current) =>
+ current.map((candidate) =>
+ candidate.id === item.id
+ ? { ...candidate, readAt: previousReadAt }
+ : candidate
+ )
+ );
+ updateUnreadCount(item.kind, -1);
+ }
setLoadState({
status: "error",
message: t("teamInbox.errors.markUnread"),
@@ -212,15 +280,52 @@ const TeamInboxView: React.FC = ({
item.readAt === null &&
(targetKind === null || item.kind === targetKind)
);
- if (unreadItems.length === 0) return;
+ const filterUnreadCount =
+ filter === "all"
+ ? unreadCounts.all
+ : filter === "mentions"
+ ? unreadCounts.mentions
+ : unreadCounts.assigned;
+ if (filterUnreadCount === 0) return;
const readAt = new Date().toISOString();
- const markedIds = new Set(unreadItems.map((item) => item.id));
+ const affectedItems = items.filter(
+ (item) => targetKind === null || item.kind === targetKind
+ );
+ const previousReadAtById = new Map(
+ affectedItems.map((item) => [item.id, item.readAt])
+ );
+ const affectedIds = affectedItems.map((item) => item.id);
+ const epoch = beginItemMutations(affectedIds);
+ const previousCounts = authoritativeUnreadCounts;
+ const markedIds = new Set(affectedIds);
setItems((current) =>
current.map((item) =>
markedIds.has(item.id) ? { ...item, readAt } : item
)
);
- void dataSource.markAllRead?.(unreadItems).catch(() => {
+ setAuthoritativeUnreadCounts((current) => {
+ if (!current) return null;
+ const assigned =
+ filter === "all" || filter === "assigned" ? 0 : current.assigned;
+ const mentions =
+ filter === "all" || filter === "mentions" ? 0 : current.mentions;
+ return { all: assigned + mentions, assigned, mentions };
+ });
+ void dataSource.markAllRead?.(unreadItems, filter).catch(() => {
+ setItems((current) =>
+ current.map((item) =>
+ isCurrentItemMutation(item.id, epoch) &&
+ previousReadAtById.has(item.id)
+ ? {
+ ...item,
+ readAt: previousReadAtById.get(item.id) ?? null,
+ }
+ : item
+ )
+ );
+ if (affectedIds.every((itemId) => isCurrentItemMutation(itemId, epoch))) {
+ setAuthoritativeUnreadCounts(previousCounts);
+ }
setLoadState({
status: "error",
message: t("teamInbox.errors.markAllRead"),
@@ -281,11 +386,7 @@ const TeamInboxView: React.FC = ({
item={selectedItem}
onMarkRead={dataSource.markRead ? handleMarkRead : undefined}
onMarkUnread={dataSource.markUnread ? handleMarkUnread : undefined}
- onNavigate={
- onNavigate
- ? () => onNavigate(toTeamInboxNavigationIntent(selectedItem))
- : undefined
- }
+ onNavigate={onNavigate}
/>
);
})();
@@ -306,7 +407,7 @@ const TeamInboxView: React.FC = ({
minListWidth={160}
resizable
collapsible
- alwaysShowBreadcrumb
+ hideBreadcrumbWhenSidebarCollapsed
listPanelBackgroundClassName="bg-bg-2"
mainContentClassName="bg-bg-1"
listContent={
diff --git a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts
new file mode 100644
index 0000000000..fd347a904d
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts
@@ -0,0 +1,213 @@
+// @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 type { WorkItem } from "@src/types/core/workItem";
+
+import AssignedWorkItemDetail from "../components/AssignedWorkItemDetail";
+import type { AssignedWorkItem } from "../domain";
+
+const mocks = vi.hoisted(() => ({
+ workItem: {
+ session_id: "work-item-1",
+ user_id: "member-2",
+ name: "Add Team Inbox",
+ status: "backlog",
+ spec: "Build the reusable feature surface.",
+ star: false,
+ target_date: null,
+ created_time: "2026-07-23T10:00:00.000Z",
+ updated_time: "2026-07-23T10:00:00.000Z",
+ todos: [],
+ linkedSessions: [],
+ orchestratorConfig: {
+ review_enabled: true,
+ follow_up_enabled: true,
+ auto_retry_on_failure: false,
+ max_retry_count: 1,
+ auto_create_pr: false,
+ selected_account_id: "account-1",
+ selected_model_id: "model-1",
+ },
+ } as WorkItem,
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("../useTeamInboxWorkItem", () => ({
+ useTeamInboxWorkItem: () => ({
+ workItem: mocks.workItem,
+ status: "ready",
+ error: null,
+ repoPath: "/repo",
+ members: [],
+ updateWorkItem: vi.fn(),
+ refreshWorkItem: vi.fn(),
+ }),
+}));
+
+vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({
+ WorkItemProperties: ({ pillLayout }: { pillLayout?: string }) =>
+ createElement("div", {
+ "data-testid": "work-item-properties",
+ "data-pill-layout": pillLayout,
+ }),
+ WorkItemContent: ({
+ onStartAgent,
+ onOpenSession,
+ headerProperties,
+ }: {
+ onStartAgent?: () => void;
+ onOpenSession?: (sessionId: string) => void;
+ headerProperties?: React.ReactNode;
+ }) =>
+ createElement(
+ "div",
+ null,
+ headerProperties,
+ createElement(
+ "button",
+ {
+ type: "button",
+ "data-testid": "start-agent",
+ onClick: onStartAgent,
+ },
+ "Start Agent"
+ ),
+ createElement(
+ "button",
+ {
+ type: "button",
+ "data-testid": "open-session",
+ onClick: () => onOpenSession?.("session-1"),
+ },
+ "Open session"
+ )
+ ),
+}));
+
+vi.mock("../components/TeamInboxDetailLayout", () => ({
+ default: ({ children }: { children?: React.ReactNode }) =>
+ createElement("div", null, children),
+}));
+
+const item: AssignedWorkItem = {
+ id: "work-item-1",
+ kind: "assigned_work_item",
+ occurredAt: "2026-07-23T10:00:00.000Z",
+ readAt: null,
+ actor: { id: "member-2", displayName: "Lin" },
+ target: {
+ kind: "work_item",
+ projectId: "project-1",
+ workItemId: "work-item-1",
+ },
+ payload: {
+ title: "Add Team Inbox",
+ status: "in_progress",
+ priority: "high",
+ assigneeMemberId: "member-2",
+ updatedAt: "2026-07-23T10:00:00.000Z",
+ },
+};
+
+describe("AssignedWorkItemDetail navigation actions", () => {
+ 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(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ it("requests canonical Work Item start instead of mounting an Inbox orchestrator", () => {
+ const onNavigate = vi.fn();
+ act(() => {
+ root.render(
+ createElement(AssignedWorkItemDetail, {
+ item,
+ onNavigate,
+ })
+ );
+ });
+
+ act(() => {
+ container
+ .querySelector("[data-testid='start-agent']")
+ ?.click();
+ });
+
+ expect(onNavigate).toHaveBeenCalledWith({
+ kind: "open_work_item",
+ projectId: "project-1",
+ workItemId: "work-item-1",
+ action: "start_agent",
+ });
+ });
+
+ it("uses the responsive wrapping layout for constrained property pills", () => {
+ act(() => {
+ root.render(createElement(AssignedWorkItemDetail, { item }));
+ });
+
+ expect(
+ container
+ .querySelector("[data-testid='work-item-properties']")
+ ?.getAttribute("data-pill-layout")
+ ).toBe("wrap");
+ });
+
+ it("preserves linked-session navigation as a distinct Session tab intent", () => {
+ const onNavigate = vi.fn();
+ act(() => {
+ root.render(
+ createElement(AssignedWorkItemDetail, {
+ item,
+ onNavigate,
+ })
+ );
+ });
+
+ act(() => {
+ container
+ .querySelector("[data-testid='open-session']")
+ ?.click();
+ });
+
+ expect(onNavigate).toHaveBeenCalledWith({
+ kind: "open_session",
+ sessionId: "session-1",
+ });
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
index d5b7fceef8..031f5a960c 100644
--- a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
+++ b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
@@ -19,36 +19,36 @@ Behavior is derived from the shipped implementation, not aspirational.
## Happy Path
-| # | Steps | Expected Result |
-|---|-------|-----------------|
-| 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. |
-| 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. |
-| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both `localCursorRef` and `cloudCursorRef` are null, `hasMore` becomes false and the button disappears. |
-| 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). |
-| 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. |
+| # | Steps | Expected Result |
+| --- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. |
+| 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. |
+| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both `localCursorRef` and `cloudCursorRef` are null, `hasMore` becomes false and the button disappears. |
+| 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). |
+| 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. |
## Edge Cases
-| # | Scenario | Steps | Expected Result |
-|---|----------|-------|-----------------|
-| 1 | Empty inbox | Open inbox with 0 items. | Empty `Placeholder` renders; **no** "Load more" button (it lives in the items-present branch). |
-| 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. |
-| 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. |
-| 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). |
-| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | `loadingMoreRef` guard + `disabled={loadingMore}` ensure only one in-flight load; extra clicks are no-ops; no duplicated/skipped pages. |
-| 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. |
-| 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. |
-| 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). |
-| 9 | Refresh after paginating | Load more, then trigger refresh (manual or project-change signal). | Cursors reset to page 1; `hasMore` recomputed from page-1 cursors; list resets to first page. |
+| # | Scenario | Steps | Expected Result |
+| --- | ------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | Empty inbox | Open inbox with 0 items. | Empty `Placeholder` renders; **no** "Load more" button (it lives in the items-present branch). |
+| 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. |
+| 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. |
+| 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). |
+| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | `loadingMoreRef` guard + `disabled={loadingMore}` ensure only one in-flight load; extra clicks are no-ops; no duplicated/skipped pages. |
+| 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. |
+| 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. |
+| 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). |
+| 9 | Refresh after paginating | Load more, then trigger refresh (manual or project-change signal). | Cursors reset to page 1; `hasMore` recomputed from page-1 cursors; list resets to first page. |
## Error / Degraded States
-| # | Scenario | Steps | Expected Result |
-|---|----------|-------|-----------------|
-| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Caught inside `loadMore` (`.catch(() => ({ mentions: [], nextCursor: undefined }))`); cloud cursor becomes null (cloud pagination stops); local page still appends; no crash. |
-| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | `Promise.all` rejects → `handleLoadMore` catch sets the error banner (`teamInbox.errors.load`); `loadingMore` resets via `finally`; existing items remain. |
-| 3 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. |
-| 4 | Signed-out / no active cloud org | Only local paginates. | Cloud branch resolves to empty; only local advances; behavior identical to Edge #3. |
+| # | Scenario | Steps | Expected Result |
+| --- | ---------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Caught inside `loadMore` (`.catch(() => ({ mentions: [], nextCursor: undefined }))`); cloud cursor becomes null (cloud pagination stops); local page still appends; no crash. |
+| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | `Promise.all` rejects → `handleLoadMore` catch sets the error banner (`teamInbox.errors.load`); `loadingMore` resets via `finally`; existing items remain. |
+| 3 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. |
+| 4 | Signed-out / no active cloud org | Only local paginates. | Cloud branch resolves to empty; only local advances; behavior identical to Edge #3. |
## Accessibility
@@ -64,14 +64,14 @@ Behavior is derived from the shipped implementation, not aspirational.
- [ ] Appended pages are de-duplicated and correctly ordered by the view selectors.
- [ ] Concurrent/rapid load-more is guarded (single in-flight request).
- [ ] A cloud failure degrades gracefully (local still paginates); a local failure surfaces a non-blocking error banner without losing loaded items.
-- [ ] Unread badge semantics are unchanged by load-more (the single-source-of-truth question, A2, is intentionally out of scope here and documented in code).
+- [ ] Load-more never derives the badge from the loaded window; the server's authoritative mention count remains unchanged until a read mutation succeeds.
- [ ] `pnpm test` for `src/modules/MainApp/TeamInbox` passes; no new TypeScript/lint errors in edited files.
## Notes / Known limitations
-- The unread badge does **not** count unread mentions that only appear on page 2+
- (badge = local full-DB unread + first-page unread mentions). This matches the
- pre-A1 direction and is deferred to the A2 "unread single source of truth" task.
+- The unread badge uses the cloud RPC's authoritative full-result count, so
+ unread mentions on page 2+ are included before those rows are loaded.
- Hook-level behavior is not unit-tested (repo policy forbids `.tsx` / React
Testing Library tests); pure logic is covered by `selectors.test.ts`
- (dedupe/sort/select) and `store.test.ts`.
+ (dedupe/sort/select), while the two-instance rendered cloud spec covers the
+ production mention picker and durable read-receipt path.
diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts
new file mode 100644
index 0000000000..41599e518f
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts
@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+import { act, createElement } from "react";
+import { type Root, createRoot } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import TeamInboxView from "../TeamInboxView";
+
+const splitViewProps = vi.hoisted(() => ({
+ current: null as Record | null,
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("@src/modules/shared/layouts/SplitViewLayout", () => ({
+ default: (props: Record) => {
+ splitViewProps.current = props;
+ return createElement("div", { "data-testid": "team-inbox-split" });
+ },
+}));
+
+vi.mock("@src/modules/shared/layouts/blocks", () => ({
+ Placeholder: () => null,
+}));
+
+vi.mock("../components", () => ({
+ AssignedWorkItemDetail: () => null,
+ CommentMentionDetail: () => null,
+ TeamInboxList: () => null,
+}));
+
+describe("TeamInboxView split layout", () => {
+ 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(() => {
+ splitViewProps.current = null;
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ it("does not leak the global Code Editor breadcrumb into Team Inbox", () => {
+ act(() => {
+ root.render(
+ createElement(TeamInboxView, {
+ dataSource: {
+ listPage: () => new Promise(() => undefined),
+ },
+ })
+ );
+ });
+
+ expect(splitViewProps.current?.alwaysShowBreadcrumb).toBeUndefined();
+ expect(splitViewProps.current?.hideBreadcrumbWhenSidebarCollapsed).toBe(
+ true
+ );
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
index fd71a24346..988f187a47 100644
--- a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
+++ b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
@@ -2,10 +2,25 @@ import { describe, expect, it } from "vitest";
import {
humanizeToken,
+ isGitHubIssueStatus,
workItemPriorityLabelKey,
workItemStatusLabelKey,
} from "../domain/labels";
+describe("isGitHubIssueStatus", () => {
+ it("recognizes the GitHub issue status vocabulary", () => {
+ expect(isGitHubIssueStatus("open")).toBe(true);
+ expect(isGitHubIssueStatus("closed")).toBe(true);
+ });
+
+ it("rejects local Work Item statuses", () => {
+ expect(isGitHubIssueStatus("todo")).toBe(false);
+ expect(isGitHubIssueStatus("in_progress")).toBe(false);
+ expect(isGitHubIssueStatus("completed")).toBe(false);
+ expect(isGitHubIssueStatus("")).toBe(false);
+ });
+});
+
describe("humanizeToken", () => {
it("sentence-cases a snake_case enum token", () => {
expect(humanizeToken("in_progress")).toBe("In progress");
diff --git a/src/modules/MainApp/TeamInbox/__tests__/store.test.ts b/src/modules/MainApp/TeamInbox/__tests__/store.test.ts
deleted file mode 100644
index 05f66b60be..0000000000
--- a/src/modules/MainApp/TeamInbox/__tests__/store.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS,
- addTeamInboxCloudReadReceipts,
- removeTeamInboxCloudReadReceipts,
-} from "../store";
-
-describe("addTeamInboxCloudReadReceipts", () => {
- it("keeps the persisted receipt map bounded", () => {
- const current = Object.fromEntries(
- Array.from({ length: MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS }, (_, index) => [
- `receipt-${index}`,
- new Date(index).toISOString(),
- ])
- );
-
- const next = addTeamInboxCloudReadReceipts(current, {
- "receipt-new": "2026-07-23T12:00:00.000Z",
- });
-
- expect(Object.keys(next)).toHaveLength(MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS);
- expect(next).not.toHaveProperty("receipt-0");
- expect(next["receipt-new"]).toBe("2026-07-23T12:00:00.000Z");
- });
-
- it("refreshes an existing receipt without evicting an extra entry", () => {
- const next = addTeamInboxCloudReadReceipts(
- {
- first: "2026-07-23T10:00:00.000Z",
- second: "2026-07-23T11:00:00.000Z",
- },
- { first: "2026-07-23T12:00:00.000Z" }
- );
-
- expect(next).toEqual({
- second: "2026-07-23T11:00:00.000Z",
- first: "2026-07-23T12:00:00.000Z",
- });
- });
-});
-
-describe("removeTeamInboxCloudReadReceipts", () => {
- it("deletes the given receipt keys", () => {
- const next = removeTeamInboxCloudReadReceipts(
- {
- keep: "2026-07-23T10:00:00.000Z",
- drop: "2026-07-23T11:00:00.000Z",
- },
- ["drop"]
- );
-
- expect(next).toEqual({ keep: "2026-07-23T10:00:00.000Z" });
- });
-
- it("returns the same reference when nothing changes", () => {
- const current = { keep: "2026-07-23T10:00:00.000Z" };
- expect(removeTeamInboxCloudReadReceipts(current, [])).toBe(current);
- expect(removeTeamInboxCloudReadReceipts(current, ["missing"])).toBe(
- current
- );
- });
-});
diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
index ab80ecbe4d..afcce63421 100644
--- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
+++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
@@ -2,19 +2,32 @@ import { ClipboardList, ExternalLink } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
-import Markdown from "@src/components/MarkDown";
-import { CARD_ROW_TOKENS } from "@src/modules/shared/layouts/blocks";
+import {
+ WorkItemContent,
+ WorkItemProperties,
+} from "@src/modules/ProjectManager/WorkItems/components";
+import type { WorkItemPropertyFieldKey } from "@src/modules/ProjectManager/WorkItems/components/WorkItemProperties/types";
+import { Placeholder } from "@src/modules/shared/layouts/blocks";
+import type { Person } from "@src/types/core/shared";
+import type { WorkItem } from "@src/types/core/workItem";
import {
type AssignedWorkItem,
type TeamInboxNavigationIntent,
- humanizeToken,
- workItemPriorityLabelKey,
- workItemStatusLabelKey,
+ isGitHubIssueStatus,
} from "../domain";
-import { useTeamInboxWorkItemBody } from "../useTeamInboxWorkItemBody";
+import { useTeamInboxWorkItem } from "../useTeamInboxWorkItem";
import TeamInboxDetailLayout from "./TeamInboxDetailLayout";
+const WORK_ITEM_THREAD_PROPERTY_FIELDS: WorkItemPropertyFieldKey[] = [
+ "project",
+ "status",
+ "priority",
+ "assignee",
+ "reviewer",
+ "date",
+];
+
export interface AssignedWorkItemDetailProps {
item: AssignedWorkItem;
onNavigate?: (intent: TeamInboxNavigationIntent) => void;
@@ -22,6 +35,95 @@ export interface AssignedWorkItemDetailProps {
onMarkUnread?: (item: AssignedWorkItem) => void;
}
+interface AssignedWorkItemThreadProps {
+ item: AssignedWorkItem;
+ workItem: WorkItem;
+ repoPath: string | null;
+ members: Person[];
+ error: string | null;
+ updateWorkItem: (updates: Partial) => void;
+ refreshWorkItem: () => void;
+ onNavigate?: (intent: TeamInboxNavigationIntent) => void;
+}
+
+const AssignedWorkItemThread: React.FC = ({
+ item,
+ workItem,
+ repoPath,
+ members,
+ error,
+ updateWorkItem,
+ refreshWorkItem,
+ onNavigate,
+}) => {
+ const canUpdate = Boolean(item.target.projectId);
+ const isGitHubIssue = isGitHubIssueStatus(item.payload.status);
+
+ const properties = canUpdate ? (
+
+ ) : null;
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ onNavigate({
+ kind: "open_work_item",
+ projectId: item.target.projectId,
+ workItemId: item.target.workItemId,
+ action: "start_agent",
+ })
+ : undefined
+ }
+ onOpenSession={
+ onNavigate
+ ? (sessionId) =>
+ onNavigate({
+ kind: "open_session",
+ sessionId,
+ })
+ : undefined
+ }
+ onRefreshWorkflow={refreshWorkItem}
+ />
+
+
+ );
+};
+
const AssignedWorkItemDetail: React.FC = ({
item,
onNavigate,
@@ -29,25 +131,28 @@ const AssignedWorkItemDetail: React.FC = ({
onMarkUnread,
}) => {
const { t } = useTranslation();
- const { body } = useTeamInboxWorkItemBody(item.target);
- const excerpt = item.payload.summary ?? null;
- const statusLabel = t(workItemStatusLabelKey(item.payload.status), {
- defaultValue: humanizeToken(item.payload.status),
- });
- const priorityLabel = t(workItemPriorityLabelKey(item.payload.priority), {
- defaultValue: humanizeToken(item.payload.priority),
- });
+ const {
+ workItem,
+ status,
+ error,
+ repoPath,
+ members,
+ updateWorkItem,
+ refreshWorkItem,
+ } = useTeamInboxWorkItem(item.target);
return (
}
+ openPlacement="header"
onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined}
onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined}
onOpen={
@@ -60,32 +165,32 @@ const AssignedWorkItemDetail: React.FC = ({
})
: undefined
}
- metadata={[
- { label: t("teamInbox.fields.status"), value: statusLabel },
- { label: t("teamInbox.fields.priority"), value: priorityLabel },
- {
- label: t("teamInbox.fields.assignee"),
- value: item.payload.assigneeName ?? item.payload.assigneeMemberId,
- },
- {
- label: t("teamInbox.fields.workItemId"),
- value: item.target.workItemId,
- },
- ]}
>
- {body ? (
-
- ) : excerpt ? (
-
- ) : null}
+ {status === "loading" ? (
+
+ ) : status === "ready" && workItem ? (
+
+ ) : (
+
+ )}
);
};
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
index 8a8ff5713e..34e310b50c 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
@@ -16,7 +16,12 @@ export interface TeamInboxDetailLayoutProps {
title: string;
subtitle: string;
icon: LucideIcon;
- metadata: InfoCardRow[];
+ metadata?: InfoCardRow[];
+ /**
+ * `scroll` owns a padded detail column. `fill` lets a nested Work Item own
+ * its scrolling and responsive rail.
+ */
+ contentLayout?: "scroll" | "fill";
unread: boolean;
markReadLabel: string;
markUnreadLabel?: string;
@@ -25,6 +30,7 @@ export interface TeamInboxDetailLayoutProps {
onMarkRead?: () => void;
onMarkUnread?: () => void;
onOpen?: () => void;
+ openPlacement?: "header" | "footer";
children?: React.ReactNode;
}
@@ -33,6 +39,7 @@ const TeamInboxDetailLayout: React.FC = ({
subtitle,
icon,
metadata,
+ contentLayout = "scroll",
unread,
markReadLabel,
markUnreadLabel,
@@ -41,60 +48,96 @@ const TeamInboxDetailLayout: React.FC = ({
onMarkRead,
onMarkUnread,
onOpen,
+ openPlacement = "footer",
children,
-}) => (
-
- }
- onClick={onMarkRead}
- >
- {markReadLabel}
-
+}) => {
+ const readAction = unread ? (
+ onMarkRead ? (
+ }
+ onClick={onMarkRead}
+ >
+ {markReadLabel}
+
+ ) : null
+ ) : onMarkUnread && markUnreadLabel ? (
+ }
+ onClick={onMarkUnread}
+ >
+ {markUnreadLabel}
+
+ ) : null;
+ const headerOpenAction =
+ onOpen && openPlacement === "header" ? (
+
+ {openLabel}
+
+ ) : null;
+
+ return (
+
+
+ {readAction}
+ {headerOpenAction}
+
) : undefined
- ) : onMarkUnread && markUnreadLabel ? (
- }
- onClick={onMarkUnread}
- >
- {markUnreadLabel}
-
- ) : undefined
- }
- />
+ }
+ />
-
-
- {children ? (
-
{children}
- ) : null}
-
-
-
+ {contentLayout === "fill" ? (
+
+ {children}
+
+ ) : (
+
+
+ {children ? (
+
{children}
+ ) : null}
+ {metadata && metadata.length > 0 ? (
+
+ ) : null}
+
+
+ )}
- {onOpen ? (
-
- ) : null}
-
-);
+ {onOpen && openPlacement === "footer" ? (
+
+ ) : null}
+
+ );
+};
+
+/*
+ * Keep the detail shell shared across mention and assigned-item surfaces.
+ * Assigned Work Items opt into header placement so the thread owns the full
+ * vertical canvas; other sources retain the established footer action.
+ */
export default TeamInboxDetailLayout;
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
index 54d94cbb15..308b143266 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
@@ -185,6 +185,7 @@ const TeamInboxList: React.FC = ({
}
title={t("inbox.markAllAsRead")}
aria-label={t("inbox.markAllAsRead")}
+ data-testid="team-inbox-mark-all-read"
onClick={onMarkAllRead}
/>
) : null}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
index 1903b8bb60..1a2bf0f5ed 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
@@ -56,6 +56,9 @@ const TeamInboxRow = forwardRef(
aria-selected={selected}
aria-label={`${title},${readLabel}`}
tabIndex={selected ? 0 : -1}
+ data-testid="team-inbox-row"
+ data-item-kind={item.kind}
+ data-item-id={item.id}
data-unread={unread}
className={`${getListItemClasses(selected)} w-full min-w-0 !items-start text-left`}
onClick={() => onSelect(item)}
diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts
index 6c1dd973ee..516b2ffde6 100644
--- a/src/modules/MainApp/TeamInbox/domain/index.ts
+++ b/src/modules/MainApp/TeamInbox/domain/index.ts
@@ -18,6 +18,7 @@ export type {
} from "./selectors";
export {
humanizeToken,
+ isGitHubIssueStatus,
workItemPriorityLabelKey,
workItemStatusLabelKey,
} from "./labels";
diff --git a/src/modules/MainApp/TeamInbox/domain/labels.ts b/src/modules/MainApp/TeamInbox/domain/labels.ts
index f49ddb3a80..ebc635afe2 100644
--- a/src/modules/MainApp/TeamInbox/domain/labels.ts
+++ b/src/modules/MainApp/TeamInbox/domain/labels.ts
@@ -1,3 +1,13 @@
+import { WORK_ITEM_STATUS } from "@src/types/core/workItem";
+
+/** GitHub-backed Work Items use the open/closed status vocabulary. */
+export function isGitHubIssueStatus(status: string): boolean {
+ return (
+ status === WORK_ITEM_STATUS.GITHUB_OPEN ||
+ status === WORK_ITEM_STATUS.GITHUB_CLOSED
+ );
+}
+
/**
* Turns a raw enum token from the work-item read model (e.g. `in_progress`,
* `HIGH`, `in-review`) into a human sentence-cased label (`In progress`,
diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts
index 7da20f9cc8..1a59324a18 100644
--- a/src/modules/MainApp/TeamInbox/domain/types.ts
+++ b/src/modules/MainApp/TeamInbox/domain/types.ts
@@ -66,6 +66,12 @@ export interface TeamInboxCursor {
export interface TeamInboxPage {
items: TeamInboxItem[];
nextCursor: TeamInboxCursor | null;
+ /** Authoritative source totals; absent on lightweight/test data sources. */
+ unreadCounts?: {
+ all: number;
+ mentions: number;
+ assigned: number;
+ };
}
export interface ListTeamInboxInput {
@@ -84,7 +90,10 @@ export interface TeamInboxDataSource {
listPage(input: ListTeamInboxInput): Promise;
markRead?(item: TeamInboxItem): Promise;
markUnread?(item: TeamInboxItem): Promise;
- markAllRead?(items: readonly TeamInboxItem[]): Promise;
+ markAllRead?(
+ items: readonly TeamInboxItem[],
+ filter?: TeamInboxFilter
+ ): Promise;
refresh?(): Promise;
/**
* Loads the next page from every source that still has one and appends the
@@ -95,6 +104,10 @@ export interface TeamInboxDataSource {
}
export type TeamInboxNavigationIntent =
+ | {
+ kind: "open_session";
+ sessionId: string;
+ }
| {
kind: "open_session_comment";
sessionId: string;
@@ -106,4 +119,5 @@ export type TeamInboxNavigationIntent =
kind: "open_work_item";
projectId: string;
workItemId: string;
+ action?: "start_agent";
};
diff --git a/src/modules/MainApp/TeamInbox/store.ts b/src/modules/MainApp/TeamInbox/store.ts
index e0b26f36e1..dfa6da566b 100644
--- a/src/modules/MainApp/TeamInbox/store.ts
+++ b/src/modules/MainApp/TeamInbox/store.ts
@@ -1,11 +1,12 @@
import { atom } from "jotai";
-import { atomWithStorage } from "jotai/utils";
import type { TeamInboxItem } from "./domain";
+import type { TeamInboxUnreadCounts } from "./domain";
export interface TeamInboxCacheState {
items: TeamInboxItem[];
unreadCount: number;
+ unreadCounts: TeamInboxUnreadCounts;
loading: boolean;
error: string | null;
revision: number;
@@ -17,6 +18,7 @@ export interface TeamInboxCacheState {
export const teamInboxCacheAtom = atom({
items: [],
unreadCount: 0,
+ unreadCounts: { all: 0, mentions: 0, assigned: 0 },
loading: false,
error: null,
revision: 0,
@@ -33,54 +35,6 @@ teamInboxUnreadCountAtom.debugLabel = "teamInboxUnreadCountAtom";
export const teamInboxInvalidationAtom = atom(0);
teamInboxInvalidationAtom.debugLabel = "teamInboxInvalidationAtom";
-export type TeamInboxCloudReadReceipts = Record;
-export const MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS = 1_000;
-
-export function addTeamInboxCloudReadReceipts(
- current: TeamInboxCloudReadReceipts,
- additions: TeamInboxCloudReadReceipts
-): TeamInboxCloudReadReceipts {
- const next = { ...current };
- for (const [key, readAt] of Object.entries(additions)) {
- delete next[key];
- next[key] = readAt;
- }
- const keys = Object.keys(next);
- for (
- let index = 0;
- index < keys.length - MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS;
- index += 1
- ) {
- delete next[keys[index]!];
- }
- return next;
-}
-
-export function removeTeamInboxCloudReadReceipts(
- current: TeamInboxCloudReadReceipts,
- keys: readonly string[]
-): TeamInboxCloudReadReceipts {
- if (keys.length === 0) return current;
- let changed = false;
- const next = { ...current };
- for (const key of keys) {
- if (key in next) {
- delete next[key];
- changed = true;
- }
- }
- return changed ? next : current;
-}
-
-export const teamInboxCloudReadReceiptsAtom =
- atomWithStorage(
- "orgii:team-inbox:cloud-read-receipts",
- {},
- undefined,
- { getOnInit: true }
- );
-teamInboxCloudReadReceiptsAtom.debugLabel = "teamInboxCloudReadReceiptsAtom";
-
export const invalidateTeamInboxAtom = atom(null, (get, set) => {
set(teamInboxInvalidationAtom, get(teamInboxInvalidationAtom) + 1);
});
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
index 3812a06179..00be22929f 100644
--- a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
@@ -1,5 +1,12 @@
import { useAtomValue, useSetAtom } from "jotai";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { invalidateProjectCache, projectApi } from "@src/api/http/project";
import type { MemberEntry } from "@src/api/http/project";
@@ -14,7 +21,10 @@ import {
import { sidebarActiveCloudOrgIdAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom";
import {
type TeamInboxMention,
+ listInitialTeamInboxMentions,
listTeamInboxMentions,
+ markAllTeamInboxMentionsRead,
+ setTeamInboxMentionRead,
} from "@src/features/Org2Cloud/teamInboxMentionsClient";
import { useProjectDataChanged } from "@src/hooks/project";
import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId";
@@ -33,16 +43,13 @@ import type {
TeamInboxItem,
} from "./domain";
import {
- type TeamInboxCloudReadReceipts,
- addTeamInboxCloudReadReceipts,
invalidateTeamInboxAtom,
- removeTeamInboxCloudReadReceipts,
teamInboxCacheAtom,
- teamInboxCloudReadReceiptsAtom,
teamInboxInvalidationAtom,
} from "./store";
const listeners = new Set<() => void>();
+const MAX_PENDING_TEAM_INBOX_MUTATIONS = 100;
let membersRequest: Promise | null = null;
let inboxRequest: {
key: string;
@@ -50,19 +57,25 @@ let inboxRequest: {
mentionItems: TeamInboxItem[];
localItems: TeamInboxItem[];
localUnread: number;
+ cloudUnread: number;
localNextCursor: TeamInboxCursor | null;
cloudNextCursor: string | null;
}>;
} | null = null;
+const EMPTY_CLOUD_MENTION_PAGE = {
+ mentions: [],
+ nextCursor: undefined,
+ unreadCount: 0,
+} as const;
+
function notifyTeamInboxListeners(): void {
for (const listener of listeners) listener();
}
/**
- * Maps raw cloud mentions into Team Inbox items with `readAt` left unresolved;
- * the caller overlays the latest local read receipts afterwards. Shared by the
- * initial load and `loadMore` so both pages produce identical item shapes.
+ * Maps the server-authoritative cloud mention projection into Team Inbox
+ * items. Shared by initial load and pagination so both paths stay identical.
*/
function mapMentionsToItems(
mentions: readonly TeamInboxMention[],
@@ -74,7 +87,7 @@ function mapMentionsToItems(
id: itemId,
kind: "comment_mention" as const,
occurredAt: mention.createdAt,
- readAt: null,
+ readAt: mention.readAt,
actor: {
id: mention.author.userId,
displayName: mention.author.displayName ?? "Team member",
@@ -96,18 +109,6 @@ function mapMentionsToItems(
});
}
-/** Overlays the current cloud read receipts onto freshly-mapped mention items. */
-function overlayCloudReadReceipts(
- mentionItems: readonly TeamInboxItem[],
- cloudReadReceipts: TeamInboxCloudReadReceipts,
- cloudScopeKey: string
-): TeamInboxItem[] {
- return mentionItems.map((item) => ({
- ...item,
- readAt: cloudReadReceipts[`${cloudScopeKey}|${item.id}`] ?? null,
- }));
-}
-
/**
* Resolves each assigned item's display name from its stable `assigneeMemberId`
* into the optional `assigneeName` field. When the member cannot be resolved the
@@ -165,10 +166,6 @@ export function useTeamInboxDataSource(): {
const activeCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom);
const viewerKey = `${viewerMemberIds.join("|")}::${authIdentityKey ?? "signed-out"}::${activeCloudOrgId ?? "local"}`;
const commentsSignals = useAtomValue(org2CloudCommentsSignalAtom);
- const cloudReadReceipts = useAtomValue(teamInboxCloudReadReceiptsAtom);
- const cloudReadReceiptsRef = useRef(cloudReadReceipts);
- cloudReadReceiptsRef.current = cloudReadReceipts;
- const setCloudReadReceipts = useSetAtom(teamInboxCloudReadReceiptsAtom);
const activeCloudCommentsRevision = activeCloudOrgId
? (commentsSignals[orgCommentsKey(activeCloudOrgId)] ?? 0)
: 0;
@@ -179,6 +176,68 @@ export function useTeamInboxDataSource(): {
const localCursorRef = useRef(null);
const cloudCursorRef = useRef(null);
const loadingMoreRef = useRef(false);
+ const mutationQueueRef = useRef>(Promise.resolve());
+ const pendingMutationCountRef = useRef(0);
+
+ const enqueueMutation = useCallback(
+ (operation: () => Promise): Promise => {
+ if (pendingMutationCountRef.current >= MAX_PENDING_TEAM_INBOX_MUTATIONS) {
+ return Promise.reject(
+ new Error("Too many pending Team Inbox updates; try again shortly")
+ );
+ }
+ pendingMutationCountRef.current += 1;
+ const run = async (): Promise => {
+ try {
+ return await operation();
+ } finally {
+ pendingMutationCountRef.current = Math.max(
+ 0,
+ pendingMutationCountRef.current - 1
+ );
+ }
+ };
+ const result = mutationQueueRef.current.then(run, run);
+ mutationQueueRef.current = result.then(
+ () => undefined,
+ () => undefined
+ );
+ return result;
+ },
+ []
+ );
+
+ useLayoutEffect(() => {
+ if (
+ cache.loadedForViewerKey === null ||
+ cache.loadedForViewerKey === viewerKey
+ ) {
+ return;
+ }
+
+ // Never render the previous account/org projection while the new identity
+ // is revalidating. Bump the generation first so late page/mutation
+ // completions cannot repopulate the evicted cache.
+ loadGeneration.current += 1;
+ localCursorRef.current = null;
+ cloudCursorRef.current = null;
+ loadingMoreRef.current = false;
+ setCache((current) =>
+ current.loadedForViewerKey === viewerKey
+ ? current
+ : {
+ ...current,
+ items: [],
+ unreadCount: 0,
+ unreadCounts: { all: 0, mentions: 0, assigned: 0 },
+ loading: true,
+ hasMore: false,
+ loadedForViewerKey: null,
+ error: null,
+ revision: current.revision + 1,
+ }
+ );
+ }, [cache.loadedForViewerKey, setCache, viewerKey]);
useEffect(() => {
let cancelled = false;
@@ -215,6 +274,7 @@ export function useTeamInboxDataSource(): {
...current,
items: [],
unreadCount: 0,
+ unreadCounts: { all: 0, mentions: 0, assigned: 0 },
loading: false,
hasMore: false,
loadedForViewerKey: viewerKey,
@@ -239,13 +299,12 @@ export function useTeamInboxDataSource(): {
unreadCount: 0,
}),
auth && activeCloudOrgId
- ? listTeamInboxMentions(
+ ? listInitialTeamInboxMentions(
auth.accessToken,
activeCloudOrgId,
- null,
50
- ).catch(() => ({ mentions: [], nextCursor: undefined }))
- : Promise.resolve({ mentions: [], nextCursor: undefined }),
+ )
+ : Promise.resolve(EMPTY_CLOUD_MENTION_PAGE),
]).then(([{ page, unreadCount }, mentionPage]) => {
// Read state is intentionally NOT baked in here: the cached request
// promise stays receipt-independent so a mention marked read while
@@ -259,38 +318,30 @@ export function useTeamInboxDataSource(): {
mentionItems,
localItems: page.items,
localUnread: unreadCount,
+ cloudUnread: mentionPage.unreadCount,
localNextCursor: page.nextCursor,
cloudNextCursor: mentionPage.nextCursor ?? null,
};
});
inboxRequest = { key: requestKey, promise };
- void promise.finally(() => {
+ const clearSettledRequest = () => {
if (inboxRequest?.promise === promise) inboxRequest = null;
- });
+ };
+ void promise.then(clearSettledRequest, clearSettledRequest);
}
const {
mentionItems,
localItems,
localUnread,
+ cloudUnread,
localNextCursor,
cloudNextCursor,
} = await inboxRequest.promise;
if (generation !== loadGeneration.current) return;
localCursorRef.current = localNextCursor;
cloudCursorRef.current = cloudNextCursor;
- // Overlay the latest cloud read receipts here (not inside the cached
- // request promise) so optimistic mark-read/unread survives a concurrent
- // in-flight list request.
- const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
- const overlaidMentions = overlayCloudReadReceipts(
- mentionItems,
- cloudReadReceiptsRef.current,
- cloudScopeKey
- );
- const mergedItems = [...overlaidMentions, ...localItems];
- const unreadCount =
- localUnread +
- overlaidMentions.filter((item) => item.readAt === null).length;
+ const mergedItems = [...mentionItems, ...localItems];
+ const unreadCount = localUnread + cloudUnread;
const resolvedItems = resolveAssigneeDisplayNames(
mergedItems,
membersRef.current
@@ -299,6 +350,11 @@ export function useTeamInboxDataSource(): {
...current,
items: resolvedItems,
unreadCount,
+ unreadCounts: {
+ all: unreadCount,
+ mentions: cloudUnread,
+ assigned: localUnread,
+ },
loading: false,
error: null,
loadedForViewerKey: viewerKey,
@@ -319,8 +375,6 @@ export function useTeamInboxDataSource(): {
}, [
activeCloudOrgId,
auth,
- authIdentityKey,
- cloudReadReceipts,
members.length,
setCache,
viewerKey,
@@ -347,6 +401,7 @@ export function useTeamInboxDataSource(): {
// cursors internally.
return {
items: cache.items,
+ unreadCounts: cache.unreadCounts,
nextCursor: cache.hasMore
? { occurredAt: "", itemKey: "team-inbox-has-more" }
: null,
@@ -354,6 +409,7 @@ export function useTeamInboxDataSource(): {
},
loadMore: async () => {
if (loadingMoreRef.current) return;
+ const generation = loadGeneration.current;
const localCursor = localCursorRef.current;
const cloudCursor = cloudCursorRef.current;
if (!localCursor && !cloudCursor) return;
@@ -372,16 +428,19 @@ export function useTeamInboxDataSource(): {
activeCloudOrgId,
cloudCursor,
50
- ).catch(() => ({ mentions: [], nextCursor: undefined }))
- : Promise.resolve({ mentions: [], nextCursor: undefined }),
+ )
+ : Promise.resolve({
+ mentions: [],
+ nextCursor: undefined,
+ unreadCount: cache.unreadCounts.mentions,
+ }),
]);
+ if (generation !== loadGeneration.current) return;
localCursorRef.current = localResult.page.nextCursor ?? null;
cloudCursorRef.current = cloudResult.nextCursor ?? null;
- const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
- const appendedMentions = overlayCloudReadReceipts(
- mapMentionsToItems(cloudResult.mentions, activeCloudOrgId ?? ""),
- cloudReadReceiptsRef.current,
- cloudScopeKey
+ const appendedMentions = mapMentionsToItems(
+ cloudResult.mentions,
+ activeCloudOrgId ?? ""
);
const appended = resolveAssigneeDisplayNames(
[...appendedMentions, ...localResult.page.items],
@@ -416,89 +475,177 @@ export function useTeamInboxDataSource(): {
invalidate();
},
markRead: async (item: TeamInboxItem) => {
- const readAt = new Date().toISOString();
- if (item.kind === "comment_mention") {
- const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
- setCloudReadReceipts((current) =>
- addTeamInboxCloudReadReceipts(current, {
- [`${cloudScopeKey}|${item.id}`]: readAt,
- })
- );
- } else {
- await markLocalTeamInboxItemRead(viewerMemberIds, item.id);
- }
- setCache((current) => ({
- ...current,
- items: current.items.map((candidate) =>
- candidate.id === item.id ? { ...candidate, readAt } : candidate
- ),
- unreadCount: Math.max(0, current.unreadCount - 1),
- revision: current.revision + 1,
- }));
- notifyTeamInboxListeners();
+ const generation = loadGeneration.current;
+ return enqueueMutation(async () => {
+ let readAt = new Date().toISOString();
+ let cloudUnread: number | null = null;
+ if (item.kind === "comment_mention") {
+ if (!auth || !activeCloudOrgId) {
+ throw new Error(
+ "Cloud identity is required to mark a mention read"
+ );
+ }
+ const result = await setTeamInboxMentionRead(
+ auth.accessToken,
+ activeCloudOrgId,
+ item.target.commentId,
+ true
+ );
+ readAt = result.readAt ?? readAt;
+ cloudUnread = result.unreadCount;
+ } else {
+ await markLocalTeamInboxItemRead(viewerMemberIds, item.id);
+ }
+ if (generation !== loadGeneration.current) return;
+ setCache((current) => {
+ const wasUnread =
+ current.items.find((candidate) => candidate.id === item.id)
+ ?.readAt === null;
+ const assignedUnread =
+ item.kind === "comment_mention"
+ ? current.unreadCounts.assigned
+ : Math.max(
+ 0,
+ current.unreadCounts.assigned - (wasUnread ? 1 : 0)
+ );
+ const mentionUnread =
+ item.kind === "comment_mention"
+ ? (cloudUnread ?? current.unreadCounts.mentions)
+ : current.unreadCounts.mentions;
+ return {
+ ...current,
+ items: current.items.map((candidate) =>
+ candidate.id === item.id ? { ...candidate, readAt } : candidate
+ ),
+ unreadCounts: {
+ all: assignedUnread + mentionUnread,
+ assigned: assignedUnread,
+ mentions: mentionUnread,
+ },
+ unreadCount: assignedUnread + mentionUnread,
+ revision: current.revision + 1,
+ };
+ });
+ notifyTeamInboxListeners();
+ });
},
markUnread: async (item: TeamInboxItem) => {
- if (item.kind === "comment_mention") {
- const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
- setCloudReadReceipts((current) =>
- removeTeamInboxCloudReadReceipts(current, [
- `${cloudScopeKey}|${item.id}`,
- ])
- );
- } else {
- await markLocalTeamInboxItemUnread(viewerMemberIds, item.id);
- }
- setCache((current) => ({
- ...current,
- items: current.items.map((candidate) =>
- candidate.id === item.id
- ? { ...candidate, readAt: null }
- : candidate
- ),
- unreadCount: current.unreadCount + 1,
- revision: current.revision + 1,
- }));
- notifyTeamInboxListeners();
+ const generation = loadGeneration.current;
+ return enqueueMutation(async () => {
+ let cloudUnread: number | null = null;
+ if (item.kind === "comment_mention") {
+ if (!auth || !activeCloudOrgId) {
+ throw new Error(
+ "Cloud identity is required to mark a mention unread"
+ );
+ }
+ const result = await setTeamInboxMentionRead(
+ auth.accessToken,
+ activeCloudOrgId,
+ item.target.commentId,
+ false
+ );
+ cloudUnread = result.unreadCount;
+ } else {
+ await markLocalTeamInboxItemUnread(viewerMemberIds, item.id);
+ }
+ if (generation !== loadGeneration.current) return;
+ setCache((current) => {
+ const wasUnread =
+ current.items.find((candidate) => candidate.id === item.id)
+ ?.readAt === null;
+ const assignedUnread =
+ item.kind === "comment_mention"
+ ? current.unreadCounts.assigned
+ : current.unreadCounts.assigned + (wasUnread ? 0 : 1);
+ const mentionUnread =
+ item.kind === "comment_mention"
+ ? (cloudUnread ?? current.unreadCounts.mentions)
+ : current.unreadCounts.mentions;
+ return {
+ ...current,
+ items: current.items.map((candidate) =>
+ candidate.id === item.id
+ ? { ...candidate, readAt: null }
+ : candidate
+ ),
+ unreadCounts: {
+ all: assignedUnread + mentionUnread,
+ assigned: assignedUnread,
+ mentions: mentionUnread,
+ },
+ unreadCount: assignedUnread + mentionUnread,
+ revision: current.revision + 1,
+ };
+ });
+ notifyTeamInboxListeners();
+ });
},
- markAllRead: async (items) => {
- const assigned = items.filter(
- (
- item
- ): item is Extract =>
- item.kind === "assigned_work_item"
- );
- if (assigned.length > 0) {
- await markAllLocalTeamInboxRead(viewerMemberIds, "assigned");
- }
- const readAt = new Date().toISOString();
- const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`;
- const mentionReceipts = items
- .filter((item) => item.kind === "comment_mention")
- .reduce>((next, item) => {
- next[`${cloudScopeKey}|${item.id}`] = readAt;
- return next;
- }, {});
- if (Object.keys(mentionReceipts).length > 0) {
- setCloudReadReceipts((current) =>
- addTeamInboxCloudReadReceipts(current, mentionReceipts)
- );
- }
- const itemIds = new Set(items.map((item) => item.id));
- // Decrement only by the items that were actually unread; counting the
- // whole set would over-subtract when some passed items were already read.
- const newlyReadCount = items.reduce(
- (count, item) => count + (item.readAt === null ? 1 : 0),
- 0
- );
- setCache((current) => ({
- ...current,
- items: current.items.map((item) =>
- itemIds.has(item.id) ? { ...item, readAt } : item
- ),
- unreadCount: Math.max(0, current.unreadCount - newlyReadCount),
- revision: current.revision + 1,
- }));
- notifyTeamInboxListeners();
+ markAllRead: async (_items, filter = "all") => {
+ const generation = loadGeneration.current;
+ return enqueueMutation(async () => {
+ const includeAssigned = filter === "all" || filter === "assigned";
+ const includeMentions = filter === "all" || filter === "mentions";
+ let cloudReadAt: string | null = null;
+ let cloudUnread: number | null = null;
+ if (
+ includeMentions &&
+ cache.unreadCounts.mentions > 0 &&
+ (!auth || !activeCloudOrgId)
+ ) {
+ throw new Error(
+ "Cloud identity is required to mark all mentions read"
+ );
+ }
+ try {
+ const [, cloudResult] = await Promise.all([
+ includeAssigned && cache.unreadCounts.assigned > 0
+ ? markAllLocalTeamInboxRead(viewerMemberIds, "assigned")
+ : Promise.resolve(),
+ includeMentions &&
+ cache.unreadCounts.mentions > 0 &&
+ auth &&
+ activeCloudOrgId
+ ? markAllTeamInboxMentionsRead(
+ auth.accessToken,
+ activeCloudOrgId
+ )
+ : Promise.resolve(null),
+ ]);
+ cloudReadAt = cloudResult?.readAt ?? null;
+ cloudUnread = cloudResult?.unreadCount ?? null;
+ } catch (error) {
+ invalidate();
+ throw error;
+ }
+ if (generation !== loadGeneration.current) return;
+ const readAt = cloudReadAt ?? new Date().toISOString();
+ setCache((current) => {
+ const assignedUnread = includeAssigned
+ ? 0
+ : current.unreadCounts.assigned;
+ const mentionUnread = includeMentions
+ ? (cloudUnread ?? 0)
+ : current.unreadCounts.mentions;
+ return {
+ ...current,
+ items: current.items.map((item) =>
+ (includeAssigned && item.kind === "assigned_work_item") ||
+ (includeMentions && item.kind === "comment_mention")
+ ? { ...item, readAt }
+ : item
+ ),
+ unreadCounts: {
+ all: assignedUnread + mentionUnread,
+ assigned: assignedUnread,
+ mentions: mentionUnread,
+ },
+ unreadCount: assignedUnread + mentionUnread,
+ revision: current.revision + 1,
+ };
+ });
+ notifyTeamInboxListeners();
+ });
},
subscribe: (listener: () => void) => {
listeners.add(listener);
@@ -508,13 +655,13 @@ export function useTeamInboxDataSource(): {
[
activeCloudOrgId,
auth,
- authIdentityKey,
cache.error,
cache.hasMore,
cache.items,
+ cache.unreadCounts,
+ enqueueMutation,
invalidate,
setCache,
- setCloudReadReceipts,
viewerMemberIds,
]
);
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts
index 29d5dc1a3a..45650029bd 100644
--- a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts
@@ -10,6 +10,7 @@ import { createLogger } from "@src/hooks/logger";
import {
openOrFocusSessionInChatPanelTabAtom,
openWorkItemInChatPanelTabAtom,
+ requestChatPanelWorkItemActionAtom,
} from "@src/store/chatPanel/chatPanelTabsAtom";
import { sessionsAtom } from "@src/store/session";
@@ -23,10 +24,14 @@ export function useTeamInboxNavigation(): (
const sessions = useAtomValue(sessionsAtom);
const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom);
const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom);
+ const requestWorkItemAction = useSetAtom(requestChatPanelWorkItemActionAtom);
return useCallback(
(intent: TeamInboxNavigationIntent) => {
- if (intent.kind === "open_session_comment") {
+ if (
+ intent.kind === "open_session" ||
+ intent.kind === "open_session_comment"
+ ) {
const session = sessions.find(
(candidate) => candidate.session_id === intent.sessionId
);
@@ -35,11 +40,13 @@ export function useTeamInboxNavigation(): (
sessionName: session?.name,
repoPath: session?.repoPath,
});
- window.requestAnimationFrame(() => {
- document
- .getElementById(intent.anchor ?? `comment-${intent.commentId}`)
- ?.scrollIntoView({ block: "center", behavior: "smooth" });
- });
+ if (intent.kind === "open_session_comment") {
+ window.requestAnimationFrame(() => {
+ document
+ .getElementById(intent.anchor ?? `comment-${intent.commentId}`)
+ ?.scrollIntoView({ block: "center", behavior: "smooth" });
+ });
+ }
return;
}
@@ -58,6 +65,12 @@ export function useTeamInboxNavigation(): (
projectName: project?.meta.name ?? "Standalone",
orgId: project?.meta.org_id,
});
+ if (intent.action) {
+ requestWorkItemAction({
+ workItemShortId: shortId,
+ action: intent.action,
+ });
+ }
};
if (!intent.projectId) {
@@ -79,6 +92,6 @@ export function useTeamInboxNavigation(): (
log.warn("Failed to open project Team Inbox Work Item", error);
});
},
- [openSession, openWorkItem, sessions]
+ [openSession, openWorkItem, requestWorkItemAction, sessions]
);
}
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts
new file mode 100644
index 0000000000..ee208c82a3
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts
@@ -0,0 +1,199 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import {
+ enrichedWorkItemToUI,
+ projectApi,
+ standaloneWorkItemDataToEnriched,
+} from "@src/api/http/project";
+import type { MemberEntry } from "@src/api/http/project";
+import { createLogger } from "@src/hooks/logger";
+import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate";
+import type { Person } from "@src/types/core/shared";
+import type { WorkItem } from "@src/types/core/workItem";
+
+import type { WorkItemTarget } from "./domain";
+
+const log = createLogger("TeamInboxWorkItem");
+
+interface ResolvedWorkItem {
+ key: string;
+ workItem: WorkItem | null;
+ repoPath: string | null;
+ members: Person[];
+ error: string | null;
+}
+
+export interface TeamInboxWorkItemState {
+ workItem: WorkItem | null;
+ status: "loading" | "ready" | "error";
+ error: string | null;
+ repoPath: string | null;
+ members: Person[];
+ updateWorkItem: (updates: Partial) => void;
+ refreshWorkItem: () => void;
+}
+
+/**
+ * Demand-load the full Work Item for the selected inbox row.
+ *
+ * The resolved value is keyed to the selection, late reads are ignored after
+ * cleanup, and only the newest overlapping property update may replace the
+ * displayed snapshot.
+ */
+export function useTeamInboxWorkItem(
+ target: WorkItemTarget
+): TeamInboxWorkItemState {
+ const { projectId, workItemId } = target;
+ const requestKey = `${projectId || "standalone"}:${workItemId}`;
+ const [resolved, setResolved] = useState(null);
+ const [refreshGeneration, setRefreshGeneration] = useState(0);
+ const updateGenerationRef = useRef(0);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const request = projectId
+ ? Promise.all([
+ projectApi.readWorkItem(projectId, workItemId),
+ projectApi.readProject(projectId),
+ projectApi.readMembers(projectId),
+ ]).then(([data, project, memberFile]) => ({
+ data,
+ project,
+ memberEntries: memberFile.members,
+ }))
+ : projectApi.readStandaloneWorkItem(workItemId).then((data) => ({
+ data,
+ project: null,
+ memberEntries: [] as MemberEntry[],
+ }));
+
+ void request
+ .then(({ data, project, memberEntries }) => {
+ if (cancelled) return;
+ const converted = enrichedWorkItemToUI(
+ standaloneWorkItemDataToEnriched(data)
+ );
+ const activeMembers = new Map();
+ for (const member of memberEntries) {
+ if (member.active === false) continue;
+ const existing = activeMembers.get(member.id);
+ if (
+ !existing ||
+ (member.last_commit_date ?? "") > (existing.last_commit_date ?? "")
+ ) {
+ activeMembers.set(member.id, member);
+ }
+ }
+ const members = [...activeMembers.values()].map((member) => ({
+ id: member.id,
+ name: member.name,
+ email: member.email,
+ avatar: member.avatar,
+ }));
+ const resolvedAssignee = converted.assignee
+ ? (members.find((member) => member.id === converted.assignee?.id) ??
+ converted.assignee)
+ : undefined;
+ setResolved({
+ key: requestKey,
+ workItem: project
+ ? {
+ ...converted,
+ assignee: resolvedAssignee,
+ project: {
+ id: project.slug,
+ name: project.meta.name,
+ },
+ }
+ : converted,
+ repoPath: project?.meta.linked_repos[0] ?? null,
+ members,
+ error: null,
+ });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ log.warn("Failed to load Team Inbox Work Item", error);
+ setResolved((current) => ({
+ key: requestKey,
+ workItem: current?.key === requestKey ? current.workItem : null,
+ repoPath: current?.key === requestKey ? current.repoPath : null,
+ members: current?.key === requestKey ? current.members : [],
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId, refreshGeneration, requestKey, workItemId]);
+
+ const refreshWorkItem = useCallback(() => {
+ setRefreshGeneration((current) => current + 1);
+ }, []);
+
+ const updateWorkItem = useCallback(
+ (updates: Partial) => {
+ if (!projectId) return;
+ const payload = toWorkItemPartialUpdate(updates);
+ if (Object.keys(payload).length === 0) return;
+
+ const generation = ++updateGenerationRef.current;
+ void projectApi
+ .updateWorkItemPartial(projectId, workItemId, payload)
+ .then((updated) => {
+ if (generation !== updateGenerationRef.current) return;
+ setResolved((current) =>
+ current?.key === requestKey
+ ? {
+ key: requestKey,
+ workItem: {
+ ...enrichedWorkItemToUI(updated),
+ project: current.workItem?.project,
+ },
+ repoPath: current.repoPath,
+ members: current.members,
+ error: null,
+ }
+ : current
+ );
+ })
+ .catch((error: unknown) => {
+ if (generation !== updateGenerationRef.current) return;
+ log.warn("Failed to update Team Inbox Work Item", error);
+ setResolved((current) =>
+ current?.key === requestKey
+ ? {
+ ...current,
+ error: error instanceof Error ? error.message : String(error),
+ }
+ : current
+ );
+ });
+ },
+ [projectId, requestKey, workItemId]
+ );
+
+ if (resolved?.key !== requestKey) {
+ return {
+ workItem: null,
+ status: "loading",
+ error: null,
+ repoPath: null,
+ members: [],
+ updateWorkItem,
+ refreshWorkItem,
+ };
+ }
+
+ return {
+ workItem: resolved.workItem,
+ status: resolved.workItem ? "ready" : "error",
+ error: resolved.error,
+ repoPath: resolved.repoPath,
+ members: resolved.members,
+ updateWorkItem,
+ refreshWorkItem,
+ };
+}
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
deleted file mode 100644
index 49bf105605..0000000000
--- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useEffect, useState } from "react";
-
-import { projectApi } from "@src/api/http/project";
-import { createLogger } from "@src/hooks/logger";
-
-import type { WorkItemTarget } from "./domain";
-
-const log = createLogger("TeamInboxWorkItemBody");
-
-export interface TeamInboxWorkItemBodyState {
- /** Full Markdown body once resolved, or null while loading / empty / failed. */
- body: string | null;
- loading: boolean;
-}
-
-interface ResolvedWorkItemBodyState extends TeamInboxWorkItemBodyState {
- requestKey: string;
-}
-
-/**
- * Lazily loads the full Work Item body for the selected assigned inbox item so
- * the detail preview can render the real content instead of the short list
- * excerpt. The fetch reuses the same project store adapters as navigation and is
- * demand-driven (one read per selection, no polling); stale responses are
- * discarded when the selection changes.
- */
-export function useTeamInboxWorkItemBody(
- target: WorkItemTarget
-): TeamInboxWorkItemBodyState {
- const { projectId, workItemId } = target;
- const requestKey = `${projectId ?? "standalone"}:${workItemId}`;
- const [state, setState] = useState({
- requestKey,
- body: null,
- loading: true,
- });
-
- useEffect(() => {
- let cancelled = false;
-
- const request = projectId
- ? projectApi.readWorkItem(projectId, workItemId)
- : projectApi.readStandaloneWorkItem(workItemId);
-
- void request
- .then((workItem) => {
- if (cancelled) return;
- const body = workItem.body.trim();
- setState({
- requestKey,
- body: body.length > 0 ? body : null,
- loading: false,
- });
- })
- .catch((error: unknown) => {
- if (cancelled) return;
- log.warn("Failed to load Team Inbox Work Item body", error);
- setState({ requestKey, body: null, loading: false });
- });
-
- return () => {
- cancelled = true;
- };
- }, [projectId, requestKey, workItemId]);
-
- return state.requestKey === requestKey
- ? state
- : { body: null, loading: true };
-}
diff --git a/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts b/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts
new file mode 100644
index 0000000000..7d2e27e7ae
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import { toWorkItemPartialUpdate } from "../workItemPartialUpdate";
+
+describe("toWorkItemPartialUpdate", () => {
+ it("maps editable Work Item fields to the project-store payload", () => {
+ expect(
+ toWorkItemPartialUpdate({
+ name: "Inbox thread",
+ spec: "Unified body",
+ workItemStatus: "in_progress",
+ priority: "high",
+ assignee: { id: "member-1", name: "Ada" },
+ labels: [{ id: "label-1", name: "UX", color: "#000000" }],
+ })
+ ).toMatchObject({
+ title: "Inbox thread",
+ body: "Unified body",
+ status: "in_progress",
+ priority: "high",
+ assignee: "member-1",
+ labels: ["label-1"],
+ });
+ });
+
+ it("preserves explicit clears", () => {
+ expect(
+ toWorkItemPartialUpdate({
+ assignee: null,
+ milestone: null,
+ labels: [],
+ endDate: null,
+ })
+ ).toMatchObject({
+ assignee: null,
+ milestone: null,
+ labels: [],
+ targetDate: null,
+ });
+ });
+
+ it("returns an empty payload when no persisted field changes", () => {
+ expect(toWorkItemPartialUpdate({})).toEqual({});
+ });
+});
diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx
index 06f294c2ab..2d8a81a2d9 100644
--- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx
+++ b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx
@@ -22,6 +22,7 @@ interface IdleStateProps {
/** Collab lock held by a teammate (design §16.6): disable + show holder. */
isLockedByOther?: boolean;
lockHolderName?: string | null;
+ compact?: boolean;
}
export const IdleState: React.FC = ({
@@ -31,6 +32,7 @@ export const IdleState: React.FC = ({
isStartingAgent,
isLockedByOther,
lockHolderName,
+ compact = false,
}) => {
const { t } = useTranslation("projects");
@@ -50,6 +52,33 @@ export const IdleState: React.FC = ({
? t("workItems.agentWorkflow.running")
: t("workItems.agentWorkflow.startAgent");
+ if (compact) {
+ return (
+
+
+
+ {t("workItems.agentWorkflow.noWorkflowRun")}
+
+
+ {t("workItems.agentWorkflow.startAgentAndChat")}
+
+
+ {onStartAgent ? (
+
onStartAgent()}
+ disabled={!canStart}
+ loading={isStartingAgent}
+ data-testid="work-item-start-agent-button"
+ >
+ {startLabel}
+
+ ) : null}
+
+ );
+ }
+
return (
= ({
@@ -86,6 +88,7 @@ const AgentWorkflow: React.FC = ({
activeAgentRole,
isLockedByOther,
lockHolderName,
+ presentation = "default",
}) => {
const { t } = useTranslation("projects");
const persistedPhase: OrchestratorPhase =
@@ -179,15 +182,29 @@ const AgentWorkflow: React.FC = ({
const showCompletedBadge =
phase === "completed" &&
!(hasReviewFeedback && reviewOutcome === "approved");
+ const isThread = presentation === "thread";
return (
{phase !== "idle" && (
@@ -202,6 +219,7 @@ const AgentWorkflow: React.FC
= ({
isStartingAgent={isStartingAgent}
isLockedByOther={isLockedByOther}
lockHolderName={lockHolderName}
+ compact={isThread}
/>
)}
{ACTIVE_PHASES.has(phase) && (
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx
index e1074f6d3b..de6ca875e7 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx
@@ -1,6 +1,8 @@
import {
ArrowRightLeft,
ArrowUp,
+ Bell,
+ BellOff,
Bot,
MessageSquare,
Pencil,
@@ -49,146 +51,165 @@ const HistoryTab: React.FC = ({
onCommentTextChange,
onCommentSubmit,
isSubmittingComment,
+ presentation = "default",
}) => {
const { t } = useTranslation("projects");
+ const isThread = presentation === "thread";
- return (
-
-
-
-
- {isSubscribed
- ? t("workItems.activity.unsubscribe")
- : t("workItems.activity.subscribe")}
-
-
- {currentUser.name.charAt(0).toUpperCase()}
-
-
-
+ const subscriptionControl = (
+
+ ) : (
+
+ )
+ }
+ onClick={onToggleSubscribe}
+ data-testid="work-item-subscription-toggle"
+ >
+ {isSubscribed
+ ? t("workItems.activity.unsubscribe")
+ : t("workItems.activity.subscribe")}
+
+ );
- {timelineEntries.length > 0 && (
-
-
- {timelineEntries.map((entry, entryIndex) => {
- const isDelegationComment =
- entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED &&
- entry.userName === OS_AGENT_USERNAME &&
- entry.descriptions[0]?.startsWith(DELEGATION_PREFIX);
- const isLast = entryIndex === timelineEntries.length - 1;
+ const timeline = timelineEntries.length > 0 && (
+
+
+ {timelineEntries.map((entry, entryIndex) => {
+ const isDelegationComment =
+ entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED &&
+ entry.userName === OS_AGENT_USERNAME &&
+ entry.descriptions[0]?.startsWith(DELEGATION_PREFIX);
+ const isLast = entryIndex === timelineEntries.length - 1;
- if (
- entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED &&
- !isDelegationComment
- ) {
- const body = entry.descriptions[0] ?? "";
- return (
-
-
- {entry.userName.charAt(0).toUpperCase()}
-
+ if (
+ entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED &&
+ !isDelegationComment
+ ) {
+ const body = entry.descriptions[0] ?? "";
+ return (
+
+
+ >
+ {entry.userName.charAt(0).toUpperCase()}
+
}
- >
-
-
-
- );
- }
+ actor={entry.userName}
+ action="commented"
+ timestamp={entry.timestamp}
+ />
+ }
+ >
+
+
+
+ );
+ }
- return (
-
-
- ) : (
- TIMELINE_ICONS[entry.type]
- )
- }
- >
-
- {isDelegationComment
- ? t("workItems.activity.agent")
- : entry.userName}
- {" "}
- {entry.descriptions.length === 1 ? (
- {entry.descriptions[0]}
- ) : (
-
-
- {t("workItems.activity.editedFields", {
- count: entry.descriptions.length,
- })}
-
-
- {entry.descriptions.map(
- (description, descriptionIndex) => (
-
- {description}
-
- )
- )}
-
-
- )}
- ·
-
-
-
- );
- })}
-
-
- )}
+ return (
+
+
+ ) : (
+ TIMELINE_ICONS[entry.type]
+ )
+ }
+ >
+
+ {isDelegationComment
+ ? t("workItems.activity.agent")
+ : entry.userName}
+ {" "}
+ {entry.descriptions.length === 1 ? (
+ {entry.descriptions[0]}
+ ) : (
+
+
+ {t("workItems.activity.editedFields", {
+ count: entry.descriptions.length,
+ })}
+
+
+ {entry.descriptions.map(
+ (description, descriptionIndex) => (
+
+ {description}
+
+ )
+ )}
+
+
+ )}
+ ·
+
+
+
+ );
+ })}
+
+
+ );
-
+ const composer = (
+
+ {isThread ? (
+
+ {currentUser.name.charAt(0).toUpperCase()}
+
+ ) : null}
+
onCommentTextChange(markdown)}
onSubmit={onCommentSubmit}
- minHeight={60}
+ minHeight={isThread ? 48 : 60}
maxHeight={120}
appearance="outlined"
+ showTabs={!isThread}
dataTestId="work-item-comment-editor"
/>
-
);
+
+ if (isThread) {
+ return (
+
+
+
+ {t("workItems.activity.title")}
+
+ {subscriptionControl}
+
+ {timeline}
+ {composer}
+
+ );
+ }
+
+ return (
+
+
+
+ {subscriptionControl}
+
+ {currentUser.name.charAt(0).toUpperCase()}
+
+
+
+
+ {timeline}
+ {composer}
+
+ );
};
export default HistoryTab;
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx
new file mode 100644
index 0000000000..a96bb08955
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx
@@ -0,0 +1,219 @@
+import { CheckSquare2, Plus, Trash2, X } from "lucide-react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import Button from "@src/components/Button";
+import Checkbox from "@src/components/Checkbox";
+import Input from "@src/components/Input";
+import type { TodoItem } from "@src/types/core/workItem";
+
+import { WorkItemThreadSection } from "../WorkItemThread";
+import {
+ THREAD_TODO_MAX_LENGTH,
+ createThreadTodo,
+ normalizeThreadTodos,
+} from "./threadTodos";
+
+interface ThreadTodoChecklistProps {
+ todos: TodoItem[];
+ onChange: (todos: TodoItem[]) => void;
+ disabled?: boolean;
+}
+
+const ThreadTodoChecklist: React.FC = ({
+ todos,
+ onChange,
+ disabled = false,
+}) => {
+ const { t } = useTranslation(["projects", "common"]);
+ const [adding, setAdding] = useState(false);
+ const [draft, setDraft] = useState("");
+ const inputRef = useRef(null);
+ const normalizedTodos = useMemo(() => normalizeThreadTodos(todos), [todos]);
+ const completedCount = normalizedTodos.filter(
+ (todo) => todo.status === "completed"
+ ).length;
+
+ useEffect(() => {
+ if (adding) inputRef.current?.focus({ preventScroll: true });
+ }, [adding]);
+
+ const closeComposer = () => {
+ setAdding(false);
+ setDraft("");
+ };
+
+ const commitDraft = () => {
+ const nextTodo = createThreadTodo(draft, Date.now());
+ if (!nextTodo) {
+ closeComposer();
+ return;
+ }
+ onChange([...normalizedTodos, nextTodo]);
+ setDraft("");
+ requestAnimationFrame(() =>
+ inputRef.current?.focus({ preventScroll: true })
+ );
+ };
+
+ return (
+
+ }
+ title={t("projects:workItems.todos.title")}
+ meta={
+
+ {completedCount}/{normalizedTodos.length}
+
+ }
+ action={
+ !disabled ? (
+ }
+ onClick={() => setAdding(true)}
+ disabled={adding}
+ data-testid="work-item-thread-todo-add"
+ >
+ {t("common:actions.add")}
+
+ ) : null
+ }
+ >
+ {normalizedTodos.length === 0 && !adding ? (
+ : undefined}
+ iconPosition="right"
+ className="!h-auto !justify-between !rounded-lg !px-2 !py-2 !text-left !text-[12px] !font-normal !text-text-3 hover:!bg-fill-1 hover:!text-text-2"
+ onClick={() => setAdding(true)}
+ disabled={disabled}
+ data-testid="work-item-thread-todos-empty"
+ >
+ {t("projects:workItems.todos.addFirst")}
+
+ ) : (
+
+ {normalizedTodos.map((todo) => (
+
+
+
+ onChange(
+ normalizedTodos.map((candidate) =>
+ candidate.id === todo.id
+ ? {
+ ...candidate,
+ status:
+ candidate.status === "completed"
+ ? "pending"
+ : "completed",
+ }
+ : candidate
+ )
+ )
+ }
+ disabled={disabled}
+ />
+
+
+ {todo.content}
+
+ {!disabled ? (
+
}
+ className="opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
+ aria-label={t("common:actions.delete")}
+ onClick={() =>
+ onChange(
+ normalizedTodos.filter(
+ (candidate) => candidate.id !== todo.id
+ )
+ )
+ }
+ />
+ ) : null}
+
+ ))}
+
+ )}
+
+ {adding ? (
+
+ {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ commitDraft();
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ closeComposer();
+ }
+ }}
+ data-testid="work-item-thread-todo-input"
+ />
+ }
+ aria-label={t("common:actions.cancel")}
+ onClick={closeComposer}
+ />
+ }
+ disabled={!draft.trim()}
+ onClick={commitDraft}
+ data-testid="work-item-thread-todo-commit"
+ >
+ {t("common:actions.add")}
+
+
+ ) : null}
+
+ );
+};
+
+export default ThreadTodoChecklist;
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts
index 674dedf9f1..068aaf36f8 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts
@@ -106,8 +106,11 @@ vi.mock("@src/modules/shared/components/ActivityTimeline", () => ({
TimelineCard: ({
children,
footer,
- }: React.PropsWithChildren<{ footer?: React.ReactNode }>) =>
- createElement("div", null, children, footer),
+ actions,
+ }: React.PropsWithChildren<{
+ footer?: React.ReactNode;
+ actions?: React.ReactNode;
+ }>) => createElement("div", null, actions, children, footer),
}));
vi.mock("@src/modules/shared/layouts/blocks", () => ({
@@ -127,6 +130,7 @@ vi.mock("@src/modules/shared/layouts/blocks", () => ({
label: string;
onClick?: () => void;
dataTestId?: string;
+ disabled?: boolean;
};
}) =>
createElement(
@@ -151,6 +155,7 @@ vi.mock("@src/modules/shared/layouts/blocks", () => ({
type: "button",
"data-testid": primaryAction.dataTestId,
onClick: primaryAction.onClick,
+ disabled: primaryAction.disabled,
},
primaryAction.label
)
@@ -160,6 +165,7 @@ vi.mock("@src/modules/shared/layouts/blocks", () => ({
vi.mock("../../AgentWorkflow", () => ({ default: () => null }));
vi.mock("../../TodoChecklist", () => ({ default: () => null }));
+vi.mock("../ThreadTodoChecklist", () => ({ default: () => null }));
vi.mock("../../WorkItemContentStack", () => ({
default: ({ descriptionContent }: { descriptionContent?: React.ReactNode }) =>
createElement("div", null, descriptionContent),
@@ -345,4 +351,64 @@ describe("WorkItemContent description editing", () => {
container.querySelector("[data-testid='description-footer']")
).toBeNull();
});
+
+ it("keeps the thread compact until Edit is explicitly requested", () => {
+ act(() => {
+ root.render(
+ createElement(WorkItemContent, {
+ workItem: baseWorkItem,
+ presentation: "thread",
+ onUpdateWorkItem: vi.fn(),
+ })
+ );
+ });
+
+ expect(
+ container.querySelector("[data-testid='description-editor']")
+ ).toBeNull();
+ expect(
+ container.querySelector("[data-testid='github-read-only-description']")
+ ?.textContent
+ ).toBe(baseWorkItem.spec);
+
+ act(() => {
+ container
+ .querySelector(
+ "[data-testid='work-item-description-edit']"
+ )
+ ?.click();
+ });
+
+ expect(
+ container.querySelector("[data-testid='description-editor']")
+ ).not.toBeNull();
+ expect(
+ container.querySelector(
+ "[data-testid='work-item-description-save']"
+ )?.disabled
+ ).toBe(true);
+
+ changeDescription("## Compact thread editor");
+
+ expect(
+ container.querySelector(
+ "[data-testid='work-item-description-save']"
+ )?.disabled
+ ).toBe(false);
+
+ act(() => {
+ container
+ .querySelector(
+ "[data-testid='work-item-description-save']"
+ )
+ ?.click();
+ });
+
+ expect(mocks.handleDescriptionChange).toHaveBeenCalledWith(
+ "## Compact thread editor"
+ );
+ expect(
+ container.querySelector("[data-testid='description-editor']")
+ ).toBeNull();
+ });
});
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts
new file mode 100644
index 0000000000..e7f263b8dc
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveWorkItemContentSectionPolicy } from "../presentation";
+
+describe("resolveWorkItemContentSectionPolicy", () => {
+ it("keeps the existing tabs and linked-session table by default", () => {
+ expect(resolveWorkItemContentSectionPolicy("default", true)).toEqual({
+ showTabbedLowerSection: true,
+ showLinkedSessionsTable: true,
+ showInlineWorkflow: false,
+ showInlineOutput: false,
+ showInlineHistory: false,
+ });
+ });
+
+ it("turns Team Inbox into one inline thread without the legacy table", () => {
+ expect(resolveWorkItemContentSectionPolicy("thread", true)).toEqual({
+ showTabbedLowerSection: false,
+ showLinkedSessionsTable: false,
+ showInlineWorkflow: true,
+ showInlineOutput: true,
+ showInlineHistory: true,
+ });
+ });
+
+ it("does not render an empty output block before proof of work exists", () => {
+ expect(
+ resolveWorkItemContentSectionPolicy("thread", false).showInlineOutput
+ ).toBe(false);
+ });
+});
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts
new file mode 100644
index 0000000000..25a2e07efb
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import { createThreadTodo, normalizeThreadTodos } from "../threadTodos";
+
+describe("thread todo presentation", () => {
+ it("drops blank persisted rows and trims visible content", () => {
+ expect(
+ normalizeThreadTodos([
+ { id: "blank", content: " ", status: "pending" },
+ { id: "kept", content: " Verify inbox ", status: "completed" },
+ ])
+ ).toEqual([{ id: "kept", content: "Verify inbox", status: "completed" }]);
+ });
+
+ it("creates a pending todo only after non-empty input is committed", () => {
+ expect(createThreadTodo(" Add compact composer ", 42)).toEqual({
+ id: "todo-42",
+ content: "Add compact composer",
+ status: "pending",
+ });
+ expect(createThreadTodo(" ", 42)).toBeNull();
+ });
+
+ it("enforces the 120 character domain boundary", () => {
+ expect(createThreadTodo("x".repeat(140), 42)?.content).toHaveLength(120);
+ });
+});
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts
index e0bdf40ef9..15793ddace 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts
@@ -168,4 +168,32 @@ describe("work item history timeline", () => {
"history-late",
]);
});
+
+ it("resolves stored member ids in history and legacy comments", () => {
+ const entries = buildWorkItemTimelineEntries(
+ workItemWithTimeline({
+ comments: [
+ {
+ id: "comment-member",
+ author: "member-2",
+ content: "Member comment",
+ created_at: "2026-01-02T00:00:00Z",
+ },
+ ],
+ history: [
+ {
+ id: "history-member",
+ action: WORK_ITEM_HISTORY_ACTION.UPDATED,
+ timestamp: "2026-01-01T00:00:00Z",
+ actorId: "member-2",
+ summary: "Updated",
+ },
+ ],
+ }),
+ translate,
+ [{ id: "member-2", name: "Lin" }]
+ );
+
+ expect(entries.map((entry) => entry.userName)).toEqual(["Lin", "Lin"]);
+ });
});
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx
index 1c3deb889d..b1e0a685b0 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx
@@ -66,11 +66,14 @@ export function useWorkItemContentState(
const pendingOpenChatRef = useRef(false);
- const handleStartAgentAndOpenChat = useCallback(
- (instructions?: string) => {
- pendingOpenChatRef.current = true;
- onStartAgent?.(instructions);
- },
+ const handleStartAgentAndOpenChat = useMemo(
+ () =>
+ onStartAgent
+ ? (instructions?: string) => {
+ pendingOpenChatRef.current = true;
+ onStartAgent(instructions);
+ }
+ : undefined,
[onStartAgent]
);
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx
index 4c2d1ed8d1..c090e83e55 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx
@@ -1,8 +1,9 @@
-import { Bot, Repeat, Terminal } from "lucide-react";
+import { Bot, Pencil, Repeat, Terminal } from "lucide-react";
import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Avatar from "@src/components/Avatar";
+import Button from "@src/components/Button";
import TabPill from "@src/components/TabPill";
import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens";
import { useWorkItemImageInsert } from "@src/hooks/project";
@@ -38,10 +39,13 @@ import AgentWorkflow from "../AgentWorkflow";
import { ROLE_I18N_KEYS, STATUS_I18N_KEYS } from "../AgentWorkflow/types";
import TodoChecklist from "../TodoChecklist";
import WorkItemContentStack from "../WorkItemContentStack";
+import { WorkItemThreadLayout } from "../WorkItemThread";
import HistoryTab from "./HistoryTab";
import OutputTab from "./OutputTab";
+import ThreadTodoChecklist from "./ThreadTodoChecklist";
import { useGitHubIssueTimeline } from "./hooks/useGitHubIssueTimeline";
import { useWorkItemContentState } from "./hooks/useWorkItemContentState";
+import { resolveWorkItemContentSectionPolicy } from "./presentation";
import type { SessionTab, WorkItemContentProps } from "./types";
interface LinkedSessionsListProps {
@@ -151,6 +155,7 @@ const LinkedSessionsList: React.FC = ({
const WorkItemContent: React.FC = ({
workItem,
+ presentation = "default",
onUpdateWorkItem,
onUpdateWorkItemImmediate,
currentUser: currentUserProp,
@@ -219,6 +224,7 @@ const WorkItemContent: React.FC = ({
const creatorName =
workItem.createdBy?.name ||
+ teamMembers?.find((member) => member.id === workItem.user_id)?.name ||
workItem.user_id ||
t("workItems.activity.system");
const displayedDescription = resolvedDescription ?? rawDescription;
@@ -238,6 +244,9 @@ const WorkItemContent: React.FC = ({
base: string;
value: string;
} | null>(null);
+ const [descriptionEditWorkItemId, setDescriptionEditWorkItemId] = useState<
+ string | null
+ >(null);
const currentDescriptionDraft =
descriptionDraftState?.workItemId === workItem.session_id
? descriptionDraftState
@@ -250,6 +259,13 @@ const WorkItemContent: React.FC = ({
currentDescriptionDraft && descriptionHasChanges
? currentDescriptionDraft.value
: displayedDescription;
+ const sectionPolicy = resolveWorkItemContentSectionPolicy(
+ presentation,
+ Boolean(workItem.proofOfWork)
+ );
+ const isThread = presentation === "thread";
+ const isEditingThreadDescription =
+ isThread && descriptionEditWorkItemId === workItem.session_id;
const handleDescriptionDraftChange = (markdown: string) => {
setDescriptionDraftState((current) => {
@@ -266,13 +282,29 @@ const WorkItemContent: React.FC = ({
const handleCancelDescription = () => {
setDescriptionDraftState(null);
+ setDescriptionEditWorkItemId(null);
};
const handleSaveDescription = () => {
handleDescriptionChange(descriptionDraft);
setDescriptionDraftState(null);
+ setDescriptionEditWorkItemId(null);
};
+ const descriptionActions =
+ isThread && canEditDescription && !isEditingThreadDescription ? (
+ }
+ onClick={() => setDescriptionEditWorkItemId(workItem.session_id)}
+ data-testid="work-item-description-edit"
+ >
+ {t("common:actions.edit")}
+
+ ) : null;
+
const descriptionSection = (
= ({
>
= ({
primaryAction={{
label: t("common:actions.save"),
onClick: handleSaveDescription,
+ disabled: !descriptionHasChanges,
dataTestId: "work-item-description-save",
}}
/>
@@ -346,11 +383,12 @@ const WorkItemContent: React.FC = ({
)}
- {isGitHubWorkItem ? (
+ {isGitHubWorkItem || (isThread && !isEditingThreadDescription) ? (
) : (
= ({
separatorVisible={false}
descriptionPlaceholder={t("workItems.descriptionPlaceholder")}
editable={canEditDescription}
- descriptionMaxHeight={600}
+ descriptionMinHeight={isThread ? 120 : 200}
+ descriptionMaxHeight={isThread ? 360 : 600}
+ descriptionDefaultMode={isThread ? "raw" : undefined}
descriptionClassName="no-bottom-border"
repoPath={repoPath}
className="w-full"
@@ -383,7 +423,14 @@ const WorkItemContent: React.FC = ({
);
- const todosSection = (
+ const todosSection = isThread ? (
+
+ ) : (
= ({
/>
);
- const lowerSection = (
+ const agentWorkflow = (
+
+ );
+
+ const outputContent = (
+
+ );
+
+ const historyContent = (
+ setIsSubscribed(!isSubscribed)}
+ commentText={commentText}
+ onCommentTextChange={setCommentText}
+ onCommentSubmit={handleCommentSubmit}
+ isSubmittingComment={isSubmittingComment}
+ presentation={presentation}
+ />
+ );
+
+ const tabbedLowerSection = (
= ({
{activeSessionTab === "session" && (
<>
-
+ {sectionPolicy.showLinkedSessionsTable ? (
+
-
-
+ ) : null}
>
)}
- {activeSessionTab === "output" && (
-
- )}
+ {activeSessionTab === "output" && outputContent}
- {activeSessionTab === "history" && (
- setIsSubscribed(!isSubscribed)}
- commentText={commentText}
- onCommentTextChange={setCommentText}
- onCommentSubmit={handleCommentSubmit}
- isSubmittingComment={isSubmittingComment}
- />
- )}
+ {activeSessionTab === "history" && historyContent}
);
+ const threadLowerSection = (
+ <>
+ {sectionPolicy.showInlineWorkflow ? agentWorkflow : null}
+ {sectionPolicy.showInlineOutput ? outputContent : null}
+ {sectionPolicy.showInlineHistory ? historyContent : null}
+ >
+ );
+
+ if (isThread) {
+ return (
+
+ {descriptionSection}
+ {todosSection}
+ {threadLowerSection}
+
+ );
+ }
+
return (
= ({
propertiesContent={headerProperties}
descriptionContent={descriptionSection}
todosContent={todosSection}
- lowerContent={lowerSection}
+ lowerContent={
+ sectionPolicy.showTabbedLowerSection
+ ? tabbedLowerSection
+ : threadLowerSection
+ }
scrollable
/>
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts
new file mode 100644
index 0000000000..961dd632aa
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts
@@ -0,0 +1,39 @@
+export type WorkItemContentPresentation = "default" | "thread";
+
+export interface WorkItemContentSectionPolicy {
+ showTabbedLowerSection: boolean;
+ showLinkedSessionsTable: boolean;
+ showInlineWorkflow: boolean;
+ showInlineOutput: boolean;
+ showInlineHistory: boolean;
+}
+
+/**
+ * Keep the Work Item presentation policy explicit and testable.
+ *
+ * The default surface retains its existing tabs/table. Team Inbox uses the
+ * thread policy: workflow/session cards and activity are inline, while the
+ * duplicate linked-session table is absent.
+ */
+export function resolveWorkItemContentSectionPolicy(
+ presentation: WorkItemContentPresentation,
+ hasProofOfWork: boolean
+): WorkItemContentSectionPolicy {
+ if (presentation === "thread") {
+ return {
+ showTabbedLowerSection: false,
+ showLinkedSessionsTable: false,
+ showInlineWorkflow: true,
+ showInlineOutput: hasProofOfWork,
+ showInlineHistory: true,
+ };
+ }
+
+ return {
+ showTabbedLowerSection: true,
+ showLinkedSessionsTable: true,
+ showInlineWorkflow: false,
+ showInlineOutput: false,
+ showInlineHistory: false,
+ };
+}
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts
new file mode 100644
index 0000000000..68bba8618f
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts
@@ -0,0 +1,28 @@
+import type { TodoItem } from "@src/types/core/workItem";
+
+export const THREAD_TODO_MAX_LENGTH = 120;
+
+export function normalizeThreadTodos(
+ todos: readonly TodoItem[] | null | undefined
+): TodoItem[] {
+ return (todos ?? [])
+ .map((todo) => ({
+ ...todo,
+ content: todo.content.trim(),
+ }))
+ .filter((todo) => todo.content.length > 0);
+}
+
+export function createThreadTodo(
+ content: string,
+ now: number
+): TodoItem | null {
+ const normalized = content.trim().slice(0, THREAD_TODO_MAX_LENGTH);
+ if (!normalized) return null;
+
+ return {
+ id: `todo-${now}`,
+ content: normalized,
+ status: "pending",
+ };
+}
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts
index d13d96b34e..dcbdb9b55b 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts
@@ -9,12 +9,18 @@ import type { Person } from "@src/types/core/shared";
import type { WorkItem as WorkItemExtended } from "@src/types/core/workItem";
import type { AgentRole } from "../../constants";
+import type { WorkItemContentPresentation } from "./presentation";
export const SESSION_TAB_KEYS = ["session", "output", "history"] as const;
export type SessionTab = (typeof SESSION_TAB_KEYS)[number];
export interface WorkItemContentProps {
workItem: WorkItemExtended;
+ /**
+ * `thread` lays workflow/session cards and activity into one continuous
+ * surface. It omits the legacy lower tab strip and linked-session table.
+ */
+ presentation?: WorkItemContentPresentation;
onUpdateWorkItem?: (updates: Partial) => void;
onUpdateWorkItemImmediate?: (updates: Partial) => void;
currentUser?: Person;
@@ -78,6 +84,7 @@ export interface HistoryTabProps {
onCommentTextChange: (text: string) => void;
onCommentSubmit: () => void;
isSubmittingComment: boolean;
+ presentation?: WorkItemContentPresentation;
}
export interface TimelineEntry {
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts
index 61341ca67d..a8c8501c59 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts
@@ -23,13 +23,13 @@ type TimelineTranslator = (
export function useWorkItemTimeline({
workItem,
- teamMembers: _teamMembers,
+ teamMembers,
}: UseWorkItemTimelineOptions) {
const { t } = useTranslation("projects");
const timelineEntries = useMemo(
- () => buildWorkItemTimelineEntries(workItem, t),
- [workItem, t]
+ () => buildWorkItemTimelineEntries(workItem, t, teamMembers),
+ [workItem, t, teamMembers]
);
const lastUpdatedRef = useRef(workItem.updated_time);
@@ -42,11 +42,16 @@ export function useWorkItemTimeline({
export function buildWorkItemTimelineEntries(
workItem: WorkItemExtended,
- t: TimelineTranslator
+ t: TimelineTranslator,
+ teamMembers: readonly Person[] = []
): TimelineEntry[] {
+ const memberNameById = new Map(
+ teamMembers.map((member) => [member.id, member.name])
+ );
const entries =
- workItem.history?.map((event) => historyEventToTimelineEntry(event, t)) ??
- [];
+ workItem.history?.map((event) =>
+ historyEventToTimelineEntry(event, t, memberNameById)
+ ) ?? [];
const existingCommentIds = commentIdsFromHistory(workItem.history ?? []);
for (const comment of workItem.comments ?? []) {
@@ -58,7 +63,7 @@ export function buildWorkItemTimelineEntries(
id: comment.id,
timestamp: comment.created_at,
type: WORK_ITEM_HISTORY_ACTION.COMMENTED,
- userName: comment.author,
+ userName: memberNameById.get(comment.author) ?? comment.author,
descriptions: [comment.content || t("workItems.activity.commented")],
});
}
@@ -84,14 +89,18 @@ function commentIdsFromHistory(history: WorkItemHistoryEvent[]): Set {
function historyEventToTimelineEntry(
event: WorkItemHistoryEvent,
- t: TimelineTranslator
+ t: TimelineTranslator,
+ memberNameById: ReadonlyMap
): TimelineEntry {
return {
id: event.id,
timestamp: event.timestamp,
type: event.action,
userName:
- event.actorName || event.actorId || t("workItems.activity.system"),
+ event.actorName ||
+ (event.actorId ? memberNameById.get(event.actorId) : undefined) ||
+ event.actorId ||
+ t("workItems.activity.system"),
descriptions: eventDescriptions(event, t),
};
}
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts
new file mode 100644
index 0000000000..805378dfb3
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment jsdom
+import { act, createElement } from "react";
+import { type Root, createRoot } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import type { WorkItem } from "@src/types/core/workItem";
+
+import WorkItemProperties from ".";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("./PlanningSection", () => ({
+ PlanningSection: () => createElement("span", null, "Planning"),
+}));
+vi.mock("./StatusPrioritySection", () => ({
+ StatusPrioritySection: () => createElement("span", null, "Status"),
+}));
+vi.mock("./PeopleSection", () => ({
+ PeopleSection: () => createElement("span", null, "People"),
+}));
+vi.mock("./DatesScheduleSection", () => ({
+ DatesScheduleSection: () => createElement("span", null, "Dates"),
+}));
+vi.mock("./LabelsSection", () => ({
+ LabelsSection: () => createElement("span", null, "Labels"),
+}));
+vi.mock("./useWorkItemPropertyHandlers", () => ({
+ useWorkItemPropertyHandlers: () => ({}),
+}));
+
+const workItem = {
+ session_id: "work-item-1",
+ labels: [],
+} as unknown as WorkItem;
+
+describe("WorkItemProperties pill layout", () => {
+ 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(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ it("wraps pills when the host opts into a responsive layout", () => {
+ act(() => {
+ root.render(
+ createElement(WorkItemProperties, {
+ workItem,
+ onUpdate: vi.fn(),
+ fieldVariant: "pill",
+ pillLayout: "wrap",
+ })
+ );
+ });
+
+ const pills = container.querySelector(
+ "[data-testid='work-item-property-pills']"
+ );
+ expect(pills?.getAttribute("data-layout")).toBe("wrap");
+ expect(pills?.classList.contains("flex-wrap")).toBe(true);
+ expect(pills?.classList.contains("flex-nowrap")).toBe(false);
+ });
+
+ it("preserves the compact single-row default for existing hosts", () => {
+ act(() => {
+ root.render(
+ createElement(WorkItemProperties, {
+ workItem,
+ onUpdate: vi.fn(),
+ fieldVariant: "pill",
+ })
+ );
+ });
+
+ const pills = container.querySelector(
+ "[data-testid='work-item-property-pills']"
+ );
+ expect(pills?.getAttribute("data-layout")).toBe("nowrap");
+ expect(pills?.classList.contains("flex-nowrap")).toBe(true);
+ });
+});
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx
index 861ca72536..3ba4d0b7d5 100644
--- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx
@@ -102,6 +102,7 @@ const WorkItemProperties: React.FC = ({
showTime = true,
externalStatusConfig,
fieldVariant = "row",
+ pillLayout = "nowrap",
visibleFields = DEFAULT_VISIBLE_FIELDS,
showMoreMenu = false,
}) => {
@@ -252,8 +253,16 @@ const WorkItemProperties: React.FC = ({
if (fieldVariant === "pill") {
return (
-
-
+
+
` regions.
+- [ ] Header and To-Do actions remain keyboard-navigable.
+- [ ] Icon-only controls retain translated accessible names.
+- [ ] Collapsible Workflow keeps the existing button semantics and focus treatment.
+
+## Acceptance Criteria
+
+- [ ] Team Inbox composes the thread through `WorkItemThreadLayout`.
+- [ ] Static thread cards compose through `WorkItemThreadSection`.
+- [ ] Collapsible Workflow reuses the same Work Item thread tokens without duplicating collapse state.
+- [ ] The ordinary Work Item presentation remains unchanged.
+- [ ] No persistence, orchestration, navigation, polling, or subscription ownership moves into the presentation primitives.
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts
new file mode 100644
index 0000000000..987dcaa7aa
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveWorkItemThreadHeaderPolicy } from "../presentation";
+
+describe("resolveWorkItemThreadHeaderPolicy", () => {
+ it("omits the metadata band when no path or properties exist", () => {
+ expect(resolveWorkItemThreadHeaderPolicy(false, false)).toEqual({
+ showHeader: false,
+ showSeparator: false,
+ });
+ });
+
+ it.each([
+ [true, false],
+ [false, true],
+ ])(
+ "renders a single header source without a separator",
+ (hasPath, hasProperties) => {
+ expect(resolveWorkItemThreadHeaderPolicy(hasPath, hasProperties)).toEqual(
+ {
+ showHeader: true,
+ showSeparator: false,
+ }
+ );
+ }
+ );
+
+ it("separates the path from properties when both are present", () => {
+ expect(resolveWorkItemThreadHeaderPolicy(true, true)).toEqual({
+ showHeader: true,
+ showSeparator: true,
+ });
+ });
+});
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx
new file mode 100644
index 0000000000..123e2ee3ff
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx
@@ -0,0 +1,102 @@
+import React, { useId } from "react";
+
+import { DetailPanelContainer } from "@src/modules/shared/layouts/blocks";
+
+import { resolveWorkItemThreadHeaderPolicy } from "./presentation";
+import { WORK_ITEM_THREAD_TOKENS } from "./tokens";
+
+interface WorkItemThreadLayoutProps {
+ path?: React.ReactNode;
+ properties?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export const WorkItemThreadLayout: React.FC = ({
+ path,
+ properties,
+ children,
+}) => {
+ const headerPolicy = resolveWorkItemThreadHeaderPolicy(
+ Boolean(path),
+ Boolean(properties)
+ );
+
+ return (
+
+
+
+ {headerPolicy.showHeader ? (
+
+ {path ?
{path}
: null}
+ {headerPolicy.showSeparator ? (
+
+ ) : null}
+ {properties ? (
+
{properties}
+ ) : null}
+
+ ) : null}
+ {children}
+
+
+
+ );
+};
+
+interface WorkItemThreadSectionProps {
+ icon?: React.ReactNode;
+ title: React.ReactNode;
+ meta?: React.ReactNode;
+ action?: React.ReactNode;
+ children: React.ReactNode;
+ testId?: string;
+ bodyClassName?: string;
+}
+
+export const WorkItemThreadSection: React.FC = ({
+ icon,
+ title,
+ meta,
+ action,
+ children,
+ testId,
+ bodyClassName,
+}) => {
+ const titleId = useId();
+
+ return (
+
+
+
+ {icon}
+
+ {title}
+
+ {meta}
+
+ {action}
+
+
+ {children}
+
+
+ );
+};
+
+export { WORK_ITEM_THREAD_TOKENS } from "./tokens";
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts
new file mode 100644
index 0000000000..c1d86cfde7
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts
@@ -0,0 +1,14 @@
+export interface WorkItemThreadHeaderPolicy {
+ showHeader: boolean;
+ showSeparator: boolean;
+}
+
+export function resolveWorkItemThreadHeaderPolicy(
+ hasPath: boolean,
+ hasProperties: boolean
+): WorkItemThreadHeaderPolicy {
+ return {
+ showHeader: hasPath || hasProperties,
+ showSeparator: hasPath && hasProperties,
+ };
+}
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts
new file mode 100644
index 0000000000..3a86244fd4
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts
@@ -0,0 +1,11 @@
+export const WORK_ITEM_THREAD_TOKENS = {
+ card: "overflow-hidden rounded-xl border border-border-1 bg-primary-container",
+ cardHeader:
+ "flex min-h-10 items-center justify-between gap-3 border-b border-border-1 px-3 py-2",
+ cardBody: "px-3 py-2",
+ collapsibleHeader: "!mb-0 !h-10 border-b border-border-1 px-3",
+ contentColumn:
+ "mx-auto flex w-full max-w-[920px] flex-col gap-3 px-5 py-5 pb-24",
+ metadataBand:
+ "flex min-w-0 items-center gap-2 overflow-x-auto rounded-xl border border-border-1 bg-fill-1 px-3 py-2 scrollbar-hide",
+} as const;
diff --git a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
new file mode 100644
index 0000000000..ebed82af23
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
@@ -0,0 +1,82 @@
+import type { WorkItemPartialUpdate } from "@src/api/http/project";
+import type { WorkItem } from "@src/types/core/workItem";
+
+export type WorkItemUiPatch = Omit<
+ Partial,
+ "assignee" | "milestone" | "endDate" | "target_date"
+> & {
+ assignee?: WorkItem["assignee"] | null;
+ milestone?: WorkItem["milestone"] | null;
+ endDate?: WorkItem["endDate"] | null;
+ target_date?: WorkItem["target_date"] | null;
+};
+
+/**
+ * Map a UI-shaped Work Item patch onto the canonical project-store payload.
+ *
+ * Shared by every Work Item surface so the Chat Panel and Team Inbox cannot
+ * drift on which fields are persisted.
+ */
+export function toWorkItemPartialUpdate(
+ updates: WorkItemUiPatch
+): WorkItemPartialUpdate {
+ const payload: WorkItemPartialUpdate = {};
+
+ if (updates.name !== undefined) payload.title = updates.name;
+ if (updates.spec !== undefined) payload.body = updates.spec;
+ if (updates.workItemStatus !== undefined) {
+ payload.status = updates.workItemStatus;
+ }
+ if (updates.priority !== undefined) payload.priority = updates.priority;
+ if (updates.project?.id) payload.project = updates.project.id;
+ if (updates.star !== undefined) payload.starred = updates.star;
+ if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null;
+ if ("assigneeType" in updates) {
+ payload.assigneeType = updates.assigneeType ?? null;
+ }
+ if ("labels" in updates) {
+ payload.labels = updates.labels?.map((label) => label.id) ?? [];
+ }
+ if ("milestone" in updates) {
+ payload.milestone = updates.milestone?.id ?? null;
+ }
+ if ("startDate" in updates) payload.startDate = updates.startDate ?? null;
+ if ("endDate" in updates) payload.targetDate = updates.endDate ?? null;
+ if ("target_date" in updates) {
+ payload.targetDate = updates.target_date ?? null;
+ }
+ if (updates.todos !== undefined) {
+ payload.todos = updates.todos.map((todo) => ({
+ id: todo.id,
+ content: todo.content,
+ status: todo.status,
+ }));
+ }
+ if (updates.comments !== undefined) {
+ payload.comments = updates.comments.map((comment) => ({
+ id: comment.id,
+ author: comment.author,
+ content: comment.content,
+ created_at: comment.created_at,
+ }));
+ }
+ if (updates.linkedSessions !== undefined) {
+ payload.linkedSessions = updates.linkedSessions;
+ }
+ if (updates.orchestratorConfig !== undefined) {
+ payload.orchestratorConfig = updates.orchestratorConfig;
+ }
+ if (updates.orchestratorState !== undefined) {
+ payload.orchestratorState = updates.orchestratorState;
+ }
+ if (updates.schedule !== undefined) payload.schedule = updates.schedule;
+ if (updates.executionLock !== undefined) {
+ payload.executionLock = updates.executionLock;
+ }
+ if (updates.closeOut !== undefined) payload.closeOut = updates.closeOut;
+ if (updates.workProducts !== undefined) {
+ payload.workProducts = updates.workProducts;
+ }
+
+ return payload;
+}
diff --git a/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx b/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx
index 7f1b13705c..1ce2651309 100644
--- a/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx
+++ b/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx
@@ -68,6 +68,7 @@ export interface ProjectContentEditorProps {
titleActions?: ReactNode;
metaContent?: ReactNode;
descriptionClassName?: string;
+ descriptionMinHeight?: number;
descriptionMaxHeight?: number | string;
descriptionDefaultMode?: RichMarkdownEditorMode;
repoPath?: string | null;
@@ -140,6 +141,7 @@ const ProjectContentEditor = forwardRef<
titleActions,
metaContent,
descriptionClassName = "",
+ descriptionMinHeight = 200,
descriptionMaxHeight,
descriptionDefaultMode,
repoPath,
@@ -390,7 +392,7 @@ const ProjectContentEditor = forwardRef<
slashCommandKeyboardHandlerRef.current?.(event) ?? false
}
onImageInsert={editable ? onImageInsert : undefined}
- minHeight={200}
+ minHeight={descriptionMinHeight}
maxHeight={descriptionMaxHeight}
defaultMode={descriptionDefaultMode}
editable={editable}
diff --git a/src/modules/shared/components/ActivityTimeline/index.tsx b/src/modules/shared/components/ActivityTimeline/index.tsx
index 2920348bf0..a98b7ed36b 100644
--- a/src/modules/shared/components/ActivityTimeline/index.tsx
+++ b/src/modules/shared/components/ActivityTimeline/index.tsx
@@ -127,21 +127,36 @@ export function ConnectedTimelineItem({
export function TimelineCard({
header,
copyBody,
+ actions,
footer,
children,
+ className = "",
+ bodyClassName = "",
}: {
header: React.ReactNode;
copyBody?: string;
+ actions?: React.ReactNode;
footer?: React.ReactNode;
children?: React.ReactNode;
+ className?: string;
+ bodyClassName?: string;
}): React.ReactNode {
return (
-
+
{header}
- {copyBody ?
: null}
+ {copyBody || actions ? (
+
+ {actions}
+ {copyBody ? : null}
+
+ ) : null}
+
+
+ {children}
-
{children}
{footer}
);
diff --git a/src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts b/src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts
new file mode 100644
index 0000000000..08449f23c8
--- /dev/null
+++ b/src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts
@@ -0,0 +1,63 @@
+import { createStore } from "jotai";
+import { describe, expect, it } from "vitest";
+
+import {
+ consumeChatPanelWorkItemActionAtom,
+ pendingChatPanelWorkItemActionAtom,
+ requestChatPanelWorkItemActionAtom,
+} from "../chatPanelWorkItemActionAtoms";
+
+describe("chat panel Work Item action requests", () => {
+ it("consumes a matching start request exactly once", () => {
+ const store = createStore();
+ const request = store.set(requestChatPanelWorkItemActionAtom, {
+ workItemShortId: "ORG-42",
+ action: "start_agent",
+ });
+
+ expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(request);
+ expect(store.set(consumeChatPanelWorkItemActionAtom, request)).toEqual(
+ request
+ );
+ expect(store.get(pendingChatPanelWorkItemActionAtom)).toBeNull();
+ expect(store.set(consumeChatPanelWorkItemActionAtom, request)).toBeNull();
+ });
+
+ it("does not consume a request from another Work Item", () => {
+ const store = createStore();
+ const request = store.set(requestChatPanelWorkItemActionAtom, {
+ workItemShortId: "ORG-42",
+ action: "start_agent",
+ });
+
+ expect(
+ store.set(consumeChatPanelWorkItemActionAtom, {
+ ...request,
+ workItemShortId: "ORG-43",
+ })
+ ).toBeNull();
+ expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(request);
+ });
+
+ it("lets the newest unclaimed navigation intent supersede an older one", () => {
+ const store = createStore();
+ const olderRequest = store.set(requestChatPanelWorkItemActionAtom, {
+ workItemShortId: "ORG-42",
+ action: "start_agent",
+ });
+ const newestRequest = store.set(requestChatPanelWorkItemActionAtom, {
+ workItemShortId: "ORG-43",
+ action: "start_agent",
+ });
+
+ expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(
+ newestRequest
+ );
+ expect(
+ store.set(consumeChatPanelWorkItemActionAtom, olderRequest)
+ ).toBeNull();
+ expect(
+ store.set(consumeChatPanelWorkItemActionAtom, newestRequest)
+ ).toEqual(newestRequest);
+ });
+});
diff --git a/src/store/chatPanel/chatPanelTabsAtom.ts b/src/store/chatPanel/chatPanelTabsAtom.ts
index f1ed721823..ca1e4277ed 100644
--- a/src/store/chatPanel/chatPanelTabsAtom.ts
+++ b/src/store/chatPanel/chatPanelTabsAtom.ts
@@ -72,3 +72,10 @@ export {
chatPanelTabCountAtom,
chatPanelTabsAtom,
} from "./chatPanelTabsState";
+export {
+ consumeChatPanelWorkItemActionAtom,
+ pendingChatPanelWorkItemActionAtom,
+ requestChatPanelWorkItemActionAtom,
+ type ChatPanelWorkItemAction,
+ type ChatPanelWorkItemActionRequest,
+} from "./chatPanelWorkItemActionAtoms";
diff --git a/src/store/chatPanel/chatPanelWorkItemActionAtoms.ts b/src/store/chatPanel/chatPanelWorkItemActionAtoms.ts
new file mode 100644
index 0000000000..6cb6eb4ea3
--- /dev/null
+++ b/src/store/chatPanel/chatPanelWorkItemActionAtoms.ts
@@ -0,0 +1,45 @@
+import { atom } from "jotai";
+
+export type ChatPanelWorkItemAction = "start_agent";
+
+export interface ChatPanelWorkItemActionRequest {
+ requestId: string;
+ workItemShortId: string;
+ action: ChatPanelWorkItemAction;
+}
+
+export const pendingChatPanelWorkItemActionAtom =
+ atom
(null);
+
+export const requestChatPanelWorkItemActionAtom = atom(
+ null,
+ (_get, set, request: Omit) => {
+ const pendingRequest: ChatPanelWorkItemActionRequest = {
+ ...request,
+ requestId: crypto.randomUUID(),
+ };
+ set(pendingChatPanelWorkItemActionAtom, pendingRequest);
+ return pendingRequest;
+ }
+);
+
+export const consumeChatPanelWorkItemActionAtom = atom(
+ null,
+ (
+ get,
+ set,
+ request: ChatPanelWorkItemActionRequest
+ ): ChatPanelWorkItemActionRequest | null => {
+ const pendingRequest = get(pendingChatPanelWorkItemActionAtom);
+ if (
+ pendingRequest?.requestId !== request.requestId ||
+ pendingRequest.workItemShortId !== request.workItemShortId ||
+ pendingRequest.action !== request.action
+ ) {
+ return null;
+ }
+
+ set(pendingChatPanelWorkItemActionAtom, null);
+ return pendingRequest;
+ }
+);
From 2ca0006b2ba02e4000773f10bba6539ffac8f286 Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Mon, 27 Jul 2026 23:39:47 +0800
Subject: [PATCH 03/11] test(team-inbox): cover dual-instance mention receipts
Pre-commit hook ran. Total eslint: 2, total circular: 0
---
.../core/cloud-dual-instance-ui.spec.mjs | 98 +++++++++++++++++++
tests/e2e/support/core/cloudOrgUiDriver.mjs | 15 ++-
2 files changed, 112 insertions(+), 1 deletion(-)
diff --git a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs
index e9462010ae..aabe8d5184 100644
--- a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs
+++ b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs
@@ -23,6 +23,7 @@ import {
openCreateOrgFormFromSidebar,
openTurnCommentPanel,
postTurnComment,
+ postTurnCommentMentioning,
pressEscape,
provisionCloudUser,
publishCloudSessionMetadata,
@@ -62,6 +63,7 @@ const SESSION_NOTE_BODY = `Dual-instance session note ${RUN_ID}`;
const EDITED_COMMENT_BODY = `@agent dual-instance edited task ${RUN_ID}`;
const EDITED_COMMENT_BRIEF = EDITED_COMMENT_BODY.slice("@agent ".length);
const REPLY_BODY = `Owner reply from the other instance ${RUN_ID}`;
+const TEAM_INBOX_MENTION_BODY = `Team Inbox mention ${RUN_ID}`;
const SEND_BODY = `Continue this work from the matching workspace ${RUN_ID}`;
const PROJECT_NAME = `Dual cloud project ${RUN_ID}`;
const PROJECT_SLUG = PROJECT_NAME.toLowerCase()
@@ -2353,6 +2355,102 @@ describe("Cloud collaboration with two independent rendered app instances", func
}
});
+ it("C2. delivers a structured member mention and persists the teammate read receipt", async function () {
+ this.timeout(180_000);
+
+ unwrap(
+ await invokeE2E("openSession", sessionId),
+ "primary reopen source session for Team Inbox mention"
+ );
+ await openTurnCommentPanel(sourceTurnAnchorEventId);
+ await postTurnCommentMentioning(TEAM_INBOX_MENTION_BODY, teammate.userId);
+ await waitForRendered(
+ '[data-testid="comment-member-mention-pill"]',
+ "primary rendered teammate mention chip",
+ CLOUD_FETCH_TIMEOUT_MS
+ );
+
+ await waitForRenderedOn(
+ second.client,
+ '[data-testid="sidebar-team-inbox"]',
+ "secondary Team Inbox navigation",
+ CLOUD_FETCH_TIMEOUT_MS
+ );
+ await clickRenderedOn(
+ second.client,
+ '[data-testid="sidebar-team-inbox"]',
+ "secondary Team Inbox navigation"
+ );
+ await second.client.waitUntil(
+ async () =>
+ executeOn(
+ second.client,
+ `
+ const body = arguments[0];
+ const row = Array.from(
+ document.querySelectorAll(
+ '[data-testid="team-inbox-row"][data-item-kind="comment_mention"]'
+ )
+ ).find((candidate) => (candidate.textContent ?? '').includes(body));
+ if (!row) return false;
+ row.setAttribute('data-e2e-team-inbox-mention', 'true');
+ return row.getAttribute('data-unread') === 'true';
+ `,
+ [TEAM_INBOX_MENTION_BODY]
+ ),
+ {
+ timeout: CLOUD_FETCH_TIMEOUT_MS,
+ interval: 250,
+ timeoutMsg:
+ "secondary Team Inbox never rendered the teammate mention as unread",
+ }
+ );
+
+ await clickRenderedOn(
+ second.client,
+ '[data-e2e-team-inbox-mention="true"]',
+ "secondary unread Team Inbox mention"
+ );
+
+ let teammateInbox = null;
+ await second.client.waitUntil(
+ async () => {
+ teammateInbox = await callProjectsRpc(
+ env,
+ teammate,
+ "cloud_list_team_inbox_mentions",
+ { p_org_id: teamOrgId, p_cursor: null, p_limit: 50 }
+ );
+ const mention = (teammateInbox?.mentions ?? []).find(
+ (entry) => entry.body === TEAM_INBOX_MENTION_BODY
+ );
+ return Boolean(mention?.readAt && teammateInbox.unreadCount === 0);
+ },
+ {
+ timeout: CLOUD_FETCH_TIMEOUT_MS,
+ interval: 500,
+ timeoutMsg:
+ "secondary click did not persist the viewer-scoped cloud read receipt",
+ }
+ );
+
+ const ownerInbox = await callProjectsRpc(
+ env,
+ owner,
+ "cloud_list_team_inbox_mentions",
+ { p_org_id: teamOrgId, p_cursor: null, p_limit: 50 }
+ );
+ if (
+ (ownerInbox?.mentions ?? []).some(
+ (entry) => entry.body === TEAM_INBOX_MENTION_BODY
+ )
+ ) {
+ throw new Error(
+ "mention projection leaked the teammate-targeted comment into the owner Inbox"
+ );
+ }
+ });
+
it("D. syncs comment CRUD/status, intercepts send into a same-remote fork, and revokes directed access live", async function () {
this.timeout(360_000);
diff --git a/tests/e2e/support/core/cloudOrgUiDriver.mjs b/tests/e2e/support/core/cloudOrgUiDriver.mjs
index a3cdf1a7ad..e510139af5 100644
--- a/tests/e2e/support/core/cloudOrgUiDriver.mjs
+++ b/tests/e2e/support/core/cloudOrgUiDriver.mjs
@@ -977,7 +977,7 @@ async function postOpenComment(body) {
await browser.waitUntil(
async () =>
(await execJS(
- js.click('[data-testid="session-comment-composer"] button')
+ js.click('[data-testid="session-comment-composer-submit"]')
)) === "clicked",
{
timeout: RENDER_TIMEOUT_MS,
@@ -996,6 +996,19 @@ export async function postTurnComment(body) {
await postOpenComment(body);
}
+/** Posts through the production member picker; no RPC/helper creates mention state. */
+export async function postTurnCommentMentioning(body, memberUserId) {
+ await clickRendered(
+ '[data-testid="session-comment-composer-mention-members"]',
+ "comment member mention picker"
+ );
+ await clickRendered(
+ `[data-testid="session-comment-mention-${memberUserId}"]`,
+ "comment mentioned member"
+ );
+ await postOpenComment(body);
+}
+
/** Opens the header-level session-notes dialog and posts an unanchored note. */
export async function postSessionNote(body) {
await clickRendered(
From 4ec1851132c7696543e18f829b91349be7fbc023 Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Mon, 27 Jul 2026 23:40:24 +0800
Subject: [PATCH 04/11] docs(audit): record team inbox closure
Pre-commit hook ran. Total eslint: 2, total circular: 0
---
.../TeamInboxCollaboration.md | 85 ++++++++++++++++
.../TeamInboxThread.md | 98 +++++++++++++++++++
.../TeamInboxCollaboration.md | 51 ++++++++++
.../TeamInboxResponsive.md | 48 +++++++++
.../TeamInboxThread.md | 61 ++++++++++++
.../TeamInboxCollaboration.md | 25 +++++
.../TeamInboxKanban.md | 12 +++
7 files changed, 380 insertions(+)
create mode 100644 docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md
create mode 100644 docs/architecture-audit-2026-07-27/TeamInboxThread.md
create mode 100644 docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md
create mode 100644 docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md
create mode 100644 docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md
create mode 100644 docs/org2-performance-guard-2026-07-27/TeamInboxCollaboration.md
create mode 100644 docs/org2-performance-guard-2026-07-27/TeamInboxKanban.md
diff --git a/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md
new file mode 100644
index 0000000000..0d9af282b0
--- /dev/null
+++ b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md
@@ -0,0 +1,85 @@
+# Architecture Audit — Team Inbox Multi-User Collaboration
+
+**Scope:** structured member mentions, durable viewer-scoped read receipts, authoritative unread counts, full-roster Work Item identity projection, and dual-instance UI coverage.
+**Date:** 2026-07-27
+
+## Layer 1 — Compilation correctness
+
+- TypeScript `tsc --noEmit`: passed.
+- Focused ESLint over all changed collaboration/UI files: passed.
+- Twenty-four focused Vitest files: 230 tests passed after the capability-gate regression cases were added.
+- Cloud migration was statically reviewed; live apply and live two-account E2E remain deployment validation.
+
+## Layer 2 — Dead code and structural deduplication
+
+- Removed the cloud mention localStorage receipt owner; server receipts are now the sole cross-device source of truth.
+- `resolveMentions` and `MemberMentionChip` own repeated UUID-to-name and pill UI logic.
+- Session comments load the active roster through the existing shared roster coordinator rather than adding a second fetch/cache.
+- Work Item history, description creator, assignee, and reviewer all project the same roster identities.
+
+## Layer 3 — Naming consistency
+
+- `mentionedUserIds` is used consistently on client wire/domain models; PostgreSQL uses `mentioned_user_ids`.
+- `readAt` denotes the viewer-specific receipt timestamp, while `unreadCount` denotes the authoritative full-result total.
+- `markAllTeamInboxMentionsRead` is explicitly org/viewer scoped rather than implying a global Inbox mutation.
+
+## Layer 4 — Semantic overloading
+
+| Term | Meaning | Verdict |
+| --------------- | -------------------------------------------------------------- | ----------------------------------------------------------- |
+| mention | Explicit active-org member UUID attached to a comment | Never inferred from display text. |
+| read | Receipt for one authenticated viewer and one mentioned comment | Separate from comment resolution or Session state. |
+| unread count | Full eligible mention total outside the current page | Owned by the server response, not derived from loaded rows. |
+| member identity | Stable user UUID with roster-projected display name | IDs persist; names may change without rewriting history. |
+
+## Layer 5 — Default branch analysis
+
+- Old cloud deployments report no `teamInboxMentions` capability, so the structured picker stays hidden.
+- Comment adds without mentions retain the legacy RPC; adds with mentions require the atomic 0010 RPC and never silently drop recipients.
+- The view owns optimistic read/unread presentation and per-item rollback generations. The data source serializes the corresponding durable mutations through a bounded queue, so rapid opposite actions cannot commit out of order.
+- Empty, loading, pagination, filtered, and partially loaded Inbox states preserve the server unread total.
+
+## Layer 6 — Cross-domain concept leakage
+
+- PostgreSQL owns durable receipts, recipient validation, visibility, retention, and authoritative totals.
+- Org2Cloud clients own wire validation and transport retry only.
+- Team Inbox owns list/filter/optimistic presentation, not receipt persistence.
+- Session comments own member selection and mention rendering.
+- Work Item components own assignee/reviewer/history identity presentation.
+
+## Layer 7 — New developer confusion test
+
+- No caller supplies a viewer ID to receipt RPCs; `auth.uid()` is always authoritative.
+- The server accepts recipient UUIDs only after validating active membership in the target org.
+- The capability flag documents the required server/client rollout order.
+- Local single-user assigned items and cloud mention items remain distinct data-source branches with one normalized Inbox model.
+
+## Layer 8 — Wire protocol and serialization
+
+- `cloud_add_session_comment_with_mentions` atomically writes the comment and its deduplicated recipient UUIDs.
+- Existing `cloud_list_session_comments` keeps its signature and legacy keys, adding `mentionedUserIds`.
+- Mention list rows add `readAt`; the page adds `unreadCount` and a keyset `nextCursor`.
+- Receipt mutations return both the resulting `readAt` and a fresh authoritative `unreadCount`.
+- Zod schemas reject malformed wire state before it enters UI state.
+
+## Layer 9 — Init parity
+
+- Initial Inbox load and pagination both use the same mention projection; only the first page replaces the authoritative count.
+- The initial cloud projection is capability-gated. A pre-0010 backend keeps local assigned items available without attempting a missing RPC.
+- Reopened comment surfaces load persisted recipient IDs from the ordinary comment list.
+- Roster loading is keyed by endpoint/account/org/revision and discards stale identity results.
+- Account/org changes evict the previous projection in a layout effect before paint; page and mutation completions carry a load generation and cannot repopulate the new identity with old rows.
+- Both primary and secondary desktop instances exercise the production UI/data paths in the extended E2E scenario.
+
+## Layer 10 — Resolver symmetry
+
+- Owner, assignee, reviewer, comment author, and mentioned recipient all resolve through the active org roster.
+- Mark-read, mark-unread, and mark-all share the same eligibility rules as list/count: membership, retention, deletion, visibility, and active sharing.
+- Restricted Sessions are visible only to owner or active grantees across both list and count paths.
+- The owner does not receive another member's targeted mention projection unless explicitly included as a recipient.
+
+## Completion verdict
+
+- Architecture verdict: pass for Layers 1–10 in the implemented scope.
+- Deployment gate: apply cloud migration `0010_team_inbox_mentions.sql` before shipping the desktop capability-enabled experience.
+- Remaining production proof: run the managed-cloud two-account E2E after the migration is applied.
diff --git a/docs/architecture-audit-2026-07-27/TeamInboxThread.md b/docs/architecture-audit-2026-07-27/TeamInboxThread.md
new file mode 100644
index 0000000000..dc306ab99e
--- /dev/null
+++ b/docs/architecture-audit-2026-07-27/TeamInboxThread.md
@@ -0,0 +1,98 @@
+# Architecture Audit — Team Inbox Thread and Kanban Refresh
+
+**Scope:** Team Inbox full Work Item loading/editing, shared Work Item presentation policy, canonical Start Agent handoff, Session-tab navigation, and local/cloud Kanban manual refresh.
+**Date:** 2026-07-27
+
+## Layer 1 — Compilation correctness
+
+- Focused Vitest suites: passed.
+- TypeScript `tsc --noEmit`: passed.
+- Focused ESLint: passed.
+
+## Layer 2 — Dead code and structural deduplication
+
+- Removed the body-only `useTeamInboxWorkItemBody` path.
+- `useTeamInboxWorkItem` now resolves the full canonical item used by both `WorkItemContent` and `WorkItemProperties`.
+- Moved `toWorkItemPartialUpdate` out of `WorkItemPanelView` so the Chat Panel and Team Inbox share one write-payload mapper.
+- The ordinary Work Item view and Team Inbox both use one `WorkItemContent`; only an explicit presentation policy differs.
+- `WorkItemThreadLayout` now owns the centered reading frame and metadata-band composition; `WorkItemThreadSection` owns the static card shell.
+- To-Do and Workflow share `WORK_ITEM_THREAD_TOKENS`, while Workflow retains the existing `CollapsibleSection` state owner rather than introducing a second collapsible abstraction.
+- Thread-only To-Do draft state is component-local and is never persisted until a non-empty item is committed.
+- `ChatPanelWorkItemActionRequest` is a transient one-slot command envelope. It carries intent only; the canonical Work Item orchestrator remains the sole execution owner.
+
+## Layer 3 — Naming consistency
+
+- Added `presentation: "default" | "thread"` rather than an ambiguous boolean such as `hideSessions`.
+- `start_agent` is named as a navigation action request instead of overloading ordinary `open_work_item`.
+- `usePendingWorkItemAction` names the only bridge from the transient request to the canonical Work Item start command.
+- Added a dedicated `open_session` navigation intent. Opening a Session no longer overloads `open_session_comment` with empty comment/thread IDs.
+- `refreshKanbanSources` names the local/cloud fan-out without claiming ownership of either cache.
+
+## Layer 4 — Semantic overloading
+
+| Term | Meaning | Verdict |
+| ------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
+| thread | One Work Item activity flow containing workflow/session cards and history | Distinct from a Session comment thread; scoped to `WorkItemContent` presentation. |
+| thread layout | Stateless Work Item-domain presentation primitives | Owns composition/tokens only; it does not own persistence, collapse state, or orchestration. |
+| Session open | Open/focus a Session Chat Panel tab | Explicit `open_session`; comment anchoring remains `open_session_comment`. |
+| refresh | User-triggered authoritative revalidation | Local roster and cloud teammate snapshots keep their own identity/single-flight owners. |
+| start request | One-shot UI intent for the matching canonical Work Item | Not workflow state and not persisted in the tab; claimed before async orchestration begins. |
+
+## Layer 5 — Default branch analysis
+
+- `resolveWorkItemContentSectionPolicy` handles both closed presentation variants and is unit-tested.
+- `default` preserves the legacy tabs plus linked-Session table for existing consumers.
+- `thread` omits that table, renders workflow/history inline, and renders output only when proof of work exists.
+- Thread description transitions are explicit: read → editing → dirty → saved/cancelled. Save is disabled in editing/clean state.
+- Start transitions are explicit: Inbox idle action → resolve/open canonical Work Item → publish matching request → atomically claim request → existing orchestrator validates configuration/locks and starts or reports failure.
+- A claimed request is cleared before the async call, so remounts and repeated React effects cannot replay it. A non-matching Work Item cannot claim it.
+- The transient channel holds at most one unclaimed request. A newer navigation intent supersedes an older unclaimed intent, preventing a hidden tab from starting unexpectedly when visited later.
+- Read/update failures are explicit UI states; they do not fall back to fabricated data.
+
+## Layer 6 — Cross-domain concept leakage
+
+- Project persistence stays behind `projectApi` and the shared Work Item payload mapper.
+- Team Inbox owns selection and presentation only.
+- Thread primitives live under the Work Item component domain rather than a global shared package because the reading width, metadata band, and density are Work Item-specific.
+- Agent execution remains exclusively owned by the canonical Work Item surface. Team Inbox publishes intent but does not mount a second orchestrator (which would duplicate collaboration-lock, auto-review, and stale-session lifecycles).
+- Chat Panel atoms remain the sole owner of Session tab creation/focus.
+- Kanban composes refresh callbacks but does not take ownership of session/cloud caches.
+
+## Layer 7 — New developer confusion test
+
+- The presentation policy documents exactly which legacy elements are absent.
+- The thread layout API uses semantic slots (`path`, `properties`, `title`, `meta`, `action`) instead of exposing consumer-defined class bags.
+- Static versus collapsible cards remain visibly consistent through one token source, while their interaction semantics stay explicit in their owning components.
+- The full Work Item hook exposes `loading / ready / error` rather than conflating missing data with loading.
+- Standalone items remain readable but do not expose non-functional edit controls.
+- Project-scoped items expose compact shared property pills; the full property editor remains available through the canonical Work Item surface.
+
+## Layer 8 — Wire protocol and serialization
+
+- No new wire format was introduced.
+- The extracted presentation primitives are stateless and introduce no new IPC, persistence, cache, subscription, timer, or request lifecycle.
+- The start request is process-local transient UI state; it never enters tab persistence, project persistence, IPC, or the Agent wire payload.
+- Work Item writes reuse the existing `WorkItemPartialUpdate` contract.
+- To-Do drafts never cross that boundary; only normalized committed rows are serialized.
+- Team/shared `+/-` impact is not synthesized: Kanban continues to consume authoritative local impact and cloud session metadata only.
+
+## Layer 9 — Init parity
+
+- No Agent initialization entry point changed. The request terminates at the same `handleStartAgent` used by the existing Work Item button.
+- Manual refresh uses the same production local roster coordinator and cloud remote-session hook used by initial demand/realtime recovery.
+- Tests call the source-composition helper only; rendered acceptance must still drive the real button.
+
+## Layer 10 — Resolver symmetry
+
+- Project-scoped reads resolve Work Item plus project metadata/repo identity; standalone reads use the standalone API and stay read-only.
+- Local and cloud Kanban sources are both invoked by the manual action, while each source preserves its own scope/identity rules.
+- Existing Session tabs are focused; missing tabs are created through the same open-or-focus atom for both Session cards and mention navigation.
+- Work Item action resolution is symmetric for newly created and already-open tabs: both are activated first, then receive the same keyed one-shot request.
+
+## Completion verdict
+
+- One persistent Work Item owner, one Agent start dispatcher, one Session-tab dispatcher, and one cache owner per Kanban source.
+- The Team Inbox navigation wrapper now forwards explicit child intents, so Session cards no longer collapse back to the selected row's generic Work Item destination.
+- Stale Work Item reads are cancelled on selection change; overlapping writes use a monotonic generation before replacing UI state.
+- Manual workflow refresh preserves the currently rendered Work Item on read failure and exposes the error banner; it does not replace success data with a transient empty state.
+- Architecture verdict: pass for Layers 1–10 in the changed scope.
diff --git a/docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md b/docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md
new file mode 100644
index 0000000000..b2793b70c0
--- /dev/null
+++ b/docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md
@@ -0,0 +1,51 @@
+# Frontend UI Audit — Team Inbox Multi-User Collaboration
+
+**Files:** `src/features/Org2Cloud/SessionComments/*.tsx`, `src/modules/MainApp/TeamInbox/**/*.tsx`, `src/modules/ProjectManager/WorkItems/components/WorkItemContent/*.tsx`
+**Date:** 2026-07-27
+**Auditor:** Codex implementation session
+
+## D1 — Raw HTML vs Design System
+
+| Line | Element | Verdict | Reason | Suggested change |
+| ---------------------------- | ------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| `CommentThreadList.tsx` | member mention picker | fix | A collaboration action needs the same keyboard/search/selection behavior as other menus. | Reused the shared `Dropdown` in multiple-selection mode and the shared tertiary `Button`; no bespoke popover was introduced. |
+| `CommentThreadList.tsx` | selected and persisted mention chips | abstract | Composer selections and rendered comments initially repeated the same identity pill styling and ID-to-name resolution. | Added one `resolveMentions` projection and one `MemberMentionChip` presentation primitive within the owning comment domain. |
+| `AssignedWorkItemDetail.tsx` | assignee/reviewer presentation | fix | Showing only a raw assignee UUID made team ownership ambiguous and omitted reviewer state. | Reused the Work Item property surface with the complete active roster so assignee and reviewer resolve to member display names. |
+| `TeamInboxList.tsx` | mark-all-read action | keep with reason | This is a standard labeled command, already implemented with the shared Button and now receives only a stable test hook. | — |
+| `TeamInboxRow.tsx` | unread row | keep with reason | The existing row owns selection, unread emphasis, and keyboard activation; the change adds semantic test/state attributes without duplicating it. | — |
+
+## D2 — Arbitrary Tailwind Value vs Token
+
+| Line | Value | Verdict | Reason | Suggested change |
+| ----------------------- | ------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
+| `CommentThreadList.tsx` | `max-w-[160px]`, `text-[10px]` | keep with reason | These match the established compact comment-meta density and bound long member names. The values are centralized in `MemberMentionChip`. | Promote a global identity-chip token only if a second product domain needs the same compact treatment. |
+| changed files | colors | keep with reason | All new color usage is expressed through semantic primary/background/text/border tokens. No raw color literals were added. | — |
+
+## D3 — Hardcoded Sizes / Colors
+
+| Line | Value | Verdict | Reason | Suggested change |
+| ----------------------- | ---------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| `CommentThreadList.tsx` | mention pill width cap | keep with reason | The cap prevents a single member name from consuming the composer action row while preserving the full identity in the searchable picker. | Add a tooltip only if real rosters show frequent ambiguous truncation. |
+| `TeamInboxView.tsx` | no new fixed geometry | keep with reason | Optimistic state and authoritative counts change behavior only; the existing compact Inbox layout is preserved. | — |
+
+## D4 — Accessibility
+
+| Line | Element | Verdict | Reason | Suggested change |
+| ----------------------- | --------------------- | ---------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| `CommentThreadList.tsx` | mention member action | fix | The action must be keyboard reachable and expose a visible label. | Shared Button renders the translated “Mention” label; shared Dropdown provides search and selection keyboard behavior. |
+| `TeamInboxList.tsx` | mark all as read | keep with reason | The action already has a translated visible label and native Button semantics. | — |
+| `TeamInboxRow.tsx` | unread state | fix | Visual emphasis alone is insufficient for deterministic behavioral verification. | Added stable row identity and `data-unread` state; existing visible unread indicator remains unchanged. |
+
+## D5 — Visual Patterns Observed
+
+- Member identity is selected from the authoritative active roster and persisted as UUIDs; display names are a rendering projection.
+- Mention chips share one product-domain component across draft and persisted states.
+- Team Inbox keeps the existing unified thread hierarchy; collaboration adds data and state, not a second detail layout.
+- Reviewer and assignee reuse the canonical Work Item property UI instead of introducing Inbox-only badges.
+- The picker is capability-gated, so older cloud deployments do not render a control whose RPC is unavailable.
+
+## Summary
+
+- 5 fixes completed
+- 5 kept with documented reason
+- 1 abstract candidate completed
diff --git a/docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md b/docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md
new file mode 100644
index 0000000000..d32b9a04f6
--- /dev/null
+++ b/docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md
@@ -0,0 +1,48 @@
+# Frontend UI Audit — Team Inbox Responsive Detail
+
+**Files:** `src/modules/MainApp/TeamInbox/TeamInboxView.tsx`, `src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx`, `src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx`
+**Date:** 2026-07-27
+**Auditor:** Codex
+
+## D1 — Raw HTML vs Design System
+
+| Line | Element | Verdict | Reason | Suggested change |
+| -------------------------------------- | ---------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- |
+| WorkItemProperties: 301 | More-properties action | keep with reason | Uses the shared `Button` component with the established circular secondary treatment. | — |
+| TeamInboxView / AssignedWorkItemDetail | Interactive controls | keep with reason | All controls are delegated to shared `SplitViewLayout`, `WorkItemProperties`, and detail components; no new raw interactive HTML was introduced. | — |
+
+## D2 — Arbitrary Tailwind Value vs Token
+
+| Line | Value | Verdict | Reason | Suggested change |
+| ---------------------- | -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| WorkItemProperties: 42 | `bg-[var(--cm-editor-background,...)]` | fix candidate | Pre-existing project-owned surface token; the repo sweep finds three direct uses. This is outside the responsive fix and should be handled once as a token-mapping sweep. | Add a semantic Tailwind surface mapping, then replace all three sites together. |
+
+## D3 — Hardcoded Sizes / Colors
+
+| Line | Value | Verdict | Reason | Suggested change |
+| ---------------------- | --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------- |
+| TeamInboxView: 406–407 | `listWidth={200}`, `minListWidth={160}` | keep with reason | Existing resizable master-list bounds; detail responsiveness is owned by the remaining flex width and wrapping property layout. | — |
+| WorkItemProperties: 44 | `text-[13px]` | keep with reason | Existing dense property typography, repeated consistently throughout Work Items; changing one header would reduce local consistency. | — |
+
+## D4 — Accessibility
+
+| Line | Element | Verdict | Reason | Suggested change |
+| ----------------------------- | ---------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- |
+| AssignedWorkItemDetail: 63–76 | Responsive property controls | keep with reason | Wrapping changes visual flow only; shared fields retain native button semantics, accessible names, keyboard handling, and portalled menus. | — |
+| TeamInboxView: 404–412 | Split-view header policy | keep with reason | Removing the unrelated global breadcrumb also removes a misleading navigation announcement from the Team Inbox reading order. | — |
+
+## D5 — Visual Patterns Observed
+
+- Responsive pill layout is implemented once in shared `WorkItemProperties` through an explicit `pillLayout` policy.
+- Team Inbox opts into wrapping; existing inline-create hosts preserve their compact single-row behavior.
+- No new repeated visual pattern or abstraction candidate was introduced.
+
+## Next-refactor candidates
+
+- Sweep the three `bg-[var(--cm-editor-background,...)]` uses into one semantic Tailwind surface token rather than changing only this component.
+
+## Summary
+
+- 1 fix candidate, intentionally deferred to a repository-wide token sweep
+- 4 kept with documented reason
+- 0 new abstract candidates
diff --git a/docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md b/docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md
new file mode 100644
index 0000000000..1989d8c3b4
--- /dev/null
+++ b/docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md
@@ -0,0 +1,61 @@
+# Frontend UI Audit — Team Inbox Work Item Thread and Kanban Refresh
+
+**Files:** `src/modules/MainApp/TeamInbox/components/*.tsx`, `src/modules/ProjectManager/WorkItems/components/WorkItemContent/*.tsx`, `src/modules/ProjectManager/WorkItems/components/AgentWorkflow/*.tsx`, `src/modules/shared/components/ActivityTimeline/index.tsx`, `src/features/TaskKanban/**/*.tsx`
+**Date:** 2026-07-27
+**Auditor:** Codex implementation session
+
+## D1 — Raw HTML vs Design System
+
+| Line | Element | Verdict | Reason | Suggested change |
+| ---------------------------------------- | ------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `AssignedWorkItemDetail.tsx` | Work Item content/property controls | fix | The initial unified version still placed two heavy property cards in a competing right rail. | Reused `WorkItemProperties` in pill mode inside one compact metadata band and removed the Team Inbox-only rail. |
+| `WorkItemContent/index.tsx` | always-mounted description editor | fix | Preview/Raw controls and a 200px editor made reading a short Inbox item feel like editing a full database record. | Thread mode now renders natural-height Markdown and demand-mounts the shared editor after an explicit Edit action. |
+| `ThreadTodoChecklist.tsx` | empty To-Do editor | fix | The generic checklist immediately persisted an empty row and exposed an input before user intent. | Added a thread-specific demand composer that commits only trimmed, non-empty items and reuses Button/Input/Checkbox. |
+| `HistoryTab.tsx` | disconnected subscription/avatar row | fix | Subscription, avatar, history, composer, and submit were visually unrelated. | Grouped subscription with the Activity heading and attached the avatar to the outlined comment composer. |
+| `AssignedWorkItemDetail.tsx` | missing idle Agent Workflow action | fix | Removing the previous no-op callback also removed the visible primary action, leaving actionable copy without a control. | Restored the shared Agent Workflow `Button`; it forwards a one-shot start intent to the canonical Work Item surface. |
+| `ThreadTodoChecklist.tsx` | empty-state full-row action | fix | The full-width empty-state hit area is still a standard labeled action. | Reused the shared Button in long/ghost form with right-side icon instead of maintaining raw button styling. |
+| `WorkItemThread/index.tsx` | repeated thread card/layout shells | abstract | The centered reading column, metadata band, and card header/body treatment were repeated or assembled at consumer sites, making future thread surfaces likely to drift. | Added Work Item-owned `WorkItemThreadLayout` / `WorkItemThreadSection`; collapsible Workflow consumes the same token set while retaining `CollapsibleSection` semantics. |
+| `KanbanHeaderTrailingControls/index.tsx` | refresh action | fix | Kanban had no manual refresh control. | Added the shared `Button` tertiary/ghost treatment, `WorkstationToolbarTooltip`, and `useRefreshSpin`. |
+| `TaskKanban/index.tsx:396` | raw circular add-session `` | keep with reason | Existing bottom-overlay control has a specialized floating circular hit area and positioning contract; it is unrelated to the new header refresh action and was not reimplemented in this change. | Consider a separate `IconButton` sweep for floating Work Management actions. |
+
+## D2 — Arbitrary Tailwind Value vs Token
+
+| Line | Value | Verdict | Reason | Suggested change |
+| ----------------------------------------------- | ------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
+| Work Item thread files | `text-[11px]`, `text-[13px]`, `text-[14px]` | keep with reason | These values match the established Work Item property, timeline, and editor density. They are typography roles, not arbitrary spatial geometry or raw colors. | — |
+| `KanbanHeaderTrailingControls/index.tsx:95,106` | `text-[12px]` | keep with reason | Existing compact Select triggers require the 12px toolbar density; the new refresh control uses shared button sizing and adds no arbitrary typography. | — |
+
+## D3 — Hardcoded Sizes / Colors
+
+| Line | Value | Verdict | Reason | Suggested change |
+| ---------------------------------- | --------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
+| `WorkItemContent/index.tsx` | 920px thread maximum width | keep with reason | A bounded reading measure prevents long Markdown/activity lines on wide desktop windows while still fitting the property-pill row. It uses the existing detail-panel width family. | — |
+| `ProjectContentEditor` thread mode | 120px / 360px editor limits | keep with reason | Interaction bounds apply only after explicit Edit: enough space for useful authoring without recreating the oversized always-on editor. | — |
+| changed files | colors | keep with reason | All added colors use semantic danger/background/text/border tokens. No raw hex/rgb/hsl colors were added. | — |
+
+## D4 — Accessibility
+
+| Line | Element | Verdict | Reason | Suggested change |
+| ---------------------------------------- | ------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| `KanbanHeaderTrailingControls/index.tsx` | icon-only refresh | fix | New action needs an accessible name and visible hover explanation. | Added translated `aria-label` plus shared toolbar tooltip; spinner icon is `aria-hidden`. |
+| `AssignedWorkItemDetail.tsx` | `Start Agent` action | fix | The idle workflow copy must have an operable, keyboard-accessible primary action rather than a visually implied action. | Reused the existing named primary Button and its disabled/loading policy. |
+| `TaskKanban/index.tsx` | refresh failure banner | fix | Async failure must be announced without removing the board. | Added a tokenized `role="status"` banner while retaining existing cards. |
+| Session run cards | open/focus Session action | keep with reason | Existing Session-card buttons provide visible labels (`View live chat` / `View conversation`) and use the canonical navigation callback. | — |
+| `WorkItemContent/index.tsx` | Edit/Save/Cancel description controls | fix | Editing needs an explicit entry state and disabled Save before changes. | Added named Edit action, disabled-until-dirty Save, Cancel restoration, and compact editor bounds. |
+| `ThreadTodoChecklist.tsx` | icon-only delete/cancel actions | fix | Icon-only actions require accessible names and visible keyboard focus behavior. | Added translated `aria-label`; delete is revealed on hover or `focus-visible`. |
+
+## D5 — Visual Patterns Observed
+
+- Work Item persistence, properties, workflow/session cards, and activity reuse their existing owning components. The only Inbox-specific component is the deliberately different demand-commit To-Do interaction.
+- Refresh visuals reuse the established tertiary button + `useRefreshSpin` pattern already used by Work Item workflow and shared panel headers.
+- The Team Inbox-only thread mode remains an explicit presentation policy on the shared Work Item component, not a second persistence/detail hierarchy.
+- The restored Start Agent affordance reuses the shared Agent Workflow control; Inbox contributes only navigation intent and no duplicate button styling.
+- Description edit actions extend the shared `TimelineCard` action slot, so other timeline surfaces can adopt the same header composition without copying markup.
+- The repeated shell is abstracted inside the Work Item domain rather than promoted to a global design-system Card: its 920px reading measure, metadata pills, and thread density are product-specific.
+- Static To-Do sections use `WorkItemThreadSection`; the stateful Workflow continues to use shared `CollapsibleSection` with `WORK_ITEM_THREAD_TOKENS`, avoiding a second collapse implementation.
+
+## Summary
+
+- 13 fixes completed
+- 7 kept with documented reason
+- 1 abstract candidate completed
diff --git a/docs/org2-performance-guard-2026-07-27/TeamInboxCollaboration.md b/docs/org2-performance-guard-2026-07-27/TeamInboxCollaboration.md
new file mode 100644
index 0000000000..0e9114da9c
--- /dev/null
+++ b/docs/org2-performance-guard-2026-07-27/TeamInboxCollaboration.md
@@ -0,0 +1,25 @@
+# ORG2 Performance Guard — Team Inbox Multi-User Collaboration
+
+**Date:** 2026-07-27
+
+| Lifecycle / area | Active | Idle / hidden | Repeated open/close | Verdict and verification |
+| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
+| Roster loading | One demand read through the shared endpoint/account/org/revision coordinator. | No polling, timer, subscription, or worker added. | Cached coordinator result is reused; late identity responses are discarded and effects cancel commits after unmount. | pass — code trace, ESLint, typecheck |
+| Inbox listing | Keyset pages are bounded to 100 rows; full unread total is computed server-side. | No background pagination or retained scan. | Cache replaces the first page and appends later pages without duplicating authoritative counts. | pass — client unit tests and SQL review |
+| Read mutations | User-triggered, idempotent upsert/delete; optimistic UI is generation guarded and durable writes are serialized through a queue capped at 100 pending actions. | No retry loop beyond the existing bounded transport retry. | Account/org generation rejects late completions; server totals repair count state; receipts remain one row per viewer/comment. | pass — focused client tests, typecheck, lifecycle trace |
+| Mark all | One explicit bulk upsert for currently eligible mentions. | Does not run automatically. | Primary key prevents growth from repeated invocation; returned count is recomputed to include concurrent mentions. | pass — SQL lifecycle review |
+| Comment mentions | At most 50 deduplicated member UUIDs per new comment. | No retained composer data after successful submit. | Failed submit preserves only local draft state; no duplicate background writer. | pass — client unit tests and SQL validation |
+| Database access | GIN lookup uses `mentioned_user_ids @> array[viewer]`; row ordering uses `(org_id, created_at desc, id desc)`. | No database work without a request. | Receipt lookup is primary-key/index backed; per-page thread counts are bounded by the page limit. | pass with live-query-plan follow-up after deployment |
+| Identity switch | The active endpoint/account/org is part of the request key and every async path captures a load generation. | Previous rows/counts are evicted in a layout effect before paint. | Late initial-page, pagination, and receipt completions are discarded after a scope switch. | pass — code trace, typecheck |
+
+## Rejection-rule review
+
+- No polling, timer, streaming loop, worker, unbounded cache, or cross-instance singleton was added.
+- No scan is initiated while the Inbox is hidden.
+- Viewer identity and org scope are part of every cache/request boundary, and prior-scope rows are cleared before paint.
+- The only potentially growing state is durable receipt data, bounded to one row per mentioned viewer/comment and cascade-deleted with its org/comment.
+
+## Verdict
+
+- Performance verdict: pass.
+- Deployment follow-up: capture `EXPLAIN (ANALYZE, BUFFERS)` for mention list/count on production-like cardinality after applying migration 0010.
diff --git a/docs/org2-performance-guard-2026-07-27/TeamInboxKanban.md b/docs/org2-performance-guard-2026-07-27/TeamInboxKanban.md
new file mode 100644
index 0000000000..257dd3e46c
--- /dev/null
+++ b/docs/org2-performance-guard-2026-07-27/TeamInboxKanban.md
@@ -0,0 +1,12 @@
+# ORG2 Performance Guard — Team Inbox Thread and Kanban Refresh
+
+**Date:** 2026-07-27
+
+| Area | Verdict | Evidence | Change or reason kept | Verification |
+| ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
+| Background work | keep | No timer, polling loop, or retained subscription was added. The To-Do composer uses one `requestAnimationFrame` only after a successful commit to restore focus. | Full Work Item load is selection-driven; Kanban refresh is user-driven. Team Inbox publishes a one-shot start intent but deliberately does not mount `useWorkItemOrchestrator`, avoiding duplicate collaboration-lock, auto-review, and stale-session lifecycles. | Call-chain/lifecycle trace; focused tests |
+| Memory | keep | The new action atom retains at most one small request and is cleared synchronously when its matching Work Item claims it. | It is transient process state, excluded from persisted Chat Panel tabs, and cannot grow with repeated actions. A newer unclaimed intent supersedes the older one instead of leaving a delayed action behind. | Exact-once, supersession, mismatch-isolation, and remount tests |
+| Scope/isolation | keep | Cloud refresh stays keyed by authenticated identity + org; local refresh uses the process-wide roster coordinator | Manual action composes existing source owners and does not introduce a cross-identity cache | Existing remote-session identity/generation guards; typecheck |
+| Rendering/hot path | keep | Existing cards remain mounted during refresh; Work Item thread loads full detail only after selection; description editor and To-Do composer mount only after explicit intent | The Start Agent handoff adds one atom read on the canonical Work Item panel and performs no work while no matching request exists. No eager scan, clone, or duplicate orchestrator was added. | Focused Work Item/Inbox/Kanban/action tests; ESLint; typecheck |
+
+- Performance verdict: pass.
From 345943b6119f22a9af7496998de6c28a7819b6a1 Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Mon, 27 Jul 2026 23:48:10 +0800
Subject: [PATCH 05/11] docs(audit): record merged validation count
---
docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md
index 0d9af282b0..99aed8d570 100644
--- a/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md
+++ b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md
@@ -7,7 +7,7 @@
- TypeScript `tsc --noEmit`: passed.
- Focused ESLint over all changed collaboration/UI files: passed.
-- Twenty-four focused Vitest files: 230 tests passed after the capability-gate regression cases were added.
+- Twenty-four focused Vitest files: 229 tests passed after rebasing onto the latest `develop` and adding capability-gate regression coverage.
- Cloud migration was statically reviewed; live apply and live two-account E2E remain deployment validation.
## Layer 2 — Dead code and structural deduplication
From 0133aef5c5d7fa5f2539f8ebb106a96bc52bc279 Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Tue, 28 Jul 2026 12:55:14 +0800
Subject: [PATCH 06/11] feat(team-inbox): unify collaborative work item threads
Pre-commit hook ran. Total eslint: 2, total circular: 0
---
.../src/projects/io/work_items/atomic.rs | 73 +-
.../projects/io/work_items/atomic_tests.rs | 77 +-
.../src/projects/io/work_items/history.rs | 24 +-
.../src/projects/types/work_items.rs | 11 +
src/api/http/project/types/workItems.ts | 7 +
src/components/ComposerShell/index.tsx | 7 +-
.../ChatPanel/panels/ProjectPanelView.tsx | 15 +-
.../ChatPanel/panels/WorkItemPanelView.tsx | 187 ++--
.../Org2Cloud/org2CloudCapabilities.ts | 8 +-
src/features/Org2Cloud/org2CloudClient.ts | 15 +-
.../Org2Cloud/teamInboxMentionsClient.test.ts | 19 +
.../Org2Cloud/teamInboxMentionsClient.ts | 109 ++-
.../project/useCurrentUserMemberId.test.ts | 162 ++++
src/hooks/project/useCurrentUserMemberId.ts | 165 +++-
src/i18n/locales/en/common.json | 16 +-
src/i18n/locales/zh/common.json | 15 +-
.../TeamInbox/ConnectedTeamInboxView.tsx | 10 +-
src/modules/MainApp/TeamInbox/TEST_CASES.md | 44 +-
.../MainApp/TeamInbox/TeamInboxView.tsx | 303 +++---
.../__tests__/AssignedWorkItemDetail.test.ts | 56 +-
.../MainApp/TeamInbox/__tests__/TEST_CASES.md | 38 +-
.../TeamInbox/__tests__/TeamInboxList.test.ts | 44 +
.../TeamInbox/__tests__/TeamInboxRow.test.ts | 112 +++
.../__tests__/TeamInboxView.layout.test.ts | 150 ++-
.../__tests__/teamInboxCoordinator.test.ts | 385 ++++++++
.../__tests__/useTeamInboxWorkItem.test.ts | 206 ++++
src/modules/MainApp/TeamInbox/api.ts | 2 +-
.../components/AssignedWorkItemDetail.tsx | 94 +-
.../components/CommentMentionDetail.tsx | 17 +-
.../TeamInbox/components/TeamInboxList.tsx | 74 +-
.../TeamInbox/components/TeamInboxRow.tsx | 57 +-
src/modules/MainApp/TeamInbox/domain/index.ts | 2 +
src/modules/MainApp/TeamInbox/domain/types.ts | 22 +
src/modules/MainApp/TeamInbox/store.ts | 5 +-
.../MainApp/TeamInbox/teamInboxCoordinator.ts | 884 ++++++++++++++++++
.../TeamInbox/useTeamInboxDataSource.ts | 739 ++++-----------
.../TeamInbox/useTeamInboxNavigation.ts | 33 +-
.../MainApp/TeamInbox/useTeamInboxWorkItem.ts | 142 ++-
...ProjectWorkItemsTabContentInteractions.tsx | 15 +-
.../__tests__/workItemPartialUpdate.test.ts | 26 +-
.../components/WorkItemContent/HistoryTab.tsx | 130 ++-
.../__tests__/HistoryTab.test.ts | 197 ++++
.../WorkItemContent/__tests__/TEST_CASES.md | 29 +
.../WorkItemDescriptionEditing.test.ts | 68 ++
.../__tests__/useWorkItemTimeline.test.ts | 18 +-
.../WorkItemContent/descriptionMarkdown.ts | 23 +
.../hooks/useWorkItemContentState.tsx | 34 +-
.../components/WorkItemContent/index.tsx | 11 +-
.../components/WorkItemContent/types.ts | 2 +
.../WorkItemContent/useWorkItemTimeline.ts | 18 +-
.../components/WorkItemProperties/index.tsx | 15 +
.../__tests__/WorkItemThreadSurface.test.ts | 95 ++
.../WorkItemThreadSurface/index.tsx | 57 ++
.../WorkItems/components/index.ts | 6 +-
.../WorkItems/hooks/useWorkItemsData.ts | 97 +-
.../WorkItems/workItemPartialUpdate.ts | 31 +-
.../components/ProjectContentEditor/index.tsx | 3 +
.../layouts/blocks/CollapsibleSection.tsx | 1 +
58 files changed, 3836 insertions(+), 1369 deletions(-)
create mode 100644 src/hooks/project/useCurrentUserMemberId.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/teamInboxCoordinator.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/HistoryTab.test.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/descriptionMarkdown.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/__tests__/WorkItemThreadSurface.test.ts
create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/index.tsx
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 19065e9708..52d1110a0c 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
@@ -78,7 +78,13 @@ where
F: FnOnce(&mut WorkItemFrontmatter, &mut String) -> Result,
{
let (value, changed_fields, payload_tail_changed) =
- update_work_item_atomic_with_revisions(project_slug, short_id, HashMap::new(), mutator)?;
+ update_work_item_atomic_with_revisions(
+ project_slug,
+ short_id,
+ HashMap::new(),
+ None,
+ mutator,
+ )?;
if !changed_fields.is_empty() {
// Re-read the work item to build the outbox payload. The read
// is one extra round trip but keeps the closure-form API
@@ -135,6 +141,7 @@ pub fn update_work_item_atomic_with_revisions(
project_slug: &str,
short_id: &str,
override_revisions: HashMap,
+ actor: Option<&crate::projects::types::WorkItemMutationActor>,
mutator: F,
) -> Result<(T, Vec<&'static str>, bool), String>
where
@@ -244,6 +251,12 @@ where
let result = mutator(&mut frontmatter, &mut body)?;
let changed_fields = before.diff(&frontmatter, &body);
+ let assignment_changed =
+ core.assignee != frontmatter.assignee || core.assignee_type != frontmatter.assignee_type;
+ let assigned_human_id = human_assignee_id(
+ frontmatter.assignee.as_deref(),
+ frontmatter.assignee_type.as_deref(),
+ );
let payload_tail_changed = payload_tail_fingerprint(&frontmatter) != tail_before;
let scheduler_changed = scheduler_before
!= (
@@ -317,16 +330,17 @@ where
priority = ?4,
assignee = ?5,
assignee_type = ?6,
- milestone = ?7,
- parent = ?8,
- start_date = ?9,
- target_date = ?10,
- org_id = ?11,
- project_id = ?12,
- created_at = ?13,
- updated_at = ?14,
- local_version = ?15
- WHERE id = ?16",
+ assigned_human_id = ?7,
+ milestone = ?8,
+ parent = ?9,
+ start_date = ?10,
+ target_date = ?11,
+ org_id = ?12,
+ project_id = ?13,
+ created_at = ?14,
+ updated_at = ?15,
+ local_version = ?16
+ WHERE id = ?17",
params![
frontmatter.title,
body,
@@ -334,6 +348,7 @@ where
frontmatter.priority,
frontmatter.assignee,
frontmatter.assignee_type,
+ assigned_human_id,
frontmatter.milestone,
frontmatter.parent,
frontmatter.start_date,
@@ -347,6 +362,17 @@ where
],
))?;
+ if assignment_changed {
+ // A receipt acknowledges one assignment episode, not the Work Item for
+ // all time. Clear every viewer's old episode in the same transaction as
+ // the assignee write so reassignment can never commit half-way.
+ map_db(tx.execute(
+ "DELETE FROM team_inbox_read_receipts
+ WHERE source_kind = 'work_item_assigned' AND source_id = ?1",
+ params![&core.work_item_id],
+ ))?;
+ }
+
// Replace label set.
map_db(tx.execute(
"DELETE FROM workitem_labels WHERE work_item_id = ?1",
@@ -372,7 +398,13 @@ where
// revision, regardless of whether the value diffed. This is
// what lets the merge cycle pin watermarks for fields where the
// resolver-adopted value happens to equal the pre-mutator value.
- append_mutation_event(&history_before, &mut frontmatter, &body, &to_iso8601(now));
+ append_mutation_event(
+ &history_before,
+ &mut frontmatter,
+ &body,
+ &to_iso8601(now),
+ actor,
+ );
let mut next_extras = ExtrasPayload::from_frontmatter(&frontmatter);
next_extras.field_revisions = extras.field_revisions.clone();
@@ -551,6 +583,7 @@ pub fn update_work_item_partial_with_revisions(
project_slug,
short_id,
override_revisions,
+ updates.actor.as_ref(),
|fm, body| {
let now_iso = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
@@ -719,6 +752,22 @@ fn slices_equal_unordered(left: &[String], right: &[String]) -> bool {
left_sorted == right_sorted
}
+fn human_assignee_id(
+ assignee: Option<&str>,
+ assignee_type: Option<&str>,
+) -> Option {
+ let assignee = assignee?.trim();
+ if assignee.is_empty() {
+ return None;
+ }
+ let is_human = assignee_type
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(|value| value.eq_ignore_ascii_case("member") || value.eq_ignore_ascii_case("human"))
+ .unwrap_or(true);
+ is_human.then(|| assignee.to_string())
+}
+
struct AtomicCore {
work_item_id: String,
short_id: String,
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 23fec34867..51d8d55409 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
@@ -5,7 +5,7 @@ use crate::projects::io::projects::write_project;
use crate::projects::io::work_items::{read_standalone_work_item, read_work_item, write_work_item};
use crate::projects::types::{
CommentEntry, ProjectMeta, TodoEntry, WorkItemHistoryAction, WorkItemPartialUpdate,
- WorkItemSchedule,
+ WorkItemMutationActor, WorkItemSchedule,
};
use test_helpers::test_env;
@@ -129,6 +129,8 @@ fn partial_update_records_property_and_body_history() {
.changes
.iter()
.any(|change| change.field == "priority"));
+ assert_eq!(event.actor_id, None);
+ assert_eq!(event.actor_name, None);
}
#[test]
@@ -139,10 +141,14 @@ fn partial_update_records_comment_history_event() {
let updates = WorkItemPartialUpdate {
comments: Some(vec![CommentEntry {
id: "c1".to_string(),
- author: "Ada".to_string(),
+ author: "member-1".to_string(),
content: "Looks good".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
}]),
+ actor: Some(WorkItemMutationActor {
+ id: "member-1".to_string(),
+ name: "Ada".to_string(),
+ }),
..Default::default()
};
update_work_item_partial("demo", "AAA-0001", &updates).expect("update");
@@ -150,6 +156,8 @@ fn partial_update_records_comment_history_event() {
let after = read_work_item("demo", "AAA-0001").expect("read");
let event = after.frontmatter.history.last().expect("history event");
assert_eq!(event.action, WorkItemHistoryAction::Commented);
+ assert_eq!(event.actor_id.as_deref(), Some("member-1"));
+ assert_eq!(event.actor_name.as_deref(), Some("Ada"));
assert_eq!(event.changes.len(), 1);
assert_eq!(event.changes[0].field, "comments");
}
@@ -312,6 +320,71 @@ fn partial_clears_assignee_with_some_none() {
assert!(result.frontmatter.assignee.is_none(), "assignee cleared");
}
+#[test]
+fn assignee_change_atomically_resets_team_inbox_receipts() {
+ let _sandbox = test_env::sandbox();
+ seed("demo", "p1");
+
+ let mut assign_alice = WorkItemPartialUpdate::default();
+ assign_alice.assignee = Some(Some("member-alice".to_string()));
+ assign_alice.assignee_type = Some(Some("member".to_string()));
+ update_work_item_partial("demo", "AAA-0001", &assign_alice).expect("assign alice");
+
+ let connection = conn().expect("conn");
+ connection
+ .execute(
+ "INSERT INTO team_inbox_read_receipts
+ (viewer_member_id, source_kind, source_id, read_at)
+ VALUES (?1, 'work_item_assigned', 'w1', 42)",
+ ["member-alice"],
+ )
+ .expect("seed read receipt");
+ drop(connection);
+
+ let mut assign_bob = WorkItemPartialUpdate::default();
+ assign_bob.assignee = Some(Some("member-bob".to_string()));
+ update_work_item_partial("demo", "AAA-0001", &assign_bob).expect("assign bob");
+
+ let connection = conn().expect("conn");
+ let (assigned_human_id, receipt_count): (Option, i64) = connection
+ .query_row(
+ "SELECT w.assigned_human_id,
+ (SELECT COUNT(*) FROM team_inbox_read_receipts r
+ WHERE r.source_kind = 'work_item_assigned'
+ AND r.source_id = w.id)
+ FROM workitems w WHERE w.id = 'w1'",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .expect("assignment projection");
+ assert_eq!(assigned_human_id.as_deref(), Some("member-bob"));
+ assert_eq!(
+ receipt_count, 0,
+ "the old assignment episode must not stay read"
+ );
+}
+
+#[test]
+fn non_human_assignee_is_excluded_from_assigned_human_projection() {
+ let _sandbox = test_env::sandbox();
+ seed("demo", "p1");
+
+ let mut assign_agent = WorkItemPartialUpdate::default();
+ assign_agent.assignee = Some(Some("agent-1".to_string()));
+ assign_agent.assignee_type = Some(Some("agent".to_string()));
+ update_work_item_partial("demo", "AAA-0001", &assign_agent).expect("assign agent");
+
+ let connection = conn().expect("conn");
+ let assigned_human_id: Option = connection
+ .query_row(
+ "SELECT assigned_human_id FROM workitems WHERE id = 'w1'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("assigned_human_id");
+ assert_eq!(assigned_human_id, None);
+}
+
#[test]
fn partial_nullable_fields_json_null_deserializes_as_explicit_clear() {
let updates: WorkItemPartialUpdate = serde_json::from_value(serde_json::json!({
diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/history.rs b/src-tauri/crates/project-management/src/projects/io/work_items/history.rs
index 8f79925441..650499156a 100644
--- a/src-tauri/crates/project-management/src/projects/io/work_items/history.rs
+++ b/src-tauri/crates/project-management/src/projects/io/work_items/history.rs
@@ -3,7 +3,7 @@ use serde_json::Value as JsonValue;
use crate::projects::types::{
CommentEntry, WorkItemFrontmatter, WorkItemHistoryAction, WorkItemHistoryChange,
- WorkItemHistoryEvent,
+ WorkItemHistoryEvent, WorkItemMutationActor,
};
pub(super) fn ensure_created_event(frontmatter: &mut WorkItemFrontmatter, timestamp: &str) {
@@ -34,8 +34,8 @@ pub(super) fn append_deleted_event(frontmatter: &mut WorkItemFrontmatter, timest
id: history_event_id(frontmatter, WorkItemHistoryAction::Deleted, timestamp),
action: WorkItemHistoryAction::Deleted,
timestamp: timestamp.to_string(),
- actor_id: frontmatter.created_by.clone(),
- actor_name: frontmatter.created_by.clone(),
+ actor_id: None,
+ actor_name: None,
changes: Vec::new(),
summary: Some("Deleted item".to_string()),
});
@@ -46,8 +46,8 @@ pub(super) fn append_restored_event(frontmatter: &mut WorkItemFrontmatter, times
id: history_event_id(frontmatter, WorkItemHistoryAction::Restored, timestamp),
action: WorkItemHistoryAction::Restored,
timestamp: timestamp.to_string(),
- actor_id: frontmatter.created_by.clone(),
- actor_name: frontmatter.created_by.clone(),
+ actor_id: None,
+ actor_name: None,
changes: Vec::new(),
summary: Some("Restored item".to_string()),
});
@@ -58,6 +58,7 @@ pub(super) fn append_mutation_event(
frontmatter: &mut WorkItemFrontmatter,
body: &str,
timestamp: &str,
+ actor: Option<&WorkItemMutationActor>,
) {
let mut changes = before.diff(frontmatter, body);
if changes.is_empty() {
@@ -91,12 +92,21 @@ pub(super) fn append_mutation_event(
| WorkItemHistoryAction::Restored => None,
};
+ let actor_id = actor
+ .map(|value| value.id.trim())
+ .filter(|value| !value.is_empty())
+ .map(str::to_string);
+ let actor_name = actor
+ .map(|value| value.name.trim())
+ .filter(|value| !value.is_empty())
+ .map(str::to_string);
+
frontmatter.history.push(WorkItemHistoryEvent {
id: history_event_id(frontmatter, action.clone(), timestamp),
action,
timestamp: timestamp.to_string(),
- actor_id: frontmatter.created_by.clone(),
- actor_name: frontmatter.created_by.clone(),
+ actor_id,
+ actor_name,
changes,
summary,
});
diff --git a/src-tauri/crates/project-management/src/projects/types/work_items.rs b/src-tauri/crates/project-management/src/projects/types/work_items.rs
index edff838789..bad4105444 100644
--- a/src-tauri/crates/project-management/src/projects/types/work_items.rs
+++ b/src-tauri/crates/project-management/src/projects/types/work_items.rs
@@ -383,6 +383,13 @@ where
/// All fields are optional — only provided fields will be updated.
/// This enables atomic read-modify-write in Rust, eliminating
/// multiple IPC calls and JS-side type conversions.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WorkItemMutationActor {
+ pub id: String,
+ pub name: String,
+}
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkItemPartialUpdate {
@@ -469,6 +476,10 @@ pub struct WorkItemPartialUpdate {
pub close_out: Option>,
#[serde(skip_serializing_if = "Option::is_none")]
pub work_products: Option>,
+ /// Request metadata used to attribute the generated history event.
+ /// This is never copied into Work Item frontmatter as mutable item data.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub actor: Option,
}
// ============================================
diff --git a/src/api/http/project/types/workItems.ts b/src/api/http/project/types/workItems.ts
index 34cd8b987a..4000794494 100644
--- a/src/api/http/project/types/workItems.ts
+++ b/src/api/http/project/types/workItems.ts
@@ -223,6 +223,13 @@ export interface WorkItemPartialUpdate {
executionLock?: WorkItemExecutionLock | null;
closeOut?: WorkItemCloseOut | null;
workProducts?: WorkItemWorkProduct[];
+ /** Identity of the user initiating this mutation; used only for history attribution. */
+ actor?: WorkItemMutationActor;
+}
+
+export interface WorkItemMutationActor {
+ id: string;
+ name: string;
}
export interface ResolvedPerson {
diff --git a/src/components/ComposerShell/index.tsx b/src/components/ComposerShell/index.tsx
index 5b7a954961..6d63274e73 100644
--- a/src/components/ComposerShell/index.tsx
+++ b/src/components/ComposerShell/index.tsx
@@ -11,6 +11,7 @@
* • "default" — session creator, standalone (bg-chat-input)
* • "embedded" — chat panel embedded in conversation (bg-chat-input)
* • "pill" — compact single-row capsule (rounded-full, same padding)
+ * • "comment" — compact avatar-adjacent comment input
* • "edit" — queued-message edit box (label strip + inner editor card)
* • "historyEdit" — sent-message edit box (single layer, same token as normal input)
*/
@@ -22,6 +23,7 @@ export type ComposerShellVariant =
| "default"
| "embedded"
| "pill"
+ | "comment"
| "edit"
| "historyEdit";
@@ -45,6 +47,7 @@ const VARIANT_CLASSES: Record = {
default: `${INPUT_AREA.borderRadiusClass} px-1.5 pt-2.5 pb-1.5 gap-2`,
embedded: `${INPUT_AREA.borderRadiusClass} px-1.5 pt-2.5 pb-1.5 gap-2`,
pill: "rounded-full p-1.5 gap-2",
+ comment: `${INPUT_AREA.borderRadiusClass} px-1.5 py-1.5 gap-1.5`,
// `edit` is the OUTER label strip — the inner editor card is rendered as
// a separately-styled child (see `InputArea`). The strip itself is just
// a padded container that hosts the label row + the inner card.
@@ -56,6 +59,7 @@ const VARIANT_BG_CLASS: Record = {
default: INPUT_AREA.backgroundDefaultClass,
embedded: INPUT_AREA.backgroundChatPanelClass,
pill: INPUT_AREA.backgroundChatPanelClass,
+ comment: INPUT_AREA.backgroundChatPanelClass,
// The outer edit strip sits on `bg-fill-2` so it reads as a distinct card
// that wraps the header row + inner editor card (`bg-fill-1`).
edit: "bg-fill-2",
@@ -69,6 +73,7 @@ const VARIANT_INTERACTION_CLASSES: Record = {
default: INPUT_AREA.shellInteractionClasses,
embedded: INPUT_AREA.shellInteractionClasses,
pill: INPUT_AREA.shellInteractionClasses,
+ comment: INPUT_AREA.shellInteractionClasses,
edit: INPUT_AREA.borderClass,
historyEdit: INPUT_AREA.shellEditInteractionClasses,
};
@@ -92,7 +97,7 @@ const ComposerShell = forwardRef(
return (
= ({
}
return [...people.values()];
}, [workItems]);
- const { memberIds: currentUserMemberIds } =
+ const { currentUser, memberIds: currentUserMemberIds } =
useCurrentUserMemberIds(workItemPeople);
const pinnedKanbanColumnIds = useMemo(
() => [...currentUserMemberIds].map((memberId) => `person:${memberId}`),
@@ -473,15 +474,7 @@ export const ProjectPanelView: React.FC
= ({
const shortId = getWorkItemShortId(workItemId);
if (!shortId) return;
- const payload = {} as Parameters<
- typeof projectApi.updateWorkItemPartial
- >[2];
- if (updates.name !== undefined) payload.title = updates.name;
- if (updates.spec !== undefined) payload.body = updates.spec;
- if (updates.workItemStatus !== undefined) {
- payload.status = updates.workItemStatus;
- }
- if (updates.priority !== undefined) payload.priority = updates.priority;
+ const payload = toWorkItemPartialUpdate(updates, currentUser);
if (Object.keys(payload).length === 0) return;
const updated = await projectApi.updateWorkItemPartial(
@@ -496,7 +489,7 @@ export const ProjectPanelView: React.FC = ({
)
);
},
- [getWorkItemShortId, projectSlug]
+ [currentUser, getWorkItemShortId, projectSlug, setWorkItems]
);
const handleAddKanbanTask = useCallback(
diff --git a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
index 894a1594bc..ed9612a3da 100644
--- a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
+++ b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
@@ -1,6 +1,6 @@
import { emit } from "@tauri-apps/api/event";
import { useAtomValue, useSetAtom } from "jotai";
-import { ExternalLink, Info, ListChecks, Trash2, X } from "lucide-react";
+import { ExternalLink, ListChecks, Trash2, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -18,17 +18,12 @@ import { HEADER_ICON_SIZE } from "@src/config/workstation/tokens";
import { usePublishChatPanelHeader } from "@src/engines/ChatPanel/header";
import { createLogger } from "@src/hooks/logger";
import { useProjectDataChanged } from "@src/hooks/project";
-import { useResizeHandle } from "@src/hooks/ui/useResizeHandle";
-import {
- WorkItemContent,
- WorkItemProperties,
-} from "@src/modules/ProjectManager/WorkItems/components";
+import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId";
+import { WorkItemThreadSurface } from "@src/modules/ProjectManager/WorkItems/components";
import { WorkItemDetailHeaderBreadcrumb } from "@src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailHeader";
import { useWorkItemOrchestrator } from "@src/modules/ProjectManager/WorkItems/hooks";
import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate";
-import { PropertiesRailFrame } from "@src/modules/ProjectManager/shared";
import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared";
-import { VerticalResizeHandle } from "@src/scaffold/Resize";
import { closeWorkItemChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom";
import { activeSessionIdAtom } from "@src/store/session";
import {
@@ -44,9 +39,6 @@ import { usePendingWorkItemAction } from "./usePendingWorkItemAction";
const logger = createLogger("WorkItemPanelView");
const saveNoPendingWorkItemChanges = async (): Promise => undefined;
-const WORK_ITEM_INFO_PANEL_DEFAULT_WIDTH = 240;
-const WORK_ITEM_INFO_PANEL_MIN_WIDTH = 200;
-const WORK_ITEM_INFO_PANEL_MAX_WIDTH = 280;
interface WorkItemPanelViewProps {
selectedWorkItem: ChatPanelSelectedWorkItem;
@@ -118,21 +110,23 @@ export const WorkItemPanelView: React.FC = ({
const [floatingSessionId, setFloatingSessionId] = useState(
null
);
- const [propertiesOpen, setPropertiesOpen] = useState(true);
- const [infoPanelWidth, setInfoPanelWidth] = useState(
- WORK_ITEM_INFO_PANEL_DEFAULT_WIDTH
- );
const [projectSyncAdapter, setProjectSyncAdapter] = useState<{
projectSlug: string;
adapterId: string | null;
} | null>(null);
- const { handleMouseDown: handleInfoPanelResize, isResizing } =
- useResizeHandle(infoPanelWidth, setInfoPanelWidth, {
- direction: "horizontal",
- minSize: WORK_ITEM_INFO_PANEL_MIN_WIDTH,
- maxSize: WORK_ITEM_INFO_PANEL_MAX_WIDTH,
- isReversed: true,
- });
+ const workItemMembers = useMemo(
+ () => [
+ ...(selectedWorkItem.sourceProject?.project.members ?? []),
+ ...(selectedWorkItem.workItem.assignee
+ ? [selectedWorkItem.workItem.assignee]
+ : []),
+ ],
+ [
+ selectedWorkItem.sourceProject?.project.members,
+ selectedWorkItem.workItem.assignee,
+ ]
+ );
+ const { currentUser } = useCurrentUserMemberIds(workItemMembers);
useEffect(() => {
const projectSlug = selectedWorkItem.projectSlug;
@@ -168,7 +162,7 @@ export const WorkItemPanelView: React.FC = ({
}
try {
- const payload = toWorkItemPartialUpdate(updates);
+ const payload = toWorkItemPartialUpdate(updates, currentUser);
if (Object.keys(payload).length === 0) return;
if (selectedWorkItem.projectSlug) {
@@ -210,7 +204,7 @@ export const WorkItemPanelView: React.FC = ({
logger.error("Failed to update chat panel work item", error);
}
},
- [onUpdateWorkItem, selectedWorkItem, setSelectedWorkItem]
+ [currentUser, onUpdateWorkItem, selectedWorkItem, setSelectedWorkItem]
);
const refreshSelectedWorkItem = useCallback(async () => {
@@ -394,11 +388,11 @@ export const WorkItemPanelView: React.FC = ({
}, [closeWorkItemTab, selectedWorkItem, t]);
const headerActions = useMemo(
- () => (
-
- {selectedWorkItem.projectSlug &&
- projectSyncAdapterId !== undefined &&
- !isGitHubSyncedProject ? (
+ () =>
+ selectedWorkItem.projectSlug &&
+ projectSyncAdapterId !== undefined &&
+ !isGitHubSyncedProject ? (
+
@@ -413,40 +407,12 @@ export const WorkItemPanelView: React.FC = ({
icon={ }
/>
- ) : null}
-
- setPropertiesOpen((current) => !current)}
- aria-label={
- propertiesOpen
- ? t("projects:workItems.hideProperties")
- : t("projects:workItems.showProperties")
- }
- aria-pressed={propertiesOpen}
- data-testid="chat-panel-work-item-properties-toggle"
- icon={ }
- />
-
-
- ),
+
+ ) : null,
[
handleDeleteWorkItem,
isGitHubSyncedProject,
projectSyncAdapterId,
- propertiesOpen,
selectedWorkItem.projectSlug,
t,
]
@@ -496,75 +462,50 @@ export const WorkItemPanelView: React.FC = ({
content: { content: headerContent, trailing: headerActions },
});
- const propertiesContent = (
-
- );
-
return (
-
-
-
-
- {propertiesOpen ? (
- <>
-
-
- {propertiesContent}
-
- >
- ) : null}
+
+
{floatingSessionId && (
{
- const payload = await getCloudCapabilitiesRaw(accessToken);
+ const payload = await runCloudRequestWithTimeout(
+ (signal) => getCloudCapabilitiesRaw(accessToken, signal),
+ CLOUD_CAPABILITIES_TIMEOUT_MS
+ );
const parsed = CloudCapabilitiesWireSchema.safeParse(payload);
if (payload === null || !parsed.success) {
// 404 (pre-0005) and transient failures are indistinguishable here, so
diff --git a/src/features/Org2Cloud/org2CloudClient.ts b/src/features/Org2Cloud/org2CloudClient.ts
index eda9218949..8940b02cfa 100644
--- a/src/features/Org2Cloud/org2CloudClient.ts
+++ b/src/features/Org2Cloud/org2CloudClient.ts
@@ -98,7 +98,8 @@ async function callRpc(
functionName: string,
accessToken?: string,
body?: Record
,
- endpoint: CloudRpcEndpoint = getCloudEndpoint()
+ endpoint: CloudRpcEndpoint = getCloudEndpoint(),
+ signal?: AbortSignal
): Promise {
try {
const response = await fetchWithTransportRetry(
@@ -107,6 +108,7 @@ async function callRpc(
method: "POST",
headers: rpcHeaders(accessToken, endpoint),
body: JSON.stringify(body ?? {}),
+ signal,
}
);
if (!response.ok) {
@@ -132,9 +134,16 @@ export async function schemaVersion(): Promise {
* transport failure. Interpretation/caching live in `org2CloudCapabilities`.
*/
export async function getCloudCapabilitiesRaw(
- accessToken: string
+ accessToken: string,
+ signal?: AbortSignal
): Promise {
- return callRpc("get_cloud_capabilities", accessToken);
+ return callRpc(
+ "get_cloud_capabilities",
+ accessToken,
+ undefined,
+ getCloudEndpoint(),
+ signal
+ );
}
/**
diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
index 680cdc480f..e60b640608 100644
--- a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
+++ b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
@@ -238,6 +238,25 @@ describe("listTeamInboxMentions", () => {
expect(error).toMatchObject({ code: "ORG2_MEMBER_REQUIRED", status: 403 });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
+
+ it("cancels an in-flight RPC when its owning Inbox scope is disposed", async () => {
+ fetchMock.mockImplementationOnce(
+ () => new Promise(() => undefined)
+ );
+ const controller = new AbortController();
+
+ const request = listTeamInboxMentions(
+ "jwt-viewer",
+ "org-1",
+ null,
+ 25,
+ controller.signal
+ );
+ controller.abort();
+
+ await expect(request).rejects.toMatchObject({ name: "AbortError" });
+ expect((lastCall().init.signal as AbortSignal).aborted).toBe(true);
+ });
});
describe("Team Inbox read receipts", () => {
diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.ts b/src/features/Org2Cloud/teamInboxMentionsClient.ts
index 9b4af2ee56..6a87fa44ce 100644
--- a/src/features/Org2Cloud/teamInboxMentionsClient.ts
+++ b/src/features/Org2Cloud/teamInboxMentionsClient.ts
@@ -3,12 +3,16 @@ import { z } from "zod/v4";
import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config";
import { getCloudCapabilities } from "./org2CloudCapabilities";
import { Org2CloudCommentError } from "./org2CloudCommentsClient";
-import { fetchWithTransportRetry } from "./org2CloudFetchRetry";
+import {
+ fetchWithTransportRetry,
+ runCloudRequestWithTimeout,
+} from "./org2CloudFetchRetry";
const TEAM_INBOX_MENTIONS_RPC = "cloud_list_team_inbox_mentions";
const SET_TEAM_INBOX_MENTION_READ_RPC = "cloud_set_team_inbox_mention_read";
const MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC =
"cloud_mark_all_team_inbox_mentions_read";
+const TEAM_INBOX_REQUEST_TIMEOUT_MS = 15_000;
const TeamInboxMentionRequestSchema = z.object({
orgId: z.string().min(1),
@@ -74,39 +78,47 @@ export interface TeamInboxReadMutation {
async function callTeamInboxRpc(
functionName: string,
accessToken: string,
- body: Record
+ body: Record,
+ sourceSignal?: AbortSignal
): Promise {
const endpoint = getCloudEndpoint();
- const response = await fetchWithTransportRetry(
- `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`,
- {
- method: "POST",
- headers: {
- apikey: endpoint.anonKey,
- authorization: `Bearer ${accessToken}`,
- "content-type": "application/json",
- "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA,
- },
- body: JSON.stringify(body),
- }
+ return runCloudRequestWithTimeout(
+ async (signal) => {
+ const response = await fetchWithTransportRetry(
+ `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`,
+ {
+ method: "POST",
+ headers: {
+ apikey: endpoint.anonKey,
+ authorization: `Bearer ${accessToken}`,
+ "content-type": "application/json",
+ "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA,
+ },
+ body: JSON.stringify(body),
+ signal,
+ }
+ );
+
+ const text = await response.text();
+ let payload: unknown = null;
+ try {
+ payload = text ? JSON.parse(text) : null;
+ } catch {
+ payload = null;
+ }
+
+ if (!response.ok) {
+ const message =
+ payload && typeof payload === "object" && "message" in payload
+ ? String((payload as { message: unknown }).message)
+ : `org2_cloud rpc ${functionName} failed with ${response.status}`;
+ throw new Org2CloudCommentError(message, response.status);
+ }
+ return payload;
+ },
+ TEAM_INBOX_REQUEST_TIMEOUT_MS,
+ sourceSignal
);
-
- const text = await response.text();
- let payload: unknown = null;
- try {
- payload = text ? JSON.parse(text) : null;
- } catch {
- payload = null;
- }
-
- if (!response.ok) {
- const message =
- payload && typeof payload === "object" && "message" in payload
- ? String((payload as { message: unknown }).message)
- : `org2_cloud rpc ${functionName} failed with ${response.status}`;
- throw new Org2CloudCommentError(message, response.status);
- }
- return payload;
}
/**
@@ -120,14 +132,20 @@ export async function listTeamInboxMentions(
accessToken: string,
orgId: string,
cursor: string | null,
- limit: number
+ limit: number,
+ signal?: AbortSignal
): Promise {
const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit });
- const payload = await callTeamInboxRpc(TEAM_INBOX_MENTIONS_RPC, accessToken, {
- p_org_id: input.orgId,
- p_cursor: input.cursor,
- p_limit: input.limit,
- });
+ const payload = await callTeamInboxRpc(
+ TEAM_INBOX_MENTIONS_RPC,
+ accessToken,
+ {
+ p_org_id: input.orgId,
+ p_cursor: input.cursor,
+ p_limit: input.limit,
+ },
+ signal
+ );
return TeamInboxMentionsPageSchema.parse(payload);
}
@@ -139,13 +157,14 @@ export async function listTeamInboxMentions(
export async function listInitialTeamInboxMentions(
accessToken: string,
orgId: string,
- limit = 50
+ limit = 50,
+ signal?: AbortSignal
): Promise {
const capabilities = await getCloudCapabilities(accessToken);
if (!capabilities.teamInboxMentions) {
return EMPTY_TEAM_INBOX_MENTIONS_PAGE;
}
- return listTeamInboxMentions(accessToken, orgId, null, limit);
+ return listTeamInboxMentions(accessToken, orgId, null, limit, signal);
}
/** Persists one viewer-scoped mention receipt. The viewer comes from JWT. */
@@ -153,7 +172,8 @@ export async function setTeamInboxMentionRead(
accessToken: string,
orgId: string,
commentId: string,
- read: boolean
+ read: boolean,
+ signal?: AbortSignal
): Promise {
const payload = await callTeamInboxRpc(
SET_TEAM_INBOX_MENTION_READ_RPC,
@@ -162,7 +182,8 @@ export async function setTeamInboxMentionRead(
p_org_id: z.string().min(1).parse(orgId),
p_comment_id: z.string().min(1).parse(commentId),
p_read: read,
- }
+ },
+ signal
);
return TeamInboxReadMutationSchema.parse(payload);
}
@@ -170,12 +191,14 @@ export async function setTeamInboxMentionRead(
/** Marks every currently visible mention read, including unloaded pages. */
export async function markAllTeamInboxMentionsRead(
accessToken: string,
- orgId: string
+ orgId: string,
+ signal?: AbortSignal
): Promise {
const payload = await callTeamInboxRpc(
MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC,
accessToken,
- { p_org_id: z.string().min(1).parse(orgId) }
+ { p_org_id: z.string().min(1).parse(orgId) },
+ signal
);
return TeamInboxReadMutationSchema.parse(payload);
}
diff --git a/src/hooks/project/useCurrentUserMemberId.test.ts b/src/hooks/project/useCurrentUserMemberId.test.ts
new file mode 100644
index 0000000000..c51ecfc5d5
--- /dev/null
+++ b/src/hooks/project/useCurrentUserMemberId.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from "vitest";
+
+import type { IUserInfo } from "@src/types/core/user";
+
+import {
+ findMemberIdsByUser,
+ resolveCurrentUserIdentity,
+} from "./useCurrentUserMemberId";
+
+function user(overrides: Partial = {}): IUserInfo {
+ return {
+ uuid: "",
+ name: "",
+ authing_id: "",
+ profile: "",
+ picture: "",
+ profile_image_url: "",
+ openai_api_key: "",
+ deepseek_api_key: "",
+ git_user_name: "",
+ git_user_email: "",
+ github_infos: [],
+ gitlab_infos: [],
+ ...overrides,
+ };
+}
+
+describe("current Work Item identity", () => {
+ it("uses the project member identity for a consistent name and avatar", () => {
+ const members = [
+ {
+ id: "user-ea821852",
+ name: "hanafish",
+ email: "hanafish@example.com",
+ avatar: "https://example.com/hanafish.png",
+ color: "#1677ff",
+ },
+ ];
+ const account = user({
+ uuid: "user-ea821852",
+ name: "Account fallback",
+ git_user_email: "hanafish@example.com",
+ });
+ const memberIds = findMemberIdsByUser(members, account);
+
+ expect(
+ resolveCurrentUserIdentity(members, memberIds, account, null)
+ ).toEqual({
+ id: "user-ea821852",
+ name: "hanafish",
+ email: "hanafish@example.com",
+ avatar: "https://example.com/hanafish.png",
+ color: "#1677ff",
+ });
+ });
+
+ it("falls back to the signed-in profile instead of the generic You label", () => {
+ const account = user({
+ uuid: "user-ea821852",
+ name: "hanafish",
+ profile_image_url: "https://example.com/hanafish.png",
+ });
+
+ expect(
+ resolveCurrentUserIdentity([], new Set(), account, null)
+ ).toMatchObject({
+ id: "user-ea821852",
+ name: "hanafish",
+ avatar: "https://example.com/hanafish.png",
+ });
+ });
+
+ it("enriches an opaque member record with the signed-in profile", () => {
+ const account = user({
+ uuid: "user-ea821852",
+ name: "Yuki",
+ profile_image_url: "https://example.com/yuki.png",
+ });
+
+ expect(
+ resolveCurrentUserIdentity(
+ [{ id: "user-ea821852", name: "user-ea821852" }],
+ new Set(),
+ account,
+ null
+ )
+ ).toMatchObject({
+ id: "user-ea821852",
+ name: "Yuki",
+ avatar: "https://example.com/yuki.png",
+ });
+ });
+
+ it("returns no actor when neither account nor git identity is trustworthy", () => {
+ expect(resolveCurrentUserIdentity([], new Set(), user(), null)).toBeNull();
+ });
+
+ it("does not merge different people who share an email local-part", () => {
+ const members = [
+ {
+ id: "member-company-alice",
+ name: "Alice Company",
+ email: "alice@company.example",
+ },
+ {
+ id: "member-personal-alice",
+ name: "Alice Personal",
+ email: "alice@personal.example",
+ },
+ ];
+
+ expect(
+ findMemberIdsByUser(
+ members,
+ user({ git_user_email: "alice@company.example" })
+ )
+ ).toEqual(new Set(["member-company-alice"]));
+ });
+
+ it("does not infer a member id from a non-unique display name", () => {
+ const members = [
+ {
+ id: "member-1",
+ name: "Alex",
+ email: "alex-one@example.com",
+ },
+ {
+ id: "member-2",
+ name: "Alex",
+ email: "alex-two@example.com",
+ },
+ ];
+
+ expect(findMemberIdsByUser(members, user({ name: "Alex" }))).toEqual(
+ new Set()
+ );
+ });
+
+ it("matches exact account ids and verified linked emails", () => {
+ const members = [
+ {
+ id: "account-1",
+ name: "Account member",
+ },
+ {
+ id: "member-linked",
+ name: "Linked member",
+ linked_emails: [{ email: "linked@example.com" }],
+ },
+ ];
+
+ expect(
+ findMemberIdsByUser(
+ members,
+ user({
+ uuid: "account-1",
+ git_user_email: "linked@example.com",
+ })
+ )
+ ).toEqual(new Set(["account-1", "member-linked"]));
+ });
+});
diff --git a/src/hooks/project/useCurrentUserMemberId.ts b/src/hooks/project/useCurrentUserMemberId.ts
index e4cfe9c9b5..f898e2a008 100644
--- a/src/hooks/project/useCurrentUserMemberId.ts
+++ b/src/hooks/project/useCurrentUserMemberId.ts
@@ -3,15 +3,16 @@
*
* Resolves the current user's project member ID(s) by matching against
* all known user identities:
- * - Local git config user.email (from Tauri command — most reliable)
- * - Local git config user.name (fallback)
+ * - Stable account/member IDs
+ * - Local git config user.email (from Tauri command)
* - userAtom.git_user_email (if populated)
- * - github_infos / gitlab_infos usernames (matched against email prefix)
+ * - Exact GitHub/GitLab usernames when a member carries that provider field
*
* A single person often has multiple member entries (from git shortlog)
* because they commit with different emails. This hook returns ALL
- * matching member IDs so assignment notifications work regardless of
- * which member entry was used.
+ * exact matching member IDs so assignment notifications work regardless of
+ * which verified member entry was used. Display names and email local-parts
+ * are deliberately excluded because they are not unique identities.
*/
import { invoke } from "@tauri-apps/api/core";
import { useAtomValue } from "jotai";
@@ -19,13 +20,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { MemberEntry } from "@src/api/http/project";
import { userAtom } from "@src/store/user/userAtom";
+import type { Person } from "@src/types/core/shared";
import type { IUserInfo } from "@src/types/core/user";
// ============================================
// Git identity from Tauri
// ============================================
-interface GitUserIdentity {
+export interface GitUserIdentity {
email: string | null;
name: string | null;
/** GitHub username from gh CLI config (~/.config/gh/hosts.yml) */
@@ -72,7 +74,80 @@ export function resetGitIdentityCache() {
interface UserIdentities {
emails: string[];
- userName: string;
+ accountIds: string[];
+ usernames: string[];
+}
+
+export type MemberIdentity = Pick<
+ MemberEntry,
+ "id" | "name" | "email" | "avatar" | "github_username" | "linked_emails"
+> & {
+ color?: string;
+};
+
+export function resolveCurrentUserIdentity(
+ members: readonly MemberIdentity[],
+ memberIds: ReadonlySet,
+ user: IUserInfo,
+ gitIdentity: GitUserIdentity | null
+): Person | null {
+ const accountIds = new Set(
+ [user.uuid, user.authing_id].map((value) => value.trim()).filter(Boolean)
+ );
+ const currentMember = members.find(
+ (member) => memberIds.has(member.id) || accountIds.has(member.id)
+ );
+ if (currentMember) {
+ const memberName = currentMember.name.trim();
+ const accountName = (
+ user.name ||
+ gitIdentity?.name ||
+ user.git_user_name ||
+ ""
+ ).trim();
+ const memberNameIsOpaque =
+ !memberName ||
+ memberName === currentMember.id ||
+ /^user-[a-z0-9]+$/i.test(memberName);
+
+ return {
+ id: currentMember.id,
+ name:
+ memberNameIsOpaque && accountName
+ ? accountName
+ : memberName || accountName,
+ email: currentMember.email,
+ avatar:
+ currentMember.avatar ||
+ user.profile_image_url ||
+ user.picture ||
+ undefined,
+ color: currentMember.color,
+ };
+ }
+
+ const name = (
+ user.name ||
+ gitIdentity?.name ||
+ user.git_user_name ||
+ ""
+ ).trim();
+ const id = (
+ user.uuid ||
+ user.authing_id ||
+ gitIdentity?.email ||
+ user.git_user_email ||
+ name
+ ).trim();
+ if (!id || !name) return null;
+
+ return {
+ id,
+ name,
+ email: gitIdentity?.email || user.git_user_email || undefined,
+ avatar: user.profile_image_url || user.picture || undefined,
+ color: "#52c41a",
+ };
}
/**
@@ -83,42 +158,46 @@ function collectIdentities(
gitIdentity: GitUserIdentity | null
): UserIdentities {
const emailSet = new Set();
+ const usernameSet = new Set();
+ const accountIdSet = new Set();
+
+ for (const accountId of [user.uuid, user.authing_id]) {
+ const normalized = accountId.trim();
+ if (normalized) accountIdSet.add(normalized);
+ }
- // 1. GitHub username from gh CLI (most reliable for matching)
+ // GitHub username from gh CLI.
if (gitIdentity?.github_username) {
- emailSet.add(gitIdentity.github_username.toLowerCase().trim());
+ usernameSet.add(gitIdentity.github_username.toLowerCase().trim());
}
- // 2. Local git config email (matches git shortlog entries)
+ // Exact email identities.
if (gitIdentity?.email) {
emailSet.add(gitIdentity.email.toLowerCase().trim());
}
- // 3. userAtom git_user_email (if populated by backend)
if (user.git_user_email) {
emailSet.add(user.git_user_email.toLowerCase().trim());
}
- // 4. GitHub usernames from linked accounts
+ // Exact provider usernames.
for (const gh of user.github_infos ?? []) {
if (gh.user_name) {
- emailSet.add(gh.user_name.toLowerCase().trim());
+ usernameSet.add(gh.user_name.toLowerCase().trim());
}
}
- // 5. GitLab usernames
for (const gl of user.gitlab_infos ?? []) {
if (gl.user_name) {
- emailSet.add(gl.user_name.toLowerCase().trim());
+ usernameSet.add(gl.user_name.toLowerCase().trim());
}
}
- // Best user name: prefer git config, then userAtom
- const userName = (gitIdentity?.name || user.git_user_name || "")
- .toLowerCase()
- .trim();
-
- return { emails: [...emailSet], userName };
+ return {
+ emails: [...emailSet],
+ accountIds: [...accountIdSet],
+ usernames: [...usernameSet],
+ };
}
// ============================================
@@ -129,30 +208,22 @@ function collectIdentities(
* Check if a member entry matches any of the user's known identities.
*/
function memberMatchesUser(
- member: MemberEntry,
+ member: MemberIdentity,
identities: UserIdentities
): boolean {
const memberEmail = (member.email || "").toLowerCase().trim();
- const memberName = (member.name || "").toLowerCase().trim();
-
- for (const email of identities.emails) {
- // Direct email match
- if (memberEmail === email) return true;
+ const memberUsername = (member.github_username || "").toLowerCase().trim();
- // Email prefix match (e.g. github username "alice" matches "alice@example.com")
- if (memberEmail && memberEmail.split("@")[0] === email) return true;
-
- // Reverse: member email prefix matches user email
- if (
- email.includes("@") &&
- email.split("@")[0] === memberEmail.split("@")[0]
- ) {
- return true;
- }
+ if (identities.accountIds.includes(member.id)) return true;
+ if (memberEmail && identities.emails.includes(memberEmail)) return true;
+ if (memberUsername && identities.usernames.includes(memberUsername)) {
+ return true;
}
- // Name-based fallback
- if (identities.userName && memberName === identities.userName) return true;
+ for (const linked of member.linked_emails ?? []) {
+ const email = linked.email.toLowerCase().trim();
+ if (email && identities.emails.includes(email)) return true;
+ }
return false;
}
@@ -161,9 +232,9 @@ function memberMatchesUser(
* Find a member entry by exact email match.
*/
export function findMemberByEmail(
- members: MemberEntry[],
+ members: readonly MemberIdentity[],
email: string
-): MemberEntry | undefined {
+): MemberIdentity | undefined {
const normalized = email.toLowerCase().trim();
return members.find(
(member) => (member.email || "").toLowerCase().trim() === normalized
@@ -182,7 +253,7 @@ export function findMemberByEmail(
* For the async version that fetches git config, use the hook.
*/
export function findMemberIdsByUser(
- members: MemberEntry[],
+ members: readonly MemberIdentity[],
user: IUserInfo,
gitIdentity?: GitUserIdentity | null
): Set {
@@ -207,6 +278,8 @@ interface UseCurrentUserMemberIdsReturn {
memberIds: Set;
/** Current user's git email (primary) */
gitEmail: string;
+ /** Display identity used by Work Item comments and mutation history. */
+ currentUser: Person | null;
}
/**
@@ -214,7 +287,7 @@ interface UseCurrentUserMemberIdsReturn {
* Fetches git identity from local config on mount.
*/
export function useCurrentUserMemberIds(
- members: MemberEntry[]
+ members: readonly MemberIdentity[]
): UseCurrentUserMemberIdsReturn {
const user = useAtomValue(userAtom);
const [gitIdentity, setGitIdentity] = useState(
@@ -244,6 +317,10 @@ export function useCurrentUserMemberIds(
);
const gitEmail = gitIdentity?.email || user.git_user_email || "";
+ const currentUser = useMemo(
+ () => resolveCurrentUserIdentity(members, memberIds, user, gitIdentity),
+ [gitIdentity, memberIds, members, user]
+ );
- return { memberIds, gitEmail };
+ return { memberIds, gitEmail, currentUser };
}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 9f7edd9199..648866578c 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -2466,7 +2466,8 @@
"unread": "Unread"
},
"row": {
- "assignedSummary": "{{status}} · {{priority}}"
+ "assignedSummary": "{{status}} · {{priority}}",
+ "ariaLabel": "{{title}}, {{status}}"
},
"search": {
"placeholder": "Search inbox",
@@ -2500,15 +2501,24 @@
"errors": {
"loadTitle": "Unable to load Team Inbox",
"load": "Unable to load Team Inbox",
+ "loadMore": "Unable to load more Team Inbox items. Try again.",
"refresh": "Unable to refresh Team Inbox",
"markRead": "Unable to mark this item as read. Try again.",
"markUnread": "Unable to mark this item as unread. Try again.",
- "markAllRead": "Unable to mark all items as read. Try again."
+ "markAllRead": "Unable to mark all items as read. Try again.",
+ "identity": "Your account could not be matched to a project member. Check your project profile email.",
+ "partialLoad": "Some Team Inbox sources could not be refreshed. Available items are still shown.",
+ "workItemContext": "Some project context is unavailable. The work item remains usable.",
+ "workItemLoad": "Unable to load this work item. Try again.",
+ "workItemUpdate": "Unable to save the latest work item change. Try again."
},
"detail": {
"assignedSubtitle": "Assigned work item",
+ "standaloneProject": "Standalone",
"mentionSubtitle": "Mentioned in a comment",
- "mentionedYou": "mentioned you"
+ "mentionedYou": "mentioned you",
+ "threadComments_one": "{{count}} comment in this thread",
+ "threadComments_other": "{{count}} comments in this thread"
},
"actions": {
"markRead": "Mark as read",
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
index a3b70973f6..cc89bf0e53 100644
--- a/src/i18n/locales/zh/common.json
+++ b/src/i18n/locales/zh/common.json
@@ -2346,7 +2346,8 @@
"unread": "未读"
},
"row": {
- "assignedSummary": "{{status}} · {{priority}}"
+ "assignedSummary": "{{status}} · {{priority}}",
+ "ariaLabel": "{{title}},{{status}}"
},
"search": {
"placeholder": "搜索收件箱",
@@ -2380,15 +2381,23 @@
"errors": {
"loadTitle": "无法加载团队收件箱",
"load": "无法加载团队收件箱",
+ "loadMore": "加载更多团队收件箱事项失败,请重试。",
"refresh": "无法刷新团队收件箱",
"markRead": "标记已读失败,请重试。",
"markUnread": "标记未读失败,请重试。",
- "markAllRead": "全部标记已读失败,请重试。"
+ "markAllRead": "全部标记已读失败,请重试。",
+ "identity": "当前账户无法匹配到项目成员,请检查项目个人资料中的邮箱。",
+ "partialLoad": "部分团队收件箱来源刷新失败,当前可用事项仍会保留显示。",
+ "workItemContext": "部分项目上下文暂不可用,工作项仍可继续查看和操作。",
+ "workItemLoad": "无法加载此工作项,请重试。",
+ "workItemUpdate": "无法保存刚才的工作项修改,请重试。"
},
"detail": {
"assignedSubtitle": "分配给你的工作项",
+ "standaloneProject": "独立工作项",
"mentionSubtitle": "评论中提及了你",
- "mentionedYou": "提及了你"
+ "mentionedYou": "提及了你",
+ "threadComments": "该话题中有 {{count}} 条评论"
},
"actions": {
"markRead": "标记已读",
diff --git a/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
index e92b62f971..f32d84922c 100644
--- a/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
+++ b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
@@ -5,9 +5,15 @@ import { useTeamInboxDataSource } from "./useTeamInboxDataSource";
import { useTeamInboxNavigation } from "./useTeamInboxNavigation";
const ConnectedTeamInboxView: React.FC = () => {
- const { dataSource } = useTeamInboxDataSource();
+ const { dataSource, viewerMemberIds } = useTeamInboxDataSource();
const navigate = useTeamInboxNavigation();
- return ;
+ return (
+
+ );
};
export default ConnectedTeamInboxView;
diff --git a/src/modules/MainApp/TeamInbox/TEST_CASES.md b/src/modules/MainApp/TeamInbox/TEST_CASES.md
index 421a7f689b..8ea5018b64 100644
--- a/src/modules/MainApp/TeamInbox/TEST_CASES.md
+++ b/src/modules/MainApp/TeamInbox/TEST_CASES.md
@@ -19,20 +19,43 @@
- Assigned items carry a trimmed, whitespace-folded, 240-char body excerpt as `summary`; blank bodies omit the field (`work_item_summary_excerpt`).
- `mark_unread` deletes the viewer-scoped local or cloud receipt so the item returns to unread and remains idempotent; cloud receipts are not owned by localStorage.
- `toWireCursorItemId` preserves the backend `work_item_assigned:` source prefix (strips only the UI `assigned_work_item:` kind prefix) so `Load more` cursor pagination round-trips instead of erroring.
+- Sidebar and full Inbox consumers in the same Jotai store share one scope-keyed coordinator, including initial request identity, local/cloud cursors, mutation ordering, cancellation, and the bounded 500-row snapshot.
+- Local and cloud reads settle independently: one successful source remains visible with a localized partial-success notice, and a failed pagination cursor remains retryable.
+- Switching account, organization, or resolved viewer identity synchronously evicts the old snapshot, aborts cloud work, and prevents late responses from committing into the new scope.
+- Exact account IDs, verified full email addresses, linked emails, and provider usernames may resolve a viewer; matching display names or equal email local-parts across domains never does.
+- Reassigning a Work Item changes `assigned_human_id` and deletes the prior assignment episode's read receipt in the same SQLite transaction; agent assignments never enter the human-assignment projection.
+- Failed read/unread persistence rolls back the coordinator-owned optimistic snapshot, while a newer per-item mutation supersedes an older response.
## Presentation / polish
1. Filter tabs (`All` / `Mentions` / `Assigned`) show a primary count badge only when that surface has unread items; badge clamps to `99+`.
2. Unread rows render a leading primary dot and bold title; read rows drop the dot and use medium weight.
-3. Assigned rows show the resolved assignee **name** (not the raw member id) and a `status · priority` summary using localized labels.
-4. Assigned detail shows localized `Status` and `Priority` rows and no misleading `Assigned by` row when no assigner is known.
-5. `Mark all as read` in the header marks **only the active filter's** unread items (Mentions view never marks Assigned, and vice versa).
-6. Empty state copy is filter-specific (`No mentions` vs `Nothing assigned to you`), falling back to the generic empty copy for `All`.
-7. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list.
-8. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries.
-9. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes.
-10. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the managed-cloud receipt). Re-marking read still works after refresh or on another device.
-11. When a source still has a next page, the list shows a `Load more` control; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more.
+3. Assigned rows show one title line, at most two plain-text excerpt lines, and a localized `status · priority` metadata line; Markdown syntax, escaped newlines, and redundant assignee names do not leak into the card.
+4. Successful edits in the selected Work Item immediately update the matching list row's title, summary, status, priority, and assignee; reassigning away from the viewer removes the stale assigned row.
+5. The list excerpt and detail Markdown body use the same `text-text-1` content token; hierarchy comes from size and weight rather than mismatched foreground colors.
+6. Assigned detail shows localized `Status` and `Priority` rows and no misleading `Assigned by` row when no assigner is known.
+7. `Mark all as read` in the header marks **only the active filter's** unread items (Mentions view never marks Assigned, and vice versa).
+8. Empty state copy is filter-specific (`No mentions` vs `Nothing assigned to you`), falling back to the generic empty copy for `All`.
+9. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list.
+10. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries.
+11. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes.
+12. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the managed-cloud receipt). Re-marking read still works after refresh or on another device.
+13. When a source still has a next page, the list shows a `Load more` control—even when the active filter/search has no visible first-page result; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more.
+14. Activating Retry after an initial load error calls the backing source's refresh boundary before reading a new snapshot; it never loops on the same failed cache entry.
+15. Partial-source degradation uses a warning treatment and preserves readable results; a total failure uses the blocking error state.
+
+## Coordinator state machine
+
+| State | Entry | Visible behavior | Allowed transition | Ownership / persistence |
+| -------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------- |
+| Unavailable identity | Member files loaded but no exact viewer identity matches | Cloud results may remain visible; local assignment availability is explicitly degraded | Refresh after profile/account correction | Identity is derived; no guessed member id is persisted |
+| Loading | New viewer/account/org scope or explicit refresh | Old scope is synchronously removed; the new scope shows loading | Success, partial success, empty, error, scope switch | Coordinator owns request generation and AbortController |
+| Ready | Every requested source succeeds | Shared list, counts, cursors, filters and detail are usable | Load more, mutation, refresh, scope switch | Jotai cache is the canonical runtime snapshot |
+| Empty | Successful sources return no rows | Filter-specific empty state; Load more stays available when a cursor exists | Load more or refresh | Empty is a successful snapshot, not an error |
+| Partial success | At least one source/prerequisite succeeds and one degrades | Successful rows stay actionable under a localized warning | Retry, pagination of remaining cursors, scope switch | Successful source data replaces only that source's projection |
+| Error / timeout | Every requested source fails or prerequisite loading fails | Blocking error only when no usable rows remain; retained rows otherwise stay visible | Retry invokes the real refresh boundary | Diagnostic details remain internal; UI maps issue codes to localized copy |
+| Mutating | Read/unread operation enters the shared mutation queue | Snapshot updates optimistically once | Commit authoritative receipt, rollback, or supersede | Durable receipt is SQLite/cloud; optimistic state is coordinator-owned |
+| Superseded | Scope generation changes or a newer same-item mutation starts | Late completion is ignored; cloud work is aborted best-effort | New scope/request continues | No stale completion may write the current snapshot |
## Unified Work Item thread
@@ -43,7 +66,7 @@
| 3 | Activate `View live chat` / `View conversation` on a Session card. | A separate Session Chat Panel tab opens or the existing tab for that Session is focused. Team Inbox remains open as its singleton tab. |
| 4 | Inspect a Work Item with proof of work and comments/history. | Output and activity render inline after the workflow; no second nested detail surface is introduced. |
| 5 | Switch assigned rows while the first full Work Item is still loading. | A late response from the first row never replaces the newly selected Work Item. |
-| 6 | Make two property changes in quick succession. | Only the newest response may replace the displayed Work Item snapshot; both writes use the canonical partial-update payload. |
+| 6 | Make two property changes in quick succession. | Same-item writes run in invocation order through a bounded queue, so the final response contains both atomic partial updates and an older response cannot overwrite newer intent. |
| 7 | Open a standalone assigned Work Item. | The thread remains readable, but edit controls/property rail are not exposed because standalone persistence requires the owning frontmatter round-trip. |
| 8 | Fail the selected Work Item read. | A visible error placeholder is shown; the short list row remains available for retry/navigation. |
| 9 | Open a project Work Item with a short description. | The description renders at its natural Markdown height. `Preview / Raw` and the editor are absent until `Edit` is activated. |
@@ -57,6 +80,7 @@
| 17 | Compare the To-Do and Agent Workflow cards, then collapse Workflow. | Both cards share one Work Item thread visual shell; Workflow retains its existing collapse behavior and To-Do remains independently interactive. |
| 18 | Open Assignee or Reviewer in a project-scoped Inbox Work Item. | The picker contains the complete active project roster, resolves stored member ids to names, and persists through the canonical partial-update boundary. |
| 19 | Inspect creator, comments, and history written with stored member ids. | Known ids resolve to project-member names; unknown ids remain visible instead of being guessed or silently blanked. |
+| 20 | Load a Work Item while its project or member context read fails. | The successfully loaded Work Item remains usable under a localized warning; only failure of the required Work Item read replaces it with an error state. |
### Unified thread acceptance criteria
diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
index effb3d294e..24bef4996e 100644
--- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
+++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
@@ -1,8 +1,15 @@
-import React, { useEffect, useMemo, useRef, useState } from "react";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { useTranslation } from "react-i18next";
import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout";
import { Placeholder } from "@src/modules/shared/layouts/blocks";
+import type { WorkItem } from "@src/types/core/workItem";
import {
AssignedWorkItemDetail,
@@ -12,11 +19,11 @@ import {
import {
type TeamInboxDataSource,
type TeamInboxFilter,
+ type TeamInboxIssue,
type TeamInboxItem,
type TeamInboxNavigationIntent,
type TeamInboxUnreadCounts,
countUnreadTeamInboxItemsByFilter,
- filterItemKind,
getTeamInboxItemKey,
searchTeamInboxItems,
selectTeamInboxItems,
@@ -28,6 +35,7 @@ export interface TeamInboxViewProps {
onNavigate?: (intent: TeamInboxNavigationIntent) => void;
initialFilter?: TeamInboxFilter;
pageSize?: number;
+ viewerMemberIds?: readonly string[];
}
const EMPTY_TEAM_INBOX_DATA_SOURCE: TeamInboxDataSource = {
@@ -37,7 +45,7 @@ const EMPTY_TEAM_INBOX_DATA_SOURCE: TeamInboxDataSource = {
};
interface LoadState {
- status: "loading" | "ready" | "error";
+ status: "loading" | "ready" | "warning" | "error";
message: string | null;
}
@@ -46,6 +54,7 @@ const TeamInboxView: React.FC = ({
onNavigate,
initialFilter = "all",
pageSize = 50,
+ viewerMemberIds = [],
}) => {
const { t } = useTranslation();
const [filter, setFilter] = useState(initialFilter);
@@ -62,8 +71,27 @@ const TeamInboxView: React.FC = ({
const [reloadRevision, setReloadRevision] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
- const mutationEpochRef = useRef(0);
- const mutationByItemRef = useRef(new Map());
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const issueMessage = useCallback(
+ (issue: TeamInboxIssue): string => {
+ if (issue.code === "identity_unresolved") {
+ return t("teamInbox.errors.identity");
+ }
+ if (issue.code === "partial_load") {
+ return t("teamInbox.errors.partialLoad");
+ }
+ return t("teamInbox.errors.load");
+ },
+ [t]
+ );
useEffect(() => {
const abortController = new AbortController();
@@ -76,7 +104,17 @@ const TeamInboxView: React.FC = ({
setAuthoritativeUnreadCounts(page.unreadCounts ?? null);
setRecencyAnchorMs(Date.now());
setHasMore(page.nextCursor != null);
- setLoadState({ status: "ready", message: null });
+ setLoadState(
+ page.loading
+ ? { status: "loading", message: null }
+ : page.issue
+ ? {
+ status:
+ page.issue.code === "partial_load" ? "warning" : "error",
+ message: issueMessage(page.issue),
+ }
+ : { status: "ready", message: null }
+ );
})
.catch((reason: unknown) => {
if (abortController.signal.aborted) return;
@@ -84,13 +122,18 @@ const TeamInboxView: React.FC = ({
status: "error",
message:
reason instanceof Error
- ? reason.message
+ ? "issue" in reason &&
+ reason.issue &&
+ typeof reason.issue === "object" &&
+ "code" in reason.issue
+ ? issueMessage(reason.issue as TeamInboxIssue)
+ : reason.message
: t("teamInbox.errors.load"),
});
});
return () => abortController.abort();
- }, [dataSource, pageSize, reloadRevision, t]);
+ }, [dataSource, issueMessage, pageSize, reloadRevision, t]);
useEffect(() => {
if (!dataSource.subscribe) return;
@@ -121,26 +164,25 @@ const TeamInboxView: React.FC = ({
? getTeamInboxItemKey(selectedItem)
: null;
- const retry = () => {
- setLoadState({ status: "loading", message: null });
- setReloadRevision((value) => value + 1);
- };
-
const handleLoadMore = () => {
if (!dataSource.loadMore || loadingMore) return;
setLoadingMore(true);
void dataSource
.loadMore()
- .catch((reason: unknown) => {
+ .then(() => {
+ if (mountedRef.current) {
+ setReloadRevision((value) => value + 1);
+ }
+ })
+ .catch(() => {
setLoadState({
status: "error",
- message:
- reason instanceof Error
- ? reason.message
- : t("teamInbox.errors.load"),
+ message: t("teamInbox.errors.loadMore"),
});
})
- .finally(() => setLoadingMore(false));
+ .finally(() => {
+ if (mountedRef.current) setLoadingMore(false);
+ });
};
const handleRefresh = () => {
@@ -149,70 +191,25 @@ const TeamInboxView: React.FC = ({
setReloadRevision((value) => value + 1);
return;
}
- void dataSource.refresh().catch((reason: unknown) => {
- setLoadState({
- status: "error",
- message:
- reason instanceof Error
- ? reason.message
- : t("teamInbox.errors.refresh"),
+ void dataSource
+ .refresh()
+ .then(() => {
+ if (mountedRef.current) {
+ setReloadRevision((value) => value + 1);
+ }
+ })
+ .catch(() => {
+ setLoadState({
+ status: "error",
+ message: t("teamInbox.errors.refresh"),
+ });
});
- });
- };
-
- const beginItemMutations = (itemIds: readonly string[]): number => {
- const epoch = ++mutationEpochRef.current;
- for (const itemId of itemIds) mutationByItemRef.current.set(itemId, epoch);
- return epoch;
- };
-
- const isCurrentItemMutation = (itemId: string, epoch: number): boolean =>
- mutationByItemRef.current.get(itemId) === epoch;
-
- const updateUnreadCount = (kind: TeamInboxItem["kind"], delta: number) => {
- setAuthoritativeUnreadCounts((current) => {
- if (!current) return null;
- const key =
- kind === "comment_mention"
- ? ("mentions" as const)
- : ("assigned" as const);
- const nextForKind = Math.max(0, current[key] + delta);
- return {
- ...current,
- [key]: nextForKind,
- all: Math.max(0, current.all + delta),
- };
- });
- };
-
- const markLocallyRead = (item: TeamInboxItem) => {
- const readAt = new Date().toISOString();
- setItems((current) =>
- current.map((candidate) =>
- getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item)
- ? { ...candidate, readAt }
- : candidate
- )
- );
};
const handleSelect = (item: TeamInboxItem) => {
setRequestedItemId(getTeamInboxItemKey(item));
if (item.readAt !== null) return;
- const epoch = beginItemMutations([item.id]);
- markLocallyRead(item);
- updateUnreadCount(item.kind, -1);
void dataSource.markRead?.(item).catch(() => {
- if (isCurrentItemMutation(item.id, epoch)) {
- setItems((current) =>
- current.map((candidate) =>
- candidate.id === item.id
- ? { ...candidate, readAt: null }
- : candidate
- )
- );
- updateUnreadCount(item.kind, 1);
- }
setLoadState({
status: "error",
message: t("teamInbox.errors.markRead"),
@@ -222,20 +219,7 @@ const TeamInboxView: React.FC = ({
const handleMarkRead = (item: TeamInboxItem) => {
if (item.readAt !== null) return;
- const epoch = beginItemMutations([item.id]);
- markLocallyRead(item);
- updateUnreadCount(item.kind, -1);
void dataSource.markRead?.(item).catch(() => {
- if (isCurrentItemMutation(item.id, epoch)) {
- setItems((current) =>
- current.map((candidate) =>
- candidate.id === item.id
- ? { ...candidate, readAt: null }
- : candidate
- )
- );
- updateUnreadCount(item.kind, 1);
- }
setLoadState({
status: "error",
message: t("teamInbox.errors.markRead"),
@@ -245,27 +229,7 @@ const TeamInboxView: React.FC = ({
const handleMarkUnread = (item: TeamInboxItem) => {
if (item.readAt === null) return;
- const previousReadAt = item.readAt;
- const epoch = beginItemMutations([item.id]);
- setItems((current) =>
- current.map((candidate) =>
- getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item)
- ? { ...candidate, readAt: null }
- : candidate
- )
- );
- updateUnreadCount(item.kind, 1);
void dataSource.markUnread?.(item).catch(() => {
- if (isCurrentItemMutation(item.id, epoch)) {
- setItems((current) =>
- current.map((candidate) =>
- candidate.id === item.id
- ? { ...candidate, readAt: previousReadAt }
- : candidate
- )
- );
- updateUnreadCount(item.kind, -1);
- }
setLoadState({
status: "error",
message: t("teamInbox.errors.markUnread"),
@@ -274,12 +238,6 @@ const TeamInboxView: React.FC = ({
};
const handleMarkAllRead = () => {
- const targetKind = filterItemKind(filter);
- const unreadItems = items.filter(
- (item) =>
- item.readAt === null &&
- (targetKind === null || item.kind === targetKind)
- );
const filterUnreadCount =
filter === "all"
? unreadCounts.all
@@ -287,45 +245,7 @@ const TeamInboxView: React.FC = ({
? unreadCounts.mentions
: unreadCounts.assigned;
if (filterUnreadCount === 0) return;
- const readAt = new Date().toISOString();
- const affectedItems = items.filter(
- (item) => targetKind === null || item.kind === targetKind
- );
- const previousReadAtById = new Map(
- affectedItems.map((item) => [item.id, item.readAt])
- );
- const affectedIds = affectedItems.map((item) => item.id);
- const epoch = beginItemMutations(affectedIds);
- const previousCounts = authoritativeUnreadCounts;
- const markedIds = new Set(affectedIds);
- setItems((current) =>
- current.map((item) =>
- markedIds.has(item.id) ? { ...item, readAt } : item
- )
- );
- setAuthoritativeUnreadCounts((current) => {
- if (!current) return null;
- const assigned =
- filter === "all" || filter === "assigned" ? 0 : current.assigned;
- const mentions =
- filter === "all" || filter === "mentions" ? 0 : current.mentions;
- return { all: assigned + mentions, assigned, mentions };
- });
- void dataSource.markAllRead?.(unreadItems, filter).catch(() => {
- setItems((current) =>
- current.map((item) =>
- isCurrentItemMutation(item.id, epoch) &&
- previousReadAtById.has(item.id)
- ? {
- ...item,
- readAt: previousReadAtById.get(item.id) ?? null,
- }
- : item
- )
- );
- if (affectedIds.every((itemId) => isCurrentItemMutation(itemId, epoch))) {
- setAuthoritativeUnreadCounts(previousCounts);
- }
+ void dataSource.markAllRead?.([], filter).catch(() => {
setLoadState({
status: "error",
message: t("teamInbox.errors.markAllRead"),
@@ -333,6 +253,53 @@ const TeamInboxView: React.FC = ({
});
};
+ const handleWorkItemUpdated = useCallback(
+ (sourceItem: TeamInboxItem, workItem: WorkItem) => {
+ if (sourceItem.kind !== "assigned_work_item") return;
+ const sourceKey = getTeamInboxItemKey(sourceItem);
+ const assignee = workItem.assignee;
+ const belongsToViewer = assignee
+ ? viewerMemberIds.length > 0
+ ? viewerMemberIds.includes(assignee.id)
+ : assignee.id === sourceItem.payload.assigneeMemberId
+ : false;
+ const status =
+ workItem.workItemStatus ?? workItem.status ?? sourceItem.payload.status;
+ const updatedAt = workItem.updated_time || sourceItem.payload.updatedAt;
+ const nextItem: TeamInboxItem | null =
+ assignee && belongsToViewer
+ ? {
+ ...sourceItem,
+ occurredAt: updatedAt,
+ payload: {
+ ...sourceItem.payload,
+ title: workItem.name || sourceItem.payload.title,
+ status,
+ priority: workItem.priority ?? sourceItem.payload.priority,
+ assigneeMemberId: assignee.id,
+ assigneeName: assignee.name,
+ summary: workItem.spec?.trim() || undefined,
+ updatedAt,
+ },
+ }
+ : null;
+ if (dataSource.reconcileItem) {
+ dataSource.reconcileItem(sourceKey, nextItem);
+ return;
+ }
+ setItems((current) =>
+ current.flatMap((candidate) =>
+ getTeamInboxItemKey(candidate) === sourceKey
+ ? nextItem
+ ? [nextItem]
+ : []
+ : [candidate]
+ )
+ );
+ },
+ [dataSource, viewerMemberIds]
+ );
+
const detail = (() => {
if (loadState.status === "loading") {
return (
@@ -351,7 +318,7 @@ const TeamInboxView: React.FC = ({
placement="detail-panel"
title={t("teamInbox.errors.loadTitle")}
subtitle={loadState.message ?? undefined}
- action={{ label: t("common:actions.retry"), onClick: retry }}
+ action={{ label: t("common:actions.retry"), onClick: handleRefresh }}
fillParentHeight
/>
);
@@ -387,24 +354,33 @@ const TeamInboxView: React.FC = ({
onMarkRead={dataSource.markRead ? handleMarkRead : undefined}
onMarkUnread={dataSource.markUnread ? handleMarkUnread : undefined}
onNavigate={onNavigate}
+ onWorkItemUpdated={(workItem) =>
+ handleWorkItemUpdated(selectedItem, workItem)
+ }
/>
);
})();
return (
-
- {loadState.status === "error" && items.length > 0 ? (
+
+ {(loadState.status === "error" || loadState.status === "warning") &&
+ items.length > 0 ? (
{loadState.message}
) : null}
= ({
variant="error"
title={t("teamInbox.errors.loadTitle")}
subtitle={loadState.message ?? undefined}
- action={{ label: t("common:actions.retry"), onClick: retry }}
+ action={{
+ label: t("common:actions.retry"),
+ onClick: handleRefresh,
+ }}
fillParentHeight
/>
) : (
diff --git a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts
index fd347a904d..38b2db2175 100644
--- a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts
+++ b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts
@@ -52,33 +52,46 @@ vi.mock("../useTeamInboxWorkItem", () => ({
useTeamInboxWorkItem: () => ({
workItem: mocks.workItem,
status: "ready",
- error: null,
+ issue: null,
repoPath: "/repo",
members: [],
+ currentUser: {
+ id: "user-ea821852",
+ name: "hanafish",
+ avatar: "https://example.com/hanafish.png",
+ color: "#52c41a",
+ },
updateWorkItem: vi.fn(),
refreshWorkItem: vi.fn(),
}),
}));
vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({
- WorkItemProperties: ({ pillLayout }: { pillLayout?: string }) =>
- createElement("div", {
- "data-testid": "work-item-properties",
- "data-pill-layout": pillLayout,
- }),
- WorkItemContent: ({
+ WorkItemThreadSurface: ({
onStartAgent,
onOpenSession,
- headerProperties,
+ propertyProps,
+ currentUser,
}: {
onStartAgent?: () => void;
onOpenSession?: (sessionId: string) => void;
- headerProperties?: React.ReactNode;
+ propertyProps?: Record;
+ currentUser?: { id: string; name: string; avatar?: string };
}) =>
createElement(
"div",
- null,
- headerProperties,
+ {
+ "data-testid": "work-item-content",
+ "data-current-user-id": currentUser?.id,
+ "data-current-user-name": currentUser?.name,
+ "data-current-user-avatar": currentUser?.avatar,
+ },
+ propertyProps
+ ? createElement("div", {
+ "data-testid": "work-item-properties",
+ "data-property-configured": "true",
+ })
+ : null,
createElement(
"button",
{
@@ -176,7 +189,7 @@ describe("AssignedWorkItemDetail navigation actions", () => {
});
});
- it("uses the responsive wrapping layout for constrained property pills", () => {
+ it("provides editable properties to the shared thread surface", () => {
act(() => {
root.render(createElement(AssignedWorkItemDetail, { item }));
});
@@ -184,8 +197,23 @@ describe("AssignedWorkItemDetail navigation actions", () => {
expect(
container
.querySelector("[data-testid='work-item-properties']")
- ?.getAttribute("data-pill-layout")
- ).toBe("wrap");
+ ?.getAttribute("data-property-configured")
+ ).toBe("true");
+ });
+
+ it("passes one resolved identity to the comment composer and history surface", () => {
+ act(() => {
+ root.render(createElement(AssignedWorkItemDetail, { item }));
+ });
+
+ const content = container.querySelector(
+ "[data-testid='work-item-content']"
+ );
+ expect(content?.getAttribute("data-current-user-id")).toBe("user-ea821852");
+ expect(content?.getAttribute("data-current-user-name")).toBe("hanafish");
+ expect(content?.getAttribute("data-current-user-avatar")).toBe(
+ "https://example.com/hanafish.png"
+ );
});
it("preserves linked-session navigation as a distinct Session tab intent", () => {
diff --git a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
index 031f5a960c..34f3f4b950 100644
--- a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
+++ b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
@@ -12,10 +12,10 @@ Behavior is derived from the shipped implementation, not aspirational.
- Local source page size is 50 (`listLocalTeamInboxPage(..., 50)`); cloud
mentions page size is 50 (`listTeamInboxMentions(..., 50)`).
- `hasMore` is surfaced to the view via `listPage().nextCursor != null`; the
- cursor value itself is an inert sentinel — the data source owns the real
- per-source cursors (`localCursorRef` / `cloudCursorRef`).
-- The load-more control renders only inside the non-empty list branch, at the
- bottom of the scroll area, when `hasMore === true` and `onLoadMore` is defined.
+ cursor value itself is an inert sentinel — the per-store coordinator owns the
+ real local/cloud cursors shared by Sidebar and full Inbox consumers.
+- The load-more control renders whenever `hasMore === true` and `onLoadMore` is
+ defined, including filter/search empty-result states.
## Happy Path
@@ -23,7 +23,7 @@ Behavior is derived from the shipped implementation, not aspirational.
| --- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. |
| 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. |
-| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both `localCursorRef` and `cloudCursorRef` are null, `hasMore` becomes false and the button disappears. |
+| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both shared coordinator cursors are null, `hasMore` becomes false and the button disappears. |
| 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). |
| 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. |
@@ -31,11 +31,11 @@ Behavior is derived from the shipped implementation, not aspirational.
| # | Scenario | Steps | Expected Result |
| --- | ------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1 | Empty inbox | Open inbox with 0 items. | Empty `Placeholder` renders; **no** "Load more" button (it lives in the items-present branch). |
+| 1 | Empty first page with a remaining cursor | Open an active filter/search with 0 visible items and `hasMore`. | Empty `Placeholder` renders together with "Load more", so a matching later page remains reachable. |
| 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. |
| 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. |
| 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). |
-| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | `loadingMoreRef` guard + `disabled={loadingMore}` ensure only one in-flight load; extra clicks are no-ops; no duplicated/skipped pages. |
+| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | View loading state plus the coordinator single-flight promise ensure only one in-flight load; extra clicks reuse/no-op; no duplicated/skipped pages. |
| 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. |
| 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. |
| 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). |
@@ -43,18 +43,19 @@ Behavior is derived from the shipped implementation, not aspirational.
## Error / Degraded States
-| # | Scenario | Steps | Expected Result |
-| --- | ---------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Caught inside `loadMore` (`.catch(() => ({ mentions: [], nextCursor: undefined }))`); cloud cursor becomes null (cloud pagination stops); local page still appends; no crash. |
-| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | `Promise.all` rejects → `handleLoadMore` catch sets the error banner (`teamInbox.errors.load`); `loadingMore` resets via `finally`; existing items remain. |
-| 3 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. |
-| 4 | Signed-out / no active cloud org | Only local paginates. | Cloud branch resolves to empty; only local advances; behavior identical to Edge #3. |
+| # | Scenario | Steps | Expected Result |
+| --- | ---------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Local page still commits; cloud cursor is preserved for retry; a localized partial-success warning appears. |
+| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | Cloud page still commits; local cursor is preserved for retry; a localized partial-success warning appears. |
+| 3 | Every requested source fails | Both active source reads reject. | Existing rows remain; load-more rejects to the view, which shows localized non-blocking error copy and resets loading in `finally`. |
+| 4 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. |
+| 5 | Signed-out / no active cloud org | Only local paginates. | No cloud request is started; only local advances. |
## Accessibility
- [ ] "Load more" uses the design-system `Button` (keyboard focusable, Enter/Space activate).
- [ ] While loading, the button is `disabled` and shows `loading` state (no double submit).
-- [ ] The button has a visible localized label (`teamInbox.loadMore`, defaultValue "Load more") — no raw i18n key leaks.
+- [ ] The button has a visible localized label (`teamInbox.loadMore`) — no raw fallback string or i18n key leaks.
- [ ] Load-more does not steal focus from the list; existing roving-tabindex list navigation is unaffected.
## Acceptance Criteria
@@ -63,7 +64,7 @@ Behavior is derived from the shipped implementation, not aspirational.
- [ ] `hasMore` accurately reflects "either source has a next page" and the button visibility follows it.
- [ ] Appended pages are de-duplicated and correctly ordered by the view selectors.
- [ ] Concurrent/rapid load-more is guarded (single in-flight request).
-- [ ] A cloud failure degrades gracefully (local still paginates); a local failure surfaces a non-blocking error banner without losing loaded items.
+- [ ] Either source may fail independently; the successful source still paginates, the failed cursor remains retryable, and loaded items are preserved.
- [ ] Load-more never derives the badge from the loaded window; the server's authoritative mention count remains unchanged until a read mutation succeeds.
- [ ] `pnpm test` for `src/modules/MainApp/TeamInbox` passes; no new TypeScript/lint errors in edited files.
@@ -71,7 +72,6 @@ Behavior is derived from the shipped implementation, not aspirational.
- The unread badge uses the cloud RPC's authoritative full-result count, so
unread mentions on page 2+ are included before those rows are loaded.
-- Hook-level behavior is not unit-tested (repo policy forbids `.tsx` / React
- Testing Library tests); pure logic is covered by `selectors.test.ts`
- (dedupe/sort/select), while the two-instance rendered cloud spec covers the
- production mention picker and durable read-receipt path.
+- Coordinator behavior is unit-tested at the shared Jotai-store seam for cursor
+ continuity, partial failure, scope switching, optimistic rollback and cache
+ bounds. Component composition tests cover empty-result pagination and retry.
diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts
new file mode 100644
index 0000000000..a6ccbea8ee
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts
@@ -0,0 +1,44 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import TeamInboxList from "../components/TeamInboxList";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+function renderEmptyList(query: string): string {
+ return renderToStaticMarkup(
+ createElement(TeamInboxList, {
+ filter: "all",
+ items: [],
+ recencyAnchorMs: Date.UTC(2026, 6, 28),
+ selectedItemId: null,
+ totalUnread: 0,
+ unreadCounts: { all: 0, mentions: 0, assigned: 0 },
+ query,
+ loading: false,
+ onQueryChange: vi.fn(),
+ onFilterChange: vi.fn(),
+ onSelectItem: vi.fn(),
+ hasMore: true,
+ onLoadMore: vi.fn(),
+ })
+ );
+}
+
+describe("TeamInboxList pagination", () => {
+ it("keeps Load more reachable when the current search has no visible rows", () => {
+ const markup = renderEmptyList("missing");
+
+ expect(markup).toContain("teamInbox.empty.noResults.title");
+ expect(markup).toContain("teamInbox.loadMore");
+ });
+
+ it("does not point assistive technology at an unmounted active row", () => {
+ expect(renderEmptyList("")).not.toContain("aria-activedescendant");
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts
new file mode 100644
index 0000000000..d0987769b1
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment jsdom
+import { act, createElement } from "react";
+import { type Root, createRoot } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import TeamInboxRow from "../components/TeamInboxRow";
+import type { AssignedWorkItem } from "../domain";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (_key: string, options?: { defaultValue?: string }) =>
+ options?.defaultValue ?? _key,
+ }),
+}));
+
+const assignedItem: AssignedWorkItem = {
+ id: "assigned-1",
+ kind: "assigned_work_item",
+ occurredAt: new Date().toISOString(),
+ readAt: "2026-07-28T00:00:00.000Z",
+ actor: { id: "member-1", displayName: "Yuki" },
+ target: { kind: "work_item", projectId: "demo", workItemId: "AAA-0001" },
+ payload: {
+ title: "验收 Team Inbox 的真实分配与已读流程",
+ status: "todo",
+ priority: "medium",
+ assigneeMemberId: "member-1",
+ assigneeName: "Yuki",
+ summary:
+ "## 验收目标\\n- 在 Team Inbox 的“全部”和“分配给我”中看到此事项\\n- 打开详情并标记已读",
+ updatedAt: "2026-07-28T00:00:00.000Z",
+ },
+};
+
+describe("TeamInboxRow", () => {
+ 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(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ it("renders a compact plain-text excerpt and useful Work Item metadata", () => {
+ act(() => {
+ root.render(
+ createElement(TeamInboxRow, {
+ item: assignedItem,
+ itemKey: "assigned_work_item:assigned-1",
+ selected: true,
+ onSelect: vi.fn(),
+ })
+ );
+ });
+
+ const summary = container.querySelector("[title]");
+ expect(summary?.textContent).toBe(
+ "验收目标 在 Team Inbox 的“全部”和“分配给我”中看到此事项 打开详情并标记已读"
+ );
+ expect(summary?.textContent).not.toContain("\\n");
+ expect(summary?.textContent).not.toContain("##");
+ expect(summary?.className).toContain("max-h-10");
+ expect(summary?.className).toContain("text-text-1");
+ expect(container.textContent).toContain("Todo · Medium");
+ expect(container.textContent).not.toContain("Yuki");
+ });
+
+ it("omits the excerpt row when an assigned item has no summary", () => {
+ act(() => {
+ root.render(
+ createElement(TeamInboxRow, {
+ item: {
+ ...assignedItem,
+ payload: { ...assignedItem.payload, summary: undefined },
+ },
+ itemKey: "assigned_work_item:assigned-1",
+ selected: false,
+ onSelect: vi.fn(),
+ })
+ );
+ });
+
+ expect(container.querySelector("[title]")).toBeNull();
+ expect(container.textContent).toContain("Todo · Medium");
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts
index 41599e518f..e888e42931 100644
--- a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts
+++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
-import { act, createElement } from "react";
+import React, { act, createElement } from "react";
import { type Root, createRoot } from "react-dom/client";
import {
afterAll,
@@ -12,33 +12,56 @@ import {
vi,
} from "vitest";
+import type { WorkItem } from "@src/types/core/workItem";
+
import TeamInboxView from "../TeamInboxView";
+import type { AssignedWorkItem } from "../domain";
const splitViewProps = vi.hoisted(() => ({
current: null as Record | null,
}));
+const componentProps = vi.hoisted(() => ({
+ assignedDetail: null as Record | null,
+ list: null as Record | null,
+ placeholder: null as Record | null,
+}));
+const translate = vi.hoisted(() => vi.fn((key: string) => key));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
- t: (key: string) => key,
+ t: translate,
}),
}));
vi.mock("@src/modules/shared/layouts/SplitViewLayout", () => ({
default: (props: Record) => {
splitViewProps.current = props;
- return createElement("div", { "data-testid": "team-inbox-split" });
+ return createElement(
+ "div",
+ { "data-testid": "team-inbox-split" },
+ props.listContent as React.ReactNode,
+ props.mainContent as React.ReactNode
+ );
},
}));
vi.mock("@src/modules/shared/layouts/blocks", () => ({
- Placeholder: () => null,
+ Placeholder: (props: Record) => {
+ componentProps.placeholder = props;
+ return null;
+ },
}));
vi.mock("../components", () => ({
- AssignedWorkItemDetail: () => null,
+ AssignedWorkItemDetail: (props: Record) => {
+ componentProps.assignedDetail = props;
+ return null;
+ },
CommentMentionDetail: () => null,
- TeamInboxList: () => null,
+ TeamInboxList: (props: Record) => {
+ componentProps.list = props;
+ return null;
+ },
}));
describe("TeamInboxView split layout", () => {
@@ -54,6 +77,9 @@ describe("TeamInboxView split layout", () => {
beforeEach(() => {
splitViewProps.current = null;
+ componentProps.assignedDetail = null;
+ componentProps.list = null;
+ componentProps.placeholder = null;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -84,4 +110,116 @@ describe("TeamInboxView split layout", () => {
true
);
});
+
+ it("projects successful detail edits back into the matching Inbox row", async () => {
+ const assignedItem: AssignedWorkItem = {
+ id: "assigned-1",
+ kind: "assigned_work_item",
+ occurredAt: "2026-07-28T00:00:00.000Z",
+ readAt: "2026-07-28T00:01:00.000Z",
+ actor: { id: "member-1", displayName: "Yuki" },
+ target: {
+ kind: "work_item",
+ projectId: "demo",
+ workItemId: "AAA-0001",
+ },
+ payload: {
+ title: "Old title",
+ status: "todo",
+ priority: "medium",
+ assigneeMemberId: "member-1",
+ assigneeName: "Yuki",
+ summary: "Old summary",
+ updatedAt: "2026-07-28T00:00:00.000Z",
+ },
+ };
+
+ await act(async () => {
+ root.render(
+ createElement(TeamInboxView, {
+ dataSource: {
+ listPage: async () => ({
+ items: [assignedItem],
+ nextCursor: null,
+ }),
+ },
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const onWorkItemUpdated = componentProps.assignedDetail
+ ?.onWorkItemUpdated as ((workItem: WorkItem) => void) | undefined;
+ expect(onWorkItemUpdated).toBeTypeOf("function");
+
+ const updatedWorkItem: WorkItem = {
+ session_id: "AAA-0001",
+ user_id: "member-1",
+ name: "Updated title",
+ status: "in_review",
+ workItemStatus: "in_review",
+ priority: "high",
+ spec: "## Updated summary",
+ assignee: { id: "member-1", name: "Yuki" },
+ star: false,
+ target_date: null,
+ created_time: "2026-07-28T00:00:00.000Z",
+ updated_time: "2026-07-28T00:05:00.000Z",
+ linkedSessions: [],
+ todos: [],
+ };
+
+ act(() => onWorkItemUpdated?.(updatedWorkItem));
+
+ const updatedItems = componentProps.list?.items as AssignedWorkItem[];
+ expect(updatedItems[0].payload).toMatchObject({
+ title: "Updated title",
+ status: "in_review",
+ priority: "high",
+ assigneeMemberId: "member-1",
+ assigneeName: "Yuki",
+ summary: "## Updated summary",
+ updatedAt: "2026-07-28T00:05:00.000Z",
+ });
+
+ act(() =>
+ onWorkItemUpdated?.({
+ ...updatedWorkItem,
+ assignee: { id: "member-2", name: "Lin" },
+ })
+ );
+
+ expect(componentProps.list?.items).toEqual([]);
+ });
+
+ it("retries the backing source instead of rereading a failed snapshot", async () => {
+ const listPage = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("offline"))
+ .mockResolvedValueOnce({ items: [], nextCursor: null });
+ const refresh = vi.fn(async () => undefined);
+
+ await act(async () => {
+ root.render(
+ createElement(TeamInboxView, {
+ dataSource: { listPage, refresh },
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const action = componentProps.placeholder?.action as
+ | { onClick?: () => void }
+ | undefined;
+ expect(action?.onClick).toBeTypeOf("function");
+
+ await act(async () => {
+ action?.onClick?.();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(listPage).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts
new file mode 100644
index 0000000000..4e0bd67011
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts
@@ -0,0 +1,385 @@
+import { createStore } from "jotai";
+import { describe, expect, it, vi } from "vitest";
+
+import type { TeamInboxMention } from "@src/features/Org2Cloud/teamInboxMentionsClient";
+
+import type { AssignedWorkItem } from "../domain";
+import { teamInboxCacheAtom } from "../store";
+import {
+ TEAM_INBOX_CACHE_LIMIT,
+ TeamInboxCoordinator,
+ type TeamInboxCoordinatorDependencies,
+ type TeamInboxCoordinatorScope,
+} from "../teamInboxCoordinator";
+
+function assignedItem(
+ id: string,
+ occurredAt = "2026-07-28T10:00:00.000Z"
+): AssignedWorkItem {
+ return {
+ id,
+ kind: "assigned_work_item",
+ occurredAt,
+ readAt: null,
+ actor: { id: "assigner", displayName: "Assigner" },
+ target: {
+ kind: "work_item",
+ projectId: "project-1",
+ workItemId: id,
+ },
+ payload: {
+ title: id,
+ status: "todo",
+ priority: "medium",
+ assigneeMemberId: "viewer-1",
+ updatedAt: occurredAt,
+ },
+ };
+}
+
+function mention(id: string): TeamInboxMention {
+ return {
+ comment: { id },
+ session: { id: `session-${id}` },
+ author: { userId: "author-1" },
+ body: `Mention ${id}`,
+ createdAt: "2026-07-28T11:00:00.000Z",
+ readAt: null,
+ commentCount: 1,
+ threadCount: 1,
+ };
+}
+
+function dependencies(
+ overrides: Partial = {}
+): TeamInboxCoordinatorDependencies {
+ return {
+ listLocalPage: vi.fn(async () => ({
+ page: { items: [], nextCursor: null },
+ unreadCount: 0,
+ })),
+ listInitialMentions: vi.fn(async () => ({
+ mentions: [],
+ unreadCount: 0,
+ })),
+ listMentions: vi.fn(async () => ({
+ mentions: [],
+ unreadCount: 0,
+ })),
+ markLocalRead: vi.fn(async () => true),
+ markLocalUnread: vi.fn(async () => true),
+ markAllLocalRead: vi.fn(async () => 0),
+ setMentionRead: vi.fn(async () => ({
+ readAt: "2026-07-28T12:00:00.000Z",
+ unreadCount: 0,
+ })),
+ markAllMentionsRead: vi.fn(async () => ({
+ readAt: "2026-07-28T12:00:00.000Z",
+ unreadCount: 0,
+ })),
+ now: () => "2026-07-28T12:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function scope(
+ overrides: Partial = {}
+): TeamInboxCoordinatorScope {
+ return {
+ key: "viewer-1::local",
+ viewerMemberIds: ["viewer-1"],
+ accessToken: null,
+ activeCloudOrgId: null,
+ members: [],
+ ...overrides,
+ };
+}
+
+describe("TeamInboxCoordinator", () => {
+ it("shares the first-page cursor across consumers using the same store", async () => {
+ const firstCursor = {
+ occurredAt: "2026-07-28T10:00:00.000Z",
+ itemKey: "assigned_work_item:first",
+ };
+ const listLocalPage = vi
+ .fn()
+ .mockResolvedValueOnce({
+ page: { items: [assignedItem("first")], nextCursor: firstCursor },
+ unreadCount: 2,
+ })
+ .mockResolvedValueOnce({
+ page: {
+ items: [assignedItem("second", "2026-07-28T09:00:00.000Z")],
+ nextCursor: null,
+ },
+ unreadCount: 2,
+ });
+ const coordinator = new TeamInboxCoordinator(
+ dependencies({ listLocalPage })
+ );
+ const store = createStore();
+ const viewerScope = scope();
+
+ await coordinator.refresh(store, viewerScope, "version-1");
+ await coordinator.loadMore(store, viewerScope);
+
+ expect(listLocalPage).toHaveBeenNthCalledWith(
+ 2,
+ ["viewer-1"],
+ "all",
+ firstCursor
+ );
+ expect(store.get(teamInboxCacheAtom).items.map((item) => item.id)).toEqual([
+ "first",
+ "second",
+ ]);
+ expect(store.get(teamInboxCacheAtom).hasMore).toBe(false);
+ });
+
+ it("publishes a usable partial snapshot when one source fails", async () => {
+ const coordinator = new TeamInboxCoordinator(
+ dependencies({
+ listLocalPage: vi.fn(async () => ({
+ page: { items: [assignedItem("local")], nextCursor: null },
+ unreadCount: 1,
+ })),
+ listInitialMentions: vi.fn(async () => {
+ throw new Error("cloud unavailable");
+ }),
+ })
+ );
+ const store = createStore();
+
+ await coordinator.refresh(
+ store,
+ scope({
+ key: "viewer-1::org-1",
+ accessToken: "token",
+ activeCloudOrgId: "org-1",
+ }),
+ "version-1"
+ );
+
+ expect(store.get(teamInboxCacheAtom)).toMatchObject({
+ unreadCount: 1,
+ unreadCounts: { all: 1, assigned: 1, mentions: 0 },
+ issue: { code: "partial_load", detail: "cloud unavailable" },
+ });
+ expect(store.get(teamInboxCacheAtom).items).toHaveLength(1);
+ });
+
+ it("keeps cloud results visible while reporting an unresolved local identity", async () => {
+ const coordinator = new TeamInboxCoordinator(
+ dependencies({
+ listInitialMentions: vi.fn(async () => ({
+ mentions: [mention("cloud-1")],
+ unreadCount: 1,
+ })),
+ })
+ );
+ const store = createStore();
+
+ await coordinator.refresh(
+ store,
+ scope({
+ key: "::org-1",
+ viewerMemberIds: [],
+ accessToken: "token",
+ activeCloudOrgId: "org-1",
+ members: [
+ {
+ id: "someone-else",
+ name: "Someone Else",
+ email: "else@example.com",
+ active: true,
+ },
+ ],
+ }),
+ "version-1"
+ );
+
+ expect(store.get(teamInboxCacheAtom).issue?.code).toBe(
+ "identity_unresolved"
+ );
+ expect(store.get(teamInboxCacheAtom).items).toHaveLength(1);
+ });
+
+ it("keeps a failed source cursor retryable while appending a successful page", async () => {
+ const localCursor = {
+ occurredAt: "2026-07-28T10:00:00.000Z",
+ itemKey: "assigned_work_item:first",
+ };
+ const listLocalPage = vi
+ .fn()
+ .mockResolvedValueOnce({
+ page: { items: [assignedItem("first")], nextCursor: localCursor },
+ unreadCount: 2,
+ })
+ .mockResolvedValueOnce({
+ page: {
+ items: [assignedItem("second", "2026-07-28T09:00:00.000Z")],
+ nextCursor: null,
+ },
+ unreadCount: 2,
+ });
+ const listMentions = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("temporary cloud failure"))
+ .mockResolvedValueOnce({
+ mentions: [mention("cloud-2")],
+ unreadCount: 2,
+ });
+ const coordinator = new TeamInboxCoordinator(
+ dependencies({
+ listLocalPage,
+ listInitialMentions: vi.fn(async () => ({
+ mentions: [mention("cloud-1")],
+ nextCursor: "cloud-cursor",
+ unreadCount: 2,
+ })),
+ listMentions,
+ })
+ );
+ const store = createStore();
+ const viewerScope = scope({
+ key: "viewer-1::org-1",
+ accessToken: "token",
+ activeCloudOrgId: "org-1",
+ });
+
+ await coordinator.refresh(store, viewerScope, "version-1");
+ await coordinator.loadMore(store, viewerScope);
+
+ expect(store.get(teamInboxCacheAtom).issue?.code).toBe("partial_load");
+ expect(store.get(teamInboxCacheAtom).hasMore).toBe(true);
+ expect(
+ store.get(teamInboxCacheAtom).items.map((item) => item.id)
+ ).toContain("second");
+
+ await coordinator.loadMore(store, viewerScope);
+
+ expect(listMentions).toHaveBeenNthCalledWith(
+ 2,
+ "token",
+ "org-1",
+ "cloud-cursor",
+ 50,
+ expect.any(AbortSignal)
+ );
+ expect(
+ store.get(teamInboxCacheAtom).items.map((item) => item.id)
+ ).toContain("cloud-comment:org-1:cloud-2");
+ expect(store.get(teamInboxCacheAtom).hasMore).toBe(false);
+ });
+
+ it("ignores a late response after the viewer scope changes", async () => {
+ let resolveOldCloud:
+ | ((value: { mentions: TeamInboxMention[]; unreadCount: number }) => void)
+ | undefined;
+ const oldCloud = new Promise<{
+ mentions: TeamInboxMention[];
+ unreadCount: number;
+ }>((resolve) => {
+ resolveOldCloud = resolve;
+ });
+ const coordinator = new TeamInboxCoordinator(
+ dependencies({
+ listLocalPage: vi.fn(async (viewerIds) => ({
+ page: {
+ items: [assignedItem(viewerIds[0] ?? "unknown")],
+ nextCursor: null,
+ },
+ unreadCount: 1,
+ })),
+ listInitialMentions: vi
+ .fn()
+ .mockImplementationOnce(async () => oldCloud)
+ .mockResolvedValueOnce({ mentions: [], unreadCount: 0 }),
+ })
+ );
+ const store = createStore();
+ const oldScope = scope({
+ key: "viewer-1::org-1",
+ accessToken: "token",
+ activeCloudOrgId: "org-1",
+ });
+ const nextScope = scope({
+ key: "viewer-2::org-2",
+ viewerMemberIds: ["viewer-2"],
+ accessToken: "token",
+ activeCloudOrgId: "org-2",
+ });
+
+ const staleRefresh = coordinator.refresh(store, oldScope, "version-1");
+ await coordinator.refresh(store, nextScope, "version-1");
+ resolveOldCloud?.({ mentions: [mention("stale")], unreadCount: 1 });
+ await staleRefresh;
+
+ expect(store.get(teamInboxCacheAtom).loadedForViewerKey).toBe(
+ "viewer-2::org-2"
+ );
+ expect(store.get(teamInboxCacheAtom).items.map((item) => item.id)).toEqual([
+ "viewer-2",
+ ]);
+ });
+
+ it("rolls back an optimistic read mutation when persistence fails", async () => {
+ const coordinator = new TeamInboxCoordinator(
+ dependencies({
+ listLocalPage: vi.fn(async () => ({
+ page: { items: [assignedItem("first")], nextCursor: null },
+ unreadCount: 1,
+ })),
+ markLocalRead: vi.fn(async () => {
+ throw new Error("write failed");
+ }),
+ })
+ );
+ const store = createStore();
+ const viewerScope = scope();
+ await coordinator.refresh(store, viewerScope, "version-1");
+ const item = store.get(teamInboxCacheAtom).items[0];
+
+ const mutation = coordinator.markRead(store, viewerScope, item);
+ expect(store.get(teamInboxCacheAtom).items[0].readAt).toBe(
+ "2026-07-28T12:00:00.000Z"
+ );
+ expect(store.get(teamInboxCacheAtom).unreadCount).toBe(0);
+
+ await expect(mutation).rejects.toThrow("write failed");
+ expect(store.get(teamInboxCacheAtom).items[0].readAt).toBeNull();
+ expect(store.get(teamInboxCacheAtom).unreadCount).toBe(1);
+ });
+
+ it("caps retained rows and closes cursors at the cache boundary", async () => {
+ const coordinator = new TeamInboxCoordinator(
+ dependencies({
+ listLocalPage: vi.fn(async () => ({
+ page: {
+ items: Array.from(
+ { length: TEAM_INBOX_CACHE_LIMIT + 25 },
+ (_, index) =>
+ assignedItem(
+ `item-${index}`,
+ new Date(Date.UTC(2026, 6, 28, 12, 0, index)).toISOString()
+ )
+ ),
+ nextCursor: {
+ occurredAt: "2026-07-28T00:00:00.000Z",
+ itemKey: "more",
+ },
+ },
+ unreadCount: TEAM_INBOX_CACHE_LIMIT + 25,
+ })),
+ })
+ );
+ const store = createStore();
+
+ await coordinator.refresh(store, scope(), "version-1");
+
+ expect(store.get(teamInboxCacheAtom).items).toHaveLength(
+ TEAM_INBOX_CACHE_LIMIT
+ );
+ expect(store.get(teamInboxCacheAtom).hasMore).toBe(false);
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts
new file mode 100644
index 0000000000..6f1bbe50dd
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts
@@ -0,0 +1,206 @@
+// @vitest-environment jsdom
+import { act, createElement, useEffect } from "react";
+import { type Root, createRoot } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import type { WorkItem } from "@src/types/core/workItem";
+
+import {
+ type TeamInboxWorkItemState,
+ useTeamInboxWorkItem,
+} from "../useTeamInboxWorkItem";
+
+const mocks = vi.hoisted(() => ({
+ readWorkItem: vi.fn(),
+ readProject: vi.fn(),
+ readMembers: vi.fn(),
+ readStandaloneWorkItem: vi.fn(),
+ updateWorkItemPartial: vi.fn(),
+}));
+
+vi.mock("@src/api/http/project", () => ({
+ projectApi: {
+ readWorkItem: mocks.readWorkItem,
+ readProject: mocks.readProject,
+ readMembers: mocks.readMembers,
+ readStandaloneWorkItem: mocks.readStandaloneWorkItem,
+ updateWorkItemPartial: mocks.updateWorkItemPartial,
+ },
+ standaloneWorkItemDataToEnriched: (value: unknown) => value,
+ enrichedWorkItemToUI: (value: unknown) => value,
+}));
+
+vi.mock("@src/hooks/project/useCurrentUserMemberId", () => ({
+ useCurrentUserMemberIds: () => ({ currentUser: null }),
+}));
+
+vi.mock("@src/hooks/logger", () => ({
+ createLogger: () => ({ warn: vi.fn() }),
+}));
+
+vi.mock("@src/modules/ProjectManager/WorkItems/workItemPartialUpdate", () => ({
+ toWorkItemPartialUpdate: (value: unknown) => value,
+}));
+
+const WORK_ITEM: WorkItem = {
+ session_id: "AAA-0001",
+ user_id: "member-1",
+ name: "Inbox item",
+ status: "planned",
+ workItemStatus: "planned",
+ priority: "medium",
+ spec: "Body",
+ assignee: { id: "member-1", name: "Ada" },
+ star: false,
+ target_date: null,
+ created_time: "2026-07-28T00:00:00.000Z",
+ updated_time: "2026-07-28T00:00:00.000Z",
+ linkedSessions: [],
+ todos: [],
+};
+
+let latestState: TeamInboxWorkItemState | null = null;
+
+function Probe() {
+ const state = useTeamInboxWorkItem({
+ kind: "work_item",
+ projectId: "demo",
+ workItemId: "AAA-0001",
+ });
+ useEffect(() => {
+ latestState = state;
+ }, [state]);
+ return null;
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((nextResolve) => {
+ resolve = nextResolve;
+ });
+ return { promise, resolve };
+}
+
+describe("useTeamInboxWorkItem", () => {
+ 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(() => {
+ latestState = null;
+ vi.clearAllMocks();
+ mocks.readWorkItem.mockResolvedValue(WORK_ITEM);
+ mocks.readProject.mockResolvedValue({
+ slug: "demo",
+ meta: { name: "Demo", linked_repos: [] },
+ });
+ mocks.readMembers.mockResolvedValue({ members: [] });
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ it("keeps the Work Item usable when optional project context fails", async () => {
+ mocks.readMembers.mockRejectedValueOnce(new Error("members unavailable"));
+
+ await act(async () => {
+ root.render(createElement(Probe));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(latestState).toMatchObject({
+ status: "ready",
+ workItem: WORK_ITEM,
+ members: [],
+ issue: "context_unavailable",
+ });
+ });
+
+ it("uses the blocking state only when the required Work Item read fails", async () => {
+ mocks.readWorkItem.mockRejectedValueOnce(new Error("item unavailable"));
+
+ await act(async () => {
+ root.render(createElement(Probe));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(latestState).toMatchObject({
+ status: "error",
+ workItem: null,
+ issue: "load_failed",
+ });
+ expect(mocks.readProject).not.toHaveBeenCalled();
+ expect(mocks.readMembers).not.toHaveBeenCalled();
+ });
+
+ it("serializes same-item updates so response order follows user intent", async () => {
+ const first = deferred();
+ const second = deferred();
+ mocks.updateWorkItemPartial
+ .mockImplementationOnce(() => first.promise)
+ .mockImplementationOnce(() => second.promise);
+
+ await act(async () => {
+ root.render(createElement(Probe));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ act(() => {
+ latestState?.updateWorkItem({ workItemStatus: "in_review" });
+ latestState?.updateWorkItem({ priority: "high" });
+ });
+ await Promise.resolve();
+ expect(mocks.updateWorkItemPartial).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ first.resolve({
+ ...WORK_ITEM,
+ status: "in_review",
+ workItemStatus: "in_review",
+ });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(mocks.updateWorkItemPartial).toHaveBeenCalledTimes(2);
+
+ await act(async () => {
+ second.resolve({
+ ...WORK_ITEM,
+ status: "in_review",
+ workItemStatus: "in_review",
+ priority: "high",
+ });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(latestState?.workItem).toMatchObject({
+ workItemStatus: "in_review",
+ priority: "high",
+ });
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/api.ts b/src/modules/MainApp/TeamInbox/api.ts
index db1787615f..8e06deae5a 100644
--- a/src/modules/MainApp/TeamInbox/api.ts
+++ b/src/modules/MainApp/TeamInbox/api.ts
@@ -75,7 +75,7 @@ function mapWireItem(item: TeamInboxWireItem): TeamInboxItem {
const occurredAt = new Date(item.occurredAt).toISOString();
const actor = item.actor ?? {
id: "system",
- displayName: "Team Inbox",
+ displayName: "",
};
if (
diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
index afcce63421..6a3ef79cc6 100644
--- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
+++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
@@ -2,11 +2,7 @@ import { ClipboardList, ExternalLink } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
-import {
- WorkItemContent,
- WorkItemProperties,
-} from "@src/modules/ProjectManager/WorkItems/components";
-import type { WorkItemPropertyFieldKey } from "@src/modules/ProjectManager/WorkItems/components/WorkItemProperties/types";
+import { WorkItemThreadSurface } from "@src/modules/ProjectManager/WorkItems/components";
import { Placeholder } from "@src/modules/shared/layouts/blocks";
import type { Person } from "@src/types/core/shared";
import type { WorkItem } from "@src/types/core/workItem";
@@ -17,22 +13,15 @@ import {
isGitHubIssueStatus,
} from "../domain";
import { useTeamInboxWorkItem } from "../useTeamInboxWorkItem";
+import type { TeamInboxWorkItemIssue } from "../useTeamInboxWorkItem";
import TeamInboxDetailLayout from "./TeamInboxDetailLayout";
-const WORK_ITEM_THREAD_PROPERTY_FIELDS: WorkItemPropertyFieldKey[] = [
- "project",
- "status",
- "priority",
- "assignee",
- "reviewer",
- "date",
-];
-
export interface AssignedWorkItemDetailProps {
item: AssignedWorkItem;
onNavigate?: (intent: TeamInboxNavigationIntent) => void;
onMarkRead?: (item: AssignedWorkItem) => void;
onMarkUnread?: (item: AssignedWorkItem) => void;
+ onWorkItemUpdated?: (workItem: WorkItem) => void;
}
interface AssignedWorkItemThreadProps {
@@ -40,7 +29,9 @@ interface AssignedWorkItemThreadProps {
workItem: WorkItem;
repoPath: string | null;
members: Person[];
- error: string | null;
+ currentUser: Person | null;
+ issueMessage: string | null;
+ issueTone: "warning" | "error" | null;
updateWorkItem: (updates: Partial) => void;
refreshWorkItem: () => void;
onNavigate?: (intent: TeamInboxNavigationIntent) => void;
@@ -51,7 +42,9 @@ const AssignedWorkItemThread: React.FC = ({
workItem,
repoPath,
members,
- error,
+ currentUser,
+ issueMessage,
+ issueTone,
updateWorkItem,
refreshWorkItem,
onNavigate,
@@ -59,41 +52,42 @@ const AssignedWorkItemThread: React.FC = ({
const canUpdate = Boolean(item.target.projectId);
const isGitHubIssue = isGitHubIssueStatus(item.payload.status);
- const properties = canUpdate ? (
-
- ) : null;
-
return (
- {error ? (
+ {issueMessage ? (
- {error}
+ {issueMessage}
) : null}
-
= ({
onNavigate,
onMarkRead,
onMarkUnread,
+ onWorkItemUpdated,
}) => {
const { t } = useTranslation();
const {
workItem,
status,
- error,
+ issue,
repoPath,
members,
+ currentUser,
updateWorkItem,
refreshWorkItem,
- } = useTeamInboxWorkItem(item.target);
+ } = useTeamInboxWorkItem(item.target, onWorkItemUpdated);
+ const issueMessage = ((): string | null => {
+ const keyByIssue: Record = {
+ context_unavailable: "teamInbox.errors.workItemContext",
+ load_failed: "teamInbox.errors.workItemLoad",
+ update_failed: "teamInbox.errors.workItemUpdate",
+ };
+ return issue ? t(keyByIssue[issue]) : null;
+ })();
return (
= ({
workItem={workItem}
repoPath={repoPath}
members={members}
- error={error}
+ currentUser={currentUser}
+ issueMessage={issueMessage}
+ issueTone={
+ issue === "context_unavailable" ? "warning" : issue ? "error" : null
+ }
updateWorkItem={updateWorkItem}
refreshWorkItem={refreshWorkItem}
onNavigate={onNavigate}
@@ -187,7 +195,7 @@ const AssignedWorkItemDetail: React.FC = ({
)}
diff --git a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
index b3291abe75..2af5cd68ca 100644
--- a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
+++ b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
@@ -56,14 +56,6 @@ const CommentMentionDetail: React.FC = ({
label: t("teamInbox.fields.comments"),
value: item.payload.commentCount,
},
- {
- label: t("teamInbox.fields.threadId"),
- value: item.target.threadId,
- },
- {
- label: t("teamInbox.fields.commentId"),
- value: item.target.commentId,
- },
]}
>
@@ -78,9 +70,14 @@ const CommentMentionDetail: React.FC = ({
) : null}
- {item.payload.context ? (
+ {item.payload.threadCommentCount !== undefined ||
+ item.payload.context ? (
- {item.payload.context}
+ {item.payload.threadCommentCount !== undefined
+ ? t("teamInbox.detail.threadComments", {
+ count: item.payload.threadCommentCount,
+ })
+ : item.payload.context}
) : null}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
index 308b143266..01437fda6e 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
@@ -89,6 +89,20 @@ const TeamInboxList: React.FC
= ({
[items, recencyAnchorMs]
);
const activeFilterUnread = unreadCounts[filter];
+ const loadMoreAction =
+ hasMore && onLoadMore ? (
+
+
+ {t("teamInbox.loadMore")}
+
+
+ ) : null;
const filterTabs = useMemo(
() => [
{
@@ -224,34 +238,36 @@ const TeamInboxList: React.FC = ({
{items.length === 0 ? (
- hasQuery ? (
-
- ) : (
-
- )
+
+ {hasQuery ? (
+
+ ) : (
+
+ )}
+ {loadMoreAction}
+
) : (
{groups.map((group) => {
@@ -290,19 +306,7 @@ const TeamInboxList: React.FC = ({
);
})}
- {hasMore && onLoadMore ? (
-
-
- {t("teamInbox.loadMore", { defaultValue: "Load more" })}
-
-
- ) : null}
+ {loadMoreAction}
)}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
index 1a2bf0f5ed..dc3acdd588 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
@@ -19,25 +19,48 @@ export interface TeamInboxRowProps {
onSelect: (item: TeamInboxItem) => void;
}
+function toCompactPreview(content: string): string {
+ return content
+ .replace(/\\[nr]/g, "\n")
+ .replace(/^\s*```[^\n]*$/gm, "")
+ .replace(/!\[([^\]]*)\]\((?:\\.|[^)])*\)/g, "$1")
+ .replace(/\[([^\]]+)\]\((?:\\.|[^)])*\)/g, "$1")
+ .replace(/^\s{0,3}#{1,6}[\t ]+/gm, "")
+ .replace(/^\s{0,3}>[\t ]?/gm, "")
+ .replace(/^\s{0,3}(?:[-+*]|\d+[.)])[\t ]+/gm, "")
+ .replace(/^\s*\[[ xX]\][\t ]+/gm, "")
+ .replace(/(`+)([\s\S]*?)\1/g, "$2")
+ .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
+ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
const TeamInboxRow = forwardRef(
({ item, itemKey, selected, onSelect }, ref) => {
const { t } = useTranslation();
const isMention = item.kind === "comment_mention";
const title = isMention ? item.target.sessionTitle : item.payload.title;
- const summary = useMemo(() => {
- if (item.kind === "comment_mention") return item.payload.commentBody;
- if (item.payload.summary) return item.payload.summary;
+ const { meta, summary } = useMemo(() => {
+ if (item.kind === "comment_mention") {
+ return {
+ meta: item.actor.displayName,
+ summary: toCompactPreview(item.payload.commentBody),
+ };
+ }
const status = t(workItemStatusLabelKey(item.payload.status), {
defaultValue: humanizeToken(item.payload.status),
});
const priority = t(workItemPriorityLabelKey(item.payload.priority), {
defaultValue: humanizeToken(item.payload.priority),
});
- return t("teamInbox.row.assignedSummary", { status, priority });
+ return {
+ meta: `${status} · ${priority}`,
+ summary: item.payload.summary
+ ? toCompactPreview(item.payload.summary)
+ : "",
+ };
}, [item, t]);
- const personName = isMention
- ? item.actor.displayName
- : (item.payload.assigneeName ?? item.payload.assigneeMemberId);
const relativeTime = useMemo(
() => formatRelativeTime(item.occurredAt, "nano"),
[item.occurredAt]
@@ -54,7 +77,10 @@ const TeamInboxRow = forwardRef(
type="button"
role="option"
aria-selected={selected}
- aria-label={`${title},${readLabel}`}
+ aria-label={t("teamInbox.row.ariaLabel", {
+ title,
+ status: readLabel,
+ })}
tabIndex={selected ? 0 : -1}
data-testid="team-inbox-row"
data-item-kind={item.kind}
@@ -86,11 +112,16 @@ const TeamInboxRow = forwardRef(
{relativeTime}
-
- {summary}
-
-
- {personName}
+ {summary ? (
+
+ {summary}
+
+ ) : null}
+
+ {meta}
diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts
index 516b2ffde6..f06ab07eb1 100644
--- a/src/modules/MainApp/TeamInbox/domain/index.ts
+++ b/src/modules/MainApp/TeamInbox/domain/index.ts
@@ -33,6 +33,8 @@ export type {
TeamInboxDataSource,
TeamInboxFilter,
TeamInboxItem,
+ TeamInboxIssue,
+ TeamInboxIssueCode,
TeamInboxNavigationIntent,
TeamInboxPage,
TeamInboxTarget,
diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts
index 1a59324a18..a14f39d618 100644
--- a/src/modules/MainApp/TeamInbox/domain/types.ts
+++ b/src/modules/MainApp/TeamInbox/domain/types.ts
@@ -36,6 +36,8 @@ export interface CommentMentionItem extends TeamInboxItemBase {
payload: {
commentBody: string;
context?: string;
+ /** Structured cloud value; presentation localizes it at render time. */
+ threadCommentCount?: number;
commentCount: number;
};
}
@@ -66,6 +68,10 @@ export interface TeamInboxCursor {
export interface TeamInboxPage {
items: TeamInboxItem[];
nextCursor: TeamInboxCursor | null;
+ /** True when this snapshot was synchronously cleared for a new scope. */
+ loading?: boolean;
+ /** Non-fatal or fatal source condition associated with this snapshot. */
+ issue?: TeamInboxIssue | null;
/** Authoritative source totals; absent on lightweight/test data sources. */
unreadCounts?: {
all: number;
@@ -74,6 +80,17 @@ export interface TeamInboxPage {
};
}
+export type TeamInboxIssueCode =
+ | "identity_unresolved"
+ | "load_failed"
+ | "partial_load";
+
+export interface TeamInboxIssue {
+ code: TeamInboxIssueCode;
+ /** Diagnostic detail for logs/support; UI copy is derived from `code`. */
+ detail?: string;
+}
+
export interface ListTeamInboxInput {
cursor?: TeamInboxCursor | null;
limit?: number;
@@ -101,6 +118,11 @@ export interface TeamInboxDataSource {
*/
loadMore?(): Promise;
subscribe?(listener: () => void): () => void;
+ /**
+ * Reconciles a detail-side projection into the canonical list snapshot.
+ * `nextItem = null` removes an item that no longer belongs to this viewer.
+ */
+ reconcileItem?(itemKey: string, nextItem: TeamInboxItem | null): void;
}
export type TeamInboxNavigationIntent =
diff --git a/src/modules/MainApp/TeamInbox/store.ts b/src/modules/MainApp/TeamInbox/store.ts
index dfa6da566b..80d76c5480 100644
--- a/src/modules/MainApp/TeamInbox/store.ts
+++ b/src/modules/MainApp/TeamInbox/store.ts
@@ -1,6 +1,7 @@
import { atom } from "jotai";
import type { TeamInboxItem } from "./domain";
+import type { TeamInboxIssue } from "./domain";
import type { TeamInboxUnreadCounts } from "./domain";
export interface TeamInboxCacheState {
@@ -8,7 +9,7 @@ export interface TeamInboxCacheState {
unreadCount: number;
unreadCounts: TeamInboxUnreadCounts;
loading: boolean;
- error: string | null;
+ issue: TeamInboxIssue | null;
revision: number;
loadedForViewerKey: string | null;
/** True when either the local or cloud source still has a next page. */
@@ -20,7 +21,7 @@ export const teamInboxCacheAtom = atom({
unreadCount: 0,
unreadCounts: { all: 0, mentions: 0, assigned: 0 },
loading: false,
- error: null,
+ issue: null,
revision: 0,
loadedForViewerKey: null,
hasMore: false,
diff --git a/src/modules/MainApp/TeamInbox/teamInboxCoordinator.ts b/src/modules/MainApp/TeamInbox/teamInboxCoordinator.ts
new file mode 100644
index 0000000000..adc50adbb3
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/teamInboxCoordinator.ts
@@ -0,0 +1,884 @@
+import type { Store } from "jotai/vanilla/store";
+
+import type { MemberEntry } from "@src/api/http/project";
+import type {
+ TeamInboxMention,
+ TeamInboxMentionsPage,
+ TeamInboxReadMutation,
+} from "@src/features/Org2Cloud/teamInboxMentionsClient";
+import {
+ listInitialTeamInboxMentions,
+ listTeamInboxMentions,
+ markAllTeamInboxMentionsRead,
+ setTeamInboxMentionRead,
+} from "@src/features/Org2Cloud/teamInboxMentionsClient";
+
+import {
+ listLocalTeamInboxPage,
+ markAllLocalTeamInboxRead,
+ markLocalTeamInboxItemRead,
+ markLocalTeamInboxItemUnread,
+} from "./api";
+import {
+ dedupeTeamInboxItems,
+ getTeamInboxItemKey,
+ sortTeamInboxItems,
+} from "./domain";
+import type {
+ TeamInboxCursor,
+ TeamInboxFilter,
+ TeamInboxIssue,
+ TeamInboxItem,
+} from "./domain";
+import { teamInboxCacheAtom, teamInboxInvalidationAtom } from "./store";
+
+const MAX_CACHED_TEAM_INBOX_ITEMS = 500;
+const MAX_PENDING_TEAM_INBOX_MUTATIONS = 100;
+
+interface LocalPageResult {
+ page: {
+ items: TeamInboxItem[];
+ nextCursor: TeamInboxCursor | null;
+ };
+ unreadCount: number;
+}
+
+export interface TeamInboxCoordinatorDependencies {
+ listLocalPage(
+ viewerMemberIds: readonly string[],
+ filter: TeamInboxFilter,
+ cursor?: TeamInboxCursor | null
+ ): Promise;
+ listInitialMentions(
+ accessToken: string,
+ orgId: string,
+ limit: number,
+ signal?: AbortSignal
+ ): Promise;
+ listMentions(
+ accessToken: string,
+ orgId: string,
+ cursor: string | null,
+ limit: number,
+ signal?: AbortSignal
+ ): Promise;
+ markLocalRead(
+ viewerMemberIds: readonly string[],
+ itemId: string
+ ): Promise;
+ markLocalUnread(
+ viewerMemberIds: readonly string[],
+ itemId: string
+ ): Promise;
+ markAllLocalRead(
+ viewerMemberIds: readonly string[],
+ filter: TeamInboxFilter
+ ): Promise;
+ setMentionRead(
+ accessToken: string,
+ orgId: string,
+ commentId: string,
+ read: boolean,
+ signal?: AbortSignal
+ ): Promise;
+ markAllMentionsRead(
+ accessToken: string,
+ orgId: string,
+ signal?: AbortSignal
+ ): Promise;
+ now(): string;
+}
+
+export interface TeamInboxCoordinatorScope {
+ key: string;
+ viewerMemberIds: readonly string[];
+ accessToken: string | null;
+ activeCloudOrgId: string | null;
+ members: readonly MemberEntry[];
+ /** Degraded prerequisite reads (for example, a subset of member files). */
+ prerequisiteIssue?: TeamInboxIssue | null;
+}
+
+interface CoordinatorRuntime {
+ scopeKey: string;
+ generation: number;
+ scopeController: AbortController;
+ localCursor: TeamInboxCursor | null;
+ cloudCursor: string | null;
+ refreshPromise: Promise | null;
+ activeRefreshVersion: string | null;
+ desiredRefreshVersion: string | null;
+ queuedRefresh: { scope: TeamInboxCoordinatorScope; version: string } | null;
+ loadMorePromise: Promise | null;
+ mutationTail: Promise;
+ pendingMutations: number;
+ mutationEpoch: number;
+ mutationEpochByItem: Map;
+ invalidationQueued: boolean;
+}
+
+type Settled = { ok: true; value: T } | { ok: false; error: unknown };
+
+function settle(promise: Promise): Promise> {
+ return promise.then(
+ (value) => ({ ok: true, value }),
+ (error: unknown) => ({ ok: false, error })
+ );
+}
+
+function errorDetail(errors: readonly unknown[]): string | undefined {
+ const messages = errors
+ .map((error) => (error instanceof Error ? error.message : String(error)))
+ .filter(Boolean);
+ return messages.length > 0 ? messages.join(" · ") : undefined;
+}
+
+function issueForFailures(
+ failures: readonly unknown[],
+ requestedSourceCount: number
+): TeamInboxIssue | null {
+ if (failures.length === 0) return null;
+ return {
+ code:
+ failures.length >= requestedSourceCount ? "load_failed" : "partial_load",
+ detail: errorDetail(failures),
+ };
+}
+
+function mergeIssues(
+ primary: TeamInboxIssue | null,
+ secondary: TeamInboxIssue | null | undefined
+): TeamInboxIssue | null {
+ if (!primary) return secondary ?? null;
+ if (!secondary) return primary;
+ return {
+ code:
+ primary.code === "load_failed" || secondary.code === "load_failed"
+ ? "load_failed"
+ : primary.code === "identity_unresolved" ||
+ secondary.code === "identity_unresolved"
+ ? "identity_unresolved"
+ : "partial_load",
+ detail: errorDetail([primary.detail, secondary.detail].filter(Boolean)),
+ };
+}
+
+function prerequisiteIssueForScope(
+ scope: TeamInboxCoordinatorScope
+): TeamInboxIssue | null {
+ const identityIssue =
+ scope.viewerMemberIds.length === 0 && scope.members.length > 0
+ ? ({ code: "identity_unresolved" } as const)
+ : null;
+ return mergeIssues(identityIssue, scope.prerequisiteIssue);
+}
+
+function mapMentionsToItems(
+ mentions: readonly TeamInboxMention[],
+ activeCloudOrgId: string
+): TeamInboxItem[] {
+ return mentions.map((mention) => ({
+ id: `cloud-comment:${activeCloudOrgId}:${mention.comment.id}`,
+ kind: "comment_mention" as const,
+ occurredAt: mention.createdAt,
+ readAt: mention.readAt,
+ actor: {
+ id: mention.author.userId,
+ displayName: mention.author.displayName ?? mention.author.userId,
+ },
+ target: {
+ kind: "session_comment" as const,
+ sessionId: mention.session.id,
+ sessionTitle: mention.session.title ?? mention.session.id,
+ commentId: mention.comment.id,
+ threadId: mention.comment.parentId ?? mention.comment.id,
+ anchor: mention.comment.id,
+ },
+ payload: {
+ commentBody: mention.body,
+ commentCount: mention.commentCount,
+ threadCommentCount: mention.threadCount,
+ },
+ }));
+}
+
+function resolveAssigneeDisplayNames(
+ items: readonly TeamInboxItem[],
+ members: readonly MemberEntry[]
+): TeamInboxItem[] {
+ if (members.length === 0) return [...items];
+ const nameById = new Map(members.map((member) => [member.id, member.name]));
+ return items.map((item) => {
+ if (item.kind !== "assigned_work_item") return item;
+ const resolved = nameById.get(item.payload.assigneeMemberId);
+ if (!resolved || resolved === item.payload.assigneeName) return item;
+ return {
+ ...item,
+ payload: { ...item.payload, assigneeName: resolved },
+ };
+ });
+}
+
+function boundedItems(items: readonly TeamInboxItem[]): TeamInboxItem[] {
+ return sortTeamInboxItems(dedupeTeamInboxItems(items)).slice(
+ 0,
+ MAX_CACHED_TEAM_INBOX_ITEMS
+ );
+}
+
+function createRuntime(scopeKey: string): CoordinatorRuntime {
+ return {
+ scopeKey,
+ generation: 0,
+ scopeController: new AbortController(),
+ localCursor: null,
+ cloudCursor: null,
+ refreshPromise: null,
+ activeRefreshVersion: null,
+ desiredRefreshVersion: null,
+ queuedRefresh: null,
+ loadMorePromise: null,
+ mutationTail: Promise.resolve(),
+ pendingMutations: 0,
+ mutationEpoch: 0,
+ mutationEpochByItem: new Map(),
+ invalidationQueued: false,
+ };
+}
+
+/**
+ * Store-scoped Team Inbox coordinator.
+ *
+ * All mounted consumers in one Jotai store share request identity, cursors,
+ * mutation ordering and cancellation. Separate stores receive isolated runtime
+ * state through the WeakMap, while persisted/cache state remains in Jotai.
+ */
+export class TeamInboxCoordinator {
+ private readonly runtimeByStore = new WeakMap();
+
+ constructor(
+ private readonly dependencies: TeamInboxCoordinatorDependencies
+ ) {}
+
+ ensureScope(store: Store, scopeKey: string): CoordinatorRuntime {
+ const currentRuntime = this.runtimeByStore.get(store);
+ if (currentRuntime?.scopeKey === scopeKey) return currentRuntime;
+
+ currentRuntime?.scopeController.abort();
+ const runtime = createRuntime(scopeKey);
+ this.runtimeByStore.set(store, runtime);
+
+ const cache = store.get(teamInboxCacheAtom);
+ if (cache.loadedForViewerKey !== scopeKey) {
+ store.set(teamInboxCacheAtom, {
+ ...cache,
+ items: [],
+ unreadCount: 0,
+ unreadCounts: { all: 0, mentions: 0, assigned: 0 },
+ loading: true,
+ hasMore: false,
+ loadedForViewerKey: null,
+ issue: null,
+ revision: cache.revision + 1,
+ });
+ }
+ return runtime;
+ }
+
+ invalidate(store: Store): void {
+ const runtime = this.runtimeByStore.get(store);
+ if (runtime?.invalidationQueued) return;
+ if (runtime) runtime.invalidationQueued = true;
+ queueMicrotask(() => {
+ const latest = this.runtimeByStore.get(store);
+ if (latest) latest.invalidationQueued = false;
+ store.set(
+ teamInboxInvalidationAtom,
+ store.get(teamInboxInvalidationAtom) + 1
+ );
+ });
+ }
+
+ refresh(
+ store: Store,
+ scope: TeamInboxCoordinatorScope,
+ requestVersion: string
+ ): Promise {
+ const runtime = this.ensureScope(store, scope.key);
+ runtime.desiredRefreshVersion = requestVersion;
+
+ if (runtime.refreshPromise) {
+ if (runtime.activeRefreshVersion !== requestVersion) {
+ runtime.queuedRefresh = { scope, version: requestVersion };
+ }
+ return runtime.refreshPromise;
+ }
+ if (
+ runtime.activeRefreshVersion === requestVersion &&
+ store.get(teamInboxCacheAtom).loadedForViewerKey === scope.key
+ ) {
+ return Promise.resolve();
+ }
+
+ const generation = ++runtime.generation;
+ runtime.activeRefreshVersion = requestVersion;
+ store.set(teamInboxCacheAtom, (current) => ({
+ ...current,
+ loading: true,
+ issue: null,
+ }));
+
+ const canLoadLocal = scope.viewerMemberIds.length > 0;
+ const canLoadCloud = Boolean(scope.accessToken && scope.activeCloudOrgId);
+ if (!canLoadLocal && !canLoadCloud) {
+ runtime.localCursor = null;
+ runtime.cloudCursor = null;
+ store.set(teamInboxCacheAtom, (current) => ({
+ ...current,
+ items: [],
+ unreadCount: 0,
+ unreadCounts: { all: 0, mentions: 0, assigned: 0 },
+ loading: false,
+ hasMore: false,
+ loadedForViewerKey: scope.key,
+ issue: prerequisiteIssueForScope(scope),
+ revision: current.revision + 1,
+ }));
+ return Promise.resolve();
+ }
+
+ const requestedSourceCount = Number(canLoadLocal) + Number(canLoadCloud);
+ const promise = Promise.all([
+ canLoadLocal
+ ? settle(
+ this.dependencies.listLocalPage(scope.viewerMemberIds, "all", null)
+ )
+ : Promise.resolve>({
+ ok: true,
+ value: {
+ page: { items: [], nextCursor: null },
+ unreadCount: 0,
+ },
+ }),
+ canLoadCloud && scope.accessToken && scope.activeCloudOrgId
+ ? settle(
+ this.dependencies.listInitialMentions(
+ scope.accessToken,
+ scope.activeCloudOrgId,
+ 50,
+ runtime.scopeController.signal
+ )
+ )
+ : Promise.resolve>({
+ ok: true,
+ value: { mentions: [], unreadCount: 0 },
+ }),
+ ])
+ .then(([local, cloud]) => {
+ const currentRuntime = this.runtimeByStore.get(store);
+ if (
+ currentRuntime !== runtime ||
+ generation !== runtime.generation ||
+ runtime.scopeController.signal.aborted ||
+ runtime.desiredRefreshVersion !== requestVersion
+ ) {
+ return;
+ }
+
+ const previous = store.get(teamInboxCacheAtom);
+ const sameScope = previous.loadedForViewerKey === scope.key;
+ const previousLocal = sameScope
+ ? previous.items.filter((item) => item.kind === "assigned_work_item")
+ : [];
+ const previousCloud = sameScope
+ ? previous.items.filter((item) => item.kind === "comment_mention")
+ : [];
+ const failures = [
+ ...(local.ok ? [] : [local.error]),
+ ...(cloud.ok ? [] : [cloud.error]),
+ ];
+
+ const localItems = local.ok ? local.value.page.items : previousLocal;
+ const cloudItems = cloud.ok
+ ? mapMentionsToItems(
+ cloud.value.mentions,
+ scope.activeCloudOrgId ?? ""
+ )
+ : previousCloud;
+ const localUnread = local.ok
+ ? local.value.unreadCount
+ : sameScope
+ ? previous.unreadCounts.assigned
+ : 0;
+ const cloudUnread = cloud.ok
+ ? cloud.value.unreadCount
+ : sameScope
+ ? previous.unreadCounts.mentions
+ : 0;
+
+ if (local.ok) runtime.localCursor = local.value.page.nextCursor;
+ if (cloud.ok) runtime.cloudCursor = cloud.value.nextCursor ?? null;
+
+ const items = boundedItems(
+ resolveAssigneeDisplayNames(
+ [...cloudItems, ...localItems],
+ scope.members
+ )
+ );
+ if (items.length >= MAX_CACHED_TEAM_INBOX_ITEMS) {
+ runtime.localCursor = null;
+ runtime.cloudCursor = null;
+ }
+ store.set(teamInboxCacheAtom, (current) => ({
+ ...current,
+ items,
+ unreadCount: localUnread + cloudUnread,
+ unreadCounts: {
+ all: localUnread + cloudUnread,
+ mentions: cloudUnread,
+ assigned: localUnread,
+ },
+ loading: false,
+ issue: mergeIssues(
+ issueForFailures(failures, requestedSourceCount),
+ prerequisiteIssueForScope(scope)
+ ),
+ loadedForViewerKey: scope.key,
+ hasMore: Boolean(runtime.localCursor || runtime.cloudCursor),
+ revision: current.revision + 1,
+ }));
+ })
+ .finally(() => {
+ if (runtime.refreshPromise === promise) {
+ runtime.refreshPromise = null;
+ }
+ const queued = runtime.queuedRefresh;
+ runtime.queuedRefresh = null;
+ if (
+ queued &&
+ this.runtimeByStore.get(store) === runtime &&
+ !runtime.scopeController.signal.aborted
+ ) {
+ void this.refresh(store, queued.scope, queued.version);
+ }
+ });
+ runtime.refreshPromise = promise;
+ return promise;
+ }
+
+ loadMore(store: Store, scope: TeamInboxCoordinatorScope): Promise {
+ const runtime = this.ensureScope(store, scope.key);
+ if (runtime.loadMorePromise) return runtime.loadMorePromise;
+ const localCursor = runtime.localCursor;
+ const cloudCursor = runtime.cloudCursor;
+ if (!localCursor && !cloudCursor) return Promise.resolve();
+
+ const generation = runtime.generation;
+ const requestedSourceCount =
+ Number(Boolean(localCursor)) + Number(Boolean(cloudCursor));
+ const promise = Promise.all([
+ localCursor
+ ? settle(
+ this.dependencies.listLocalPage(
+ scope.viewerMemberIds,
+ "all",
+ localCursor
+ )
+ )
+ : Promise.resolve>({
+ ok: true,
+ value: {
+ page: { items: [], nextCursor: null },
+ unreadCount: store.get(teamInboxCacheAtom).unreadCounts.assigned,
+ },
+ }),
+ cloudCursor && scope.accessToken && scope.activeCloudOrgId
+ ? settle(
+ this.dependencies.listMentions(
+ scope.accessToken,
+ scope.activeCloudOrgId,
+ cloudCursor,
+ 50,
+ runtime.scopeController.signal
+ )
+ )
+ : Promise.resolve>({
+ ok: true,
+ value: {
+ mentions: [],
+ unreadCount: store.get(teamInboxCacheAtom).unreadCounts.mentions,
+ },
+ }),
+ ])
+ .then(([local, cloud]) => {
+ if (
+ this.runtimeByStore.get(store) !== runtime ||
+ generation !== runtime.generation ||
+ runtime.scopeController.signal.aborted
+ ) {
+ return;
+ }
+ const failures = [
+ ...(local.ok ? [] : [local.error]),
+ ...(cloud.ok ? [] : [cloud.error]),
+ ];
+ if (localCursor && local.ok) {
+ runtime.localCursor = local.value.page.nextCursor;
+ }
+ if (cloudCursor && cloud.ok) {
+ runtime.cloudCursor = cloud.value.nextCursor ?? null;
+ }
+ const appended = resolveAssigneeDisplayNames(
+ [
+ ...(cloud.ok
+ ? mapMentionsToItems(
+ cloud.value.mentions,
+ scope.activeCloudOrgId ?? ""
+ )
+ : []),
+ ...(local.ok ? local.value.page.items : []),
+ ],
+ scope.members
+ );
+ store.set(teamInboxCacheAtom, (current) => {
+ const items = boundedItems([...current.items, ...appended]);
+ if (items.length >= MAX_CACHED_TEAM_INBOX_ITEMS) {
+ runtime.localCursor = null;
+ runtime.cloudCursor = null;
+ }
+ const assigned = local.ok
+ ? local.value.unreadCount
+ : current.unreadCounts.assigned;
+ const mentions = cloud.ok
+ ? cloud.value.unreadCount
+ : current.unreadCounts.mentions;
+ return {
+ ...current,
+ items,
+ unreadCount: assigned + mentions,
+ unreadCounts: {
+ all: assigned + mentions,
+ assigned,
+ mentions,
+ },
+ issue: mergeIssues(
+ issueForFailures(failures, requestedSourceCount),
+ prerequisiteIssueForScope(scope)
+ ),
+ hasMore: Boolean(runtime.localCursor || runtime.cloudCursor),
+ revision: current.revision + 1,
+ };
+ });
+ if (failures.length >= requestedSourceCount) {
+ throw new Error(errorDetail(failures) ?? "Team Inbox load failed");
+ }
+ })
+ .finally(() => {
+ if (runtime.loadMorePromise === promise) {
+ runtime.loadMorePromise = null;
+ }
+ });
+ runtime.loadMorePromise = promise;
+ return promise;
+ }
+
+ markRead(
+ store: Store,
+ scope: TeamInboxCoordinatorScope,
+ item: TeamInboxItem
+ ): Promise {
+ return this.setReadState(store, scope, item, true);
+ }
+
+ markUnread(
+ store: Store,
+ scope: TeamInboxCoordinatorScope,
+ item: TeamInboxItem
+ ): Promise {
+ return this.setReadState(store, scope, item, false);
+ }
+
+ private setReadState(
+ store: Store,
+ scope: TeamInboxCoordinatorScope,
+ item: TeamInboxItem,
+ read: boolean
+ ): Promise {
+ const runtime = this.ensureScope(store, scope.key);
+ const itemKey = getTeamInboxItemKey(item);
+ const epoch = ++runtime.mutationEpoch;
+ runtime.mutationEpochByItem.set(itemKey, epoch);
+ this.patchReadState(
+ store,
+ scope.key,
+ itemKey,
+ read ? this.dependencies.now() : null
+ );
+
+ return this.enqueueMutation(runtime, async () => {
+ try {
+ let cloudResult: TeamInboxReadMutation | null = null;
+ if (item.kind === "comment_mention") {
+ if (!scope.accessToken || !scope.activeCloudOrgId) {
+ throw new Error("Cloud identity is unavailable");
+ }
+ cloudResult = await this.dependencies.setMentionRead(
+ scope.accessToken,
+ scope.activeCloudOrgId,
+ item.target.commentId,
+ read,
+ runtime.scopeController.signal
+ );
+ } else {
+ const updated = read
+ ? await this.dependencies.markLocalRead(
+ scope.viewerMemberIds,
+ item.id
+ )
+ : await this.dependencies.markLocalUnread(
+ scope.viewerMemberIds,
+ item.id
+ );
+ if (!updated)
+ throw new Error("Assigned Work Item is no longer visible");
+ }
+ if (
+ this.runtimeByStore.get(store) !== runtime ||
+ runtime.scopeController.signal.aborted ||
+ runtime.mutationEpochByItem.get(itemKey) !== epoch
+ ) {
+ return;
+ }
+ if (cloudResult) {
+ const authoritativeReadAt = read
+ ? (cloudResult.readAt ?? this.dependencies.now())
+ : null;
+ this.patchReadState(
+ store,
+ scope.key,
+ itemKey,
+ authoritativeReadAt,
+ cloudResult.unreadCount
+ );
+ }
+ } catch (error) {
+ if (
+ this.runtimeByStore.get(store) === runtime &&
+ !runtime.scopeController.signal.aborted &&
+ runtime.mutationEpochByItem.get(itemKey) === epoch
+ ) {
+ this.patchReadState(
+ store,
+ scope.key,
+ itemKey,
+ read ? null : item.readAt
+ );
+ }
+ throw error;
+ } finally {
+ if (runtime.mutationEpochByItem.get(itemKey) === epoch) {
+ runtime.mutationEpochByItem.delete(itemKey);
+ }
+ }
+ });
+ }
+
+ markAllRead(
+ store: Store,
+ scope: TeamInboxCoordinatorScope,
+ filter: TeamInboxFilter
+ ): Promise {
+ const runtime = this.ensureScope(store, scope.key);
+ return this.enqueueMutation(runtime, async () => {
+ const includeAssigned = filter === "all" || filter === "assigned";
+ const includeMentions = filter === "all" || filter === "mentions";
+ const before = store.get(teamInboxCacheAtom);
+ const [local, cloud] = await Promise.all([
+ includeAssigned && before.unreadCounts.assigned > 0
+ ? settle(
+ this.dependencies.markAllLocalRead(
+ scope.viewerMemberIds,
+ "assigned"
+ )
+ )
+ : Promise.resolve>({ ok: true, value: 0 }),
+ includeMentions && before.unreadCounts.mentions > 0
+ ? scope.accessToken && scope.activeCloudOrgId
+ ? settle(
+ this.dependencies.markAllMentionsRead(
+ scope.accessToken,
+ scope.activeCloudOrgId,
+ runtime.scopeController.signal
+ )
+ )
+ : Promise.resolve>({
+ ok: false,
+ error: new Error("Cloud identity is unavailable"),
+ })
+ : Promise.resolve>({
+ ok: true,
+ value: { readAt: null, unreadCount: 0 },
+ }),
+ ]);
+ if (
+ this.runtimeByStore.get(store) !== runtime ||
+ runtime.scopeController.signal.aborted
+ ) {
+ return;
+ }
+ const readAt = cloud.ok
+ ? (cloud.value.readAt ?? this.dependencies.now())
+ : this.dependencies.now();
+ store.set(teamInboxCacheAtom, (current) => {
+ const assigned =
+ includeAssigned && local.ok ? 0 : current.unreadCounts.assigned;
+ const mentions =
+ includeMentions && cloud.ok
+ ? cloud.value.unreadCount
+ : current.unreadCounts.mentions;
+ return {
+ ...current,
+ items: current.items.map((candidate) => {
+ const shouldMark =
+ (includeAssigned &&
+ local.ok &&
+ candidate.kind === "assigned_work_item") ||
+ (includeMentions &&
+ cloud.ok &&
+ candidate.kind === "comment_mention");
+ return shouldMark ? { ...candidate, readAt } : candidate;
+ }),
+ unreadCount: assigned + mentions,
+ unreadCounts: { all: assigned + mentions, assigned, mentions },
+ revision: current.revision + 1,
+ };
+ });
+ const failures = [
+ ...(local.ok ? [] : [local.error]),
+ ...(cloud.ok ? [] : [cloud.error]),
+ ];
+ if (failures.length > 0) {
+ throw new Error(errorDetail(failures) ?? "Team Inbox update failed");
+ }
+ });
+ }
+
+ reconcileItem(
+ store: Store,
+ scopeKey: string,
+ itemKey: string,
+ nextItem: TeamInboxItem | null
+ ): void {
+ store.set(teamInboxCacheAtom, (current) => {
+ if (current.loadedForViewerKey !== scopeKey) return current;
+ const previousItem = current.items.find(
+ (candidate) => getTeamInboxItemKey(candidate) === itemKey
+ );
+ const nextItems = current.items.flatMap((candidate) =>
+ getTeamInboxItemKey(candidate) === itemKey
+ ? nextItem
+ ? [nextItem]
+ : []
+ : [candidate]
+ );
+ const previousUnread = previousItem?.readAt === null ? 1 : 0;
+ const nextUnread = nextItem?.readAt === null ? 1 : 0;
+ const unreadDelta = nextUnread - previousUnread;
+ const assigned =
+ previousItem?.kind === "assigned_work_item" ||
+ nextItem?.kind === "assigned_work_item"
+ ? Math.max(0, current.unreadCounts.assigned + unreadDelta)
+ : current.unreadCounts.assigned;
+ const mentions =
+ previousItem?.kind === "comment_mention" ||
+ nextItem?.kind === "comment_mention"
+ ? Math.max(0, current.unreadCounts.mentions + unreadDelta)
+ : current.unreadCounts.mentions;
+ return {
+ ...current,
+ items: boundedItems(nextItems),
+ unreadCount: assigned + mentions,
+ unreadCounts: { all: assigned + mentions, assigned, mentions },
+ revision: current.revision + 1,
+ };
+ });
+ }
+
+ private enqueueMutation(
+ runtime: CoordinatorRuntime,
+ operation: () => Promise
+ ): Promise {
+ if (runtime.pendingMutations >= MAX_PENDING_TEAM_INBOX_MUTATIONS) {
+ return Promise.reject(new Error("Too many pending Team Inbox updates"));
+ }
+ runtime.pendingMutations += 1;
+ const run = async (): Promise => {
+ try {
+ return await operation();
+ } finally {
+ runtime.pendingMutations = Math.max(0, runtime.pendingMutations - 1);
+ }
+ };
+ const result = runtime.mutationTail.then(run, run);
+ runtime.mutationTail = result.then(
+ () => undefined,
+ () => undefined
+ );
+ return result;
+ }
+
+ private patchReadState(
+ store: Store,
+ scopeKey: string,
+ itemKey: string,
+ readAt: string | null,
+ authoritativeMentionUnread?: number
+ ): void {
+ store.set(teamInboxCacheAtom, (current) => {
+ if (current.loadedForViewerKey !== scopeKey) return current;
+ const candidate = current.items.find(
+ (item) => getTeamInboxItemKey(item) === itemKey
+ );
+ if (!candidate) return current;
+ const wasUnread = candidate.readAt === null;
+ const willBeUnread = readAt === null;
+ const delta = Number(willBeUnread) - Number(wasUnread);
+ const assigned =
+ candidate.kind === "assigned_work_item"
+ ? Math.max(0, current.unreadCounts.assigned + delta)
+ : current.unreadCounts.assigned;
+ const mentions =
+ candidate.kind === "comment_mention"
+ ? (authoritativeMentionUnread ??
+ Math.max(0, current.unreadCounts.mentions + delta))
+ : current.unreadCounts.mentions;
+ return {
+ ...current,
+ items: current.items.map((item) =>
+ getTeamInboxItemKey(item) === itemKey ? { ...item, readAt } : item
+ ),
+ unreadCount: assigned + mentions,
+ unreadCounts: { all: assigned + mentions, assigned, mentions },
+ revision: current.revision + 1,
+ };
+ });
+ }
+}
+
+const productionDependencies: TeamInboxCoordinatorDependencies = {
+ listLocalPage: listLocalTeamInboxPage,
+ listInitialMentions: listInitialTeamInboxMentions,
+ listMentions: listTeamInboxMentions,
+ markLocalRead: markLocalTeamInboxItemRead,
+ markLocalUnread: markLocalTeamInboxItemUnread,
+ markAllLocalRead: markAllLocalTeamInboxRead,
+ setMentionRead: setTeamInboxMentionRead,
+ markAllMentionsRead: markAllTeamInboxMentionsRead,
+ now: () => new Date().toISOString(),
+};
+
+export const teamInboxCoordinator = new TeamInboxCoordinator(
+ productionDependencies
+);
+
+export const TEAM_INBOX_CACHE_LIMIT = MAX_CACHED_TEAM_INBOX_ITEMS;
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
index 00be22929f..5f909fa5f8 100644
--- a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
@@ -1,12 +1,5 @@
-import { useAtomValue, useSetAtom } from "jotai";
-import {
- useCallback,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { useAtomValue, useStore } from "jotai";
+import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { invalidateProjectCache, projectApi } from "@src/api/http/project";
import type { MemberEntry } from "@src/api/http/project";
@@ -19,131 +12,92 @@ import {
orgCommentsKey,
} from "@src/features/Org2Cloud/org2CloudCommentsBus";
import { sidebarActiveCloudOrgIdAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom";
-import {
- type TeamInboxMention,
- listInitialTeamInboxMentions,
- listTeamInboxMentions,
- markAllTeamInboxMentionsRead,
- setTeamInboxMentionRead,
-} from "@src/features/Org2Cloud/teamInboxMentionsClient";
+import { createLogger } from "@src/hooks/logger";
import { useProjectDataChanged } from "@src/hooks/project";
import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId";
-import {
- listLocalTeamInboxPage,
- markAllLocalTeamInboxRead,
- markLocalTeamInboxItemRead,
- markLocalTeamInboxItemUnread,
-} from "./api";
-import { dedupeTeamInboxItems } from "./domain";
import type {
- TeamInboxCursor,
TeamInboxDataSource,
TeamInboxFilter,
+ TeamInboxIssue,
TeamInboxItem,
} from "./domain";
+import { teamInboxCacheAtom, teamInboxInvalidationAtom } from "./store";
import {
- invalidateTeamInboxAtom,
- teamInboxCacheAtom,
- teamInboxInvalidationAtom,
-} from "./store";
+ type TeamInboxCoordinatorScope,
+ teamInboxCoordinator,
+} from "./teamInboxCoordinator";
-const listeners = new Set<() => void>();
-const MAX_PENDING_TEAM_INBOX_MUTATIONS = 100;
-let membersRequest: Promise | null = null;
-let inboxRequest: {
- key: string;
- promise: Promise<{
- mentionItems: TeamInboxItem[];
- localItems: TeamInboxItem[];
- localUnread: number;
- cloudUnread: number;
- localNextCursor: TeamInboxCursor | null;
- cloudNextCursor: string | null;
- }>;
-} | null = null;
+const log = createLogger("TeamInboxDataSource");
+const MEMBER_READ_CONCURRENCY = 8;
-const EMPTY_CLOUD_MENTION_PAGE = {
- mentions: [],
- nextCursor: undefined,
- unreadCount: 0,
-} as const;
-
-function notifyTeamInboxListeners(): void {
- for (const listener of listeners) listener();
+interface MemberSnapshot {
+ members: MemberEntry[];
+ issue: TeamInboxIssue | null;
}
-/**
- * Maps the server-authoritative cloud mention projection into Team Inbox
- * items. Shared by initial load and pagination so both paths stay identical.
- */
-function mapMentionsToItems(
- mentions: readonly TeamInboxMention[],
- activeCloudOrgId: string
-): TeamInboxItem[] {
- return mentions.map((mention) => {
- const itemId = `cloud-comment:${activeCloudOrgId}:${mention.comment.id}`;
- return {
- id: itemId,
- kind: "comment_mention" as const,
- occurredAt: mention.createdAt,
- readAt: mention.readAt,
- actor: {
- id: mention.author.userId,
- displayName: mention.author.displayName ?? "Team member",
- },
- target: {
- kind: "session_comment" as const,
- sessionId: mention.session.id,
- sessionTitle: mention.session.title ?? "Session",
- commentId: mention.comment.id,
- threadId: mention.comment.parentId ?? mention.comment.id,
- anchor: mention.comment.id,
- },
- payload: {
- commentBody: mention.body,
- commentCount: mention.commentCount,
- context: `${mention.threadCount} thread comments`,
- },
- };
- });
-}
+const EMPTY_MEMBER_SNAPSHOT: MemberSnapshot = {
+ members: [],
+ issue: null,
+};
-/**
- * Resolves each assigned item's display name from its stable `assigneeMemberId`
- * into the optional `assigneeName` field. When the member cannot be resolved the
- * name is left unset and consumers fall back to the id, so a row never renders
- * blank.
- */
-function resolveAssigneeDisplayNames(
- items: readonly TeamInboxItem[],
- members: readonly MemberEntry[]
-): TeamInboxItem[] {
- if (members.length === 0) return [...items];
- const nameById = new Map(members.map((member) => [member.id, member.name]));
- return items.map((item) => {
- if (item.kind !== "assigned_work_item") return item;
- const resolved = nameById.get(item.payload.assigneeMemberId);
- if (!resolved || resolved === item.payload.assigneeName) return item;
- return {
- ...item,
- payload: { ...item.payload, assigneeName: resolved },
- };
- });
-}
+let membersRequest: Promise | null = null;
-async function readAllProjectMembers(): Promise {
+async function readAllProjectMembers(): Promise