From 0ad43fc7e596576c42ca7958d51eeccb0d6005df Mon Sep 17 00:00:00 2001
From: Harry19081 <20519290+Harry19081@users.noreply.github.com>
Date: Fri, 24 Jul 2026 23:56:20 +0800
Subject: [PATCH 1/5] Reapply "Merge pull request #521 from
org2AI/feat/team-inbox"
This reverts commit 8668baefcbfa9ae55af869e37d3e633e25a55981.
Pre-commit hook ran. Total eslint: 1, total circular: 0
---
.../TeamInbox.md | 82 +++
.../frontend-ui-audit-2026-07-23/TeamInbox.md | 50 ++
.../crates/project-management/src/lib.rs | 2 +
.../project-management/src/projects/schema.rs | 1 +
.../src/team_inbox/commands.rs | 65 +++
.../project-management/src/team_inbox/mod.rs | 18 +
.../src/team_inbox/schema.rs | 23 +
.../src/team_inbox/store.rs | 424 ++++++++++++++
.../src/team_inbox/tests.rs | 510 +++++++++++++++++
.../src/team_inbox/types.rs | 108 ++++
src-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 6f4c07e785dd6ecfed9c5f71a9d7b252f733468e Mon Sep 17 00:00:00 2001
From: Harry19081 <20519290+Harry19081@users.noreply.github.com>
Date: Sat, 25 Jul 2026 00:44:50 +0800
Subject: [PATCH 2/5] refactor(chat-panel): drop narrow-viewport auto-maximize
useNarrowChatFocus force-maximized the docked chat panel whenever the
workbench fell under 480px, and it evaluated mid-drag. Dragging the panel
wider therefore snapped it to a full takeover instead of stopping, which
reads as the panel maximizing itself unprompted.
Remove the hook and its call site so the panel is maximized only by its
explicit toggle, and drop the two useSidebarState comments that pointed
at it for narrow-viewport adaptation.
Pre-commit hook ran. Total eslint: 1, total circular: 0
---
src/hooks/ui/sidebar/useSidebarState.ts | 10 +-
src/hooks/workStation/index.ts | 5 -
.../workStation/useNarrowChatFocus.test.ts | 44 ---
src/hooks/workStation/useNarrowChatFocus.ts | 321 ------------------
src/modules/index.tsx | 2 -
5 files changed, 4 insertions(+), 378 deletions(-)
delete mode 100644 src/hooks/workStation/useNarrowChatFocus.test.ts
delete mode 100644 src/hooks/workStation/useNarrowChatFocus.ts
diff --git a/src/hooks/ui/sidebar/useSidebarState.ts b/src/hooks/ui/sidebar/useSidebarState.ts
index 6d30b1ddc..127a4d0bf 100644
--- a/src/hooks/ui/sidebar/useSidebarState.ts
+++ b/src/hooks/ui/sidebar/useSidebarState.ts
@@ -3,9 +3,9 @@
*
* Manages the single global sidebar: width, collapse, drag-to-resize,
* and preference persistence. Width is user-driven only — the sidebar
- * no longer auto-collapses or re-clamps on window resize. Narrow
- * viewports are handled by `useNarrowChatFocus`, which maximizes the
- * chat panel instead of squeezing the sidebar.
+ * no longer auto-collapses or re-clamps on window resize, and narrow
+ * viewports no longer adapt the layout automatically; the chat panel is
+ * maximized only by its explicit toggle.
*
* Drag listeners are attached synchronously in handleMouseDown (not via
* useEffect) so there is zero render-cycle delay. This also avoids
@@ -52,9 +52,7 @@ export interface UseSidebarStateReturn {
export function useSidebarState(): UseSidebarStateReturn {
// Sidebar width is user-driven only: we no longer auto-shrink the max
// width on window resize, so the user's chosen width stays stable
- // until they drag the handle themselves. See `useNarrowChatFocus` for
- // the narrow-viewport adaptation — it covers the missing chrome by
- // maximizing the chat panel instead of squeezing the sidebar.
+ // until they drag the handle themselves.
const maxWidth = MAX_SIDEBAR_WIDTH;
// Global state — split read/write for isDragging so setter is stable
diff --git a/src/hooks/workStation/index.ts b/src/hooks/workStation/index.ts
index 6abb0127d..aeb2244fb 100644
--- a/src/hooks/workStation/index.ts
+++ b/src/hooks/workStation/index.ts
@@ -49,8 +49,3 @@ export type { WorkStationTabShortcutBridgeOptions } from "./useWorkStationTabSho
export { usePublishWorkstationTabHeader } from "./useWorkstationTabHeader";
export type { WorkstationTabHeaderHost } from "./useWorkstationTabHeader";
-
-export {
- useNarrowChatFocus,
- NARROW_CHAT_FOCUS_BREAKPOINT_PX,
-} from "./useNarrowChatFocus";
diff --git a/src/hooks/workStation/useNarrowChatFocus.test.ts b/src/hooks/workStation/useNarrowChatFocus.test.ts
deleted file mode 100644
index cfa9a4e26..000000000
--- a/src/hooks/workStation/useNarrowChatFocus.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { resolveWorkbenchEvaluationWidth } from "./useNarrowChatFocus";
-
-describe("resolveWorkbenchEvaluationWidth", () => {
- it("uses the projected target width during programmatic reopening", () => {
- expect(
- resolveWorkbenchEvaluationWidth({
- chatPanelDragging: false,
- chatPanelMaximized: false,
- chatVisible: true,
- chatWidth: 520,
- mainContentWidth: 1280,
- measuredWorkbenchWidth: 24,
- })
- ).toBe(760);
- });
-
- it("uses the projected target width while chat is maximized", () => {
- expect(
- resolveWorkbenchEvaluationWidth({
- chatPanelDragging: false,
- chatPanelMaximized: true,
- chatVisible: true,
- chatWidth: 520,
- mainContentWidth: 1280,
- measuredWorkbenchWidth: 0,
- })
- ).toBe(760);
- });
-
- it("uses the measured width during direct chat resizing", () => {
- expect(
- resolveWorkbenchEvaluationWidth({
- chatPanelDragging: true,
- chatPanelMaximized: false,
- chatVisible: true,
- chatWidth: 520,
- mainContentWidth: 1280,
- measuredWorkbenchWidth: 472,
- })
- ).toBe(472);
- });
-});
diff --git a/src/hooks/workStation/useNarrowChatFocus.ts b/src/hooks/workStation/useNarrowChatFocus.ts
deleted file mode 100644
index c61b7cbe0..000000000
--- a/src/hooks/workStation/useNarrowChatFocus.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-/**
- * Auto-maximize the docked chat panel when the WorkStation / Agent
- * Station *workbench* (the area to the right of the global sidebar
- * and to the left of the docked chat panel — where the editor /
- * browser / launchpad / kanban / agent surface actually renders) is
- * too narrow to be usable.
- *
- * The breakpoint deliberately tracks the workbench width, NOT the OS
- * window width and NOT the full content column to the right of the
- * sidebar. So:
- *
- * - Dragging the chat handle wider shrinks the workbench → can flip
- * into a maximized chat slot once it crosses below the breakpoint.
- * - Collapsing the sidebar widens the workbench → can flip the chat
- * slot back out of maximized.
- * - Resizing the OS window changes everything proportionally, so it
- * feels "window-driven" too — but only because the workbench is a
- * downstream of those layout choices.
- *
- * Below `NARROW_CHAT_FOCUS_BREAKPOINT_PX`, `chatPanelMaximizedAtom`
- * is forced to `true` (the same maximized layout the toolbar's
- * maximize button produces). When the workbench grows back above the
- * breakpoint, the flag is cleared — but only if it was *this* hook
- * that set it on the last wide→narrow edge. Manual maximize /
- * un-maximize actions taken while narrow are preserved across the
- * next resize.
- *
- * Width sources (two of them, switched on direct manipulation):
- *
- * - While the user drags the chat divider, `[data-workbench-surface]`
- * provides the live width because the CSS variable intentionally leads
- * the persisted chat-width atom.
- *
- * - At rest or during programmatic pane motion, derive the projected target
- * width from `[data-main-content].contentRect.width - chatWidth`. This
- * keeps animated intermediate widths from looking like a genuine narrow
- * viewport while still shrinking inline native webviews with the real
- * flex track.
- */
-import { useAtomValue, useSetAtom } from "jotai";
-import { useEffect, useRef } from "react";
-
-import {
- chatPanelDraggingAtom,
- chatPanelMaximizedAtom,
- chatVisibleAtom,
- chatWidthAtom,
-} from "@src/store/ui/chatPanelAtom";
-
-/**
- * Below this *workbench* width the chat panel takes over the entire
- * content area. Calibrated so the workbench enters the maximized
- * layout once it gets narrower than a phone-sized strip — i.e. it
- * can no longer usefully host the editor / launchpad / etc.
- * alongside the docked chat. Tune here if the workbench surfaces
- * become more or less tolerant of narrow widths.
- */
-export const NARROW_CHAT_FOCUS_BREAKPOINT_PX = 480;
-
-/**
- * Selector for the workbench surface: the children-wrapping div that
- * does NOT include the docked chat panel.
- */
-const WORKBENCH_SELECTOR = "[data-workbench-surface]";
-
-/**
- * Selector for the main content column (sidebar's flex sibling). Its
- * inner content box (`contentRect.width`) is what we use to derive a
- * projected workbench width while the maximized layout distorts the
- * workbench surface itself.
- */
-const MAIN_CONTENT_SELECTOR = "[data-main-content]";
-
-/**
- * ResizeObserver can only attach to existing elements. The AppShell can mount
- * before the WorkStation tree, so a short-lived MutationObserver waits for
- * those two selectors without a recurring timer.
- */
-
-interface UseNarrowChatFocusOptions {
- /** Only run while a WorkStation / Agent Station route is active. */
- enabled: boolean;
-}
-
-interface ResolveWorkbenchEvaluationWidthOptions {
- chatPanelDragging: boolean;
- chatPanelMaximized: boolean;
- chatVisible: boolean;
- chatWidth: number;
- mainContentWidth: number;
- measuredWorkbenchWidth: number;
-}
-
-/**
- * Use the target normal-flow width for programmatic pane motion so the
- * intermediate animation frames do not look like a genuine narrow layout.
- * During direct manipulation, keep reading the measured workbench because
- * the live CSS width intentionally leads the persisted chat-width atom.
- */
-export function resolveWorkbenchEvaluationWidth({
- chatPanelDragging,
- chatPanelMaximized,
- chatVisible,
- chatWidth,
- mainContentWidth,
- measuredWorkbenchWidth,
-}: ResolveWorkbenchEvaluationWidthOptions): number {
- if (chatPanelDragging && !chatPanelMaximized) {
- return measuredWorkbenchWidth;
- }
-
- const chatSlice = chatVisible ? chatWidth : 0;
- return Math.max(0, mainContentWidth - chatSlice);
-}
-
-export function useNarrowChatFocus({
- enabled,
-}: UseNarrowChatFocusOptions): void {
- const chatPanelMaximized = useAtomValue(chatPanelMaximizedAtom);
- const chatPanelDragging = useAtomValue(chatPanelDraggingAtom);
- const chatWidth = useAtomValue(chatWidthAtom);
- const chatVisible = useAtomValue(chatVisibleAtom);
- const setChatPanelMaximized = useSetAtom(chatPanelMaximizedAtom);
-
- // Edge-triggered state machine. We only flip the maximized flag on
- // a *transition* across the breakpoint:
- //
- // - `wasNarrowRef` remembers the side of the breakpoint at the
- // last evaluation.
- // - `autoTriggeredRef` remembers whether we were the one that
- // maximized on the last wide→narrow edge. Cleared on restore and
- // on manual un-maximize while still narrow, so the next
- // narrow→wide edge stays inert and we don't re-force maximize on
- // every pixel of resize.
- const wasNarrowRef = useRef(null);
- const autoTriggeredRef = useRef(false);
-
- // Latest atom values for the observer callbacks to read without
- // re-subscribing on every render. Mirrored in an effect so the
- // ref write happens after render (react-hooks/refs lint rule).
- const chatPanelMaximizedRef = useRef(chatPanelMaximized);
- const chatPanelDraggingRef = useRef(chatPanelDragging);
- const chatWidthRef = useRef(chatWidth);
- const chatVisibleRef = useRef(chatVisible);
- useEffect(() => {
- chatPanelMaximizedRef.current = chatPanelMaximized;
- chatPanelDraggingRef.current = chatPanelDragging;
- chatWidthRef.current = chatWidth;
- chatVisibleRef.current = chatVisible;
- }, [chatPanelDragging, chatPanelMaximized, chatWidth, chatVisible]);
-
- // Last observed measurements. Cached so atom-driven re-evaluations
- // (chat width slider, chat visibility toggle, maximize toggle) can
- // run without waiting for the next ResizeObserver tick.
- const workbenchWidthRef = useRef(0);
- const mainContentWidthRef = useRef(0);
-
- useEffect(() => {
- if (!enabled) {
- wasNarrowRef.current = null;
- autoTriggeredRef.current = false;
- return;
- }
-
- let workbenchObserver: ResizeObserver | null = null;
- let mainObserver: ResizeObserver | null = null;
- let observedWorkbench: Element | null = null;
- let observedMain: Element | null = null;
- let lookupObserver: MutationObserver | null = null;
-
- const computeWorkbenchWidth = (): number => {
- return resolveWorkbenchEvaluationWidth({
- chatPanelDragging: chatPanelDraggingRef.current,
- chatPanelMaximized: chatPanelMaximizedRef.current,
- chatVisible: chatVisibleRef.current,
- chatWidth: chatWidthRef.current,
- mainContentWidth: mainContentWidthRef.current,
- measuredWorkbenchWidth: workbenchWidthRef.current,
- });
- };
-
- const evaluate = () => {
- const width = computeWorkbenchWidth();
- if (width <= 0) return;
-
- const isNarrow = width < NARROW_CHAT_FOCUS_BREAKPOINT_PX;
- const wasNarrow = wasNarrowRef.current;
- wasNarrowRef.current = isNarrow;
- const maximized = chatPanelMaximizedRef.current;
-
- if (isNarrow && wasNarrow !== true) {
- if (maximized) return;
- setChatPanelMaximized(true);
- autoTriggeredRef.current = true;
- return;
- }
-
- if (!isNarrow && wasNarrow === true) {
- if (!autoTriggeredRef.current) return;
- autoTriggeredRef.current = false;
- if (!maximized) return;
- setChatPanelMaximized(false);
- }
- };
-
- const attachObservers = (workbench: Element, main: Element) => {
- observedWorkbench = workbench;
- observedMain = main;
-
- workbenchObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- workbenchWidthRef.current = entry.contentRect.width;
- }
- evaluate();
- });
- mainObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- mainContentWidthRef.current = entry.contentRect.width;
- }
- evaluate();
- });
-
- workbenchObserver.observe(workbench);
- mainObserver.observe(main);
-
- workbenchWidthRef.current = workbench.getBoundingClientRect().width;
- mainContentWidthRef.current = main.getBoundingClientRect().width;
- evaluate();
- };
-
- const tryAttach = () => {
- const workbench = document.querySelector(WORKBENCH_SELECTOR);
- const main = document.querySelector(MAIN_CONTENT_SELECTOR);
- if (!workbench || !main) return false;
- attachObservers(workbench, main);
- return true;
- };
-
- if (!tryAttach()) {
- lookupObserver = new MutationObserver(() => {
- if (tryAttach()) {
- lookupObserver?.disconnect();
- lookupObserver = null;
- }
- });
- lookupObserver.observe(document.documentElement, {
- childList: true,
- subtree: true,
- });
- }
-
- return () => {
- lookupObserver?.disconnect();
- if (workbenchObserver && observedWorkbench) {
- workbenchObserver.unobserve(observedWorkbench);
- }
- if (mainObserver && observedMain) {
- mainObserver.unobserve(observedMain);
- }
- workbenchObserver?.disconnect();
- mainObserver?.disconnect();
- };
- }, [enabled, setChatPanelMaximized]);
-
- // Atom-driven re-evaluations: chat width drag, chat visibility, or
- // maximize toggle changes shift the projected workbench width
- // without necessarily firing a ResizeObserver tick. Re-run the
- // same logic here so the breakpoint stays in sync.
- useEffect(() => {
- if (!enabled) return;
- if (workbenchWidthRef.current <= 0 && mainContentWidthRef.current <= 0) {
- return;
- }
-
- const width = resolveWorkbenchEvaluationWidth({
- chatPanelDragging,
- chatPanelMaximized,
- chatVisible,
- chatWidth,
- mainContentWidth: mainContentWidthRef.current,
- measuredWorkbenchWidth: workbenchWidthRef.current,
- });
-
- if (width <= 0) return;
-
- const isNarrow = width < NARROW_CHAT_FOCUS_BREAKPOINT_PX;
- const wasNarrow = wasNarrowRef.current;
- wasNarrowRef.current = isNarrow;
-
- if (isNarrow && wasNarrow !== true) {
- if (chatPanelMaximized) return;
- setChatPanelMaximized(true);
- autoTriggeredRef.current = true;
- return;
- }
-
- if (!isNarrow && wasNarrow === true) {
- if (!autoTriggeredRef.current) return;
- autoTriggeredRef.current = false;
- if (!chatPanelMaximized) return;
- setChatPanelMaximized(false);
- }
- }, [
- enabled,
- chatPanelDragging,
- chatWidth,
- chatVisible,
- chatPanelMaximized,
- setChatPanelMaximized,
- ]);
-
- // If the user manually un-maximizes while the workbench is still
- // narrow, drop the auto-flag so the eventual narrow→wide edge
- // doesn't try to restore a state they've already moved past.
- useEffect(() => {
- if (!enabled) return;
- if (chatPanelMaximized) return;
- if (wasNarrowRef.current !== true) return;
- autoTriggeredRef.current = false;
- }, [enabled, chatPanelMaximized]);
-}
diff --git a/src/modules/index.tsx b/src/modules/index.tsx
index 455fe3b16..4d73f96a0 100644
--- a/src/modules/index.tsx
+++ b/src/modules/index.tsx
@@ -32,7 +32,6 @@ import { useProjectDataChangedListener } from "@src/hooks/project";
import { useBackgroundImage } from "@src/hooks/theme/useBackgroundImage";
import { useOpenUrlInBrowser } from "@src/hooks/workStation/browser/useOpenUrlInBrowser";
import { useUrlPreviewEvents } from "@src/hooks/workStation/tabs";
-import { useNarrowChatFocus } from "@src/hooks/workStation/useNarrowChatFocus";
import { useGlobalBrowserWebviewLayering } from "@src/modules/WorkStation/Browser/hooks";
import { CODE_EDITOR_TOUR_EVENT } from "@src/scaffold/Tutorials/codeEditorTourConfig";
import {
@@ -374,7 +373,6 @@ const AppShell = () => {
const shouldBridgeWorkStationPipeline =
!isSettingsRoute && activeChatPanelTab?.type === "session";
- useNarrowChatFocus({ enabled: true });
useWorkStationPipelineBridge(shouldBridgeWorkStationPipeline);
const workStationChatPosition = useAtomValue(workStationChatPositionAtom);
From 22bfbc379be52ee2247da69c1f174d7665190a91 Mon Sep 17 00:00:00 2001
From: Harry19081 <20519290+Harry19081@users.noreply.github.com>
Date: Sat, 25 Jul 2026 00:47:05 +0800
Subject: [PATCH 3/5] fix(team-inbox): tighten list resizing and simplify the
detail surface
Resizing
- Give the list a plain width range (240/280/320) matching the shape of
WORK_STATION_PRIMARY_SIDEBAR; the old 160 floor could not fit the three
filter pills.
- ResizableSplitPanel clamped the committed width only during a drag, so a
width chosen in a wide container survived the container shrinking and
starved the right panel to zero. Re-clamp on container resize and cap the
panel in CSS so the ceiling holds regardless of how the width was set.
- Guard the clamp when the container cannot honour both minimums: the
bounds inverted and collapsed the right panel instead of the left.
List rows
- Drop `block` from the summary span. It set display:block, overriding the
display:-webkit-box that line-clamp-2 needs, so the clamp never applied
and previews ran to full height.
- Render the GitHub brand icon for GitHub-backed work items, identified by
their status vocabulary via a new isGitHubIssueStatus domain helper.
Detail surface
- Paint the pane with the chat pane colour and drop the two nested
bg-surface-selected cards. InfoCard gains an opt-in `plain` variant so the
shared tokens keep their filled default for the ~30 other call sites.
Header
- Remove the redundant title/subtitle row and move refresh and mark-all-read
into the breadcrumb trailing slot, which drops onRefresh, loading,
totalUnread and onMarkAllRead from TeamInboxList.
Default groupTeamInboxItemsByRecency's timestamp inside the domain so the
list no longer calls Date.now() during render, which the purity rule gates.
Pre-commit hook ran. Total eslint: 1, total circular: 0
---
src/components/ResizableSplitPanel/index.tsx | 36 ++++++++++++-
src/config/detailPanelTokens.ts | 2 +
.../MainApp/TeamInbox/TeamInboxView.tsx | 46 +++++++++++-----
.../TeamInbox/__tests__/labels.test.ts | 15 ++++++
.../components/AssignedWorkItemDetail.tsx | 15 ++----
.../components/CommentMentionDetail.tsx | 3 +-
.../components/TeamInboxDetailLayout.tsx | 2 +-
.../TeamInbox/components/TeamInboxList.tsx | 54 +------------------
.../TeamInbox/components/TeamInboxRow.tsx | 23 ++++++--
src/modules/MainApp/TeamInbox/domain/index.ts | 1 +
.../MainApp/TeamInbox/domain/labels.ts | 17 ++++++
.../MainApp/TeamInbox/domain/selectors.ts | 7 ++-
.../shared/layouts/blocks/InfoCard.tsx | 13 ++++-
13 files changed, 151 insertions(+), 83 deletions(-)
diff --git a/src/components/ResizableSplitPanel/index.tsx b/src/components/ResizableSplitPanel/index.tsx
index 75e7d8cc0..dbb3213fb 100644
--- a/src/components/ResizableSplitPanel/index.tsx
+++ b/src/components/ResizableSplitPanel/index.tsx
@@ -94,7 +94,10 @@ const ResizableSplitPanel: React.FC = ({
const effectiveMin = maxRightWidth
? Math.max(minLeftWidth, containerWidth - maxRightWidth)
: minLeftWidth;
- return { min: effectiveMin, max: effectiveMax };
+ // When the container is too narrow to honour both minimums, the bounds
+ // invert and the clamp would collapse the right panel toward zero. Keep
+ // the right panel's reserve and let the left panel give way instead.
+ return { min: Math.min(effectiveMin, effectiveMax), max: effectiveMax };
}, [minLeftWidth, maxLeftWidth, minRightWidth, maxRightWidth]);
/**
@@ -246,6 +249,32 @@ const ResizableSplitPanel: React.FC = ({
onSplitChange,
]);
+ /**
+ * Keep the committed width inside the *live* constraints.
+ *
+ * The drag handler clamps only while a drag is in flight, so a width chosen
+ * in a wide container survives the container shrinking. Because the right
+ * panel is `flex-1 min-w-0`, that stale width starves it to zero and the
+ * left panel reads as maximized. Re-clamping on container resize keeps
+ * `minRightWidth` honoured however the container got smaller.
+ */
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const resizeObserver = new ResizeObserver(() => {
+ if (isResizingRef.current) return;
+ const { min, max } = getEffectiveConstraints();
+ setLeftWidth((current) => {
+ const clamped = Math.max(min, Math.min(max, current));
+ return clamped === current ? current : clamped;
+ });
+ });
+
+ resizeObserver.observe(container);
+ return () => resizeObserver.disconnect();
+ }, [getEffectiveConstraints]);
+
// Width change handler for context menu — updates internal state + notifies parent
const handleContextMenuWidthChange = useCallback(
(width: number) => {
@@ -283,6 +312,11 @@ const ResizableSplitPanel: React.FC = ({
className={`relative flex-shrink-0 overflow-hidden ${leftPanelClassName}`.trim()}
style={{
width: `${leftWidth}px`,
+ // Declarative ceiling: the JS clamp only runs during a drag and on
+ // container resize, so a width committed by any other path (stale
+ // state, context menu, a parent changing the bounds) could still
+ // starve the right panel. CSS enforces the cap unconditionally.
+ maxWidth: `${maxLeftWidth}px`,
contain: "layout style",
display: leftWidth === 0 ? "none" : "block",
}}
diff --git a/src/config/detailPanelTokens.ts b/src/config/detailPanelTokens.ts
index f07d393b0..bb8016d0a 100644
--- a/src/config/detailPanelTokens.ts
+++ b/src/config/detailPanelTokens.ts
@@ -45,6 +45,8 @@ export const COLLAPSIBLE_SECTION_TOKENS = {
export const INFO_CARD_TOKENS = {
/** Card container — rounded, fill background, no border (matches Settings table containers) */
container: "rounded-lg bg-surface-selected p-4",
+ /** Unfilled container — rows sit directly on a panel that paints its own surface */
+ containerPlain: "rounded-lg",
/** Grid gap between rows */
rowGap: "gap-3",
/** Row layout */
diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
index 115da9035..be8faa37e 100644
--- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
+++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx
@@ -1,8 +1,14 @@
+import { CheckCheck } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
+import Button from "@src/components/Button";
import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout";
-import { Placeholder } from "@src/modules/shared/layouts/blocks";
+import {
+ PANEL_HEADER_TOKENS,
+ PanelRefreshButton,
+ Placeholder,
+} from "@src/modules/shared/layouts/blocks";
import {
AssignedWorkItemDetail,
@@ -14,7 +20,6 @@ import {
type TeamInboxFilter,
type TeamInboxItem,
type TeamInboxNavigationIntent,
- countUnreadTeamInboxItems,
countUnreadTeamInboxItemsByFilter,
filterItemKind,
getTeamInboxItemKey,
@@ -96,7 +101,6 @@ const TeamInboxView: React.FC = ({
() => searchTeamInboxItems(selectTeamInboxItems(items, filter), query),
[filter, items, query]
);
- const totalUnread = useMemo(() => countUnreadTeamInboxItems(items), [items]);
const unreadCounts = useMemo(
() => countUnreadTeamInboxItemsByFilter(items),
[items]
@@ -300,13 +304,37 @@ const TeamInboxView: React.FC = ({
) : null}
+ {unreadCounts[filter] > 0 && dataSource.markAllRead ? (
+
+ }
+ title={t("inbox.markAllAsRead")}
+ aria-label={t("inbox.markAllAsRead")}
+ onClick={handleMarkAllRead}
+ />
+ ) : null}
+
+ >
+ }
listPanelBackgroundClassName="bg-bg-2"
- mainContentClassName="bg-bg-1"
+ mainContentClassName="bg-chat-pane"
listContent={
loadState.status === "loading" && items.length === 0 ? (
= ({
filter={filter}
items={visibleItems}
selectedItemId={selectedItemId}
- totalUnread={totalUnread}
unreadCounts={unreadCounts}
query={query}
- loading={loadState.status === "loading"}
onQueryChange={setQuery}
onFilterChange={setFilter}
onSelectItem={handleSelect}
- onRefresh={handleRefresh}
- onMarkAllRead={
- dataSource.markAllRead ? handleMarkAllRead : undefined
- }
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={dataSource.loadMore ? handleLoadMore : undefined}
diff --git a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
index fd71a2434..bd88eff04 100644
--- a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
+++ b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
humanizeToken,
+ isGitHubIssueStatus,
workItemPriorityLabelKey,
workItemStatusLabelKey,
} from "../domain/labels";
@@ -29,6 +30,20 @@ describe("humanizeToken", () => {
});
});
+describe("isGitHubIssueStatus", () => {
+ it("recognizes the GitHub issue status vocabulary", () => {
+ expect(isGitHubIssueStatus("open")).toBe(true);
+ expect(isGitHubIssueStatus("closed")).toBe(true);
+ });
+
+ it("rejects local work-item statuses", () => {
+ expect(isGitHubIssueStatus("todo")).toBe(false);
+ expect(isGitHubIssueStatus("in_progress")).toBe(false);
+ expect(isGitHubIssueStatus("done")).toBe(false);
+ expect(isGitHubIssueStatus("")).toBe(false);
+ });
+});
+
describe("label key builders", () => {
it("namespaces status keys under teamInbox.workItemStatus", () => {
expect(workItemStatusLabelKey("in_progress")).toBe(
diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
index ab80ecbe4..bdbb9ff70 100644
--- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
+++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
@@ -3,7 +3,6 @@ import React from "react";
import { useTranslation } from "react-i18next";
import Markdown from "@src/components/MarkDown";
-import { CARD_ROW_TOKENS } from "@src/modules/shared/layouts/blocks";
import {
type AssignedWorkItem,
@@ -74,17 +73,13 @@ const AssignedWorkItemDetail: React.FC = ({
]}
>
{body ? (
-
-
-
-
+
+
) : excerpt ? (
-
+
+ {excerpt}
+
) : null}
);
diff --git a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
index b3291abe7..16ba3e3df 100644
--- a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
+++ b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx
@@ -3,7 +3,6 @@ import React from "react";
import { useTranslation } from "react-i18next";
import Markdown from "@src/components/MarkDown";
-import { CARD_ROW_TOKENS } from "@src/modules/shared/layouts/blocks";
import type { CommentMentionItem, TeamInboxNavigationIntent } from "../domain";
import TeamInboxDetailLayout from "./TeamInboxDetailLayout";
@@ -66,7 +65,7 @@ const CommentMentionDetail: React.FC
= ({
},
]}
>
-
+
{item.actor.displayName}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
index 8a8ff5713..3011868cb 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
@@ -81,7 +81,7 @@ const TeamInboxDetailLayout: React.FC = ({
{children ? (
{children}
) : null}
-
+
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
index 53ea98f58..0d346f1cb 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx
@@ -1,4 +1,4 @@
-import { AtSign, CheckCheck, ClipboardList, Inbox } from "lucide-react";
+import { AtSign, ClipboardList, Inbox } from "lucide-react";
import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -12,9 +12,6 @@ import TabPill, { type TabPillItem } from "@src/components/TabPill";
import {
ListPanelScrollArea,
ListPanelTabPillRow,
- PANEL_HEADER_TOKENS,
- PanelHeader,
- PanelRefreshButton,
Placeholder,
} from "@src/modules/shared/layouts/blocks";
@@ -31,15 +28,11 @@ 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;
@@ -61,15 +54,11 @@ const TeamInboxList: React.FC
= ({
filter,
items,
selectedItemId,
- totalUnread,
unreadCounts,
query,
- loading,
onQueryChange,
onFilterChange,
onSelectItem,
- onRefresh,
- onMarkAllRead,
hasMore = false,
loadingMore = false,
onLoadMore,
@@ -82,11 +71,7 @@ const TeamInboxList: React.FC = ({
items.findIndex((item) => getTeamInboxItemKey(item) === selectedItemId),
[items, selectedItemId]
);
- const groups = useMemo(
- () => groupTeamInboxItemsByRecency(items, Date.now()),
- [items]
- );
- const activeFilterUnread = unreadCounts[filter];
+ const groups = useMemo(() => groupTeamInboxItemsByRecency(items), [items]);
const filterTabs = useMemo(
() => [
{
@@ -162,41 +147,6 @@ const TeamInboxList: React.FC = ({
className="flex h-full min-h-0 flex-col"
aria-label={t("teamInbox.listLabel")}
>
- 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}
- >
- }
- />
-
(
({ item, itemKey, selected, onSelect }, ref) => {
const { t } = useTranslation();
const isMention = item.kind === "comment_mention";
+ const isGitHubIssue =
+ item.kind === "assigned_work_item" &&
+ isGitHubIssueStatus(item.payload.status);
const title = isMention ? item.target.sessionTitle : item.payload.title;
const summary = useMemo(() => {
if (item.kind === "comment_mention") return item.payload.commentBody;
@@ -61,10 +66,22 @@ const TeamInboxRow = forwardRef(
onClick={() => onSelect(item)}
>
- {isMention ? : }
+ {isMention ? (
+
+ ) : isGitHubIssue ? (
+
+ ) : (
+
+ )}
@@ -83,7 +100,7 @@ const TeamInboxRow = forwardRef(
{relativeTime}
-
+
{summary}
diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts
index 6c1dd973e..516b2ffde 100644
--- a/src/modules/MainApp/TeamInbox/domain/index.ts
+++ b/src/modules/MainApp/TeamInbox/domain/index.ts
@@ -18,6 +18,7 @@ export type {
} from "./selectors";
export {
humanizeToken,
+ isGitHubIssueStatus,
workItemPriorityLabelKey,
workItemStatusLabelKey,
} from "./labels";
diff --git a/src/modules/MainApp/TeamInbox/domain/labels.ts b/src/modules/MainApp/TeamInbox/domain/labels.ts
index f49ddb3a8..38ce70ad7 100644
--- a/src/modules/MainApp/TeamInbox/domain/labels.ts
+++ b/src/modules/MainApp/TeamInbox/domain/labels.ts
@@ -1,3 +1,20 @@
+import { WORK_ITEM_STATUS } from "@src/types/core/workItem";
+
+/**
+ * GitHub-backed work items are identified by their status vocabulary: the sync
+ * adapter writes `open` / `closed` where local items use `todo` / `done`.
+ *
+ * Checked against the shared `WORK_ITEM_STATUS` constant rather than importing
+ * ProjectManager's equivalent helper, for the same isolation reason described
+ * on the label keys below.
+ */
+export function isGitHubIssueStatus(status: string): boolean {
+ return (
+ status === WORK_ITEM_STATUS.GITHUB_OPEN ||
+ status === WORK_ITEM_STATUS.GITHUB_CLOSED
+ );
+}
+
/**
* Turns a raw enum token from the work-item read model (e.g. `in_progress`,
* `HIGH`, `in-review`) into a human sentence-cased label (`In progress`,
diff --git a/src/modules/MainApp/TeamInbox/domain/selectors.ts b/src/modules/MainApp/TeamInbox/domain/selectors.ts
index 1d517dc25..cdda63dd7 100644
--- a/src/modules/MainApp/TeamInbox/domain/selectors.ts
+++ b/src/modules/MainApp/TeamInbox/domain/selectors.ts
@@ -153,7 +153,12 @@ const SESSION_BUCKET_TO_RECENCY: Record<
*/
export function groupTeamInboxItemsByRecency(
items: readonly TeamInboxItem[],
- nowMs: number
+ /**
+ * Defaults to the current time. Kept out of the calling component so the
+ * bucket boundaries are not derived from an impure call during render;
+ * tests and any caller needing determinism pass an explicit value.
+ */
+ nowMs: number = Date.now()
): TeamInboxRecencyGroup[] {
const ranges = getSessionDateBucketRanges(new Date(nowMs));
diff --git a/src/modules/shared/layouts/blocks/InfoCard.tsx b/src/modules/shared/layouts/blocks/InfoCard.tsx
index 141d14989..8d60fe28d 100644
--- a/src/modules/shared/layouts/blocks/InfoCard.tsx
+++ b/src/modules/shared/layouts/blocks/InfoCard.tsx
@@ -22,20 +22,31 @@ export interface InfoCardProps {
/** Extra content rendered above the card (e.g. badges) */
header?: React.ReactNode;
className?: string;
+ /**
+ * `surface` (default) paints the filled card. `plain` drops the fill and
+ * padding so the host panel's own background shows through.
+ */
+ variant?: "surface" | "plain";
}
const InfoCard: React.FC = ({
rows,
header,
className = "",
+ variant = "surface",
}) => {
const visibleRows = rows.filter((row) => !row.hidden);
if (visibleRows.length === 0 && !header) return null;
+ const container =
+ variant === "plain"
+ ? INFO_CARD_TOKENS.containerPlain
+ : INFO_CARD_TOKENS.container;
+
return (
{header}
-
+
{visibleRows.map((row) => (
From 2668e24d6e77446576bc2dda0194c211b2ddc9c9 Mon Sep 17 00:00:00 2001
From: Harry19081 <20519290+Harry19081@users.noreply.github.com>
Date: Sat, 25 Jul 2026 00:50:30 +0800
Subject: [PATCH 4/5] fix(team-inbox): derive work-item body loading state
The effect reset state synchronously on every selection change, cascading an
extra render (react-hooks/set-state-in-effect). Store the resolved body with
the key it was fetched for and derive `loading` by comparing that key against
the current target, so the reset falls out of the render pass.
Pre-commit hook ran. Total eslint: 2, total circular: 0
---
.../TeamInbox/useTeamInboxWorkItemBody.ts | 26 ++++++++++++-------
1 file changed, 17 insertions(+), 9 deletions(-)
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
index 374fa03c7..c97fe7356 100644
--- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
+++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
@@ -24,14 +24,20 @@ export function useTeamInboxWorkItemBody(
target: WorkItemTarget
): TeamInboxWorkItemBodyState {
const { projectId, workItemId } = target;
- const [state, setState] = useState
({
- body: null,
- loading: true,
- });
+ const requestKey = `${projectId}:${workItemId}`;
+ /**
+ * Holds the resolved body together with the key it was fetched for. Loading
+ * is derived by comparing that key against the current target rather than
+ * reset by a synchronous setState in the effect, which would cascade an
+ * extra render on every selection change.
+ */
+ const [resolved, setResolved] = useState<{
+ key: string;
+ body: string | null;
+ } | null>(null);
useEffect(() => {
let cancelled = false;
- setState({ body: null, loading: true });
const request = projectId
? projectApi.readWorkItem(projectId, workItemId)
@@ -41,18 +47,20 @@ export function useTeamInboxWorkItemBody(
.then((workItem) => {
if (cancelled) return;
const body = workItem.body.trim();
- setState({ body: body.length > 0 ? body : null, loading: false });
+ setResolved({ key: requestKey, body: body.length > 0 ? body : null });
})
.catch((error: unknown) => {
if (cancelled) return;
log.warn("Failed to load Team Inbox Work Item body", error);
- setState({ body: null, loading: false });
+ setResolved({ key: requestKey, body: null });
});
return () => {
cancelled = true;
};
- }, [projectId, workItemId]);
+ }, [projectId, workItemId, requestKey]);
- return state;
+ return resolved?.key === requestKey
+ ? { body: resolved.body, loading: false }
+ : { body: null, loading: true };
}
From eac30eff85ed289544b21d916e53152d10c68272 Mon Sep 17 00:00:00 2001
From: Harry19081 <20519290+Harry19081@users.noreply.github.com>
Date: Sat, 25 Jul 2026 01:04:47 +0800
Subject: [PATCH 5/5] refactor(team-inbox): reuse work-item components in
detail
The assigned detail rebuilt a reduced view of a work item from the list
payload: a Markdown body plus a four-row InfoCard. It now composes the same
pair the chat panel's work-item pane uses, so the two surfaces stay in step.
- Replace useTeamInboxWorkItemBody with useTeamInboxWorkItem, which resolves
the full item instead of just its body and persists property edits through
updateWorkItemPartial. Standalone items stay read-only; writing those needs
the full frontmatter round-trip only the owning pane assembles.
- Render WorkItemContent for the body and WorkItemProperties inside a
PropertiesRailFrame. The inbox pane is narrower than the work-item pane, so
the rail appears only past a 720px container width.
- Extract toWorkItemPartialUpdate out of WorkItemPanelView into a shared
module so both surfaces build the same wire payload rather than diverging.
- Give TeamInboxDetailLayout a `fill` content mode and make `metadata`
optional; comment mentions keep the padded scroll column and InfoCard.
Pre-commit hook ran. Total eslint: 2, total circular: 0
---
.../ChatPanel/panels/WorkItemPanelView.tsx | 66 +----------
.../components/AssignedWorkItemDetail.tsx | 104 +++++++++++------
.../components/TeamInboxDetailLayout.tsx | 33 ++++--
.../MainApp/TeamInbox/useTeamInboxWorkItem.ts | 107 ++++++++++++++++++
.../TeamInbox/useTeamInboxWorkItemBody.ts | 66 -----------
.../WorkItems/workItemPartialUpdate.ts | 73 ++++++++++++
6 files changed, 276 insertions(+), 173 deletions(-)
create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts
delete mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
create mode 100644 src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
diff --git a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
index f1522a065..73a1d61a5 100644
--- a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
+++ b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { STORY_SYNC_ADAPTER } from "@src/api/http/integrations/syncConnections";
import {
type WorkItemFrontmatter,
- type WorkItemPartialUpdate,
enrichedWorkItemToUI,
projectApi,
standaloneWorkItemDataToEnriched,
@@ -26,6 +25,7 @@ import {
} from "@src/modules/ProjectManager/WorkItems/components";
import { WorkItemDetailHeaderBreadcrumb } from "@src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailHeader";
import { useWorkItemOrchestrator } from "@src/modules/ProjectManager/WorkItems/hooks";
+import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate";
import { PropertiesRailFrame } from "@src/modules/ProjectManager/shared";
import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared";
import { VerticalResizeHandle } from "@src/scaffold/Resize";
@@ -104,70 +104,6 @@ function applyWorkItemPatch(
};
}
-function toWorkItemPartialUpdate(
- updates: Partial
-): WorkItemPartialUpdate {
- const payload: WorkItemPartialUpdate = {};
-
- if (updates.name !== undefined) payload.title = updates.name;
- if (updates.spec !== undefined) payload.body = updates.spec;
- if (updates.workItemStatus !== undefined) {
- payload.status = updates.workItemStatus;
- }
- if (updates.priority !== undefined) payload.priority = updates.priority;
- if (updates.project?.id) payload.project = updates.project.id;
- if (updates.star !== undefined) payload.starred = updates.star;
- if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null;
- if ("assigneeType" in updates) {
- payload.assigneeType = updates.assigneeType ?? null;
- }
- if ("labels" in updates) {
- payload.labels = updates.labels?.map((label) => label.id) ?? [];
- }
- if ("milestone" in updates) {
- payload.milestone = updates.milestone?.id ?? null;
- }
- if ("startDate" in updates) payload.startDate = updates.startDate ?? null;
- if ("endDate" in updates) payload.targetDate = updates.endDate ?? null;
- if ("target_date" in updates) {
- payload.targetDate = updates.target_date ?? null;
- }
- if (updates.todos !== undefined) {
- payload.todos = updates.todos.map((todo) => ({
- id: todo.id,
- content: todo.content,
- status: todo.status,
- }));
- }
- if (updates.comments !== undefined) {
- payload.comments = updates.comments.map((comment) => ({
- id: comment.id,
- author: comment.author,
- content: comment.content,
- created_at: comment.created_at,
- }));
- }
- if (updates.linkedSessions !== undefined) {
- payload.linkedSessions = updates.linkedSessions;
- }
- if (updates.orchestratorConfig !== undefined) {
- payload.orchestratorConfig = updates.orchestratorConfig;
- }
- if (updates.orchestratorState !== undefined) {
- payload.orchestratorState = updates.orchestratorState;
- }
- if (updates.schedule !== undefined) payload.schedule = updates.schedule;
- if (updates.executionLock !== undefined) {
- payload.executionLock = updates.executionLock;
- }
- if (updates.closeOut !== undefined) payload.closeOut = updates.closeOut;
- if (updates.workProducts !== undefined) {
- payload.workProducts = updates.workProducts;
- }
-
- return payload;
-}
-
export const WorkItemPanelView: React.FC = ({
selectedWorkItem,
onUpdateWorkItem,
diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
index bdbb9ff70..63219ee5e 100644
--- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
+++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx
@@ -2,18 +2,24 @@ import { ClipboardList, ExternalLink } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
-import Markdown from "@src/components/MarkDown";
+import {
+ WorkItemContent,
+ WorkItemProperties,
+} from "@src/modules/ProjectManager/WorkItems/components";
+import { PropertiesRailFrame } from "@src/modules/ProjectManager/shared";
+import { Placeholder } from "@src/modules/shared/layouts/blocks";
import {
type AssignedWorkItem,
type TeamInboxNavigationIntent,
- humanizeToken,
- workItemPriorityLabelKey,
- workItemStatusLabelKey,
+ isGitHubIssueStatus,
} from "../domain";
-import { useTeamInboxWorkItemBody } from "../useTeamInboxWorkItemBody";
+import { useTeamInboxWorkItem } from "../useTeamInboxWorkItem";
import TeamInboxDetailLayout from "./TeamInboxDetailLayout";
+/** Matches the work-item pane's info rail so both surfaces read identically. */
+const PROPERTIES_RAIL_WIDTH = 240;
+
export interface AssignedWorkItemDetailProps {
item: AssignedWorkItem;
onNavigate?: (intent: TeamInboxNavigationIntent) => void;
@@ -28,20 +34,17 @@ const AssignedWorkItemDetail: React.FC = ({
onMarkUnread,
}) => {
const { t } = useTranslation();
- const { body } = useTeamInboxWorkItemBody(item.target);
- const excerpt = item.payload.summary ?? null;
- const statusLabel = t(workItemStatusLabelKey(item.payload.status), {
- defaultValue: humanizeToken(item.payload.status),
- });
- const priorityLabel = t(workItemPriorityLabelKey(item.payload.priority), {
- defaultValue: humanizeToken(item.payload.priority),
- });
+ const { workItem, loading, updateWorkItem } = useTeamInboxWorkItem(
+ item.target
+ );
+ const isGitHubIssue = isGitHubIssueStatus(item.payload.status);
return (
= ({
})
: undefined
}
- metadata={[
- { label: t("teamInbox.fields.status"), value: statusLabel },
- { label: t("teamInbox.fields.priority"), value: priorityLabel },
- {
- label: t("teamInbox.fields.assignee"),
- value: item.payload.assigneeName ?? item.payload.assigneeMemberId,
- },
- {
- label: t("teamInbox.fields.workItemId"),
- value: item.target.workItemId,
- },
- ]}
>
- {body ? (
-
-
+ {loading ? (
+
+ ) : workItem ? (
+
+
+
+ onNavigate({
+ kind: "open_session_comment",
+ sessionId,
+ commentId: "",
+ threadId: "",
+ })
+ : undefined
+ }
+ />
+
+ {/* The inbox detail is narrower than the work-item pane, so the rail
+ only appears once there is room for it beside the content. */}
+
- ) : excerpt ? (
-
- {excerpt}
-
- ) : null}
+ ) : (
+
+ )}
);
};
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
index 3011868cb..beae9bd40 100644
--- a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
+++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx
@@ -16,7 +16,15 @@ export interface TeamInboxDetailLayoutProps {
title: string;
subtitle: string;
icon: LucideIcon;
- metadata: InfoCardRow[];
+ /** Key-value rows rendered under the body. Omitted when the body owns its
+ * own property surface (see `contentLayout: "fill"`). */
+ metadata?: InfoCardRow[];
+ /**
+ * `scroll` (default) puts the body in a padded, centred scroll column.
+ * `fill` hands it the remaining height untouched, for bodies that manage
+ * their own scrolling and side rails.
+ */
+ contentLayout?: "scroll" | "fill";
unread: boolean;
markReadLabel: string;
markUnreadLabel?: string;
@@ -33,6 +41,7 @@ const TeamInboxDetailLayout: React.FC
= ({
subtitle,
icon,
metadata,
+ contentLayout = "scroll",
unread,
markReadLabel,
markUnreadLabel,
@@ -76,14 +85,22 @@ const TeamInboxDetailLayout: React.FC = ({
}
/>
-
-
- {children ? (
-
{children}
- ) : null}
-
+ {contentLayout === "fill" ? (
+
+ {children}
-
+ ) : (
+
+
+ {children ? (
+
{children}
+ ) : null}
+ {metadata && metadata.length > 0 ? (
+
+ ) : null}
+
+
+ )}
{onOpen ? (
) => void;
+}
+
+/**
+ * Loads the full Work Item behind an assigned inbox row so the detail pane can
+ * render the same `WorkItemContent` / `WorkItemProperties` pair the work-item
+ * pane uses, instead of a reduced summary of the list payload.
+ *
+ * The read is demand-driven (one per selection, no polling) and stale responses
+ * are discarded when the selection changes.
+ */
+export function useTeamInboxWorkItem(
+ target: WorkItemTarget
+): TeamInboxWorkItemState {
+ const { projectId, workItemId } = target;
+ const requestKey = `${projectId}:${workItemId}`;
+ /**
+ * Holds the resolved item together with the key it was fetched for. Loading
+ * is derived by comparing that key against the current target rather than
+ * reset by a synchronous setState in the effect.
+ */
+ const [resolved, setResolved] = useState<{
+ key: string;
+ workItem: WorkItem | null;
+ } | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const request = projectId
+ ? projectApi.readWorkItem(projectId, workItemId)
+ : projectApi.readStandaloneWorkItem(workItemId);
+
+ void request
+ .then((data) => {
+ if (cancelled) return;
+ setResolved({
+ key: requestKey,
+ workItem: enrichedWorkItemToUI(
+ standaloneWorkItemDataToEnriched(data)
+ ),
+ });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ log.warn("Failed to load Team Inbox Work Item", error);
+ setResolved({ key: requestKey, workItem: null });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId, workItemId, requestKey]);
+
+ const updateWorkItem = useCallback(
+ (updates: Partial) => {
+ // Standalone items are written back through a full frontmatter
+ // round-trip that only the owning pane assembles, so the inbox edits
+ // project-scoped items and leaves standalone ones read-only.
+ if (!projectId) return;
+
+ const payload = toWorkItemPartialUpdate(updates);
+ if (Object.keys(payload).length === 0) return;
+
+ void projectApi
+ .updateWorkItemPartial(projectId, workItemId, payload)
+ .then((updated) => {
+ setResolved({
+ key: requestKey,
+ workItem: enrichedWorkItemToUI(updated),
+ });
+ })
+ .catch((error: unknown) => {
+ log.warn("Failed to update Team Inbox Work Item", error);
+ });
+ },
+ [projectId, workItemId, requestKey]
+ );
+
+ const isResolved = resolved?.key === requestKey;
+
+ return {
+ workItem: isResolved ? resolved.workItem : null,
+ loading: !isResolved,
+ updateWorkItem,
+ };
+}
diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
deleted file mode 100644
index c97fe7356..000000000
--- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useEffect, useState } from "react";
-
-import { projectApi } from "@src/api/http/project";
-import { createLogger } from "@src/hooks/logger";
-
-import type { WorkItemTarget } from "./domain";
-
-const log = createLogger("TeamInboxWorkItemBody");
-
-export interface TeamInboxWorkItemBodyState {
- /** Full Markdown body once resolved, or null while loading / empty / failed. */
- body: string | null;
- loading: boolean;
-}
-
-/**
- * Lazily loads the full Work Item body for the selected assigned inbox item so
- * the detail preview can render the real content instead of the short list
- * excerpt. The fetch reuses the same project store adapters as navigation and is
- * demand-driven (one read per selection, no polling); stale responses are
- * discarded when the selection changes.
- */
-export function useTeamInboxWorkItemBody(
- target: WorkItemTarget
-): TeamInboxWorkItemBodyState {
- const { projectId, workItemId } = target;
- const requestKey = `${projectId}:${workItemId}`;
- /**
- * Holds the resolved body together with the key it was fetched for. Loading
- * is derived by comparing that key against the current target rather than
- * reset by a synchronous setState in the effect, which would cascade an
- * extra render on every selection change.
- */
- const [resolved, setResolved] = useState<{
- key: string;
- body: string | null;
- } | null>(null);
-
- useEffect(() => {
- let cancelled = false;
-
- const request = projectId
- ? projectApi.readWorkItem(projectId, workItemId)
- : projectApi.readStandaloneWorkItem(workItemId);
-
- void request
- .then((workItem) => {
- if (cancelled) return;
- const body = workItem.body.trim();
- setResolved({ key: requestKey, body: body.length > 0 ? body : null });
- })
- .catch((error: unknown) => {
- if (cancelled) return;
- log.warn("Failed to load Team Inbox Work Item body", error);
- setResolved({ key: requestKey, body: null });
- });
-
- return () => {
- cancelled = true;
- };
- }, [projectId, workItemId, requestKey]);
-
- return resolved?.key === requestKey
- ? { body: resolved.body, loading: false }
- : { body: null, loading: true };
-}
diff --git a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
new file mode 100644
index 000000000..a64c97a37
--- /dev/null
+++ b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts
@@ -0,0 +1,73 @@
+import type { WorkItemPartialUpdate } from "@src/api/http/project";
+import type { WorkItem } from "@src/types/core/workItem";
+
+/**
+ * Map a UI-shaped partial work-item update onto the wire payload the project
+ * store accepts.
+ *
+ * Shared so every surface that edits a work item — the chat panel's work-item
+ * pane and the Team Inbox detail — persists the same fields the same way.
+ */
+export function toWorkItemPartialUpdate(
+ updates: Partial
+): WorkItemPartialUpdate {
+ const payload: WorkItemPartialUpdate = {};
+
+ if (updates.name !== undefined) payload.title = updates.name;
+ if (updates.spec !== undefined) payload.body = updates.spec;
+ if (updates.workItemStatus !== undefined) {
+ payload.status = updates.workItemStatus;
+ }
+ if (updates.priority !== undefined) payload.priority = updates.priority;
+ if (updates.project?.id) payload.project = updates.project.id;
+ if (updates.star !== undefined) payload.starred = updates.star;
+ if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null;
+ if ("assigneeType" in updates) {
+ payload.assigneeType = updates.assigneeType ?? null;
+ }
+ if ("labels" in updates) {
+ payload.labels = updates.labels?.map((label) => label.id) ?? [];
+ }
+ if ("milestone" in updates) {
+ payload.milestone = updates.milestone?.id ?? null;
+ }
+ if ("startDate" in updates) payload.startDate = updates.startDate ?? null;
+ if ("endDate" in updates) payload.targetDate = updates.endDate ?? null;
+ if ("target_date" in updates) {
+ payload.targetDate = updates.target_date ?? null;
+ }
+ if (updates.todos !== undefined) {
+ payload.todos = updates.todos.map((todo) => ({
+ id: todo.id,
+ content: todo.content,
+ status: todo.status,
+ }));
+ }
+ if (updates.comments !== undefined) {
+ payload.comments = updates.comments.map((comment) => ({
+ id: comment.id,
+ author: comment.author,
+ content: comment.content,
+ created_at: comment.created_at,
+ }));
+ }
+ if (updates.linkedSessions !== undefined) {
+ payload.linkedSessions = updates.linkedSessions;
+ }
+ if (updates.orchestratorConfig !== undefined) {
+ payload.orchestratorConfig = updates.orchestratorConfig;
+ }
+ if (updates.orchestratorState !== undefined) {
+ payload.orchestratorState = updates.orchestratorState;
+ }
+ if (updates.schedule !== undefined) payload.schedule = updates.schedule;
+ if (updates.executionLock !== undefined) {
+ payload.executionLock = updates.executionLock;
+ }
+ if (updates.closeOut !== undefined) payload.closeOut = updates.closeOut;
+ if (updates.workProducts !== undefined) {
+ payload.workProducts = updates.workProducts;
+ }
+
+ return payload;
+}