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 000000000..609000296
--- /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: 12 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 000000000..31a98b45b
--- /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 32b2484c1..6321884d5 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 f5a6cd95a..b016f9631 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 000000000..10fd20c5c
--- /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 000000000..a20247eaa
--- /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 000000000..ab4047808
--- /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 000000000..6c24afa73
--- /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 000000000..f8841e22a
--- /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 000000000..97263b4aa
--- /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-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc
index 97ea4390f..bd528ec91 100644
--- a/src-tauri/src/commands/handler_list.inc
+++ b/src-tauri/src/commands/handler_list.inc
@@ -587,6 +587,12 @@ project_management::projects::commands::project_allocate_work_item_id,
project_management::projects::commands::work_item_allocate_standalone_id,
project_management::projects::commands::project_batch_delete_work_items,
project_management::projects::commands::project_batch_update_work_items,
+// Team Inbox read model (viewer member IDs are explicit at the IPC boundary).
+project_management::team_inbox::commands::team_inbox_list_page,
+project_management::team_inbox::commands::team_inbox_unread_count,
+project_management::team_inbox::commands::team_inbox_mark_read,
+project_management::team_inbox::commands::team_inbox_mark_all_read,
+project_management::team_inbox::commands::team_inbox_mark_unread,
project_management::projects::commands::project_list_routines,
project_management::projects::commands::project_read_routine,
project_management::projects::commands::project_upsert_routine,
diff --git a/src/components/ResizableSplitPanel/index.tsx b/src/components/ResizableSplitPanel/index.tsx
index 75e7d8cc0..dbb3213fb 100644
--- a/src/components/ResizableSplitPanel/index.tsx
+++ b/src/components/ResizableSplitPanel/index.tsx
@@ -94,7 +94,10 @@ const ResizableSplitPanel: React.FC = ({
const effectiveMin = maxRightWidth
? Math.max(minLeftWidth, containerWidth - maxRightWidth)
: minLeftWidth;
- return { min: effectiveMin, max: effectiveMax };
+ // When the container is too narrow to honour both minimums, the bounds
+ // invert and the clamp would collapse the right panel toward zero. Keep
+ // the right panel's reserve and let the left panel give way instead.
+ return { min: Math.min(effectiveMin, effectiveMax), max: effectiveMax };
}, [minLeftWidth, maxLeftWidth, minRightWidth, maxRightWidth]);
/**
@@ -246,6 +249,32 @@ const ResizableSplitPanel: React.FC = ({
onSplitChange,
]);
+ /**
+ * Keep the committed width inside the *live* constraints.
+ *
+ * The drag handler clamps only while a drag is in flight, so a width chosen
+ * in a wide container survives the container shrinking. Because the right
+ * panel is `flex-1 min-w-0`, that stale width starves it to zero and the
+ * left panel reads as maximized. Re-clamping on container resize keeps
+ * `minRightWidth` honoured however the container got smaller.
+ */
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const resizeObserver = new ResizeObserver(() => {
+ if (isResizingRef.current) return;
+ const { min, max } = getEffectiveConstraints();
+ setLeftWidth((current) => {
+ const clamped = Math.max(min, Math.min(max, current));
+ return clamped === current ? current : clamped;
+ });
+ });
+
+ resizeObserver.observe(container);
+ return () => resizeObserver.disconnect();
+ }, [getEffectiveConstraints]);
+
// Width change handler for context menu — updates internal state + notifies parent
const handleContextMenuWidthChange = useCallback(
(width: number) => {
@@ -283,6 +312,11 @@ const ResizableSplitPanel: React.FC = ({
className={`relative flex-shrink-0 overflow-hidden ${leftPanelClassName}`.trim()}
style={{
width: `${leftWidth}px`,
+ // Declarative ceiling: the JS clamp only runs during a drag and on
+ // container resize, so a width committed by any other path (stale
+ // state, context menu, a parent changing the bounds) could still
+ // starve the right panel. CSS enforces the cap unconditionally.
+ maxWidth: `${maxLeftWidth}px`,
contain: "layout style",
display: leftWidth === 0 ? "none" : "block",
}}
diff --git a/src/config/detailPanelTokens.ts b/src/config/detailPanelTokens.ts
index f07d393b0..bb8016d0a 100644
--- a/src/config/detailPanelTokens.ts
+++ b/src/config/detailPanelTokens.ts
@@ -45,6 +45,8 @@ export const COLLAPSIBLE_SECTION_TOKENS = {
export const INFO_CARD_TOKENS = {
/** Card container — rounded, fill background, no border (matches Settings table containers) */
container: "rounded-lg bg-surface-selected p-4",
+ /** Unfilled container — rows sit directly on a panel that paints its own surface */
+ containerPlain: "rounded-lg",
/** Grid gap between rows */
rowGap: "gap-3",
/** Row layout */
diff --git a/src/engines/ChatPanel/ChatPanelTabBar.tsx b/src/engines/ChatPanel/ChatPanelTabBar.tsx
index 86bfc61af..3d072e8c6 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,
@@ -184,6 +185,7 @@ const TabPill = memo(function TabPill({
const displayTitle = resolveChatPanelTabDisplayTitle(tab, session, {
launchpad: t("navigation:routes.launchpad"),
runtime: t("sessions:chat.startPage.tabs.runtime"),
+ teamInbox: t("navigation:labels.teamInbox", "Team Inbox"),
organization: t("navigation:collaboration.manageOrg"),
workManagement: {
kanban: t("sessions:simulator.tabs.kanban"),
@@ -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 aa48860a3..409eeb745 100644
--- a/src/engines/ChatPanel/chatPanelTabDisplay.test.ts
+++ b/src/engines/ChatPanel/chatPanelTabDisplay.test.ts
@@ -12,6 +12,7 @@ import {
const labels: ChatPanelTabDisplayLabels = {
launchpad: "Launchpad",
runtime: "Runtime",
+ teamInbox: "Team Inbox",
organization: "Manage ORG",
workManagement: {
kanban: "Kanban",
@@ -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 6d0ce73ca..a423ad1f5 100644
--- a/src/engines/ChatPanel/chatPanelTabDisplay.ts
+++ b/src/engines/ChatPanel/chatPanelTabDisplay.ts
@@ -7,6 +7,7 @@ import { stripPillReferences } from "@src/util/session/stripPillReferences";
export interface ChatPanelTabDisplayLabels {
launchpad: string;
runtime: string;
+ teamInbox: string;
organization: string;
workManagement: {
kanban: 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/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
index f1522a065..73a1d61a5 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";
@@ -104,70 +104,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,
diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts
new file mode 100644
index 000000000..0618277eb
--- /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 000000000..c298e0edc
--- /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/hooks/ui/sidebar/useSidebarState.ts b/src/hooks/ui/sidebar/useSidebarState.ts
index 6d30b1ddc..127a4d0bf 100644
--- a/src/hooks/ui/sidebar/useSidebarState.ts
+++ b/src/hooks/ui/sidebar/useSidebarState.ts
@@ -3,9 +3,9 @@
*
* Manages the single global sidebar: width, collapse, drag-to-resize,
* and preference persistence. Width is user-driven only — the sidebar
- * no longer auto-collapses or re-clamps on window resize. Narrow
- * viewports are handled by `useNarrowChatFocus`, which maximizes the
- * chat panel instead of squeezing the sidebar.
+ * no longer auto-collapses or re-clamps on window resize, and narrow
+ * viewports no longer adapt the layout automatically; the chat panel is
+ * maximized only by its explicit toggle.
*
* Drag listeners are attached synchronously in handleMouseDown (not via
* useEffect) so there is zero render-cycle delay. This also avoids
@@ -52,9 +52,7 @@ export interface UseSidebarStateReturn {
export function useSidebarState(): UseSidebarStateReturn {
// Sidebar width is user-driven only: we no longer auto-shrink the max
// width on window resize, so the user's chosen width stays stable
- // until they drag the handle themselves. See `useNarrowChatFocus` for
- // the narrow-viewport adaptation — it covers the missing chrome by
- // maximizing the chat panel instead of squeezing the sidebar.
+ // until they drag the handle themselves.
const maxWidth = MAX_SIDEBAR_WIDTH;
// Global state — split read/write for isDragging so setter is stable
diff --git a/src/hooks/workStation/index.ts b/src/hooks/workStation/index.ts
index 6abb0127d..aeb2244fb 100644
--- a/src/hooks/workStation/index.ts
+++ b/src/hooks/workStation/index.ts
@@ -49,8 +49,3 @@ export type { WorkStationTabShortcutBridgeOptions } from "./useWorkStationTabSho
export { usePublishWorkstationTabHeader } from "./useWorkstationTabHeader";
export type { WorkstationTabHeaderHost } from "./useWorkstationTabHeader";
-
-export {
- useNarrowChatFocus,
- NARROW_CHAT_FOCUS_BREAKPOINT_PX,
-} from "./useNarrowChatFocus";
diff --git a/src/hooks/workStation/useNarrowChatFocus.test.ts b/src/hooks/workStation/useNarrowChatFocus.test.ts
deleted file mode 100644
index cfa9a4e26..000000000
--- a/src/hooks/workStation/useNarrowChatFocus.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { resolveWorkbenchEvaluationWidth } from "./useNarrowChatFocus";
-
-describe("resolveWorkbenchEvaluationWidth", () => {
- it("uses the projected target width during programmatic reopening", () => {
- expect(
- resolveWorkbenchEvaluationWidth({
- chatPanelDragging: false,
- chatPanelMaximized: false,
- chatVisible: true,
- chatWidth: 520,
- mainContentWidth: 1280,
- measuredWorkbenchWidth: 24,
- })
- ).toBe(760);
- });
-
- it("uses the projected target width while chat is maximized", () => {
- expect(
- resolveWorkbenchEvaluationWidth({
- chatPanelDragging: false,
- chatPanelMaximized: true,
- chatVisible: true,
- chatWidth: 520,
- mainContentWidth: 1280,
- measuredWorkbenchWidth: 0,
- })
- ).toBe(760);
- });
-
- it("uses the measured width during direct chat resizing", () => {
- expect(
- resolveWorkbenchEvaluationWidth({
- chatPanelDragging: true,
- chatPanelMaximized: false,
- chatVisible: true,
- chatWidth: 520,
- mainContentWidth: 1280,
- measuredWorkbenchWidth: 472,
- })
- ).toBe(472);
- });
-});
diff --git a/src/hooks/workStation/useNarrowChatFocus.ts b/src/hooks/workStation/useNarrowChatFocus.ts
deleted file mode 100644
index c61b7cbe0..000000000
--- a/src/hooks/workStation/useNarrowChatFocus.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-/**
- * Auto-maximize the docked chat panel when the WorkStation / Agent
- * Station *workbench* (the area to the right of the global sidebar
- * and to the left of the docked chat panel — where the editor /
- * browser / launchpad / kanban / agent surface actually renders) is
- * too narrow to be usable.
- *
- * The breakpoint deliberately tracks the workbench width, NOT the OS
- * window width and NOT the full content column to the right of the
- * sidebar. So:
- *
- * - Dragging the chat handle wider shrinks the workbench → can flip
- * into a maximized chat slot once it crosses below the breakpoint.
- * - Collapsing the sidebar widens the workbench → can flip the chat
- * slot back out of maximized.
- * - Resizing the OS window changes everything proportionally, so it
- * feels "window-driven" too — but only because the workbench is a
- * downstream of those layout choices.
- *
- * Below `NARROW_CHAT_FOCUS_BREAKPOINT_PX`, `chatPanelMaximizedAtom`
- * is forced to `true` (the same maximized layout the toolbar's
- * maximize button produces). When the workbench grows back above the
- * breakpoint, the flag is cleared — but only if it was *this* hook
- * that set it on the last wide→narrow edge. Manual maximize /
- * un-maximize actions taken while narrow are preserved across the
- * next resize.
- *
- * Width sources (two of them, switched on direct manipulation):
- *
- * - While the user drags the chat divider, `[data-workbench-surface]`
- * provides the live width because the CSS variable intentionally leads
- * the persisted chat-width atom.
- *
- * - At rest or during programmatic pane motion, derive the projected target
- * width from `[data-main-content].contentRect.width - chatWidth`. This
- * keeps animated intermediate widths from looking like a genuine narrow
- * viewport while still shrinking inline native webviews with the real
- * flex track.
- */
-import { useAtomValue, useSetAtom } from "jotai";
-import { useEffect, useRef } from "react";
-
-import {
- chatPanelDraggingAtom,
- chatPanelMaximizedAtom,
- chatVisibleAtom,
- chatWidthAtom,
-} from "@src/store/ui/chatPanelAtom";
-
-/**
- * Below this *workbench* width the chat panel takes over the entire
- * content area. Calibrated so the workbench enters the maximized
- * layout once it gets narrower than a phone-sized strip — i.e. it
- * can no longer usefully host the editor / launchpad / etc.
- * alongside the docked chat. Tune here if the workbench surfaces
- * become more or less tolerant of narrow widths.
- */
-export const NARROW_CHAT_FOCUS_BREAKPOINT_PX = 480;
-
-/**
- * Selector for the workbench surface: the children-wrapping div that
- * does NOT include the docked chat panel.
- */
-const WORKBENCH_SELECTOR = "[data-workbench-surface]";
-
-/**
- * Selector for the main content column (sidebar's flex sibling). Its
- * inner content box (`contentRect.width`) is what we use to derive a
- * projected workbench width while the maximized layout distorts the
- * workbench surface itself.
- */
-const MAIN_CONTENT_SELECTOR = "[data-main-content]";
-
-/**
- * ResizeObserver can only attach to existing elements. The AppShell can mount
- * before the WorkStation tree, so a short-lived MutationObserver waits for
- * those two selectors without a recurring timer.
- */
-
-interface UseNarrowChatFocusOptions {
- /** Only run while a WorkStation / Agent Station route is active. */
- enabled: boolean;
-}
-
-interface ResolveWorkbenchEvaluationWidthOptions {
- chatPanelDragging: boolean;
- chatPanelMaximized: boolean;
- chatVisible: boolean;
- chatWidth: number;
- mainContentWidth: number;
- measuredWorkbenchWidth: number;
-}
-
-/**
- * Use the target normal-flow width for programmatic pane motion so the
- * intermediate animation frames do not look like a genuine narrow layout.
- * During direct manipulation, keep reading the measured workbench because
- * the live CSS width intentionally leads the persisted chat-width atom.
- */
-export function resolveWorkbenchEvaluationWidth({
- chatPanelDragging,
- chatPanelMaximized,
- chatVisible,
- chatWidth,
- mainContentWidth,
- measuredWorkbenchWidth,
-}: ResolveWorkbenchEvaluationWidthOptions): number {
- if (chatPanelDragging && !chatPanelMaximized) {
- return measuredWorkbenchWidth;
- }
-
- const chatSlice = chatVisible ? chatWidth : 0;
- return Math.max(0, mainContentWidth - chatSlice);
-}
-
-export function useNarrowChatFocus({
- enabled,
-}: UseNarrowChatFocusOptions): void {
- const chatPanelMaximized = useAtomValue(chatPanelMaximizedAtom);
- const chatPanelDragging = useAtomValue(chatPanelDraggingAtom);
- const chatWidth = useAtomValue(chatWidthAtom);
- const chatVisible = useAtomValue(chatVisibleAtom);
- const setChatPanelMaximized = useSetAtom(chatPanelMaximizedAtom);
-
- // Edge-triggered state machine. We only flip the maximized flag on
- // a *transition* across the breakpoint:
- //
- // - `wasNarrowRef` remembers the side of the breakpoint at the
- // last evaluation.
- // - `autoTriggeredRef` remembers whether we were the one that
- // maximized on the last wide→narrow edge. Cleared on restore and
- // on manual un-maximize while still narrow, so the next
- // narrow→wide edge stays inert and we don't re-force maximize on
- // every pixel of resize.
- const wasNarrowRef = useRef(null);
- const autoTriggeredRef = useRef(false);
-
- // Latest atom values for the observer callbacks to read without
- // re-subscribing on every render. Mirrored in an effect so the
- // ref write happens after render (react-hooks/refs lint rule).
- const chatPanelMaximizedRef = useRef(chatPanelMaximized);
- const chatPanelDraggingRef = useRef(chatPanelDragging);
- const chatWidthRef = useRef(chatWidth);
- const chatVisibleRef = useRef(chatVisible);
- useEffect(() => {
- chatPanelMaximizedRef.current = chatPanelMaximized;
- chatPanelDraggingRef.current = chatPanelDragging;
- chatWidthRef.current = chatWidth;
- chatVisibleRef.current = chatVisible;
- }, [chatPanelDragging, chatPanelMaximized, chatWidth, chatVisible]);
-
- // Last observed measurements. Cached so atom-driven re-evaluations
- // (chat width slider, chat visibility toggle, maximize toggle) can
- // run without waiting for the next ResizeObserver tick.
- const workbenchWidthRef = useRef(0);
- const mainContentWidthRef = useRef(0);
-
- useEffect(() => {
- if (!enabled) {
- wasNarrowRef.current = null;
- autoTriggeredRef.current = false;
- return;
- }
-
- let workbenchObserver: ResizeObserver | null = null;
- let mainObserver: ResizeObserver | null = null;
- let observedWorkbench: Element | null = null;
- let observedMain: Element | null = null;
- let lookupObserver: MutationObserver | null = null;
-
- const computeWorkbenchWidth = (): number => {
- return resolveWorkbenchEvaluationWidth({
- chatPanelDragging: chatPanelDraggingRef.current,
- chatPanelMaximized: chatPanelMaximizedRef.current,
- chatVisible: chatVisibleRef.current,
- chatWidth: chatWidthRef.current,
- mainContentWidth: mainContentWidthRef.current,
- measuredWorkbenchWidth: workbenchWidthRef.current,
- });
- };
-
- const evaluate = () => {
- const width = computeWorkbenchWidth();
- if (width <= 0) return;
-
- const isNarrow = width < NARROW_CHAT_FOCUS_BREAKPOINT_PX;
- const wasNarrow = wasNarrowRef.current;
- wasNarrowRef.current = isNarrow;
- const maximized = chatPanelMaximizedRef.current;
-
- if (isNarrow && wasNarrow !== true) {
- if (maximized) return;
- setChatPanelMaximized(true);
- autoTriggeredRef.current = true;
- return;
- }
-
- if (!isNarrow && wasNarrow === true) {
- if (!autoTriggeredRef.current) return;
- autoTriggeredRef.current = false;
- if (!maximized) return;
- setChatPanelMaximized(false);
- }
- };
-
- const attachObservers = (workbench: Element, main: Element) => {
- observedWorkbench = workbench;
- observedMain = main;
-
- workbenchObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- workbenchWidthRef.current = entry.contentRect.width;
- }
- evaluate();
- });
- mainObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- mainContentWidthRef.current = entry.contentRect.width;
- }
- evaluate();
- });
-
- workbenchObserver.observe(workbench);
- mainObserver.observe(main);
-
- workbenchWidthRef.current = workbench.getBoundingClientRect().width;
- mainContentWidthRef.current = main.getBoundingClientRect().width;
- evaluate();
- };
-
- const tryAttach = () => {
- const workbench = document.querySelector(WORKBENCH_SELECTOR);
- const main = document.querySelector(MAIN_CONTENT_SELECTOR);
- if (!workbench || !main) return false;
- attachObservers(workbench, main);
- return true;
- };
-
- if (!tryAttach()) {
- lookupObserver = new MutationObserver(() => {
- if (tryAttach()) {
- lookupObserver?.disconnect();
- lookupObserver = null;
- }
- });
- lookupObserver.observe(document.documentElement, {
- childList: true,
- subtree: true,
- });
- }
-
- return () => {
- lookupObserver?.disconnect();
- if (workbenchObserver && observedWorkbench) {
- workbenchObserver.unobserve(observedWorkbench);
- }
- if (mainObserver && observedMain) {
- mainObserver.unobserve(observedMain);
- }
- workbenchObserver?.disconnect();
- mainObserver?.disconnect();
- };
- }, [enabled, setChatPanelMaximized]);
-
- // Atom-driven re-evaluations: chat width drag, chat visibility, or
- // maximize toggle changes shift the projected workbench width
- // without necessarily firing a ResizeObserver tick. Re-run the
- // same logic here so the breakpoint stays in sync.
- useEffect(() => {
- if (!enabled) return;
- if (workbenchWidthRef.current <= 0 && mainContentWidthRef.current <= 0) {
- return;
- }
-
- const width = resolveWorkbenchEvaluationWidth({
- chatPanelDragging,
- chatPanelMaximized,
- chatVisible,
- chatWidth,
- mainContentWidth: mainContentWidthRef.current,
- measuredWorkbenchWidth: workbenchWidthRef.current,
- });
-
- if (width <= 0) return;
-
- const isNarrow = width < NARROW_CHAT_FOCUS_BREAKPOINT_PX;
- const wasNarrow = wasNarrowRef.current;
- wasNarrowRef.current = isNarrow;
-
- if (isNarrow && wasNarrow !== true) {
- if (chatPanelMaximized) return;
- setChatPanelMaximized(true);
- autoTriggeredRef.current = true;
- return;
- }
-
- if (!isNarrow && wasNarrow === true) {
- if (!autoTriggeredRef.current) return;
- autoTriggeredRef.current = false;
- if (!chatPanelMaximized) return;
- setChatPanelMaximized(false);
- }
- }, [
- enabled,
- chatPanelDragging,
- chatWidth,
- chatVisible,
- chatPanelMaximized,
- setChatPanelMaximized,
- ]);
-
- // If the user manually un-maximizes while the workbench is still
- // narrow, drop the auto-flag so the eventual narrow→wide edge
- // doesn't try to restore a state they've already moved past.
- useEffect(() => {
- if (!enabled) return;
- if (chatPanelMaximized) return;
- if (wasNarrowRef.current !== true) return;
- autoTriggeredRef.current = false;
- }, [enabled, chatPanelMaximized]);
-}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 391f3cc5b..acea85ac1 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -1037,6 +1037,101 @@
"markAllAsRead": "Mark all as read",
"scrollToUnread": "Go to unread"
},
+ "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…",
+ "loadMore": "Load more",
+ "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"
+ }
+ },
"placeholderTypes": {
"mcpServer": "MCP server",
"connection": "connection",
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
index dc5fa92e7..d01d302bd 100644
--- a/src/i18n/locales/zh/common.json
+++ b/src/i18n/locales/zh/common.json
@@ -1024,6 +1024,101 @@
"markAllAsRead": "全部标记为已读",
"scrollToUnread": "跳转到未读"
},
+ "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": "正在加载团队收件箱…",
+ "loadMore": "加载更多",
+ "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": "紧急"
+ }
+ },
"placeholderTypes": {
"mcpServer": "MCP 服务器",
"connection": "连接",
diff --git a/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
new file mode 100644
index 000000000..e92b62f97
--- /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 000000000..79dbd57c5
--- /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 000000000..be8faa37e
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
@@ -0,0 +1,375 @@
+import { CheckCheck } from "lucide-react";
+import React, { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import Button from "@src/components/Button";
+import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout";
+import {
+ PANEL_HEADER_TOKENS,
+ PanelRefreshButton,
+ Placeholder,
+} from "@src/modules/shared/layouts/blocks";
+
+import {
+ AssignedWorkItemDetail,
+ CommentMentionDetail,
+ TeamInboxList,
+} from "./components";
+import {
+ type TeamInboxDataSource,
+ type TeamInboxFilter,
+ type TeamInboxItem,
+ type TeamInboxNavigationIntent,
+ 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 [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);
+ 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 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}
+
+ {unreadCounts[filter] > 0 && dataSource.markAllRead ? (
+
+ }
+ title={t("inbox.markAllAsRead")}
+ aria-label={t("inbox.markAllAsRead")}
+ onClick={handleMarkAllRead}
+ />
+ ) : null}
+
+ >
+ }
+ listPanelBackgroundClassName="bg-bg-2"
+ mainContentClassName="bg-chat-pane"
+ listContent={
+ loadState.status === "loading" && items.length === 0 ? (
+
+ ) : 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 000000000..d5b7fceef
--- /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 000000000..a789b612f
--- /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 000000000..bd88eff04
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ humanizeToken,
+ isGitHubIssueStatus,
+ 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("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("done")).toBe(false);
+ expect(isGitHubIssueStatus("")).toBe(false);
+ });
+});
+
+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 000000000..725e81775
--- /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 000000000..05f66b60b
--- /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 000000000..db1787615
--- /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 000000000..63219ee5e
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
@@ -0,0 +1,124 @@
+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 { PropertiesRailFrame } from "@src/modules/ProjectManager/shared";
+import { Placeholder } from "@src/modules/shared/layouts/blocks";
+
+import {
+ type AssignedWorkItem,
+ type TeamInboxNavigationIntent,
+ isGitHubIssueStatus,
+} from "../domain";
+import { useTeamInboxWorkItem } from "../useTeamInboxWorkItem";
+import TeamInboxDetailLayout from "./TeamInboxDetailLayout";
+
+/** Matches the work-item pane's info rail so both surfaces read identically. */
+const PROPERTIES_RAIL_WIDTH = 240;
+
+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 { workItem, loading, updateWorkItem } = useTeamInboxWorkItem(
+ item.target
+ );
+ const isGitHubIssue = isGitHubIssueStatus(item.payload.status);
+
+ 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
+ }
+ >
+ {loading ? (
+
+ ) : workItem ? (
+
+
+
+ onNavigate({
+ kind: "open_session_comment",
+ sessionId,
+ commentId: "",
+ threadId: "",
+ })
+ : undefined
+ }
+ />
+
+ {/* The inbox detail is narrower than the work-item pane, so the rail
+ only appears once there is room for it beside the content. */}
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+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 000000000..16ba3e3df
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
@@ -0,0 +1,93 @@
+import { AtSign, MessageSquare } from "lucide-react";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+import Markdown from "@src/components/MarkDown";
+
+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 000000000..beae9bd40
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
@@ -0,0 +1,117 @@
+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;
+ /** Key-value rows rendered under the body. Omitted when the body owns its
+ * own property surface (see `contentLayout: "fill"`). */
+ metadata?: InfoCardRow[];
+ /**
+ * `scroll` (default) puts the body in a padded, centred scroll column.
+ * `fill` hands it the remaining height untouched, for bodies that manage
+ * their own scrolling and side rails.
+ */
+ contentLayout?: "scroll" | "fill";
+ 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,
+ contentLayout = "scroll",
+ unread,
+ markReadLabel,
+ markUnreadLabel,
+ openLabel,
+ openIcon,
+ onMarkRead,
+ onMarkUnread,
+ onOpen,
+ children,
+}) => (
+
+ }
+ onClick={onMarkRead}
+ >
+ {markReadLabel}
+
+ ) : undefined
+ ) : onMarkUnread && markUnreadLabel ? (
+ }
+ onClick={onMarkUnread}
+ >
+ {markUnreadLabel}
+
+ ) : undefined
+ }
+ />
+
+ {contentLayout === "fill" ? (
+
+ {children}
+
+ ) : (
+
+
+ {children ? (
+
{children}
+ ) : null}
+ {metadata && metadata.length > 0 ? (
+
+ ) : 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 000000000..0d346f1cb
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
@@ -0,0 +1,259 @@
+import { AtSign, 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,
+ 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[];
+ selectedItemId: string | null;
+ unreadCounts: TeamInboxUnreadCounts;
+ query: string;
+ onQueryChange: (query: string) => void;
+ onFilterChange: (filter: TeamInboxFilter) => void;
+ onSelectItem: (item: TeamInboxItem) => 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,
+ selectedItemId,
+ unreadCounts,
+ query,
+ onQueryChange,
+ onFilterChange,
+ onSelectItem,
+ 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), [items]);
+ 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 (
+
+
+ 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 000000000..afcb0c23b
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
@@ -0,0 +1,117 @@
+import { AtSign, ClipboardList } from "lucide-react";
+import { forwardRef, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
+import IntegrationIcon from "@src/components/IntegrationIcon";
+import { getListItemClasses } from "@src/components/ListPanel";
+import { formatRelativeTime } from "@src/util/time/formatRelativeTime";
+
+import {
+ type TeamInboxItem,
+ humanizeToken,
+ isGitHubIssueStatus,
+ 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 isGitHubIssue =
+ item.kind === "assigned_work_item" &&
+ isGitHubIssueStatus(item.payload.status);
+ 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 ? (
+
+ ) : isGitHubIssue ? (
+
+ ) : (
+
+ )}
+
+
+
+ {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 000000000..c495c4303
--- /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 000000000..bc92b14ad
--- /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 000000000..516b2ffde
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/index.ts
@@ -0,0 +1,40 @@
+export {
+ countUnreadTeamInboxItems,
+ countUnreadTeamInboxItemsByFilter,
+ dedupeTeamInboxItems,
+ filterItemKind,
+ filterTeamInboxItems,
+ getTeamInboxItemKey,
+ groupTeamInboxItemsByRecency,
+ searchTeamInboxItems,
+ selectTeamInboxItems,
+ sortTeamInboxItems,
+ toTeamInboxNavigationIntent,
+} from "./selectors";
+export type {
+ TeamInboxRecencyGroup,
+ TeamInboxRecencyGroupKey,
+ TeamInboxUnreadCounts,
+} from "./selectors";
+export {
+ humanizeToken,
+ isGitHubIssueStatus,
+ 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 000000000..38ce70ad7
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/labels.ts
@@ -0,0 +1,54 @@
+import { WORK_ITEM_STATUS } from "@src/types/core/workItem";
+
+/**
+ * GitHub-backed work items are identified by their status vocabulary: the sync
+ * adapter writes `open` / `closed` where local items use `todo` / `done`.
+ *
+ * Checked against the shared `WORK_ITEM_STATUS` constant rather than importing
+ * ProjectManager's equivalent helper, for the same isolation reason described
+ * on the label keys below.
+ */
+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`,
+ * `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 000000000..cdda63dd7
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/selectors.ts
@@ -0,0 +1,252 @@
+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[],
+ /**
+ * Defaults to the current time. Kept out of the calling component so the
+ * bucket boundaries are not derived from an impure call during render;
+ * tests and any caller needing determinism pass an explicit value.
+ */
+ nowMs: number = Date.now()
+): 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 000000000..7da20f9cc
--- /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 000000000..afc88e3cd
--- /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 000000000..e0b26f36e
--- /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 000000000..815be3175
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
@@ -0,0 +1,526 @@
+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 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,
+ cloudReadReceipts,
+ 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 ?? ""),
+ cloudReadReceipts,
+ 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,
+ cloudReadReceipts,
+ 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 000000000..29d5dc1a3
--- /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/useTeamInboxWorkItem.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts
new file mode 100644
index 000000000..14e67d324
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts
@@ -0,0 +1,107 @@
+import { useCallback, useEffect, useState } from "react";
+
+import {
+ enrichedWorkItemToUI,
+ projectApi,
+ standaloneWorkItemDataToEnriched,
+} from "@src/api/http/project";
+import { createLogger } from "@src/hooks/logger";
+import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate";
+import type { WorkItem } from "@src/types/core/workItem";
+
+import type { WorkItemTarget } from "./domain";
+
+const log = createLogger("TeamInboxWorkItem");
+
+export interface TeamInboxWorkItemState {
+ /** Full work item once resolved, or null while loading / failed. */
+ workItem: WorkItem | null;
+ loading: boolean;
+ /** Persists a property edit and swaps in the returned item. */
+ updateWorkItem: (updates: Partial) => void;
+}
+
+/**
+ * Loads the full Work Item behind an assigned inbox row so the detail pane can
+ * render the same `WorkItemContent` / `WorkItemProperties` pair the work-item
+ * pane uses, instead of a reduced summary of the list payload.
+ *
+ * The read is demand-driven (one per selection, no polling) and stale responses
+ * are discarded when the selection changes.
+ */
+export function useTeamInboxWorkItem(
+ target: WorkItemTarget
+): TeamInboxWorkItemState {
+ const { projectId, workItemId } = target;
+ const requestKey = `${projectId}:${workItemId}`;
+ /**
+ * Holds the resolved item together with the key it was fetched for. Loading
+ * is derived by comparing that key against the current target rather than
+ * reset by a synchronous setState in the effect.
+ */
+ const [resolved, setResolved] = useState<{
+ key: string;
+ workItem: WorkItem | null;
+ } | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const request = projectId
+ ? projectApi.readWorkItem(projectId, workItemId)
+ : projectApi.readStandaloneWorkItem(workItemId);
+
+ void request
+ .then((data) => {
+ if (cancelled) return;
+ setResolved({
+ key: requestKey,
+ workItem: enrichedWorkItemToUI(
+ standaloneWorkItemDataToEnriched(data)
+ ),
+ });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ log.warn("Failed to load Team Inbox Work Item", error);
+ setResolved({ key: requestKey, workItem: null });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId, workItemId, requestKey]);
+
+ const updateWorkItem = useCallback(
+ (updates: Partial) => {
+ // Standalone items are written back through a full frontmatter
+ // round-trip that only the owning pane assembles, so the inbox edits
+ // project-scoped items and leaves standalone ones read-only.
+ if (!projectId) return;
+
+ const payload = toWorkItemPartialUpdate(updates);
+ if (Object.keys(payload).length === 0) return;
+
+ void projectApi
+ .updateWorkItemPartial(projectId, workItemId, payload)
+ .then((updated) => {
+ setResolved({
+ key: requestKey,
+ workItem: enrichedWorkItemToUI(updated),
+ });
+ })
+ .catch((error: unknown) => {
+ log.warn("Failed to update Team Inbox Work Item", error);
+ });
+ },
+ [projectId, workItemId, requestKey]
+ );
+
+ const isResolved = resolved?.key === requestKey;
+
+ return {
+ workItem: isResolved ? resolved.workItem : null,
+ loading: !isResolved,
+ updateWorkItem,
+ };
+}
diff --git a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
new file mode 100644
index 000000000..a64c97a37
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
@@ -0,0 +1,73 @@
+import type { WorkItemPartialUpdate } from "@src/api/http/project";
+import type { WorkItem } from "@src/types/core/workItem";
+
+/**
+ * Map a UI-shaped partial work-item update onto the wire payload the project
+ * store accepts.
+ *
+ * Shared so every surface that edits a work item — the chat panel's work-item
+ * pane and the Team Inbox detail — persists the same fields the same way.
+ */
+export 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;
+}
diff --git a/src/modules/index.tsx b/src/modules/index.tsx
index 455fe3b16..4d73f96a0 100644
--- a/src/modules/index.tsx
+++ b/src/modules/index.tsx
@@ -32,7 +32,6 @@ import { useProjectDataChangedListener } from "@src/hooks/project";
import { useBackgroundImage } from "@src/hooks/theme/useBackgroundImage";
import { useOpenUrlInBrowser } from "@src/hooks/workStation/browser/useOpenUrlInBrowser";
import { useUrlPreviewEvents } from "@src/hooks/workStation/tabs";
-import { useNarrowChatFocus } from "@src/hooks/workStation/useNarrowChatFocus";
import { useGlobalBrowserWebviewLayering } from "@src/modules/WorkStation/Browser/hooks";
import { CODE_EDITOR_TOUR_EVENT } from "@src/scaffold/Tutorials/codeEditorTourConfig";
import {
@@ -374,7 +373,6 @@ const AppShell = () => {
const shouldBridgeWorkStationPipeline =
!isSettingsRoute && activeChatPanelTab?.type === "session";
- useNarrowChatFocus({ enabled: true });
useWorkStationPipelineBridge(shouldBridgeWorkStationPipeline);
const workStationChatPosition = useAtomValue(workStationChatPositionAtom);
diff --git a/src/modules/shared/layouts/blocks/InfoCard.tsx b/src/modules/shared/layouts/blocks/InfoCard.tsx
index 141d14989..8d60fe28d 100644
--- a/src/modules/shared/layouts/blocks/InfoCard.tsx
+++ b/src/modules/shared/layouts/blocks/InfoCard.tsx
@@ -22,20 +22,31 @@ export interface InfoCardProps {
/** Extra content rendered above the card (e.g. badges) */
header?: React.ReactNode;
className?: string;
+ /**
+ * `surface` (default) paints the filled card. `plain` drops the fill and
+ * padding so the host panel's own background shows through.
+ */
+ variant?: "surface" | "plain";
}
const InfoCard: React.FC = ({
rows,
header,
className = "",
+ variant = "surface",
}) => {
const visibleRows = rows.filter((row) => !row.hidden);
if (visibleRows.length === 0 && !header) return null;
+ const container =
+ variant === "plain"
+ ? INFO_CARD_TOKENS.containerPlain
+ : INFO_CARD_TOKENS.container;
+
return (
{header}
-
+
{visibleRows.map((row) => (
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx
index 022161f32..dcb3fb513 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 9c1b3e69c..64745bac7 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 d9afb4b70..1f9dc8916 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 56f15eba0..8ee68e752 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 84c6f8a42..68312648d 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 02f86f1eb..36207bf98 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 651c9ff3c..1d7b12c5a 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 86f7cd06a..be1870d4b 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 678480c0b..21dd54714 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/sidebarConnectorUtils.ts b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts
index 88dcf99c0..fbec02293 100644
--- a/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts
+++ b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts
@@ -18,6 +18,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 6dac574da..d65b85290 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 30cc91c19..afb89c26d 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 c0dea6856..bfd60313a 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 8a9119a11..8b8bec8a0 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 b31cfb35a..7cd0f2c5b 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 e892ffb4d..f1ed72182 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 bf4ee0e41..c8d9ee78f 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)
)