From f49906f16b6023a5f149d6fe7842c964b258975a Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Fri, 24 Jul 2026 23:16:08 +0800
Subject: [PATCH 1/2] feat(team-inbox): add unified team inbox
---
.../TeamInbox.md | 82 +++
.../frontend-ui-audit-2026-07-23/TeamInbox.md | 50 ++
.../crates/project-management/src/lib.rs | 2 +
.../project-management/src/projects/schema.rs | 1 +
.../src/team_inbox/commands.rs | 65 +++
.../project-management/src/team_inbox/mod.rs | 18 +
.../src/team_inbox/schema.rs | 23 +
.../src/team_inbox/store.rs | 424 ++++++++++++++
.../src/team_inbox/tests.rs | 510 +++++++++++++++++
.../src/team_inbox/types.rs | 108 ++++
src-tauri/src/commands/handler_list.inc | 6 +
src/engines/ChatPanel/ChatPanelTabBar.tsx | 10 +
src/engines/ChatPanel/TabContent/registry.ts | 6 +
.../ChatPanel/TabContent/surfaceRenderers.tsx | 11 +
.../ChatPanel/chatPanelTabDisplay.test.ts | 7 +
src/engines/ChatPanel/chatPanelTabDisplay.ts | 3 +
.../Org2Cloud/teamInboxMentionsClient.test.ts | 165 ++++++
.../Org2Cloud/teamInboxMentionsClient.ts | 101 ++++
src/i18n/locales/en/common.json | 95 ++++
src/i18n/locales/zh/common.json | 95 ++++
.../TeamInbox/ConnectedTeamInboxView.tsx | 13 +
src/modules/MainApp/TeamInbox/TEST_CASES.md | 52 ++
.../MainApp/TeamInbox/TeamInboxView.tsx | 353 ++++++++++++
.../MainApp/TeamInbox/__tests__/TEST_CASES.md | 77 +++
.../TeamInbox/__tests__/cursor.test.ts | 23 +
.../TeamInbox/__tests__/labels.test.ts | 42 ++
.../TeamInbox/__tests__/selectors.test.ts | 234 ++++++++
.../MainApp/TeamInbox/__tests__/store.test.ts | 63 +++
src/modules/MainApp/TeamInbox/api.ts | 201 +++++++
.../components/AssignedWorkItemDetail.tsx | 93 ++++
.../components/CommentMentionDetail.tsx | 94 ++++
.../components/TeamInboxDetailLayout.tsx | 100 ++++
.../TeamInbox/components/TeamInboxList.tsx | 309 ++++++++++
.../TeamInbox/components/TeamInboxRow.tsx | 100 ++++
.../MainApp/TeamInbox/components/index.ts | 10 +
.../MainApp/TeamInbox/domain/cursor.ts | 13 +
src/modules/MainApp/TeamInbox/domain/index.ts | 39 ++
.../MainApp/TeamInbox/domain/labels.ts | 37 ++
.../MainApp/TeamInbox/domain/selectors.ts | 247 ++++++++
src/modules/MainApp/TeamInbox/domain/types.ts | 109 ++++
src/modules/MainApp/TeamInbox/index.ts | 7 +
src/modules/MainApp/TeamInbox/store.ts | 87 +++
.../TeamInbox/useTeamInboxDataSource.ts | 526 ++++++++++++++++++
.../TeamInbox/useTeamInboxNavigation.ts | 84 +++
.../TeamInbox/useTeamInboxWorkItemBody.ts | 58 ++
.../WorkstationSidebarConnector/index.tsx | 10 +
.../menuSelection.test.ts | 17 +
.../menuSelection.ts | 5 +-
.../sidebarConnector.chatPanelAtoms.ts | 3 +
.../sidebarConnector.chrome.tsx | 6 +
.../sidebarConnector.labels.ts | 4 +
.../sidebarConnector.menuItemRouting.ts | 11 +
.../sidebarConnector.pinnedAndRevealData.ts | 6 +
.../sidebarMenuCollections.ts | 16 +-
.../connectors/sidebarConnectorUtils.ts | 1 +
.../workstationSidebarMenuItems.test.ts | 11 +-
.../workstationSidebarMenuItems.tsx | 23 +
.../__tests__/chatPanelTabsAtom.test.ts | 29 +
src/store/chatPanel/chatPanelTabFactories.ts | 14 +
src/store/chatPanel/chatPanelTabOpenAtoms.ts | 20 +
src/store/chatPanel/chatPanelTabsAtom.ts | 2 +
src/store/chatPanel/chatPanelTabsModel.ts | 7 +
62 files changed, 4934 insertions(+), 4 deletions(-)
create mode 100644 docs/architecture-audit-2026-07-23/TeamInbox.md
create mode 100644 docs/frontend-ui-audit-2026-07-23/TeamInbox.md
create mode 100644 src-tauri/crates/project-management/src/team_inbox/commands.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/mod.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/schema.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/store.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/tests.rs
create mode 100644 src-tauri/crates/project-management/src/team_inbox/types.rs
create mode 100644 src/features/Org2Cloud/teamInboxMentionsClient.test.ts
create mode 100644 src/features/Org2Cloud/teamInboxMentionsClient.ts
create mode 100644 src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx
create mode 100644 src/modules/MainApp/TeamInbox/TEST_CASES.md
create mode 100644 src/modules/MainApp/TeamInbox/TeamInboxView.tsx
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/__tests__/store.test.ts
create mode 100644 src/modules/MainApp/TeamInbox/api.ts
create mode 100644 src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
create mode 100644 src/modules/MainApp/TeamInbox/components/index.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/cursor.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/index.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/labels.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/selectors.ts
create mode 100644 src/modules/MainApp/TeamInbox/domain/types.ts
create mode 100644 src/modules/MainApp/TeamInbox/index.ts
create mode 100644 src/modules/MainApp/TeamInbox/store.ts
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
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 `
+ ) : undefined
+ ) : onMarkUnread && markUnreadLabel ? (
+ }
+ onClick={onMarkUnread}
+ >
+ {markUnreadLabel}
+
+ ) : undefined
+ }
+ />
+
+
+
+ {children ? (
+
{children}
+ ) : null}
+
+
+
+
+ {onOpen ? (
+
+ ) : null}
+
+);
+
+export default TeamInboxDetailLayout;
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
new file mode 100644
index 000000000..53ea98f58
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
@@ -0,0 +1,309 @@
+import { AtSign, CheckCheck, ClipboardList, Inbox } from "lucide-react";
+import React, { useCallback, useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+
+import Button from "@src/components/Button";
+import {
+ LIST_PANEL_SECTIONS,
+ LIST_PANEL_SECTION_HEADER,
+} from "@src/components/ListPanel";
+import SearchInput from "@src/components/SearchInput";
+import TabPill, { type TabPillItem } from "@src/components/TabPill";
+import {
+ ListPanelScrollArea,
+ ListPanelTabPillRow,
+ PANEL_HEADER_TOKENS,
+ PanelHeader,
+ PanelRefreshButton,
+ Placeholder,
+} from "@src/modules/shared/layouts/blocks";
+
+import {
+ type TeamInboxFilter,
+ type TeamInboxItem,
+ type TeamInboxUnreadCounts,
+ getTeamInboxItemKey,
+ groupTeamInboxItemsByRecency,
+} from "../domain";
+import TeamInboxRow from "./TeamInboxRow";
+
+export interface TeamInboxListProps {
+ filter: TeamInboxFilter;
+ items: readonly TeamInboxItem[];
+ selectedItemId: string | null;
+ totalUnread: number;
+ unreadCounts: TeamInboxUnreadCounts;
+ query: string;
+ loading: boolean;
+ onQueryChange: (query: string) => void;
+ onFilterChange: (filter: TeamInboxFilter) => void;
+ onSelectItem: (item: TeamInboxItem) => void;
+ onRefresh?: () => void;
+ onMarkAllRead?: () => void;
+ hasMore?: boolean;
+ loadingMore?: boolean;
+ onLoadMore?: () => void;
+}
+
+function filterCountBadge(count: number, ariaLabel: string): React.ReactNode {
+ if (count <= 0) return undefined;
+ return (
+
+ {count > 99 ? "99+" : count}
+
+ );
+}
+
+const TeamInboxList: React.FC = ({
+ filter,
+ items,
+ selectedItemId,
+ totalUnread,
+ unreadCounts,
+ query,
+ loading,
+ onQueryChange,
+ onFilterChange,
+ onSelectItem,
+ onRefresh,
+ onMarkAllRead,
+ hasMore = false,
+ loadingMore = false,
+ onLoadMore,
+}) => {
+ const { t } = useTranslation();
+ const hasQuery = query.trim().length > 0;
+ const rowRefs = useRef(new Map());
+ const selectedIndex = useMemo(
+ () =>
+ items.findIndex((item) => getTeamInboxItemKey(item) === selectedItemId),
+ [items, selectedItemId]
+ );
+ const groups = useMemo(
+ () => groupTeamInboxItemsByRecency(items, Date.now()),
+ [items]
+ );
+ const activeFilterUnread = unreadCounts[filter];
+ const filterTabs = useMemo(
+ () => [
+ {
+ key: "all",
+ label: t("teamInbox.filters.all"),
+ icon: ,
+ badge: filterCountBadge(
+ unreadCounts.all,
+ t("teamInbox.unreadCount", { count: unreadCounts.all })
+ ),
+ },
+ {
+ key: "mentions",
+ label: t("teamInbox.filters.mentions"),
+ icon: ,
+ badge: filterCountBadge(
+ unreadCounts.mentions,
+ t("teamInbox.unreadCount", { count: unreadCounts.mentions })
+ ),
+ },
+ {
+ key: "assigned",
+ label: t("teamInbox.filters.assigned"),
+ icon: ,
+ badge: filterCountBadge(
+ unreadCounts.assigned,
+ t("teamInbox.unreadCount", { count: unreadCounts.assigned })
+ ),
+ },
+ ],
+ [t, unreadCounts.all, unreadCounts.mentions, unreadCounts.assigned]
+ );
+
+ const selectAt = useCallback(
+ (index: number) => {
+ const item = items[index];
+ if (!item) return;
+ onSelectItem(item);
+ rowRefs.current.get(getTeamInboxItemKey(item))?.focus();
+ },
+ [items, onSelectItem]
+ );
+
+ const handleListKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (items.length === 0) return;
+ const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
+ let nextIndex: number | null = null;
+ switch (event.key) {
+ case "ArrowDown":
+ nextIndex = Math.min(currentIndex + 1, items.length - 1);
+ break;
+ case "ArrowUp":
+ nextIndex = Math.max(currentIndex - 1, 0);
+ break;
+ case "Home":
+ nextIndex = 0;
+ break;
+ case "End":
+ nextIndex = items.length - 1;
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ selectAt(nextIndex);
+ },
+ [items.length, selectAt, selectedIndex]
+ );
+
+ return (
+
+ 0
+ ? t("teamInbox.unreadCount", { count: totalUnread })
+ : t("teamInbox.allRead")
+ }
+ variant="list"
+ actions={
+ <>
+ {activeFilterUnread > 0 && onMarkAllRead ? (
+
+ }
+ title={t("inbox.markAllAsRead")}
+ aria-label={t("inbox.markAllAsRead")}
+ onClick={onMarkAllRead}
+ />
+ ) : null}
+ {onRefresh ? (
+
+ ) : null}
+ >
+ }
+ />
+
+
+ onFilterChange(key as TeamInboxFilter)}
+ variant="pill"
+ colorScheme="ghost"
+ size="mini"
+ fillWidth
+ />
+
+
+
+
+
+
+ {items.length === 0 ? (
+ hasQuery ? (
+
+ ) : (
+
+ )
+ ) : (
+
+
+ {groups.map((group) => {
+ const groupLabel = t(`teamInbox.groups.${group.key}`);
+ return (
+
+
+ {groupLabel}
+
+
+ {group.items.map((item) => {
+ const key = getTeamInboxItemKey(item);
+ return (
+ {
+ if (node) rowRefs.current.set(key, node);
+ else rowRefs.current.delete(key);
+ }}
+ item={item}
+ itemKey={key}
+ selected={key === selectedItemId}
+ onSelect={onSelectItem}
+ />
+ );
+ })}
+
+
+ );
+ })}
+
+ {hasMore && onLoadMore ? (
+
+
+
+ ) : 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..1903b8bb6
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx
@@ -0,0 +1,100 @@
+import { AtSign, ClipboardList } from "lucide-react";
+import { forwardRef, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
+import { getListItemClasses } from "@src/components/ListPanel";
+import { formatRelativeTime } from "@src/util/time/formatRelativeTime";
+
+import {
+ type TeamInboxItem,
+ humanizeToken,
+ workItemPriorityLabelKey,
+ workItemStatusLabelKey,
+} from "../domain";
+
+export interface TeamInboxRowProps {
+ item: TeamInboxItem;
+ itemKey: string;
+ selected: boolean;
+ onSelect: (item: TeamInboxItem) => void;
+}
+
+const TeamInboxRow = forwardRef(
+ ({ item, itemKey, selected, onSelect }, ref) => {
+ const { t } = useTranslation();
+ const isMention = item.kind === "comment_mention";
+ const title = isMention ? item.target.sessionTitle : item.payload.title;
+ const summary = useMemo(() => {
+ if (item.kind === "comment_mention") return item.payload.commentBody;
+ if (item.payload.summary) return item.payload.summary;
+ const status = t(workItemStatusLabelKey(item.payload.status), {
+ defaultValue: humanizeToken(item.payload.status),
+ });
+ const priority = t(workItemPriorityLabelKey(item.payload.priority), {
+ defaultValue: humanizeToken(item.payload.priority),
+ });
+ return t("teamInbox.row.assignedSummary", { status, priority });
+ }, [item, t]);
+ const personName = isMention
+ ? item.actor.displayName
+ : (item.payload.assigneeName ?? item.payload.assigneeMemberId);
+ const relativeTime = useMemo(
+ () => formatRelativeTime(item.occurredAt, "nano"),
+ [item.occurredAt]
+ );
+ const unread = item.readAt === null;
+ const readLabel = t(
+ unread ? "teamInbox.status.unread" : "teamInbox.status.read"
+ );
+
+ return (
+
+ );
+ }
+);
+
+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..6c1dd973e
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/index.ts
@@ -0,0 +1,39 @@
+export {
+ countUnreadTeamInboxItems,
+ countUnreadTeamInboxItemsByFilter,
+ dedupeTeamInboxItems,
+ filterItemKind,
+ filterTeamInboxItems,
+ getTeamInboxItemKey,
+ groupTeamInboxItemsByRecency,
+ searchTeamInboxItems,
+ selectTeamInboxItems,
+ sortTeamInboxItems,
+ toTeamInboxNavigationIntent,
+} from "./selectors";
+export type {
+ TeamInboxRecencyGroup,
+ TeamInboxRecencyGroupKey,
+ TeamInboxUnreadCounts,
+} from "./selectors";
+export {
+ humanizeToken,
+ workItemPriorityLabelKey,
+ workItemStatusLabelKey,
+} from "./labels";
+export { toWireCursorItemId } from "./cursor";
+export type {
+ AssignedWorkItem,
+ CommentMentionItem,
+ ListTeamInboxInput,
+ SessionCommentTarget,
+ TeamInboxActor,
+ TeamInboxCursor,
+ TeamInboxDataSource,
+ TeamInboxFilter,
+ TeamInboxItem,
+ TeamInboxNavigationIntent,
+ TeamInboxPage,
+ TeamInboxTarget,
+ WorkItemTarget,
+} from "./types";
diff --git a/src/modules/MainApp/TeamInbox/domain/labels.ts b/src/modules/MainApp/TeamInbox/domain/labels.ts
new file mode 100644
index 000000000..f49ddb3a8
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/labels.ts
@@ -0,0 +1,37 @@
+/**
+ * Turns a raw enum token from the work-item read model (e.g. `in_progress`,
+ * `HIGH`, `in-review`) into a human sentence-cased label (`In progress`,
+ * `High`, `In review`).
+ *
+ * This is the deterministic fallback for values that have no explicit localized
+ * key; callers pass the result as the i18next `defaultValue` so a translated
+ * label wins when present and raw enum strings never leak to the UI.
+ */
+export function humanizeToken(value: string): string {
+ const normalized = value.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
+ if (!normalized) return "";
+ const lower = normalized.toLowerCase();
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
+}
+
+/**
+ * i18n key for a work-item status/priority token, with a humanized default.
+ *
+ * Team Inbox deliberately owns the `teamInbox.workItemStatus.*` /
+ * `teamInbox.priority.*` namespaces instead of reusing ProjectManager's
+ * `workItems.statusLabels.*` / `workItems.priorityLabels.*`. The two label sets
+ * model *different* status vocabularies — Team Inbox surfaces read-model tokens
+ * like `todo` / `done` / `blocked`, while ProjectManager uses `planned` /
+ * `completed` and omits `blocked` — so pointing at the shared keys would drop
+ * those labels to the humanized fallback. Keeping the namespaces separate is
+ * intentional isolation, not accidental duplication; the humanized default keeps
+ * any unmapped token readable.
+ */
+export function workItemStatusLabelKey(status: string): string {
+ return `teamInbox.workItemStatus.${status}`;
+}
+
+/** i18n key for a work-item priority token, with a humanized default value. */
+export function workItemPriorityLabelKey(priority: string): string {
+ return `teamInbox.priority.${priority}`;
+}
diff --git a/src/modules/MainApp/TeamInbox/domain/selectors.ts b/src/modules/MainApp/TeamInbox/domain/selectors.ts
new file mode 100644
index 000000000..1d517dc25
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/domain/selectors.ts
@@ -0,0 +1,247 @@
+import {
+ type SessionDateBucket,
+ getSessionDateBucketRanges,
+} from "@src/util/session/sessionDateBuckets";
+
+import type {
+ TeamInboxFilter,
+ TeamInboxItem,
+ TeamInboxNavigationIntent,
+} from "./types";
+
+const INVALID_TIMESTAMP = Number.NEGATIVE_INFINITY;
+
+function timestamp(value: string): number {
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? INVALID_TIMESTAMP : parsed;
+}
+
+export function getTeamInboxItemKey(item: TeamInboxItem): string {
+ return `${item.kind}:${item.id}`;
+}
+
+/**
+ * De-duplicates pages by canonical item identity. When a later page contains a
+ * fresher copy of the same item, the fresher copy wins.
+ */
+export function dedupeTeamInboxItems(
+ items: readonly TeamInboxItem[]
+): TeamInboxItem[] {
+ const byKey = new Map();
+
+ for (const item of items) {
+ const key = getTeamInboxItemKey(item);
+ const current = byKey.get(key);
+ if (
+ !current ||
+ timestamp(item.occurredAt) > timestamp(current.occurredAt)
+ ) {
+ byKey.set(key, item);
+ }
+ }
+
+ return [...byKey.values()];
+}
+
+/** Newest first; identity is a deterministic tie-breaker for cursor stability. */
+export function sortTeamInboxItems(
+ items: readonly TeamInboxItem[]
+): TeamInboxItem[] {
+ return [...items].sort((left, right) => {
+ const timeDifference =
+ timestamp(right.occurredAt) - timestamp(left.occurredAt);
+ if (timeDifference !== 0) return timeDifference;
+ return getTeamInboxItemKey(left).localeCompare(getTeamInboxItemKey(right));
+ });
+}
+
+export function filterTeamInboxItems(
+ items: readonly TeamInboxItem[],
+ filter: TeamInboxFilter
+): TeamInboxItem[] {
+ if (filter === "all") return [...items];
+ const kind = filter === "mentions" ? "comment_mention" : "assigned_work_item";
+ return items.filter((item) => item.kind === kind);
+}
+
+export function selectTeamInboxItems(
+ items: readonly TeamInboxItem[],
+ filter: TeamInboxFilter
+): TeamInboxItem[] {
+ return filterTeamInboxItems(
+ sortTeamInboxItems(dedupeTeamInboxItems(items)),
+ filter
+ );
+}
+
+/** Fields searched for each item kind, so the free-text query stays discoverable. */
+function searchableText(item: TeamInboxItem): string[] {
+ if (item.kind === "comment_mention") {
+ return [
+ item.target.sessionTitle,
+ item.payload.commentBody,
+ item.payload.context ?? "",
+ item.actor.displayName,
+ ];
+ }
+ return [
+ item.payload.title,
+ item.payload.summary ?? "",
+ item.payload.assigneeName ?? item.payload.assigneeMemberId,
+ item.payload.status,
+ item.payload.priority,
+ item.actor.displayName,
+ ];
+}
+
+/**
+ * Case-insensitive free-text filter over the already-loaded items. An empty or
+ * whitespace-only query returns every item unchanged; otherwise an item is kept
+ * when any of its searchable fields contains the query.
+ */
+export function searchTeamInboxItems(
+ items: readonly TeamInboxItem[],
+ query: string
+): TeamInboxItem[] {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return [...items];
+ return items.filter((item) =>
+ searchableText(item).some((text) => text.toLowerCase().includes(needle))
+ );
+}
+
+export type TeamInboxRecencyGroupKey =
+ | "today"
+ | "yesterday"
+ | "thisWeek"
+ | "earlier";
+
+export interface TeamInboxRecencyGroup {
+ key: TeamInboxRecencyGroupKey;
+ items: TeamInboxItem[];
+}
+
+const RECENCY_GROUP_ORDER: TeamInboxRecencyGroupKey[] = [
+ "today",
+ "yesterday",
+ "thisWeek",
+ "earlier",
+];
+
+/**
+ * Maps the shared session date-bucket keys onto the Team Inbox recency keys, so
+ * both surfaces derive day boundaries from one source of truth
+ * (`getSessionDateBucketRanges`). Only the presentation key name differs
+ * ("earlier" here vs "older" in the shared helper).
+ */
+const SESSION_BUCKET_TO_RECENCY: Record<
+ SessionDateBucket,
+ TeamInboxRecencyGroupKey
+> = {
+ today: "today",
+ yesterday: "yesterday",
+ thisWeek: "thisWeek",
+ older: "earlier",
+};
+
+/**
+ * Buckets already-ordered items into recency sections relative to `nowMs`
+ * (Today / Yesterday / This week / Earlier). Day boundaries are reused from the
+ * shared `getSessionDateBucketRanges` helper so the "this week" window stays
+ * consistent with the rest of the app. Empty groups are omitted and group order
+ * is stable; unparseable timestamps fall into "earlier".
+ */
+export function groupTeamInboxItemsByRecency(
+ items: readonly TeamInboxItem[],
+ nowMs: number
+): TeamInboxRecencyGroup[] {
+ const ranges = getSessionDateBucketRanges(new Date(nowMs));
+
+ const buckets: Record = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ earlier: [],
+ };
+
+ for (const item of items) {
+ const occurred = Date.parse(item.occurredAt);
+ let key: TeamInboxRecencyGroupKey = "earlier";
+ if (!Number.isNaN(occurred)) {
+ const match = ranges.find(
+ ({ startMs, endMs }) =>
+ (startMs === undefined || occurred >= startMs) &&
+ (endMs === undefined || occurred < endMs)
+ );
+ if (match) key = SESSION_BUCKET_TO_RECENCY[match.bucket];
+ }
+ buckets[key].push(item);
+ }
+
+ return RECENCY_GROUP_ORDER.filter((key) => buckets[key].length > 0).map(
+ (key) => ({ key, items: buckets[key] })
+ );
+}
+
+export function countUnreadTeamInboxItems(
+ items: readonly TeamInboxItem[]
+): number {
+ return dedupeTeamInboxItems(items).reduce(
+ (count, item) => count + (item.readAt === null ? 1 : 0),
+ 0
+ );
+}
+
+export interface TeamInboxUnreadCounts {
+ all: number;
+ mentions: number;
+ assigned: number;
+}
+
+/**
+ * Unread totals split by the surfaces the filter tabs expose. Canonical items
+ * are de-duplicated first so a duplicated page never double-counts a badge.
+ */
+export function countUnreadTeamInboxItemsByFilter(
+ items: readonly TeamInboxItem[]
+): TeamInboxUnreadCounts {
+ return dedupeTeamInboxItems(items).reduce(
+ (counts, item) => {
+ if (item.readAt !== null) return counts;
+ counts.all += 1;
+ if (item.kind === "comment_mention") counts.mentions += 1;
+ else counts.assigned += 1;
+ return counts;
+ },
+ { all: 0, mentions: 0, assigned: 0 }
+ );
+}
+
+/** Maps a filter tab to the item kind it exposes, or null for the combined view. */
+export function filterItemKind(
+ filter: TeamInboxFilter
+): TeamInboxItem["kind"] | null {
+ if (filter === "mentions") return "comment_mention";
+ if (filter === "assigned") return "assigned_work_item";
+ return null;
+}
+
+export function toTeamInboxNavigationIntent(
+ item: TeamInboxItem
+): TeamInboxNavigationIntent {
+ if (item.target.kind === "session_comment") {
+ return {
+ kind: "open_session_comment",
+ sessionId: item.target.sessionId,
+ commentId: item.target.commentId,
+ threadId: item.target.threadId,
+ ...(item.target.anchor ? { anchor: item.target.anchor } : {}),
+ };
+ }
+
+ return {
+ kind: "open_work_item",
+ projectId: item.target.projectId,
+ workItemId: item.target.workItemId,
+ };
+}
diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts
new file mode 100644
index 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/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
new file mode 100644
index 000000000..374fa03c7
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
@@ -0,0 +1,58 @@
+import { useEffect, useState } from "react";
+
+import { projectApi } from "@src/api/http/project";
+import { createLogger } from "@src/hooks/logger";
+
+import type { WorkItemTarget } from "./domain";
+
+const log = createLogger("TeamInboxWorkItemBody");
+
+export interface TeamInboxWorkItemBodyState {
+ /** Full Markdown body once resolved, or null while loading / empty / failed. */
+ body: string | null;
+ loading: boolean;
+}
+
+/**
+ * Lazily loads the full Work Item body for the selected assigned inbox item so
+ * the detail preview can render the real content instead of the short list
+ * excerpt. The fetch reuses the same project store adapters as navigation and is
+ * demand-driven (one read per selection, no polling); stale responses are
+ * discarded when the selection changes.
+ */
+export function useTeamInboxWorkItemBody(
+ target: WorkItemTarget
+): TeamInboxWorkItemBodyState {
+ const { projectId, workItemId } = target;
+ const [state, setState] = useState({
+ body: null,
+ loading: true,
+ });
+
+ useEffect(() => {
+ let cancelled = false;
+ setState({ body: null, loading: true });
+
+ const request = projectId
+ ? projectApi.readWorkItem(projectId, workItemId)
+ : projectApi.readStandaloneWorkItem(workItemId);
+
+ void request
+ .then((workItem) => {
+ if (cancelled) return;
+ const body = workItem.body.trim();
+ setState({ body: body.length > 0 ? body : null, loading: false });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ log.warn("Failed to load Team Inbox Work Item body", error);
+ setState({ body: null, loading: false });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId, workItemId]);
+
+ return state;
+}
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)
)
From 87ac2f7600fa80314d949cb2dd3167b5f0674143 Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Fri, 24 Jul 2026 23:52:28 +0800
Subject: [PATCH 2/2] fix(team-inbox): satisfy React lifecycle rules
---
.../TeamInboxReactLifecycle.md | 28 ++++
.../Org2Cloud/useOrg2CloudRealtime.ts | 3 +-
.../MainApp/TeamInbox/TeamInboxView.tsx | 3 +
.../TeamInbox/components/TeamInboxList.tsx | 6 +-
.../useTeamInboxWorkItemBody.test.ts | 122 ++++++++++++++++++
.../TeamInbox/useTeamInboxWorkItemBody.ts | 24 +++-
6 files changed, 177 insertions(+), 9 deletions(-)
create mode 100644 docs/frontend-ui-audit-2026-07-24/TeamInboxReactLifecycle.md
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.test.ts
diff --git a/docs/frontend-ui-audit-2026-07-24/TeamInboxReactLifecycle.md b/docs/frontend-ui-audit-2026-07-24/TeamInboxReactLifecycle.md
new file mode 100644
index 000000000..749e5ba41
--- /dev/null
+++ b/docs/frontend-ui-audit-2026-07-24/TeamInboxReactLifecycle.md
@@ -0,0 +1,28 @@
+# Team Inbox React Lifecycle — Frontend UI Audit
+
+## Scope
+
+- `src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx`
+- `src/modules/MainApp/TeamInbox/TeamInboxView.tsx`
+- `src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts`
+
+## Summary
+
+| Verdict | Count |
+| ---------------- | ----: |
+| fix | 2 |
+| keep with reason | 4 |
+| abstract | 0 |
+
+No cross-file design-system sweep candidate was found.
+
+## Findings
+
+| Line | Element | Verdict | Reason | Suggested change |
+| -------------------------------: | ------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| `TeamInboxList.tsx:87` | Recency grouping | fix | Calling `Date.now()` during render violates React render purity and makes the memo depend on an untracked value. | Pass the load-time reference timestamp from the owning view and include it in the memo dependencies. |
+| `useTeamInboxWorkItemBody.ts:38` | Selected Work Item body effect | fix | Synchronously resetting state inside the effect causes an extra render and trips the React lifecycle rule. | Tag resolved state with the request key and derive the loading fallback during render when keys differ. |
+| `TeamInboxView.tsx:64` | Page load lifecycle | keep with reason | The effect owns one abort controller per request, checks cancellation before state writes, and aborts on dependency change or unmount. | Keep. |
+| `TeamInboxView.tsx:313` | Loading/error/empty states | keep with reason | The shared `Placeholder` component expresses all three states consistently with the rest of the application. | Keep. |
+| `TeamInboxView.tsx:328` | Split list/detail composition | keep with reason | `SplitViewLayout` and `TeamInboxList` are existing shared layout and feature boundaries; another wrapper would add indirection. | Keep. |
+| `TeamInboxList.tsx:92` | Filter tabs and unread badges | keep with reason | The implementation uses the shared `TabPill` control, translated labels, and explicit accessible badge labels. | Keep. |
diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.ts
index d670b2431..5a5ffbd18 100644
--- a/src/features/Org2Cloud/useOrg2CloudRealtime.ts
+++ b/src/features/Org2Cloud/useOrg2CloudRealtime.ts
@@ -474,6 +474,7 @@ export function useOrg2CloudRealtime(): void {
const unsubscribes: Array<() => void> = [];
const orgId = activeRealtimeOrgId;
+ const orgTeardownAt = orgTeardownAtRef.current;
if (!broadcastSignals) {
unsubscribes.push(
connection.subscribe({
@@ -519,7 +520,7 @@ export function useOrg2CloudRealtime(): void {
return () => {
for (const unsub of unsubscribes) unsub();
- orgTeardownAtRef.current.set(orgId, Date.now());
+ orgTeardownAt.set(orgId, Date.now());
setRosterRealtimeConnected((current) => {
if (!(orgId in current)) return current;
const next = { ...current };
diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
index 115da9035..598eee068 100644
--- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
+++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
@@ -57,6 +57,7 @@ const TeamInboxView: React.FC = ({
message: null,
});
const [reloadRevision, setReloadRevision] = useState(0);
+ const [groupingReferenceTime, setGroupingReferenceTime] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
@@ -67,6 +68,7 @@ const TeamInboxView: React.FC = ({
.listPage({ limit: pageSize, signal: abortController.signal })
.then((page) => {
if (abortController.signal.aborted) return;
+ setGroupingReferenceTime(Date.now());
setItems(page.items);
setHasMore(page.nextCursor != null);
setLoadState({ status: "ready", message: null });
@@ -329,6 +331,7 @@ const TeamInboxView: React.FC = ({
selectedItemId={selectedItemId}
totalUnread={totalUnread}
unreadCounts={unreadCounts}
+ groupingReferenceTime={groupingReferenceTime}
query={query}
loading={loadState.status === "loading"}
onQueryChange={setQuery}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
index 53ea98f58..ba7da4aae 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
@@ -33,6 +33,7 @@ export interface TeamInboxListProps {
selectedItemId: string | null;
totalUnread: number;
unreadCounts: TeamInboxUnreadCounts;
+ groupingReferenceTime: number;
query: string;
loading: boolean;
onQueryChange: (query: string) => void;
@@ -63,6 +64,7 @@ const TeamInboxList: React.FC = ({
selectedItemId,
totalUnread,
unreadCounts,
+ groupingReferenceTime,
query,
loading,
onQueryChange,
@@ -83,8 +85,8 @@ const TeamInboxList: React.FC = ({
[items, selectedItemId]
);
const groups = useMemo(
- () => groupTeamInboxItemsByRecency(items, Date.now()),
- [items]
+ () => groupTeamInboxItemsByRecency(items, groupingReferenceTime),
+ [groupingReferenceTime, items]
);
const activeFilterUnread = unreadCounts[filter];
const filterTabs = useMemo(
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.test.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.test.ts
new file mode 100644
index 000000000..a26efea95
--- /dev/null
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.test.ts
@@ -0,0 +1,122 @@
+// @vitest-environment jsdom
+import { act, createElement } from "react";
+import { type Root, createRoot } from "react-dom/client";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import type { WorkItemTarget } from "./domain";
+import { useTeamInboxWorkItemBody } from "./useTeamInboxWorkItemBody";
+
+const mocks = vi.hoisted(() => ({
+ readWorkItem: vi.fn(),
+}));
+
+vi.mock("@src/api/http/project", () => ({
+ projectApi: {
+ readWorkItem: mocks.readWorkItem,
+ readStandaloneWorkItem: vi.fn(),
+ },
+}));
+
+function deferred() {
+ let resolve: (value: T) => void = () => undefined;
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+ return { promise, resolve };
+}
+
+const Harness = ({ target }: { target: WorkItemTarget }) => {
+ const state = useTeamInboxWorkItemBody(target);
+ return createElement("output", {
+ "data-body": state.body ?? "",
+ "data-loading": String(state.loading),
+ });
+};
+
+const reactActEnvironment = globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
+};
+
+describe("useTeamInboxWorkItemBody", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeAll(() => {
+ reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
+ });
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ afterAll(() => {
+ Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT");
+ });
+
+ it("shows loading for a new target and discards the stale response", async () => {
+ const first = deferred<{ body: string }>();
+ const second = deferred<{ body: string }>();
+ mocks.readWorkItem
+ .mockReturnValueOnce(first.promise)
+ .mockReturnValueOnce(second.promise);
+
+ await act(async () => {
+ root.render(
+ createElement(Harness, {
+ target: {
+ kind: "work_item",
+ projectId: "project-a",
+ workItemId: "item-a",
+ },
+ })
+ );
+ });
+ expect(container.querySelector("output")?.dataset.loading).toBe("true");
+
+ await act(async () => {
+ root.render(
+ createElement(Harness, {
+ target: {
+ kind: "work_item",
+ projectId: "project-b",
+ workItemId: "item-b",
+ },
+ })
+ );
+ });
+ expect(container.querySelector("output")?.dataset.loading).toBe("true");
+
+ await act(async () => {
+ first.resolve({ body: "stale body" });
+ await first.promise;
+ });
+ expect(container.querySelector("output")?.dataset.loading).toBe("true");
+ expect(container.querySelector("output")?.dataset.body).toBe("");
+
+ await act(async () => {
+ second.resolve({ body: "current body" });
+ await second.promise;
+ });
+ expect(container.querySelector("output")?.dataset.loading).toBe("false");
+ expect(container.querySelector("output")?.dataset.body).toBe(
+ "current body"
+ );
+ });
+});
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
index 374fa03c7..b48211e2b 100644
--- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
@@ -13,6 +13,10 @@ export interface TeamInboxWorkItemBodyState {
loading: boolean;
}
+interface KeyedTeamInboxWorkItemBodyState extends TeamInboxWorkItemBodyState {
+ requestKey: string;
+}
+
/**
* Lazily loads the full Work Item body for the selected assigned inbox item so
* the detail preview can render the real content instead of the short list
@@ -24,14 +28,15 @@ export function useTeamInboxWorkItemBody(
target: WorkItemTarget
): TeamInboxWorkItemBodyState {
const { projectId, workItemId } = target;
- const [state, setState] = useState({
+ const requestKey = `${projectId ?? "standalone"}:${workItemId}`;
+ const [state, setState] = useState({
+ requestKey,
body: null,
loading: true,
});
useEffect(() => {
let cancelled = false;
- setState({ body: null, loading: true });
const request = projectId
? projectApi.readWorkItem(projectId, workItemId)
@@ -41,18 +46,25 @@ export function useTeamInboxWorkItemBody(
.then((workItem) => {
if (cancelled) return;
const body = workItem.body.trim();
- setState({ body: body.length > 0 ? body : null, loading: false });
+ setState({
+ requestKey,
+ body: body.length > 0 ? body : null,
+ loading: false,
+ });
})
.catch((error: unknown) => {
if (cancelled) return;
log.warn("Failed to load Team Inbox Work Item body", error);
- setState({ body: null, loading: false });
+ setState({ requestKey, body: null, loading: false });
});
return () => {
cancelled = true;
};
- }, [projectId, workItemId]);
+ }, [projectId, requestKey, workItemId]);
- return state;
+ if (state.requestKey !== requestKey) {
+ return { body: null, loading: true };
+ }
+ return { body: state.body, loading: state.loading };
}