From e97aac62606d957e0528550c1ae52a157b0aedbb Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 27 Jul 2026 11:25:37 +0800 Subject: [PATCH 01/11] feat(team-inbox): unify assignments and mentions Pre-commit hook ran. Total eslint: 10, total circular: 0 --- .../TeamInbox.md | 82 +++ .../frontend-ui-audit-2026-07-23/TeamInbox.md | 50 ++ .../crates/project-management/src/lib.rs | 2 + .../project-management/src/projects/schema.rs | 1 + .../src/team_inbox/commands.rs | 65 +++ .../project-management/src/team_inbox/mod.rs | 18 + .../src/team_inbox/schema.rs | 23 + .../src/team_inbox/store.rs | 424 ++++++++++++++ .../src/team_inbox/tests.rs | 510 +++++++++++++++++ .../src/team_inbox/types.rs | 108 ++++ src/api/realtime/websocket/schemas.ts | 1 + src/engines/ChatPanel/ChatPanelTabBar.tsx | 10 + src/engines/ChatPanel/TabContent/registry.ts | 6 + .../ChatPanel/TabContent/surfaceRenderers.tsx | 11 + .../ChatPanel/chatPanelTabDisplay.test.ts | 7 + src/engines/ChatPanel/chatPanelTabDisplay.ts | 3 + .../Org2Cloud/teamInboxMentionsClient.test.ts | 165 ++++++ .../Org2Cloud/teamInboxMentionsClient.ts | 101 ++++ src/i18n/locales/en/common.json | 94 ++++ src/i18n/locales/zh/common.json | 94 ++++ .../TeamInbox/ConnectedTeamInboxView.tsx | 13 + src/modules/MainApp/TeamInbox/TEST_CASES.md | 52 ++ .../MainApp/TeamInbox/TeamInboxView.tsx | 356 ++++++++++++ .../MainApp/TeamInbox/__tests__/TEST_CASES.md | 77 +++ .../TeamInbox/__tests__/cursor.test.ts | 23 + .../TeamInbox/__tests__/labels.test.ts | 42 ++ .../TeamInbox/__tests__/selectors.test.ts | 234 ++++++++ .../MainApp/TeamInbox/__tests__/store.test.ts | 63 +++ src/modules/MainApp/TeamInbox/api.ts | 201 +++++++ .../components/AssignedWorkItemDetail.tsx | 93 ++++ .../components/CommentMentionDetail.tsx | 94 ++++ .../components/TeamInboxDetailLayout.tsx | 100 ++++ .../TeamInbox/components/TeamInboxList.tsx | 311 +++++++++++ .../TeamInbox/components/TeamInboxRow.tsx | 100 ++++ .../MainApp/TeamInbox/components/index.ts | 10 + .../MainApp/TeamInbox/domain/cursor.ts | 13 + src/modules/MainApp/TeamInbox/domain/index.ts | 39 ++ .../MainApp/TeamInbox/domain/labels.ts | 37 ++ .../MainApp/TeamInbox/domain/selectors.ts | 247 ++++++++ src/modules/MainApp/TeamInbox/domain/types.ts | 109 ++++ src/modules/MainApp/TeamInbox/index.ts | 7 + src/modules/MainApp/TeamInbox/store.ts | 87 +++ .../TeamInbox/useTeamInboxDataSource.ts | 527 ++++++++++++++++++ .../TeamInbox/useTeamInboxNavigation.ts | 84 +++ .../TeamInbox/useTeamInboxWorkItemBody.ts | 69 +++ .../WorkstationSidebarConnector/index.tsx | 10 + .../menuSelection.test.ts | 17 + .../menuSelection.ts | 5 +- .../sidebarConnector.chatPanelAtoms.ts | 3 + .../sidebarConnector.chrome.tsx | 6 + .../sidebarConnector.labels.ts | 4 + .../sidebarConnector.menuItemRouting.ts | 11 + .../sidebarConnector.pinnedAndRevealData.ts | 6 + .../sidebarMenuCollections.ts | 16 +- .../useWorkstationSidebarReveal.ts | 124 +++++ .../connectors/sidebarConnectorUtils.ts | 1 + .../workstationSidebarMenuItems.test.ts | 11 +- .../workstationSidebarMenuItems.tsx | 23 + .../__tests__/chatPanelTabsAtom.test.ts | 29 + src/store/chatPanel/chatPanelTabFactories.ts | 14 + src/store/chatPanel/chatPanelTabOpenAtoms.ts | 20 + src/store/chatPanel/chatPanelTabsAtom.ts | 2 + src/store/chatPanel/chatPanelTabsModel.ts | 7 + 63 files changed, 5068 insertions(+), 4 deletions(-) create mode 100644 docs/architecture-audit-2026-07-23/TeamInbox.md create mode 100644 docs/frontend-ui-audit-2026-07-23/TeamInbox.md create mode 100644 src-tauri/crates/project-management/src/team_inbox/commands.rs create mode 100644 src-tauri/crates/project-management/src/team_inbox/mod.rs create mode 100644 src-tauri/crates/project-management/src/team_inbox/schema.rs create mode 100644 src-tauri/crates/project-management/src/team_inbox/store.rs create mode 100644 src-tauri/crates/project-management/src/team_inbox/tests.rs create mode 100644 src-tauri/crates/project-management/src/team_inbox/types.rs create mode 100644 src/features/Org2Cloud/teamInboxMentionsClient.test.ts create mode 100644 src/features/Org2Cloud/teamInboxMentionsClient.ts create mode 100644 src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx create mode 100644 src/modules/MainApp/TeamInbox/TEST_CASES.md create mode 100644 src/modules/MainApp/TeamInbox/TeamInboxView.tsx create mode 100644 src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md create mode 100644 src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts create mode 100644 src/modules/MainApp/TeamInbox/__tests__/labels.test.ts create mode 100644 src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts create mode 100644 src/modules/MainApp/TeamInbox/__tests__/store.test.ts create mode 100644 src/modules/MainApp/TeamInbox/api.ts create mode 100644 src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx create mode 100644 src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx create mode 100644 src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx create mode 100644 src/modules/MainApp/TeamInbox/components/index.ts create mode 100644 src/modules/MainApp/TeamInbox/domain/cursor.ts create mode 100644 src/modules/MainApp/TeamInbox/domain/index.ts create mode 100644 src/modules/MainApp/TeamInbox/domain/labels.ts create mode 100644 src/modules/MainApp/TeamInbox/domain/selectors.ts create mode 100644 src/modules/MainApp/TeamInbox/domain/types.ts create mode 100644 src/modules/MainApp/TeamInbox/index.ts create mode 100644 src/modules/MainApp/TeamInbox/store.ts create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts create mode 100644 src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts diff --git a/docs/architecture-audit-2026-07-23/TeamInbox.md b/docs/architecture-audit-2026-07-23/TeamInbox.md new file mode 100644 index 0000000000..7d4be898cb --- /dev/null +++ b/docs/architecture-audit-2026-07-23/TeamInbox.md @@ -0,0 +1,82 @@ +# Architecture Audit — Team Inbox + +**Scope:** Team Inbox TypeScript domain/UI/data source, managed-cloud mention RPC client, project-management SQLite projection, Tauri commands, Sidebar and Chat Panel tab integration. +**Date:** 2026-07-23 + +## Layer 1 — Compilation correctness + +- TypeScript `tsc --noEmit`: passed. +- Tauri application `cargo check -p org2`: passed. +- Focused Rust Team Inbox tests: 7 passed. + +## Layer 2 — Dead code and structural deduplication + +- Production entry path is Sidebar row → singleton Team Inbox tab → connected view → shared cache/data source → local Tauri projection plus managed-cloud mention RPC. +- Sidebar badge and rendered page consume the same cache; no second unread query implementation remains. +- Local assignment reads remain in SQLite; the frontend does not rescan every project Work Item. +- Mention response mapping is centralized in the Team Inbox data source; sorting/filtering/deduplication remain pure domain selectors. + +## Layer 3 — Naming consistency + +- Wire `work_item_assigned` is mapped once to UI `assigned_work_item`; names are explicit at the boundary. +- `viewerMemberIds` is used consistently for the local viewer identity. The cloud RPC deliberately accepts no viewer ID because JWT identity is authoritative. +- Sidebar/menu/tab terms consistently use `team-inbox` / `Team Inbox`. + +## Layer 4 — Semantic overloading + +| Term | Meaning in this change | Verdict | +| ------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| viewer | Explicit local project member IDs, or managed-cloud JWT subject | Kept separate at transport boundaries; never inferred from an agent/session ID. | +| read receipt | SQLite viewer-scoped receipt for local assignment; endpoint+user+org scoped persisted receipt for cloud mention | Separate storage owners with one UI read state. | +| projectId | Project slug for project-store navigation; empty for standalone Work Items | Boundary is explicit and standalone navigation uses the standalone API. | + +## Layer 5 — Default branch analysis + +- Item-kind branching uses discriminated unions with explicit mention/assignment cases; unsupported wire combinations throw. +- Local mentions filter returns an explicit empty page rather than falling through to assignments. +- Cloud RPC failure degrades to local items only; it does not fabricate mention data or scan comment bodies. + +## Layer 6 — Cross-domain concept leakage + +- Project-management owns only local assigned Work Item projection and receipt DDL. +- Managed-cloud mention transport remains under `features/Org2Cloud`. +- Presentation consumes a transport-independent Team Inbox domain contract. + +## Layer 7 — New developer confusion test + +- `ConnectedTeamInboxView` identifies the production-wired surface; `TeamInboxView` remains injectable for tests/reuse. +- `useTeamInboxDataSource` names local/cloud composition and identity resolution explicitly. +- `useTeamInboxNavigation` separates Session comment navigation from project/standalone Work Item navigation. + +## Layer 8 — Wire protocol and serialization + +- Local DTOs use serde-tagged target/payload variants and camelCase fields, covered by Rust serialization tests. +- Cloud request body contains only `p_org_id`, `p_cursor`, and `p_limit`; tests assert no caller-supplied viewer/user ID. +- Cloud response is Zod-validated; malformed counts and pagination input are rejected. + +## Layer 9 — Init parity + +| Entry point | Canonical schema init | Explicit viewer | Blocking DB isolation | +| ------------- | --------------------: | --------------: | --------------------: | +| list page | yes | yes | `spawn_blocking` | +| unread count | yes | yes | `spawn_blocking` | +| mark read | yes | yes | `spawn_blocking` | +| mark all read | yes | yes | `spawn_blocking` | +| mark unread | yes | yes | `spawn_blocking` | + +All five commands (`team_inbox_list_page`, `team_inbox_unread_count`, `team_inbox_mark_read`, `team_inbox_mark_all_read`, `team_inbox_mark_unread`) are registered in the same Tauri handler list. + +## Layer 10 — Resolver symmetry + +- Local viewer identity uses the same current-user member resolver for list, single read, and bulk read. +- Cloud cache and persisted receipt keys use the same endpoint + authenticated user + org scope. +- Project and standalone navigation both resolve raw Work Item data through the same adapter chain before opening the canonical Chat Panel Work Item tab. + +## Completion verdict + +- Canonical DDL changed directly; no `ALTER TABLE` compatibility path was introduced. +- Local cursor ordering and viewer-scoped receipt idempotence are tested. +- Cloud receipt storage is bounded to 1,000 entries. +- No timer or polling loop was introduced; refresh is driven by initial demand, existing project-change signals, cloud comment signals, and mutations. + +**Architecture verdict: pass for the audited Team Inbox scope.** diff --git a/docs/frontend-ui-audit-2026-07-23/TeamInbox.md b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md new file mode 100644 index 0000000000..09b75a4451 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md @@ -0,0 +1,50 @@ +# Frontend UI Audit — Team Inbox + +**Files:** `src/modules/MainApp/TeamInbox/**/*.tsx` +**Date:** 2026-07-23 +**Auditor:** ORGII implementation session + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +|---|---|---|---|---| +| `TeamInboxRow.tsx:42` | raw ` + ) : undefined + ) : onMarkUnread && markUnreadLabel ? ( + + ) : undefined + } + /> + +
+
+ {children ? ( +
{children}
+ ) : null} + +
+
+ + {onOpen ? ( + + ) : null} + +); + +export default TeamInboxDetailLayout; diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx new file mode 100644 index 0000000000..54d94cbb15 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx @@ -0,0 +1,311 @@ +import { AtSign, CheckCheck, ClipboardList, Inbox } from "lucide-react"; +import React, { useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import { + LIST_PANEL_SECTIONS, + LIST_PANEL_SECTION_HEADER, +} from "@src/components/ListPanel"; +import SearchInput from "@src/components/SearchInput"; +import TabPill, { type TabPillItem } from "@src/components/TabPill"; +import { + ListPanelScrollArea, + ListPanelTabPillRow, + PANEL_HEADER_TOKENS, + PanelHeader, + PanelRefreshButton, + Placeholder, +} from "@src/modules/shared/layouts/blocks"; + +import { + type TeamInboxFilter, + type TeamInboxItem, + type TeamInboxUnreadCounts, + getTeamInboxItemKey, + groupTeamInboxItemsByRecency, +} from "../domain"; +import TeamInboxRow from "./TeamInboxRow"; + +export interface TeamInboxListProps { + filter: TeamInboxFilter; + items: readonly TeamInboxItem[]; + recencyAnchorMs: number; + selectedItemId: string | null; + totalUnread: number; + unreadCounts: TeamInboxUnreadCounts; + query: string; + loading: boolean; + onQueryChange: (query: string) => void; + onFilterChange: (filter: TeamInboxFilter) => void; + onSelectItem: (item: TeamInboxItem) => void; + onRefresh?: () => void; + onMarkAllRead?: () => void; + hasMore?: boolean; + loadingMore?: boolean; + onLoadMore?: () => void; +} + +function filterCountBadge(count: number, ariaLabel: string): React.ReactNode { + if (count <= 0) return undefined; + return ( + + {count > 99 ? "99+" : count} + + ); +} + +const TeamInboxList: React.FC = ({ + filter, + items, + recencyAnchorMs, + selectedItemId, + totalUnread, + unreadCounts, + query, + loading, + onQueryChange, + onFilterChange, + onSelectItem, + onRefresh, + onMarkAllRead, + hasMore = false, + loadingMore = false, + onLoadMore, +}) => { + const { t } = useTranslation(); + const hasQuery = query.trim().length > 0; + const rowRefs = useRef(new Map()); + const selectedIndex = useMemo( + () => + items.findIndex((item) => getTeamInboxItemKey(item) === selectedItemId), + [items, selectedItemId] + ); + const groups = useMemo( + () => groupTeamInboxItemsByRecency(items, recencyAnchorMs), + [items, recencyAnchorMs] + ); + const activeFilterUnread = unreadCounts[filter]; + const filterTabs = useMemo( + () => [ + { + key: "all", + label: t("teamInbox.filters.all"), + icon: , + badge: filterCountBadge( + unreadCounts.all, + t("teamInbox.unreadCount", { count: unreadCounts.all }) + ), + }, + { + key: "mentions", + label: t("teamInbox.filters.mentions"), + icon: , + badge: filterCountBadge( + unreadCounts.mentions, + t("teamInbox.unreadCount", { count: unreadCounts.mentions }) + ), + }, + { + key: "assigned", + label: t("teamInbox.filters.assigned"), + icon: , + badge: filterCountBadge( + unreadCounts.assigned, + t("teamInbox.unreadCount", { count: unreadCounts.assigned }) + ), + }, + ], + [t, unreadCounts.all, unreadCounts.mentions, unreadCounts.assigned] + ); + + const selectAt = useCallback( + (index: number) => { + const item = items[index]; + if (!item) return; + onSelectItem(item); + rowRefs.current.get(getTeamInboxItemKey(item))?.focus(); + }, + [items, onSelectItem] + ); + + const handleListKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (items.length === 0) return; + const currentIndex = selectedIndex >= 0 ? selectedIndex : 0; + let nextIndex: number | null = null; + switch (event.key) { + case "ArrowDown": + nextIndex = Math.min(currentIndex + 1, items.length - 1); + break; + case "ArrowUp": + nextIndex = Math.max(currentIndex - 1, 0); + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = items.length - 1; + break; + default: + return; + } + event.preventDefault(); + selectAt(nextIndex); + }, + [items.length, selectAt, selectedIndex] + ); + + return ( +
+ 0 + ? t("teamInbox.unreadCount", { count: totalUnread }) + : t("teamInbox.allRead") + } + variant="list" + actions={ + <> + {activeFilterUnread > 0 && onMarkAllRead ? ( + + + ) : null} + + )} +
+ ); +}; + +export default TeamInboxList; diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx new file mode 100644 index 0000000000..1903b8bb60 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx @@ -0,0 +1,100 @@ +import { AtSign, ClipboardList } from "lucide-react"; +import { forwardRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { getListItemClasses } from "@src/components/ListPanel"; +import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; + +import { + type TeamInboxItem, + humanizeToken, + workItemPriorityLabelKey, + workItemStatusLabelKey, +} from "../domain"; + +export interface TeamInboxRowProps { + item: TeamInboxItem; + itemKey: string; + selected: boolean; + onSelect: (item: TeamInboxItem) => void; +} + +const TeamInboxRow = forwardRef( + ({ item, itemKey, selected, onSelect }, ref) => { + const { t } = useTranslation(); + const isMention = item.kind === "comment_mention"; + const title = isMention ? item.target.sessionTitle : item.payload.title; + const summary = useMemo(() => { + if (item.kind === "comment_mention") return item.payload.commentBody; + if (item.payload.summary) return item.payload.summary; + const status = t(workItemStatusLabelKey(item.payload.status), { + defaultValue: humanizeToken(item.payload.status), + }); + const priority = t(workItemPriorityLabelKey(item.payload.priority), { + defaultValue: humanizeToken(item.payload.priority), + }); + return t("teamInbox.row.assignedSummary", { status, priority }); + }, [item, t]); + const personName = isMention + ? item.actor.displayName + : (item.payload.assigneeName ?? item.payload.assigneeMemberId); + const relativeTime = useMemo( + () => formatRelativeTime(item.occurredAt, "nano"), + [item.occurredAt] + ); + const unread = item.readAt === null; + const readLabel = t( + unread ? "teamInbox.status.unread" : "teamInbox.status.read" + ); + + return ( + + ); + } +); + +TeamInboxRow.displayName = "TeamInboxRow"; + +export default TeamInboxRow; diff --git a/src/modules/MainApp/TeamInbox/components/index.ts b/src/modules/MainApp/TeamInbox/components/index.ts new file mode 100644 index 0000000000..c495c4303f --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/index.ts @@ -0,0 +1,10 @@ +export { default as AssignedWorkItemDetail } from "./AssignedWorkItemDetail"; +export type { AssignedWorkItemDetailProps } from "./AssignedWorkItemDetail"; +export { default as CommentMentionDetail } from "./CommentMentionDetail"; +export type { CommentMentionDetailProps } from "./CommentMentionDetail"; +export { default as TeamInboxDetailLayout } from "./TeamInboxDetailLayout"; +export type { TeamInboxDetailLayoutProps } from "./TeamInboxDetailLayout"; +export { default as TeamInboxList } from "./TeamInboxList"; +export type { TeamInboxListProps } from "./TeamInboxList"; +export { default as TeamInboxRow } from "./TeamInboxRow"; +export type { TeamInboxRowProps } from "./TeamInboxRow"; diff --git a/src/modules/MainApp/TeamInbox/domain/cursor.ts b/src/modules/MainApp/TeamInbox/domain/cursor.ts new file mode 100644 index 0000000000..bc92b14ad9 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/cursor.ts @@ -0,0 +1,13 @@ +/** + * Encodes a Team Inbox cursor item key into the backend cursor `itemId`. + * + * The local read model's cursor already carries the backend source id + * (`work_item_assigned:`), and the Rust `list_page` command strips + * that `work_item_assigned:` source prefix itself. Only the UI kind prefix + * (`assigned_work_item:`) — if a UI item key is passed by mistake — must be + * removed here. The `work_item_assigned:` source prefix MUST be preserved, or + * the backend rejects the cursor with "Unsupported Team Inbox cursor item id". + */ +export function toWireCursorItemId(itemKey: string): string { + return itemKey.replace(/^assigned_work_item:/, ""); +} diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts new file mode 100644 index 0000000000..6c1dd973ee --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/index.ts @@ -0,0 +1,39 @@ +export { + countUnreadTeamInboxItems, + countUnreadTeamInboxItemsByFilter, + dedupeTeamInboxItems, + filterItemKind, + filterTeamInboxItems, + getTeamInboxItemKey, + groupTeamInboxItemsByRecency, + searchTeamInboxItems, + selectTeamInboxItems, + sortTeamInboxItems, + toTeamInboxNavigationIntent, +} from "./selectors"; +export type { + TeamInboxRecencyGroup, + TeamInboxRecencyGroupKey, + TeamInboxUnreadCounts, +} from "./selectors"; +export { + humanizeToken, + workItemPriorityLabelKey, + workItemStatusLabelKey, +} from "./labels"; +export { toWireCursorItemId } from "./cursor"; +export type { + AssignedWorkItem, + CommentMentionItem, + ListTeamInboxInput, + SessionCommentTarget, + TeamInboxActor, + TeamInboxCursor, + TeamInboxDataSource, + TeamInboxFilter, + TeamInboxItem, + TeamInboxNavigationIntent, + TeamInboxPage, + TeamInboxTarget, + WorkItemTarget, +} from "./types"; diff --git a/src/modules/MainApp/TeamInbox/domain/labels.ts b/src/modules/MainApp/TeamInbox/domain/labels.ts new file mode 100644 index 0000000000..f49ddb3a80 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/labels.ts @@ -0,0 +1,37 @@ +/** + * Turns a raw enum token from the work-item read model (e.g. `in_progress`, + * `HIGH`, `in-review`) into a human sentence-cased label (`In progress`, + * `High`, `In review`). + * + * This is the deterministic fallback for values that have no explicit localized + * key; callers pass the result as the i18next `defaultValue` so a translated + * label wins when present and raw enum strings never leak to the UI. + */ +export function humanizeToken(value: string): string { + const normalized = value.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " "); + if (!normalized) return ""; + const lower = normalized.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +/** + * i18n key for a work-item status/priority token, with a humanized default. + * + * Team Inbox deliberately owns the `teamInbox.workItemStatus.*` / + * `teamInbox.priority.*` namespaces instead of reusing ProjectManager's + * `workItems.statusLabels.*` / `workItems.priorityLabels.*`. The two label sets + * model *different* status vocabularies — Team Inbox surfaces read-model tokens + * like `todo` / `done` / `blocked`, while ProjectManager uses `planned` / + * `completed` and omits `blocked` — so pointing at the shared keys would drop + * those labels to the humanized fallback. Keeping the namespaces separate is + * intentional isolation, not accidental duplication; the humanized default keeps + * any unmapped token readable. + */ +export function workItemStatusLabelKey(status: string): string { + return `teamInbox.workItemStatus.${status}`; +} + +/** i18n key for a work-item priority token, with a humanized default value. */ +export function workItemPriorityLabelKey(priority: string): string { + return `teamInbox.priority.${priority}`; +} diff --git a/src/modules/MainApp/TeamInbox/domain/selectors.ts b/src/modules/MainApp/TeamInbox/domain/selectors.ts new file mode 100644 index 0000000000..1d517dc252 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/selectors.ts @@ -0,0 +1,247 @@ +import { + type SessionDateBucket, + getSessionDateBucketRanges, +} from "@src/util/session/sessionDateBuckets"; + +import type { + TeamInboxFilter, + TeamInboxItem, + TeamInboxNavigationIntent, +} from "./types"; + +const INVALID_TIMESTAMP = Number.NEGATIVE_INFINITY; + +function timestamp(value: string): number { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? INVALID_TIMESTAMP : parsed; +} + +export function getTeamInboxItemKey(item: TeamInboxItem): string { + return `${item.kind}:${item.id}`; +} + +/** + * De-duplicates pages by canonical item identity. When a later page contains a + * fresher copy of the same item, the fresher copy wins. + */ +export function dedupeTeamInboxItems( + items: readonly TeamInboxItem[] +): TeamInboxItem[] { + const byKey = new Map(); + + for (const item of items) { + const key = getTeamInboxItemKey(item); + const current = byKey.get(key); + if ( + !current || + timestamp(item.occurredAt) > timestamp(current.occurredAt) + ) { + byKey.set(key, item); + } + } + + return [...byKey.values()]; +} + +/** Newest first; identity is a deterministic tie-breaker for cursor stability. */ +export function sortTeamInboxItems( + items: readonly TeamInboxItem[] +): TeamInboxItem[] { + return [...items].sort((left, right) => { + const timeDifference = + timestamp(right.occurredAt) - timestamp(left.occurredAt); + if (timeDifference !== 0) return timeDifference; + return getTeamInboxItemKey(left).localeCompare(getTeamInboxItemKey(right)); + }); +} + +export function filterTeamInboxItems( + items: readonly TeamInboxItem[], + filter: TeamInboxFilter +): TeamInboxItem[] { + if (filter === "all") return [...items]; + const kind = filter === "mentions" ? "comment_mention" : "assigned_work_item"; + return items.filter((item) => item.kind === kind); +} + +export function selectTeamInboxItems( + items: readonly TeamInboxItem[], + filter: TeamInboxFilter +): TeamInboxItem[] { + return filterTeamInboxItems( + sortTeamInboxItems(dedupeTeamInboxItems(items)), + filter + ); +} + +/** Fields searched for each item kind, so the free-text query stays discoverable. */ +function searchableText(item: TeamInboxItem): string[] { + if (item.kind === "comment_mention") { + return [ + item.target.sessionTitle, + item.payload.commentBody, + item.payload.context ?? "", + item.actor.displayName, + ]; + } + return [ + item.payload.title, + item.payload.summary ?? "", + item.payload.assigneeName ?? item.payload.assigneeMemberId, + item.payload.status, + item.payload.priority, + item.actor.displayName, + ]; +} + +/** + * Case-insensitive free-text filter over the already-loaded items. An empty or + * whitespace-only query returns every item unchanged; otherwise an item is kept + * when any of its searchable fields contains the query. + */ +export function searchTeamInboxItems( + items: readonly TeamInboxItem[], + query: string +): TeamInboxItem[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...items]; + return items.filter((item) => + searchableText(item).some((text) => text.toLowerCase().includes(needle)) + ); +} + +export type TeamInboxRecencyGroupKey = + | "today" + | "yesterday" + | "thisWeek" + | "earlier"; + +export interface TeamInboxRecencyGroup { + key: TeamInboxRecencyGroupKey; + items: TeamInboxItem[]; +} + +const RECENCY_GROUP_ORDER: TeamInboxRecencyGroupKey[] = [ + "today", + "yesterday", + "thisWeek", + "earlier", +]; + +/** + * Maps the shared session date-bucket keys onto the Team Inbox recency keys, so + * both surfaces derive day boundaries from one source of truth + * (`getSessionDateBucketRanges`). Only the presentation key name differs + * ("earlier" here vs "older" in the shared helper). + */ +const SESSION_BUCKET_TO_RECENCY: Record< + SessionDateBucket, + TeamInboxRecencyGroupKey +> = { + today: "today", + yesterday: "yesterday", + thisWeek: "thisWeek", + older: "earlier", +}; + +/** + * Buckets already-ordered items into recency sections relative to `nowMs` + * (Today / Yesterday / This week / Earlier). Day boundaries are reused from the + * shared `getSessionDateBucketRanges` helper so the "this week" window stays + * consistent with the rest of the app. Empty groups are omitted and group order + * is stable; unparseable timestamps fall into "earlier". + */ +export function groupTeamInboxItemsByRecency( + items: readonly TeamInboxItem[], + nowMs: number +): TeamInboxRecencyGroup[] { + const ranges = getSessionDateBucketRanges(new Date(nowMs)); + + const buckets: Record = { + today: [], + yesterday: [], + thisWeek: [], + earlier: [], + }; + + for (const item of items) { + const occurred = Date.parse(item.occurredAt); + let key: TeamInboxRecencyGroupKey = "earlier"; + if (!Number.isNaN(occurred)) { + const match = ranges.find( + ({ startMs, endMs }) => + (startMs === undefined || occurred >= startMs) && + (endMs === undefined || occurred < endMs) + ); + if (match) key = SESSION_BUCKET_TO_RECENCY[match.bucket]; + } + buckets[key].push(item); + } + + return RECENCY_GROUP_ORDER.filter((key) => buckets[key].length > 0).map( + (key) => ({ key, items: buckets[key] }) + ); +} + +export function countUnreadTeamInboxItems( + items: readonly TeamInboxItem[] +): number { + return dedupeTeamInboxItems(items).reduce( + (count, item) => count + (item.readAt === null ? 1 : 0), + 0 + ); +} + +export interface TeamInboxUnreadCounts { + all: number; + mentions: number; + assigned: number; +} + +/** + * Unread totals split by the surfaces the filter tabs expose. Canonical items + * are de-duplicated first so a duplicated page never double-counts a badge. + */ +export function countUnreadTeamInboxItemsByFilter( + items: readonly TeamInboxItem[] +): TeamInboxUnreadCounts { + return dedupeTeamInboxItems(items).reduce( + (counts, item) => { + if (item.readAt !== null) return counts; + counts.all += 1; + if (item.kind === "comment_mention") counts.mentions += 1; + else counts.assigned += 1; + return counts; + }, + { all: 0, mentions: 0, assigned: 0 } + ); +} + +/** Maps a filter tab to the item kind it exposes, or null for the combined view. */ +export function filterItemKind( + filter: TeamInboxFilter +): TeamInboxItem["kind"] | null { + if (filter === "mentions") return "comment_mention"; + if (filter === "assigned") return "assigned_work_item"; + return null; +} + +export function toTeamInboxNavigationIntent( + item: TeamInboxItem +): TeamInboxNavigationIntent { + if (item.target.kind === "session_comment") { + return { + kind: "open_session_comment", + sessionId: item.target.sessionId, + commentId: item.target.commentId, + threadId: item.target.threadId, + ...(item.target.anchor ? { anchor: item.target.anchor } : {}), + }; + } + + return { + kind: "open_work_item", + projectId: item.target.projectId, + workItemId: item.target.workItemId, + }; +} diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts new file mode 100644 index 0000000000..7da20f9cc8 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/types.ts @@ -0,0 +1,109 @@ +export type TeamInboxFilter = "all" | "mentions" | "assigned"; + +export interface TeamInboxActor { + id: string; + displayName: string; + avatarUrl?: string; +} + +export interface SessionCommentTarget { + kind: "session_comment"; + sessionId: string; + sessionTitle: string; + commentId: string; + threadId: string; + anchor?: string; +} + +export interface WorkItemTarget { + kind: "work_item"; + projectId: string; + workItemId: string; +} + +export type TeamInboxTarget = SessionCommentTarget | WorkItemTarget; + +interface TeamInboxItemBase { + id: string; + occurredAt: string; + readAt: string | null; + actor: TeamInboxActor; +} + +export interface CommentMentionItem extends TeamInboxItemBase { + kind: "comment_mention"; + target: SessionCommentTarget; + payload: { + commentBody: string; + context?: string; + commentCount: number; + }; +} + +export interface AssignedWorkItem extends TeamInboxItemBase { + kind: "assigned_work_item"; + target: WorkItemTarget; + payload: { + title: string; + status: string; + priority: string; + /** Raw member id from the read model; the stable assignee identity. */ + assigneeMemberId: string; + /** Display name resolved from project members; absent until resolved. */ + assigneeName?: string; + summary?: string; + updatedAt: string; + }; +} + +export type TeamInboxItem = CommentMentionItem | AssignedWorkItem; + +export interface TeamInboxCursor { + occurredAt: string; + itemKey: string; +} + +export interface TeamInboxPage { + items: TeamInboxItem[]; + nextCursor: TeamInboxCursor | null; +} + +export interface ListTeamInboxInput { + cursor?: TeamInboxCursor | null; + limit?: number; + signal?: AbortSignal; +} + +/** + * Transport-independent Team Inbox boundary. + * + * The feature owns presentation and local selection only. Its host supplies an + * implementation backed by the canonical comment/work-item read model. + */ +export interface TeamInboxDataSource { + listPage(input: ListTeamInboxInput): Promise; + markRead?(item: TeamInboxItem): Promise; + markUnread?(item: TeamInboxItem): Promise; + markAllRead?(items: readonly TeamInboxItem[]): Promise; + refresh?(): Promise; + /** + * Loads the next page from every source that still has one and appends the + * results to the current page. A no-op when nothing more is available. + */ + loadMore?(): Promise; + subscribe?(listener: () => void): () => void; +} + +export type TeamInboxNavigationIntent = + | { + kind: "open_session_comment"; + sessionId: string; + commentId: string; + threadId: string; + anchor?: string; + } + | { + kind: "open_work_item"; + projectId: string; + workItemId: string; + }; diff --git a/src/modules/MainApp/TeamInbox/index.ts b/src/modules/MainApp/TeamInbox/index.ts new file mode 100644 index 0000000000..afc88e3cd5 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/index.ts @@ -0,0 +1,7 @@ +export { default } from "./ConnectedTeamInboxView"; +export { default as ConnectedTeamInboxView } from "./ConnectedTeamInboxView"; +export { default as TeamInboxView } from "./TeamInboxView"; +export type { TeamInboxViewProps } from "./TeamInboxView"; +export * from "./components"; +export * from "./domain"; +export { teamInboxUnreadCountAtom } from "./store"; diff --git a/src/modules/MainApp/TeamInbox/store.ts b/src/modules/MainApp/TeamInbox/store.ts new file mode 100644 index 0000000000..e0b26f36e1 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/store.ts @@ -0,0 +1,87 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +import type { TeamInboxItem } from "./domain"; + +export interface TeamInboxCacheState { + items: TeamInboxItem[]; + unreadCount: number; + loading: boolean; + error: string | null; + revision: number; + loadedForViewerKey: string | null; + /** True when either the local or cloud source still has a next page. */ + hasMore: boolean; +} + +export const teamInboxCacheAtom = atom({ + items: [], + unreadCount: 0, + loading: false, + error: null, + revision: 0, + loadedForViewerKey: null, + hasMore: false, +}); +teamInboxCacheAtom.debugLabel = "teamInboxCacheAtom"; + +export const teamInboxUnreadCountAtom = atom( + (get) => get(teamInboxCacheAtom).unreadCount +); +teamInboxUnreadCountAtom.debugLabel = "teamInboxUnreadCountAtom"; + +export const teamInboxInvalidationAtom = atom(0); +teamInboxInvalidationAtom.debugLabel = "teamInboxInvalidationAtom"; + +export type TeamInboxCloudReadReceipts = Record; +export const MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS = 1_000; + +export function addTeamInboxCloudReadReceipts( + current: TeamInboxCloudReadReceipts, + additions: TeamInboxCloudReadReceipts +): TeamInboxCloudReadReceipts { + const next = { ...current }; + for (const [key, readAt] of Object.entries(additions)) { + delete next[key]; + next[key] = readAt; + } + const keys = Object.keys(next); + for ( + let index = 0; + index < keys.length - MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS; + index += 1 + ) { + delete next[keys[index]!]; + } + return next; +} + +export function removeTeamInboxCloudReadReceipts( + current: TeamInboxCloudReadReceipts, + keys: readonly string[] +): TeamInboxCloudReadReceipts { + if (keys.length === 0) return current; + let changed = false; + const next = { ...current }; + for (const key of keys) { + if (key in next) { + delete next[key]; + changed = true; + } + } + return changed ? next : current; +} + +export const teamInboxCloudReadReceiptsAtom = + atomWithStorage( + "orgii:team-inbox:cloud-read-receipts", + {}, + undefined, + { getOnInit: true } + ); +teamInboxCloudReadReceiptsAtom.debugLabel = "teamInboxCloudReadReceiptsAtom"; + +export const invalidateTeamInboxAtom = atom(null, (get, set) => { + set(teamInboxInvalidationAtom, get(teamInboxInvalidationAtom) + 1); +}); +invalidateTeamInboxAtom.debugLabel = "invalidateTeamInboxAtom"; diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts new file mode 100644 index 0000000000..3812a06179 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts @@ -0,0 +1,527 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { invalidateProjectCache, projectApi } from "@src/api/http/project"; +import type { MemberEntry } from "@src/api/http/project"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudCommentsSignalAtom, + orgCommentsKey, +} from "@src/features/Org2Cloud/org2CloudCommentsBus"; +import { sidebarActiveCloudOrgIdAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { + type TeamInboxMention, + listTeamInboxMentions, +} from "@src/features/Org2Cloud/teamInboxMentionsClient"; +import { useProjectDataChanged } from "@src/hooks/project"; +import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; + +import { + listLocalTeamInboxPage, + markAllLocalTeamInboxRead, + markLocalTeamInboxItemRead, + markLocalTeamInboxItemUnread, +} from "./api"; +import { dedupeTeamInboxItems } from "./domain"; +import type { + TeamInboxCursor, + TeamInboxDataSource, + TeamInboxFilter, + TeamInboxItem, +} from "./domain"; +import { + type TeamInboxCloudReadReceipts, + addTeamInboxCloudReadReceipts, + invalidateTeamInboxAtom, + removeTeamInboxCloudReadReceipts, + teamInboxCacheAtom, + teamInboxCloudReadReceiptsAtom, + teamInboxInvalidationAtom, +} from "./store"; + +const listeners = new Set<() => void>(); +let membersRequest: Promise | null = null; +let inboxRequest: { + key: string; + promise: Promise<{ + mentionItems: TeamInboxItem[]; + localItems: TeamInboxItem[]; + localUnread: number; + localNextCursor: TeamInboxCursor | null; + cloudNextCursor: string | null; + }>; +} | null = null; + +function notifyTeamInboxListeners(): void { + for (const listener of listeners) listener(); +} + +/** + * Maps raw cloud mentions into Team Inbox items with `readAt` left unresolved; + * the caller overlays the latest local read receipts afterwards. Shared by the + * initial load and `loadMore` so both pages produce identical item shapes. + */ +function mapMentionsToItems( + mentions: readonly TeamInboxMention[], + activeCloudOrgId: string +): TeamInboxItem[] { + return mentions.map((mention) => { + const itemId = `cloud-comment:${activeCloudOrgId}:${mention.comment.id}`; + return { + id: itemId, + kind: "comment_mention" as const, + occurredAt: mention.createdAt, + readAt: null, + actor: { + id: mention.author.userId, + displayName: mention.author.displayName ?? "Team member", + }, + target: { + kind: "session_comment" as const, + sessionId: mention.session.id, + sessionTitle: mention.session.title ?? "Session", + commentId: mention.comment.id, + threadId: mention.comment.parentId ?? mention.comment.id, + anchor: mention.comment.id, + }, + payload: { + commentBody: mention.body, + commentCount: mention.commentCount, + context: `${mention.threadCount} thread comments`, + }, + }; + }); +} + +/** Overlays the current cloud read receipts onto freshly-mapped mention items. */ +function overlayCloudReadReceipts( + mentionItems: readonly TeamInboxItem[], + cloudReadReceipts: TeamInboxCloudReadReceipts, + cloudScopeKey: string +): TeamInboxItem[] { + return mentionItems.map((item) => ({ + ...item, + readAt: cloudReadReceipts[`${cloudScopeKey}|${item.id}`] ?? null, + })); +} + +/** + * Resolves each assigned item's display name from its stable `assigneeMemberId` + * into the optional `assigneeName` field. When the member cannot be resolved the + * name is left unset and consumers fall back to the id, so a row never renders + * blank. + */ +function resolveAssigneeDisplayNames( + items: readonly TeamInboxItem[], + members: readonly MemberEntry[] +): TeamInboxItem[] { + if (members.length === 0) return [...items]; + const nameById = new Map(members.map((member) => [member.id, member.name])); + return items.map((item) => { + if (item.kind !== "assigned_work_item") return item; + const resolved = nameById.get(item.payload.assigneeMemberId); + if (!resolved || resolved === item.payload.assigneeName) return item; + return { + ...item, + payload: { ...item.payload, assigneeName: resolved }, + }; + }); +} + +async function readAllProjectMembers(): Promise { + if (membersRequest) return membersRequest; + membersRequest = (async () => { + const projects = await projectApi.readProjects(); + const memberFiles = await Promise.all( + projects.map((project) => projectApi.readMembers(project.slug)) + ); + const members = new Map(); + for (const file of memberFiles) { + for (const member of file.members) members.set(member.id, member); + } + return [...members.values()]; + })(); + try { + return await membersRequest; + } finally { + membersRequest = null; + } +} + +export function useTeamInboxDataSource(): { + dataSource: TeamInboxDataSource; + viewerMemberIds: readonly string[]; +} { + const [members, setMembers] = useState([]); + const membersRef = useRef([]); + const { memberIds } = useCurrentUserMemberIds(members); + const viewerMemberIds = useMemo(() => [...memberIds].sort(), [memberIds]); + const cache = useAtomValue(teamInboxCacheAtom); + const auth = useAtomValue(org2CloudAuthAtom); + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const activeCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom); + const viewerKey = `${viewerMemberIds.join("|")}::${authIdentityKey ?? "signed-out"}::${activeCloudOrgId ?? "local"}`; + const commentsSignals = useAtomValue(org2CloudCommentsSignalAtom); + const cloudReadReceipts = useAtomValue(teamInboxCloudReadReceiptsAtom); + const cloudReadReceiptsRef = useRef(cloudReadReceipts); + cloudReadReceiptsRef.current = cloudReadReceipts; + const setCloudReadReceipts = useSetAtom(teamInboxCloudReadReceiptsAtom); + const activeCloudCommentsRevision = activeCloudOrgId + ? (commentsSignals[orgCommentsKey(activeCloudOrgId)] ?? 0) + : 0; + const invalidation = useAtomValue(teamInboxInvalidationAtom); + const setCache = useSetAtom(teamInboxCacheAtom); + const invalidate = useSetAtom(invalidateTeamInboxAtom); + const loadGeneration = useRef(0); + const localCursorRef = useRef(null); + const cloudCursorRef = useRef(null); + const loadingMoreRef = useRef(false); + + useEffect(() => { + let cancelled = false; + void readAllProjectMembers() + .then((nextMembers) => { + if (!cancelled) { + membersRef.current = nextMembers; + setMembers(nextMembers); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setCache((current) => ({ + ...current, + error: + error instanceof Error + ? error.message + : "Failed to resolve current Team Inbox member identity", + })); + } + }); + return () => { + cancelled = true; + }; + }, [invalidation, setCache]); + + const refresh = useCallback(async (): Promise => { + const canLoadLocalAssignments = viewerMemberIds.length > 0; + const canLoadCloudMentions = Boolean(auth && activeCloudOrgId); + if (!canLoadLocalAssignments && !canLoadCloudMentions) { + localCursorRef.current = null; + cloudCursorRef.current = null; + setCache((current) => ({ + ...current, + items: [], + unreadCount: 0, + loading: false, + hasMore: false, + loadedForViewerKey: viewerKey, + error: + members.length > 0 + ? "No project member matches the current Git identity" + : null, + })); + notifyTeamInboxListeners(); + return; + } + const generation = ++loadGeneration.current; + setCache((current) => ({ ...current, loading: true, error: null })); + try { + const requestKey = viewerKey; + if (!inboxRequest || inboxRequest.key !== requestKey) { + const promise = Promise.all([ + canLoadLocalAssignments + ? listLocalTeamInboxPage(viewerMemberIds, "all") + : Promise.resolve({ + page: { items: [], nextCursor: null }, + unreadCount: 0, + }), + auth && activeCloudOrgId + ? listTeamInboxMentions( + auth.accessToken, + activeCloudOrgId, + null, + 50 + ).catch(() => ({ mentions: [], nextCursor: undefined })) + : Promise.resolve({ mentions: [], nextCursor: undefined }), + ]).then(([{ page, unreadCount }, mentionPage]) => { + // Read state is intentionally NOT baked in here: the cached request + // promise stays receipt-independent so a mention marked read while + // this request is in flight is not reverted when the page resolves. + // The current cloud read receipts are overlaid after the await below. + const mentionItems = mapMentionsToItems( + mentionPage.mentions, + activeCloudOrgId ?? "" + ); + return { + mentionItems, + localItems: page.items, + localUnread: unreadCount, + localNextCursor: page.nextCursor, + cloudNextCursor: mentionPage.nextCursor ?? null, + }; + }); + inboxRequest = { key: requestKey, promise }; + void promise.finally(() => { + if (inboxRequest?.promise === promise) inboxRequest = null; + }); + } + const { + mentionItems, + localItems, + localUnread, + localNextCursor, + cloudNextCursor, + } = await inboxRequest.promise; + if (generation !== loadGeneration.current) return; + localCursorRef.current = localNextCursor; + cloudCursorRef.current = cloudNextCursor; + // Overlay the latest cloud read receipts here (not inside the cached + // request promise) so optimistic mark-read/unread survives a concurrent + // in-flight list request. + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + const overlaidMentions = overlayCloudReadReceipts( + mentionItems, + cloudReadReceiptsRef.current, + cloudScopeKey + ); + const mergedItems = [...overlaidMentions, ...localItems]; + const unreadCount = + localUnread + + overlaidMentions.filter((item) => item.readAt === null).length; + const resolvedItems = resolveAssigneeDisplayNames( + mergedItems, + membersRef.current + ); + setCache((current) => ({ + ...current, + items: resolvedItems, + unreadCount, + loading: false, + error: null, + loadedForViewerKey: viewerKey, + hasMore: Boolean(localNextCursor || cloudNextCursor), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + } catch (error) { + if (generation !== loadGeneration.current) return; + setCache((current) => ({ + ...current, + loading: false, + error: + error instanceof Error ? error.message : "Failed to load Team Inbox", + })); + notifyTeamInboxListeners(); + } + }, [ + activeCloudOrgId, + auth, + authIdentityKey, + cloudReadReceipts, + members.length, + setCache, + viewerKey, + viewerMemberIds, + ]); + + useEffect(() => { + if (activeCloudCommentsRevision > 0) void refresh(); + }, [activeCloudCommentsRevision, refresh]); + useEffect(() => { + if (cache.loadedForViewerKey === viewerKey && invalidation === 0) return; + void refresh(); + }, [cache.loadedForViewerKey, invalidation, refresh, viewerKey]); + + useProjectDataChanged(() => invalidate()); + + const dataSource = useMemo( + () => ({ + listPage: async () => { + if (cache.error && cache.items.length === 0) + throw new Error(cache.error); + // A non-null nextCursor signals the view that a further page exists; the + // exact value is a sentinel because `loadMore` owns the real per-source + // cursors internally. + return { + items: cache.items, + nextCursor: cache.hasMore + ? { occurredAt: "", itemKey: "team-inbox-has-more" } + : null, + }; + }, + loadMore: async () => { + if (loadingMoreRef.current) return; + const localCursor = localCursorRef.current; + const cloudCursor = cloudCursorRef.current; + if (!localCursor && !cloudCursor) return; + loadingMoreRef.current = true; + try { + const [localResult, cloudResult] = await Promise.all([ + localCursor && viewerMemberIds.length > 0 + ? listLocalTeamInboxPage(viewerMemberIds, "all", localCursor) + : Promise.resolve({ + page: { items: [], nextCursor: null }, + unreadCount: 0, + }), + cloudCursor && auth && activeCloudOrgId + ? listTeamInboxMentions( + auth.accessToken, + activeCloudOrgId, + cloudCursor, + 50 + ).catch(() => ({ mentions: [], nextCursor: undefined })) + : Promise.resolve({ mentions: [], nextCursor: undefined }), + ]); + localCursorRef.current = localResult.page.nextCursor ?? null; + cloudCursorRef.current = cloudResult.nextCursor ?? null; + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + const appendedMentions = overlayCloudReadReceipts( + mapMentionsToItems(cloudResult.mentions, activeCloudOrgId ?? ""), + cloudReadReceiptsRef.current, + cloudScopeKey + ); + const appended = resolveAssigneeDisplayNames( + [...appendedMentions, ...localResult.page.items], + membersRef.current + ); + // Unread badge semantics are intentionally left unchanged here (the + // single-source-of-truth question is tracked separately); loadMore + // only extends the loaded window. + setCache((current) => ({ + ...current, + items: dedupeTeamInboxItems([...current.items, ...appended]), + hasMore: Boolean(localCursorRef.current || cloudCursorRef.current), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + } finally { + loadingMoreRef.current = false; + } + }, + refresh: async () => { + invalidateProjectCache(); + membersRequest = null; + const nextMembers = await readAllProjectMembers(); + membersRef.current = nextMembers; + setMembers(nextMembers); + setCache((current) => ({ + ...current, + loadedForViewerKey: null, + loading: true, + error: null, + })); + invalidate(); + }, + markRead: async (item: TeamInboxItem) => { + const readAt = new Date().toISOString(); + if (item.kind === "comment_mention") { + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + setCloudReadReceipts((current) => + addTeamInboxCloudReadReceipts(current, { + [`${cloudScopeKey}|${item.id}`]: readAt, + }) + ); + } else { + await markLocalTeamInboxItemRead(viewerMemberIds, item.id); + } + setCache((current) => ({ + ...current, + items: current.items.map((candidate) => + candidate.id === item.id ? { ...candidate, readAt } : candidate + ), + unreadCount: Math.max(0, current.unreadCount - 1), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + }, + markUnread: async (item: TeamInboxItem) => { + if (item.kind === "comment_mention") { + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + setCloudReadReceipts((current) => + removeTeamInboxCloudReadReceipts(current, [ + `${cloudScopeKey}|${item.id}`, + ]) + ); + } else { + await markLocalTeamInboxItemUnread(viewerMemberIds, item.id); + } + setCache((current) => ({ + ...current, + items: current.items.map((candidate) => + candidate.id === item.id + ? { ...candidate, readAt: null } + : candidate + ), + unreadCount: current.unreadCount + 1, + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + }, + markAllRead: async (items) => { + const assigned = items.filter( + ( + item + ): item is Extract => + item.kind === "assigned_work_item" + ); + if (assigned.length > 0) { + await markAllLocalTeamInboxRead(viewerMemberIds, "assigned"); + } + const readAt = new Date().toISOString(); + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + const mentionReceipts = items + .filter((item) => item.kind === "comment_mention") + .reduce>((next, item) => { + next[`${cloudScopeKey}|${item.id}`] = readAt; + return next; + }, {}); + if (Object.keys(mentionReceipts).length > 0) { + setCloudReadReceipts((current) => + addTeamInboxCloudReadReceipts(current, mentionReceipts) + ); + } + const itemIds = new Set(items.map((item) => item.id)); + // Decrement only by the items that were actually unread; counting the + // whole set would over-subtract when some passed items were already read. + const newlyReadCount = items.reduce( + (count, item) => count + (item.readAt === null ? 1 : 0), + 0 + ); + setCache((current) => ({ + ...current, + items: current.items.map((item) => + itemIds.has(item.id) ? { ...item, readAt } : item + ), + unreadCount: Math.max(0, current.unreadCount - newlyReadCount), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }), + [ + activeCloudOrgId, + auth, + authIdentityKey, + cache.error, + cache.hasMore, + cache.items, + invalidate, + setCache, + setCloudReadReceipts, + viewerMemberIds, + ] + ); + + return { dataSource, viewerMemberIds }; +} + +export function filterForItem(item: TeamInboxItem): TeamInboxFilter { + return item.kind === "comment_mention" ? "mentions" : "assigned"; +} diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts new file mode 100644 index 0000000000..29d5dc1a3a --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts @@ -0,0 +1,84 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback } from "react"; + +import { + enrichedWorkItemToUI, + projectApi, + standaloneWorkItemDataToEnriched, +} from "@src/api/http/project"; +import { createLogger } from "@src/hooks/logger"; +import { + openOrFocusSessionInChatPanelTabAtom, + openWorkItemInChatPanelTabAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; +import { sessionsAtom } from "@src/store/session"; + +import type { TeamInboxNavigationIntent } from "./domain"; + +const log = createLogger("TeamInboxNavigation"); + +export function useTeamInboxNavigation(): ( + intent: TeamInboxNavigationIntent +) => void { + const sessions = useAtomValue(sessionsAtom); + const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom); + const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); + + return useCallback( + (intent: TeamInboxNavigationIntent) => { + if (intent.kind === "open_session_comment") { + const session = sessions.find( + (candidate) => candidate.session_id === intent.sessionId + ); + openSession({ + sessionId: intent.sessionId, + sessionName: session?.name, + repoPath: session?.repoPath, + }); + window.requestAnimationFrame(() => { + document + .getElementById(intent.anchor ?? `comment-${intent.commentId}`) + ?.scrollIntoView({ block: "center", behavior: "smooth" }); + }); + return; + } + + const openResolvedWorkItem = ( + workItem: Awaited>, + project?: Awaited> + ) => { + const shortId = workItem.frontmatter.short_id; + openWorkItem({ + workItem: enrichedWorkItemToUI( + standaloneWorkItemDataToEnriched(workItem) + ), + shortId, + projectId: project?.meta.id ?? "", + projectSlug: project?.slug ?? "", + projectName: project?.meta.name ?? "Standalone", + orgId: project?.meta.org_id, + }); + }; + + if (!intent.projectId) { + void projectApi + .readStandaloneWorkItem(intent.workItemId) + .then((workItem) => openResolvedWorkItem(workItem)) + .catch((error: unknown) => { + log.warn("Failed to open standalone Team Inbox Work Item", error); + }); + return; + } + + void Promise.all([ + projectApi.readProject(intent.projectId), + projectApi.readWorkItem(intent.projectId, intent.workItemId), + ]) + .then(([project, workItem]) => openResolvedWorkItem(workItem, project)) + .catch((error: unknown) => { + log.warn("Failed to open project Team Inbox Work Item", error); + }); + }, + [openSession, openWorkItem, sessions] + ); +} diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts new file mode 100644 index 0000000000..49bf105605 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; + +import { projectApi } from "@src/api/http/project"; +import { createLogger } from "@src/hooks/logger"; + +import type { WorkItemTarget } from "./domain"; + +const log = createLogger("TeamInboxWorkItemBody"); + +export interface TeamInboxWorkItemBodyState { + /** Full Markdown body once resolved, or null while loading / empty / failed. */ + body: string | null; + loading: boolean; +} + +interface ResolvedWorkItemBodyState extends TeamInboxWorkItemBodyState { + requestKey: string; +} + +/** + * Lazily loads the full Work Item body for the selected assigned inbox item so + * the detail preview can render the real content instead of the short list + * excerpt. The fetch reuses the same project store adapters as navigation and is + * demand-driven (one read per selection, no polling); stale responses are + * discarded when the selection changes. + */ +export function useTeamInboxWorkItemBody( + target: WorkItemTarget +): TeamInboxWorkItemBodyState { + const { projectId, workItemId } = target; + const requestKey = `${projectId ?? "standalone"}:${workItemId}`; + const [state, setState] = useState({ + requestKey, + body: null, + loading: true, + }); + + useEffect(() => { + let cancelled = false; + + const request = projectId + ? projectApi.readWorkItem(projectId, workItemId) + : projectApi.readStandaloneWorkItem(workItemId); + + void request + .then((workItem) => { + if (cancelled) return; + const body = workItem.body.trim(); + setState({ + requestKey, + body: body.length > 0 ? body : null, + loading: false, + }); + }) + .catch((error: unknown) => { + if (cancelled) return; + log.warn("Failed to load Team Inbox Work Item body", error); + setState({ requestKey, body: null, loading: false }); + }); + + return () => { + cancelled = true; + }; + }, [projectId, requestKey, workItemId]); + + return state.requestKey === requestKey + ? state + : { body: null, loading: true }; +} diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx index 022161f320..dcb3fb5133 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx @@ -6,6 +6,8 @@ import { useLocation, useNavigate } from "react-router-dom"; import { useAppNavigation } from "@src/hooks/navigation/useAppNavigation"; import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; +import { teamInboxUnreadCountAtom } from "@src/modules/MainApp/TeamInbox/store"; +import { useTeamInboxDataSource } from "@src/modules/MainApp/TeamInbox/useTeamInboxDataSource"; import { activeSessionCreatorDraftIdAtom, deleteSessionCreatorDraftAtom, @@ -65,6 +67,8 @@ export const WorkstationSidebarConnector: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); const sessions = useAtomValue(sessionsAtom); + useTeamInboxDataSource(); + const teamInboxUnreadCount = useAtomValue(teamInboxUnreadCountAtom); const sessionsLoading = useAtomValue(sessionLoadingAtom); const sessionPagination = useAtomValue(sessionPaginationAtom); const sessionSidebarRevealRequest = useAtomValue( @@ -103,6 +107,7 @@ export const WorkstationSidebarConnector: React.FC = () => { openStartPageTab, openCreateTargetInStartPage, openRuntimeTab, + openTeamInboxTab, closeAndDestroyChatPanelTab, } = useWorkstationSidebarChatPanelAtoms(); @@ -203,6 +208,7 @@ export const WorkstationSidebarConnector: React.FC = () => { createWorkItemLabel, workItemsLabel, runtimeLabel, + teamInboxLabel, importGithubIssuesLabel, addOrgLabel, manageOrgLabel, @@ -296,6 +302,8 @@ export const WorkstationSidebarConnector: React.FC = () => { importGithubIssuesLabel, newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, t, tSessions, }); @@ -487,6 +495,8 @@ export const WorkstationSidebarConnector: React.FC = () => { openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, handleProjectsMenuItemClick, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts index 9c1b3e69ca..64745bac7e 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts @@ -43,6 +43,23 @@ describe("resolveSelectedMenuItemIds", () => { ).toBe("runtime"); }); + it("selects Team Inbox from the active team inbox tab", () => { + expect( + resolveSelectedMenuItemIds({ + activeSessionCreatorDraftId: null, + activeSessionId: "session-1", + activeSidebarKey: "workstation", + activeChatPanelTabType: "team-inbox", + chatPanelContentMode: CHAT_PANEL_CONTENT_MODE.SESSION, + chatPanelCreateTarget: CHAT_PANEL_CREATE_TARGET.AGENT_SESSION, + chatPanelSelectedProject: null, + chatPanelSelectedWorkItem: null, + projectsSelectedMenuItemId: "", + sessionCreatorDrafts: [], + }).selectedMenuItemId + ).toBe("team-inbox"); + }); + it("selects Add Org by default on the projects sidebar for the collab org create target", () => { expect( resolveSelectedMenuItemIds({ diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts index d9afb4b70c..1f9dc89162 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts @@ -13,6 +13,7 @@ import { COLLAB_ADD_ORG_MENU_ITEM_ID, KANBAN_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, } from "../sidebarConnectorUtils"; import { getSelectedDraftMenuItemId, @@ -59,7 +60,9 @@ export function resolveSelectedMenuItemIds({ ? KANBAN_MENU_ITEM_ID : activeChatPanelTabType === "runtime" ? RUNTIME_MENU_ITEM_ID - : ""; + : activeChatPanelTabType === "team-inbox" + ? TEAM_INBOX_MENU_ITEM_ID + : ""; const isChatPanelProjectsContentSelected = chatPanelContentMode === CHAT_PANEL_CONTENT_MODE.NON_SESSION || Boolean(chatPanelSelectedWorkItem) || diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts index 56f15eba0d..8ee68e752c 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts @@ -18,6 +18,7 @@ import { openOrganizationInChatPanelTabAtom, openRuntimeInChatPanelTabAtom, openSessionInNewChatTabAtom, + openTeamInboxInChatPanelTabAtom, openWorkManagementChatPanelTabAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; import { openSessionInWorkstationAtom } from "@src/store/session/sessionTabPlacementAtom"; @@ -60,6 +61,7 @@ export function useWorkstationSidebarChatPanelAtoms() { openCreateTargetInChatPanelStartPageAtom ); const openRuntimeTab = useSetAtom(openRuntimeInChatPanelTabAtom); + const openTeamInboxTab = useSetAtom(openTeamInboxInChatPanelTabAtom); const closeAndDestroyChatPanelTab = useSetAtom( closeAndDestroyChatPanelTabAtom ); @@ -85,6 +87,7 @@ export function useWorkstationSidebarChatPanelAtoms() { openStartPageTab, openCreateTargetInStartPage, openRuntimeTab, + openTeamInboxTab, closeAndDestroyChatPanelTab, }; } diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx index 84c6f8a42a..68312648d7 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx @@ -67,6 +67,8 @@ interface UseWorkstationSidebarChromeParams { openWorkManagementTab: MenuItemRoutingParams["openWorkManagementTab"]; openRuntimeTab: MenuItemRoutingParams["openRuntimeTab"]; runtimeLabel: string; + openTeamInboxTab: MenuItemRoutingParams["openTeamInboxTab"]; + teamInboxLabel: string; activateChatPanelTab: MenuItemRoutingParams["activateChatPanelTab"]; handleMenuItemClick: MenuItemRoutingParams["handleMenuItemClick"]; handleProjectsMenuItemClick: MenuItemRoutingParams["handleProjectsMenuItemClick"]; @@ -106,6 +108,8 @@ export function useWorkstationSidebarChrome({ openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, handleProjectsMenuItemClick, @@ -144,6 +148,8 @@ export function useWorkstationSidebarChrome({ openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, workItemsContentVisible, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts index 02f86f1eb0..36207bf98e 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts @@ -28,6 +28,9 @@ export function buildWorkstationSidebarLabels({ const createWorkItemLabel = tProjects("workItems.createWorkItem"); const workItemsLabel = t("labels.workItems"); const runtimeLabel = tSessions("chat.startPage.tabs.runtime"); + const teamInboxLabel = t("labels.teamInbox", { + defaultValue: "Team Inbox", + }); const importGithubIssuesLabel = tProjects("githubIssuesImport.menuLabel"); const addOrgLabel = t("collaboration.addOrg"); const manageOrgLabel = t("collaboration.manageOrg"); @@ -43,6 +46,7 @@ export function buildWorkstationSidebarLabels({ createWorkItemLabel, workItemsLabel, runtimeLabel, + teamInboxLabel, importGithubIssuesLabel, addOrgLabel, manageOrgLabel, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts index 651c9ff3cc..1d7b12c5ac 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts @@ -22,6 +22,7 @@ import { KANBAN_MENU_ITEM_ID, NEW_SESSION_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_GITHUB_ISSUES_MENU_ITEM_ID, WORK_ITEMS_GITHUB_PRS_MENU_ITEM_ID, WORK_ITEMS_PROJECTS_MENU_ITEM_ID, @@ -60,6 +61,8 @@ interface UseWorkstationSidebarMenuItemRoutingParams { }) => void; openRuntimeTab: (title: string) => void; runtimeLabel: string; + openTeamInboxTab: (title: string) => void; + teamInboxLabel: string; activateChatPanelTab: (tabId: string) => void; handleMenuItemClick: (key: string, item: NavigationMenuItem) => void; workItemsContentVisible: boolean; @@ -79,6 +82,8 @@ export function useWorkstationSidebarMenuItemRouting({ openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, workItemsContentVisible, @@ -129,6 +134,10 @@ export function useWorkstationSidebarMenuItemRouting({ openRuntimeTab(runtimeLabel); return; } + if (item.id === TEAM_INBOX_MENU_ITEM_ID) { + openTeamInboxTab(teamInboxLabel); + return; + } if (isChatTerminalSidebarItem(item.id)) { activateChatPanelTab(getChatTerminalTabId(item.id)); return; @@ -161,7 +170,9 @@ export function useWorkstationSidebarMenuItemRouting({ handleProjectsMenuItemClick, handleOpenInNewTab, openRuntimeTab, + openTeamInboxTab, runtimeLabel, + teamInboxLabel, sessionMap, workItemsContentVisible, ] diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts index 86f7cd06a7..be1870d4b0 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts @@ -35,6 +35,8 @@ interface UseWorkstationSidebarPinnedAndRevealDataParams { importGithubIssuesLabel: string; newSessionLabel: string; runtimeLabel: string; + teamInboxLabel: string; + teamInboxUnreadCount: number; t: TFunction<"navigation">; tSessions: TFunction<"sessions">; } @@ -51,6 +53,8 @@ export function useWorkstationSidebarPinnedAndRevealData({ importGithubIssuesLabel, newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, t, tSessions, }: UseWorkstationSidebarPinnedAndRevealDataParams) { @@ -87,6 +91,8 @@ export function useWorkstationSidebarPinnedAndRevealData({ kanbanLabel: tSessions("simulator.tabs.kanban"), newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, workItemDestinations: workItemsSidebarMenuItems, t, }); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts index 678480c0b3..21dd547147 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts @@ -27,6 +27,8 @@ interface UsePinnedMenuItemsParams { kanbanLabel: string; newSessionLabel: string; runtimeLabel: string; + teamInboxLabel: string; + teamInboxUnreadCount?: number; workItemDestinations: NavigationMenuItem[]; t: TFunction<"navigation">; } @@ -44,6 +46,8 @@ export function usePinnedMenuItems({ kanbanLabel, newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, workItemDestinations, t, }: UsePinnedMenuItemsParams): UsePinnedMenuItemsResult { @@ -57,8 +61,18 @@ export function usePinnedMenuItems({ kanbanLabel, kanbanShortcut: getShortcutKeys("open_kanban"), runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, }), - [kanbanLabel, newSessionLabel, runtimeLabel, workItemDestinations, t] + [ + kanbanLabel, + newSessionLabel, + runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, + workItemDestinations, + t, + ] ); const projectsPinnedMenuItems = useMemo( () => diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts new file mode 100644 index 0000000000..fe9c60b271 --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/useWorkstationSidebarReveal.ts @@ -0,0 +1,124 @@ +import React, { useEffect, useMemo } from "react"; + +import { createLogger } from "@src/hooks/logger"; +import { loadSidebarSessionById } from "@src/store/session"; +import type { SessionSidebarRevealRequest } from "@src/store/ui/sidebarAtom"; + +import type { WorkstationSidebarKey } from "./types"; +import { buildCloudOrgSelectorValue } from "./useSidebarOrgScope"; + +const logger = createLogger("WorkstationSidebarReveal"); + +interface UseWorkstationSidebarRevealParams { + activeSessionId: string; + request: SessionSidebarRevealRequest | null; + clearRequest: (requestId: number) => void; + setSidebarCollapsed: (collapsed: boolean) => void; + setActiveSidebarKey: React.Dispatch< + React.SetStateAction + >; + setWorkItemsOpen: React.Dispatch>; + setSelectedOrgId: (orgId: string) => void; + setSidebarSearchQueries: React.Dispatch< + React.SetStateAction> + >; + setExpandedSubagentParentIds: React.Dispatch< + React.SetStateAction> + >; +} + +export function useWorkstationSidebarReveal({ + activeSessionId, + request, + clearRequest, + setSidebarCollapsed, + setActiveSidebarKey, + setWorkItemsOpen, + setSelectedOrgId, + setSidebarSearchQueries, + setExpandedSubagentParentIds, +}: UseWorkstationSidebarRevealParams): { + activeRequest: SessionSidebarRevealRequest | null; + revealedSessionIds: ReadonlySet; +} { + const activatedRequestIdRef = React.useRef(null); + const activeRequest = request?.sessionId === activeSessionId ? request : null; + + useEffect(() => { + if (!request) { + activatedRequestIdRef.current = null; + return; + } + if (request.sessionId === activeSessionId) { + activatedRequestIdRef.current = request.requestId; + return; + } + if (activatedRequestIdRef.current === request.requestId) { + clearRequest(request.requestId); + activatedRequestIdRef.current = null; + } + }, [activeSessionId, clearRequest, request]); + + const revealedSessionIds = useMemo(() => { + const ids = new Set(); + if (activeRequest?.sessionId) ids.add(activeRequest.sessionId); + if (activeRequest?.parentSessionId) ids.add(activeRequest.parentSessionId); + return ids; + }, [activeRequest]); + + useEffect(() => { + if (!request) return; + + setSidebarCollapsed(false); + const parentSessionId = request.parentSessionId ?? request.sessionId; + const revealFrame = window.requestAnimationFrame(() => { + setActiveSidebarKey("workstation"); + setWorkItemsOpen(false); + if (request.cloudOrgId) { + setSelectedOrgId(buildCloudOrgSelectorValue(request.cloudOrgId)); + } + setSidebarSearchQueries((currentQueries) => + currentQueries.workstation + ? { ...currentQueries, workstation: "" } + : currentQueries + ); + if (request.parentSessionId) { + setExpandedSubagentParentIds((previousIds) => { + if (previousIds.has(parentSessionId)) return previousIds; + const nextIds = new Set(previousIds); + nextIds.add(parentSessionId); + return nextIds; + }); + } + }); + + for (const sessionId of new Set([parentSessionId, request.sessionId])) { + void loadSidebarSessionById(sessionId) + .then((session) => { + if (!session) { + logger.warn( + `Unable to hydrate sidebar row for session ${sessionId}` + ); + } + }) + .catch((error: unknown) => { + logger.warn( + `Failed to hydrate sidebar row for session ${sessionId}:`, + error + ); + }); + } + + return () => window.cancelAnimationFrame(revealFrame); + }, [ + request, + setActiveSidebarKey, + setExpandedSubagentParentIds, + setSelectedOrgId, + setSidebarCollapsed, + setSidebarSearchQueries, + setWorkItemsOpen, + ]); + + return { activeRequest, revealedSessionIds }; +} diff --git a/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts index 4a71c6dddf..3c6990bbf3 100644 --- a/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts +++ b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts @@ -22,6 +22,7 @@ export const PROJECTS_NEW_WORK_ITEM_MENU_ITEM_ID = "projects-new-work-item"; export const WORK_ITEMS_MENU_ITEM_ID = "work-items"; export const KANBAN_MENU_ITEM_ID = "kanban"; export const RUNTIME_MENU_ITEM_ID = "runtime"; +export const TEAM_INBOX_MENU_ITEM_ID = "team-inbox"; export const WORK_ITEMS_PROJECTS_MENU_ITEM_ID = "work-items:projects"; export const WORK_ITEMS_GITHUB_ISSUES_MENU_ITEM_ID = "work-items:github-issues"; export const WORK_ITEMS_GITHUB_PRS_MENU_ITEM_ID = "work-items:github-prs"; diff --git a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts index 6dac574da2..d65b85290e 100644 --- a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { KANBAN_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_MENU_ITEM_ID, WORK_ITEMS_PROJECTS_MENU_ITEM_ID, } from "./sidebarConnectorUtils"; @@ -27,18 +28,24 @@ describe("buildPinnedMenuItems", () => { kanbanLabel: "Kanban", kanbanShortcut: "⌘O", runtimeLabel: "Runtime", + teamInboxLabel: "Team Inbox", }); expect(items.map((item) => item.id)).toEqual([ "new-session", KANBAN_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_MENU_ITEM_ID, ]); - expect(items[3]?.children?.map((item) => item.id)).toEqual([ + expect(items[4]?.children?.map((item) => item.id)).toEqual([ WORK_ITEMS_PROJECTS_MENU_ITEM_ID, ]); - expect(items[3]?.routePath).toBeUndefined(); + expect(items[4]?.routePath).toBeUndefined(); + expect(items[3]).toMatchObject({ + label: "Team Inbox", + dataTestId: "sidebar-team-inbox", + }); expect(items[2]).toMatchObject({ label: "Runtime", dataTestId: "sidebar-runtime", diff --git a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx index 30cc91c19f..afb89c26da 100644 --- a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx +++ b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx @@ -3,6 +3,7 @@ import { Columns3, Gauge, Github, + Inbox, ListTodo, Plus, SquarePen, @@ -21,6 +22,7 @@ import { PROJECTS_NEW_PROJECT_MENU_ITEM_ID, PROJECTS_NEW_WORK_ITEM_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_MENU_ITEM_ID, getDraftMenuItemId, getDraftPreviewText, @@ -34,6 +36,8 @@ interface BuildPinnedMenuItemsParams { kanbanLabel: string; kanbanShortcut: string; runtimeLabel: string; + teamInboxLabel: string; + teamInboxUnreadCount?: number; } interface BuildProjectsPinnedMenuItemsParams { @@ -51,6 +55,8 @@ export function buildPinnedMenuItems({ kanbanLabel, kanbanShortcut, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount = 0, }: BuildPinnedMenuItemsParams): NavigationMenuItem[] { return [ { @@ -78,6 +84,23 @@ export function buildPinnedMenuItems({ iconName: "gauge", dataTestId: "sidebar-runtime", }, + { + id: TEAM_INBOX_MENU_ITEM_ID, + key: TEAM_INBOX_MENU_ITEM_ID, + label: teamInboxLabel, + icon: Inbox, + iconName: "inbox", + dataTestId: "sidebar-team-inbox", + trailingElement: + teamInboxUnreadCount > 0 ? ( + + {teamInboxUnreadCount > 99 ? "99+" : teamInboxUnreadCount} + + ) : undefined, + }, { id: WORK_ITEMS_MENU_ITEM_ID, key: WORK_ITEMS_MENU_ITEM_ID, diff --git a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts index c0dea68568..bfd60313af 100644 --- a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts +++ b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts @@ -52,6 +52,7 @@ async function loadChatPanelTabAtoms() { openWorkManagementChatPanelTabAtom, openOrFocusChatPanelStartPageTabAtom, openRuntimeInChatPanelTabAtom, + openTeamInboxInChatPanelTabAtom, openOrFocusSessionInChatPanelTabAtom, openOrReplaceSessionInChatPanelTabAtom, openProjectInChatPanelTabAtom, @@ -118,6 +119,7 @@ async function loadChatPanelTabAtoms() { openWorkManagementChatPanelTabAtom, openOrFocusChatPanelStartPageTabAtom, openRuntimeInChatPanelTabAtom, + openTeamInboxInChatPanelTabAtom, openOrFocusSessionInChatPanelTabAtom, openOrReplaceSessionInChatPanelTabAtom, openProjectInChatPanelTabAtom, @@ -828,6 +830,33 @@ describe("ChatPanel navigation tabs", () => { ).toHaveLength(1); }); + it("opens Team Inbox as its own singleton tab", async () => { + const { chatPanelTabsAtom, openTeamInboxInChatPanelTabAtom, store } = + await loadChatPanelTabAtoms(); + + const teamInboxTabId = store.set( + openTeamInboxInChatPanelTabAtom, + "Team Inbox" + ); + const focusedTabId = store.set( + openTeamInboxInChatPanelTabAtom, + "Team Inbox" + ); + + expect(focusedTabId).toBe(teamInboxTabId); + expect(store.get(chatPanelTabsAtom).activeTabId).toBe(teamInboxTabId); + expect( + store + .get(chatPanelTabsAtom) + .tabs.filter((tab) => tab.type === "team-inbox") + ).toEqual([ + expect.objectContaining({ + id: teamInboxTabId, + title: "Team Inbox", + }), + ]); + }); + it("opens org management in its own singleton tab and restores the selected org", async () => { const { activateChatPanelTabAtom, diff --git a/src/store/chatPanel/chatPanelTabFactories.ts b/src/store/chatPanel/chatPanelTabFactories.ts index 8a9119a113..8b8bec8a0d 100644 --- a/src/store/chatPanel/chatPanelTabFactories.ts +++ b/src/store/chatPanel/chatPanelTabFactories.ts @@ -30,6 +30,8 @@ export const DEFAULT_LAUNCHPAD_TAB_ID = "launchpad-default"; export const WORK_MANAGEMENT_TAB_ID_PREFIX = "chat-work-management"; /** Fixed id of the singleton Runtime tab. */ export const RUNTIME_TAB_ID = "chat-runtime"; +/** Fixed id of the singleton Team Inbox tab. */ +export const TEAM_INBOX_TAB_ID = "chat-team-inbox"; // --------------------------------------------------------------------------- // start-page (Launchpad) @@ -80,6 +82,18 @@ export const createRuntimeTab = defineChatPanelTabFactory<{ title?: string }>({ getTitle: (data) => data.title ?? "Runtime", }); +// --------------------------------------------------------------------------- +// team-inbox — singleton +// --------------------------------------------------------------------------- + +export const createTeamInboxTab = defineChatPanelTabFactory<{ title?: string }>( + { + tabType: "team-inbox", + idStrategy: { type: "fixed", id: TEAM_INBOX_TAB_ID }, + getTitle: (data) => data.title ?? "Team Inbox", + } +); + // --------------------------------------------------------------------------- // workspace (overview) — one pill per workspace, deduped by openers // --------------------------------------------------------------------------- diff --git a/src/store/chatPanel/chatPanelTabOpenAtoms.ts b/src/store/chatPanel/chatPanelTabOpenAtoms.ts index b31cfb35a3..7cd0f2c5bc 100644 --- a/src/store/chatPanel/chatPanelTabOpenAtoms.ts +++ b/src/store/chatPanel/chatPanelTabOpenAtoms.ts @@ -26,6 +26,7 @@ import { createProjectTab, createRuntimeTab, createSessionTab, + createTeamInboxTab, createTerminalTab, createWorkItemTab, createWorkManagementTab, @@ -123,6 +124,25 @@ export const openRuntimeInChatPanelTabAtom = atom( ); openRuntimeInChatPanelTabAtom.debugLabel = "openRuntimeInChatPanelTab"; +/** Open or focus the singleton Team Inbox tab. */ +export const openTeamInboxInChatPanelTabAtom = atom( + null, + (get, set, title: string = "Team Inbox") => { + const existingTab = get(chatPanelTabsAtom).tabs.find( + (tab) => tab.type === "team-inbox" + ); + if (existingTab) { + set(activateChatPanelTabAtom, existingTab.id); + return existingTab.id; + } + + const tab = createTeamInboxTab({ title }); + set(appendAndActivateChatPanelTabAtom, { tab }); + return tab.id; + } +); +openTeamInboxInChatPanelTabAtom.debugLabel = "openTeamInboxInChatPanelTab"; + interface OpenWorkManagementTabOptions { section?: WorkManagementSection; title?: string; diff --git a/src/store/chatPanel/chatPanelTabsAtom.ts b/src/store/chatPanel/chatPanelTabsAtom.ts index e892ffb4d7..f1ed721823 100644 --- a/src/store/chatPanel/chatPanelTabsAtom.ts +++ b/src/store/chatPanel/chatPanelTabsAtom.ts @@ -30,6 +30,7 @@ export { openWorkManagementChatPanelTabAtom, openOrFocusChatPanelStartPageTabAtom, openRuntimeInChatPanelTabAtom, + openTeamInboxInChatPanelTabAtom, openOrFocusSessionInChatPanelTabAtom, openOrReplaceSessionInChatPanelTabAtom, openProjectInChatPanelTabAtom, @@ -44,6 +45,7 @@ export { createLaunchpadTab, createRuntimeTab, createSessionTab, + createTeamInboxTab, createTerminalTab, createWorkManagementTab, createWorkspaceTab, diff --git a/src/store/chatPanel/chatPanelTabsModel.ts b/src/store/chatPanel/chatPanelTabsModel.ts index bf4ee0e41c..c8d9ee78fa 100644 --- a/src/store/chatPanel/chatPanelTabsModel.ts +++ b/src/store/chatPanel/chatPanelTabsModel.ts @@ -16,6 +16,7 @@ export type ChatPanelTabType = | "terminal" | "start-page" | "runtime" + | "team-inbox" | "work-management" | "workspace" | "organization" @@ -97,6 +98,7 @@ const PERSISTED_CHAT_PANEL_TAB_TYPES = new Set([ "session", "start-page", "runtime", + "team-inbox", "work-management", "workspace", "organization", @@ -209,6 +211,10 @@ export function normalizePersistedChatPanelTabsState( activeMappedTab?.type === "runtime" ? activeMappedTab.id : mappedTabs.find((tab) => tab.type === "runtime")?.id; + const preferredTeamInboxTabId = + activeMappedTab?.type === "team-inbox" + ? activeMappedTab.id + : mappedTabs.find((tab) => tab.type === "team-inbox")?.id; const preferredOrganizationTab = activeMappedTab?.type === "organization" ? activeMappedTab @@ -228,6 +234,7 @@ export function normalizePersistedChatPanelTabsState( tab.id === preferredWorkManagementTabIds.get(tab.managementSection))) && (tab.type !== "runtime" || tab.id === preferredRuntimeTabId) && + (tab.type !== "team-inbox" || tab.id === preferredTeamInboxTabId) && (tab.type !== "organization" || tab === preferredOrganizationTab) && (tab.type !== "start-page" || tab.id === preferredStartPageTabId) ) From 910253c11ddb625da650ec4b4dce793450d6f45d Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 27 Jul 2026 23:38:23 +0800 Subject: [PATCH 02/11] feat(team-inbox): close collaboration workflow Pre-commit hook ran. Total eslint: 2, total circular: 0 --- .../ChatPanel/panels/WorkItemPanelView.tsx | 72 +-- .../usePendingWorkItemAction.test.ts | 123 ++++++ .../panels/usePendingWorkItemAction.ts | 40 ++ .../SessionComments/CommentThreadList.tsx | 174 ++++++-- .../SessionCommentsContext.tsx | 75 +++- .../SessionCommentsHeaderExtras.tsx | 16 +- .../SessionComments/TurnCommentChrome.tsx | 6 +- .../Org2Cloud/org2CloudCapabilities.test.ts | 25 ++ .../Org2Cloud/org2CloudCapabilities.ts | 4 + .../Org2Cloud/org2CloudCommentsClient.test.ts | 24 + .../Org2Cloud/org2CloudCommentsClient.ts | 21 +- .../Org2Cloud/org2CloudSessionCommentsAtom.ts | 1 + .../org2CloudSessionCommentsAtom.types.ts | 2 + .../Org2Cloud/org2CloudSyncClient.test.ts | 3 + .../Org2Cloud/teamInboxMentionsClient.test.ts | 132 +++++- .../Org2Cloud/teamInboxMentionsClient.ts | 122 ++++- src/i18n/locales/de/navigation.json | 4 +- src/i18n/locales/en/navigation.json | 4 +- src/i18n/locales/es/navigation.json | 4 +- src/i18n/locales/fr/navigation.json | 4 +- src/i18n/locales/ja/navigation.json | 4 +- src/i18n/locales/ko/navigation.json | 4 +- src/i18n/locales/pl/navigation.json | 4 +- src/i18n/locales/pt/navigation.json | 4 +- src/i18n/locales/ru/navigation.json | 4 +- src/i18n/locales/tr/navigation.json | 4 +- src/i18n/locales/vi/navigation.json | 4 +- src/i18n/locales/zh-Hant/navigation.json | 4 +- src/i18n/locales/zh/navigation.json | 4 +- src/modules/MainApp/TeamInbox/TEST_CASES.md | 54 ++- .../MainApp/TeamInbox/TeamInboxView.tsx | 127 +++++- .../__tests__/AssignedWorkItemDetail.test.ts | 213 +++++++++ .../MainApp/TeamInbox/__tests__/TEST_CASES.md | 58 +-- .../__tests__/TeamInboxView.layout.test.ts | 87 ++++ .../TeamInbox/__tests__/labels.test.ts | 15 + .../MainApp/TeamInbox/__tests__/store.test.ts | 63 --- .../components/AssignedWorkItemDetail.tsx | 185 ++++++-- .../components/TeamInboxDetailLayout.tsx | 147 +++--- .../TeamInbox/components/TeamInboxList.tsx | 1 + .../TeamInbox/components/TeamInboxRow.tsx | 3 + src/modules/MainApp/TeamInbox/domain/index.ts | 1 + .../MainApp/TeamInbox/domain/labels.ts | 10 + src/modules/MainApp/TeamInbox/domain/types.ts | 16 +- src/modules/MainApp/TeamInbox/store.ts | 52 +-- .../TeamInbox/useTeamInboxDataSource.ts | 417 ++++++++++++------ .../TeamInbox/useTeamInboxNavigation.ts | 27 +- .../MainApp/TeamInbox/useTeamInboxWorkItem.ts | 199 +++++++++ .../TeamInbox/useTeamInboxWorkItemBody.ts | 69 --- .../__tests__/workItemPartialUpdate.test.ts | 45 ++ .../components/AgentWorkflow/PhaseStates.tsx | 29 ++ .../components/AgentWorkflow/index.tsx | 20 +- .../components/WorkItemContent/HistoryTab.tsx | 307 ++++++++----- .../WorkItemContent/ThreadTodoChecklist.tsx | 219 +++++++++ .../WorkItemDescriptionEditing.test.ts | 70 ++- .../__tests__/presentation.test.ts | 31 ++ .../__tests__/threadTodos.test.ts | 27 ++ .../__tests__/useWorkItemTimeline.test.ts | 28 ++ .../hooks/useWorkItemContentState.tsx | 13 +- .../components/WorkItemContent/index.tsx | 205 ++++++--- .../WorkItemContent/presentation.ts | 39 ++ .../components/WorkItemContent/threadTodos.ts | 28 ++ .../components/WorkItemContent/types.ts | 7 + .../WorkItemContent/useWorkItemTimeline.ts | 27 +- .../WorkItemProperties.pillLayout.test.ts | 112 +++++ .../components/WorkItemProperties/index.tsx | 13 +- .../components/WorkItemProperties/types.ts | 5 + .../WorkItemThread/__tests__/TEST_CASES.md | 47 ++ .../__tests__/presentation.test.ts | 34 ++ .../components/WorkItemThread/index.tsx | 102 +++++ .../components/WorkItemThread/presentation.ts | 14 + .../components/WorkItemThread/tokens.ts | 11 + .../WorkItems/workItemPartialUpdate.ts | 82 ++++ .../components/ProjectContentEditor/index.tsx | 4 +- .../components/ActivityTimeline/index.tsx | 21 +- .../chatPanelWorkItemActionAtoms.test.ts | 63 +++ src/store/chatPanel/chatPanelTabsAtom.ts | 7 + .../chatPanel/chatPanelWorkItemActionAtoms.ts | 45 ++ 77 files changed, 3480 insertions(+), 811 deletions(-) create mode 100644 src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts create mode 100644 src/engines/ChatPanel/panels/usePendingWorkItemAction.ts create mode 100644 src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts create mode 100644 src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts delete mode 100644 src/modules/MainApp/TeamInbox/__tests__/store.test.ts create mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts delete mode 100644 src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts create mode 100644 src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/TEST_CASES.md create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts create mode 100644 src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts create mode 100644 src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts create mode 100644 src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts create mode 100644 src/store/chatPanel/chatPanelWorkItemActionAtoms.ts diff --git a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx index f1522a0656..894a1594bc 100644 --- a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx +++ b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx @@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next"; import { STORY_SYNC_ADAPTER } from "@src/api/http/integrations/syncConnections"; import { type WorkItemFrontmatter, - type WorkItemPartialUpdate, enrichedWorkItemToUI, projectApi, standaloneWorkItemDataToEnriched, @@ -26,6 +25,7 @@ import { } from "@src/modules/ProjectManager/WorkItems/components"; import { WorkItemDetailHeaderBreadcrumb } from "@src/modules/ProjectManager/WorkItems/components/WorkItemDetail/WorkItemDetailHeader"; import { useWorkItemOrchestrator } from "@src/modules/ProjectManager/WorkItems/hooks"; +import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate"; import { PropertiesRailFrame } from "@src/modules/ProjectManager/shared"; import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared"; import { VerticalResizeHandle } from "@src/scaffold/Resize"; @@ -40,6 +40,7 @@ import { WORK_ITEM_STATUS, type WorkItem } from "@src/types/core/workItem"; import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAction"; import SessionContentView from "../SessionContentView"; +import { usePendingWorkItemAction } from "./usePendingWorkItemAction"; const logger = createLogger("WorkItemPanelView"); const saveNoPendingWorkItemChanges = async (): Promise => undefined; @@ -104,70 +105,6 @@ function applyWorkItemPatch( }; } -function toWorkItemPartialUpdate( - updates: Partial -): WorkItemPartialUpdate { - const payload: WorkItemPartialUpdate = {}; - - if (updates.name !== undefined) payload.title = updates.name; - if (updates.spec !== undefined) payload.body = updates.spec; - if (updates.workItemStatus !== undefined) { - payload.status = updates.workItemStatus; - } - if (updates.priority !== undefined) payload.priority = updates.priority; - if (updates.project?.id) payload.project = updates.project.id; - if (updates.star !== undefined) payload.starred = updates.star; - if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null; - if ("assigneeType" in updates) { - payload.assigneeType = updates.assigneeType ?? null; - } - if ("labels" in updates) { - payload.labels = updates.labels?.map((label) => label.id) ?? []; - } - if ("milestone" in updates) { - payload.milestone = updates.milestone?.id ?? null; - } - if ("startDate" in updates) payload.startDate = updates.startDate ?? null; - if ("endDate" in updates) payload.targetDate = updates.endDate ?? null; - if ("target_date" in updates) { - payload.targetDate = updates.target_date ?? null; - } - if (updates.todos !== undefined) { - payload.todos = updates.todos.map((todo) => ({ - id: todo.id, - content: todo.content, - status: todo.status, - })); - } - if (updates.comments !== undefined) { - payload.comments = updates.comments.map((comment) => ({ - id: comment.id, - author: comment.author, - content: comment.content, - created_at: comment.created_at, - })); - } - if (updates.linkedSessions !== undefined) { - payload.linkedSessions = updates.linkedSessions; - } - if (updates.orchestratorConfig !== undefined) { - payload.orchestratorConfig = updates.orchestratorConfig; - } - if (updates.orchestratorState !== undefined) { - payload.orchestratorState = updates.orchestratorState; - } - if (updates.schedule !== undefined) payload.schedule = updates.schedule; - if (updates.executionLock !== undefined) { - payload.executionLock = updates.executionLock; - } - if (updates.closeOut !== undefined) payload.closeOut = updates.closeOut; - if (updates.workProducts !== undefined) { - payload.workProducts = updates.workProducts; - } - - return payload; -} - export const WorkItemPanelView: React.FC = ({ selectedWorkItem, onUpdateWorkItem, @@ -378,6 +315,11 @@ export const WorkItemPanelView: React.FC = ({ handleSave: saveNoPendingWorkItemChanges, }); + usePendingWorkItemAction({ + workItemShortId: selectedWorkItem.shortId, + onStartAgent: handleStartAgent, + }); + const handleOpenSession = useCallback( (sessionId: string) => { setFloatingSessionId(sessionId); diff --git a/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts b/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts new file mode 100644 index 0000000000..72e64d372e --- /dev/null +++ b/src/engines/ChatPanel/panels/__tests__/usePendingWorkItemAction.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + pendingChatPanelWorkItemActionAtom, + requestChatPanelWorkItemActionAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; + +import { usePendingWorkItemAction } from "../usePendingWorkItemAction"; + +function Harness({ + workItemShortId, + onStartAgent, +}: { + workItemShortId: string; + onStartAgent: () => void; +}) { + usePendingWorkItemAction({ workItemShortId, onStartAgent }); + return null; +} + +describe("usePendingWorkItemAction", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("starts once after the canonical Work Item surface claims the request", () => { + const store = createStore(); + const onStartAgent = vi.fn(); + store.set(requestChatPanelWorkItemActionAtom, { + workItemShortId: "ORG-42", + action: "start_agent", + }); + + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(Harness, { + workItemShortId: "ORG-42", + onStartAgent, + }) + ) + ); + }); + + expect(onStartAgent).toHaveBeenCalledTimes(1); + expect(store.get(pendingChatPanelWorkItemActionAtom)).toBeNull(); + + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(Harness, { + workItemShortId: "ORG-42", + onStartAgent, + }) + ) + ); + }); + expect(onStartAgent).toHaveBeenCalledTimes(1); + }); + + it("leaves a request pending for its owning Work Item", () => { + const store = createStore(); + const onStartAgent = vi.fn(); + const request = store.set(requestChatPanelWorkItemActionAtom, { + workItemShortId: "ORG-42", + action: "start_agent", + }); + + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(Harness, { + workItemShortId: "ORG-43", + onStartAgent, + }) + ) + ); + }); + + expect(onStartAgent).not.toHaveBeenCalled(); + expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(request); + }); +}); diff --git a/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts b/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts new file mode 100644 index 0000000000..c7c548ce92 --- /dev/null +++ b/src/engines/ChatPanel/panels/usePendingWorkItemAction.ts @@ -0,0 +1,40 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect } from "react"; + +import { + consumeChatPanelWorkItemActionAtom, + pendingChatPanelWorkItemActionAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; + +interface UsePendingWorkItemActionOptions { + workItemShortId: string; + onStartAgent: () => void | Promise; +} + +export function usePendingWorkItemAction({ + workItemShortId, + onStartAgent, +}: UsePendingWorkItemActionOptions): void { + const pendingWorkItemAction = useAtomValue( + pendingChatPanelWorkItemActionAtom + ); + const consumeWorkItemAction = useSetAtom(consumeChatPanelWorkItemActionAtom); + + useEffect(() => { + if ( + pendingWorkItemAction?.workItemShortId !== workItemShortId || + pendingWorkItemAction.action !== "start_agent" + ) { + return; + } + + const consumedRequest = consumeWorkItemAction(pendingWorkItemAction); + if (!consumedRequest) return; + void onStartAgent(); + }, [ + consumeWorkItemAction, + onStartAgent, + pendingWorkItemAction, + workItemShortId, + ]); +} diff --git a/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx index f4073237ee..231e36da6e 100644 --- a/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx +++ b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx @@ -23,17 +23,19 @@ * ordinary replies with a tiny agent affix, and a thread whose round is live * shows one minimal "Agent is addressing…" line. */ -import { Bot, Check, Loader2, Pencil, Trash2 } from "lucide-react"; -import React, { useCallback, useRef, useState } from "react"; +import { AtSign, Bot, Check, Loader2, Pencil, Trash2 } from "lucide-react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; +import Dropdown from "@src/components/Dropdown"; import Message from "@src/components/Message"; import TextButton from "@src/components/TextButton"; import Textarea from "@src/components/Textarea"; import Tooltip from "@src/components/Tooltip"; import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; +import type { CloudOrgMember } from "../org2CloudClient"; import { CLOUD_COMMENT_MAX_BODY_LENGTH, type CloudCommentResolution, @@ -54,6 +56,38 @@ import { export type CommentThreadStatus = "active" | CloudCommentResolution; +interface ResolvedMention { + id: string; + name: string; +} + +function resolveMentions( + mentionedUserIds: readonly string[], + members: readonly CloudOrgMember[] +): ResolvedMention[] { + const nameById = new Map( + members.map((member) => [ + member.userId, + member.displayName ?? member.userId, + ]) + ); + return mentionedUserIds.map((id) => ({ + id, + name: nameById.get(id) ?? id, + })); +} + +const MemberMentionChip: React.FC< + ResolvedMention & { dataTestId?: string } +> = ({ name, dataTestId }) => ( + + @{name} + +); + const THREAD_STATUS_OPTIONS: readonly CommentThreadStatus[] = [ "active", "resolved", @@ -80,6 +114,8 @@ export interface CommentThreadListProps { /** Optional top-level composer cancel action (inline panels use it to close). */ onComposerCancel?: () => void; emptyLabel?: string; + /** Explicit override for header dialogs mounted outside the provider tree. */ + mentionableMembers?: readonly CloudOrgMember[]; /** * Resolves with the created row when the caller's add path returns it * (context surfaces do) — the `@agent ` prefix needs the new comment's @@ -88,7 +124,8 @@ export interface CommentThreadListProps { */ onAdd: ( body: string, - parentId?: string + parentId?: string, + mentionedUserIds?: string[] ) => Promise; onEdit: (commentId: string, body: string) => Promise; onDelete: (commentId: string) => Promise; @@ -105,7 +142,8 @@ interface ComposerProps { autoFocus?: boolean; disabled?: boolean; allowAgentMention?: boolean; - onSubmit: (body: string) => Promise; + mentionableMembers?: readonly CloudOrgMember[]; + onSubmit: (body: string, mentionedUserIds: string[]) => Promise; onCancel?: () => void; testId?: string; } @@ -117,31 +155,47 @@ const CommentComposer: React.FC = ({ autoFocus = false, disabled = false, allowAgentMention = false, + mentionableMembers = [], onSubmit, onCancel, testId, }) => { const { t } = useTranslation("navigation"); const [body, setBody] = useState(""); + const [mentionedUserIds, setMentionedUserIds] = useState([]); const [busy, setBusy] = useState(false); const textareaRef = useRef(null); const trimmed = body.trim(); const showAgentSuggestion = allowAgentMention && shouldShowAgentSuggestion(body); + const mentionOptions = useMemo( + () => + mentionableMembers.map((member) => ({ + value: member.userId, + label: member.displayName ?? member.userId, + dataTestId: `session-comment-mention-${member.userId}`, + })), + [mentionableMembers] + ); + const mentionedNames = useMemo( + () => resolveMentions(mentionedUserIds, mentionableMembers), + [mentionableMembers, mentionedUserIds] + ); const submit = useCallback(async () => { if (!trimmed || busy || disabled) return; setBusy(true); try { - await onSubmit(trimmed); + await onSubmit(trimmed, mentionedUserIds); setBody(""); + setMentionedUserIds([]); } catch { // Draft restore: the text stays in the composer. Message.error(t("cloud.comments.addError")); } finally { setBusy(false); } - }, [trimmed, busy, disabled, onSubmit, t]); + }, [trimmed, busy, disabled, mentionedUserIds, onSubmit, t]); return (
@@ -181,6 +235,39 @@ const CommentComposer: React.FC = ({ ) : null} + {mentionOptions.length > 0 ? ( +
+ + setMentionedUserIds(Array.isArray(value) ? value.map(String) : []) + } + > + + + {mentionedNames.map((member) => ( + + ))} +
+ ) : null}
{onCancel && (
) : ( -
- {agentMention ? ( - <> - - - {agentMention.brief} - - ) : ( - comment.body - )} -
+ <> + {mentionedMembers.length > 0 ? ( +
+ {mentionedMembers.map((member) => ( + + ))} +
+ ) : null} +
+ {agentMention ? ( + <> + + + {agentMention.brief} + + ) : ( + comment.body + )} +
+ )}
); @@ -461,6 +567,7 @@ interface ThreadBlockProps { thread: CommentThread; viewerUserId: string | null; viewerIsAdmin: boolean; + mentionableMembers: readonly CloudOrgMember[]; onAdd: CommentThreadListProps["onAdd"]; onEdit: CommentThreadListProps["onEdit"]; onDelete: CommentThreadListProps["onDelete"]; @@ -471,6 +578,7 @@ const ThreadBlock: React.FC = ({ thread, viewerUserId, viewerIsAdmin, + mentionableMembers, onAdd, onEdit, onDelete, @@ -500,6 +608,7 @@ const ThreadBlock: React.FC = ({
= ({ = ({ placeholder={t("cloud.comments.replyPlaceholder")} submitLabel={t("cloud.comments.reply")} autoFocus - onSubmit={async (body) => { - await onAdd(body, thread.top.id); + mentionableMembers={mentionableMembers} + onSubmit={async (body, mentionedUserIds) => { + await onAdd(body, thread.top.id, mentionedUserIds); setReplying(false); }} onCancel={() => setReplying(false)} @@ -569,6 +680,7 @@ const CommentThreadList: React.FC = ({ composerPlaceholder, onComposerCancel, emptyLabel, + mentionableMembers: mentionableMembersOverride, onAdd, onEdit, onDelete, @@ -576,6 +688,11 @@ const CommentThreadList: React.FC = ({ }) => { const { t } = useTranslation("navigation"); const context = useSessionCommentsContext(); + const mentionableMembers = ( + mentionableMembersOverride ?? + context?.mentionableMembers ?? + [] + ).filter((member) => member.userId !== viewerUserId); const [showResolved, setShowResolved] = useState(false); const openThreads = threads.filter((thread) => !isThreadResolved(thread)); @@ -583,8 +700,8 @@ const CommentThreadList: React.FC = ({ const requestAgent = context?.requestAgent; const submitTopLevel = useCallback( - async (body: string): Promise => { - const comment = await onAdd(body); + async (body: string, mentionedUserIds: string[]): Promise => { + const comment = await onAdd(body, undefined, mentionedUserIds); // Beyond here the comment IS posted — never throw (a throw would // trigger the composer's draft restore for a send that succeeded). if (!comment || comment.parentId) return; @@ -612,6 +729,7 @@ const CommentThreadList: React.FC = ({ submitLabel={t("cloud.comments.send")} disabled={composerDisabled} allowAgentMention={Boolean(requestAgent && context?.canRunAgent)} + mentionableMembers={mentionableMembers} onSubmit={submitTopLevel} onCancel={onComposerCancel} testId="session-comment-composer" @@ -629,6 +747,7 @@ const CommentThreadList: React.FC = ({ thread={thread} viewerUserId={viewerUserId} viewerIsAdmin={viewerIsAdmin} + mentionableMembers={mentionableMembers} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} @@ -653,6 +772,7 @@ const CommentThreadList: React.FC = ({ thread={thread} viewerUserId={viewerUserId} viewerIsAdmin={viewerIsAdmin} + mentionableMembers={mentionableMembers} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx index 09e6e2ce37..e9c7dc7575 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx @@ -16,7 +16,7 @@ * session-id-keyed registry atom written here and read by * `SessionCommentsHeaderExtras` (the header renders outside ChatView). */ -import { atom, useAtomValue, useSetAtom } from "jotai"; +import { atom, useAtomValue, useSetAtom, useStore } from "jotai"; import React, { createContext, useCallback, @@ -24,6 +24,7 @@ import React, { useEffect, useId, useMemo, + useState, } from "react"; import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; @@ -34,14 +35,21 @@ import { getSessionForkedFrom } from "../../TeamCollaboration/forkSession"; import { collectAddressableThreads } from "../addressComments"; import { addressRunActiveAtom } from "../addressCommentsRun"; import { + commitRefreshedAuth, org2CloudAuthAtom, org2CloudAuthIdentityKey, } from "../org2CloudAuthAtom"; +import { getCloudCapabilities } from "../org2CloudCapabilities"; +import type { CloudOrgMember } from "../org2CloudClient"; import type { CloudCommentResolution, CloudSessionComment, } from "../org2CloudCommentsClient"; -import { org2CloudOrgsAtom } from "../org2CloudOrgsAtom"; +import { loadCloudOrgMembers } from "../org2CloudMembersCoordinator"; +import { + org2CloudOrgsAtom, + org2CloudRosterVersionAtom, +} from "../org2CloudOrgsAtom"; import { org2CloudRemoteSessionsAtom, remoteSessionsEntryForIdentity, @@ -134,6 +142,8 @@ export interface SessionCommentsContextValue { viewerUserId: string | null; /** Org admin/owner — may delete any comment (moderation surface). */ viewerIsAdmin: boolean; + /** Active org members available for identity-stable mentions. */ + mentionableMembers: readonly CloudOrgMember[]; refresh: () => void; addComment: (input: AddCommentInput) => Promise; /** @@ -172,6 +182,64 @@ export function useSessionCommentsContext(): SessionCommentsContextValue | null return useContext(SessionCommentsContext); } +/** + * Roster reads share the app-wide coordinator and are keyed by account, + * endpoint, org, and roster revision. Late identity responses are discarded. + */ +export function useSessionCommentMentionableMembers( + target: SessionCommentTarget | null +): readonly CloudOrgMember[] { + const store = useStore(); + const auth = useAtomValue(org2CloudAuthAtom); + const setAuth = useSetAtom(org2CloudAuthAtom); + const rosterVersions = useAtomValue(org2CloudRosterVersionAtom); + const identityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const orgId = target?.orgId ?? null; + const rosterVersion = orgId ? (rosterVersions[orgId] ?? 0) : 0; + const requestKey = + identityKey && orgId ? `${identityKey}|${orgId}|${rosterVersion}` : null; + const [resolved, setResolved] = useState<{ + key: string; + members: CloudOrgMember[]; + } | null>(null); + + useEffect(() => { + let cancelled = false; + if (!auth || !identityKey || !orgId || !requestKey) return; + const requestAuth = auth; + void Promise.all([ + loadCloudOrgMembers(store, requestAuth, orgId, rosterVersion), + getCloudCapabilities(requestAuth.accessToken), + ]) + .then(([loaded, capabilities]) => { + if (!loaded || cancelled) return; + commitRefreshedAuth(setAuth, requestAuth, loaded.auth); + const latestAuth = store.get(org2CloudAuthAtom); + if ( + !latestAuth || + org2CloudAuthIdentityKey(latestAuth) !== identityKey || + (store.get(org2CloudRosterVersionAtom)[orgId] ?? 0) > rosterVersion + ) { + return; + } + setResolved({ + key: requestKey, + members: capabilities.teamInboxMentions + ? loaded.members.filter((member) => member.status === "active") + : [], + }); + }) + .catch(() => { + if (!cancelled) setResolved({ key: requestKey, members: [] }); + }); + return () => { + cancelled = true; + }; + }, [auth, identityKey, orgId, requestKey, rosterVersion, setAuth, store]); + + return resolved?.key === requestKey ? resolved.members : []; +} + /** * Viewer-side capability probes shared by the provider and the header * extras (which runs its own instance because it mounts outside ChatView). @@ -284,6 +352,7 @@ export const SessionCommentsProvider: React.FC< originSessionId ); const viewer = useSessionCommentViewer(target); + const mentionableMembers = useSessionCommentMentionableMembers(target); const setPresentRegistry = useSetAtom(sessionCommentPresentEventIdsAtom); // Publish the replay stream's event ids for the header notes dialog — @@ -376,6 +445,7 @@ export const SessionCommentsProvider: React.FC< canAnchorTurns: viewer.canAnchorTurns, viewerUserId: viewer.viewerUserId, viewerIsAdmin: viewer.viewerIsAdmin, + mentionableMembers, refresh, addComment, editComment, @@ -395,6 +465,7 @@ export const SessionCommentsProvider: React.FC< toSourceEventId, turnAnchorsVisible, viewer, + mentionableMembers, refresh, addComment, editComment, diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx index a1a7ef10b7..6a4ea3fede 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx @@ -37,6 +37,7 @@ import { useSessionCommentTarget } from "../sessionCommentTarget"; import CommentThreadList from "./CommentThreadList"; import { sessionCommentPresentEventIdsAtom, + useSessionCommentMentionableMembers, useSessionCommentViewer, } from "./SessionCommentsContext"; @@ -65,6 +66,7 @@ const SessionCommentsHeaderExtras: React.FC< : null ); const viewer = useSessionCommentViewer(target); + const mentionableMembers = useSessionCommentMentionableMembers(target); const presentRegistry = useAtomValue(sessionCommentPresentEventIdsAtom); const [open, setOpen] = useState(false); @@ -84,18 +86,22 @@ const SessionCommentsHeaderExtras: React.FC< ); const handleAddNote = useCallback( - async (body: string, parentId?: string) => + async (body: string, parentId?: string, mentionedUserIds?: string[]) => // Session-level notes carry NO anchor; replies inherit the parent's. // Returning the row satisfies the list's onAdd contract; the agent // affordances stay dormant here regardless (no provider ⇒ null // context in this dialog's tree). - addComment(parentId ? { body, parentId } : { body }), + addComment( + parentId + ? { body, parentId, mentionedUserIds } + : { body, mentionedUserIds } + ), [addComment] ); const handleReplyOnly = useCallback( - async (body: string, parentId?: string) => { + async (body: string, parentId?: string, mentionedUserIds?: string[]) => { if (!parentId) return undefined; - return addComment({ body, parentId }); + return addComment({ body, parentId, mentionedUserIds }); }, [addComment] ); @@ -149,6 +155,7 @@ const SessionCommentsHeaderExtras: React.FC< threads={grouped.sessionLevel} viewerUserId={viewer.viewerUserId} viewerIsAdmin={viewer.viewerIsAdmin} + mentionableMembers={mentionableMembers} emptyLabel={ state === "error" ? t("cloud.comments.loadError") @@ -171,6 +178,7 @@ const SessionCommentsHeaderExtras: React.FC< threads={grouped.orphaned} viewerUserId={viewer.viewerUserId} viewerIsAdmin={viewer.viewerIsAdmin} + mentionableMembers={mentionableMembers} // New top-level anchors into a dropped event would be // meaningless — replies/resolve on existing threads stay. showComposer={false} diff --git a/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx b/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx index 66a852aa5c..5ccea10a1e 100644 --- a/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx +++ b/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx @@ -44,7 +44,8 @@ const TurnCommentChrome: React.FC = ({ const handleAdd = useCallback( async ( body: string, - parentId?: string + parentId?: string, + mentionedUserIds?: string[] ): Promise => { if (!addComment) return undefined; // Replies inherit the parent's anchor — never send both (0014 @@ -53,9 +54,10 @@ const TurnCommentChrome: React.FC = ({ // the SOURCE plane, so a fork/import's namespaced local id is stripped. return addComment( parentId - ? { body, parentId } + ? { body, parentId, mentionedUserIds } : { body, + mentionedUserIds, eventId: toSourceEventId ? toSourceEventId(anchorEventId) : anchorEventId, diff --git a/src/features/Org2Cloud/org2CloudCapabilities.test.ts b/src/features/Org2Cloud/org2CloudCapabilities.test.ts index f94e74b3fc..c739d929a3 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.test.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.test.ts @@ -27,11 +27,13 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -45,6 +47,7 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); }); @@ -58,6 +61,22 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: true, homeEndpoints: true, + teamInboxMentions: false, + }); + }); + + it("parses the 0010 Team Inbox mention capability", async () => { + rawMock.mockResolvedValueOnce({ + broadcastSignals: true, + storageSegments: true, + homeEndpoints: true, + teamInboxMentions: true, + }); + expect(await getCloudCapabilities("jwt-1")).toEqual({ + broadcastSignals: true, + storageSegments: true, + homeEndpoints: true, + teamInboxMentions: true, }); }); @@ -67,12 +86,14 @@ describe("getCloudCapabilities", () => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); rawMock.mockResolvedValueOnce({ broadcastSignals: true }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(2); }); @@ -87,11 +108,13 @@ describe("getCloudCapabilities", () => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -110,11 +133,13 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); expect(await second).toEqual({ broadcastSignals: true, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); diff --git a/src/features/Org2Cloud/org2CloudCapabilities.ts b/src/features/Org2Cloud/org2CloudCapabilities.ts index b59d7b2e8c..c819b44592 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.ts @@ -14,18 +14,21 @@ const CloudCapabilitiesWireSchema = z.object({ broadcastSignals: z.boolean().nullish().catch(undefined), storageSegments: z.boolean().nullish().catch(undefined), homeEndpoints: z.boolean().nullish().catch(undefined), + teamInboxMentions: z.boolean().nullish().catch(undefined), }); export interface CloudCapabilities { broadcastSignals: boolean; storageSegments: boolean; homeEndpoints: boolean; + teamInboxMentions: boolean; } const LEGACY_CAPABILITIES: CloudCapabilities = { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }; const capabilitiesByEndpoint = new Map(); @@ -52,6 +55,7 @@ export async function getCloudCapabilities( broadcastSignals: parsed.data.broadcastSignals ?? false, storageSegments: parsed.data.storageSegments ?? false, homeEndpoints: parsed.data.homeEndpoints ?? false, + teamInboxMentions: parsed.data.teamInboxMentions ?? false, }; capabilitiesByEndpoint.set(endpointKey, capabilities); return capabilities; diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts index 9b1b46e168..2842dfcec5 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts @@ -173,6 +173,30 @@ describe("addSessionComment", () => { expect(lastBody().p_event_id).toBeNull(); }); + it("uses the atomic mentions RPC with deduplicated member ids", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + comment: { + ...WIRE_COMMENT, + mentionedUserIds: ["user-2", "user-3"], + }, + }) + ); + + const comment = await addSessionComment("jwt-1", { + orgId: "org-1", + sessionId: "sess-1", + body: "Please review", + mentionedUserIds: ["user-2", "user-2", "user-3"], + }); + + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_add_session_comment_with_mentions` + ); + expect(lastBody().p_mentioned_user_ids).toEqual(["user-2", "user-3"]); + expect(comment.mentionedUserIds).toEqual(["user-2", "user-3"]); + }); + it("sends JWT bearer + Content-Profile", async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ comment: WIRE_COMMENT })); await addSessionComment("jwt-9", { diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.ts b/src/features/Org2Cloud/org2CloudCommentsClient.ts index 98615ef918..55c8d625f2 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.ts @@ -173,6 +173,8 @@ const CloudSessionCommentWireSchema = z.object({ .nullish() .transform((value) => value ?? undefined) .optional(), + /** Explicit user ids targeted by the comment (0010 Team Inbox). */ + mentionedUserIds: z.array(z.string()).max(50).optional(), }); export type CloudSessionComment = z.output< @@ -217,6 +219,11 @@ export interface AddSessionCommentInput { parentId?: string; /** 'agent_report' — accepted only from the cloud-session owner. */ kind?: "agent_report"; + /** + * Explicit active org-member ids to notify. Display names are never parsed + * server-side because they are mutable and may not be unique. + */ + mentionedUserIds?: string[]; /** * Local session the comment ORIGINATED from (the fork the author is * viewing). Stored server-side for per-fork count attribution; omitted / @@ -248,10 +255,21 @@ export async function addSessionComment( // pre-extension-compat rule as p_kind). Only forks/imports set it — a // source-plane comment omits it and coalesces to the source at count time. if (input.originSessionId) body.p_origin_session_id = input.originSessionId; + const mentionedUserIds = [ + ...new Set(input.mentionedUserIds?.filter(Boolean) ?? []), + ]; + if (mentionedUserIds.length > 50) { + throw new Org2CloudCommentError("ORG2_VALIDATION"); + } + if (mentionedUserIds.length > 0) { + body.p_mentioned_user_ids = mentionedUserIds; + } let payload: unknown; try { payload = await callCommentRpc( - "cloud_add_session_comment", + mentionedUserIds.length > 0 + ? "cloud_add_session_comment_with_mentions" + : "cloud_add_session_comment", accessToken, body ); @@ -262,6 +280,7 @@ export async function addSessionComment( // plane); per-fork attribution just waits for the migration. if ( "p_origin_session_id" in body && + mentionedUserIds.length === 0 && error instanceof Org2CloudCommentError && error.status === 404 ) { diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts index 738777dbfe..7bdd577605 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts @@ -489,6 +489,7 @@ export function useSessionComments( body: input.body, eventId: input.eventId, parentId: input.parentId, + mentionedUserIds: input.mentionedUserIds, ...(originSessionId && originSessionId !== sessionId ? { originSessionId } : {}), diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts index 847c457a54..6252dc2e53 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts @@ -61,6 +61,8 @@ export interface AddCommentInput { body: string; eventId?: string; parentId?: string; + /** Active cloud-org members explicitly notified by this comment. */ + mentionedUserIds?: string[]; } export interface UseSessionCommentsResult { diff --git a/src/features/Org2Cloud/org2CloudSyncClient.test.ts b/src/features/Org2Cloud/org2CloudSyncClient.test.ts index aead57f075..d3ebafd063 100644 --- a/src/features/Org2Cloud/org2CloudSyncClient.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncClient.test.ts @@ -62,6 +62,7 @@ beforeEach(() => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); }); @@ -260,6 +261,7 @@ describe("storage segment offload (0006)", () => { broadcastSignals: false, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); }); @@ -340,6 +342,7 @@ describe("storage segment offload (0006)", () => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); await appendSessionEvents("jwt-1", appendInput([makeEvent("f1")], null)); expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts index 0618277eb8..680cdc480f 100644 --- a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts +++ b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts @@ -6,8 +6,14 @@ import { ORG2_CLOUD_OFFICIAL_SUPABASE_URL, ORG2_CLOUD_POSTGREST_SCHEMA, } from "./config"; +import { __CAPABILITIES_INTERNALS } from "./org2CloudCapabilities"; import { Org2CloudCommentError } from "./org2CloudCommentsClient"; -import { listTeamInboxMentions } from "./teamInboxMentionsClient"; +import { + listInitialTeamInboxMentions, + listTeamInboxMentions, + markAllTeamInboxMentionsRead, + setTeamInboxMentionRead, +} from "./teamInboxMentionsClient"; const fetchMock = vi.fn(); @@ -33,6 +39,7 @@ const WIRE_MENTION = { author: { userId: "user-a", displayName: "Alice" }, body: "Please review this change", createdAt: "2026-07-23T10:00:00.000Z", + readAt: null, commentCount: 4, threadCount: 2, }; @@ -44,12 +51,74 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); fetchMock.mockReset(); + __CAPABILITIES_INTERNALS.reset(); +}); + +describe("listInitialTeamInboxMentions", () => { + it("keeps older endpoints on the local-only path without probing a missing RPC", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + broadcastSignals: true, + storageSegments: true, + teamInboxMentions: false, + }) + ); + + await expect( + listInitialTeamInboxMentions("jwt-viewer", "org-1") + ).resolves.toEqual({ mentions: [], unreadCount: 0 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/get_cloud_capabilities` + ); + }); + + it("loads the first page after the endpoint advertises mention support", async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + broadcastSignals: true, + storageSegments: true, + teamInboxMentions: true, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + mentions: [WIRE_MENTION], + nextCursor: null, + unreadCount: 1, + }) + ); + + await expect( + listInitialTeamInboxMentions("jwt-viewer", "org-1", 25) + ).resolves.toEqual({ + mentions: [WIRE_MENTION], + nextCursor: undefined, + unreadCount: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_list_team_inbox_mentions` + ); + expect(lastBody()).toEqual({ + p_org_id: "org-1", + p_cursor: null, + p_limit: 25, + }); + }); }); describe("listTeamInboxMentions", () => { it("posts the managed-cloud wire contract without a viewer identity", async () => { fetchMock.mockResolvedValueOnce( - jsonResponse({ mentions: [WIRE_MENTION], nextCursor: "cursor-2" }) + jsonResponse({ + mentions: [WIRE_MENTION], + nextCursor: "cursor-2", + unreadCount: 7, + }) ); await listTeamInboxMentions("jwt-viewer", "org-1", "cursor-1", 25); @@ -76,7 +145,7 @@ describe("listTeamInboxMentions", () => { it("sends a null cursor for the first page", async () => { fetchMock.mockResolvedValueOnce( - jsonResponse({ mentions: [], nextCursor: null }) + jsonResponse({ mentions: [], nextCursor: null, unreadCount: 0 }) ); await listTeamInboxMentions("jwt-viewer", "org-1", null, 50); @@ -90,7 +159,11 @@ describe("listTeamInboxMentions", () => { it("parses the stable mention response contract", async () => { fetchMock.mockResolvedValueOnce( - jsonResponse({ mentions: [WIRE_MENTION], nextCursor: "cursor-2" }) + jsonResponse({ + mentions: [WIRE_MENTION], + nextCursor: "cursor-2", + unreadCount: 7, + }) ); const page = await listTeamInboxMentions("jwt-viewer", "org-1", null, 25); @@ -98,6 +171,7 @@ describe("listTeamInboxMentions", () => { expect(page).toEqual({ mentions: [WIRE_MENTION], nextCursor: "cursor-2", + unreadCount: 7, }); }); @@ -113,6 +187,7 @@ describe("listTeamInboxMentions", () => { }, ], nextCursor: null, + unreadCount: 1, }) ); @@ -131,6 +206,7 @@ describe("listTeamInboxMentions", () => { jsonResponse({ mentions: [{ ...WIRE_MENTION, commentCount: -1 }], nextCursor: null, + unreadCount: 1, }) ); @@ -163,3 +239,51 @@ describe("listTeamInboxMentions", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +describe("Team Inbox read receipts", () => { + it("persists a single receipt without sending a viewer id", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + readAt: "2026-07-27T12:00:00.000Z", + unreadCount: 2, + }) + ); + + const result = await setTeamInboxMentionRead( + "jwt-viewer", + "org-1", + "comment-2", + true + ); + + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_set_team_inbox_mention_read` + ); + expect(lastBody()).toEqual({ + p_org_id: "org-1", + p_comment_id: "comment-2", + p_read: true, + }); + expect(lastBody()).not.toHaveProperty("p_viewer_user_id"); + expect(result).toEqual({ + readAt: "2026-07-27T12:00:00.000Z", + unreadCount: 2, + }); + }); + + it("marks all server-side, including unloaded pages", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + readAt: "2026-07-27T12:00:00.000Z", + unreadCount: 0, + }) + ); + + await markAllTeamInboxMentionsRead("jwt-viewer", "org-1"); + + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_mark_all_team_inbox_mentions_read` + ); + expect(lastBody()).toEqual({ p_org_id: "org-1" }); + }); +}); diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.ts b/src/features/Org2Cloud/teamInboxMentionsClient.ts index c298e0edc1..9b4af2ee56 100644 --- a/src/features/Org2Cloud/teamInboxMentionsClient.ts +++ b/src/features/Org2Cloud/teamInboxMentionsClient.ts @@ -1,9 +1,14 @@ import { z } from "zod/v4"; import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config"; +import { getCloudCapabilities } from "./org2CloudCapabilities"; import { Org2CloudCommentError } from "./org2CloudCommentsClient"; +import { fetchWithTransportRetry } from "./org2CloudFetchRetry"; const TEAM_INBOX_MENTIONS_RPC = "cloud_list_team_inbox_mentions"; +const SET_TEAM_INBOX_MENTION_READ_RPC = "cloud_set_team_inbox_mention_read"; +const MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC = + "cloud_mark_all_team_inbox_mentions_read"; const TeamInboxMentionRequestSchema = z.object({ orgId: z.string().min(1), @@ -32,6 +37,7 @@ const TeamInboxMentionSchema = z.object({ }), body: z.string(), createdAt: z.string(), + readAt: z.string().nullable(), commentCount: z.number().int().nonnegative(), threadCount: z.number().int().nonnegative(), }); @@ -39,6 +45,7 @@ const TeamInboxMentionSchema = z.object({ const TeamInboxMentionsPageSchema = z.object({ mentions: z.array(TeamInboxMentionSchema).default([]), nextCursor: NullableStringSchema, + unreadCount: z.number().int().nonnegative(), }); export type TeamInboxMention = z.output; @@ -46,25 +53,32 @@ export type TeamInboxMention = z.output; export interface TeamInboxMentionsPage { mentions: TeamInboxMention[]; nextCursor?: string; + unreadCount: number; } -/** - * Lists managed-cloud comment mentions for the authenticated viewer. - * - * The viewer is derived by the RPC from the JWT bearer token. The client does - * not accept or send a viewer/user id, inspect comment bodies for mentions, or - * maintain a local projection of the result. - */ -export async function listTeamInboxMentions( +const EMPTY_TEAM_INBOX_MENTIONS_PAGE: TeamInboxMentionsPage = { + mentions: [], + unreadCount: 0, +}; + +const TeamInboxReadMutationSchema = z.object({ + readAt: z.string().nullable(), + unreadCount: z.number().int().nonnegative(), +}); + +export interface TeamInboxReadMutation { + readAt: string | null; + unreadCount: number; +} + +async function callTeamInboxRpc( + functionName: string, accessToken: string, - orgId: string, - cursor: string | null, - limit: number -): Promise { - const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit }); + body: Record +): Promise { const endpoint = getCloudEndpoint(); - const response = await fetch( - `${endpoint.supabaseUrl}/rest/v1/rpc/${TEAM_INBOX_MENTIONS_RPC}`, + const response = await fetchWithTransportRetry( + `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, { method: "POST", headers: { @@ -73,11 +87,7 @@ export async function listTeamInboxMentions( "content-type": "application/json", "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, }, - body: JSON.stringify({ - p_org_id: input.orgId, - p_cursor: input.cursor, - p_limit: input.limit, - }), + body: JSON.stringify(body), } ); @@ -93,9 +103,79 @@ export async function listTeamInboxMentions( const message = payload && typeof payload === "object" && "message" in payload ? String((payload as { message: unknown }).message) - : `org2_cloud rpc ${TEAM_INBOX_MENTIONS_RPC} failed with ${response.status}`; + : `org2_cloud rpc ${functionName} failed with ${response.status}`; throw new Org2CloudCommentError(message, response.status); } + return payload; +} +/** + * Lists managed-cloud comment mentions for the authenticated viewer. + * + * The viewer is derived by the RPC from the JWT bearer token. The client does + * not accept or send a viewer/user id, inspect comment bodies for mentions, or + * maintain a local projection of the result. + */ +export async function listTeamInboxMentions( + accessToken: string, + orgId: string, + cursor: string | null, + limit: number +): Promise { + const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit }); + const payload = await callTeamInboxRpc(TEAM_INBOX_MENTIONS_RPC, accessToken, { + p_org_id: input.orgId, + p_cursor: input.cursor, + p_limit: input.limit, + }); return TeamInboxMentionsPageSchema.parse(payload); } + +/** + * Lists the first mention page only when the endpoint advertises migration + * 0010. Older deployments keep local assigned work available without probing + * a missing RPC. + */ +export async function listInitialTeamInboxMentions( + accessToken: string, + orgId: string, + limit = 50 +): Promise { + const capabilities = await getCloudCapabilities(accessToken); + if (!capabilities.teamInboxMentions) { + return EMPTY_TEAM_INBOX_MENTIONS_PAGE; + } + return listTeamInboxMentions(accessToken, orgId, null, limit); +} + +/** Persists one viewer-scoped mention receipt. The viewer comes from JWT. */ +export async function setTeamInboxMentionRead( + accessToken: string, + orgId: string, + commentId: string, + read: boolean +): Promise { + const payload = await callTeamInboxRpc( + SET_TEAM_INBOX_MENTION_READ_RPC, + accessToken, + { + p_org_id: z.string().min(1).parse(orgId), + p_comment_id: z.string().min(1).parse(commentId), + p_read: read, + } + ); + return TeamInboxReadMutationSchema.parse(payload); +} + +/** Marks every currently visible mention read, including unloaded pages. */ +export async function markAllTeamInboxMentionsRead( + accessToken: string, + orgId: string +): Promise { + const payload = await callTeamInboxRpc( + MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC, + accessToken, + { p_org_id: z.string().min(1).parse(orgId) } + ); + return TeamInboxReadMutationSchema.parse(payload); +} diff --git a/src/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json index 876781859c..6a295dc9b3 100644 --- a/src/i18n/locales/de/navigation.json +++ b/src/i18n/locales/de/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "{{count}} Kommentare bearbeiten", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Erwähnen", + "searchMembers": "Mitglieder suchen" }, "sharingFloor": { "label": "Minimale Freigabestufe", diff --git a/src/i18n/locales/en/navigation.json b/src/i18n/locales/en/navigation.json index 4669845aef..0347ede927 100644 --- a/src/i18n/locales/en/navigation.json +++ b/src/i18n/locales/en/navigation.json @@ -725,7 +725,9 @@ "addressRoundScope": "Round comments", "addressConfirm_one": "Address {{count}} comment", "addressConfirm_other": "Address {{count}} comments", - "agentAuthor": "Agent @{{name}}" + "agentAuthor": "Agent @{{name}}", + "mentionMembers": "Mention", + "searchMembers": "Search members" }, "billing": { "openFailed": "Couldn't open billing. Please try again." diff --git a/src/i18n/locales/es/navigation.json b/src/i18n/locales/es/navigation.json index 9925098947..8c5c9494cf 100644 --- a/src/i18n/locales/es/navigation.json +++ b/src/i18n/locales/es/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "Atender {{count}} comentarios", "agentAuthor": "Agente @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Mencionar", + "searchMembers": "Buscar miembros" }, "sharingFloor": { "label": "Nivel mínimo de uso compartido", diff --git a/src/i18n/locales/fr/navigation.json b/src/i18n/locales/fr/navigation.json index df12cf29f9..c79e823e33 100644 --- a/src/i18n/locales/fr/navigation.json +++ b/src/i18n/locales/fr/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "Traiter {{count}} commentaires", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Mentionner", + "searchMembers": "Rechercher des membres" }, "sharingFloor": { "label": "Niveau de partage minimal", diff --git a/src/i18n/locales/ja/navigation.json b/src/i18n/locales/ja/navigation.json index 7f5a875063..198f5bbf3d 100644 --- a/src/i18n/locales/ja/navigation.json +++ b/src/i18n/locales/ja/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "{{count}} 件のコメントに対応", "agentAuthor": "エージェント @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "メンバーをメンション", + "searchMembers": "メンバーを検索" }, "sharingFloor": { "label": "最小共有レベル", diff --git a/src/i18n/locales/ko/navigation.json b/src/i18n/locales/ko/navigation.json index 5c84b31cbe..2912fb73d3 100644 --- a/src/i18n/locales/ko/navigation.json +++ b/src/i18n/locales/ko/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "댓글 {{count}}개 처리", "agentAuthor": "에이전트 @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "멤버 언급", + "searchMembers": "멤버 검색" }, "sharingFloor": { "label": "최소 공유 수준", diff --git a/src/i18n/locales/pl/navigation.json b/src/i18n/locales/pl/navigation.json index fa8f216e6b..61c7651e32 100644 --- a/src/i18n/locales/pl/navigation.json +++ b/src/i18n/locales/pl/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "Obsłuż {{count}} komentarzy", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Wspomnij", + "searchMembers": "Szukaj członków" }, "sharingFloor": { "label": "Minimalny poziom udostępniania", diff --git a/src/i18n/locales/pt/navigation.json b/src/i18n/locales/pt/navigation.json index f519c6e91f..75b15754b3 100644 --- a/src/i18n/locales/pt/navigation.json +++ b/src/i18n/locales/pt/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "Atender {{count}} comentários", "agentAuthor": "Agente @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Mencionar", + "searchMembers": "Pesquisar membros" }, "sharingFloor": { "label": "Nível mínimo de compartilhamento", diff --git a/src/i18n/locales/ru/navigation.json b/src/i18n/locales/ru/navigation.json index b9eae94ea3..7b4fd5c78b 100644 --- a/src/i18n/locales/ru/navigation.json +++ b/src/i18n/locales/ru/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "Обработать {{count}} комментариев", "agentAuthor": "Агент @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Упомянуть", + "searchMembers": "Поиск участников" }, "sharingFloor": { "label": "Минимальный уровень доступа", diff --git a/src/i18n/locales/tr/navigation.json b/src/i18n/locales/tr/navigation.json index 1e28eee2f3..449dc253df 100644 --- a/src/i18n/locales/tr/navigation.json +++ b/src/i18n/locales/tr/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "{{count}} yorumu ele al", "agentAuthor": "Ajan @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Bahset", + "searchMembers": "Üye ara" }, "sharingFloor": { "label": "En düşük paylaşım düzeyi", diff --git a/src/i18n/locales/vi/navigation.json b/src/i18n/locales/vi/navigation.json index 5ab9a985a3..48b3521ef0 100644 --- a/src/i18n/locales/vi/navigation.json +++ b/src/i18n/locales/vi/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "Xử lý {{count}} bình luận", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Nhắc đến", + "searchMembers": "Tìm thành viên" }, "sharingFloor": { "label": "Mức chia sẻ tối thiểu", diff --git a/src/i18n/locales/zh-Hant/navigation.json b/src/i18n/locales/zh-Hant/navigation.json index f0aacf6246..ecc6e12724 100644 --- a/src/i18n/locales/zh-Hant/navigation.json +++ b/src/i18n/locales/zh-Hant/navigation.json @@ -788,7 +788,9 @@ "addressRoundScope": "逐輪評論", "addressConfirm_one": "處理 {{count}} 條評論", "addressConfirm_other": "處理 {{count}} 條評論", - "agentAuthor": "Agent @{{name}}" + "agentAuthor": "Agent @{{name}}", + "mentionMembers": "提及成員", + "searchMembers": "搜尋成員" }, "billing": { "openFailed": "無法開啟帳單頁,請重試。" diff --git a/src/i18n/locales/zh/navigation.json b/src/i18n/locales/zh/navigation.json index cc7ec75b11..a876f3e1df 100644 --- a/src/i18n/locales/zh/navigation.json +++ b/src/i18n/locales/zh/navigation.json @@ -788,7 +788,9 @@ "addressRoundScope": "逐轮评论", "addressConfirm_one": "处理 {{count}} 条评论", "addressConfirm_other": "处理 {{count}} 条评论", - "agentAuthor": "Agent @{{name}}" + "agentAuthor": "Agent @{{name}}", + "mentionMembers": "提及成员", + "searchMembers": "搜索成员" }, "billing": { "openFailed": "无法打开账单页,请重试。" diff --git a/src/modules/MainApp/TeamInbox/TEST_CASES.md b/src/modules/MainApp/TeamInbox/TEST_CASES.md index 79dbd57c5d..421a7f689b 100644 --- a/src/modules/MainApp/TeamInbox/TEST_CASES.md +++ b/src/modules/MainApp/TeamInbox/TEST_CASES.md @@ -8,15 +8,16 @@ - Mixed items are deduplicated and sorted by `occurredAt`, then stable item identity. - Local assigned Work Items require explicit current-user member IDs. - Local cursor pagination is stable when timestamps tie and when newer rows arrive. -- Single and bulk read receipts are viewer-scoped and idempotent. -- Managed-cloud mention responses are Zod-validated and never accept a caller-supplied viewer ID. +- Local assignment and managed-cloud mention receipts are viewer-scoped and idempotent. +- Managed-cloud mention responses are Zod-validated, include server-owned `readAt` + full-page-independent unread totals, and never accept a caller-supplied viewer ID. +- Structured comment mentions send stable cloud user ids selected from the active roster; mutable/non-unique display names are never parsed as identities. - Raw work-item status/priority enum tokens are humanized (`humanizeToken`) when no localized key exists, and never leak to the row or detail. - Per-filter unread counts (`countUnreadTeamInboxItemsByFilter`) de-duplicate before counting and back the filter-tab badges. - `filterItemKind` maps `all → null`, `mentions → comment_mention`, `assigned → assigned_work_item`. - `searchTeamInboxItems` is case-insensitive, matches title/body/summary/people, returns a fresh copy for empty queries, and empty for no match. - `groupTeamInboxItemsByRecency` buckets by local calendar day (Today/Yesterday/This week/Earlier), omits empty groups, keeps input order, and files unparseable timestamps under "earlier". - Assigned items carry a trimmed, whitespace-folded, 240-char body excerpt as `summary`; blank bodies omit the field (`work_item_summary_excerpt`). -- `mark_unread` deletes the viewer-scoped receipt so the item returns to unread, and is idempotent (a second call changes nothing); `removeTeamInboxCloudReadReceipts` deletes cloud receipt keys and returns the same reference when nothing changes. +- `mark_unread` deletes the viewer-scoped local or cloud receipt so the item returns to unread and remains idempotent; cloud receipts are not owned by localStorage. - `toWireCursorItemId` preserves the backend `work_item_assigned:` source prefix (strips only the UI `assigned_work_item:` kind prefix) so `Load more` cursor pagination round-trips instead of erroring. ## Presentation / polish @@ -30,9 +31,48 @@ 7. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list. 8. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries. 9. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes. -10. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the local receipt). Re-marking read still works. +10. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the managed-cloud receipt). Re-marking read still works after refresh or on another device. 11. When a source still has a next page, the list shows a `Load more` control; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more. +## Unified Work Item thread + +| # | Steps | Expected result | +| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Select a project-scoped assigned Work Item. | The full Work Item uses the shared content and property components; the reduced Markdown/metadata preview is not rendered. | +| 2 | Inspect a Work Item with linked Sessions. | Workflow and Session run cards appear inline in one continuous thread. The legacy `Session / Output / History` tab strip and linked-Session table are absent in Team Inbox. | +| 3 | Activate `View live chat` / `View conversation` on a Session card. | A separate Session Chat Panel tab opens or the existing tab for that Session is focused. Team Inbox remains open as its singleton tab. | +| 4 | Inspect a Work Item with proof of work and comments/history. | Output and activity render inline after the workflow; no second nested detail surface is introduced. | +| 5 | Switch assigned rows while the first full Work Item is still loading. | A late response from the first row never replaces the newly selected Work Item. | +| 6 | Make two property changes in quick succession. | Only the newest response may replace the displayed Work Item snapshot; both writes use the canonical partial-update payload. | +| 7 | Open a standalone assigned Work Item. | The thread remains readable, but edit controls/property rail are not exposed because standalone persistence requires the owning frontmatter round-trip. | +| 8 | Fail the selected Work Item read. | A visible error placeholder is shown; the short list row remains available for retry/navigation. | +| 9 | Open a project Work Item with a short description. | The description renders at its natural Markdown height. `Preview / Raw` and the editor are absent until `Edit` is activated. | +| 10 | Activate `Edit`, change the description, then cancel. | A compact editor and Cancel/Save footer appear; Save is disabled until content changes, and Cancel restores the original Markdown. | +| 11 | Inspect a Work Item containing a persisted blank To-Do row. | The blank row is not rendered. The add input appears only after `Add` / `Add a to-do item` is activated. | +| 12 | Add a To-Do with Enter, then rapidly toggle and remove items. | Only committed, trimmed items persist; every change uses the canonical Work Item update boundary. | +| 13 | Inspect activity and comments. | Activity has one heading with its subscription action; the current-user avatar is attached to the comment composer instead of occupying a separate subscription row. | +| 14 | Activate `Start Agent` on an idle Inbox Work Item. | The canonical Work Item tab opens/focuses, claims the one-shot `start_agent` request, and starts through its existing orchestrator. The Inbox never mounts a second orchestrator. | +| 15 | Resize the detail from narrow to wide. | The thread remains a centered single reading column; compact property pills scroll horizontally instead of creating a competing right rail. | +| 16 | Rapidly activate `Start Agent`, remount the Work Item panel, or request another Work Item before the first is claimed. | A claimed request starts exactly once and cannot replay; the newest unclaimed navigation intent supersedes the older one, which can never start later. | +| 17 | Compare the To-Do and Agent Workflow cards, then collapse Workflow. | Both cards share one Work Item thread visual shell; Workflow retains its existing collapse behavior and To-Do remains independently interactive. | +| 18 | Open Assignee or Reviewer in a project-scoped Inbox Work Item. | The picker contains the complete active project roster, resolves stored member ids to names, and persists through the canonical partial-update boundary. | +| 19 | Inspect creator, comments, and history written with stored member ids. | Known ids resolve to project-member names; unknown ids remain visible instead of being guessed or silently blanked. | + +### Unified thread acceptance criteria + +- [ ] Team Inbox uses `presentation="thread"` while ordinary Work Item surfaces retain their existing default tabs/table. +- [ ] `data-testid="work-item-thread-section"` is present and `data-testid="work-item-lower-tabs-section"` / `data-testid="work-item-linked-sessions"` are absent in Team Inbox. +- [ ] The description is read-first and enters edit mode only through `data-testid="work-item-description-edit"`. +- [ ] Blank To-Do rows are removed from the thread projection; the To-Do composer is demand-mounted. +- [ ] Properties use the shared pill fields in the thread header and no separate heavy property-card rail is rendered. +- [ ] `Open work item`, read/unread, subscription, and comment actions are grouped with their owning header/composer instead of occupying disconnected footer rows. +- [ ] Session-card navigation uses the explicit `open_session` intent and the canonical open-or-focus Session-tab atom. +- [ ] Team Inbox does not mount a second Work Item orchestrator; `Start Agent` forwards a one-shot action to the canonical Work Item tab, where lock validation, start, failure recovery, and refresh remain owned. +- [ ] The one-shot action is consumed only by its matching Work Item and is cleared before the async start begins, preventing remount/double-effect replay. +- [ ] At most one unclaimed start intent exists; a newer Work Item request explicitly supersedes the older intent instead of leaving a delayed start behind. +- [ ] The centered reading frame and metadata band are composed by `WorkItemThreadLayout`; static card shells use `WorkItemThreadSection`, while collapsible Workflow shares tokens without duplicating collapse state. +- [ ] No Session/comment transcript scan or frontend-fabricated impact data is introduced. + ## Rendered product path 1. Seed or create a project member that matches the current Git identity. @@ -41,8 +81,10 @@ 4. Verify the assigned item appears and `分配给我` keeps it visible. 5. Open its detail, mark it read, and verify the row and Sidebar unread badge update together. 6. Close and reopen Team Inbox; verify the durable local receipt remains read. -7. In a managed cloud org whose backend exposes `cloud_list_team_inbox_mentions`, create a comment mention through the normal Session comments UI. -8. Verify `@ 提及` shows the stable comment/session target and source navigation opens the Session. +7. In a managed cloud org, use the normal Session comment member picker to mention user B. +8. In user B's independent app instance, verify `@ 提及` shows the stable comment/session target and unread badge. +9. Open the row and verify the production click persists `readAt`; list again with user B's JWT and observe `unreadCount = 0`. +10. List with user A's JWT and verify B's targeted mention is absent; refresh/reopen B's Inbox and verify it remains read. ## Degraded states diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx index cec6bc6d29..effb3d294e 100644 --- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx +++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout"; @@ -14,7 +14,7 @@ import { type TeamInboxFilter, type TeamInboxItem, type TeamInboxNavigationIntent, - countUnreadTeamInboxItems, + type TeamInboxUnreadCounts, countUnreadTeamInboxItemsByFilter, filterItemKind, getTeamInboxItemKey, @@ -51,6 +51,8 @@ const TeamInboxView: React.FC = ({ const [filter, setFilter] = useState(initialFilter); const [query, setQuery] = useState(""); const [items, setItems] = useState([]); + const [authoritativeUnreadCounts, setAuthoritativeUnreadCounts] = + useState(null); const [recencyAnchorMs, setRecencyAnchorMs] = useState(() => Date.now()); const [requestedItemId, setRequestedItemId] = useState(null); const [loadState, setLoadState] = useState({ @@ -60,6 +62,8 @@ const TeamInboxView: React.FC = ({ const [reloadRevision, setReloadRevision] = useState(0); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); + const mutationEpochRef = useRef(0); + const mutationByItemRef = useRef(new Map()); useEffect(() => { const abortController = new AbortController(); @@ -69,6 +73,7 @@ const TeamInboxView: React.FC = ({ .then((page) => { if (abortController.signal.aborted) return; setItems(page.items); + setAuthoritativeUnreadCounts(page.unreadCounts ?? null); setRecencyAnchorMs(Date.now()); setHasMore(page.nextCursor != null); setLoadState({ status: "ready", message: null }); @@ -98,11 +103,12 @@ const TeamInboxView: React.FC = ({ () => searchTeamInboxItems(selectTeamInboxItems(items, filter), query), [filter, items, query] ); - const totalUnread = useMemo(() => countUnreadTeamInboxItems(items), [items]); - const unreadCounts = useMemo( + const loadedUnreadCounts = useMemo( () => countUnreadTeamInboxItemsByFilter(items), [items] ); + const unreadCounts = authoritativeUnreadCounts ?? loadedUnreadCounts; + const totalUnread = unreadCounts.all; const selectedItem = useMemo(() => { if (visibleItems.length === 0) return null; return ( @@ -154,6 +160,31 @@ const TeamInboxView: React.FC = ({ }); }; + const beginItemMutations = (itemIds: readonly string[]): number => { + const epoch = ++mutationEpochRef.current; + for (const itemId of itemIds) mutationByItemRef.current.set(itemId, epoch); + return epoch; + }; + + const isCurrentItemMutation = (itemId: string, epoch: number): boolean => + mutationByItemRef.current.get(itemId) === epoch; + + const updateUnreadCount = (kind: TeamInboxItem["kind"], delta: number) => { + setAuthoritativeUnreadCounts((current) => { + if (!current) return null; + const key = + kind === "comment_mention" + ? ("mentions" as const) + : ("assigned" as const); + const nextForKind = Math.max(0, current[key] + delta); + return { + ...current, + [key]: nextForKind, + all: Math.max(0, current.all + delta), + }; + }); + }; + const markLocallyRead = (item: TeamInboxItem) => { const readAt = new Date().toISOString(); setItems((current) => @@ -168,8 +199,20 @@ const TeamInboxView: React.FC = ({ const handleSelect = (item: TeamInboxItem) => { setRequestedItemId(getTeamInboxItemKey(item)); if (item.readAt !== null) return; + const epoch = beginItemMutations([item.id]); markLocallyRead(item); + updateUnreadCount(item.kind, -1); void dataSource.markRead?.(item).catch(() => { + if (isCurrentItemMutation(item.id, epoch)) { + setItems((current) => + current.map((candidate) => + candidate.id === item.id + ? { ...candidate, readAt: null } + : candidate + ) + ); + updateUnreadCount(item.kind, 1); + } setLoadState({ status: "error", message: t("teamInbox.errors.markRead"), @@ -179,8 +222,20 @@ const TeamInboxView: React.FC = ({ const handleMarkRead = (item: TeamInboxItem) => { if (item.readAt !== null) return; + const epoch = beginItemMutations([item.id]); markLocallyRead(item); + updateUnreadCount(item.kind, -1); void dataSource.markRead?.(item).catch(() => { + if (isCurrentItemMutation(item.id, epoch)) { + setItems((current) => + current.map((candidate) => + candidate.id === item.id + ? { ...candidate, readAt: null } + : candidate + ) + ); + updateUnreadCount(item.kind, 1); + } setLoadState({ status: "error", message: t("teamInbox.errors.markRead"), @@ -190,6 +245,8 @@ const TeamInboxView: React.FC = ({ const handleMarkUnread = (item: TeamInboxItem) => { if (item.readAt === null) return; + const previousReadAt = item.readAt; + const epoch = beginItemMutations([item.id]); setItems((current) => current.map((candidate) => getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item) @@ -197,7 +254,18 @@ const TeamInboxView: React.FC = ({ : candidate ) ); + updateUnreadCount(item.kind, 1); void dataSource.markUnread?.(item).catch(() => { + if (isCurrentItemMutation(item.id, epoch)) { + setItems((current) => + current.map((candidate) => + candidate.id === item.id + ? { ...candidate, readAt: previousReadAt } + : candidate + ) + ); + updateUnreadCount(item.kind, -1); + } setLoadState({ status: "error", message: t("teamInbox.errors.markUnread"), @@ -212,15 +280,52 @@ const TeamInboxView: React.FC = ({ item.readAt === null && (targetKind === null || item.kind === targetKind) ); - if (unreadItems.length === 0) return; + const filterUnreadCount = + filter === "all" + ? unreadCounts.all + : filter === "mentions" + ? unreadCounts.mentions + : unreadCounts.assigned; + if (filterUnreadCount === 0) return; const readAt = new Date().toISOString(); - const markedIds = new Set(unreadItems.map((item) => item.id)); + const affectedItems = items.filter( + (item) => targetKind === null || item.kind === targetKind + ); + const previousReadAtById = new Map( + affectedItems.map((item) => [item.id, item.readAt]) + ); + const affectedIds = affectedItems.map((item) => item.id); + const epoch = beginItemMutations(affectedIds); + const previousCounts = authoritativeUnreadCounts; + const markedIds = new Set(affectedIds); setItems((current) => current.map((item) => markedIds.has(item.id) ? { ...item, readAt } : item ) ); - void dataSource.markAllRead?.(unreadItems).catch(() => { + setAuthoritativeUnreadCounts((current) => { + if (!current) return null; + const assigned = + filter === "all" || filter === "assigned" ? 0 : current.assigned; + const mentions = + filter === "all" || filter === "mentions" ? 0 : current.mentions; + return { all: assigned + mentions, assigned, mentions }; + }); + void dataSource.markAllRead?.(unreadItems, filter).catch(() => { + setItems((current) => + current.map((item) => + isCurrentItemMutation(item.id, epoch) && + previousReadAtById.has(item.id) + ? { + ...item, + readAt: previousReadAtById.get(item.id) ?? null, + } + : item + ) + ); + if (affectedIds.every((itemId) => isCurrentItemMutation(itemId, epoch))) { + setAuthoritativeUnreadCounts(previousCounts); + } setLoadState({ status: "error", message: t("teamInbox.errors.markAllRead"), @@ -281,11 +386,7 @@ const TeamInboxView: React.FC = ({ item={selectedItem} onMarkRead={dataSource.markRead ? handleMarkRead : undefined} onMarkUnread={dataSource.markUnread ? handleMarkUnread : undefined} - onNavigate={ - onNavigate - ? () => onNavigate(toTeamInboxNavigationIntent(selectedItem)) - : undefined - } + onNavigate={onNavigate} /> ); })(); @@ -306,7 +407,7 @@ const TeamInboxView: React.FC = ({ minListWidth={160} resizable collapsible - alwaysShowBreadcrumb + hideBreadcrumbWhenSidebarCollapsed listPanelBackgroundClassName="bg-bg-2" mainContentClassName="bg-bg-1" listContent={ diff --git a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts new file mode 100644 index 0000000000..fd347a904d --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +import React, { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { WorkItem } from "@src/types/core/workItem"; + +import AssignedWorkItemDetail from "../components/AssignedWorkItemDetail"; +import type { AssignedWorkItem } from "../domain"; + +const mocks = vi.hoisted(() => ({ + workItem: { + session_id: "work-item-1", + user_id: "member-2", + name: "Add Team Inbox", + status: "backlog", + spec: "Build the reusable feature surface.", + star: false, + target_date: null, + created_time: "2026-07-23T10:00:00.000Z", + updated_time: "2026-07-23T10:00:00.000Z", + todos: [], + linkedSessions: [], + orchestratorConfig: { + review_enabled: true, + follow_up_enabled: true, + auto_retry_on_failure: false, + max_retry_count: 1, + auto_create_pr: false, + selected_account_id: "account-1", + selected_model_id: "model-1", + }, + } as WorkItem, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("../useTeamInboxWorkItem", () => ({ + useTeamInboxWorkItem: () => ({ + workItem: mocks.workItem, + status: "ready", + error: null, + repoPath: "/repo", + members: [], + updateWorkItem: vi.fn(), + refreshWorkItem: vi.fn(), + }), +})); + +vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({ + WorkItemProperties: ({ pillLayout }: { pillLayout?: string }) => + createElement("div", { + "data-testid": "work-item-properties", + "data-pill-layout": pillLayout, + }), + WorkItemContent: ({ + onStartAgent, + onOpenSession, + headerProperties, + }: { + onStartAgent?: () => void; + onOpenSession?: (sessionId: string) => void; + headerProperties?: React.ReactNode; + }) => + createElement( + "div", + null, + headerProperties, + createElement( + "button", + { + type: "button", + "data-testid": "start-agent", + onClick: onStartAgent, + }, + "Start Agent" + ), + createElement( + "button", + { + type: "button", + "data-testid": "open-session", + onClick: () => onOpenSession?.("session-1"), + }, + "Open session" + ) + ), +})); + +vi.mock("../components/TeamInboxDetailLayout", () => ({ + default: ({ children }: { children?: React.ReactNode }) => + createElement("div", null, children), +})); + +const item: AssignedWorkItem = { + id: "work-item-1", + kind: "assigned_work_item", + occurredAt: "2026-07-23T10:00:00.000Z", + readAt: null, + actor: { id: "member-2", displayName: "Lin" }, + target: { + kind: "work_item", + projectId: "project-1", + workItemId: "work-item-1", + }, + payload: { + title: "Add Team Inbox", + status: "in_progress", + priority: "high", + assigneeMemberId: "member-2", + updatedAt: "2026-07-23T10:00:00.000Z", + }, +}; + +describe("AssignedWorkItemDetail navigation actions", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("requests canonical Work Item start instead of mounting an Inbox orchestrator", () => { + const onNavigate = vi.fn(); + act(() => { + root.render( + createElement(AssignedWorkItemDetail, { + item, + onNavigate, + }) + ); + }); + + act(() => { + container + .querySelector("[data-testid='start-agent']") + ?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith({ + kind: "open_work_item", + projectId: "project-1", + workItemId: "work-item-1", + action: "start_agent", + }); + }); + + it("uses the responsive wrapping layout for constrained property pills", () => { + act(() => { + root.render(createElement(AssignedWorkItemDetail, { item })); + }); + + expect( + container + .querySelector("[data-testid='work-item-properties']") + ?.getAttribute("data-pill-layout") + ).toBe("wrap"); + }); + + it("preserves linked-session navigation as a distinct Session tab intent", () => { + const onNavigate = vi.fn(); + act(() => { + root.render( + createElement(AssignedWorkItemDetail, { + item, + onNavigate, + }) + ); + }); + + act(() => { + container + .querySelector("[data-testid='open-session']") + ?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith({ + kind: "open_session", + sessionId: "session-1", + }); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md index d5b7fceef8..031f5a960c 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md +++ b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md @@ -19,36 +19,36 @@ Behavior is derived from the shipped implementation, not aspirational. ## Happy Path -| # | Steps | Expected Result | -|---|-------|-----------------| -| 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. | -| 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. | -| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both `localCursorRef` and `cloudCursorRef` are null, `hasMore` becomes false and the button disappears. | -| 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). | -| 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. | +| # | Steps | Expected Result | +| --- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. | +| 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. | +| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both `localCursorRef` and `cloudCursorRef` are null, `hasMore` becomes false and the button disappears. | +| 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). | +| 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. | ## Edge Cases -| # | Scenario | Steps | Expected Result | -|---|----------|-------|-----------------| -| 1 | Empty inbox | Open inbox with 0 items. | Empty `Placeholder` renders; **no** "Load more" button (it lives in the items-present branch). | -| 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. | -| 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. | -| 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). | -| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | `loadingMoreRef` guard + `disabled={loadingMore}` ensure only one in-flight load; extra clicks are no-ops; no duplicated/skipped pages. | -| 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. | -| 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. | -| 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). | -| 9 | Refresh after paginating | Load more, then trigger refresh (manual or project-change signal). | Cursors reset to page 1; `hasMore` recomputed from page-1 cursors; list resets to first page. | +| # | Scenario | Steps | Expected Result | +| --- | ------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Empty inbox | Open inbox with 0 items. | Empty `Placeholder` renders; **no** "Load more" button (it lives in the items-present branch). | +| 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. | +| 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. | +| 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). | +| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | `loadingMoreRef` guard + `disabled={loadingMore}` ensure only one in-flight load; extra clicks are no-ops; no duplicated/skipped pages. | +| 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. | +| 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. | +| 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). | +| 9 | Refresh after paginating | Load more, then trigger refresh (manual or project-change signal). | Cursors reset to page 1; `hasMore` recomputed from page-1 cursors; list resets to first page. | ## Error / Degraded States -| # | Scenario | Steps | Expected Result | -|---|----------|-------|-----------------| -| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Caught inside `loadMore` (`.catch(() => ({ mentions: [], nextCursor: undefined }))`); cloud cursor becomes null (cloud pagination stops); local page still appends; no crash. | -| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | `Promise.all` rejects → `handleLoadMore` catch sets the error banner (`teamInbox.errors.load`); `loadingMore` resets via `finally`; existing items remain. | -| 3 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. | -| 4 | Signed-out / no active cloud org | Only local paginates. | Cloud branch resolves to empty; only local advances; behavior identical to Edge #3. | +| # | Scenario | Steps | Expected Result | +| --- | ---------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Caught inside `loadMore` (`.catch(() => ({ mentions: [], nextCursor: undefined }))`); cloud cursor becomes null (cloud pagination stops); local page still appends; no crash. | +| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | `Promise.all` rejects → `handleLoadMore` catch sets the error banner (`teamInbox.errors.load`); `loadingMore` resets via `finally`; existing items remain. | +| 3 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. | +| 4 | Signed-out / no active cloud org | Only local paginates. | Cloud branch resolves to empty; only local advances; behavior identical to Edge #3. | ## Accessibility @@ -64,14 +64,14 @@ Behavior is derived from the shipped implementation, not aspirational. - [ ] Appended pages are de-duplicated and correctly ordered by the view selectors. - [ ] Concurrent/rapid load-more is guarded (single in-flight request). - [ ] A cloud failure degrades gracefully (local still paginates); a local failure surfaces a non-blocking error banner without losing loaded items. -- [ ] Unread badge semantics are unchanged by load-more (the single-source-of-truth question, A2, is intentionally out of scope here and documented in code). +- [ ] Load-more never derives the badge from the loaded window; the server's authoritative mention count remains unchanged until a read mutation succeeds. - [ ] `pnpm test` for `src/modules/MainApp/TeamInbox` passes; no new TypeScript/lint errors in edited files. ## Notes / Known limitations -- The unread badge does **not** count unread mentions that only appear on page 2+ - (badge = local full-DB unread + first-page unread mentions). This matches the - pre-A1 direction and is deferred to the A2 "unread single source of truth" task. +- The unread badge uses the cloud RPC's authoritative full-result count, so + unread mentions on page 2+ are included before those rows are loaded. - Hook-level behavior is not unit-tested (repo policy forbids `.tsx` / React Testing Library tests); pure logic is covered by `selectors.test.ts` - (dedupe/sort/select) and `store.test.ts`. + (dedupe/sort/select), while the two-instance rendered cloud spec covers the + production mention picker and durable read-receipt path. diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts new file mode 100644 index 0000000000..41599e518f --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import TeamInboxView from "../TeamInboxView"; + +const splitViewProps = vi.hoisted(() => ({ + current: null as Record | null, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@src/modules/shared/layouts/SplitViewLayout", () => ({ + default: (props: Record) => { + splitViewProps.current = props; + return createElement("div", { "data-testid": "team-inbox-split" }); + }, +})); + +vi.mock("@src/modules/shared/layouts/blocks", () => ({ + Placeholder: () => null, +})); + +vi.mock("../components", () => ({ + AssignedWorkItemDetail: () => null, + CommentMentionDetail: () => null, + TeamInboxList: () => null, +})); + +describe("TeamInboxView split layout", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + splitViewProps.current = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("does not leak the global Code Editor breadcrumb into Team Inbox", () => { + act(() => { + root.render( + createElement(TeamInboxView, { + dataSource: { + listPage: () => new Promise(() => undefined), + }, + }) + ); + }); + + expect(splitViewProps.current?.alwaysShowBreadcrumb).toBeUndefined(); + expect(splitViewProps.current?.hideBreadcrumbWhenSidebarCollapsed).toBe( + true + ); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts index fd71a24346..988f187a47 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts @@ -2,10 +2,25 @@ import { describe, expect, it } from "vitest"; import { humanizeToken, + isGitHubIssueStatus, workItemPriorityLabelKey, workItemStatusLabelKey, } from "../domain/labels"; +describe("isGitHubIssueStatus", () => { + it("recognizes the GitHub issue status vocabulary", () => { + expect(isGitHubIssueStatus("open")).toBe(true); + expect(isGitHubIssueStatus("closed")).toBe(true); + }); + + it("rejects local Work Item statuses", () => { + expect(isGitHubIssueStatus("todo")).toBe(false); + expect(isGitHubIssueStatus("in_progress")).toBe(false); + expect(isGitHubIssueStatus("completed")).toBe(false); + expect(isGitHubIssueStatus("")).toBe(false); + }); +}); + describe("humanizeToken", () => { it("sentence-cases a snake_case enum token", () => { expect(humanizeToken("in_progress")).toBe("In progress"); diff --git a/src/modules/MainApp/TeamInbox/__tests__/store.test.ts b/src/modules/MainApp/TeamInbox/__tests__/store.test.ts deleted file mode 100644 index 05f66b60be..0000000000 --- a/src/modules/MainApp/TeamInbox/__tests__/store.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS, - addTeamInboxCloudReadReceipts, - removeTeamInboxCloudReadReceipts, -} from "../store"; - -describe("addTeamInboxCloudReadReceipts", () => { - it("keeps the persisted receipt map bounded", () => { - const current = Object.fromEntries( - Array.from({ length: MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS }, (_, index) => [ - `receipt-${index}`, - new Date(index).toISOString(), - ]) - ); - - const next = addTeamInboxCloudReadReceipts(current, { - "receipt-new": "2026-07-23T12:00:00.000Z", - }); - - expect(Object.keys(next)).toHaveLength(MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS); - expect(next).not.toHaveProperty("receipt-0"); - expect(next["receipt-new"]).toBe("2026-07-23T12:00:00.000Z"); - }); - - it("refreshes an existing receipt without evicting an extra entry", () => { - const next = addTeamInboxCloudReadReceipts( - { - first: "2026-07-23T10:00:00.000Z", - second: "2026-07-23T11:00:00.000Z", - }, - { first: "2026-07-23T12:00:00.000Z" } - ); - - expect(next).toEqual({ - second: "2026-07-23T11:00:00.000Z", - first: "2026-07-23T12:00:00.000Z", - }); - }); -}); - -describe("removeTeamInboxCloudReadReceipts", () => { - it("deletes the given receipt keys", () => { - const next = removeTeamInboxCloudReadReceipts( - { - keep: "2026-07-23T10:00:00.000Z", - drop: "2026-07-23T11:00:00.000Z", - }, - ["drop"] - ); - - expect(next).toEqual({ keep: "2026-07-23T10:00:00.000Z" }); - }); - - it("returns the same reference when nothing changes", () => { - const current = { keep: "2026-07-23T10:00:00.000Z" }; - expect(removeTeamInboxCloudReadReceipts(current, [])).toBe(current); - expect(removeTeamInboxCloudReadReceipts(current, ["missing"])).toBe( - current - ); - }); -}); diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx index ab80ecbe4d..afcce63421 100644 --- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx +++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx @@ -2,19 +2,32 @@ import { ClipboardList, ExternalLink } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; -import Markdown from "@src/components/MarkDown"; -import { CARD_ROW_TOKENS } from "@src/modules/shared/layouts/blocks"; +import { + WorkItemContent, + WorkItemProperties, +} from "@src/modules/ProjectManager/WorkItems/components"; +import type { WorkItemPropertyFieldKey } from "@src/modules/ProjectManager/WorkItems/components/WorkItemProperties/types"; +import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import type { Person } from "@src/types/core/shared"; +import type { WorkItem } from "@src/types/core/workItem"; import { type AssignedWorkItem, type TeamInboxNavigationIntent, - humanizeToken, - workItemPriorityLabelKey, - workItemStatusLabelKey, + isGitHubIssueStatus, } from "../domain"; -import { useTeamInboxWorkItemBody } from "../useTeamInboxWorkItemBody"; +import { useTeamInboxWorkItem } from "../useTeamInboxWorkItem"; import TeamInboxDetailLayout from "./TeamInboxDetailLayout"; +const WORK_ITEM_THREAD_PROPERTY_FIELDS: WorkItemPropertyFieldKey[] = [ + "project", + "status", + "priority", + "assignee", + "reviewer", + "date", +]; + export interface AssignedWorkItemDetailProps { item: AssignedWorkItem; onNavigate?: (intent: TeamInboxNavigationIntent) => void; @@ -22,6 +35,95 @@ export interface AssignedWorkItemDetailProps { onMarkUnread?: (item: AssignedWorkItem) => void; } +interface AssignedWorkItemThreadProps { + item: AssignedWorkItem; + workItem: WorkItem; + repoPath: string | null; + members: Person[]; + error: string | null; + updateWorkItem: (updates: Partial) => void; + refreshWorkItem: () => void; + onNavigate?: (intent: TeamInboxNavigationIntent) => void; +} + +const AssignedWorkItemThread: React.FC = ({ + item, + workItem, + repoPath, + members, + error, + updateWorkItem, + refreshWorkItem, + onNavigate, +}) => { + const canUpdate = Boolean(item.target.projectId); + const isGitHubIssue = isGitHubIssueStatus(item.payload.status); + + const properties = canUpdate ? ( + + ) : null; + + return ( +
+ {error ? ( +
+ {error} +
+ ) : null} +
+ + onNavigate({ + kind: "open_work_item", + projectId: item.target.projectId, + workItemId: item.target.workItemId, + action: "start_agent", + }) + : undefined + } + onOpenSession={ + onNavigate + ? (sessionId) => + onNavigate({ + kind: "open_session", + sessionId, + }) + : undefined + } + onRefreshWorkflow={refreshWorkItem} + /> +
+
+ ); +}; + const AssignedWorkItemDetail: React.FC = ({ item, onNavigate, @@ -29,25 +131,28 @@ const AssignedWorkItemDetail: React.FC = ({ onMarkUnread, }) => { const { t } = useTranslation(); - const { body } = useTeamInboxWorkItemBody(item.target); - const excerpt = item.payload.summary ?? null; - const statusLabel = t(workItemStatusLabelKey(item.payload.status), { - defaultValue: humanizeToken(item.payload.status), - }); - const priorityLabel = t(workItemPriorityLabelKey(item.payload.priority), { - defaultValue: humanizeToken(item.payload.priority), - }); + const { + workItem, + status, + error, + repoPath, + members, + updateWorkItem, + refreshWorkItem, + } = useTeamInboxWorkItem(item.target); return ( } + openPlacement="header" onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined} onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined} onOpen={ @@ -60,32 +165,32 @@ const AssignedWorkItemDetail: React.FC = ({ }) : undefined } - metadata={[ - { label: t("teamInbox.fields.status"), value: statusLabel }, - { label: t("teamInbox.fields.priority"), value: priorityLabel }, - { - label: t("teamInbox.fields.assignee"), - value: item.payload.assigneeName ?? item.payload.assigneeMemberId, - }, - { - label: t("teamInbox.fields.workItemId"), - value: item.target.workItemId, - }, - ]} > - {body ? ( -
-
- -
-
- ) : excerpt ? ( -
-

- {excerpt} -

-
- ) : null} + {status === "loading" ? ( + + ) : status === "ready" && workItem ? ( + + ) : ( + + )}
); }; diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx index 8a8ff5713e..34e310b50c 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxDetailLayout.tsx @@ -16,7 +16,12 @@ export interface TeamInboxDetailLayoutProps { title: string; subtitle: string; icon: LucideIcon; - metadata: InfoCardRow[]; + metadata?: InfoCardRow[]; + /** + * `scroll` owns a padded detail column. `fill` lets a nested Work Item own + * its scrolling and responsive rail. + */ + contentLayout?: "scroll" | "fill"; unread: boolean; markReadLabel: string; markUnreadLabel?: string; @@ -25,6 +30,7 @@ export interface TeamInboxDetailLayoutProps { onMarkRead?: () => void; onMarkUnread?: () => void; onOpen?: () => void; + openPlacement?: "header" | "footer"; children?: React.ReactNode; } @@ -33,6 +39,7 @@ const TeamInboxDetailLayout: React.FC = ({ subtitle, icon, metadata, + contentLayout = "scroll", unread, markReadLabel, markUnreadLabel, @@ -41,60 +48,96 @@ const TeamInboxDetailLayout: React.FC = ({ onMarkRead, onMarkUnread, onOpen, + openPlacement = "footer", children, -}) => ( - - } - onClick={onMarkRead} - > - {markReadLabel} - +}) => { + const readAction = unread ? ( + onMarkRead ? ( + + ) : null + ) : onMarkUnread && markUnreadLabel ? ( + + ) : null; + const headerOpenAction = + onOpen && openPlacement === "header" ? ( + + ) : null; + + return ( + + + {readAction} + {headerOpenAction} +
) : undefined - ) : onMarkUnread && markUnreadLabel ? ( - - ) : undefined - } - /> + } + /> -
-
- {children ? ( -
{children}
- ) : null} - -
-
+ {contentLayout === "fill" ? ( +
+ {children} +
+ ) : ( +
+
+ {children ? ( +
{children}
+ ) : null} + {metadata && metadata.length > 0 ? ( + + ) : null} +
+
+ )} - {onOpen ? ( - - ) : null} - -); + {onOpen && openPlacement === "footer" ? ( + + ) : null} + + ); +}; + +/* + * Keep the detail shell shared across mention and assigned-item surfaces. + * Assigned Work Items opt into header placement so the thread owns the full + * vertical canvas; other sources retain the established footer action. + */ export default TeamInboxDetailLayout; diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx index 54d94cbb15..308b143266 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx @@ -185,6 +185,7 @@ const TeamInboxList: React.FC = ({ } title={t("inbox.markAllAsRead")} aria-label={t("inbox.markAllAsRead")} + data-testid="team-inbox-mark-all-read" onClick={onMarkAllRead} /> ) : null} diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx index 1903b8bb60..1a2bf0f5ed 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx @@ -56,6 +56,9 @@ const TeamInboxRow = forwardRef( aria-selected={selected} aria-label={`${title},${readLabel}`} tabIndex={selected ? 0 : -1} + data-testid="team-inbox-row" + data-item-kind={item.kind} + data-item-id={item.id} data-unread={unread} className={`${getListItemClasses(selected)} w-full min-w-0 !items-start text-left`} onClick={() => onSelect(item)} diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts index 6c1dd973ee..516b2ffde6 100644 --- a/src/modules/MainApp/TeamInbox/domain/index.ts +++ b/src/modules/MainApp/TeamInbox/domain/index.ts @@ -18,6 +18,7 @@ export type { } from "./selectors"; export { humanizeToken, + isGitHubIssueStatus, workItemPriorityLabelKey, workItemStatusLabelKey, } from "./labels"; diff --git a/src/modules/MainApp/TeamInbox/domain/labels.ts b/src/modules/MainApp/TeamInbox/domain/labels.ts index f49ddb3a80..ebc635afe2 100644 --- a/src/modules/MainApp/TeamInbox/domain/labels.ts +++ b/src/modules/MainApp/TeamInbox/domain/labels.ts @@ -1,3 +1,13 @@ +import { WORK_ITEM_STATUS } from "@src/types/core/workItem"; + +/** GitHub-backed Work Items use the open/closed status vocabulary. */ +export function isGitHubIssueStatus(status: string): boolean { + return ( + status === WORK_ITEM_STATUS.GITHUB_OPEN || + status === WORK_ITEM_STATUS.GITHUB_CLOSED + ); +} + /** * Turns a raw enum token from the work-item read model (e.g. `in_progress`, * `HIGH`, `in-review`) into a human sentence-cased label (`In progress`, diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts index 7da20f9cc8..1a59324a18 100644 --- a/src/modules/MainApp/TeamInbox/domain/types.ts +++ b/src/modules/MainApp/TeamInbox/domain/types.ts @@ -66,6 +66,12 @@ export interface TeamInboxCursor { export interface TeamInboxPage { items: TeamInboxItem[]; nextCursor: TeamInboxCursor | null; + /** Authoritative source totals; absent on lightweight/test data sources. */ + unreadCounts?: { + all: number; + mentions: number; + assigned: number; + }; } export interface ListTeamInboxInput { @@ -84,7 +90,10 @@ export interface TeamInboxDataSource { listPage(input: ListTeamInboxInput): Promise; markRead?(item: TeamInboxItem): Promise; markUnread?(item: TeamInboxItem): Promise; - markAllRead?(items: readonly TeamInboxItem[]): Promise; + markAllRead?( + items: readonly TeamInboxItem[], + filter?: TeamInboxFilter + ): Promise; refresh?(): Promise; /** * Loads the next page from every source that still has one and appends the @@ -95,6 +104,10 @@ export interface TeamInboxDataSource { } export type TeamInboxNavigationIntent = + | { + kind: "open_session"; + sessionId: string; + } | { kind: "open_session_comment"; sessionId: string; @@ -106,4 +119,5 @@ export type TeamInboxNavigationIntent = kind: "open_work_item"; projectId: string; workItemId: string; + action?: "start_agent"; }; diff --git a/src/modules/MainApp/TeamInbox/store.ts b/src/modules/MainApp/TeamInbox/store.ts index e0b26f36e1..dfa6da566b 100644 --- a/src/modules/MainApp/TeamInbox/store.ts +++ b/src/modules/MainApp/TeamInbox/store.ts @@ -1,11 +1,12 @@ import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; import type { TeamInboxItem } from "./domain"; +import type { TeamInboxUnreadCounts } from "./domain"; export interface TeamInboxCacheState { items: TeamInboxItem[]; unreadCount: number; + unreadCounts: TeamInboxUnreadCounts; loading: boolean; error: string | null; revision: number; @@ -17,6 +18,7 @@ export interface TeamInboxCacheState { export const teamInboxCacheAtom = atom({ items: [], unreadCount: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, loading: false, error: null, revision: 0, @@ -33,54 +35,6 @@ teamInboxUnreadCountAtom.debugLabel = "teamInboxUnreadCountAtom"; export const teamInboxInvalidationAtom = atom(0); teamInboxInvalidationAtom.debugLabel = "teamInboxInvalidationAtom"; -export type TeamInboxCloudReadReceipts = Record; -export const MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS = 1_000; - -export function addTeamInboxCloudReadReceipts( - current: TeamInboxCloudReadReceipts, - additions: TeamInboxCloudReadReceipts -): TeamInboxCloudReadReceipts { - const next = { ...current }; - for (const [key, readAt] of Object.entries(additions)) { - delete next[key]; - next[key] = readAt; - } - const keys = Object.keys(next); - for ( - let index = 0; - index < keys.length - MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS; - index += 1 - ) { - delete next[keys[index]!]; - } - return next; -} - -export function removeTeamInboxCloudReadReceipts( - current: TeamInboxCloudReadReceipts, - keys: readonly string[] -): TeamInboxCloudReadReceipts { - if (keys.length === 0) return current; - let changed = false; - const next = { ...current }; - for (const key of keys) { - if (key in next) { - delete next[key]; - changed = true; - } - } - return changed ? next : current; -} - -export const teamInboxCloudReadReceiptsAtom = - atomWithStorage( - "orgii:team-inbox:cloud-read-receipts", - {}, - undefined, - { getOnInit: true } - ); -teamInboxCloudReadReceiptsAtom.debugLabel = "teamInboxCloudReadReceiptsAtom"; - export const invalidateTeamInboxAtom = atom(null, (get, set) => { set(teamInboxInvalidationAtom, get(teamInboxInvalidationAtom) + 1); }); diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts index 3812a06179..00be22929f 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts @@ -1,5 +1,12 @@ import { useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { invalidateProjectCache, projectApi } from "@src/api/http/project"; import type { MemberEntry } from "@src/api/http/project"; @@ -14,7 +21,10 @@ import { import { sidebarActiveCloudOrgIdAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; import { type TeamInboxMention, + listInitialTeamInboxMentions, listTeamInboxMentions, + markAllTeamInboxMentionsRead, + setTeamInboxMentionRead, } from "@src/features/Org2Cloud/teamInboxMentionsClient"; import { useProjectDataChanged } from "@src/hooks/project"; import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; @@ -33,16 +43,13 @@ import type { TeamInboxItem, } from "./domain"; import { - type TeamInboxCloudReadReceipts, - addTeamInboxCloudReadReceipts, invalidateTeamInboxAtom, - removeTeamInboxCloudReadReceipts, teamInboxCacheAtom, - teamInboxCloudReadReceiptsAtom, teamInboxInvalidationAtom, } from "./store"; const listeners = new Set<() => void>(); +const MAX_PENDING_TEAM_INBOX_MUTATIONS = 100; let membersRequest: Promise | null = null; let inboxRequest: { key: string; @@ -50,19 +57,25 @@ let inboxRequest: { mentionItems: TeamInboxItem[]; localItems: TeamInboxItem[]; localUnread: number; + cloudUnread: number; localNextCursor: TeamInboxCursor | null; cloudNextCursor: string | null; }>; } | null = null; +const EMPTY_CLOUD_MENTION_PAGE = { + mentions: [], + nextCursor: undefined, + unreadCount: 0, +} as const; + function notifyTeamInboxListeners(): void { for (const listener of listeners) listener(); } /** - * Maps raw cloud mentions into Team Inbox items with `readAt` left unresolved; - * the caller overlays the latest local read receipts afterwards. Shared by the - * initial load and `loadMore` so both pages produce identical item shapes. + * Maps the server-authoritative cloud mention projection into Team Inbox + * items. Shared by initial load and pagination so both paths stay identical. */ function mapMentionsToItems( mentions: readonly TeamInboxMention[], @@ -74,7 +87,7 @@ function mapMentionsToItems( id: itemId, kind: "comment_mention" as const, occurredAt: mention.createdAt, - readAt: null, + readAt: mention.readAt, actor: { id: mention.author.userId, displayName: mention.author.displayName ?? "Team member", @@ -96,18 +109,6 @@ function mapMentionsToItems( }); } -/** Overlays the current cloud read receipts onto freshly-mapped mention items. */ -function overlayCloudReadReceipts( - mentionItems: readonly TeamInboxItem[], - cloudReadReceipts: TeamInboxCloudReadReceipts, - cloudScopeKey: string -): TeamInboxItem[] { - return mentionItems.map((item) => ({ - ...item, - readAt: cloudReadReceipts[`${cloudScopeKey}|${item.id}`] ?? null, - })); -} - /** * Resolves each assigned item's display name from its stable `assigneeMemberId` * into the optional `assigneeName` field. When the member cannot be resolved the @@ -165,10 +166,6 @@ export function useTeamInboxDataSource(): { const activeCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom); const viewerKey = `${viewerMemberIds.join("|")}::${authIdentityKey ?? "signed-out"}::${activeCloudOrgId ?? "local"}`; const commentsSignals = useAtomValue(org2CloudCommentsSignalAtom); - const cloudReadReceipts = useAtomValue(teamInboxCloudReadReceiptsAtom); - const cloudReadReceiptsRef = useRef(cloudReadReceipts); - cloudReadReceiptsRef.current = cloudReadReceipts; - const setCloudReadReceipts = useSetAtom(teamInboxCloudReadReceiptsAtom); const activeCloudCommentsRevision = activeCloudOrgId ? (commentsSignals[orgCommentsKey(activeCloudOrgId)] ?? 0) : 0; @@ -179,6 +176,68 @@ export function useTeamInboxDataSource(): { const localCursorRef = useRef(null); const cloudCursorRef = useRef(null); const loadingMoreRef = useRef(false); + const mutationQueueRef = useRef>(Promise.resolve()); + const pendingMutationCountRef = useRef(0); + + const enqueueMutation = useCallback( + (operation: () => Promise): Promise => { + if (pendingMutationCountRef.current >= MAX_PENDING_TEAM_INBOX_MUTATIONS) { + return Promise.reject( + new Error("Too many pending Team Inbox updates; try again shortly") + ); + } + pendingMutationCountRef.current += 1; + const run = async (): Promise => { + try { + return await operation(); + } finally { + pendingMutationCountRef.current = Math.max( + 0, + pendingMutationCountRef.current - 1 + ); + } + }; + const result = mutationQueueRef.current.then(run, run); + mutationQueueRef.current = result.then( + () => undefined, + () => undefined + ); + return result; + }, + [] + ); + + useLayoutEffect(() => { + if ( + cache.loadedForViewerKey === null || + cache.loadedForViewerKey === viewerKey + ) { + return; + } + + // Never render the previous account/org projection while the new identity + // is revalidating. Bump the generation first so late page/mutation + // completions cannot repopulate the evicted cache. + loadGeneration.current += 1; + localCursorRef.current = null; + cloudCursorRef.current = null; + loadingMoreRef.current = false; + setCache((current) => + current.loadedForViewerKey === viewerKey + ? current + : { + ...current, + items: [], + unreadCount: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, + loading: true, + hasMore: false, + loadedForViewerKey: null, + error: null, + revision: current.revision + 1, + } + ); + }, [cache.loadedForViewerKey, setCache, viewerKey]); useEffect(() => { let cancelled = false; @@ -215,6 +274,7 @@ export function useTeamInboxDataSource(): { ...current, items: [], unreadCount: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, loading: false, hasMore: false, loadedForViewerKey: viewerKey, @@ -239,13 +299,12 @@ export function useTeamInboxDataSource(): { unreadCount: 0, }), auth && activeCloudOrgId - ? listTeamInboxMentions( + ? listInitialTeamInboxMentions( auth.accessToken, activeCloudOrgId, - null, 50 - ).catch(() => ({ mentions: [], nextCursor: undefined })) - : Promise.resolve({ mentions: [], nextCursor: undefined }), + ) + : Promise.resolve(EMPTY_CLOUD_MENTION_PAGE), ]).then(([{ page, unreadCount }, mentionPage]) => { // Read state is intentionally NOT baked in here: the cached request // promise stays receipt-independent so a mention marked read while @@ -259,38 +318,30 @@ export function useTeamInboxDataSource(): { mentionItems, localItems: page.items, localUnread: unreadCount, + cloudUnread: mentionPage.unreadCount, localNextCursor: page.nextCursor, cloudNextCursor: mentionPage.nextCursor ?? null, }; }); inboxRequest = { key: requestKey, promise }; - void promise.finally(() => { + const clearSettledRequest = () => { if (inboxRequest?.promise === promise) inboxRequest = null; - }); + }; + void promise.then(clearSettledRequest, clearSettledRequest); } const { mentionItems, localItems, localUnread, + cloudUnread, localNextCursor, cloudNextCursor, } = await inboxRequest.promise; if (generation !== loadGeneration.current) return; localCursorRef.current = localNextCursor; cloudCursorRef.current = cloudNextCursor; - // Overlay the latest cloud read receipts here (not inside the cached - // request promise) so optimistic mark-read/unread survives a concurrent - // in-flight list request. - const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; - const overlaidMentions = overlayCloudReadReceipts( - mentionItems, - cloudReadReceiptsRef.current, - cloudScopeKey - ); - const mergedItems = [...overlaidMentions, ...localItems]; - const unreadCount = - localUnread + - overlaidMentions.filter((item) => item.readAt === null).length; + const mergedItems = [...mentionItems, ...localItems]; + const unreadCount = localUnread + cloudUnread; const resolvedItems = resolveAssigneeDisplayNames( mergedItems, membersRef.current @@ -299,6 +350,11 @@ export function useTeamInboxDataSource(): { ...current, items: resolvedItems, unreadCount, + unreadCounts: { + all: unreadCount, + mentions: cloudUnread, + assigned: localUnread, + }, loading: false, error: null, loadedForViewerKey: viewerKey, @@ -319,8 +375,6 @@ export function useTeamInboxDataSource(): { }, [ activeCloudOrgId, auth, - authIdentityKey, - cloudReadReceipts, members.length, setCache, viewerKey, @@ -347,6 +401,7 @@ export function useTeamInboxDataSource(): { // cursors internally. return { items: cache.items, + unreadCounts: cache.unreadCounts, nextCursor: cache.hasMore ? { occurredAt: "", itemKey: "team-inbox-has-more" } : null, @@ -354,6 +409,7 @@ export function useTeamInboxDataSource(): { }, loadMore: async () => { if (loadingMoreRef.current) return; + const generation = loadGeneration.current; const localCursor = localCursorRef.current; const cloudCursor = cloudCursorRef.current; if (!localCursor && !cloudCursor) return; @@ -372,16 +428,19 @@ export function useTeamInboxDataSource(): { activeCloudOrgId, cloudCursor, 50 - ).catch(() => ({ mentions: [], nextCursor: undefined })) - : Promise.resolve({ mentions: [], nextCursor: undefined }), + ) + : Promise.resolve({ + mentions: [], + nextCursor: undefined, + unreadCount: cache.unreadCounts.mentions, + }), ]); + if (generation !== loadGeneration.current) return; localCursorRef.current = localResult.page.nextCursor ?? null; cloudCursorRef.current = cloudResult.nextCursor ?? null; - const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; - const appendedMentions = overlayCloudReadReceipts( - mapMentionsToItems(cloudResult.mentions, activeCloudOrgId ?? ""), - cloudReadReceiptsRef.current, - cloudScopeKey + const appendedMentions = mapMentionsToItems( + cloudResult.mentions, + activeCloudOrgId ?? "" ); const appended = resolveAssigneeDisplayNames( [...appendedMentions, ...localResult.page.items], @@ -416,89 +475,177 @@ export function useTeamInboxDataSource(): { invalidate(); }, markRead: async (item: TeamInboxItem) => { - const readAt = new Date().toISOString(); - if (item.kind === "comment_mention") { - const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; - setCloudReadReceipts((current) => - addTeamInboxCloudReadReceipts(current, { - [`${cloudScopeKey}|${item.id}`]: readAt, - }) - ); - } else { - await markLocalTeamInboxItemRead(viewerMemberIds, item.id); - } - setCache((current) => ({ - ...current, - items: current.items.map((candidate) => - candidate.id === item.id ? { ...candidate, readAt } : candidate - ), - unreadCount: Math.max(0, current.unreadCount - 1), - revision: current.revision + 1, - })); - notifyTeamInboxListeners(); + const generation = loadGeneration.current; + return enqueueMutation(async () => { + let readAt = new Date().toISOString(); + let cloudUnread: number | null = null; + if (item.kind === "comment_mention") { + if (!auth || !activeCloudOrgId) { + throw new Error( + "Cloud identity is required to mark a mention read" + ); + } + const result = await setTeamInboxMentionRead( + auth.accessToken, + activeCloudOrgId, + item.target.commentId, + true + ); + readAt = result.readAt ?? readAt; + cloudUnread = result.unreadCount; + } else { + await markLocalTeamInboxItemRead(viewerMemberIds, item.id); + } + if (generation !== loadGeneration.current) return; + setCache((current) => { + const wasUnread = + current.items.find((candidate) => candidate.id === item.id) + ?.readAt === null; + const assignedUnread = + item.kind === "comment_mention" + ? current.unreadCounts.assigned + : Math.max( + 0, + current.unreadCounts.assigned - (wasUnread ? 1 : 0) + ); + const mentionUnread = + item.kind === "comment_mention" + ? (cloudUnread ?? current.unreadCounts.mentions) + : current.unreadCounts.mentions; + return { + ...current, + items: current.items.map((candidate) => + candidate.id === item.id ? { ...candidate, readAt } : candidate + ), + unreadCounts: { + all: assignedUnread + mentionUnread, + assigned: assignedUnread, + mentions: mentionUnread, + }, + unreadCount: assignedUnread + mentionUnread, + revision: current.revision + 1, + }; + }); + notifyTeamInboxListeners(); + }); }, markUnread: async (item: TeamInboxItem) => { - if (item.kind === "comment_mention") { - const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; - setCloudReadReceipts((current) => - removeTeamInboxCloudReadReceipts(current, [ - `${cloudScopeKey}|${item.id}`, - ]) - ); - } else { - await markLocalTeamInboxItemUnread(viewerMemberIds, item.id); - } - setCache((current) => ({ - ...current, - items: current.items.map((candidate) => - candidate.id === item.id - ? { ...candidate, readAt: null } - : candidate - ), - unreadCount: current.unreadCount + 1, - revision: current.revision + 1, - })); - notifyTeamInboxListeners(); + const generation = loadGeneration.current; + return enqueueMutation(async () => { + let cloudUnread: number | null = null; + if (item.kind === "comment_mention") { + if (!auth || !activeCloudOrgId) { + throw new Error( + "Cloud identity is required to mark a mention unread" + ); + } + const result = await setTeamInboxMentionRead( + auth.accessToken, + activeCloudOrgId, + item.target.commentId, + false + ); + cloudUnread = result.unreadCount; + } else { + await markLocalTeamInboxItemUnread(viewerMemberIds, item.id); + } + if (generation !== loadGeneration.current) return; + setCache((current) => { + const wasUnread = + current.items.find((candidate) => candidate.id === item.id) + ?.readAt === null; + const assignedUnread = + item.kind === "comment_mention" + ? current.unreadCounts.assigned + : current.unreadCounts.assigned + (wasUnread ? 0 : 1); + const mentionUnread = + item.kind === "comment_mention" + ? (cloudUnread ?? current.unreadCounts.mentions) + : current.unreadCounts.mentions; + return { + ...current, + items: current.items.map((candidate) => + candidate.id === item.id + ? { ...candidate, readAt: null } + : candidate + ), + unreadCounts: { + all: assignedUnread + mentionUnread, + assigned: assignedUnread, + mentions: mentionUnread, + }, + unreadCount: assignedUnread + mentionUnread, + revision: current.revision + 1, + }; + }); + notifyTeamInboxListeners(); + }); }, - markAllRead: async (items) => { - const assigned = items.filter( - ( - item - ): item is Extract => - item.kind === "assigned_work_item" - ); - if (assigned.length > 0) { - await markAllLocalTeamInboxRead(viewerMemberIds, "assigned"); - } - const readAt = new Date().toISOString(); - const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; - const mentionReceipts = items - .filter((item) => item.kind === "comment_mention") - .reduce>((next, item) => { - next[`${cloudScopeKey}|${item.id}`] = readAt; - return next; - }, {}); - if (Object.keys(mentionReceipts).length > 0) { - setCloudReadReceipts((current) => - addTeamInboxCloudReadReceipts(current, mentionReceipts) - ); - } - const itemIds = new Set(items.map((item) => item.id)); - // Decrement only by the items that were actually unread; counting the - // whole set would over-subtract when some passed items were already read. - const newlyReadCount = items.reduce( - (count, item) => count + (item.readAt === null ? 1 : 0), - 0 - ); - setCache((current) => ({ - ...current, - items: current.items.map((item) => - itemIds.has(item.id) ? { ...item, readAt } : item - ), - unreadCount: Math.max(0, current.unreadCount - newlyReadCount), - revision: current.revision + 1, - })); - notifyTeamInboxListeners(); + markAllRead: async (_items, filter = "all") => { + const generation = loadGeneration.current; + return enqueueMutation(async () => { + const includeAssigned = filter === "all" || filter === "assigned"; + const includeMentions = filter === "all" || filter === "mentions"; + let cloudReadAt: string | null = null; + let cloudUnread: number | null = null; + if ( + includeMentions && + cache.unreadCounts.mentions > 0 && + (!auth || !activeCloudOrgId) + ) { + throw new Error( + "Cloud identity is required to mark all mentions read" + ); + } + try { + const [, cloudResult] = await Promise.all([ + includeAssigned && cache.unreadCounts.assigned > 0 + ? markAllLocalTeamInboxRead(viewerMemberIds, "assigned") + : Promise.resolve(), + includeMentions && + cache.unreadCounts.mentions > 0 && + auth && + activeCloudOrgId + ? markAllTeamInboxMentionsRead( + auth.accessToken, + activeCloudOrgId + ) + : Promise.resolve(null), + ]); + cloudReadAt = cloudResult?.readAt ?? null; + cloudUnread = cloudResult?.unreadCount ?? null; + } catch (error) { + invalidate(); + throw error; + } + if (generation !== loadGeneration.current) return; + const readAt = cloudReadAt ?? new Date().toISOString(); + setCache((current) => { + const assignedUnread = includeAssigned + ? 0 + : current.unreadCounts.assigned; + const mentionUnread = includeMentions + ? (cloudUnread ?? 0) + : current.unreadCounts.mentions; + return { + ...current, + items: current.items.map((item) => + (includeAssigned && item.kind === "assigned_work_item") || + (includeMentions && item.kind === "comment_mention") + ? { ...item, readAt } + : item + ), + unreadCounts: { + all: assignedUnread + mentionUnread, + assigned: assignedUnread, + mentions: mentionUnread, + }, + unreadCount: assignedUnread + mentionUnread, + revision: current.revision + 1, + }; + }); + notifyTeamInboxListeners(); + }); }, subscribe: (listener: () => void) => { listeners.add(listener); @@ -508,13 +655,13 @@ export function useTeamInboxDataSource(): { [ activeCloudOrgId, auth, - authIdentityKey, cache.error, cache.hasMore, cache.items, + cache.unreadCounts, + enqueueMutation, invalidate, setCache, - setCloudReadReceipts, viewerMemberIds, ] ); diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts index 29d5dc1a3a..45650029bd 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts @@ -10,6 +10,7 @@ import { createLogger } from "@src/hooks/logger"; import { openOrFocusSessionInChatPanelTabAtom, openWorkItemInChatPanelTabAtom, + requestChatPanelWorkItemActionAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; import { sessionsAtom } from "@src/store/session"; @@ -23,10 +24,14 @@ export function useTeamInboxNavigation(): ( const sessions = useAtomValue(sessionsAtom); const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom); const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); + const requestWorkItemAction = useSetAtom(requestChatPanelWorkItemActionAtom); return useCallback( (intent: TeamInboxNavigationIntent) => { - if (intent.kind === "open_session_comment") { + if ( + intent.kind === "open_session" || + intent.kind === "open_session_comment" + ) { const session = sessions.find( (candidate) => candidate.session_id === intent.sessionId ); @@ -35,11 +40,13 @@ export function useTeamInboxNavigation(): ( sessionName: session?.name, repoPath: session?.repoPath, }); - window.requestAnimationFrame(() => { - document - .getElementById(intent.anchor ?? `comment-${intent.commentId}`) - ?.scrollIntoView({ block: "center", behavior: "smooth" }); - }); + if (intent.kind === "open_session_comment") { + window.requestAnimationFrame(() => { + document + .getElementById(intent.anchor ?? `comment-${intent.commentId}`) + ?.scrollIntoView({ block: "center", behavior: "smooth" }); + }); + } return; } @@ -58,6 +65,12 @@ export function useTeamInboxNavigation(): ( projectName: project?.meta.name ?? "Standalone", orgId: project?.meta.org_id, }); + if (intent.action) { + requestWorkItemAction({ + workItemShortId: shortId, + action: intent.action, + }); + } }; if (!intent.projectId) { @@ -79,6 +92,6 @@ export function useTeamInboxNavigation(): ( log.warn("Failed to open project Team Inbox Work Item", error); }); }, - [openSession, openWorkItem, sessions] + [openSession, openWorkItem, requestWorkItemAction, sessions] ); } diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts new file mode 100644 index 0000000000..ee208c82a3 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + enrichedWorkItemToUI, + projectApi, + standaloneWorkItemDataToEnriched, +} from "@src/api/http/project"; +import type { MemberEntry } from "@src/api/http/project"; +import { createLogger } from "@src/hooks/logger"; +import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate"; +import type { Person } from "@src/types/core/shared"; +import type { WorkItem } from "@src/types/core/workItem"; + +import type { WorkItemTarget } from "./domain"; + +const log = createLogger("TeamInboxWorkItem"); + +interface ResolvedWorkItem { + key: string; + workItem: WorkItem | null; + repoPath: string | null; + members: Person[]; + error: string | null; +} + +export interface TeamInboxWorkItemState { + workItem: WorkItem | null; + status: "loading" | "ready" | "error"; + error: string | null; + repoPath: string | null; + members: Person[]; + updateWorkItem: (updates: Partial) => void; + refreshWorkItem: () => void; +} + +/** + * Demand-load the full Work Item for the selected inbox row. + * + * The resolved value is keyed to the selection, late reads are ignored after + * cleanup, and only the newest overlapping property update may replace the + * displayed snapshot. + */ +export function useTeamInboxWorkItem( + target: WorkItemTarget +): TeamInboxWorkItemState { + const { projectId, workItemId } = target; + const requestKey = `${projectId || "standalone"}:${workItemId}`; + const [resolved, setResolved] = useState(null); + const [refreshGeneration, setRefreshGeneration] = useState(0); + const updateGenerationRef = useRef(0); + + useEffect(() => { + let cancelled = false; + + const request = projectId + ? Promise.all([ + projectApi.readWorkItem(projectId, workItemId), + projectApi.readProject(projectId), + projectApi.readMembers(projectId), + ]).then(([data, project, memberFile]) => ({ + data, + project, + memberEntries: memberFile.members, + })) + : projectApi.readStandaloneWorkItem(workItemId).then((data) => ({ + data, + project: null, + memberEntries: [] as MemberEntry[], + })); + + void request + .then(({ data, project, memberEntries }) => { + if (cancelled) return; + const converted = enrichedWorkItemToUI( + standaloneWorkItemDataToEnriched(data) + ); + const activeMembers = new Map(); + for (const member of memberEntries) { + if (member.active === false) continue; + const existing = activeMembers.get(member.id); + if ( + !existing || + (member.last_commit_date ?? "") > (existing.last_commit_date ?? "") + ) { + activeMembers.set(member.id, member); + } + } + const members = [...activeMembers.values()].map((member) => ({ + id: member.id, + name: member.name, + email: member.email, + avatar: member.avatar, + })); + const resolvedAssignee = converted.assignee + ? (members.find((member) => member.id === converted.assignee?.id) ?? + converted.assignee) + : undefined; + setResolved({ + key: requestKey, + workItem: project + ? { + ...converted, + assignee: resolvedAssignee, + project: { + id: project.slug, + name: project.meta.name, + }, + } + : converted, + repoPath: project?.meta.linked_repos[0] ?? null, + members, + error: null, + }); + }) + .catch((error: unknown) => { + if (cancelled) return; + log.warn("Failed to load Team Inbox Work Item", error); + setResolved((current) => ({ + key: requestKey, + workItem: current?.key === requestKey ? current.workItem : null, + repoPath: current?.key === requestKey ? current.repoPath : null, + members: current?.key === requestKey ? current.members : [], + error: error instanceof Error ? error.message : String(error), + })); + }); + + return () => { + cancelled = true; + }; + }, [projectId, refreshGeneration, requestKey, workItemId]); + + const refreshWorkItem = useCallback(() => { + setRefreshGeneration((current) => current + 1); + }, []); + + const updateWorkItem = useCallback( + (updates: Partial) => { + if (!projectId) return; + const payload = toWorkItemPartialUpdate(updates); + if (Object.keys(payload).length === 0) return; + + const generation = ++updateGenerationRef.current; + void projectApi + .updateWorkItemPartial(projectId, workItemId, payload) + .then((updated) => { + if (generation !== updateGenerationRef.current) return; + setResolved((current) => + current?.key === requestKey + ? { + key: requestKey, + workItem: { + ...enrichedWorkItemToUI(updated), + project: current.workItem?.project, + }, + repoPath: current.repoPath, + members: current.members, + error: null, + } + : current + ); + }) + .catch((error: unknown) => { + if (generation !== updateGenerationRef.current) return; + log.warn("Failed to update Team Inbox Work Item", error); + setResolved((current) => + current?.key === requestKey + ? { + ...current, + error: error instanceof Error ? error.message : String(error), + } + : current + ); + }); + }, + [projectId, requestKey, workItemId] + ); + + if (resolved?.key !== requestKey) { + return { + workItem: null, + status: "loading", + error: null, + repoPath: null, + members: [], + updateWorkItem, + refreshWorkItem, + }; + } + + return { + workItem: resolved.workItem, + status: resolved.workItem ? "ready" : "error", + error: resolved.error, + repoPath: resolved.repoPath, + members: resolved.members, + updateWorkItem, + refreshWorkItem, + }; +} diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts deleted file mode 100644 index 49bf105605..0000000000 --- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useState } from "react"; - -import { projectApi } from "@src/api/http/project"; -import { createLogger } from "@src/hooks/logger"; - -import type { WorkItemTarget } from "./domain"; - -const log = createLogger("TeamInboxWorkItemBody"); - -export interface TeamInboxWorkItemBodyState { - /** Full Markdown body once resolved, or null while loading / empty / failed. */ - body: string | null; - loading: boolean; -} - -interface ResolvedWorkItemBodyState extends TeamInboxWorkItemBodyState { - requestKey: string; -} - -/** - * Lazily loads the full Work Item body for the selected assigned inbox item so - * the detail preview can render the real content instead of the short list - * excerpt. The fetch reuses the same project store adapters as navigation and is - * demand-driven (one read per selection, no polling); stale responses are - * discarded when the selection changes. - */ -export function useTeamInboxWorkItemBody( - target: WorkItemTarget -): TeamInboxWorkItemBodyState { - const { projectId, workItemId } = target; - const requestKey = `${projectId ?? "standalone"}:${workItemId}`; - const [state, setState] = useState({ - requestKey, - body: null, - loading: true, - }); - - useEffect(() => { - let cancelled = false; - - const request = projectId - ? projectApi.readWorkItem(projectId, workItemId) - : projectApi.readStandaloneWorkItem(workItemId); - - void request - .then((workItem) => { - if (cancelled) return; - const body = workItem.body.trim(); - setState({ - requestKey, - body: body.length > 0 ? body : null, - loading: false, - }); - }) - .catch((error: unknown) => { - if (cancelled) return; - log.warn("Failed to load Team Inbox Work Item body", error); - setState({ requestKey, body: null, loading: false }); - }); - - return () => { - cancelled = true; - }; - }, [projectId, requestKey, workItemId]); - - return state.requestKey === requestKey - ? state - : { body: null, loading: true }; -} diff --git a/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts b/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts new file mode 100644 index 0000000000..7d2e27e7ae --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { toWorkItemPartialUpdate } from "../workItemPartialUpdate"; + +describe("toWorkItemPartialUpdate", () => { + it("maps editable Work Item fields to the project-store payload", () => { + expect( + toWorkItemPartialUpdate({ + name: "Inbox thread", + spec: "Unified body", + workItemStatus: "in_progress", + priority: "high", + assignee: { id: "member-1", name: "Ada" }, + labels: [{ id: "label-1", name: "UX", color: "#000000" }], + }) + ).toMatchObject({ + title: "Inbox thread", + body: "Unified body", + status: "in_progress", + priority: "high", + assignee: "member-1", + labels: ["label-1"], + }); + }); + + it("preserves explicit clears", () => { + expect( + toWorkItemPartialUpdate({ + assignee: null, + milestone: null, + labels: [], + endDate: null, + }) + ).toMatchObject({ + assignee: null, + milestone: null, + labels: [], + targetDate: null, + }); + }); + + it("returns an empty payload when no persisted field changes", () => { + expect(toWorkItemPartialUpdate({})).toEqual({}); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx index 06f294c2ab..2d8a81a2d9 100644 --- a/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx +++ b/src/modules/ProjectManager/WorkItems/components/AgentWorkflow/PhaseStates.tsx @@ -22,6 +22,7 @@ interface IdleStateProps { /** Collab lock held by a teammate (design §16.6): disable + show holder. */ isLockedByOther?: boolean; lockHolderName?: string | null; + compact?: boolean; } export const IdleState: React.FC = ({ @@ -31,6 +32,7 @@ export const IdleState: React.FC = ({ isStartingAgent, isLockedByOther, lockHolderName, + compact = false, }) => { const { t } = useTranslation("projects"); @@ -50,6 +52,33 @@ export const IdleState: React.FC = ({ ? t("workItems.agentWorkflow.running") : t("workItems.agentWorkflow.startAgent"); + if (compact) { + return ( +
+
+

+ {t("workItems.agentWorkflow.noWorkflowRun")} +

+

+ {t("workItems.agentWorkflow.startAgentAndChat")} +

+
+ {onStartAgent ? ( + + ) : null} +
+ ); + } + return ( = ({ @@ -86,6 +88,7 @@ const AgentWorkflow: React.FC = ({ activeAgentRole, isLockedByOther, lockHolderName, + presentation = "default", }) => { const { t } = useTranslation("projects"); const persistedPhase: OrchestratorPhase = @@ -179,15 +182,29 @@ const AgentWorkflow: React.FC = ({ const showCompletedBadge = phase === "completed" && !(hasReviewFeedback && reviewOutcome === "approved"); + const isThread = presentation === "thread"; return (
{phase !== "idle" && ( @@ -202,6 +219,7 @@ const AgentWorkflow: React.FC = ({ isStartingAgent={isStartingAgent} isLockedByOther={isLockedByOther} lockHolderName={lockHolderName} + compact={isThread} /> )} {ACTIVE_PHASES.has(phase) && ( diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx index e1074f6d3b..de6ca875e7 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx @@ -1,6 +1,8 @@ import { ArrowRightLeft, ArrowUp, + Bell, + BellOff, Bot, MessageSquare, Pencil, @@ -49,146 +51,165 @@ const HistoryTab: React.FC = ({ onCommentTextChange, onCommentSubmit, isSubmittingComment, + presentation = "default", }) => { const { t } = useTranslation("projects"); + const isThread = presentation === "thread"; - return ( -
-
-
- - - {currentUser.name.charAt(0).toUpperCase()} - -
-
+ const subscriptionControl = ( + + ); - {timelineEntries.length > 0 && ( -
- - {timelineEntries.map((entry, entryIndex) => { - const isDelegationComment = - entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED && - entry.userName === OS_AGENT_USERNAME && - entry.descriptions[0]?.startsWith(DELEGATION_PREFIX); - const isLast = entryIndex === timelineEntries.length - 1; + const timeline = timelineEntries.length > 0 && ( +
+ + {timelineEntries.map((entry, entryIndex) => { + const isDelegationComment = + entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED && + entry.userName === OS_AGENT_USERNAME && + entry.descriptions[0]?.startsWith(DELEGATION_PREFIX); + const isLast = entryIndex === timelineEntries.length - 1; - if ( - entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED && - !isDelegationComment - ) { - const body = entry.descriptions[0] ?? ""; - return ( - - - {entry.userName.charAt(0).toUpperCase()} - + if ( + entry.type === WORK_ITEM_HISTORY_ACTION.COMMENTED && + !isDelegationComment + ) { + const body = entry.descriptions[0] ?? ""; + return ( + + + > + {entry.userName.charAt(0).toUpperCase()} + } - > - - - - ); - } + actor={entry.userName} + action="commented" + timestamp={entry.timestamp} + /> + } + > + + + + ); + } - return ( - - - ) : ( - TIMELINE_ICONS[entry.type] - ) - } - > - - {isDelegationComment - ? t("workItems.activity.agent") - : entry.userName} - {" "} - {entry.descriptions.length === 1 ? ( - {entry.descriptions[0]} - ) : ( -
- - {t("workItems.activity.editedFields", { - count: entry.descriptions.length, - })} - -
    - {entry.descriptions.map( - (description, descriptionIndex) => ( -
  • - {description} -
  • - ) - )} -
-
- )} - · - -
-
- ); - })} -
-
- )} + return ( + + + ) : ( + TIMELINE_ICONS[entry.type] + ) + } + > + + {isDelegationComment + ? t("workItems.activity.agent") + : entry.userName} + {" "} + {entry.descriptions.length === 1 ? ( + {entry.descriptions[0]} + ) : ( +
+ + {t("workItems.activity.editedFields", { + count: entry.descriptions.length, + })} + +
    + {entry.descriptions.map( + (description, descriptionIndex) => ( +
  • + {description} +
  • + ) + )} +
+
+ )} + · + +
+
+ ); + })} +
+
+ ); -
+ const composer = ( +
+ {isThread ? ( + + {currentUser.name.charAt(0).toUpperCase()} + + ) : null} +
onCommentTextChange(markdown)} onSubmit={onCommentSubmit} - minHeight={60} + minHeight={isThread ? 48 : 60} maxHeight={120} appearance="outlined" + showTabs={!isThread} dataTestId="work-item-comment-editor" /> -
+
); + + if (isThread) { + return ( +
+
+

+ {t("workItems.activity.title")} +

+ {subscriptionControl} +
+ {timeline} + {composer} +
+ ); + } + + return ( +
+
+
+ {subscriptionControl} + + {currentUser.name.charAt(0).toUpperCase()} + +
+
+ + {timeline} + {composer} +
+ ); }; export default HistoryTab; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx new file mode 100644 index 0000000000..a96bb08955 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/ThreadTodoChecklist.tsx @@ -0,0 +1,219 @@ +import { CheckSquare2, Plus, Trash2, X } from "lucide-react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Checkbox from "@src/components/Checkbox"; +import Input from "@src/components/Input"; +import type { TodoItem } from "@src/types/core/workItem"; + +import { WorkItemThreadSection } from "../WorkItemThread"; +import { + THREAD_TODO_MAX_LENGTH, + createThreadTodo, + normalizeThreadTodos, +} from "./threadTodos"; + +interface ThreadTodoChecklistProps { + todos: TodoItem[]; + onChange: (todos: TodoItem[]) => void; + disabled?: boolean; +} + +const ThreadTodoChecklist: React.FC = ({ + todos, + onChange, + disabled = false, +}) => { + const { t } = useTranslation(["projects", "common"]); + const [adding, setAdding] = useState(false); + const [draft, setDraft] = useState(""); + const inputRef = useRef(null); + const normalizedTodos = useMemo(() => normalizeThreadTodos(todos), [todos]); + const completedCount = normalizedTodos.filter( + (todo) => todo.status === "completed" + ).length; + + useEffect(() => { + if (adding) inputRef.current?.focus({ preventScroll: true }); + }, [adding]); + + const closeComposer = () => { + setAdding(false); + setDraft(""); + }; + + const commitDraft = () => { + const nextTodo = createThreadTodo(draft, Date.now()); + if (!nextTodo) { + closeComposer(); + return; + } + onChange([...normalizedTodos, nextTodo]); + setDraft(""); + requestAnimationFrame(() => + inputRef.current?.focus({ preventScroll: true }) + ); + }; + + return ( + + } + title={t("projects:workItems.todos.title")} + meta={ + + {completedCount}/{normalizedTodos.length} + + } + action={ + !disabled ? ( + + ) : null + } + > + {normalizedTodos.length === 0 && !adding ? ( + + ) : ( +
+ {normalizedTodos.map((todo) => ( +
+
+ + onChange( + normalizedTodos.map((candidate) => + candidate.id === todo.id + ? { + ...candidate, + status: + candidate.status === "completed" + ? "pending" + : "completed", + } + : candidate + ) + ) + } + disabled={disabled} + /> +
+ + {todo.content} + + {!disabled ? ( +
+ ))} +
+ )} + + {adding ? ( +
+ { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + commitDraft(); + } + if (event.key === "Escape") { + event.preventDefault(); + closeComposer(); + } + }} + data-testid="work-item-thread-todo-input" + /> + +
+ ) : null} +
+ ); +}; + +export default ThreadTodoChecklist; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts index 674dedf9f1..068aaf36f8 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts @@ -106,8 +106,11 @@ vi.mock("@src/modules/shared/components/ActivityTimeline", () => ({ TimelineCard: ({ children, footer, - }: React.PropsWithChildren<{ footer?: React.ReactNode }>) => - createElement("div", null, children, footer), + actions, + }: React.PropsWithChildren<{ + footer?: React.ReactNode; + actions?: React.ReactNode; + }>) => createElement("div", null, actions, children, footer), })); vi.mock("@src/modules/shared/layouts/blocks", () => ({ @@ -127,6 +130,7 @@ vi.mock("@src/modules/shared/layouts/blocks", () => ({ label: string; onClick?: () => void; dataTestId?: string; + disabled?: boolean; }; }) => createElement( @@ -151,6 +155,7 @@ vi.mock("@src/modules/shared/layouts/blocks", () => ({ type: "button", "data-testid": primaryAction.dataTestId, onClick: primaryAction.onClick, + disabled: primaryAction.disabled, }, primaryAction.label ) @@ -160,6 +165,7 @@ vi.mock("@src/modules/shared/layouts/blocks", () => ({ vi.mock("../../AgentWorkflow", () => ({ default: () => null })); vi.mock("../../TodoChecklist", () => ({ default: () => null })); +vi.mock("../ThreadTodoChecklist", () => ({ default: () => null })); vi.mock("../../WorkItemContentStack", () => ({ default: ({ descriptionContent }: { descriptionContent?: React.ReactNode }) => createElement("div", null, descriptionContent), @@ -345,4 +351,64 @@ describe("WorkItemContent description editing", () => { container.querySelector("[data-testid='description-footer']") ).toBeNull(); }); + + it("keeps the thread compact until Edit is explicitly requested", () => { + act(() => { + root.render( + createElement(WorkItemContent, { + workItem: baseWorkItem, + presentation: "thread", + onUpdateWorkItem: vi.fn(), + }) + ); + }); + + expect( + container.querySelector("[data-testid='description-editor']") + ).toBeNull(); + expect( + container.querySelector("[data-testid='github-read-only-description']") + ?.textContent + ).toBe(baseWorkItem.spec); + + act(() => { + container + .querySelector( + "[data-testid='work-item-description-edit']" + ) + ?.click(); + }); + + expect( + container.querySelector("[data-testid='description-editor']") + ).not.toBeNull(); + expect( + container.querySelector( + "[data-testid='work-item-description-save']" + )?.disabled + ).toBe(true); + + changeDescription("## Compact thread editor"); + + expect( + container.querySelector( + "[data-testid='work-item-description-save']" + )?.disabled + ).toBe(false); + + act(() => { + container + .querySelector( + "[data-testid='work-item-description-save']" + ) + ?.click(); + }); + + expect(mocks.handleDescriptionChange).toHaveBeenCalledWith( + "## Compact thread editor" + ); + expect( + container.querySelector("[data-testid='description-editor']") + ).toBeNull(); + }); }); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts new file mode 100644 index 0000000000..e7f263b8dc --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/presentation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { resolveWorkItemContentSectionPolicy } from "../presentation"; + +describe("resolveWorkItemContentSectionPolicy", () => { + it("keeps the existing tabs and linked-session table by default", () => { + expect(resolveWorkItemContentSectionPolicy("default", true)).toEqual({ + showTabbedLowerSection: true, + showLinkedSessionsTable: true, + showInlineWorkflow: false, + showInlineOutput: false, + showInlineHistory: false, + }); + }); + + it("turns Team Inbox into one inline thread without the legacy table", () => { + expect(resolveWorkItemContentSectionPolicy("thread", true)).toEqual({ + showTabbedLowerSection: false, + showLinkedSessionsTable: false, + showInlineWorkflow: true, + showInlineOutput: true, + showInlineHistory: true, + }); + }); + + it("does not render an empty output block before proof of work exists", () => { + expect( + resolveWorkItemContentSectionPolicy("thread", false).showInlineOutput + ).toBe(false); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts new file mode 100644 index 0000000000..25a2e07efb --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/threadTodos.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { createThreadTodo, normalizeThreadTodos } from "../threadTodos"; + +describe("thread todo presentation", () => { + it("drops blank persisted rows and trims visible content", () => { + expect( + normalizeThreadTodos([ + { id: "blank", content: " ", status: "pending" }, + { id: "kept", content: " Verify inbox ", status: "completed" }, + ]) + ).toEqual([{ id: "kept", content: "Verify inbox", status: "completed" }]); + }); + + it("creates a pending todo only after non-empty input is committed", () => { + expect(createThreadTodo(" Add compact composer ", 42)).toEqual({ + id: "todo-42", + content: "Add compact composer", + status: "pending", + }); + expect(createThreadTodo(" ", 42)).toBeNull(); + }); + + it("enforces the 120 character domain boundary", () => { + expect(createThreadTodo("x".repeat(140), 42)?.content).toHaveLength(120); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts index e0bdf40ef9..15793ddace 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts @@ -168,4 +168,32 @@ describe("work item history timeline", () => { "history-late", ]); }); + + it("resolves stored member ids in history and legacy comments", () => { + const entries = buildWorkItemTimelineEntries( + workItemWithTimeline({ + comments: [ + { + id: "comment-member", + author: "member-2", + content: "Member comment", + created_at: "2026-01-02T00:00:00Z", + }, + ], + history: [ + { + id: "history-member", + action: WORK_ITEM_HISTORY_ACTION.UPDATED, + timestamp: "2026-01-01T00:00:00Z", + actorId: "member-2", + summary: "Updated", + }, + ], + }), + translate, + [{ id: "member-2", name: "Lin" }] + ); + + expect(entries.map((entry) => entry.userName)).toEqual(["Lin", "Lin"]); + }); }); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx index 1c3deb889d..b1e0a685b0 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx @@ -66,11 +66,14 @@ export function useWorkItemContentState( const pendingOpenChatRef = useRef(false); - const handleStartAgentAndOpenChat = useCallback( - (instructions?: string) => { - pendingOpenChatRef.current = true; - onStartAgent?.(instructions); - }, + const handleStartAgentAndOpenChat = useMemo( + () => + onStartAgent + ? (instructions?: string) => { + pendingOpenChatRef.current = true; + onStartAgent(instructions); + } + : undefined, [onStartAgent] ); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx index 4c2d1ed8d1..c090e83e55 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx @@ -1,8 +1,9 @@ -import { Bot, Repeat, Terminal } from "lucide-react"; +import { Bot, Pencil, Repeat, Terminal } from "lucide-react"; import React, { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Avatar from "@src/components/Avatar"; +import Button from "@src/components/Button"; import TabPill from "@src/components/TabPill"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { useWorkItemImageInsert } from "@src/hooks/project"; @@ -38,10 +39,13 @@ import AgentWorkflow from "../AgentWorkflow"; import { ROLE_I18N_KEYS, STATUS_I18N_KEYS } from "../AgentWorkflow/types"; import TodoChecklist from "../TodoChecklist"; import WorkItemContentStack from "../WorkItemContentStack"; +import { WorkItemThreadLayout } from "../WorkItemThread"; import HistoryTab from "./HistoryTab"; import OutputTab from "./OutputTab"; +import ThreadTodoChecklist from "./ThreadTodoChecklist"; import { useGitHubIssueTimeline } from "./hooks/useGitHubIssueTimeline"; import { useWorkItemContentState } from "./hooks/useWorkItemContentState"; +import { resolveWorkItemContentSectionPolicy } from "./presentation"; import type { SessionTab, WorkItemContentProps } from "./types"; interface LinkedSessionsListProps { @@ -151,6 +155,7 @@ const LinkedSessionsList: React.FC = ({ const WorkItemContent: React.FC = ({ workItem, + presentation = "default", onUpdateWorkItem, onUpdateWorkItemImmediate, currentUser: currentUserProp, @@ -219,6 +224,7 @@ const WorkItemContent: React.FC = ({ const creatorName = workItem.createdBy?.name || + teamMembers?.find((member) => member.id === workItem.user_id)?.name || workItem.user_id || t("workItems.activity.system"); const displayedDescription = resolvedDescription ?? rawDescription; @@ -238,6 +244,9 @@ const WorkItemContent: React.FC = ({ base: string; value: string; } | null>(null); + const [descriptionEditWorkItemId, setDescriptionEditWorkItemId] = useState< + string | null + >(null); const currentDescriptionDraft = descriptionDraftState?.workItemId === workItem.session_id ? descriptionDraftState @@ -250,6 +259,13 @@ const WorkItemContent: React.FC = ({ currentDescriptionDraft && descriptionHasChanges ? currentDescriptionDraft.value : displayedDescription; + const sectionPolicy = resolveWorkItemContentSectionPolicy( + presentation, + Boolean(workItem.proofOfWork) + ); + const isThread = presentation === "thread"; + const isEditingThreadDescription = + isThread && descriptionEditWorkItemId === workItem.session_id; const handleDescriptionDraftChange = (markdown: string) => { setDescriptionDraftState((current) => { @@ -266,13 +282,29 @@ const WorkItemContent: React.FC = ({ const handleCancelDescription = () => { setDescriptionDraftState(null); + setDescriptionEditWorkItemId(null); }; const handleSaveDescription = () => { handleDescriptionChange(descriptionDraft); setDescriptionDraftState(null); + setDescriptionEditWorkItemId(null); }; + const descriptionActions = + isThread && canEditDescription && !isEditingThreadDescription ? ( + + ) : null; + const descriptionSection = ( = ({ > = ({ primaryAction={{ label: t("common:actions.save"), onClick: handleSaveDescription, + disabled: !descriptionHasChanges, dataTestId: "work-item-description-save", }} /> @@ -346,11 +383,12 @@ const WorkItemContent: React.FC = ({
)} - {isGitHubWorkItem ? ( + {isGitHubWorkItem || (isThread && !isEditingThreadDescription) ? ( ) : ( = ({ separatorVisible={false} descriptionPlaceholder={t("workItems.descriptionPlaceholder")} editable={canEditDescription} - descriptionMaxHeight={600} + descriptionMinHeight={isThread ? 120 : 200} + descriptionMaxHeight={isThread ? 360 : 600} + descriptionDefaultMode={isThread ? "raw" : undefined} descriptionClassName="no-bottom-border" repoPath={repoPath} className="w-full" @@ -383,7 +423,14 @@ const WorkItemContent: React.FC = ({ ); - const todosSection = ( + const todosSection = isThread ? ( + + ) : ( = ({ /> ); - const lowerSection = ( + const agentWorkflow = ( + + ); + + const outputContent = ( + + ); + + const historyContent = ( + setIsSubscribed(!isSubscribed)} + commentText={commentText} + onCommentTextChange={setCommentText} + onCommentSubmit={handleCommentSubmit} + isSubmittingComment={isSubmittingComment} + presentation={presentation} + /> + ); + + const tabbedLowerSection = (
= ({ {activeSessionTab === "session" && ( <> -
- {agentWorkflow}
+ {sectionPolicy.showLinkedSessionsTable ? ( + -
- + ) : null} )} - {activeSessionTab === "output" && ( - - )} + {activeSessionTab === "output" && outputContent} - {activeSessionTab === "history" && ( - setIsSubscribed(!isSubscribed)} - commentText={commentText} - onCommentTextChange={setCommentText} - onCommentSubmit={handleCommentSubmit} - isSubmittingComment={isSubmittingComment} - /> - )} + {activeSessionTab === "history" && historyContent}
); + const threadLowerSection = ( + <> + {sectionPolicy.showInlineWorkflow ? agentWorkflow : null} + {sectionPolicy.showInlineOutput ? outputContent : null} + {sectionPolicy.showInlineHistory ? historyContent : null} + + ); + + if (isThread) { + return ( + + {descriptionSection} + {todosSection} + {threadLowerSection} + + ); + } + return ( = ({ propertiesContent={headerProperties} descriptionContent={descriptionSection} todosContent={todosSection} - lowerContent={lowerSection} + lowerContent={ + sectionPolicy.showTabbedLowerSection + ? tabbedLowerSection + : threadLowerSection + } scrollable /> diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts new file mode 100644 index 0000000000..961dd632aa --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts @@ -0,0 +1,39 @@ +export type WorkItemContentPresentation = "default" | "thread"; + +export interface WorkItemContentSectionPolicy { + showTabbedLowerSection: boolean; + showLinkedSessionsTable: boolean; + showInlineWorkflow: boolean; + showInlineOutput: boolean; + showInlineHistory: boolean; +} + +/** + * Keep the Work Item presentation policy explicit and testable. + * + * The default surface retains its existing tabs/table. Team Inbox uses the + * thread policy: workflow/session cards and activity are inline, while the + * duplicate linked-session table is absent. + */ +export function resolveWorkItemContentSectionPolicy( + presentation: WorkItemContentPresentation, + hasProofOfWork: boolean +): WorkItemContentSectionPolicy { + if (presentation === "thread") { + return { + showTabbedLowerSection: false, + showLinkedSessionsTable: false, + showInlineWorkflow: true, + showInlineOutput: hasProofOfWork, + showInlineHistory: true, + }; + } + + return { + showTabbedLowerSection: true, + showLinkedSessionsTable: true, + showInlineWorkflow: false, + showInlineOutput: false, + showInlineHistory: false, + }; +} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts new file mode 100644 index 0000000000..68bba8618f --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/threadTodos.ts @@ -0,0 +1,28 @@ +import type { TodoItem } from "@src/types/core/workItem"; + +export const THREAD_TODO_MAX_LENGTH = 120; + +export function normalizeThreadTodos( + todos: readonly TodoItem[] | null | undefined +): TodoItem[] { + return (todos ?? []) + .map((todo) => ({ + ...todo, + content: todo.content.trim(), + })) + .filter((todo) => todo.content.length > 0); +} + +export function createThreadTodo( + content: string, + now: number +): TodoItem | null { + const normalized = content.trim().slice(0, THREAD_TODO_MAX_LENGTH); + if (!normalized) return null; + + return { + id: `todo-${now}`, + content: normalized, + status: "pending", + }; +} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts index d13d96b34e..dcbdb9b55b 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts @@ -9,12 +9,18 @@ import type { Person } from "@src/types/core/shared"; import type { WorkItem as WorkItemExtended } from "@src/types/core/workItem"; import type { AgentRole } from "../../constants"; +import type { WorkItemContentPresentation } from "./presentation"; export const SESSION_TAB_KEYS = ["session", "output", "history"] as const; export type SessionTab = (typeof SESSION_TAB_KEYS)[number]; export interface WorkItemContentProps { workItem: WorkItemExtended; + /** + * `thread` lays workflow/session cards and activity into one continuous + * surface. It omits the legacy lower tab strip and linked-session table. + */ + presentation?: WorkItemContentPresentation; onUpdateWorkItem?: (updates: Partial) => void; onUpdateWorkItemImmediate?: (updates: Partial) => void; currentUser?: Person; @@ -78,6 +84,7 @@ export interface HistoryTabProps { onCommentTextChange: (text: string) => void; onCommentSubmit: () => void; isSubmittingComment: boolean; + presentation?: WorkItemContentPresentation; } export interface TimelineEntry { diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts index 61341ca67d..a8c8501c59 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/useWorkItemTimeline.ts @@ -23,13 +23,13 @@ type TimelineTranslator = ( export function useWorkItemTimeline({ workItem, - teamMembers: _teamMembers, + teamMembers, }: UseWorkItemTimelineOptions) { const { t } = useTranslation("projects"); const timelineEntries = useMemo( - () => buildWorkItemTimelineEntries(workItem, t), - [workItem, t] + () => buildWorkItemTimelineEntries(workItem, t, teamMembers), + [workItem, t, teamMembers] ); const lastUpdatedRef = useRef(workItem.updated_time); @@ -42,11 +42,16 @@ export function useWorkItemTimeline({ export function buildWorkItemTimelineEntries( workItem: WorkItemExtended, - t: TimelineTranslator + t: TimelineTranslator, + teamMembers: readonly Person[] = [] ): TimelineEntry[] { + const memberNameById = new Map( + teamMembers.map((member) => [member.id, member.name]) + ); const entries = - workItem.history?.map((event) => historyEventToTimelineEntry(event, t)) ?? - []; + workItem.history?.map((event) => + historyEventToTimelineEntry(event, t, memberNameById) + ) ?? []; const existingCommentIds = commentIdsFromHistory(workItem.history ?? []); for (const comment of workItem.comments ?? []) { @@ -58,7 +63,7 @@ export function buildWorkItemTimelineEntries( id: comment.id, timestamp: comment.created_at, type: WORK_ITEM_HISTORY_ACTION.COMMENTED, - userName: comment.author, + userName: memberNameById.get(comment.author) ?? comment.author, descriptions: [comment.content || t("workItems.activity.commented")], }); } @@ -84,14 +89,18 @@ function commentIdsFromHistory(history: WorkItemHistoryEvent[]): Set { function historyEventToTimelineEntry( event: WorkItemHistoryEvent, - t: TimelineTranslator + t: TimelineTranslator, + memberNameById: ReadonlyMap ): TimelineEntry { return { id: event.id, timestamp: event.timestamp, type: event.action, userName: - event.actorName || event.actorId || t("workItems.activity.system"), + event.actorName || + (event.actorId ? memberNameById.get(event.actorId) : undefined) || + event.actorId || + t("workItems.activity.system"), descriptions: eventDescriptions(event, t), }; } diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts new file mode 100644 index 0000000000..805378dfb3 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/WorkItemProperties.pillLayout.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { WorkItem } from "@src/types/core/workItem"; + +import WorkItemProperties from "."; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("./PlanningSection", () => ({ + PlanningSection: () => createElement("span", null, "Planning"), +})); +vi.mock("./StatusPrioritySection", () => ({ + StatusPrioritySection: () => createElement("span", null, "Status"), +})); +vi.mock("./PeopleSection", () => ({ + PeopleSection: () => createElement("span", null, "People"), +})); +vi.mock("./DatesScheduleSection", () => ({ + DatesScheduleSection: () => createElement("span", null, "Dates"), +})); +vi.mock("./LabelsSection", () => ({ + LabelsSection: () => createElement("span", null, "Labels"), +})); +vi.mock("./useWorkItemPropertyHandlers", () => ({ + useWorkItemPropertyHandlers: () => ({}), +})); + +const workItem = { + session_id: "work-item-1", + labels: [], +} as unknown as WorkItem; + +describe("WorkItemProperties pill layout", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("wraps pills when the host opts into a responsive layout", () => { + act(() => { + root.render( + createElement(WorkItemProperties, { + workItem, + onUpdate: vi.fn(), + fieldVariant: "pill", + pillLayout: "wrap", + }) + ); + }); + + const pills = container.querySelector( + "[data-testid='work-item-property-pills']" + ); + expect(pills?.getAttribute("data-layout")).toBe("wrap"); + expect(pills?.classList.contains("flex-wrap")).toBe(true); + expect(pills?.classList.contains("flex-nowrap")).toBe(false); + }); + + it("preserves the compact single-row default for existing hosts", () => { + act(() => { + root.render( + createElement(WorkItemProperties, { + workItem, + onUpdate: vi.fn(), + fieldVariant: "pill", + }) + ); + }); + + const pills = container.querySelector( + "[data-testid='work-item-property-pills']" + ); + expect(pills?.getAttribute("data-layout")).toBe("nowrap"); + expect(pills?.classList.contains("flex-nowrap")).toBe(true); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx index 861ca72536..3ba4d0b7d5 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx @@ -102,6 +102,7 @@ const WorkItemProperties: React.FC = ({ showTime = true, externalStatusConfig, fieldVariant = "row", + pillLayout = "nowrap", visibleFields = DEFAULT_VISIBLE_FIELDS, showMoreMenu = false, }) => { @@ -252,8 +253,16 @@ const WorkItemProperties: React.FC = ({ if (fieldVariant === "pill") { return ( -
-
+
+
` regions. +- [ ] Header and To-Do actions remain keyboard-navigable. +- [ ] Icon-only controls retain translated accessible names. +- [ ] Collapsible Workflow keeps the existing button semantics and focus treatment. + +## Acceptance Criteria + +- [ ] Team Inbox composes the thread through `WorkItemThreadLayout`. +- [ ] Static thread cards compose through `WorkItemThreadSection`. +- [ ] Collapsible Workflow reuses the same Work Item thread tokens without duplicating collapse state. +- [ ] The ordinary Work Item presentation remains unchanged. +- [ ] No persistence, orchestration, navigation, polling, or subscription ownership moves into the presentation primitives. diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts new file mode 100644 index 0000000000..987dcaa7aa --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/presentation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveWorkItemThreadHeaderPolicy } from "../presentation"; + +describe("resolveWorkItemThreadHeaderPolicy", () => { + it("omits the metadata band when no path or properties exist", () => { + expect(resolveWorkItemThreadHeaderPolicy(false, false)).toEqual({ + showHeader: false, + showSeparator: false, + }); + }); + + it.each([ + [true, false], + [false, true], + ])( + "renders a single header source without a separator", + (hasPath, hasProperties) => { + expect(resolveWorkItemThreadHeaderPolicy(hasPath, hasProperties)).toEqual( + { + showHeader: true, + showSeparator: false, + } + ); + } + ); + + it("separates the path from properties when both are present", () => { + expect(resolveWorkItemThreadHeaderPolicy(true, true)).toEqual({ + showHeader: true, + showSeparator: true, + }); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx new file mode 100644 index 0000000000..123e2ee3ff --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx @@ -0,0 +1,102 @@ +import React, { useId } from "react"; + +import { DetailPanelContainer } from "@src/modules/shared/layouts/blocks"; + +import { resolveWorkItemThreadHeaderPolicy } from "./presentation"; +import { WORK_ITEM_THREAD_TOKENS } from "./tokens"; + +interface WorkItemThreadLayoutProps { + path?: React.ReactNode; + properties?: React.ReactNode; + children: React.ReactNode; +} + +export const WorkItemThreadLayout: React.FC = ({ + path, + properties, + children, +}) => { + const headerPolicy = resolveWorkItemThreadHeaderPolicy( + Boolean(path), + Boolean(properties) + ); + + return ( + +
+
+ {headerPolicy.showHeader ? ( +
+ {path ?
{path}
: null} + {headerPolicy.showSeparator ? ( +
+ ) : null} + {properties ? ( +
{properties}
+ ) : null} +
+ ) : null} + {children} +
+
+ + ); +}; + +interface WorkItemThreadSectionProps { + icon?: React.ReactNode; + title: React.ReactNode; + meta?: React.ReactNode; + action?: React.ReactNode; + children: React.ReactNode; + testId?: string; + bodyClassName?: string; +} + +export const WorkItemThreadSection: React.FC = ({ + icon, + title, + meta, + action, + children, + testId, + bodyClassName, +}) => { + const titleId = useId(); + + return ( +
+
+
+ {icon} + + {title} + + {meta} +
+ {action} +
+
+ {children} +
+
+ ); +}; + +export { WORK_ITEM_THREAD_TOKENS } from "./tokens"; diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts new file mode 100644 index 0000000000..c1d86cfde7 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/presentation.ts @@ -0,0 +1,14 @@ +export interface WorkItemThreadHeaderPolicy { + showHeader: boolean; + showSeparator: boolean; +} + +export function resolveWorkItemThreadHeaderPolicy( + hasPath: boolean, + hasProperties: boolean +): WorkItemThreadHeaderPolicy { + return { + showHeader: hasPath || hasProperties, + showSeparator: hasPath && hasProperties, + }; +} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts new file mode 100644 index 0000000000..3a86244fd4 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/tokens.ts @@ -0,0 +1,11 @@ +export const WORK_ITEM_THREAD_TOKENS = { + card: "overflow-hidden rounded-xl border border-border-1 bg-primary-container", + cardHeader: + "flex min-h-10 items-center justify-between gap-3 border-b border-border-1 px-3 py-2", + cardBody: "px-3 py-2", + collapsibleHeader: "!mb-0 !h-10 border-b border-border-1 px-3", + contentColumn: + "mx-auto flex w-full max-w-[920px] flex-col gap-3 px-5 py-5 pb-24", + metadataBand: + "flex min-w-0 items-center gap-2 overflow-x-auto rounded-xl border border-border-1 bg-fill-1 px-3 py-2 scrollbar-hide", +} as const; diff --git a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts new file mode 100644 index 0000000000..ebed82af23 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts @@ -0,0 +1,82 @@ +import type { WorkItemPartialUpdate } from "@src/api/http/project"; +import type { WorkItem } from "@src/types/core/workItem"; + +export type WorkItemUiPatch = Omit< + Partial, + "assignee" | "milestone" | "endDate" | "target_date" +> & { + assignee?: WorkItem["assignee"] | null; + milestone?: WorkItem["milestone"] | null; + endDate?: WorkItem["endDate"] | null; + target_date?: WorkItem["target_date"] | null; +}; + +/** + * Map a UI-shaped Work Item patch onto the canonical project-store payload. + * + * Shared by every Work Item surface so the Chat Panel and Team Inbox cannot + * drift on which fields are persisted. + */ +export function toWorkItemPartialUpdate( + updates: WorkItemUiPatch +): WorkItemPartialUpdate { + const payload: WorkItemPartialUpdate = {}; + + if (updates.name !== undefined) payload.title = updates.name; + if (updates.spec !== undefined) payload.body = updates.spec; + if (updates.workItemStatus !== undefined) { + payload.status = updates.workItemStatus; + } + if (updates.priority !== undefined) payload.priority = updates.priority; + if (updates.project?.id) payload.project = updates.project.id; + if (updates.star !== undefined) payload.starred = updates.star; + if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null; + if ("assigneeType" in updates) { + payload.assigneeType = updates.assigneeType ?? null; + } + if ("labels" in updates) { + payload.labels = updates.labels?.map((label) => label.id) ?? []; + } + if ("milestone" in updates) { + payload.milestone = updates.milestone?.id ?? null; + } + if ("startDate" in updates) payload.startDate = updates.startDate ?? null; + if ("endDate" in updates) payload.targetDate = updates.endDate ?? null; + if ("target_date" in updates) { + payload.targetDate = updates.target_date ?? null; + } + if (updates.todos !== undefined) { + payload.todos = updates.todos.map((todo) => ({ + id: todo.id, + content: todo.content, + status: todo.status, + })); + } + if (updates.comments !== undefined) { + payload.comments = updates.comments.map((comment) => ({ + id: comment.id, + author: comment.author, + content: comment.content, + created_at: comment.created_at, + })); + } + if (updates.linkedSessions !== undefined) { + payload.linkedSessions = updates.linkedSessions; + } + if (updates.orchestratorConfig !== undefined) { + payload.orchestratorConfig = updates.orchestratorConfig; + } + if (updates.orchestratorState !== undefined) { + payload.orchestratorState = updates.orchestratorState; + } + if (updates.schedule !== undefined) payload.schedule = updates.schedule; + if (updates.executionLock !== undefined) { + payload.executionLock = updates.executionLock; + } + if (updates.closeOut !== undefined) payload.closeOut = updates.closeOut; + if (updates.workProducts !== undefined) { + payload.workProducts = updates.workProducts; + } + + return payload; +} diff --git a/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx b/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx index 7f1b13705c..1ce2651309 100644 --- a/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx +++ b/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx @@ -68,6 +68,7 @@ export interface ProjectContentEditorProps { titleActions?: ReactNode; metaContent?: ReactNode; descriptionClassName?: string; + descriptionMinHeight?: number; descriptionMaxHeight?: number | string; descriptionDefaultMode?: RichMarkdownEditorMode; repoPath?: string | null; @@ -140,6 +141,7 @@ const ProjectContentEditor = forwardRef< titleActions, metaContent, descriptionClassName = "", + descriptionMinHeight = 200, descriptionMaxHeight, descriptionDefaultMode, repoPath, @@ -390,7 +392,7 @@ const ProjectContentEditor = forwardRef< slashCommandKeyboardHandlerRef.current?.(event) ?? false } onImageInsert={editable ? onImageInsert : undefined} - minHeight={200} + minHeight={descriptionMinHeight} maxHeight={descriptionMaxHeight} defaultMode={descriptionDefaultMode} editable={editable} diff --git a/src/modules/shared/components/ActivityTimeline/index.tsx b/src/modules/shared/components/ActivityTimeline/index.tsx index 2920348bf0..a98b7ed36b 100644 --- a/src/modules/shared/components/ActivityTimeline/index.tsx +++ b/src/modules/shared/components/ActivityTimeline/index.tsx @@ -127,21 +127,36 @@ export function ConnectedTimelineItem({ export function TimelineCard({ header, copyBody, + actions, footer, children, + className = "", + bodyClassName = "", }: { header: React.ReactNode; copyBody?: string; + actions?: React.ReactNode; footer?: React.ReactNode; children?: React.ReactNode; + className?: string; + bodyClassName?: string; }): React.ReactNode { return ( -
+
{header} - {copyBody ? : null} + {copyBody || actions ? ( +
+ {actions} + {copyBody ? : null} +
+ ) : null} +
+
+ {children}
-
{children}
{footer}
); diff --git a/src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts b/src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts new file mode 100644 index 0000000000..08449f23c8 --- /dev/null +++ b/src/store/chatPanel/__tests__/chatPanelWorkItemActionAtoms.test.ts @@ -0,0 +1,63 @@ +import { createStore } from "jotai"; +import { describe, expect, it } from "vitest"; + +import { + consumeChatPanelWorkItemActionAtom, + pendingChatPanelWorkItemActionAtom, + requestChatPanelWorkItemActionAtom, +} from "../chatPanelWorkItemActionAtoms"; + +describe("chat panel Work Item action requests", () => { + it("consumes a matching start request exactly once", () => { + const store = createStore(); + const request = store.set(requestChatPanelWorkItemActionAtom, { + workItemShortId: "ORG-42", + action: "start_agent", + }); + + expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(request); + expect(store.set(consumeChatPanelWorkItemActionAtom, request)).toEqual( + request + ); + expect(store.get(pendingChatPanelWorkItemActionAtom)).toBeNull(); + expect(store.set(consumeChatPanelWorkItemActionAtom, request)).toBeNull(); + }); + + it("does not consume a request from another Work Item", () => { + const store = createStore(); + const request = store.set(requestChatPanelWorkItemActionAtom, { + workItemShortId: "ORG-42", + action: "start_agent", + }); + + expect( + store.set(consumeChatPanelWorkItemActionAtom, { + ...request, + workItemShortId: "ORG-43", + }) + ).toBeNull(); + expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual(request); + }); + + it("lets the newest unclaimed navigation intent supersede an older one", () => { + const store = createStore(); + const olderRequest = store.set(requestChatPanelWorkItemActionAtom, { + workItemShortId: "ORG-42", + action: "start_agent", + }); + const newestRequest = store.set(requestChatPanelWorkItemActionAtom, { + workItemShortId: "ORG-43", + action: "start_agent", + }); + + expect(store.get(pendingChatPanelWorkItemActionAtom)).toEqual( + newestRequest + ); + expect( + store.set(consumeChatPanelWorkItemActionAtom, olderRequest) + ).toBeNull(); + expect( + store.set(consumeChatPanelWorkItemActionAtom, newestRequest) + ).toEqual(newestRequest); + }); +}); diff --git a/src/store/chatPanel/chatPanelTabsAtom.ts b/src/store/chatPanel/chatPanelTabsAtom.ts index f1ed721823..ca1e4277ed 100644 --- a/src/store/chatPanel/chatPanelTabsAtom.ts +++ b/src/store/chatPanel/chatPanelTabsAtom.ts @@ -72,3 +72,10 @@ export { chatPanelTabCountAtom, chatPanelTabsAtom, } from "./chatPanelTabsState"; +export { + consumeChatPanelWorkItemActionAtom, + pendingChatPanelWorkItemActionAtom, + requestChatPanelWorkItemActionAtom, + type ChatPanelWorkItemAction, + type ChatPanelWorkItemActionRequest, +} from "./chatPanelWorkItemActionAtoms"; diff --git a/src/store/chatPanel/chatPanelWorkItemActionAtoms.ts b/src/store/chatPanel/chatPanelWorkItemActionAtoms.ts new file mode 100644 index 0000000000..6cb6eb4ea3 --- /dev/null +++ b/src/store/chatPanel/chatPanelWorkItemActionAtoms.ts @@ -0,0 +1,45 @@ +import { atom } from "jotai"; + +export type ChatPanelWorkItemAction = "start_agent"; + +export interface ChatPanelWorkItemActionRequest { + requestId: string; + workItemShortId: string; + action: ChatPanelWorkItemAction; +} + +export const pendingChatPanelWorkItemActionAtom = + atom(null); + +export const requestChatPanelWorkItemActionAtom = atom( + null, + (_get, set, request: Omit) => { + const pendingRequest: ChatPanelWorkItemActionRequest = { + ...request, + requestId: crypto.randomUUID(), + }; + set(pendingChatPanelWorkItemActionAtom, pendingRequest); + return pendingRequest; + } +); + +export const consumeChatPanelWorkItemActionAtom = atom( + null, + ( + get, + set, + request: ChatPanelWorkItemActionRequest + ): ChatPanelWorkItemActionRequest | null => { + const pendingRequest = get(pendingChatPanelWorkItemActionAtom); + if ( + pendingRequest?.requestId !== request.requestId || + pendingRequest.workItemShortId !== request.workItemShortId || + pendingRequest.action !== request.action + ) { + return null; + } + + set(pendingChatPanelWorkItemActionAtom, null); + return pendingRequest; + } +); From 2ca0006b2ba02e4000773f10bba6539ffac8f286 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 27 Jul 2026 23:39:47 +0800 Subject: [PATCH 03/11] test(team-inbox): cover dual-instance mention receipts Pre-commit hook ran. Total eslint: 2, total circular: 0 --- .../core/cloud-dual-instance-ui.spec.mjs | 98 +++++++++++++++++++ tests/e2e/support/core/cloudOrgUiDriver.mjs | 15 ++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs index e9462010ae..aabe8d5184 100644 --- a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs +++ b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs @@ -23,6 +23,7 @@ import { openCreateOrgFormFromSidebar, openTurnCommentPanel, postTurnComment, + postTurnCommentMentioning, pressEscape, provisionCloudUser, publishCloudSessionMetadata, @@ -62,6 +63,7 @@ const SESSION_NOTE_BODY = `Dual-instance session note ${RUN_ID}`; const EDITED_COMMENT_BODY = `@agent dual-instance edited task ${RUN_ID}`; const EDITED_COMMENT_BRIEF = EDITED_COMMENT_BODY.slice("@agent ".length); const REPLY_BODY = `Owner reply from the other instance ${RUN_ID}`; +const TEAM_INBOX_MENTION_BODY = `Team Inbox mention ${RUN_ID}`; const SEND_BODY = `Continue this work from the matching workspace ${RUN_ID}`; const PROJECT_NAME = `Dual cloud project ${RUN_ID}`; const PROJECT_SLUG = PROJECT_NAME.toLowerCase() @@ -2353,6 +2355,102 @@ describe("Cloud collaboration with two independent rendered app instances", func } }); + it("C2. delivers a structured member mention and persists the teammate read receipt", async function () { + this.timeout(180_000); + + unwrap( + await invokeE2E("openSession", sessionId), + "primary reopen source session for Team Inbox mention" + ); + await openTurnCommentPanel(sourceTurnAnchorEventId); + await postTurnCommentMentioning(TEAM_INBOX_MENTION_BODY, teammate.userId); + await waitForRendered( + '[data-testid="comment-member-mention-pill"]', + "primary rendered teammate mention chip", + CLOUD_FETCH_TIMEOUT_MS + ); + + await waitForRenderedOn( + second.client, + '[data-testid="sidebar-team-inbox"]', + "secondary Team Inbox navigation", + CLOUD_FETCH_TIMEOUT_MS + ); + await clickRenderedOn( + second.client, + '[data-testid="sidebar-team-inbox"]', + "secondary Team Inbox navigation" + ); + await second.client.waitUntil( + async () => + executeOn( + second.client, + ` + const body = arguments[0]; + const row = Array.from( + document.querySelectorAll( + '[data-testid="team-inbox-row"][data-item-kind="comment_mention"]' + ) + ).find((candidate) => (candidate.textContent ?? '').includes(body)); + if (!row) return false; + row.setAttribute('data-e2e-team-inbox-mention', 'true'); + return row.getAttribute('data-unread') === 'true'; + `, + [TEAM_INBOX_MENTION_BODY] + ), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: + "secondary Team Inbox never rendered the teammate mention as unread", + } + ); + + await clickRenderedOn( + second.client, + '[data-e2e-team-inbox-mention="true"]', + "secondary unread Team Inbox mention" + ); + + let teammateInbox = null; + await second.client.waitUntil( + async () => { + teammateInbox = await callProjectsRpc( + env, + teammate, + "cloud_list_team_inbox_mentions", + { p_org_id: teamOrgId, p_cursor: null, p_limit: 50 } + ); + const mention = (teammateInbox?.mentions ?? []).find( + (entry) => entry.body === TEAM_INBOX_MENTION_BODY + ); + return Boolean(mention?.readAt && teammateInbox.unreadCount === 0); + }, + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 500, + timeoutMsg: + "secondary click did not persist the viewer-scoped cloud read receipt", + } + ); + + const ownerInbox = await callProjectsRpc( + env, + owner, + "cloud_list_team_inbox_mentions", + { p_org_id: teamOrgId, p_cursor: null, p_limit: 50 } + ); + if ( + (ownerInbox?.mentions ?? []).some( + (entry) => entry.body === TEAM_INBOX_MENTION_BODY + ) + ) { + throw new Error( + "mention projection leaked the teammate-targeted comment into the owner Inbox" + ); + } + }); + it("D. syncs comment CRUD/status, intercepts send into a same-remote fork, and revokes directed access live", async function () { this.timeout(360_000); diff --git a/tests/e2e/support/core/cloudOrgUiDriver.mjs b/tests/e2e/support/core/cloudOrgUiDriver.mjs index a3cdf1a7ad..e510139af5 100644 --- a/tests/e2e/support/core/cloudOrgUiDriver.mjs +++ b/tests/e2e/support/core/cloudOrgUiDriver.mjs @@ -977,7 +977,7 @@ async function postOpenComment(body) { await browser.waitUntil( async () => (await execJS( - js.click('[data-testid="session-comment-composer"] button') + js.click('[data-testid="session-comment-composer-submit"]') )) === "clicked", { timeout: RENDER_TIMEOUT_MS, @@ -996,6 +996,19 @@ export async function postTurnComment(body) { await postOpenComment(body); } +/** Posts through the production member picker; no RPC/helper creates mention state. */ +export async function postTurnCommentMentioning(body, memberUserId) { + await clickRendered( + '[data-testid="session-comment-composer-mention-members"]', + "comment member mention picker" + ); + await clickRendered( + `[data-testid="session-comment-mention-${memberUserId}"]`, + "comment mentioned member" + ); + await postOpenComment(body); +} + /** Opens the header-level session-notes dialog and posts an unanchored note. */ export async function postSessionNote(body) { await clickRendered( From 4ec1851132c7696543e18f829b91349be7fbc023 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 27 Jul 2026 23:40:24 +0800 Subject: [PATCH 04/11] docs(audit): record team inbox closure Pre-commit hook ran. Total eslint: 2, total circular: 0 --- .../TeamInboxCollaboration.md | 85 ++++++++++++++++ .../TeamInboxThread.md | 98 +++++++++++++++++++ .../TeamInboxCollaboration.md | 51 ++++++++++ .../TeamInboxResponsive.md | 48 +++++++++ .../TeamInboxThread.md | 61 ++++++++++++ .../TeamInboxCollaboration.md | 25 +++++ .../TeamInboxKanban.md | 12 +++ 7 files changed, 380 insertions(+) create mode 100644 docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md create mode 100644 docs/architecture-audit-2026-07-27/TeamInboxThread.md create mode 100644 docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md create mode 100644 docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md create mode 100644 docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md create mode 100644 docs/org2-performance-guard-2026-07-27/TeamInboxCollaboration.md create mode 100644 docs/org2-performance-guard-2026-07-27/TeamInboxKanban.md diff --git a/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md new file mode 100644 index 0000000000..0d9af282b0 --- /dev/null +++ b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md @@ -0,0 +1,85 @@ +# Architecture Audit — Team Inbox Multi-User Collaboration + +**Scope:** structured member mentions, durable viewer-scoped read receipts, authoritative unread counts, full-roster Work Item identity projection, and dual-instance UI coverage. +**Date:** 2026-07-27 + +## Layer 1 — Compilation correctness + +- TypeScript `tsc --noEmit`: passed. +- Focused ESLint over all changed collaboration/UI files: passed. +- Twenty-four focused Vitest files: 230 tests passed after the capability-gate regression cases were added. +- Cloud migration was statically reviewed; live apply and live two-account E2E remain deployment validation. + +## Layer 2 — Dead code and structural deduplication + +- Removed the cloud mention localStorage receipt owner; server receipts are now the sole cross-device source of truth. +- `resolveMentions` and `MemberMentionChip` own repeated UUID-to-name and pill UI logic. +- Session comments load the active roster through the existing shared roster coordinator rather than adding a second fetch/cache. +- Work Item history, description creator, assignee, and reviewer all project the same roster identities. + +## Layer 3 — Naming consistency + +- `mentionedUserIds` is used consistently on client wire/domain models; PostgreSQL uses `mentioned_user_ids`. +- `readAt` denotes the viewer-specific receipt timestamp, while `unreadCount` denotes the authoritative full-result total. +- `markAllTeamInboxMentionsRead` is explicitly org/viewer scoped rather than implying a global Inbox mutation. + +## Layer 4 — Semantic overloading + +| Term | Meaning | Verdict | +| --------------- | -------------------------------------------------------------- | ----------------------------------------------------------- | +| mention | Explicit active-org member UUID attached to a comment | Never inferred from display text. | +| read | Receipt for one authenticated viewer and one mentioned comment | Separate from comment resolution or Session state. | +| unread count | Full eligible mention total outside the current page | Owned by the server response, not derived from loaded rows. | +| member identity | Stable user UUID with roster-projected display name | IDs persist; names may change without rewriting history. | + +## Layer 5 — Default branch analysis + +- Old cloud deployments report no `teamInboxMentions` capability, so the structured picker stays hidden. +- Comment adds without mentions retain the legacy RPC; adds with mentions require the atomic 0010 RPC and never silently drop recipients. +- The view owns optimistic read/unread presentation and per-item rollback generations. The data source serializes the corresponding durable mutations through a bounded queue, so rapid opposite actions cannot commit out of order. +- Empty, loading, pagination, filtered, and partially loaded Inbox states preserve the server unread total. + +## Layer 6 — Cross-domain concept leakage + +- PostgreSQL owns durable receipts, recipient validation, visibility, retention, and authoritative totals. +- Org2Cloud clients own wire validation and transport retry only. +- Team Inbox owns list/filter/optimistic presentation, not receipt persistence. +- Session comments own member selection and mention rendering. +- Work Item components own assignee/reviewer/history identity presentation. + +## Layer 7 — New developer confusion test + +- No caller supplies a viewer ID to receipt RPCs; `auth.uid()` is always authoritative. +- The server accepts recipient UUIDs only after validating active membership in the target org. +- The capability flag documents the required server/client rollout order. +- Local single-user assigned items and cloud mention items remain distinct data-source branches with one normalized Inbox model. + +## Layer 8 — Wire protocol and serialization + +- `cloud_add_session_comment_with_mentions` atomically writes the comment and its deduplicated recipient UUIDs. +- Existing `cloud_list_session_comments` keeps its signature and legacy keys, adding `mentionedUserIds`. +- Mention list rows add `readAt`; the page adds `unreadCount` and a keyset `nextCursor`. +- Receipt mutations return both the resulting `readAt` and a fresh authoritative `unreadCount`. +- Zod schemas reject malformed wire state before it enters UI state. + +## Layer 9 — Init parity + +- Initial Inbox load and pagination both use the same mention projection; only the first page replaces the authoritative count. +- The initial cloud projection is capability-gated. A pre-0010 backend keeps local assigned items available without attempting a missing RPC. +- Reopened comment surfaces load persisted recipient IDs from the ordinary comment list. +- Roster loading is keyed by endpoint/account/org/revision and discards stale identity results. +- Account/org changes evict the previous projection in a layout effect before paint; page and mutation completions carry a load generation and cannot repopulate the new identity with old rows. +- Both primary and secondary desktop instances exercise the production UI/data paths in the extended E2E scenario. + +## Layer 10 — Resolver symmetry + +- Owner, assignee, reviewer, comment author, and mentioned recipient all resolve through the active org roster. +- Mark-read, mark-unread, and mark-all share the same eligibility rules as list/count: membership, retention, deletion, visibility, and active sharing. +- Restricted Sessions are visible only to owner or active grantees across both list and count paths. +- The owner does not receive another member's targeted mention projection unless explicitly included as a recipient. + +## Completion verdict + +- Architecture verdict: pass for Layers 1–10 in the implemented scope. +- Deployment gate: apply cloud migration `0010_team_inbox_mentions.sql` before shipping the desktop capability-enabled experience. +- Remaining production proof: run the managed-cloud two-account E2E after the migration is applied. diff --git a/docs/architecture-audit-2026-07-27/TeamInboxThread.md b/docs/architecture-audit-2026-07-27/TeamInboxThread.md new file mode 100644 index 0000000000..dc306ab99e --- /dev/null +++ b/docs/architecture-audit-2026-07-27/TeamInboxThread.md @@ -0,0 +1,98 @@ +# Architecture Audit — Team Inbox Thread and Kanban Refresh + +**Scope:** Team Inbox full Work Item loading/editing, shared Work Item presentation policy, canonical Start Agent handoff, Session-tab navigation, and local/cloud Kanban manual refresh. +**Date:** 2026-07-27 + +## Layer 1 — Compilation correctness + +- Focused Vitest suites: passed. +- TypeScript `tsc --noEmit`: passed. +- Focused ESLint: passed. + +## Layer 2 — Dead code and structural deduplication + +- Removed the body-only `useTeamInboxWorkItemBody` path. +- `useTeamInboxWorkItem` now resolves the full canonical item used by both `WorkItemContent` and `WorkItemProperties`. +- Moved `toWorkItemPartialUpdate` out of `WorkItemPanelView` so the Chat Panel and Team Inbox share one write-payload mapper. +- The ordinary Work Item view and Team Inbox both use one `WorkItemContent`; only an explicit presentation policy differs. +- `WorkItemThreadLayout` now owns the centered reading frame and metadata-band composition; `WorkItemThreadSection` owns the static card shell. +- To-Do and Workflow share `WORK_ITEM_THREAD_TOKENS`, while Workflow retains the existing `CollapsibleSection` state owner rather than introducing a second collapsible abstraction. +- Thread-only To-Do draft state is component-local and is never persisted until a non-empty item is committed. +- `ChatPanelWorkItemActionRequest` is a transient one-slot command envelope. It carries intent only; the canonical Work Item orchestrator remains the sole execution owner. + +## Layer 3 — Naming consistency + +- Added `presentation: "default" | "thread"` rather than an ambiguous boolean such as `hideSessions`. +- `start_agent` is named as a navigation action request instead of overloading ordinary `open_work_item`. +- `usePendingWorkItemAction` names the only bridge from the transient request to the canonical Work Item start command. +- Added a dedicated `open_session` navigation intent. Opening a Session no longer overloads `open_session_comment` with empty comment/thread IDs. +- `refreshKanbanSources` names the local/cloud fan-out without claiming ownership of either cache. + +## Layer 4 — Semantic overloading + +| Term | Meaning | Verdict | +| ------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| thread | One Work Item activity flow containing workflow/session cards and history | Distinct from a Session comment thread; scoped to `WorkItemContent` presentation. | +| thread layout | Stateless Work Item-domain presentation primitives | Owns composition/tokens only; it does not own persistence, collapse state, or orchestration. | +| Session open | Open/focus a Session Chat Panel tab | Explicit `open_session`; comment anchoring remains `open_session_comment`. | +| refresh | User-triggered authoritative revalidation | Local roster and cloud teammate snapshots keep their own identity/single-flight owners. | +| start request | One-shot UI intent for the matching canonical Work Item | Not workflow state and not persisted in the tab; claimed before async orchestration begins. | + +## Layer 5 — Default branch analysis + +- `resolveWorkItemContentSectionPolicy` handles both closed presentation variants and is unit-tested. +- `default` preserves the legacy tabs plus linked-Session table for existing consumers. +- `thread` omits that table, renders workflow/history inline, and renders output only when proof of work exists. +- Thread description transitions are explicit: read → editing → dirty → saved/cancelled. Save is disabled in editing/clean state. +- Start transitions are explicit: Inbox idle action → resolve/open canonical Work Item → publish matching request → atomically claim request → existing orchestrator validates configuration/locks and starts or reports failure. +- A claimed request is cleared before the async call, so remounts and repeated React effects cannot replay it. A non-matching Work Item cannot claim it. +- The transient channel holds at most one unclaimed request. A newer navigation intent supersedes an older unclaimed intent, preventing a hidden tab from starting unexpectedly when visited later. +- Read/update failures are explicit UI states; they do not fall back to fabricated data. + +## Layer 6 — Cross-domain concept leakage + +- Project persistence stays behind `projectApi` and the shared Work Item payload mapper. +- Team Inbox owns selection and presentation only. +- Thread primitives live under the Work Item component domain rather than a global shared package because the reading width, metadata band, and density are Work Item-specific. +- Agent execution remains exclusively owned by the canonical Work Item surface. Team Inbox publishes intent but does not mount a second orchestrator (which would duplicate collaboration-lock, auto-review, and stale-session lifecycles). +- Chat Panel atoms remain the sole owner of Session tab creation/focus. +- Kanban composes refresh callbacks but does not take ownership of session/cloud caches. + +## Layer 7 — New developer confusion test + +- The presentation policy documents exactly which legacy elements are absent. +- The thread layout API uses semantic slots (`path`, `properties`, `title`, `meta`, `action`) instead of exposing consumer-defined class bags. +- Static versus collapsible cards remain visibly consistent through one token source, while their interaction semantics stay explicit in their owning components. +- The full Work Item hook exposes `loading / ready / error` rather than conflating missing data with loading. +- Standalone items remain readable but do not expose non-functional edit controls. +- Project-scoped items expose compact shared property pills; the full property editor remains available through the canonical Work Item surface. + +## Layer 8 — Wire protocol and serialization + +- No new wire format was introduced. +- The extracted presentation primitives are stateless and introduce no new IPC, persistence, cache, subscription, timer, or request lifecycle. +- The start request is process-local transient UI state; it never enters tab persistence, project persistence, IPC, or the Agent wire payload. +- Work Item writes reuse the existing `WorkItemPartialUpdate` contract. +- To-Do drafts never cross that boundary; only normalized committed rows are serialized. +- Team/shared `+/-` impact is not synthesized: Kanban continues to consume authoritative local impact and cloud session metadata only. + +## Layer 9 — Init parity + +- No Agent initialization entry point changed. The request terminates at the same `handleStartAgent` used by the existing Work Item button. +- Manual refresh uses the same production local roster coordinator and cloud remote-session hook used by initial demand/realtime recovery. +- Tests call the source-composition helper only; rendered acceptance must still drive the real button. + +## Layer 10 — Resolver symmetry + +- Project-scoped reads resolve Work Item plus project metadata/repo identity; standalone reads use the standalone API and stay read-only. +- Local and cloud Kanban sources are both invoked by the manual action, while each source preserves its own scope/identity rules. +- Existing Session tabs are focused; missing tabs are created through the same open-or-focus atom for both Session cards and mention navigation. +- Work Item action resolution is symmetric for newly created and already-open tabs: both are activated first, then receive the same keyed one-shot request. + +## Completion verdict + +- One persistent Work Item owner, one Agent start dispatcher, one Session-tab dispatcher, and one cache owner per Kanban source. +- The Team Inbox navigation wrapper now forwards explicit child intents, so Session cards no longer collapse back to the selected row's generic Work Item destination. +- Stale Work Item reads are cancelled on selection change; overlapping writes use a monotonic generation before replacing UI state. +- Manual workflow refresh preserves the currently rendered Work Item on read failure and exposes the error banner; it does not replace success data with a transient empty state. +- Architecture verdict: pass for Layers 1–10 in the changed scope. diff --git a/docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md b/docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md new file mode 100644 index 0000000000..b2793b70c0 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-27/TeamInboxCollaboration.md @@ -0,0 +1,51 @@ +# Frontend UI Audit — Team Inbox Multi-User Collaboration + +**Files:** `src/features/Org2Cloud/SessionComments/*.tsx`, `src/modules/MainApp/TeamInbox/**/*.tsx`, `src/modules/ProjectManager/WorkItems/components/WorkItemContent/*.tsx` +**Date:** 2026-07-27 +**Auditor:** Codex implementation session + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------- | ------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `CommentThreadList.tsx` | member mention picker | fix | A collaboration action needs the same keyboard/search/selection behavior as other menus. | Reused the shared `Dropdown` in multiple-selection mode and the shared tertiary `Button`; no bespoke popover was introduced. | +| `CommentThreadList.tsx` | selected and persisted mention chips | abstract | Composer selections and rendered comments initially repeated the same identity pill styling and ID-to-name resolution. | Added one `resolveMentions` projection and one `MemberMentionChip` presentation primitive within the owning comment domain. | +| `AssignedWorkItemDetail.tsx` | assignee/reviewer presentation | fix | Showing only a raw assignee UUID made team ownership ambiguous and omitted reviewer state. | Reused the Work Item property surface with the complete active roster so assignee and reviewer resolve to member display names. | +| `TeamInboxList.tsx` | mark-all-read action | keep with reason | This is a standard labeled command, already implemented with the shared Button and now receives only a stable test hook. | — | +| `TeamInboxRow.tsx` | unread row | keep with reason | The existing row owns selection, unread emphasis, and keyboard activation; the change adds semantic test/state attributes without duplicating it. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ----------------------- | ------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `CommentThreadList.tsx` | `max-w-[160px]`, `text-[10px]` | keep with reason | These match the established compact comment-meta density and bound long member names. The values are centralized in `MemberMentionChip`. | Promote a global identity-chip token only if a second product domain needs the same compact treatment. | +| changed files | colors | keep with reason | All new color usage is expressed through semantic primary/background/text/border tokens. No raw color literals were added. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ----------------------- | ---------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `CommentThreadList.tsx` | mention pill width cap | keep with reason | The cap prevents a single member name from consuming the composer action row while preserving the full identity in the searchable picker. | Add a tooltip only if real rosters show frequent ambiguous truncation. | +| `TeamInboxView.tsx` | no new fixed geometry | keep with reason | Optimistic state and authoritative counts change behavior only; the existing compact Inbox layout is preserved. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ----------------------- | --------------------- | ---------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `CommentThreadList.tsx` | mention member action | fix | The action must be keyboard reachable and expose a visible label. | Shared Button renders the translated “Mention” label; shared Dropdown provides search and selection keyboard behavior. | +| `TeamInboxList.tsx` | mark all as read | keep with reason | The action already has a translated visible label and native Button semantics. | — | +| `TeamInboxRow.tsx` | unread state | fix | Visual emphasis alone is insufficient for deterministic behavioral verification. | Added stable row identity and `data-unread` state; existing visible unread indicator remains unchanged. | + +## D5 — Visual Patterns Observed + +- Member identity is selected from the authoritative active roster and persisted as UUIDs; display names are a rendering projection. +- Mention chips share one product-domain component across draft and persisted states. +- Team Inbox keeps the existing unified thread hierarchy; collaboration adds data and state, not a second detail layout. +- Reviewer and assignee reuse the canonical Work Item property UI instead of introducing Inbox-only badges. +- The picker is capability-gated, so older cloud deployments do not render a control whose RPC is unavailable. + +## Summary + +- 5 fixes completed +- 5 kept with documented reason +- 1 abstract candidate completed diff --git a/docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md b/docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md new file mode 100644 index 0000000000..d32b9a04f6 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-27/TeamInboxResponsive.md @@ -0,0 +1,48 @@ +# Frontend UI Audit — Team Inbox Responsive Detail + +**Files:** `src/modules/MainApp/TeamInbox/TeamInboxView.tsx`, `src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx`, `src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx` +**Date:** 2026-07-27 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------------------- | ---------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| WorkItemProperties: 301 | More-properties action | keep with reason | Uses the shared `Button` component with the established circular secondary treatment. | — | +| TeamInboxView / AssignedWorkItemDetail | Interactive controls | keep with reason | All controls are delegated to shared `SplitViewLayout`, `WorkItemProperties`, and detail components; no new raw interactive HTML was introduced. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---------------------- | -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| WorkItemProperties: 42 | `bg-[var(--cm-editor-background,...)]` | fix candidate | Pre-existing project-owned surface token; the repo sweep finds three direct uses. This is outside the responsive fix and should be handled once as a token-mapping sweep. | Add a semantic Tailwind surface mapping, then replace all three sites together. | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---------------------- | --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| TeamInboxView: 406–407 | `listWidth={200}`, `minListWidth={160}` | keep with reason | Existing resizable master-list bounds; detail responsiveness is owned by the remaining flex width and wrapping property layout. | — | +| WorkItemProperties: 44 | `text-[13px]` | keep with reason | Existing dense property typography, repeated consistently throughout Work Items; changing one header would reduce local consistency. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ----------------------------- | ---------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| AssignedWorkItemDetail: 63–76 | Responsive property controls | keep with reason | Wrapping changes visual flow only; shared fields retain native button semantics, accessible names, keyboard handling, and portalled menus. | — | +| TeamInboxView: 404–412 | Split-view header policy | keep with reason | Removing the unrelated global breadcrumb also removes a misleading navigation announcement from the Team Inbox reading order. | — | + +## D5 — Visual Patterns Observed + +- Responsive pill layout is implemented once in shared `WorkItemProperties` through an explicit `pillLayout` policy. +- Team Inbox opts into wrapping; existing inline-create hosts preserve their compact single-row behavior. +- No new repeated visual pattern or abstraction candidate was introduced. + +## Next-refactor candidates + +- Sweep the three `bg-[var(--cm-editor-background,...)]` uses into one semantic Tailwind surface token rather than changing only this component. + +## Summary + +- 1 fix candidate, intentionally deferred to a repository-wide token sweep +- 4 kept with documented reason +- 0 new abstract candidates diff --git a/docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md b/docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md new file mode 100644 index 0000000000..1989d8c3b4 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-27/TeamInboxThread.md @@ -0,0 +1,61 @@ +# Frontend UI Audit — Team Inbox Work Item Thread and Kanban Refresh + +**Files:** `src/modules/MainApp/TeamInbox/components/*.tsx`, `src/modules/ProjectManager/WorkItems/components/WorkItemContent/*.tsx`, `src/modules/ProjectManager/WorkItems/components/AgentWorkflow/*.tsx`, `src/modules/shared/components/ActivityTimeline/index.tsx`, `src/features/TaskKanban/**/*.tsx` +**Date:** 2026-07-27 +**Auditor:** Codex implementation session + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------------------- | ------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `AssignedWorkItemDetail.tsx` | Work Item content/property controls | fix | The initial unified version still placed two heavy property cards in a competing right rail. | Reused `WorkItemProperties` in pill mode inside one compact metadata band and removed the Team Inbox-only rail. | +| `WorkItemContent/index.tsx` | always-mounted description editor | fix | Preview/Raw controls and a 200px editor made reading a short Inbox item feel like editing a full database record. | Thread mode now renders natural-height Markdown and demand-mounts the shared editor after an explicit Edit action. | +| `ThreadTodoChecklist.tsx` | empty To-Do editor | fix | The generic checklist immediately persisted an empty row and exposed an input before user intent. | Added a thread-specific demand composer that commits only trimmed, non-empty items and reuses Button/Input/Checkbox. | +| `HistoryTab.tsx` | disconnected subscription/avatar row | fix | Subscription, avatar, history, composer, and submit were visually unrelated. | Grouped subscription with the Activity heading and attached the avatar to the outlined comment composer. | +| `AssignedWorkItemDetail.tsx` | missing idle Agent Workflow action | fix | Removing the previous no-op callback also removed the visible primary action, leaving actionable copy without a control. | Restored the shared Agent Workflow `Button`; it forwards a one-shot start intent to the canonical Work Item surface. | +| `ThreadTodoChecklist.tsx` | empty-state full-row action | fix | The full-width empty-state hit area is still a standard labeled action. | Reused the shared Button in long/ghost form with right-side icon instead of maintaining raw button styling. | +| `WorkItemThread/index.tsx` | repeated thread card/layout shells | abstract | The centered reading column, metadata band, and card header/body treatment were repeated or assembled at consumer sites, making future thread surfaces likely to drift. | Added Work Item-owned `WorkItemThreadLayout` / `WorkItemThreadSection`; collapsible Workflow consumes the same token set while retaining `CollapsibleSection` semantics. | +| `KanbanHeaderTrailingControls/index.tsx` | refresh action | fix | Kanban had no manual refresh control. | Added the shared `Button` tertiary/ghost treatment, `WorkstationToolbarTooltip`, and `useRefreshSpin`. | +| `TaskKanban/index.tsx:396` | raw circular add-session `
- ), +
+ ) : null, [ handleDeleteWorkItem, isGitHubSyncedProject, projectSyncAdapterId, - propertiesOpen, selectedWorkItem.projectSlug, t, ] @@ -496,75 +462,50 @@ export const WorkItemPanelView: React.FC = ({ content: { content: headerContent, trailing: headerActions }, }); - const propertiesContent = ( - - ); - return (
-
-
- -
- {propertiesOpen ? ( - <> - - - {propertiesContent} - - - ) : null} +
+
{floatingSessionId && (
{ - const payload = await getCloudCapabilitiesRaw(accessToken); + const payload = await runCloudRequestWithTimeout( + (signal) => getCloudCapabilitiesRaw(accessToken, signal), + CLOUD_CAPABILITIES_TIMEOUT_MS + ); const parsed = CloudCapabilitiesWireSchema.safeParse(payload); if (payload === null || !parsed.success) { // 404 (pre-0005) and transient failures are indistinguishable here, so diff --git a/src/features/Org2Cloud/org2CloudClient.ts b/src/features/Org2Cloud/org2CloudClient.ts index eda9218949..8940b02cfa 100644 --- a/src/features/Org2Cloud/org2CloudClient.ts +++ b/src/features/Org2Cloud/org2CloudClient.ts @@ -98,7 +98,8 @@ async function callRpc( functionName: string, accessToken?: string, body?: Record, - endpoint: CloudRpcEndpoint = getCloudEndpoint() + endpoint: CloudRpcEndpoint = getCloudEndpoint(), + signal?: AbortSignal ): Promise { try { const response = await fetchWithTransportRetry( @@ -107,6 +108,7 @@ async function callRpc( method: "POST", headers: rpcHeaders(accessToken, endpoint), body: JSON.stringify(body ?? {}), + signal, } ); if (!response.ok) { @@ -132,9 +134,16 @@ export async function schemaVersion(): Promise { * transport failure. Interpretation/caching live in `org2CloudCapabilities`. */ export async function getCloudCapabilitiesRaw( - accessToken: string + accessToken: string, + signal?: AbortSignal ): Promise { - return callRpc("get_cloud_capabilities", accessToken); + return callRpc( + "get_cloud_capabilities", + accessToken, + undefined, + getCloudEndpoint(), + signal + ); } /** diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts index 680cdc480f..e60b640608 100644 --- a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts +++ b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts @@ -238,6 +238,25 @@ describe("listTeamInboxMentions", () => { expect(error).toMatchObject({ code: "ORG2_MEMBER_REQUIRED", status: 403 }); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it("cancels an in-flight RPC when its owning Inbox scope is disposed", async () => { + fetchMock.mockImplementationOnce( + () => new Promise(() => undefined) + ); + const controller = new AbortController(); + + const request = listTeamInboxMentions( + "jwt-viewer", + "org-1", + null, + 25, + controller.signal + ); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect((lastCall().init.signal as AbortSignal).aborted).toBe(true); + }); }); describe("Team Inbox read receipts", () => { diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.ts b/src/features/Org2Cloud/teamInboxMentionsClient.ts index 9b4af2ee56..6a87fa44ce 100644 --- a/src/features/Org2Cloud/teamInboxMentionsClient.ts +++ b/src/features/Org2Cloud/teamInboxMentionsClient.ts @@ -3,12 +3,16 @@ import { z } from "zod/v4"; import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config"; import { getCloudCapabilities } from "./org2CloudCapabilities"; import { Org2CloudCommentError } from "./org2CloudCommentsClient"; -import { fetchWithTransportRetry } from "./org2CloudFetchRetry"; +import { + fetchWithTransportRetry, + runCloudRequestWithTimeout, +} from "./org2CloudFetchRetry"; const TEAM_INBOX_MENTIONS_RPC = "cloud_list_team_inbox_mentions"; const SET_TEAM_INBOX_MENTION_READ_RPC = "cloud_set_team_inbox_mention_read"; const MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC = "cloud_mark_all_team_inbox_mentions_read"; +const TEAM_INBOX_REQUEST_TIMEOUT_MS = 15_000; const TeamInboxMentionRequestSchema = z.object({ orgId: z.string().min(1), @@ -74,39 +78,47 @@ export interface TeamInboxReadMutation { async function callTeamInboxRpc( functionName: string, accessToken: string, - body: Record + body: Record, + sourceSignal?: AbortSignal ): Promise { const endpoint = getCloudEndpoint(); - const response = await fetchWithTransportRetry( - `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, - { - method: "POST", - headers: { - apikey: endpoint.anonKey, - authorization: `Bearer ${accessToken}`, - "content-type": "application/json", - "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, - }, - body: JSON.stringify(body), - } + return runCloudRequestWithTimeout( + async (signal) => { + const response = await fetchWithTransportRetry( + `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, + { + method: "POST", + headers: { + apikey: endpoint.anonKey, + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, + }, + body: JSON.stringify(body), + signal, + } + ); + + const text = await response.text(); + let payload: unknown = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + + if (!response.ok) { + const message = + payload && typeof payload === "object" && "message" in payload + ? String((payload as { message: unknown }).message) + : `org2_cloud rpc ${functionName} failed with ${response.status}`; + throw new Org2CloudCommentError(message, response.status); + } + return payload; + }, + TEAM_INBOX_REQUEST_TIMEOUT_MS, + sourceSignal ); - - const text = await response.text(); - let payload: unknown = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = null; - } - - if (!response.ok) { - const message = - payload && typeof payload === "object" && "message" in payload - ? String((payload as { message: unknown }).message) - : `org2_cloud rpc ${functionName} failed with ${response.status}`; - throw new Org2CloudCommentError(message, response.status); - } - return payload; } /** @@ -120,14 +132,20 @@ export async function listTeamInboxMentions( accessToken: string, orgId: string, cursor: string | null, - limit: number + limit: number, + signal?: AbortSignal ): Promise { const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit }); - const payload = await callTeamInboxRpc(TEAM_INBOX_MENTIONS_RPC, accessToken, { - p_org_id: input.orgId, - p_cursor: input.cursor, - p_limit: input.limit, - }); + const payload = await callTeamInboxRpc( + TEAM_INBOX_MENTIONS_RPC, + accessToken, + { + p_org_id: input.orgId, + p_cursor: input.cursor, + p_limit: input.limit, + }, + signal + ); return TeamInboxMentionsPageSchema.parse(payload); } @@ -139,13 +157,14 @@ export async function listTeamInboxMentions( export async function listInitialTeamInboxMentions( accessToken: string, orgId: string, - limit = 50 + limit = 50, + signal?: AbortSignal ): Promise { const capabilities = await getCloudCapabilities(accessToken); if (!capabilities.teamInboxMentions) { return EMPTY_TEAM_INBOX_MENTIONS_PAGE; } - return listTeamInboxMentions(accessToken, orgId, null, limit); + return listTeamInboxMentions(accessToken, orgId, null, limit, signal); } /** Persists one viewer-scoped mention receipt. The viewer comes from JWT. */ @@ -153,7 +172,8 @@ export async function setTeamInboxMentionRead( accessToken: string, orgId: string, commentId: string, - read: boolean + read: boolean, + signal?: AbortSignal ): Promise { const payload = await callTeamInboxRpc( SET_TEAM_INBOX_MENTION_READ_RPC, @@ -162,7 +182,8 @@ export async function setTeamInboxMentionRead( p_org_id: z.string().min(1).parse(orgId), p_comment_id: z.string().min(1).parse(commentId), p_read: read, - } + }, + signal ); return TeamInboxReadMutationSchema.parse(payload); } @@ -170,12 +191,14 @@ export async function setTeamInboxMentionRead( /** Marks every currently visible mention read, including unloaded pages. */ export async function markAllTeamInboxMentionsRead( accessToken: string, - orgId: string + orgId: string, + signal?: AbortSignal ): Promise { const payload = await callTeamInboxRpc( MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC, accessToken, - { p_org_id: z.string().min(1).parse(orgId) } + { p_org_id: z.string().min(1).parse(orgId) }, + signal ); return TeamInboxReadMutationSchema.parse(payload); } diff --git a/src/hooks/project/useCurrentUserMemberId.test.ts b/src/hooks/project/useCurrentUserMemberId.test.ts new file mode 100644 index 0000000000..c51ecfc5d5 --- /dev/null +++ b/src/hooks/project/useCurrentUserMemberId.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import type { IUserInfo } from "@src/types/core/user"; + +import { + findMemberIdsByUser, + resolveCurrentUserIdentity, +} from "./useCurrentUserMemberId"; + +function user(overrides: Partial = {}): IUserInfo { + return { + uuid: "", + name: "", + authing_id: "", + profile: "", + picture: "", + profile_image_url: "", + openai_api_key: "", + deepseek_api_key: "", + git_user_name: "", + git_user_email: "", + github_infos: [], + gitlab_infos: [], + ...overrides, + }; +} + +describe("current Work Item identity", () => { + it("uses the project member identity for a consistent name and avatar", () => { + const members = [ + { + id: "user-ea821852", + name: "hanafish", + email: "hanafish@example.com", + avatar: "https://example.com/hanafish.png", + color: "#1677ff", + }, + ]; + const account = user({ + uuid: "user-ea821852", + name: "Account fallback", + git_user_email: "hanafish@example.com", + }); + const memberIds = findMemberIdsByUser(members, account); + + expect( + resolveCurrentUserIdentity(members, memberIds, account, null) + ).toEqual({ + id: "user-ea821852", + name: "hanafish", + email: "hanafish@example.com", + avatar: "https://example.com/hanafish.png", + color: "#1677ff", + }); + }); + + it("falls back to the signed-in profile instead of the generic You label", () => { + const account = user({ + uuid: "user-ea821852", + name: "hanafish", + profile_image_url: "https://example.com/hanafish.png", + }); + + expect( + resolveCurrentUserIdentity([], new Set(), account, null) + ).toMatchObject({ + id: "user-ea821852", + name: "hanafish", + avatar: "https://example.com/hanafish.png", + }); + }); + + it("enriches an opaque member record with the signed-in profile", () => { + const account = user({ + uuid: "user-ea821852", + name: "Yuki", + profile_image_url: "https://example.com/yuki.png", + }); + + expect( + resolveCurrentUserIdentity( + [{ id: "user-ea821852", name: "user-ea821852" }], + new Set(), + account, + null + ) + ).toMatchObject({ + id: "user-ea821852", + name: "Yuki", + avatar: "https://example.com/yuki.png", + }); + }); + + it("returns no actor when neither account nor git identity is trustworthy", () => { + expect(resolveCurrentUserIdentity([], new Set(), user(), null)).toBeNull(); + }); + + it("does not merge different people who share an email local-part", () => { + const members = [ + { + id: "member-company-alice", + name: "Alice Company", + email: "alice@company.example", + }, + { + id: "member-personal-alice", + name: "Alice Personal", + email: "alice@personal.example", + }, + ]; + + expect( + findMemberIdsByUser( + members, + user({ git_user_email: "alice@company.example" }) + ) + ).toEqual(new Set(["member-company-alice"])); + }); + + it("does not infer a member id from a non-unique display name", () => { + const members = [ + { + id: "member-1", + name: "Alex", + email: "alex-one@example.com", + }, + { + id: "member-2", + name: "Alex", + email: "alex-two@example.com", + }, + ]; + + expect(findMemberIdsByUser(members, user({ name: "Alex" }))).toEqual( + new Set() + ); + }); + + it("matches exact account ids and verified linked emails", () => { + const members = [ + { + id: "account-1", + name: "Account member", + }, + { + id: "member-linked", + name: "Linked member", + linked_emails: [{ email: "linked@example.com" }], + }, + ]; + + expect( + findMemberIdsByUser( + members, + user({ + uuid: "account-1", + git_user_email: "linked@example.com", + }) + ) + ).toEqual(new Set(["account-1", "member-linked"])); + }); +}); diff --git a/src/hooks/project/useCurrentUserMemberId.ts b/src/hooks/project/useCurrentUserMemberId.ts index e4cfe9c9b5..f898e2a008 100644 --- a/src/hooks/project/useCurrentUserMemberId.ts +++ b/src/hooks/project/useCurrentUserMemberId.ts @@ -3,15 +3,16 @@ * * Resolves the current user's project member ID(s) by matching against * all known user identities: - * - Local git config user.email (from Tauri command — most reliable) - * - Local git config user.name (fallback) + * - Stable account/member IDs + * - Local git config user.email (from Tauri command) * - userAtom.git_user_email (if populated) - * - github_infos / gitlab_infos usernames (matched against email prefix) + * - Exact GitHub/GitLab usernames when a member carries that provider field * * A single person often has multiple member entries (from git shortlog) * because they commit with different emails. This hook returns ALL - * matching member IDs so assignment notifications work regardless of - * which member entry was used. + * exact matching member IDs so assignment notifications work regardless of + * which verified member entry was used. Display names and email local-parts + * are deliberately excluded because they are not unique identities. */ import { invoke } from "@tauri-apps/api/core"; import { useAtomValue } from "jotai"; @@ -19,13 +20,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { MemberEntry } from "@src/api/http/project"; import { userAtom } from "@src/store/user/userAtom"; +import type { Person } from "@src/types/core/shared"; import type { IUserInfo } from "@src/types/core/user"; // ============================================ // Git identity from Tauri // ============================================ -interface GitUserIdentity { +export interface GitUserIdentity { email: string | null; name: string | null; /** GitHub username from gh CLI config (~/.config/gh/hosts.yml) */ @@ -72,7 +74,80 @@ export function resetGitIdentityCache() { interface UserIdentities { emails: string[]; - userName: string; + accountIds: string[]; + usernames: string[]; +} + +export type MemberIdentity = Pick< + MemberEntry, + "id" | "name" | "email" | "avatar" | "github_username" | "linked_emails" +> & { + color?: string; +}; + +export function resolveCurrentUserIdentity( + members: readonly MemberIdentity[], + memberIds: ReadonlySet, + user: IUserInfo, + gitIdentity: GitUserIdentity | null +): Person | null { + const accountIds = new Set( + [user.uuid, user.authing_id].map((value) => value.trim()).filter(Boolean) + ); + const currentMember = members.find( + (member) => memberIds.has(member.id) || accountIds.has(member.id) + ); + if (currentMember) { + const memberName = currentMember.name.trim(); + const accountName = ( + user.name || + gitIdentity?.name || + user.git_user_name || + "" + ).trim(); + const memberNameIsOpaque = + !memberName || + memberName === currentMember.id || + /^user-[a-z0-9]+$/i.test(memberName); + + return { + id: currentMember.id, + name: + memberNameIsOpaque && accountName + ? accountName + : memberName || accountName, + email: currentMember.email, + avatar: + currentMember.avatar || + user.profile_image_url || + user.picture || + undefined, + color: currentMember.color, + }; + } + + const name = ( + user.name || + gitIdentity?.name || + user.git_user_name || + "" + ).trim(); + const id = ( + user.uuid || + user.authing_id || + gitIdentity?.email || + user.git_user_email || + name + ).trim(); + if (!id || !name) return null; + + return { + id, + name, + email: gitIdentity?.email || user.git_user_email || undefined, + avatar: user.profile_image_url || user.picture || undefined, + color: "#52c41a", + }; } /** @@ -83,42 +158,46 @@ function collectIdentities( gitIdentity: GitUserIdentity | null ): UserIdentities { const emailSet = new Set(); + const usernameSet = new Set(); + const accountIdSet = new Set(); + + for (const accountId of [user.uuid, user.authing_id]) { + const normalized = accountId.trim(); + if (normalized) accountIdSet.add(normalized); + } - // 1. GitHub username from gh CLI (most reliable for matching) + // GitHub username from gh CLI. if (gitIdentity?.github_username) { - emailSet.add(gitIdentity.github_username.toLowerCase().trim()); + usernameSet.add(gitIdentity.github_username.toLowerCase().trim()); } - // 2. Local git config email (matches git shortlog entries) + // Exact email identities. if (gitIdentity?.email) { emailSet.add(gitIdentity.email.toLowerCase().trim()); } - // 3. userAtom git_user_email (if populated by backend) if (user.git_user_email) { emailSet.add(user.git_user_email.toLowerCase().trim()); } - // 4. GitHub usernames from linked accounts + // Exact provider usernames. for (const gh of user.github_infos ?? []) { if (gh.user_name) { - emailSet.add(gh.user_name.toLowerCase().trim()); + usernameSet.add(gh.user_name.toLowerCase().trim()); } } - // 5. GitLab usernames for (const gl of user.gitlab_infos ?? []) { if (gl.user_name) { - emailSet.add(gl.user_name.toLowerCase().trim()); + usernameSet.add(gl.user_name.toLowerCase().trim()); } } - // Best user name: prefer git config, then userAtom - const userName = (gitIdentity?.name || user.git_user_name || "") - .toLowerCase() - .trim(); - - return { emails: [...emailSet], userName }; + return { + emails: [...emailSet], + accountIds: [...accountIdSet], + usernames: [...usernameSet], + }; } // ============================================ @@ -129,30 +208,22 @@ function collectIdentities( * Check if a member entry matches any of the user's known identities. */ function memberMatchesUser( - member: MemberEntry, + member: MemberIdentity, identities: UserIdentities ): boolean { const memberEmail = (member.email || "").toLowerCase().trim(); - const memberName = (member.name || "").toLowerCase().trim(); - - for (const email of identities.emails) { - // Direct email match - if (memberEmail === email) return true; + const memberUsername = (member.github_username || "").toLowerCase().trim(); - // Email prefix match (e.g. github username "alice" matches "alice@example.com") - if (memberEmail && memberEmail.split("@")[0] === email) return true; - - // Reverse: member email prefix matches user email - if ( - email.includes("@") && - email.split("@")[0] === memberEmail.split("@")[0] - ) { - return true; - } + if (identities.accountIds.includes(member.id)) return true; + if (memberEmail && identities.emails.includes(memberEmail)) return true; + if (memberUsername && identities.usernames.includes(memberUsername)) { + return true; } - // Name-based fallback - if (identities.userName && memberName === identities.userName) return true; + for (const linked of member.linked_emails ?? []) { + const email = linked.email.toLowerCase().trim(); + if (email && identities.emails.includes(email)) return true; + } return false; } @@ -161,9 +232,9 @@ function memberMatchesUser( * Find a member entry by exact email match. */ export function findMemberByEmail( - members: MemberEntry[], + members: readonly MemberIdentity[], email: string -): MemberEntry | undefined { +): MemberIdentity | undefined { const normalized = email.toLowerCase().trim(); return members.find( (member) => (member.email || "").toLowerCase().trim() === normalized @@ -182,7 +253,7 @@ export function findMemberByEmail( * For the async version that fetches git config, use the hook. */ export function findMemberIdsByUser( - members: MemberEntry[], + members: readonly MemberIdentity[], user: IUserInfo, gitIdentity?: GitUserIdentity | null ): Set { @@ -207,6 +278,8 @@ interface UseCurrentUserMemberIdsReturn { memberIds: Set; /** Current user's git email (primary) */ gitEmail: string; + /** Display identity used by Work Item comments and mutation history. */ + currentUser: Person | null; } /** @@ -214,7 +287,7 @@ interface UseCurrentUserMemberIdsReturn { * Fetches git identity from local config on mount. */ export function useCurrentUserMemberIds( - members: MemberEntry[] + members: readonly MemberIdentity[] ): UseCurrentUserMemberIdsReturn { const user = useAtomValue(userAtom); const [gitIdentity, setGitIdentity] = useState( @@ -244,6 +317,10 @@ export function useCurrentUserMemberIds( ); const gitEmail = gitIdentity?.email || user.git_user_email || ""; + const currentUser = useMemo( + () => resolveCurrentUserIdentity(members, memberIds, user, gitIdentity), + [gitIdentity, memberIds, members, user] + ); - return { memberIds, gitEmail }; + return { memberIds, gitEmail, currentUser }; } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 9f7edd9199..648866578c 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -2466,7 +2466,8 @@ "unread": "Unread" }, "row": { - "assignedSummary": "{{status}} · {{priority}}" + "assignedSummary": "{{status}} · {{priority}}", + "ariaLabel": "{{title}}, {{status}}" }, "search": { "placeholder": "Search inbox", @@ -2500,15 +2501,24 @@ "errors": { "loadTitle": "Unable to load Team Inbox", "load": "Unable to load Team Inbox", + "loadMore": "Unable to load more Team Inbox items. Try again.", "refresh": "Unable to refresh Team Inbox", "markRead": "Unable to mark this item as read. Try again.", "markUnread": "Unable to mark this item as unread. Try again.", - "markAllRead": "Unable to mark all items as read. Try again." + "markAllRead": "Unable to mark all items as read. Try again.", + "identity": "Your account could not be matched to a project member. Check your project profile email.", + "partialLoad": "Some Team Inbox sources could not be refreshed. Available items are still shown.", + "workItemContext": "Some project context is unavailable. The work item remains usable.", + "workItemLoad": "Unable to load this work item. Try again.", + "workItemUpdate": "Unable to save the latest work item change. Try again." }, "detail": { "assignedSubtitle": "Assigned work item", + "standaloneProject": "Standalone", "mentionSubtitle": "Mentioned in a comment", - "mentionedYou": "mentioned you" + "mentionedYou": "mentioned you", + "threadComments_one": "{{count}} comment in this thread", + "threadComments_other": "{{count}} comments in this thread" }, "actions": { "markRead": "Mark as read", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index a3b70973f6..cc89bf0e53 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -2346,7 +2346,8 @@ "unread": "未读" }, "row": { - "assignedSummary": "{{status}} · {{priority}}" + "assignedSummary": "{{status}} · {{priority}}", + "ariaLabel": "{{title}},{{status}}" }, "search": { "placeholder": "搜索收件箱", @@ -2380,15 +2381,23 @@ "errors": { "loadTitle": "无法加载团队收件箱", "load": "无法加载团队收件箱", + "loadMore": "加载更多团队收件箱事项失败,请重试。", "refresh": "无法刷新团队收件箱", "markRead": "标记已读失败,请重试。", "markUnread": "标记未读失败,请重试。", - "markAllRead": "全部标记已读失败,请重试。" + "markAllRead": "全部标记已读失败,请重试。", + "identity": "当前账户无法匹配到项目成员,请检查项目个人资料中的邮箱。", + "partialLoad": "部分团队收件箱来源刷新失败,当前可用事项仍会保留显示。", + "workItemContext": "部分项目上下文暂不可用,工作项仍可继续查看和操作。", + "workItemLoad": "无法加载此工作项,请重试。", + "workItemUpdate": "无法保存刚才的工作项修改,请重试。" }, "detail": { "assignedSubtitle": "分配给你的工作项", + "standaloneProject": "独立工作项", "mentionSubtitle": "评论中提及了你", - "mentionedYou": "提及了你" + "mentionedYou": "提及了你", + "threadComments": "该话题中有 {{count}} 条评论" }, "actions": { "markRead": "标记已读", diff --git a/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx index e92b62f971..f32d84922c 100644 --- a/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx +++ b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx @@ -5,9 +5,15 @@ import { useTeamInboxDataSource } from "./useTeamInboxDataSource"; import { useTeamInboxNavigation } from "./useTeamInboxNavigation"; const ConnectedTeamInboxView: React.FC = () => { - const { dataSource } = useTeamInboxDataSource(); + const { dataSource, viewerMemberIds } = useTeamInboxDataSource(); const navigate = useTeamInboxNavigation(); - return ; + return ( + + ); }; export default ConnectedTeamInboxView; diff --git a/src/modules/MainApp/TeamInbox/TEST_CASES.md b/src/modules/MainApp/TeamInbox/TEST_CASES.md index 421a7f689b..8ea5018b64 100644 --- a/src/modules/MainApp/TeamInbox/TEST_CASES.md +++ b/src/modules/MainApp/TeamInbox/TEST_CASES.md @@ -19,20 +19,43 @@ - Assigned items carry a trimmed, whitespace-folded, 240-char body excerpt as `summary`; blank bodies omit the field (`work_item_summary_excerpt`). - `mark_unread` deletes the viewer-scoped local or cloud receipt so the item returns to unread and remains idempotent; cloud receipts are not owned by localStorage. - `toWireCursorItemId` preserves the backend `work_item_assigned:` source prefix (strips only the UI `assigned_work_item:` kind prefix) so `Load more` cursor pagination round-trips instead of erroring. +- Sidebar and full Inbox consumers in the same Jotai store share one scope-keyed coordinator, including initial request identity, local/cloud cursors, mutation ordering, cancellation, and the bounded 500-row snapshot. +- Local and cloud reads settle independently: one successful source remains visible with a localized partial-success notice, and a failed pagination cursor remains retryable. +- Switching account, organization, or resolved viewer identity synchronously evicts the old snapshot, aborts cloud work, and prevents late responses from committing into the new scope. +- Exact account IDs, verified full email addresses, linked emails, and provider usernames may resolve a viewer; matching display names or equal email local-parts across domains never does. +- Reassigning a Work Item changes `assigned_human_id` and deletes the prior assignment episode's read receipt in the same SQLite transaction; agent assignments never enter the human-assignment projection. +- Failed read/unread persistence rolls back the coordinator-owned optimistic snapshot, while a newer per-item mutation supersedes an older response. ## Presentation / polish 1. Filter tabs (`All` / `Mentions` / `Assigned`) show a primary count badge only when that surface has unread items; badge clamps to `99+`. 2. Unread rows render a leading primary dot and bold title; read rows drop the dot and use medium weight. -3. Assigned rows show the resolved assignee **name** (not the raw member id) and a `status · priority` summary using localized labels. -4. Assigned detail shows localized `Status` and `Priority` rows and no misleading `Assigned by` row when no assigner is known. -5. `Mark all as read` in the header marks **only the active filter's** unread items (Mentions view never marks Assigned, and vice versa). -6. Empty state copy is filter-specific (`No mentions` vs `Nothing assigned to you`), falling back to the generic empty copy for `All`. -7. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list. -8. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries. -9. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes. -10. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the managed-cloud receipt). Re-marking read still works after refresh or on another device. -11. When a source still has a next page, the list shows a `Load more` control; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more. +3. Assigned rows show one title line, at most two plain-text excerpt lines, and a localized `status · priority` metadata line; Markdown syntax, escaped newlines, and redundant assignee names do not leak into the card. +4. Successful edits in the selected Work Item immediately update the matching list row's title, summary, status, priority, and assignee; reassigning away from the viewer removes the stale assigned row. +5. The list excerpt and detail Markdown body use the same `text-text-1` content token; hierarchy comes from size and weight rather than mismatched foreground colors. +6. Assigned detail shows localized `Status` and `Priority` rows and no misleading `Assigned by` row when no assigner is known. +7. `Mark all as read` in the header marks **only the active filter's** unread items (Mentions view never marks Assigned, and vice versa). +8. Empty state copy is filter-specific (`No mentions` vs `Nothing assigned to you`), falling back to the generic empty copy for `All`. +9. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list. +10. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries. +11. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes. +12. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the managed-cloud receipt). Re-marking read still works after refresh or on another device. +13. When a source still has a next page, the list shows a `Load more` control—even when the active filter/search has no visible first-page result; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more. +14. Activating Retry after an initial load error calls the backing source's refresh boundary before reading a new snapshot; it never loops on the same failed cache entry. +15. Partial-source degradation uses a warning treatment and preserves readable results; a total failure uses the blocking error state. + +## Coordinator state machine + +| State | Entry | Visible behavior | Allowed transition | Ownership / persistence | +| -------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------- | +| Unavailable identity | Member files loaded but no exact viewer identity matches | Cloud results may remain visible; local assignment availability is explicitly degraded | Refresh after profile/account correction | Identity is derived; no guessed member id is persisted | +| Loading | New viewer/account/org scope or explicit refresh | Old scope is synchronously removed; the new scope shows loading | Success, partial success, empty, error, scope switch | Coordinator owns request generation and AbortController | +| Ready | Every requested source succeeds | Shared list, counts, cursors, filters and detail are usable | Load more, mutation, refresh, scope switch | Jotai cache is the canonical runtime snapshot | +| Empty | Successful sources return no rows | Filter-specific empty state; Load more stays available when a cursor exists | Load more or refresh | Empty is a successful snapshot, not an error | +| Partial success | At least one source/prerequisite succeeds and one degrades | Successful rows stay actionable under a localized warning | Retry, pagination of remaining cursors, scope switch | Successful source data replaces only that source's projection | +| Error / timeout | Every requested source fails or prerequisite loading fails | Blocking error only when no usable rows remain; retained rows otherwise stay visible | Retry invokes the real refresh boundary | Diagnostic details remain internal; UI maps issue codes to localized copy | +| Mutating | Read/unread operation enters the shared mutation queue | Snapshot updates optimistically once | Commit authoritative receipt, rollback, or supersede | Durable receipt is SQLite/cloud; optimistic state is coordinator-owned | +| Superseded | Scope generation changes or a newer same-item mutation starts | Late completion is ignored; cloud work is aborted best-effort | New scope/request continues | No stale completion may write the current snapshot | ## Unified Work Item thread @@ -43,7 +66,7 @@ | 3 | Activate `View live chat` / `View conversation` on a Session card. | A separate Session Chat Panel tab opens or the existing tab for that Session is focused. Team Inbox remains open as its singleton tab. | | 4 | Inspect a Work Item with proof of work and comments/history. | Output and activity render inline after the workflow; no second nested detail surface is introduced. | | 5 | Switch assigned rows while the first full Work Item is still loading. | A late response from the first row never replaces the newly selected Work Item. | -| 6 | Make two property changes in quick succession. | Only the newest response may replace the displayed Work Item snapshot; both writes use the canonical partial-update payload. | +| 6 | Make two property changes in quick succession. | Same-item writes run in invocation order through a bounded queue, so the final response contains both atomic partial updates and an older response cannot overwrite newer intent. | | 7 | Open a standalone assigned Work Item. | The thread remains readable, but edit controls/property rail are not exposed because standalone persistence requires the owning frontmatter round-trip. | | 8 | Fail the selected Work Item read. | A visible error placeholder is shown; the short list row remains available for retry/navigation. | | 9 | Open a project Work Item with a short description. | The description renders at its natural Markdown height. `Preview / Raw` and the editor are absent until `Edit` is activated. | @@ -57,6 +80,7 @@ | 17 | Compare the To-Do and Agent Workflow cards, then collapse Workflow. | Both cards share one Work Item thread visual shell; Workflow retains its existing collapse behavior and To-Do remains independently interactive. | | 18 | Open Assignee or Reviewer in a project-scoped Inbox Work Item. | The picker contains the complete active project roster, resolves stored member ids to names, and persists through the canonical partial-update boundary. | | 19 | Inspect creator, comments, and history written with stored member ids. | Known ids resolve to project-member names; unknown ids remain visible instead of being guessed or silently blanked. | +| 20 | Load a Work Item while its project or member context read fails. | The successfully loaded Work Item remains usable under a localized warning; only failure of the required Work Item read replaces it with an error state. | ### Unified thread acceptance criteria diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx index effb3d294e..24bef4996e 100644 --- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx +++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx @@ -1,8 +1,15 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import type { WorkItem } from "@src/types/core/workItem"; import { AssignedWorkItemDetail, @@ -12,11 +19,11 @@ import { import { type TeamInboxDataSource, type TeamInboxFilter, + type TeamInboxIssue, type TeamInboxItem, type TeamInboxNavigationIntent, type TeamInboxUnreadCounts, countUnreadTeamInboxItemsByFilter, - filterItemKind, getTeamInboxItemKey, searchTeamInboxItems, selectTeamInboxItems, @@ -28,6 +35,7 @@ export interface TeamInboxViewProps { onNavigate?: (intent: TeamInboxNavigationIntent) => void; initialFilter?: TeamInboxFilter; pageSize?: number; + viewerMemberIds?: readonly string[]; } const EMPTY_TEAM_INBOX_DATA_SOURCE: TeamInboxDataSource = { @@ -37,7 +45,7 @@ const EMPTY_TEAM_INBOX_DATA_SOURCE: TeamInboxDataSource = { }; interface LoadState { - status: "loading" | "ready" | "error"; + status: "loading" | "ready" | "warning" | "error"; message: string | null; } @@ -46,6 +54,7 @@ const TeamInboxView: React.FC = ({ onNavigate, initialFilter = "all", pageSize = 50, + viewerMemberIds = [], }) => { const { t } = useTranslation(); const [filter, setFilter] = useState(initialFilter); @@ -62,8 +71,27 @@ const TeamInboxView: React.FC = ({ const [reloadRevision, setReloadRevision] = useState(0); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); - const mutationEpochRef = useRef(0); - const mutationByItemRef = useRef(new Map()); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const issueMessage = useCallback( + (issue: TeamInboxIssue): string => { + if (issue.code === "identity_unresolved") { + return t("teamInbox.errors.identity"); + } + if (issue.code === "partial_load") { + return t("teamInbox.errors.partialLoad"); + } + return t("teamInbox.errors.load"); + }, + [t] + ); useEffect(() => { const abortController = new AbortController(); @@ -76,7 +104,17 @@ const TeamInboxView: React.FC = ({ setAuthoritativeUnreadCounts(page.unreadCounts ?? null); setRecencyAnchorMs(Date.now()); setHasMore(page.nextCursor != null); - setLoadState({ status: "ready", message: null }); + setLoadState( + page.loading + ? { status: "loading", message: null } + : page.issue + ? { + status: + page.issue.code === "partial_load" ? "warning" : "error", + message: issueMessage(page.issue), + } + : { status: "ready", message: null } + ); }) .catch((reason: unknown) => { if (abortController.signal.aborted) return; @@ -84,13 +122,18 @@ const TeamInboxView: React.FC = ({ status: "error", message: reason instanceof Error - ? reason.message + ? "issue" in reason && + reason.issue && + typeof reason.issue === "object" && + "code" in reason.issue + ? issueMessage(reason.issue as TeamInboxIssue) + : reason.message : t("teamInbox.errors.load"), }); }); return () => abortController.abort(); - }, [dataSource, pageSize, reloadRevision, t]); + }, [dataSource, issueMessage, pageSize, reloadRevision, t]); useEffect(() => { if (!dataSource.subscribe) return; @@ -121,26 +164,25 @@ const TeamInboxView: React.FC = ({ ? getTeamInboxItemKey(selectedItem) : null; - const retry = () => { - setLoadState({ status: "loading", message: null }); - setReloadRevision((value) => value + 1); - }; - const handleLoadMore = () => { if (!dataSource.loadMore || loadingMore) return; setLoadingMore(true); void dataSource .loadMore() - .catch((reason: unknown) => { + .then(() => { + if (mountedRef.current) { + setReloadRevision((value) => value + 1); + } + }) + .catch(() => { setLoadState({ status: "error", - message: - reason instanceof Error - ? reason.message - : t("teamInbox.errors.load"), + message: t("teamInbox.errors.loadMore"), }); }) - .finally(() => setLoadingMore(false)); + .finally(() => { + if (mountedRef.current) setLoadingMore(false); + }); }; const handleRefresh = () => { @@ -149,70 +191,25 @@ const TeamInboxView: React.FC = ({ setReloadRevision((value) => value + 1); return; } - void dataSource.refresh().catch((reason: unknown) => { - setLoadState({ - status: "error", - message: - reason instanceof Error - ? reason.message - : t("teamInbox.errors.refresh"), + void dataSource + .refresh() + .then(() => { + if (mountedRef.current) { + setReloadRevision((value) => value + 1); + } + }) + .catch(() => { + setLoadState({ + status: "error", + message: t("teamInbox.errors.refresh"), + }); }); - }); - }; - - const beginItemMutations = (itemIds: readonly string[]): number => { - const epoch = ++mutationEpochRef.current; - for (const itemId of itemIds) mutationByItemRef.current.set(itemId, epoch); - return epoch; - }; - - const isCurrentItemMutation = (itemId: string, epoch: number): boolean => - mutationByItemRef.current.get(itemId) === epoch; - - const updateUnreadCount = (kind: TeamInboxItem["kind"], delta: number) => { - setAuthoritativeUnreadCounts((current) => { - if (!current) return null; - const key = - kind === "comment_mention" - ? ("mentions" as const) - : ("assigned" as const); - const nextForKind = Math.max(0, current[key] + delta); - return { - ...current, - [key]: nextForKind, - all: Math.max(0, current.all + delta), - }; - }); - }; - - const markLocallyRead = (item: TeamInboxItem) => { - const readAt = new Date().toISOString(); - setItems((current) => - current.map((candidate) => - getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item) - ? { ...candidate, readAt } - : candidate - ) - ); }; const handleSelect = (item: TeamInboxItem) => { setRequestedItemId(getTeamInboxItemKey(item)); if (item.readAt !== null) return; - const epoch = beginItemMutations([item.id]); - markLocallyRead(item); - updateUnreadCount(item.kind, -1); void dataSource.markRead?.(item).catch(() => { - if (isCurrentItemMutation(item.id, epoch)) { - setItems((current) => - current.map((candidate) => - candidate.id === item.id - ? { ...candidate, readAt: null } - : candidate - ) - ); - updateUnreadCount(item.kind, 1); - } setLoadState({ status: "error", message: t("teamInbox.errors.markRead"), @@ -222,20 +219,7 @@ const TeamInboxView: React.FC = ({ const handleMarkRead = (item: TeamInboxItem) => { if (item.readAt !== null) return; - const epoch = beginItemMutations([item.id]); - markLocallyRead(item); - updateUnreadCount(item.kind, -1); void dataSource.markRead?.(item).catch(() => { - if (isCurrentItemMutation(item.id, epoch)) { - setItems((current) => - current.map((candidate) => - candidate.id === item.id - ? { ...candidate, readAt: null } - : candidate - ) - ); - updateUnreadCount(item.kind, 1); - } setLoadState({ status: "error", message: t("teamInbox.errors.markRead"), @@ -245,27 +229,7 @@ const TeamInboxView: React.FC = ({ const handleMarkUnread = (item: TeamInboxItem) => { if (item.readAt === null) return; - const previousReadAt = item.readAt; - const epoch = beginItemMutations([item.id]); - setItems((current) => - current.map((candidate) => - getTeamInboxItemKey(candidate) === getTeamInboxItemKey(item) - ? { ...candidate, readAt: null } - : candidate - ) - ); - updateUnreadCount(item.kind, 1); void dataSource.markUnread?.(item).catch(() => { - if (isCurrentItemMutation(item.id, epoch)) { - setItems((current) => - current.map((candidate) => - candidate.id === item.id - ? { ...candidate, readAt: previousReadAt } - : candidate - ) - ); - updateUnreadCount(item.kind, -1); - } setLoadState({ status: "error", message: t("teamInbox.errors.markUnread"), @@ -274,12 +238,6 @@ const TeamInboxView: React.FC = ({ }; const handleMarkAllRead = () => { - const targetKind = filterItemKind(filter); - const unreadItems = items.filter( - (item) => - item.readAt === null && - (targetKind === null || item.kind === targetKind) - ); const filterUnreadCount = filter === "all" ? unreadCounts.all @@ -287,45 +245,7 @@ const TeamInboxView: React.FC = ({ ? unreadCounts.mentions : unreadCounts.assigned; if (filterUnreadCount === 0) return; - const readAt = new Date().toISOString(); - const affectedItems = items.filter( - (item) => targetKind === null || item.kind === targetKind - ); - const previousReadAtById = new Map( - affectedItems.map((item) => [item.id, item.readAt]) - ); - const affectedIds = affectedItems.map((item) => item.id); - const epoch = beginItemMutations(affectedIds); - const previousCounts = authoritativeUnreadCounts; - const markedIds = new Set(affectedIds); - setItems((current) => - current.map((item) => - markedIds.has(item.id) ? { ...item, readAt } : item - ) - ); - setAuthoritativeUnreadCounts((current) => { - if (!current) return null; - const assigned = - filter === "all" || filter === "assigned" ? 0 : current.assigned; - const mentions = - filter === "all" || filter === "mentions" ? 0 : current.mentions; - return { all: assigned + mentions, assigned, mentions }; - }); - void dataSource.markAllRead?.(unreadItems, filter).catch(() => { - setItems((current) => - current.map((item) => - isCurrentItemMutation(item.id, epoch) && - previousReadAtById.has(item.id) - ? { - ...item, - readAt: previousReadAtById.get(item.id) ?? null, - } - : item - ) - ); - if (affectedIds.every((itemId) => isCurrentItemMutation(itemId, epoch))) { - setAuthoritativeUnreadCounts(previousCounts); - } + void dataSource.markAllRead?.([], filter).catch(() => { setLoadState({ status: "error", message: t("teamInbox.errors.markAllRead"), @@ -333,6 +253,53 @@ const TeamInboxView: React.FC = ({ }); }; + const handleWorkItemUpdated = useCallback( + (sourceItem: TeamInboxItem, workItem: WorkItem) => { + if (sourceItem.kind !== "assigned_work_item") return; + const sourceKey = getTeamInboxItemKey(sourceItem); + const assignee = workItem.assignee; + const belongsToViewer = assignee + ? viewerMemberIds.length > 0 + ? viewerMemberIds.includes(assignee.id) + : assignee.id === sourceItem.payload.assigneeMemberId + : false; + const status = + workItem.workItemStatus ?? workItem.status ?? sourceItem.payload.status; + const updatedAt = workItem.updated_time || sourceItem.payload.updatedAt; + const nextItem: TeamInboxItem | null = + assignee && belongsToViewer + ? { + ...sourceItem, + occurredAt: updatedAt, + payload: { + ...sourceItem.payload, + title: workItem.name || sourceItem.payload.title, + status, + priority: workItem.priority ?? sourceItem.payload.priority, + assigneeMemberId: assignee.id, + assigneeName: assignee.name, + summary: workItem.spec?.trim() || undefined, + updatedAt, + }, + } + : null; + if (dataSource.reconcileItem) { + dataSource.reconcileItem(sourceKey, nextItem); + return; + } + setItems((current) => + current.flatMap((candidate) => + getTeamInboxItemKey(candidate) === sourceKey + ? nextItem + ? [nextItem] + : [] + : [candidate] + ) + ); + }, + [dataSource, viewerMemberIds] + ); + const detail = (() => { if (loadState.status === "loading") { return ( @@ -351,7 +318,7 @@ const TeamInboxView: React.FC = ({ placement="detail-panel" title={t("teamInbox.errors.loadTitle")} subtitle={loadState.message ?? undefined} - action={{ label: t("common:actions.retry"), onClick: retry }} + action={{ label: t("common:actions.retry"), onClick: handleRefresh }} fillParentHeight /> ); @@ -387,24 +354,33 @@ const TeamInboxView: React.FC = ({ onMarkRead={dataSource.markRead ? handleMarkRead : undefined} onMarkUnread={dataSource.markUnread ? handleMarkUnread : undefined} onNavigate={onNavigate} + onWorkItemUpdated={(workItem) => + handleWorkItemUpdated(selectedItem, workItem) + } /> ); })(); return ( -
- {loadState.status === "error" && items.length > 0 ? ( +
+ {(loadState.status === "error" || loadState.status === "warning") && + items.length > 0 ? (
{loadState.message}
) : null} = ({ variant="error" title={t("teamInbox.errors.loadTitle")} subtitle={loadState.message ?? undefined} - action={{ label: t("common:actions.retry"), onClick: retry }} + action={{ + label: t("common:actions.retry"), + onClick: handleRefresh, + }} fillParentHeight /> ) : ( diff --git a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts index fd347a904d..38b2db2175 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts @@ -52,33 +52,46 @@ vi.mock("../useTeamInboxWorkItem", () => ({ useTeamInboxWorkItem: () => ({ workItem: mocks.workItem, status: "ready", - error: null, + issue: null, repoPath: "/repo", members: [], + currentUser: { + id: "user-ea821852", + name: "hanafish", + avatar: "https://example.com/hanafish.png", + color: "#52c41a", + }, updateWorkItem: vi.fn(), refreshWorkItem: vi.fn(), }), })); vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({ - WorkItemProperties: ({ pillLayout }: { pillLayout?: string }) => - createElement("div", { - "data-testid": "work-item-properties", - "data-pill-layout": pillLayout, - }), - WorkItemContent: ({ + WorkItemThreadSurface: ({ onStartAgent, onOpenSession, - headerProperties, + propertyProps, + currentUser, }: { onStartAgent?: () => void; onOpenSession?: (sessionId: string) => void; - headerProperties?: React.ReactNode; + propertyProps?: Record; + currentUser?: { id: string; name: string; avatar?: string }; }) => createElement( "div", - null, - headerProperties, + { + "data-testid": "work-item-content", + "data-current-user-id": currentUser?.id, + "data-current-user-name": currentUser?.name, + "data-current-user-avatar": currentUser?.avatar, + }, + propertyProps + ? createElement("div", { + "data-testid": "work-item-properties", + "data-property-configured": "true", + }) + : null, createElement( "button", { @@ -176,7 +189,7 @@ describe("AssignedWorkItemDetail navigation actions", () => { }); }); - it("uses the responsive wrapping layout for constrained property pills", () => { + it("provides editable properties to the shared thread surface", () => { act(() => { root.render(createElement(AssignedWorkItemDetail, { item })); }); @@ -184,8 +197,23 @@ describe("AssignedWorkItemDetail navigation actions", () => { expect( container .querySelector("[data-testid='work-item-properties']") - ?.getAttribute("data-pill-layout") - ).toBe("wrap"); + ?.getAttribute("data-property-configured") + ).toBe("true"); + }); + + it("passes one resolved identity to the comment composer and history surface", () => { + act(() => { + root.render(createElement(AssignedWorkItemDetail, { item })); + }); + + const content = container.querySelector( + "[data-testid='work-item-content']" + ); + expect(content?.getAttribute("data-current-user-id")).toBe("user-ea821852"); + expect(content?.getAttribute("data-current-user-name")).toBe("hanafish"); + expect(content?.getAttribute("data-current-user-avatar")).toBe( + "https://example.com/hanafish.png" + ); }); it("preserves linked-session navigation as a distinct Session tab intent", () => { diff --git a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md index 031f5a960c..34f3f4b950 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md +++ b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md @@ -12,10 +12,10 @@ Behavior is derived from the shipped implementation, not aspirational. - Local source page size is 50 (`listLocalTeamInboxPage(..., 50)`); cloud mentions page size is 50 (`listTeamInboxMentions(..., 50)`). - `hasMore` is surfaced to the view via `listPage().nextCursor != null`; the - cursor value itself is an inert sentinel — the data source owns the real - per-source cursors (`localCursorRef` / `cloudCursorRef`). -- The load-more control renders only inside the non-empty list branch, at the - bottom of the scroll area, when `hasMore === true` and `onLoadMore` is defined. + cursor value itself is an inert sentinel — the per-store coordinator owns the + real local/cloud cursors shared by Sidebar and full Inbox consumers. +- The load-more control renders whenever `hasMore === true` and `onLoadMore` is + defined, including filter/search empty-result states. ## Happy Path @@ -23,7 +23,7 @@ Behavior is derived from the shipped implementation, not aspirational. | --- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. | | 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. | -| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both `localCursorRef` and `cloudCursorRef` are null, `hasMore` becomes false and the button disappears. | +| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both shared coordinator cursors are null, `hasMore` becomes false and the button disappears. | | 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). | | 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. | @@ -31,11 +31,11 @@ Behavior is derived from the shipped implementation, not aspirational. | # | Scenario | Steps | Expected Result | | --- | ------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Empty inbox | Open inbox with 0 items. | Empty `Placeholder` renders; **no** "Load more" button (it lives in the items-present branch). | +| 1 | Empty first page with a remaining cursor | Open an active filter/search with 0 visible items and `hasMore`. | Empty `Placeholder` renders together with "Load more", so a matching later page remains reachable. | | 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. | | 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. | | 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). | -| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | `loadingMoreRef` guard + `disabled={loadingMore}` ensure only one in-flight load; extra clicks are no-ops; no duplicated/skipped pages. | +| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | View loading state plus the coordinator single-flight promise ensure only one in-flight load; extra clicks reuse/no-op; no duplicated/skipped pages. | | 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. | | 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. | | 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). | @@ -43,18 +43,19 @@ Behavior is derived from the shipped implementation, not aspirational. ## Error / Degraded States -| # | Scenario | Steps | Expected Result | -| --- | ---------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Caught inside `loadMore` (`.catch(() => ({ mentions: [], nextCursor: undefined }))`); cloud cursor becomes null (cloud pagination stops); local page still appends; no crash. | -| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | `Promise.all` rejects → `handleLoadMore` catch sets the error banner (`teamInbox.errors.load`); `loadingMore` resets via `finally`; existing items remain. | -| 3 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. | -| 4 | Signed-out / no active cloud org | Only local paginates. | Cloud branch resolves to empty; only local advances; behavior identical to Edge #3. | +| # | Scenario | Steps | Expected Result | +| --- | ---------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Local page still commits; cloud cursor is preserved for retry; a localized partial-success warning appears. | +| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | Cloud page still commits; local cursor is preserved for retry; a localized partial-success warning appears. | +| 3 | Every requested source fails | Both active source reads reject. | Existing rows remain; load-more rejects to the view, which shows localized non-blocking error copy and resets loading in `finally`. | +| 4 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. | +| 5 | Signed-out / no active cloud org | Only local paginates. | No cloud request is started; only local advances. | ## Accessibility - [ ] "Load more" uses the design-system `Button` (keyboard focusable, Enter/Space activate). - [ ] While loading, the button is `disabled` and shows `loading` state (no double submit). -- [ ] The button has a visible localized label (`teamInbox.loadMore`, defaultValue "Load more") — no raw i18n key leaks. +- [ ] The button has a visible localized label (`teamInbox.loadMore`) — no raw fallback string or i18n key leaks. - [ ] Load-more does not steal focus from the list; existing roving-tabindex list navigation is unaffected. ## Acceptance Criteria @@ -63,7 +64,7 @@ Behavior is derived from the shipped implementation, not aspirational. - [ ] `hasMore` accurately reflects "either source has a next page" and the button visibility follows it. - [ ] Appended pages are de-duplicated and correctly ordered by the view selectors. - [ ] Concurrent/rapid load-more is guarded (single in-flight request). -- [ ] A cloud failure degrades gracefully (local still paginates); a local failure surfaces a non-blocking error banner without losing loaded items. +- [ ] Either source may fail independently; the successful source still paginates, the failed cursor remains retryable, and loaded items are preserved. - [ ] Load-more never derives the badge from the loaded window; the server's authoritative mention count remains unchanged until a read mutation succeeds. - [ ] `pnpm test` for `src/modules/MainApp/TeamInbox` passes; no new TypeScript/lint errors in edited files. @@ -71,7 +72,6 @@ Behavior is derived from the shipped implementation, not aspirational. - The unread badge uses the cloud RPC's authoritative full-result count, so unread mentions on page 2+ are included before those rows are loaded. -- Hook-level behavior is not unit-tested (repo policy forbids `.tsx` / React - Testing Library tests); pure logic is covered by `selectors.test.ts` - (dedupe/sort/select), while the two-instance rendered cloud spec covers the - production mention picker and durable read-receipt path. +- Coordinator behavior is unit-tested at the shared Jotai-store seam for cursor + continuity, partial failure, scope switching, optimistic rollback and cache + bounds. Component composition tests cover empty-result pagination and retry. diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts new file mode 100644 index 0000000000..a6ccbea8ee --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts @@ -0,0 +1,44 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import TeamInboxList from "../components/TeamInboxList"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +function renderEmptyList(query: string): string { + return renderToStaticMarkup( + createElement(TeamInboxList, { + filter: "all", + items: [], + recencyAnchorMs: Date.UTC(2026, 6, 28), + selectedItemId: null, + totalUnread: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, + query, + loading: false, + onQueryChange: vi.fn(), + onFilterChange: vi.fn(), + onSelectItem: vi.fn(), + hasMore: true, + onLoadMore: vi.fn(), + }) + ); +} + +describe("TeamInboxList pagination", () => { + it("keeps Load more reachable when the current search has no visible rows", () => { + const markup = renderEmptyList("missing"); + + expect(markup).toContain("teamInbox.empty.noResults.title"); + expect(markup).toContain("teamInbox.loadMore"); + }); + + it("does not point assistive technology at an unmounted active row", () => { + expect(renderEmptyList("")).not.toContain("aria-activedescendant"); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts new file mode 100644 index 0000000000..d0987769b1 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import TeamInboxRow from "../components/TeamInboxRow"; +import type { AssignedWorkItem } from "../domain"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? _key, + }), +})); + +const assignedItem: AssignedWorkItem = { + id: "assigned-1", + kind: "assigned_work_item", + occurredAt: new Date().toISOString(), + readAt: "2026-07-28T00:00:00.000Z", + actor: { id: "member-1", displayName: "Yuki" }, + target: { kind: "work_item", projectId: "demo", workItemId: "AAA-0001" }, + payload: { + title: "验收 Team Inbox 的真实分配与已读流程", + status: "todo", + priority: "medium", + assigneeMemberId: "member-1", + assigneeName: "Yuki", + summary: + "## 验收目标\\n- 在 Team Inbox 的“全部”和“分配给我”中看到此事项\\n- 打开详情并标记已读", + updatedAt: "2026-07-28T00:00:00.000Z", + }, +}; + +describe("TeamInboxRow", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("renders a compact plain-text excerpt and useful Work Item metadata", () => { + act(() => { + root.render( + createElement(TeamInboxRow, { + item: assignedItem, + itemKey: "assigned_work_item:assigned-1", + selected: true, + onSelect: vi.fn(), + }) + ); + }); + + const summary = container.querySelector("[title]"); + expect(summary?.textContent).toBe( + "验收目标 在 Team Inbox 的“全部”和“分配给我”中看到此事项 打开详情并标记已读" + ); + expect(summary?.textContent).not.toContain("\\n"); + expect(summary?.textContent).not.toContain("##"); + expect(summary?.className).toContain("max-h-10"); + expect(summary?.className).toContain("text-text-1"); + expect(container.textContent).toContain("Todo · Medium"); + expect(container.textContent).not.toContain("Yuki"); + }); + + it("omits the excerpt row when an assigned item has no summary", () => { + act(() => { + root.render( + createElement(TeamInboxRow, { + item: { + ...assignedItem, + payload: { ...assignedItem.payload, summary: undefined }, + }, + itemKey: "assigned_work_item:assigned-1", + selected: false, + onSelect: vi.fn(), + }) + ); + }); + + expect(container.querySelector("[title]")).toBeNull(); + expect(container.textContent).toContain("Todo · Medium"); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts index 41599e518f..e888e42931 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { act, createElement } from "react"; +import React, { act, createElement } from "react"; import { type Root, createRoot } from "react-dom/client"; import { afterAll, @@ -12,33 +12,56 @@ import { vi, } from "vitest"; +import type { WorkItem } from "@src/types/core/workItem"; + import TeamInboxView from "../TeamInboxView"; +import type { AssignedWorkItem } from "../domain"; const splitViewProps = vi.hoisted(() => ({ current: null as Record | null, })); +const componentProps = vi.hoisted(() => ({ + assignedDetail: null as Record | null, + list: null as Record | null, + placeholder: null as Record | null, +})); +const translate = vi.hoisted(() => vi.fn((key: string) => key)); vi.mock("react-i18next", () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: translate, }), })); vi.mock("@src/modules/shared/layouts/SplitViewLayout", () => ({ default: (props: Record) => { splitViewProps.current = props; - return createElement("div", { "data-testid": "team-inbox-split" }); + return createElement( + "div", + { "data-testid": "team-inbox-split" }, + props.listContent as React.ReactNode, + props.mainContent as React.ReactNode + ); }, })); vi.mock("@src/modules/shared/layouts/blocks", () => ({ - Placeholder: () => null, + Placeholder: (props: Record) => { + componentProps.placeholder = props; + return null; + }, })); vi.mock("../components", () => ({ - AssignedWorkItemDetail: () => null, + AssignedWorkItemDetail: (props: Record) => { + componentProps.assignedDetail = props; + return null; + }, CommentMentionDetail: () => null, - TeamInboxList: () => null, + TeamInboxList: (props: Record) => { + componentProps.list = props; + return null; + }, })); describe("TeamInboxView split layout", () => { @@ -54,6 +77,9 @@ describe("TeamInboxView split layout", () => { beforeEach(() => { splitViewProps.current = null; + componentProps.assignedDetail = null; + componentProps.list = null; + componentProps.placeholder = null; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -84,4 +110,116 @@ describe("TeamInboxView split layout", () => { true ); }); + + it("projects successful detail edits back into the matching Inbox row", async () => { + const assignedItem: AssignedWorkItem = { + id: "assigned-1", + kind: "assigned_work_item", + occurredAt: "2026-07-28T00:00:00.000Z", + readAt: "2026-07-28T00:01:00.000Z", + actor: { id: "member-1", displayName: "Yuki" }, + target: { + kind: "work_item", + projectId: "demo", + workItemId: "AAA-0001", + }, + payload: { + title: "Old title", + status: "todo", + priority: "medium", + assigneeMemberId: "member-1", + assigneeName: "Yuki", + summary: "Old summary", + updatedAt: "2026-07-28T00:00:00.000Z", + }, + }; + + await act(async () => { + root.render( + createElement(TeamInboxView, { + dataSource: { + listPage: async () => ({ + items: [assignedItem], + nextCursor: null, + }), + }, + }) + ); + await Promise.resolve(); + }); + + const onWorkItemUpdated = componentProps.assignedDetail + ?.onWorkItemUpdated as ((workItem: WorkItem) => void) | undefined; + expect(onWorkItemUpdated).toBeTypeOf("function"); + + const updatedWorkItem: WorkItem = { + session_id: "AAA-0001", + user_id: "member-1", + name: "Updated title", + status: "in_review", + workItemStatus: "in_review", + priority: "high", + spec: "## Updated summary", + assignee: { id: "member-1", name: "Yuki" }, + star: false, + target_date: null, + created_time: "2026-07-28T00:00:00.000Z", + updated_time: "2026-07-28T00:05:00.000Z", + linkedSessions: [], + todos: [], + }; + + act(() => onWorkItemUpdated?.(updatedWorkItem)); + + const updatedItems = componentProps.list?.items as AssignedWorkItem[]; + expect(updatedItems[0].payload).toMatchObject({ + title: "Updated title", + status: "in_review", + priority: "high", + assigneeMemberId: "member-1", + assigneeName: "Yuki", + summary: "## Updated summary", + updatedAt: "2026-07-28T00:05:00.000Z", + }); + + act(() => + onWorkItemUpdated?.({ + ...updatedWorkItem, + assignee: { id: "member-2", name: "Lin" }, + }) + ); + + expect(componentProps.list?.items).toEqual([]); + }); + + it("retries the backing source instead of rereading a failed snapshot", async () => { + const listPage = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce({ items: [], nextCursor: null }); + const refresh = vi.fn(async () => undefined); + + await act(async () => { + root.render( + createElement(TeamInboxView, { + dataSource: { listPage, refresh }, + }) + ); + await Promise.resolve(); + }); + + const action = componentProps.placeholder?.action as + | { onClick?: () => void } + | undefined; + expect(action?.onClick).toBeTypeOf("function"); + + await act(async () => { + action?.onClick?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(refresh).toHaveBeenCalledOnce(); + expect(listPage).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts new file mode 100644 index 0000000000..4e0bd67011 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts @@ -0,0 +1,385 @@ +import { createStore } from "jotai"; +import { describe, expect, it, vi } from "vitest"; + +import type { TeamInboxMention } from "@src/features/Org2Cloud/teamInboxMentionsClient"; + +import type { AssignedWorkItem } from "../domain"; +import { teamInboxCacheAtom } from "../store"; +import { + TEAM_INBOX_CACHE_LIMIT, + TeamInboxCoordinator, + type TeamInboxCoordinatorDependencies, + type TeamInboxCoordinatorScope, +} from "../teamInboxCoordinator"; + +function assignedItem( + id: string, + occurredAt = "2026-07-28T10:00:00.000Z" +): AssignedWorkItem { + return { + id, + kind: "assigned_work_item", + occurredAt, + readAt: null, + actor: { id: "assigner", displayName: "Assigner" }, + target: { + kind: "work_item", + projectId: "project-1", + workItemId: id, + }, + payload: { + title: id, + status: "todo", + priority: "medium", + assigneeMemberId: "viewer-1", + updatedAt: occurredAt, + }, + }; +} + +function mention(id: string): TeamInboxMention { + return { + comment: { id }, + session: { id: `session-${id}` }, + author: { userId: "author-1" }, + body: `Mention ${id}`, + createdAt: "2026-07-28T11:00:00.000Z", + readAt: null, + commentCount: 1, + threadCount: 1, + }; +} + +function dependencies( + overrides: Partial = {} +): TeamInboxCoordinatorDependencies { + return { + listLocalPage: vi.fn(async () => ({ + page: { items: [], nextCursor: null }, + unreadCount: 0, + })), + listInitialMentions: vi.fn(async () => ({ + mentions: [], + unreadCount: 0, + })), + listMentions: vi.fn(async () => ({ + mentions: [], + unreadCount: 0, + })), + markLocalRead: vi.fn(async () => true), + markLocalUnread: vi.fn(async () => true), + markAllLocalRead: vi.fn(async () => 0), + setMentionRead: vi.fn(async () => ({ + readAt: "2026-07-28T12:00:00.000Z", + unreadCount: 0, + })), + markAllMentionsRead: vi.fn(async () => ({ + readAt: "2026-07-28T12:00:00.000Z", + unreadCount: 0, + })), + now: () => "2026-07-28T12:00:00.000Z", + ...overrides, + }; +} + +function scope( + overrides: Partial = {} +): TeamInboxCoordinatorScope { + return { + key: "viewer-1::local", + viewerMemberIds: ["viewer-1"], + accessToken: null, + activeCloudOrgId: null, + members: [], + ...overrides, + }; +} + +describe("TeamInboxCoordinator", () => { + it("shares the first-page cursor across consumers using the same store", async () => { + const firstCursor = { + occurredAt: "2026-07-28T10:00:00.000Z", + itemKey: "assigned_work_item:first", + }; + const listLocalPage = vi + .fn() + .mockResolvedValueOnce({ + page: { items: [assignedItem("first")], nextCursor: firstCursor }, + unreadCount: 2, + }) + .mockResolvedValueOnce({ + page: { + items: [assignedItem("second", "2026-07-28T09:00:00.000Z")], + nextCursor: null, + }, + unreadCount: 2, + }); + const coordinator = new TeamInboxCoordinator( + dependencies({ listLocalPage }) + ); + const store = createStore(); + const viewerScope = scope(); + + await coordinator.refresh(store, viewerScope, "version-1"); + await coordinator.loadMore(store, viewerScope); + + expect(listLocalPage).toHaveBeenNthCalledWith( + 2, + ["viewer-1"], + "all", + firstCursor + ); + expect(store.get(teamInboxCacheAtom).items.map((item) => item.id)).toEqual([ + "first", + "second", + ]); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(false); + }); + + it("publishes a usable partial snapshot when one source fails", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async () => ({ + page: { items: [assignedItem("local")], nextCursor: null }, + unreadCount: 1, + })), + listInitialMentions: vi.fn(async () => { + throw new Error("cloud unavailable"); + }), + }) + ); + const store = createStore(); + + await coordinator.refresh( + store, + scope({ + key: "viewer-1::org-1", + accessToken: "token", + activeCloudOrgId: "org-1", + }), + "version-1" + ); + + expect(store.get(teamInboxCacheAtom)).toMatchObject({ + unreadCount: 1, + unreadCounts: { all: 1, assigned: 1, mentions: 0 }, + issue: { code: "partial_load", detail: "cloud unavailable" }, + }); + expect(store.get(teamInboxCacheAtom).items).toHaveLength(1); + }); + + it("keeps cloud results visible while reporting an unresolved local identity", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listInitialMentions: vi.fn(async () => ({ + mentions: [mention("cloud-1")], + unreadCount: 1, + })), + }) + ); + const store = createStore(); + + await coordinator.refresh( + store, + scope({ + key: "::org-1", + viewerMemberIds: [], + accessToken: "token", + activeCloudOrgId: "org-1", + members: [ + { + id: "someone-else", + name: "Someone Else", + email: "else@example.com", + active: true, + }, + ], + }), + "version-1" + ); + + expect(store.get(teamInboxCacheAtom).issue?.code).toBe( + "identity_unresolved" + ); + expect(store.get(teamInboxCacheAtom).items).toHaveLength(1); + }); + + it("keeps a failed source cursor retryable while appending a successful page", async () => { + const localCursor = { + occurredAt: "2026-07-28T10:00:00.000Z", + itemKey: "assigned_work_item:first", + }; + const listLocalPage = vi + .fn() + .mockResolvedValueOnce({ + page: { items: [assignedItem("first")], nextCursor: localCursor }, + unreadCount: 2, + }) + .mockResolvedValueOnce({ + page: { + items: [assignedItem("second", "2026-07-28T09:00:00.000Z")], + nextCursor: null, + }, + unreadCount: 2, + }); + const listMentions = vi + .fn() + .mockRejectedValueOnce(new Error("temporary cloud failure")) + .mockResolvedValueOnce({ + mentions: [mention("cloud-2")], + unreadCount: 2, + }); + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage, + listInitialMentions: vi.fn(async () => ({ + mentions: [mention("cloud-1")], + nextCursor: "cloud-cursor", + unreadCount: 2, + })), + listMentions, + }) + ); + const store = createStore(); + const viewerScope = scope({ + key: "viewer-1::org-1", + accessToken: "token", + activeCloudOrgId: "org-1", + }); + + await coordinator.refresh(store, viewerScope, "version-1"); + await coordinator.loadMore(store, viewerScope); + + expect(store.get(teamInboxCacheAtom).issue?.code).toBe("partial_load"); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(true); + expect( + store.get(teamInboxCacheAtom).items.map((item) => item.id) + ).toContain("second"); + + await coordinator.loadMore(store, viewerScope); + + expect(listMentions).toHaveBeenNthCalledWith( + 2, + "token", + "org-1", + "cloud-cursor", + 50, + expect.any(AbortSignal) + ); + expect( + store.get(teamInboxCacheAtom).items.map((item) => item.id) + ).toContain("cloud-comment:org-1:cloud-2"); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(false); + }); + + it("ignores a late response after the viewer scope changes", async () => { + let resolveOldCloud: + | ((value: { mentions: TeamInboxMention[]; unreadCount: number }) => void) + | undefined; + const oldCloud = new Promise<{ + mentions: TeamInboxMention[]; + unreadCount: number; + }>((resolve) => { + resolveOldCloud = resolve; + }); + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async (viewerIds) => ({ + page: { + items: [assignedItem(viewerIds[0] ?? "unknown")], + nextCursor: null, + }, + unreadCount: 1, + })), + listInitialMentions: vi + .fn() + .mockImplementationOnce(async () => oldCloud) + .mockResolvedValueOnce({ mentions: [], unreadCount: 0 }), + }) + ); + const store = createStore(); + const oldScope = scope({ + key: "viewer-1::org-1", + accessToken: "token", + activeCloudOrgId: "org-1", + }); + const nextScope = scope({ + key: "viewer-2::org-2", + viewerMemberIds: ["viewer-2"], + accessToken: "token", + activeCloudOrgId: "org-2", + }); + + const staleRefresh = coordinator.refresh(store, oldScope, "version-1"); + await coordinator.refresh(store, nextScope, "version-1"); + resolveOldCloud?.({ mentions: [mention("stale")], unreadCount: 1 }); + await staleRefresh; + + expect(store.get(teamInboxCacheAtom).loadedForViewerKey).toBe( + "viewer-2::org-2" + ); + expect(store.get(teamInboxCacheAtom).items.map((item) => item.id)).toEqual([ + "viewer-2", + ]); + }); + + it("rolls back an optimistic read mutation when persistence fails", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async () => ({ + page: { items: [assignedItem("first")], nextCursor: null }, + unreadCount: 1, + })), + markLocalRead: vi.fn(async () => { + throw new Error("write failed"); + }), + }) + ); + const store = createStore(); + const viewerScope = scope(); + await coordinator.refresh(store, viewerScope, "version-1"); + const item = store.get(teamInboxCacheAtom).items[0]; + + const mutation = coordinator.markRead(store, viewerScope, item); + expect(store.get(teamInboxCacheAtom).items[0].readAt).toBe( + "2026-07-28T12:00:00.000Z" + ); + expect(store.get(teamInboxCacheAtom).unreadCount).toBe(0); + + await expect(mutation).rejects.toThrow("write failed"); + expect(store.get(teamInboxCacheAtom).items[0].readAt).toBeNull(); + expect(store.get(teamInboxCacheAtom).unreadCount).toBe(1); + }); + + it("caps retained rows and closes cursors at the cache boundary", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async () => ({ + page: { + items: Array.from( + { length: TEAM_INBOX_CACHE_LIMIT + 25 }, + (_, index) => + assignedItem( + `item-${index}`, + new Date(Date.UTC(2026, 6, 28, 12, 0, index)).toISOString() + ) + ), + nextCursor: { + occurredAt: "2026-07-28T00:00:00.000Z", + itemKey: "more", + }, + }, + unreadCount: TEAM_INBOX_CACHE_LIMIT + 25, + })), + }) + ); + const store = createStore(); + + await coordinator.refresh(store, scope(), "version-1"); + + expect(store.get(teamInboxCacheAtom).items).toHaveLength( + TEAM_INBOX_CACHE_LIMIT + ); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(false); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts new file mode 100644 index 0000000000..6f1bbe50dd --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { WorkItem } from "@src/types/core/workItem"; + +import { + type TeamInboxWorkItemState, + useTeamInboxWorkItem, +} from "../useTeamInboxWorkItem"; + +const mocks = vi.hoisted(() => ({ + readWorkItem: vi.fn(), + readProject: vi.fn(), + readMembers: vi.fn(), + readStandaloneWorkItem: vi.fn(), + updateWorkItemPartial: vi.fn(), +})); + +vi.mock("@src/api/http/project", () => ({ + projectApi: { + readWorkItem: mocks.readWorkItem, + readProject: mocks.readProject, + readMembers: mocks.readMembers, + readStandaloneWorkItem: mocks.readStandaloneWorkItem, + updateWorkItemPartial: mocks.updateWorkItemPartial, + }, + standaloneWorkItemDataToEnriched: (value: unknown) => value, + enrichedWorkItemToUI: (value: unknown) => value, +})); + +vi.mock("@src/hooks/project/useCurrentUserMemberId", () => ({ + useCurrentUserMemberIds: () => ({ currentUser: null }), +})); + +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ warn: vi.fn() }), +})); + +vi.mock("@src/modules/ProjectManager/WorkItems/workItemPartialUpdate", () => ({ + toWorkItemPartialUpdate: (value: unknown) => value, +})); + +const WORK_ITEM: WorkItem = { + session_id: "AAA-0001", + user_id: "member-1", + name: "Inbox item", + status: "planned", + workItemStatus: "planned", + priority: "medium", + spec: "Body", + assignee: { id: "member-1", name: "Ada" }, + star: false, + target_date: null, + created_time: "2026-07-28T00:00:00.000Z", + updated_time: "2026-07-28T00:00:00.000Z", + linkedSessions: [], + todos: [], +}; + +let latestState: TeamInboxWorkItemState | null = null; + +function Probe() { + const state = useTeamInboxWorkItem({ + kind: "work_item", + projectId: "demo", + workItemId: "AAA-0001", + }); + useEffect(() => { + latestState = state; + }, [state]); + return null; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +describe("useTeamInboxWorkItem", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + latestState = null; + vi.clearAllMocks(); + mocks.readWorkItem.mockResolvedValue(WORK_ITEM); + mocks.readProject.mockResolvedValue({ + slug: "demo", + meta: { name: "Demo", linked_repos: [] }, + }); + mocks.readMembers.mockResolvedValue({ members: [] }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps the Work Item usable when optional project context fails", async () => { + mocks.readMembers.mockRejectedValueOnce(new Error("members unavailable")); + + await act(async () => { + root.render(createElement(Probe)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(latestState).toMatchObject({ + status: "ready", + workItem: WORK_ITEM, + members: [], + issue: "context_unavailable", + }); + }); + + it("uses the blocking state only when the required Work Item read fails", async () => { + mocks.readWorkItem.mockRejectedValueOnce(new Error("item unavailable")); + + await act(async () => { + root.render(createElement(Probe)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(latestState).toMatchObject({ + status: "error", + workItem: null, + issue: "load_failed", + }); + expect(mocks.readProject).not.toHaveBeenCalled(); + expect(mocks.readMembers).not.toHaveBeenCalled(); + }); + + it("serializes same-item updates so response order follows user intent", async () => { + const first = deferred(); + const second = deferred(); + mocks.updateWorkItemPartial + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + await act(async () => { + root.render(createElement(Probe)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + act(() => { + latestState?.updateWorkItem({ workItemStatus: "in_review" }); + latestState?.updateWorkItem({ priority: "high" }); + }); + await Promise.resolve(); + expect(mocks.updateWorkItemPartial).toHaveBeenCalledTimes(1); + + await act(async () => { + first.resolve({ + ...WORK_ITEM, + status: "in_review", + workItemStatus: "in_review", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.updateWorkItemPartial).toHaveBeenCalledTimes(2); + + await act(async () => { + second.resolve({ + ...WORK_ITEM, + status: "in_review", + workItemStatus: "in_review", + priority: "high", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(latestState?.workItem).toMatchObject({ + workItemStatus: "in_review", + priority: "high", + }); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/api.ts b/src/modules/MainApp/TeamInbox/api.ts index db1787615f..8e06deae5a 100644 --- a/src/modules/MainApp/TeamInbox/api.ts +++ b/src/modules/MainApp/TeamInbox/api.ts @@ -75,7 +75,7 @@ function mapWireItem(item: TeamInboxWireItem): TeamInboxItem { const occurredAt = new Date(item.occurredAt).toISOString(); const actor = item.actor ?? { id: "system", - displayName: "Team Inbox", + displayName: "", }; if ( diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx index afcce63421..6a3ef79cc6 100644 --- a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx +++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx @@ -2,11 +2,7 @@ import { ClipboardList, ExternalLink } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; -import { - WorkItemContent, - WorkItemProperties, -} from "@src/modules/ProjectManager/WorkItems/components"; -import type { WorkItemPropertyFieldKey } from "@src/modules/ProjectManager/WorkItems/components/WorkItemProperties/types"; +import { WorkItemThreadSurface } from "@src/modules/ProjectManager/WorkItems/components"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; import type { Person } from "@src/types/core/shared"; import type { WorkItem } from "@src/types/core/workItem"; @@ -17,22 +13,15 @@ import { isGitHubIssueStatus, } from "../domain"; import { useTeamInboxWorkItem } from "../useTeamInboxWorkItem"; +import type { TeamInboxWorkItemIssue } from "../useTeamInboxWorkItem"; import TeamInboxDetailLayout from "./TeamInboxDetailLayout"; -const WORK_ITEM_THREAD_PROPERTY_FIELDS: WorkItemPropertyFieldKey[] = [ - "project", - "status", - "priority", - "assignee", - "reviewer", - "date", -]; - export interface AssignedWorkItemDetailProps { item: AssignedWorkItem; onNavigate?: (intent: TeamInboxNavigationIntent) => void; onMarkRead?: (item: AssignedWorkItem) => void; onMarkUnread?: (item: AssignedWorkItem) => void; + onWorkItemUpdated?: (workItem: WorkItem) => void; } interface AssignedWorkItemThreadProps { @@ -40,7 +29,9 @@ interface AssignedWorkItemThreadProps { workItem: WorkItem; repoPath: string | null; members: Person[]; - error: string | null; + currentUser: Person | null; + issueMessage: string | null; + issueTone: "warning" | "error" | null; updateWorkItem: (updates: Partial) => void; refreshWorkItem: () => void; onNavigate?: (intent: TeamInboxNavigationIntent) => void; @@ -51,7 +42,9 @@ const AssignedWorkItemThread: React.FC = ({ workItem, repoPath, members, - error, + currentUser, + issueMessage, + issueTone, updateWorkItem, refreshWorkItem, onNavigate, @@ -59,41 +52,42 @@ const AssignedWorkItemThread: React.FC = ({ const canUpdate = Boolean(item.target.projectId); const isGitHubIssue = isGitHubIssueStatus(item.payload.status); - const properties = canUpdate ? ( - - ) : null; - return (
- {error ? ( + {issueMessage ? (
- {error} + {issueMessage}
) : null}
- = ({ onNavigate, onMarkRead, onMarkUnread, + onWorkItemUpdated, }) => { const { t } = useTranslation(); const { workItem, status, - error, + issue, repoPath, members, + currentUser, updateWorkItem, refreshWorkItem, - } = useTeamInboxWorkItem(item.target); + } = useTeamInboxWorkItem(item.target, onWorkItemUpdated); + const issueMessage = ((): string | null => { + const keyByIssue: Record = { + context_unavailable: "teamInbox.errors.workItemContext", + load_failed: "teamInbox.errors.workItemLoad", + update_failed: "teamInbox.errors.workItemUpdate", + }; + return issue ? t(keyByIssue[issue]) : null; + })(); return ( = ({ workItem={workItem} repoPath={repoPath} members={members} - error={error} + currentUser={currentUser} + issueMessage={issueMessage} + issueTone={ + issue === "context_unavailable" ? "warning" : issue ? "error" : null + } updateWorkItem={updateWorkItem} refreshWorkItem={refreshWorkItem} onNavigate={onNavigate} @@ -187,7 +195,7 @@ const AssignedWorkItemDetail: React.FC = ({ )} diff --git a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx index b3291abe75..2af5cd68ca 100644 --- a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx +++ b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx @@ -56,14 +56,6 @@ const CommentMentionDetail: React.FC = ({ label: t("teamInbox.fields.comments"), value: item.payload.commentCount, }, - { - label: t("teamInbox.fields.threadId"), - value: item.target.threadId, - }, - { - label: t("teamInbox.fields.commentId"), - value: item.target.commentId, - }, ]} >
@@ -78,9 +70,14 @@ const CommentMentionDetail: React.FC = ({ ) : null}
- {item.payload.context ? ( + {item.payload.threadCommentCount !== undefined || + item.payload.context ? (

- {item.payload.context} + {item.payload.threadCommentCount !== undefined + ? t("teamInbox.detail.threadComments", { + count: item.payload.threadCommentCount, + }) + : item.payload.context}

) : null}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx index 308b143266..01437fda6e 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx @@ -89,6 +89,20 @@ const TeamInboxList: React.FC = ({ [items, recencyAnchorMs] ); const activeFilterUnread = unreadCounts[filter]; + const loadMoreAction = + hasMore && onLoadMore ? ( +
+ +
+ ) : null; const filterTabs = useMemo( () => [ { @@ -224,34 +238,36 @@ const TeamInboxList: React.FC = ({
{items.length === 0 ? ( - hasQuery ? ( - - ) : ( - - ) +
+ {hasQuery ? ( + + ) : ( + + )} + {loadMoreAction} +
) : (
{groups.map((group) => { @@ -290,19 +306,7 @@ const TeamInboxList: React.FC = ({ ); })}
- {hasMore && onLoadMore ? ( -
- -
- ) : null} + {loadMoreAction}
)}
diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx index 1a2bf0f5ed..dc3acdd588 100644 --- a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx @@ -19,25 +19,48 @@ export interface TeamInboxRowProps { onSelect: (item: TeamInboxItem) => void; } +function toCompactPreview(content: string): string { + return content + .replace(/\\[nr]/g, "\n") + .replace(/^\s*```[^\n]*$/gm, "") + .replace(/!\[([^\]]*)\]\((?:\\.|[^)])*\)/g, "$1") + .replace(/\[([^\]]+)\]\((?:\\.|[^)])*\)/g, "$1") + .replace(/^\s{0,3}#{1,6}[\t ]+/gm, "") + .replace(/^\s{0,3}>[\t ]?/gm, "") + .replace(/^\s{0,3}(?:[-+*]|\d+[.)])[\t ]+/gm, "") + .replace(/^\s*\[[ xX]\][\t ]+/gm, "") + .replace(/(`+)([\s\S]*?)\1/g, "$2") + .replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2") + .replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + const TeamInboxRow = forwardRef( ({ item, itemKey, selected, onSelect }, ref) => { const { t } = useTranslation(); const isMention = item.kind === "comment_mention"; const title = isMention ? item.target.sessionTitle : item.payload.title; - const summary = useMemo(() => { - if (item.kind === "comment_mention") return item.payload.commentBody; - if (item.payload.summary) return item.payload.summary; + const { meta, summary } = useMemo(() => { + if (item.kind === "comment_mention") { + return { + meta: item.actor.displayName, + summary: toCompactPreview(item.payload.commentBody), + }; + } const status = t(workItemStatusLabelKey(item.payload.status), { defaultValue: humanizeToken(item.payload.status), }); const priority = t(workItemPriorityLabelKey(item.payload.priority), { defaultValue: humanizeToken(item.payload.priority), }); - return t("teamInbox.row.assignedSummary", { status, priority }); + return { + meta: `${status} · ${priority}`, + summary: item.payload.summary + ? toCompactPreview(item.payload.summary) + : "", + }; }, [item, t]); - const personName = isMention - ? item.actor.displayName - : (item.payload.assigneeName ?? item.payload.assigneeMemberId); const relativeTime = useMemo( () => formatRelativeTime(item.occurredAt, "nano"), [item.occurredAt] @@ -54,7 +77,10 @@ const TeamInboxRow = forwardRef( type="button" role="option" aria-selected={selected} - aria-label={`${title},${readLabel}`} + aria-label={t("teamInbox.row.ariaLabel", { + title, + status: readLabel, + })} tabIndex={selected ? 0 : -1} data-testid="team-inbox-row" data-item-kind={item.kind} @@ -86,11 +112,16 @@ const TeamInboxRow = forwardRef( {relativeTime} - - {summary} - - - {personName} + {summary ? ( + + {summary} + + ) : null} + + {meta} diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts index 516b2ffde6..f06ab07eb1 100644 --- a/src/modules/MainApp/TeamInbox/domain/index.ts +++ b/src/modules/MainApp/TeamInbox/domain/index.ts @@ -33,6 +33,8 @@ export type { TeamInboxDataSource, TeamInboxFilter, TeamInboxItem, + TeamInboxIssue, + TeamInboxIssueCode, TeamInboxNavigationIntent, TeamInboxPage, TeamInboxTarget, diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts index 1a59324a18..a14f39d618 100644 --- a/src/modules/MainApp/TeamInbox/domain/types.ts +++ b/src/modules/MainApp/TeamInbox/domain/types.ts @@ -36,6 +36,8 @@ export interface CommentMentionItem extends TeamInboxItemBase { payload: { commentBody: string; context?: string; + /** Structured cloud value; presentation localizes it at render time. */ + threadCommentCount?: number; commentCount: number; }; } @@ -66,6 +68,10 @@ export interface TeamInboxCursor { export interface TeamInboxPage { items: TeamInboxItem[]; nextCursor: TeamInboxCursor | null; + /** True when this snapshot was synchronously cleared for a new scope. */ + loading?: boolean; + /** Non-fatal or fatal source condition associated with this snapshot. */ + issue?: TeamInboxIssue | null; /** Authoritative source totals; absent on lightweight/test data sources. */ unreadCounts?: { all: number; @@ -74,6 +80,17 @@ export interface TeamInboxPage { }; } +export type TeamInboxIssueCode = + | "identity_unresolved" + | "load_failed" + | "partial_load"; + +export interface TeamInboxIssue { + code: TeamInboxIssueCode; + /** Diagnostic detail for logs/support; UI copy is derived from `code`. */ + detail?: string; +} + export interface ListTeamInboxInput { cursor?: TeamInboxCursor | null; limit?: number; @@ -101,6 +118,11 @@ export interface TeamInboxDataSource { */ loadMore?(): Promise; subscribe?(listener: () => void): () => void; + /** + * Reconciles a detail-side projection into the canonical list snapshot. + * `nextItem = null` removes an item that no longer belongs to this viewer. + */ + reconcileItem?(itemKey: string, nextItem: TeamInboxItem | null): void; } export type TeamInboxNavigationIntent = diff --git a/src/modules/MainApp/TeamInbox/store.ts b/src/modules/MainApp/TeamInbox/store.ts index dfa6da566b..80d76c5480 100644 --- a/src/modules/MainApp/TeamInbox/store.ts +++ b/src/modules/MainApp/TeamInbox/store.ts @@ -1,6 +1,7 @@ import { atom } from "jotai"; import type { TeamInboxItem } from "./domain"; +import type { TeamInboxIssue } from "./domain"; import type { TeamInboxUnreadCounts } from "./domain"; export interface TeamInboxCacheState { @@ -8,7 +9,7 @@ export interface TeamInboxCacheState { unreadCount: number; unreadCounts: TeamInboxUnreadCounts; loading: boolean; - error: string | null; + issue: TeamInboxIssue | null; revision: number; loadedForViewerKey: string | null; /** True when either the local or cloud source still has a next page. */ @@ -20,7 +21,7 @@ export const teamInboxCacheAtom = atom({ unreadCount: 0, unreadCounts: { all: 0, mentions: 0, assigned: 0 }, loading: false, - error: null, + issue: null, revision: 0, loadedForViewerKey: null, hasMore: false, diff --git a/src/modules/MainApp/TeamInbox/teamInboxCoordinator.ts b/src/modules/MainApp/TeamInbox/teamInboxCoordinator.ts new file mode 100644 index 0000000000..adc50adbb3 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/teamInboxCoordinator.ts @@ -0,0 +1,884 @@ +import type { Store } from "jotai/vanilla/store"; + +import type { MemberEntry } from "@src/api/http/project"; +import type { + TeamInboxMention, + TeamInboxMentionsPage, + TeamInboxReadMutation, +} from "@src/features/Org2Cloud/teamInboxMentionsClient"; +import { + listInitialTeamInboxMentions, + listTeamInboxMentions, + markAllTeamInboxMentionsRead, + setTeamInboxMentionRead, +} from "@src/features/Org2Cloud/teamInboxMentionsClient"; + +import { + listLocalTeamInboxPage, + markAllLocalTeamInboxRead, + markLocalTeamInboxItemRead, + markLocalTeamInboxItemUnread, +} from "./api"; +import { + dedupeTeamInboxItems, + getTeamInboxItemKey, + sortTeamInboxItems, +} from "./domain"; +import type { + TeamInboxCursor, + TeamInboxFilter, + TeamInboxIssue, + TeamInboxItem, +} from "./domain"; +import { teamInboxCacheAtom, teamInboxInvalidationAtom } from "./store"; + +const MAX_CACHED_TEAM_INBOX_ITEMS = 500; +const MAX_PENDING_TEAM_INBOX_MUTATIONS = 100; + +interface LocalPageResult { + page: { + items: TeamInboxItem[]; + nextCursor: TeamInboxCursor | null; + }; + unreadCount: number; +} + +export interface TeamInboxCoordinatorDependencies { + listLocalPage( + viewerMemberIds: readonly string[], + filter: TeamInboxFilter, + cursor?: TeamInboxCursor | null + ): Promise; + listInitialMentions( + accessToken: string, + orgId: string, + limit: number, + signal?: AbortSignal + ): Promise; + listMentions( + accessToken: string, + orgId: string, + cursor: string | null, + limit: number, + signal?: AbortSignal + ): Promise; + markLocalRead( + viewerMemberIds: readonly string[], + itemId: string + ): Promise; + markLocalUnread( + viewerMemberIds: readonly string[], + itemId: string + ): Promise; + markAllLocalRead( + viewerMemberIds: readonly string[], + filter: TeamInboxFilter + ): Promise; + setMentionRead( + accessToken: string, + orgId: string, + commentId: string, + read: boolean, + signal?: AbortSignal + ): Promise; + markAllMentionsRead( + accessToken: string, + orgId: string, + signal?: AbortSignal + ): Promise; + now(): string; +} + +export interface TeamInboxCoordinatorScope { + key: string; + viewerMemberIds: readonly string[]; + accessToken: string | null; + activeCloudOrgId: string | null; + members: readonly MemberEntry[]; + /** Degraded prerequisite reads (for example, a subset of member files). */ + prerequisiteIssue?: TeamInboxIssue | null; +} + +interface CoordinatorRuntime { + scopeKey: string; + generation: number; + scopeController: AbortController; + localCursor: TeamInboxCursor | null; + cloudCursor: string | null; + refreshPromise: Promise | null; + activeRefreshVersion: string | null; + desiredRefreshVersion: string | null; + queuedRefresh: { scope: TeamInboxCoordinatorScope; version: string } | null; + loadMorePromise: Promise | null; + mutationTail: Promise; + pendingMutations: number; + mutationEpoch: number; + mutationEpochByItem: Map; + invalidationQueued: boolean; +} + +type Settled = { ok: true; value: T } | { ok: false; error: unknown }; + +function settle(promise: Promise): Promise> { + return promise.then( + (value) => ({ ok: true, value }), + (error: unknown) => ({ ok: false, error }) + ); +} + +function errorDetail(errors: readonly unknown[]): string | undefined { + const messages = errors + .map((error) => (error instanceof Error ? error.message : String(error))) + .filter(Boolean); + return messages.length > 0 ? messages.join(" · ") : undefined; +} + +function issueForFailures( + failures: readonly unknown[], + requestedSourceCount: number +): TeamInboxIssue | null { + if (failures.length === 0) return null; + return { + code: + failures.length >= requestedSourceCount ? "load_failed" : "partial_load", + detail: errorDetail(failures), + }; +} + +function mergeIssues( + primary: TeamInboxIssue | null, + secondary: TeamInboxIssue | null | undefined +): TeamInboxIssue | null { + if (!primary) return secondary ?? null; + if (!secondary) return primary; + return { + code: + primary.code === "load_failed" || secondary.code === "load_failed" + ? "load_failed" + : primary.code === "identity_unresolved" || + secondary.code === "identity_unresolved" + ? "identity_unresolved" + : "partial_load", + detail: errorDetail([primary.detail, secondary.detail].filter(Boolean)), + }; +} + +function prerequisiteIssueForScope( + scope: TeamInboxCoordinatorScope +): TeamInboxIssue | null { + const identityIssue = + scope.viewerMemberIds.length === 0 && scope.members.length > 0 + ? ({ code: "identity_unresolved" } as const) + : null; + return mergeIssues(identityIssue, scope.prerequisiteIssue); +} + +function mapMentionsToItems( + mentions: readonly TeamInboxMention[], + activeCloudOrgId: string +): TeamInboxItem[] { + return mentions.map((mention) => ({ + id: `cloud-comment:${activeCloudOrgId}:${mention.comment.id}`, + kind: "comment_mention" as const, + occurredAt: mention.createdAt, + readAt: mention.readAt, + actor: { + id: mention.author.userId, + displayName: mention.author.displayName ?? mention.author.userId, + }, + target: { + kind: "session_comment" as const, + sessionId: mention.session.id, + sessionTitle: mention.session.title ?? mention.session.id, + commentId: mention.comment.id, + threadId: mention.comment.parentId ?? mention.comment.id, + anchor: mention.comment.id, + }, + payload: { + commentBody: mention.body, + commentCount: mention.commentCount, + threadCommentCount: mention.threadCount, + }, + })); +} + +function resolveAssigneeDisplayNames( + items: readonly TeamInboxItem[], + members: readonly MemberEntry[] +): TeamInboxItem[] { + if (members.length === 0) return [...items]; + const nameById = new Map(members.map((member) => [member.id, member.name])); + return items.map((item) => { + if (item.kind !== "assigned_work_item") return item; + const resolved = nameById.get(item.payload.assigneeMemberId); + if (!resolved || resolved === item.payload.assigneeName) return item; + return { + ...item, + payload: { ...item.payload, assigneeName: resolved }, + }; + }); +} + +function boundedItems(items: readonly TeamInboxItem[]): TeamInboxItem[] { + return sortTeamInboxItems(dedupeTeamInboxItems(items)).slice( + 0, + MAX_CACHED_TEAM_INBOX_ITEMS + ); +} + +function createRuntime(scopeKey: string): CoordinatorRuntime { + return { + scopeKey, + generation: 0, + scopeController: new AbortController(), + localCursor: null, + cloudCursor: null, + refreshPromise: null, + activeRefreshVersion: null, + desiredRefreshVersion: null, + queuedRefresh: null, + loadMorePromise: null, + mutationTail: Promise.resolve(), + pendingMutations: 0, + mutationEpoch: 0, + mutationEpochByItem: new Map(), + invalidationQueued: false, + }; +} + +/** + * Store-scoped Team Inbox coordinator. + * + * All mounted consumers in one Jotai store share request identity, cursors, + * mutation ordering and cancellation. Separate stores receive isolated runtime + * state through the WeakMap, while persisted/cache state remains in Jotai. + */ +export class TeamInboxCoordinator { + private readonly runtimeByStore = new WeakMap(); + + constructor( + private readonly dependencies: TeamInboxCoordinatorDependencies + ) {} + + ensureScope(store: Store, scopeKey: string): CoordinatorRuntime { + const currentRuntime = this.runtimeByStore.get(store); + if (currentRuntime?.scopeKey === scopeKey) return currentRuntime; + + currentRuntime?.scopeController.abort(); + const runtime = createRuntime(scopeKey); + this.runtimeByStore.set(store, runtime); + + const cache = store.get(teamInboxCacheAtom); + if (cache.loadedForViewerKey !== scopeKey) { + store.set(teamInboxCacheAtom, { + ...cache, + items: [], + unreadCount: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, + loading: true, + hasMore: false, + loadedForViewerKey: null, + issue: null, + revision: cache.revision + 1, + }); + } + return runtime; + } + + invalidate(store: Store): void { + const runtime = this.runtimeByStore.get(store); + if (runtime?.invalidationQueued) return; + if (runtime) runtime.invalidationQueued = true; + queueMicrotask(() => { + const latest = this.runtimeByStore.get(store); + if (latest) latest.invalidationQueued = false; + store.set( + teamInboxInvalidationAtom, + store.get(teamInboxInvalidationAtom) + 1 + ); + }); + } + + refresh( + store: Store, + scope: TeamInboxCoordinatorScope, + requestVersion: string + ): Promise { + const runtime = this.ensureScope(store, scope.key); + runtime.desiredRefreshVersion = requestVersion; + + if (runtime.refreshPromise) { + if (runtime.activeRefreshVersion !== requestVersion) { + runtime.queuedRefresh = { scope, version: requestVersion }; + } + return runtime.refreshPromise; + } + if ( + runtime.activeRefreshVersion === requestVersion && + store.get(teamInboxCacheAtom).loadedForViewerKey === scope.key + ) { + return Promise.resolve(); + } + + const generation = ++runtime.generation; + runtime.activeRefreshVersion = requestVersion; + store.set(teamInboxCacheAtom, (current) => ({ + ...current, + loading: true, + issue: null, + })); + + const canLoadLocal = scope.viewerMemberIds.length > 0; + const canLoadCloud = Boolean(scope.accessToken && scope.activeCloudOrgId); + if (!canLoadLocal && !canLoadCloud) { + runtime.localCursor = null; + runtime.cloudCursor = null; + store.set(teamInboxCacheAtom, (current) => ({ + ...current, + items: [], + unreadCount: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, + loading: false, + hasMore: false, + loadedForViewerKey: scope.key, + issue: prerequisiteIssueForScope(scope), + revision: current.revision + 1, + })); + return Promise.resolve(); + } + + const requestedSourceCount = Number(canLoadLocal) + Number(canLoadCloud); + const promise = Promise.all([ + canLoadLocal + ? settle( + this.dependencies.listLocalPage(scope.viewerMemberIds, "all", null) + ) + : Promise.resolve>({ + ok: true, + value: { + page: { items: [], nextCursor: null }, + unreadCount: 0, + }, + }), + canLoadCloud && scope.accessToken && scope.activeCloudOrgId + ? settle( + this.dependencies.listInitialMentions( + scope.accessToken, + scope.activeCloudOrgId, + 50, + runtime.scopeController.signal + ) + ) + : Promise.resolve>({ + ok: true, + value: { mentions: [], unreadCount: 0 }, + }), + ]) + .then(([local, cloud]) => { + const currentRuntime = this.runtimeByStore.get(store); + if ( + currentRuntime !== runtime || + generation !== runtime.generation || + runtime.scopeController.signal.aborted || + runtime.desiredRefreshVersion !== requestVersion + ) { + return; + } + + const previous = store.get(teamInboxCacheAtom); + const sameScope = previous.loadedForViewerKey === scope.key; + const previousLocal = sameScope + ? previous.items.filter((item) => item.kind === "assigned_work_item") + : []; + const previousCloud = sameScope + ? previous.items.filter((item) => item.kind === "comment_mention") + : []; + const failures = [ + ...(local.ok ? [] : [local.error]), + ...(cloud.ok ? [] : [cloud.error]), + ]; + + const localItems = local.ok ? local.value.page.items : previousLocal; + const cloudItems = cloud.ok + ? mapMentionsToItems( + cloud.value.mentions, + scope.activeCloudOrgId ?? "" + ) + : previousCloud; + const localUnread = local.ok + ? local.value.unreadCount + : sameScope + ? previous.unreadCounts.assigned + : 0; + const cloudUnread = cloud.ok + ? cloud.value.unreadCount + : sameScope + ? previous.unreadCounts.mentions + : 0; + + if (local.ok) runtime.localCursor = local.value.page.nextCursor; + if (cloud.ok) runtime.cloudCursor = cloud.value.nextCursor ?? null; + + const items = boundedItems( + resolveAssigneeDisplayNames( + [...cloudItems, ...localItems], + scope.members + ) + ); + if (items.length >= MAX_CACHED_TEAM_INBOX_ITEMS) { + runtime.localCursor = null; + runtime.cloudCursor = null; + } + store.set(teamInboxCacheAtom, (current) => ({ + ...current, + items, + unreadCount: localUnread + cloudUnread, + unreadCounts: { + all: localUnread + cloudUnread, + mentions: cloudUnread, + assigned: localUnread, + }, + loading: false, + issue: mergeIssues( + issueForFailures(failures, requestedSourceCount), + prerequisiteIssueForScope(scope) + ), + loadedForViewerKey: scope.key, + hasMore: Boolean(runtime.localCursor || runtime.cloudCursor), + revision: current.revision + 1, + })); + }) + .finally(() => { + if (runtime.refreshPromise === promise) { + runtime.refreshPromise = null; + } + const queued = runtime.queuedRefresh; + runtime.queuedRefresh = null; + if ( + queued && + this.runtimeByStore.get(store) === runtime && + !runtime.scopeController.signal.aborted + ) { + void this.refresh(store, queued.scope, queued.version); + } + }); + runtime.refreshPromise = promise; + return promise; + } + + loadMore(store: Store, scope: TeamInboxCoordinatorScope): Promise { + const runtime = this.ensureScope(store, scope.key); + if (runtime.loadMorePromise) return runtime.loadMorePromise; + const localCursor = runtime.localCursor; + const cloudCursor = runtime.cloudCursor; + if (!localCursor && !cloudCursor) return Promise.resolve(); + + const generation = runtime.generation; + const requestedSourceCount = + Number(Boolean(localCursor)) + Number(Boolean(cloudCursor)); + const promise = Promise.all([ + localCursor + ? settle( + this.dependencies.listLocalPage( + scope.viewerMemberIds, + "all", + localCursor + ) + ) + : Promise.resolve>({ + ok: true, + value: { + page: { items: [], nextCursor: null }, + unreadCount: store.get(teamInboxCacheAtom).unreadCounts.assigned, + }, + }), + cloudCursor && scope.accessToken && scope.activeCloudOrgId + ? settle( + this.dependencies.listMentions( + scope.accessToken, + scope.activeCloudOrgId, + cloudCursor, + 50, + runtime.scopeController.signal + ) + ) + : Promise.resolve>({ + ok: true, + value: { + mentions: [], + unreadCount: store.get(teamInboxCacheAtom).unreadCounts.mentions, + }, + }), + ]) + .then(([local, cloud]) => { + if ( + this.runtimeByStore.get(store) !== runtime || + generation !== runtime.generation || + runtime.scopeController.signal.aborted + ) { + return; + } + const failures = [ + ...(local.ok ? [] : [local.error]), + ...(cloud.ok ? [] : [cloud.error]), + ]; + if (localCursor && local.ok) { + runtime.localCursor = local.value.page.nextCursor; + } + if (cloudCursor && cloud.ok) { + runtime.cloudCursor = cloud.value.nextCursor ?? null; + } + const appended = resolveAssigneeDisplayNames( + [ + ...(cloud.ok + ? mapMentionsToItems( + cloud.value.mentions, + scope.activeCloudOrgId ?? "" + ) + : []), + ...(local.ok ? local.value.page.items : []), + ], + scope.members + ); + store.set(teamInboxCacheAtom, (current) => { + const items = boundedItems([...current.items, ...appended]); + if (items.length >= MAX_CACHED_TEAM_INBOX_ITEMS) { + runtime.localCursor = null; + runtime.cloudCursor = null; + } + const assigned = local.ok + ? local.value.unreadCount + : current.unreadCounts.assigned; + const mentions = cloud.ok + ? cloud.value.unreadCount + : current.unreadCounts.mentions; + return { + ...current, + items, + unreadCount: assigned + mentions, + unreadCounts: { + all: assigned + mentions, + assigned, + mentions, + }, + issue: mergeIssues( + issueForFailures(failures, requestedSourceCount), + prerequisiteIssueForScope(scope) + ), + hasMore: Boolean(runtime.localCursor || runtime.cloudCursor), + revision: current.revision + 1, + }; + }); + if (failures.length >= requestedSourceCount) { + throw new Error(errorDetail(failures) ?? "Team Inbox load failed"); + } + }) + .finally(() => { + if (runtime.loadMorePromise === promise) { + runtime.loadMorePromise = null; + } + }); + runtime.loadMorePromise = promise; + return promise; + } + + markRead( + store: Store, + scope: TeamInboxCoordinatorScope, + item: TeamInboxItem + ): Promise { + return this.setReadState(store, scope, item, true); + } + + markUnread( + store: Store, + scope: TeamInboxCoordinatorScope, + item: TeamInboxItem + ): Promise { + return this.setReadState(store, scope, item, false); + } + + private setReadState( + store: Store, + scope: TeamInboxCoordinatorScope, + item: TeamInboxItem, + read: boolean + ): Promise { + const runtime = this.ensureScope(store, scope.key); + const itemKey = getTeamInboxItemKey(item); + const epoch = ++runtime.mutationEpoch; + runtime.mutationEpochByItem.set(itemKey, epoch); + this.patchReadState( + store, + scope.key, + itemKey, + read ? this.dependencies.now() : null + ); + + return this.enqueueMutation(runtime, async () => { + try { + let cloudResult: TeamInboxReadMutation | null = null; + if (item.kind === "comment_mention") { + if (!scope.accessToken || !scope.activeCloudOrgId) { + throw new Error("Cloud identity is unavailable"); + } + cloudResult = await this.dependencies.setMentionRead( + scope.accessToken, + scope.activeCloudOrgId, + item.target.commentId, + read, + runtime.scopeController.signal + ); + } else { + const updated = read + ? await this.dependencies.markLocalRead( + scope.viewerMemberIds, + item.id + ) + : await this.dependencies.markLocalUnread( + scope.viewerMemberIds, + item.id + ); + if (!updated) + throw new Error("Assigned Work Item is no longer visible"); + } + if ( + this.runtimeByStore.get(store) !== runtime || + runtime.scopeController.signal.aborted || + runtime.mutationEpochByItem.get(itemKey) !== epoch + ) { + return; + } + if (cloudResult) { + const authoritativeReadAt = read + ? (cloudResult.readAt ?? this.dependencies.now()) + : null; + this.patchReadState( + store, + scope.key, + itemKey, + authoritativeReadAt, + cloudResult.unreadCount + ); + } + } catch (error) { + if ( + this.runtimeByStore.get(store) === runtime && + !runtime.scopeController.signal.aborted && + runtime.mutationEpochByItem.get(itemKey) === epoch + ) { + this.patchReadState( + store, + scope.key, + itemKey, + read ? null : item.readAt + ); + } + throw error; + } finally { + if (runtime.mutationEpochByItem.get(itemKey) === epoch) { + runtime.mutationEpochByItem.delete(itemKey); + } + } + }); + } + + markAllRead( + store: Store, + scope: TeamInboxCoordinatorScope, + filter: TeamInboxFilter + ): Promise { + const runtime = this.ensureScope(store, scope.key); + return this.enqueueMutation(runtime, async () => { + const includeAssigned = filter === "all" || filter === "assigned"; + const includeMentions = filter === "all" || filter === "mentions"; + const before = store.get(teamInboxCacheAtom); + const [local, cloud] = await Promise.all([ + includeAssigned && before.unreadCounts.assigned > 0 + ? settle( + this.dependencies.markAllLocalRead( + scope.viewerMemberIds, + "assigned" + ) + ) + : Promise.resolve>({ ok: true, value: 0 }), + includeMentions && before.unreadCounts.mentions > 0 + ? scope.accessToken && scope.activeCloudOrgId + ? settle( + this.dependencies.markAllMentionsRead( + scope.accessToken, + scope.activeCloudOrgId, + runtime.scopeController.signal + ) + ) + : Promise.resolve>({ + ok: false, + error: new Error("Cloud identity is unavailable"), + }) + : Promise.resolve>({ + ok: true, + value: { readAt: null, unreadCount: 0 }, + }), + ]); + if ( + this.runtimeByStore.get(store) !== runtime || + runtime.scopeController.signal.aborted + ) { + return; + } + const readAt = cloud.ok + ? (cloud.value.readAt ?? this.dependencies.now()) + : this.dependencies.now(); + store.set(teamInboxCacheAtom, (current) => { + const assigned = + includeAssigned && local.ok ? 0 : current.unreadCounts.assigned; + const mentions = + includeMentions && cloud.ok + ? cloud.value.unreadCount + : current.unreadCounts.mentions; + return { + ...current, + items: current.items.map((candidate) => { + const shouldMark = + (includeAssigned && + local.ok && + candidate.kind === "assigned_work_item") || + (includeMentions && + cloud.ok && + candidate.kind === "comment_mention"); + return shouldMark ? { ...candidate, readAt } : candidate; + }), + unreadCount: assigned + mentions, + unreadCounts: { all: assigned + mentions, assigned, mentions }, + revision: current.revision + 1, + }; + }); + const failures = [ + ...(local.ok ? [] : [local.error]), + ...(cloud.ok ? [] : [cloud.error]), + ]; + if (failures.length > 0) { + throw new Error(errorDetail(failures) ?? "Team Inbox update failed"); + } + }); + } + + reconcileItem( + store: Store, + scopeKey: string, + itemKey: string, + nextItem: TeamInboxItem | null + ): void { + store.set(teamInboxCacheAtom, (current) => { + if (current.loadedForViewerKey !== scopeKey) return current; + const previousItem = current.items.find( + (candidate) => getTeamInboxItemKey(candidate) === itemKey + ); + const nextItems = current.items.flatMap((candidate) => + getTeamInboxItemKey(candidate) === itemKey + ? nextItem + ? [nextItem] + : [] + : [candidate] + ); + const previousUnread = previousItem?.readAt === null ? 1 : 0; + const nextUnread = nextItem?.readAt === null ? 1 : 0; + const unreadDelta = nextUnread - previousUnread; + const assigned = + previousItem?.kind === "assigned_work_item" || + nextItem?.kind === "assigned_work_item" + ? Math.max(0, current.unreadCounts.assigned + unreadDelta) + : current.unreadCounts.assigned; + const mentions = + previousItem?.kind === "comment_mention" || + nextItem?.kind === "comment_mention" + ? Math.max(0, current.unreadCounts.mentions + unreadDelta) + : current.unreadCounts.mentions; + return { + ...current, + items: boundedItems(nextItems), + unreadCount: assigned + mentions, + unreadCounts: { all: assigned + mentions, assigned, mentions }, + revision: current.revision + 1, + }; + }); + } + + private enqueueMutation( + runtime: CoordinatorRuntime, + operation: () => Promise + ): Promise { + if (runtime.pendingMutations >= MAX_PENDING_TEAM_INBOX_MUTATIONS) { + return Promise.reject(new Error("Too many pending Team Inbox updates")); + } + runtime.pendingMutations += 1; + const run = async (): Promise => { + try { + return await operation(); + } finally { + runtime.pendingMutations = Math.max(0, runtime.pendingMutations - 1); + } + }; + const result = runtime.mutationTail.then(run, run); + runtime.mutationTail = result.then( + () => undefined, + () => undefined + ); + return result; + } + + private patchReadState( + store: Store, + scopeKey: string, + itemKey: string, + readAt: string | null, + authoritativeMentionUnread?: number + ): void { + store.set(teamInboxCacheAtom, (current) => { + if (current.loadedForViewerKey !== scopeKey) return current; + const candidate = current.items.find( + (item) => getTeamInboxItemKey(item) === itemKey + ); + if (!candidate) return current; + const wasUnread = candidate.readAt === null; + const willBeUnread = readAt === null; + const delta = Number(willBeUnread) - Number(wasUnread); + const assigned = + candidate.kind === "assigned_work_item" + ? Math.max(0, current.unreadCounts.assigned + delta) + : current.unreadCounts.assigned; + const mentions = + candidate.kind === "comment_mention" + ? (authoritativeMentionUnread ?? + Math.max(0, current.unreadCounts.mentions + delta)) + : current.unreadCounts.mentions; + return { + ...current, + items: current.items.map((item) => + getTeamInboxItemKey(item) === itemKey ? { ...item, readAt } : item + ), + unreadCount: assigned + mentions, + unreadCounts: { all: assigned + mentions, assigned, mentions }, + revision: current.revision + 1, + }; + }); + } +} + +const productionDependencies: TeamInboxCoordinatorDependencies = { + listLocalPage: listLocalTeamInboxPage, + listInitialMentions: listInitialTeamInboxMentions, + listMentions: listTeamInboxMentions, + markLocalRead: markLocalTeamInboxItemRead, + markLocalUnread: markLocalTeamInboxItemUnread, + markAllLocalRead: markAllLocalTeamInboxRead, + setMentionRead: setTeamInboxMentionRead, + markAllMentionsRead: markAllTeamInboxMentionsRead, + now: () => new Date().toISOString(), +}; + +export const teamInboxCoordinator = new TeamInboxCoordinator( + productionDependencies +); + +export const TEAM_INBOX_CACHE_LIMIT = MAX_CACHED_TEAM_INBOX_ITEMS; diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts index 00be22929f..5f909fa5f8 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts @@ -1,12 +1,5 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useAtomValue, useStore } from "jotai"; +import { useEffect, useLayoutEffect, useMemo, useState } from "react"; import { invalidateProjectCache, projectApi } from "@src/api/http/project"; import type { MemberEntry } from "@src/api/http/project"; @@ -19,131 +12,92 @@ import { orgCommentsKey, } from "@src/features/Org2Cloud/org2CloudCommentsBus"; import { sidebarActiveCloudOrgIdAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; -import { - type TeamInboxMention, - listInitialTeamInboxMentions, - listTeamInboxMentions, - markAllTeamInboxMentionsRead, - setTeamInboxMentionRead, -} from "@src/features/Org2Cloud/teamInboxMentionsClient"; +import { createLogger } from "@src/hooks/logger"; import { useProjectDataChanged } from "@src/hooks/project"; import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; -import { - listLocalTeamInboxPage, - markAllLocalTeamInboxRead, - markLocalTeamInboxItemRead, - markLocalTeamInboxItemUnread, -} from "./api"; -import { dedupeTeamInboxItems } from "./domain"; import type { - TeamInboxCursor, TeamInboxDataSource, TeamInboxFilter, + TeamInboxIssue, TeamInboxItem, } from "./domain"; +import { teamInboxCacheAtom, teamInboxInvalidationAtom } from "./store"; import { - invalidateTeamInboxAtom, - teamInboxCacheAtom, - teamInboxInvalidationAtom, -} from "./store"; + type TeamInboxCoordinatorScope, + teamInboxCoordinator, +} from "./teamInboxCoordinator"; -const listeners = new Set<() => void>(); -const MAX_PENDING_TEAM_INBOX_MUTATIONS = 100; -let membersRequest: Promise | null = null; -let inboxRequest: { - key: string; - promise: Promise<{ - mentionItems: TeamInboxItem[]; - localItems: TeamInboxItem[]; - localUnread: number; - cloudUnread: number; - localNextCursor: TeamInboxCursor | null; - cloudNextCursor: string | null; - }>; -} | null = null; +const log = createLogger("TeamInboxDataSource"); +const MEMBER_READ_CONCURRENCY = 8; -const EMPTY_CLOUD_MENTION_PAGE = { - mentions: [], - nextCursor: undefined, - unreadCount: 0, -} as const; - -function notifyTeamInboxListeners(): void { - for (const listener of listeners) listener(); +interface MemberSnapshot { + members: MemberEntry[]; + issue: TeamInboxIssue | null; } -/** - * Maps the server-authoritative cloud mention projection into Team Inbox - * items. Shared by initial load and pagination so both paths stay identical. - */ -function mapMentionsToItems( - mentions: readonly TeamInboxMention[], - activeCloudOrgId: string -): TeamInboxItem[] { - return mentions.map((mention) => { - const itemId = `cloud-comment:${activeCloudOrgId}:${mention.comment.id}`; - return { - id: itemId, - kind: "comment_mention" as const, - occurredAt: mention.createdAt, - readAt: mention.readAt, - actor: { - id: mention.author.userId, - displayName: mention.author.displayName ?? "Team member", - }, - target: { - kind: "session_comment" as const, - sessionId: mention.session.id, - sessionTitle: mention.session.title ?? "Session", - commentId: mention.comment.id, - threadId: mention.comment.parentId ?? mention.comment.id, - anchor: mention.comment.id, - }, - payload: { - commentBody: mention.body, - commentCount: mention.commentCount, - context: `${mention.threadCount} thread comments`, - }, - }; - }); -} +const EMPTY_MEMBER_SNAPSHOT: MemberSnapshot = { + members: [], + issue: null, +}; -/** - * Resolves each assigned item's display name from its stable `assigneeMemberId` - * into the optional `assigneeName` field. When the member cannot be resolved the - * name is left unset and consumers fall back to the id, so a row never renders - * blank. - */ -function resolveAssigneeDisplayNames( - items: readonly TeamInboxItem[], - members: readonly MemberEntry[] -): TeamInboxItem[] { - if (members.length === 0) return [...items]; - const nameById = new Map(members.map((member) => [member.id, member.name])); - return items.map((item) => { - if (item.kind !== "assigned_work_item") return item; - const resolved = nameById.get(item.payload.assigneeMemberId); - if (!resolved || resolved === item.payload.assigneeName) return item; - return { - ...item, - payload: { ...item.payload, assigneeName: resolved }, - }; - }); -} +let membersRequest: Promise | null = null; -async function readAllProjectMembers(): Promise { +async function readAllProjectMembers(): Promise { if (membersRequest) return membersRequest; membersRequest = (async () => { const projects = await projectApi.readProjects(); - const memberFiles = await Promise.all( - projects.map((project) => projectApi.readMembers(project.slug)) - ); + if (projects.length === 0) return EMPTY_MEMBER_SNAPSHOT; + + const memberFiles: MemberEntry[][] = []; + const failures: unknown[] = []; + let nextIndex = 0; + const workerCount = Math.min(MEMBER_READ_CONCURRENCY, projects.length); + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < projects.length) { + const index = nextIndex; + nextIndex += 1; + const project = projects[index]; + try { + const file = await projectApi.readMembers(project.slug); + memberFiles.push(file.members); + } catch (error) { + failures.push(error); + } + } + }); + await Promise.all(workers); + if (memberFiles.length === 0 && failures.length > 0) { + throw failures[0]; + } + if (failures.length > 0) { + log.warn( + `Skipped ${failures.length} project member file(s) while resolving Team Inbox identity` + ); + } + const members = new Map(); for (const file of memberFiles) { - for (const member of file.members) members.set(member.id, member); + for (const member of file) { + const existing = members.get(member.id); + if ( + !existing || + (member.last_commit_date ?? "") > (existing.last_commit_date ?? "") + ) { + members.set(member.id, member); + } + } } - return [...members.values()]; + return { + members: [...members.values()], + issue: + failures.length > 0 + ? { + code: "partial_load", + detail: `${failures.length} project member file(s) could not be read`, + } + : null, + }; })(); try { return await membersRequest; @@ -152,518 +106,147 @@ async function readAllProjectMembers(): Promise { } } +function issueError(issue: TeamInboxIssue): Error & { + issue: TeamInboxIssue; +} { + return Object.assign(new Error(issue.detail ?? `Team Inbox ${issue.code}`), { + issue, + }); +} + export function useTeamInboxDataSource(): { dataSource: TeamInboxDataSource; viewerMemberIds: readonly string[]; } { - const [members, setMembers] = useState([]); - const membersRef = useRef([]); + const store = useStore(); + const [memberSnapshot, setMemberSnapshot] = useState( + EMPTY_MEMBER_SNAPSHOT + ); + const { members } = memberSnapshot; 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); + // Every consumer observes the same version; the coordinator single-flights + // the resulting request instead of giving each hook its own request state. + const invalidation = useAtomValue(teamInboxInvalidationAtom); 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); - const mutationQueueRef = useRef>(Promise.resolve()); - const pendingMutationCountRef = useRef(0); - - const enqueueMutation = useCallback( - (operation: () => Promise): Promise => { - if (pendingMutationCountRef.current >= MAX_PENDING_TEAM_INBOX_MUTATIONS) { - return Promise.reject( - new Error("Too many pending Team Inbox updates; try again shortly") - ); - } - pendingMutationCountRef.current += 1; - const run = async (): Promise => { - try { - return await operation(); - } finally { - pendingMutationCountRef.current = Math.max( - 0, - pendingMutationCountRef.current - 1 - ); - } - }; - const result = mutationQueueRef.current.then(run, run); - mutationQueueRef.current = result.then( - () => undefined, - () => undefined - ); - return result; - }, - [] + const viewerKey = `${viewerMemberIds.join("|")}::${authIdentityKey ?? "signed-out"}::${activeCloudOrgId ?? "local"}`; + const scope = useMemo( + () => ({ + key: viewerKey, + viewerMemberIds, + accessToken: auth?.accessToken ?? null, + activeCloudOrgId, + members, + prerequisiteIssue: memberSnapshot.issue, + }), + [ + activeCloudOrgId, + auth?.accessToken, + memberSnapshot.issue, + members, + viewerKey, + viewerMemberIds, + ] ); useLayoutEffect(() => { - if ( - cache.loadedForViewerKey === null || - cache.loadedForViewerKey === viewerKey - ) { - return; - } - - // Never render the previous account/org projection while the new identity - // is revalidating. Bump the generation first so late page/mutation - // completions cannot repopulate the evicted cache. - loadGeneration.current += 1; - localCursorRef.current = null; - cloudCursorRef.current = null; - loadingMoreRef.current = false; - setCache((current) => - current.loadedForViewerKey === viewerKey - ? current - : { - ...current, - items: [], - unreadCount: 0, - unreadCounts: { all: 0, mentions: 0, assigned: 0 }, - loading: true, - hasMore: false, - loadedForViewerKey: null, - error: null, - revision: current.revision + 1, - } - ); - }, [cache.loadedForViewerKey, setCache, viewerKey]); + teamInboxCoordinator.ensureScope(store, viewerKey); + }, [store, viewerKey]); useEffect(() => { let cancelled = false; void readAllProjectMembers() - .then((nextMembers) => { - if (!cancelled) { - membersRef.current = nextMembers; - setMembers(nextMembers); - } + .then((nextSnapshot) => { + if (!cancelled) setMemberSnapshot(nextSnapshot); }) .catch((error: unknown) => { - if (!cancelled) { - setCache((current) => ({ - ...current, - error: - error instanceof Error - ? error.message - : "Failed to resolve current Team Inbox member identity", - })); - } + if (cancelled) return; + log.warn("Failed to resolve Team Inbox member identity", error); + store.set(teamInboxCacheAtom, (current) => ({ + ...current, + loading: false, + issue: { + code: "load_failed", + detail: error instanceof Error ? error.message : String(error), + }, + revision: current.revision + 1, + })); }); return () => { cancelled = true; }; - }, [invalidation, setCache]); + }, [invalidation, store]); - 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, - unreadCounts: { all: 0, mentions: 0, assigned: 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 - ? listInitialTeamInboxMentions( - auth.accessToken, - activeCloudOrgId, - 50 - ) - : Promise.resolve(EMPTY_CLOUD_MENTION_PAGE), - ]).then(([{ page, unreadCount }, mentionPage]) => { - // Read state is intentionally NOT baked in here: the cached request - // promise stays receipt-independent so a mention marked read while - // 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, - cloudUnread: mentionPage.unreadCount, - localNextCursor: page.nextCursor, - cloudNextCursor: mentionPage.nextCursor ?? null, - }; - }); - inboxRequest = { key: requestKey, promise }; - const clearSettledRequest = () => { - if (inboxRequest?.promise === promise) inboxRequest = null; - }; - void promise.then(clearSettledRequest, clearSettledRequest); - } - const { - mentionItems, - localItems, - localUnread, - cloudUnread, - localNextCursor, - cloudNextCursor, - } = await inboxRequest.promise; - if (generation !== loadGeneration.current) return; - localCursorRef.current = localNextCursor; - cloudCursorRef.current = cloudNextCursor; - const mergedItems = [...mentionItems, ...localItems]; - const unreadCount = localUnread + cloudUnread; - const resolvedItems = resolveAssigneeDisplayNames( - mergedItems, - membersRef.current - ); - setCache((current) => ({ - ...current, - items: resolvedItems, - unreadCount, - unreadCounts: { - all: unreadCount, - mentions: cloudUnread, - assigned: localUnread, - }, - 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(); - } + useEffect(() => { + const requestVersion = `${invalidation}:${activeCloudCommentsRevision}:${members.length}:${memberSnapshot.issue?.code ?? "members-ok"}`; + void teamInboxCoordinator.refresh(store, scope, requestVersion); }, [ - activeCloudOrgId, - auth, + activeCloudCommentsRevision, + invalidation, + memberSnapshot.issue?.code, members.length, - setCache, - viewerKey, - viewerMemberIds, + scope, + store, ]); - 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()); + useProjectDataChanged(() => teamInboxCoordinator.invalidate(store)); 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. + const cache = store.get(teamInboxCacheAtom); + if ( + cache.issue && + cache.items.length === 0 && + cache.issue.code !== "partial_load" + ) { + throw issueError(cache.issue); + } return { items: cache.items, + loading: cache.loading, + issue: cache.issue, unreadCounts: cache.unreadCounts, nextCursor: cache.hasMore ? { occurredAt: "", itemKey: "team-inbox-has-more" } : null, }; }, - loadMore: async () => { - if (loadingMoreRef.current) return; - const generation = loadGeneration.current; - 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 - ) - : Promise.resolve({ - mentions: [], - nextCursor: undefined, - unreadCount: cache.unreadCounts.mentions, - }), - ]); - if (generation !== loadGeneration.current) return; - localCursorRef.current = localResult.page.nextCursor ?? null; - cloudCursorRef.current = cloudResult.nextCursor ?? null; - const appendedMentions = mapMentionsToItems( - cloudResult.mentions, - activeCloudOrgId ?? "" - ); - 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; - } - }, + loadMore: () => teamInboxCoordinator.loadMore(store, scope), refresh: async () => { + // Never create a second roster fan-out while the first is active. + // Explicit refresh waits for it, then starts one fresh post-invalidation + // snapshot that both mounted consumers can share. + await membersRequest?.catch(() => undefined); invalidateProjectCache(); membersRequest = null; - const nextMembers = await readAllProjectMembers(); - membersRef.current = nextMembers; - setMembers(nextMembers); - setCache((current) => ({ - ...current, - loadedForViewerKey: null, - loading: true, - error: null, - })); - invalidate(); + const nextSnapshot = await readAllProjectMembers(); + setMemberSnapshot(nextSnapshot); + teamInboxCoordinator.invalidate(store); }, - markRead: async (item: TeamInboxItem) => { - const generation = loadGeneration.current; - return enqueueMutation(async () => { - let readAt = new Date().toISOString(); - let cloudUnread: number | null = null; - if (item.kind === "comment_mention") { - if (!auth || !activeCloudOrgId) { - throw new Error( - "Cloud identity is required to mark a mention read" - ); - } - const result = await setTeamInboxMentionRead( - auth.accessToken, - activeCloudOrgId, - item.target.commentId, - true - ); - readAt = result.readAt ?? readAt; - cloudUnread = result.unreadCount; - } else { - await markLocalTeamInboxItemRead(viewerMemberIds, item.id); - } - if (generation !== loadGeneration.current) return; - setCache((current) => { - const wasUnread = - current.items.find((candidate) => candidate.id === item.id) - ?.readAt === null; - const assignedUnread = - item.kind === "comment_mention" - ? current.unreadCounts.assigned - : Math.max( - 0, - current.unreadCounts.assigned - (wasUnread ? 1 : 0) - ); - const mentionUnread = - item.kind === "comment_mention" - ? (cloudUnread ?? current.unreadCounts.mentions) - : current.unreadCounts.mentions; - return { - ...current, - items: current.items.map((candidate) => - candidate.id === item.id ? { ...candidate, readAt } : candidate - ), - unreadCounts: { - all: assignedUnread + mentionUnread, - assigned: assignedUnread, - mentions: mentionUnread, - }, - unreadCount: assignedUnread + mentionUnread, - revision: current.revision + 1, - }; - }); - notifyTeamInboxListeners(); + markRead: (item) => teamInboxCoordinator.markRead(store, scope, item), + markUnread: (item) => teamInboxCoordinator.markUnread(store, scope, item), + markAllRead: (_items, filter = "all") => + teamInboxCoordinator.markAllRead(store, scope, filter), + reconcileItem: (itemKey, nextItem) => + teamInboxCoordinator.reconcileItem(store, scope.key, itemKey, nextItem), + subscribe: (listener) => { + let revision = store.get(teamInboxCacheAtom).revision; + return store.sub(teamInboxCacheAtom, () => { + const nextRevision = store.get(teamInboxCacheAtom).revision; + if (nextRevision === revision) return; + revision = nextRevision; + listener(); }); }, - markUnread: async (item: TeamInboxItem) => { - const generation = loadGeneration.current; - return enqueueMutation(async () => { - let cloudUnread: number | null = null; - if (item.kind === "comment_mention") { - if (!auth || !activeCloudOrgId) { - throw new Error( - "Cloud identity is required to mark a mention unread" - ); - } - const result = await setTeamInboxMentionRead( - auth.accessToken, - activeCloudOrgId, - item.target.commentId, - false - ); - cloudUnread = result.unreadCount; - } else { - await markLocalTeamInboxItemUnread(viewerMemberIds, item.id); - } - if (generation !== loadGeneration.current) return; - setCache((current) => { - const wasUnread = - current.items.find((candidate) => candidate.id === item.id) - ?.readAt === null; - const assignedUnread = - item.kind === "comment_mention" - ? current.unreadCounts.assigned - : current.unreadCounts.assigned + (wasUnread ? 0 : 1); - const mentionUnread = - item.kind === "comment_mention" - ? (cloudUnread ?? current.unreadCounts.mentions) - : current.unreadCounts.mentions; - return { - ...current, - items: current.items.map((candidate) => - candidate.id === item.id - ? { ...candidate, readAt: null } - : candidate - ), - unreadCounts: { - all: assignedUnread + mentionUnread, - assigned: assignedUnread, - mentions: mentionUnread, - }, - unreadCount: assignedUnread + mentionUnread, - revision: current.revision + 1, - }; - }); - notifyTeamInboxListeners(); - }); - }, - markAllRead: async (_items, filter = "all") => { - const generation = loadGeneration.current; - return enqueueMutation(async () => { - const includeAssigned = filter === "all" || filter === "assigned"; - const includeMentions = filter === "all" || filter === "mentions"; - let cloudReadAt: string | null = null; - let cloudUnread: number | null = null; - if ( - includeMentions && - cache.unreadCounts.mentions > 0 && - (!auth || !activeCloudOrgId) - ) { - throw new Error( - "Cloud identity is required to mark all mentions read" - ); - } - try { - const [, cloudResult] = await Promise.all([ - includeAssigned && cache.unreadCounts.assigned > 0 - ? markAllLocalTeamInboxRead(viewerMemberIds, "assigned") - : Promise.resolve(), - includeMentions && - cache.unreadCounts.mentions > 0 && - auth && - activeCloudOrgId - ? markAllTeamInboxMentionsRead( - auth.accessToken, - activeCloudOrgId - ) - : Promise.resolve(null), - ]); - cloudReadAt = cloudResult?.readAt ?? null; - cloudUnread = cloudResult?.unreadCount ?? null; - } catch (error) { - invalidate(); - throw error; - } - if (generation !== loadGeneration.current) return; - const readAt = cloudReadAt ?? new Date().toISOString(); - setCache((current) => { - const assignedUnread = includeAssigned - ? 0 - : current.unreadCounts.assigned; - const mentionUnread = includeMentions - ? (cloudUnread ?? 0) - : current.unreadCounts.mentions; - return { - ...current, - items: current.items.map((item) => - (includeAssigned && item.kind === "assigned_work_item") || - (includeMentions && item.kind === "comment_mention") - ? { ...item, readAt } - : item - ), - unreadCounts: { - all: assignedUnread + mentionUnread, - assigned: assignedUnread, - mentions: mentionUnread, - }, - unreadCount: assignedUnread + mentionUnread, - revision: current.revision + 1, - }; - }); - notifyTeamInboxListeners(); - }); - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, }), - [ - activeCloudOrgId, - auth, - cache.error, - cache.hasMore, - cache.items, - cache.unreadCounts, - enqueueMutation, - invalidate, - setCache, - viewerMemberIds, - ] + [scope, store] ); return { dataSource, viewerMemberIds }; @@ -672,3 +255,9 @@ export function useTeamInboxDataSource(): { export function filterForItem(item: TeamInboxItem): TeamInboxFilter { return item.kind === "comment_mention" ? "mentions" : "assigned"; } + +export const __TEAM_INBOX_MEMBER_INTERNALS = { + resetRequest: () => { + membersRequest = null; + }, +}; diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts index 45650029bd..dc77e05a8b 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts @@ -1,5 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { enrichedWorkItemToUI, @@ -21,6 +22,7 @@ const log = createLogger("TeamInboxNavigation"); export function useTeamInboxNavigation(): ( intent: TeamInboxNavigationIntent ) => void { + const { t } = useTranslation(); const sessions = useAtomValue(sessionsAtom); const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom); const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); @@ -60,9 +62,12 @@ export function useTeamInboxNavigation(): ( standaloneWorkItemDataToEnriched(workItem) ), shortId, - projectId: project?.meta.id ?? "", - projectSlug: project?.slug ?? "", - projectName: project?.meta.name ?? "Standalone", + projectId: project?.meta.id ?? intent.projectId ?? "", + projectSlug: project?.slug ?? intent.projectId ?? "", + projectName: + project?.meta.name ?? + intent.projectId ?? + t("teamInbox.detail.standaloneProject"), orgId: project?.meta.org_id, }); if (intent.action) { @@ -83,15 +88,31 @@ export function useTeamInboxNavigation(): ( return; } - void Promise.all([ + void Promise.allSettled([ projectApi.readProject(intent.projectId), projectApi.readWorkItem(intent.projectId, intent.workItemId), ]) - .then(([project, workItem]) => openResolvedWorkItem(workItem, project)) + .then(([projectResult, workItemResult]) => { + if (workItemResult.status === "rejected") { + throw workItemResult.reason; + } + if (projectResult.status === "rejected") { + log.warn( + "Opening Team Inbox Work Item without project metadata", + projectResult.reason + ); + } + openResolvedWorkItem( + workItemResult.value, + projectResult.status === "fulfilled" + ? projectResult.value + : undefined + ); + }) .catch((error: unknown) => { log.warn("Failed to open project Team Inbox Work Item", error); }); }, - [openSession, openWorkItem, requestWorkItemAction, sessions] + [openSession, openWorkItem, requestWorkItemAction, sessions, t] ); } diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts index ee208c82a3..a056fb3e6f 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts @@ -7,6 +7,7 @@ import { } from "@src/api/http/project"; import type { MemberEntry } from "@src/api/http/project"; import { createLogger } from "@src/hooks/logger"; +import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate"; import type { Person } from "@src/types/core/shared"; import type { WorkItem } from "@src/types/core/workItem"; @@ -14,21 +15,29 @@ import type { WorkItem } from "@src/types/core/workItem"; import type { WorkItemTarget } from "./domain"; const log = createLogger("TeamInboxWorkItem"); +const EMPTY_MEMBERS: Person[] = []; +const MAX_PENDING_WORK_ITEM_UPDATES = 50; interface ResolvedWorkItem { key: string; workItem: WorkItem | null; repoPath: string | null; members: Person[]; - error: string | null; + issue: TeamInboxWorkItemIssue | null; } +export type TeamInboxWorkItemIssue = + | "context_unavailable" + | "load_failed" + | "update_failed"; + export interface TeamInboxWorkItemState { workItem: WorkItem | null; status: "loading" | "ready" | "error"; - error: string | null; + issue: TeamInboxWorkItemIssue | null; repoPath: string | null; members: Person[]; + currentUser: Person | null; updateWorkItem: (updates: Partial) => void; refreshWorkItem: () => void; } @@ -37,39 +46,67 @@ export interface TeamInboxWorkItemState { * Demand-load the full Work Item for the selected inbox row. * * The resolved value is keyed to the selection, late reads are ignored after - * cleanup, and only the newest overlapping property update may replace the - * displayed snapshot. + * cleanup, and updates for one Work Item are serialized in invocation order so + * an older response can never overwrite a newer user intent. */ export function useTeamInboxWorkItem( - target: WorkItemTarget + target: WorkItemTarget, + onWorkItemUpdated?: (workItem: WorkItem) => void ): TeamInboxWorkItemState { const { projectId, workItemId } = target; const requestKey = `${projectId || "standalone"}:${workItemId}`; const [resolved, setResolved] = useState(null); const [refreshGeneration, setRefreshGeneration] = useState(0); - const updateGenerationRef = useRef(0); + const updateQueueByKeyRef = useRef(new Map>()); + const updateQueueSizeByKeyRef = useRef(new Map()); + const activeMembers = + resolved?.key === requestKey ? resolved.members : EMPTY_MEMBERS; + const { currentUser } = useCurrentUserMemberIds(activeMembers); useEffect(() => { let cancelled = false; const request = projectId - ? Promise.all([ - projectApi.readWorkItem(projectId, workItemId), - projectApi.readProject(projectId), - projectApi.readMembers(projectId), - ]).then(([data, project, memberFile]) => ({ - data, - project, - memberEntries: memberFile.members, - })) + ? projectApi.readWorkItem(projectId, workItemId).then(async (data) => { + const [projectResult, membersResult] = await Promise.allSettled([ + projectApi.readProject(projectId), + projectApi.readMembers(projectId), + ]); + const issue = + projectResult.status === "rejected" || + membersResult.status === "rejected" + ? ("context_unavailable" as const) + : null; + if (issue) { + log.warn( + "Loaded Team Inbox Work Item without complete project context", + projectResult.status === "rejected" + ? projectResult.reason + : membersResult.status === "rejected" + ? membersResult.reason + : undefined + ); + } + return { + data, + project: + projectResult.status === "fulfilled" ? projectResult.value : null, + memberEntries: + membersResult.status === "fulfilled" + ? membersResult.value.members + : [], + issue, + }; + }) : projectApi.readStandaloneWorkItem(workItemId).then((data) => ({ data, project: null, memberEntries: [] as MemberEntry[], + issue: null, })); void request - .then(({ data, project, memberEntries }) => { + .then(({ data, project, memberEntries, issue }) => { if (cancelled) return; const converted = enrichedWorkItemToUI( standaloneWorkItemDataToEnriched(data) @@ -109,7 +146,7 @@ export function useTeamInboxWorkItem( : converted, repoPath: project?.meta.linked_repos[0] ?? null, members, - error: null, + issue, }); }) .catch((error: unknown) => { @@ -120,7 +157,7 @@ export function useTeamInboxWorkItem( workItem: current?.key === requestKey ? current.workItem : null, repoPath: current?.key === requestKey ? current.repoPath : null, members: current?.key === requestKey ? current.members : [], - error: error instanceof Error ? error.message : String(error), + issue: "load_failed", })); }); @@ -136,52 +173,88 @@ export function useTeamInboxWorkItem( const updateWorkItem = useCallback( (updates: Partial) => { if (!projectId) return; - const payload = toWorkItemPartialUpdate(updates); + const payload = toWorkItemPartialUpdate(updates, currentUser); if (Object.keys(payload).length === 0) return; + const pendingCount = updateQueueSizeByKeyRef.current.get(requestKey) ?? 0; + if (pendingCount >= MAX_PENDING_WORK_ITEM_UPDATES) { + log.warn("Rejected excessive queued Team Inbox Work Item updates"); + setResolved((current) => + current?.key === requestKey + ? { ...current, issue: "update_failed" } + : current + ); + return; + } + updateQueueSizeByKeyRef.current.set(requestKey, pendingCount + 1); - const generation = ++updateGenerationRef.current; - void projectApi - .updateWorkItemPartial(projectId, workItemId, payload) - .then((updated) => { - if (generation !== updateGenerationRef.current) return; + const runUpdate = async () => { + try { + const updated = await projectApi.updateWorkItemPartial( + projectId, + workItemId, + payload + ); + const converted = enrichedWorkItemToUI(updated); + onWorkItemUpdated?.(converted); setResolved((current) => current?.key === requestKey ? { key: requestKey, workItem: { - ...enrichedWorkItemToUI(updated), + ...converted, project: current.workItem?.project, }, repoPath: current.repoPath, members: current.members, - error: null, + issue: + current.issue === "context_unavailable" + ? current.issue + : null, } : current ); - }) - .catch((error: unknown) => { - if (generation !== updateGenerationRef.current) return; + } catch (error) { log.warn("Failed to update Team Inbox Work Item", error); setResolved((current) => current?.key === requestKey ? { ...current, - error: error instanceof Error ? error.message : String(error), + issue: "update_failed", } : current ); - }); + } + }; + const previous = + updateQueueByKeyRef.current.get(requestKey) ?? Promise.resolve(); + const queued = previous.then(runUpdate, runUpdate); + updateQueueByKeyRef.current.set(requestKey, queued); + void queued.finally(() => { + const remaining = Math.max( + 0, + (updateQueueSizeByKeyRef.current.get(requestKey) ?? 1) - 1 + ); + if (remaining === 0) { + updateQueueSizeByKeyRef.current.delete(requestKey); + } else { + updateQueueSizeByKeyRef.current.set(requestKey, remaining); + } + if (updateQueueByKeyRef.current.get(requestKey) === queued) { + updateQueueByKeyRef.current.delete(requestKey); + } + }); }, - [projectId, requestKey, workItemId] + [currentUser, onWorkItemUpdated, projectId, requestKey, workItemId] ); if (resolved?.key !== requestKey) { return { workItem: null, status: "loading", - error: null, + issue: null, repoPath: null, members: [], + currentUser, updateWorkItem, refreshWorkItem, }; @@ -190,9 +263,10 @@ export function useTeamInboxWorkItem( return { workItem: resolved.workItem, status: resolved.workItem ? "ready" : "error", - error: resolved.error, + issue: resolved.issue, repoPath: resolved.repoPath, members: resolved.members, + currentUser, updateWorkItem, refreshWorkItem, }; diff --git a/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx b/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx index a65d80cfbe..1136228124 100644 --- a/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx +++ b/src/modules/ProjectManager/ProjectManagerLayout/components/useProjectWorkItemsTabContentInteractions.tsx @@ -12,13 +12,13 @@ import { useCallback, useMemo, useState } from "react"; import { type MemberEntry, - type WorkItemPartialUpdate, enrichedWorkItemToUI, projectApi, } from "@src/api/http/project"; import type { KanbanTask, TaskStatus } from "@src/features/KanbanBoard"; import { useCurrentUserMemberIds } from "@src/hooks/project"; import type { LinearProjectSelection } from "@src/modules/ProjectManager/Panels/ProjectManagerSidebar/content/WorkspaceTreeContent"; +import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate"; import { WORK_ITEMS_KANBAN_GROUP, type WorkItemsKanbanGroup, @@ -99,7 +99,7 @@ export function useProjectWorkItemsTabContentInteractions({ } return [...people.values()]; }, [workItems]); - const { memberIds: currentUserMemberIds } = + const { currentUser, memberIds: currentUserMemberIds } = useCurrentUserMemberIds(workItemPeople); const pinnedKanbanColumnIds = useMemo( () => [...currentUserMemberIds].map((memberId) => `person:${memberId}`), @@ -213,14 +213,7 @@ export function useProjectWorkItemsTabContentInteractions({ return; } - 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 ("endDate" in updates) payload.targetDate = updates.endDate ?? null; + const payload = toWorkItemPartialUpdate(updates, currentUser); if (Object.keys(payload).length === 0) return; const updated = await projectApi.updateWorkItemPartial( @@ -240,7 +233,7 @@ export function useProjectWorkItemsTabContentInteractions({ ) ); }, - [projectOptions, workItemById, setWorkItemsByProject] + [currentUser, projectOptions, workItemById, setWorkItemsByProject] ); const handleKanbanTaskMove = useCallback( diff --git a/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts b/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts index 7d2e27e7ae..d199945188 100644 --- a/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts +++ b/src/modules/ProjectManager/WorkItems/__tests__/workItemPartialUpdate.test.ts @@ -5,14 +5,17 @@ import { toWorkItemPartialUpdate } from "../workItemPartialUpdate"; describe("toWorkItemPartialUpdate", () => { it("maps editable Work Item fields to the project-store payload", () => { expect( - toWorkItemPartialUpdate({ - name: "Inbox thread", - spec: "Unified body", - workItemStatus: "in_progress", - priority: "high", - assignee: { id: "member-1", name: "Ada" }, - labels: [{ id: "label-1", name: "UX", color: "#000000" }], - }) + toWorkItemPartialUpdate( + { + name: "Inbox thread", + spec: "Unified body", + workItemStatus: "in_progress", + priority: "high", + assignee: { id: "member-1", name: "Ada" }, + labels: [{ id: "label-1", name: "UX", color: "#000000" }], + }, + { id: "member-1", name: " Ada " } + ) ).toMatchObject({ title: "Inbox thread", body: "Unified body", @@ -20,6 +23,7 @@ describe("toWorkItemPartialUpdate", () => { priority: "high", assignee: "member-1", labels: ["label-1"], + actor: { id: "member-1", name: "Ada" }, }); }); @@ -28,18 +32,22 @@ describe("toWorkItemPartialUpdate", () => { toWorkItemPartialUpdate({ assignee: null, milestone: null, + project: null, labels: [], endDate: null, }) ).toMatchObject({ assignee: null, milestone: null, + project: null, labels: [], targetDate: null, }); }); it("returns an empty payload when no persisted field changes", () => { - expect(toWorkItemPartialUpdate({})).toEqual({}); + expect( + toWorkItemPartialUpdate({}, { id: "member-1", name: "Ada" }) + ).toEqual({}); }); }); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx index de6ca875e7..b7fb858c1b 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx @@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next"; import { WORK_ITEM_HISTORY_ACTION } from "@src/api/http/project/types"; import Avatar from "@src/components/Avatar"; import Button from "@src/components/Button"; +import ComposerShell from "@src/components/ComposerShell"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import { ActivityTimestamp, @@ -27,6 +28,7 @@ import { } from "@src/modules/shared/components/ActivityTimeline"; import { MarkdownContent } from "@src/modules/shared/components/MarkdownContent"; import RichMarkdownEditor from "@src/modules/shared/components/RichMarkdownEditor"; +import { CollapsibleSection } from "@src/modules/shared/layouts/blocks"; import type { HistoryTabProps, TimelineEntry } from "./types"; @@ -101,11 +103,20 @@ const HistoryTab: React.FC = ({ avatar={ = ({
); - const composer = ( -
- {isThread ? ( - - {currentUser.name.charAt(0).toUpperCase()} - - ) : null} + const hasComment = commentText.trim().length > 0; + const submitButton = ( +
+
{submitButton}
); if (isThread) { return ( -
-
-

- {t("workItems.activity.title")} -

- {subscriptionControl} -
- {timeline} - {composer} +
+ +
+ {timeline} + {composer} +
+
); } @@ -254,6 +287,7 @@ const HistoryTab: React.FC = ({ {subscriptionControl} ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})); + +vi.mock("@src/modules/shared/components/RichMarkdownEditor", () => ({ + default: ({ + appearance, + dataTestId, + matchMarkdownPreview, + minHeight, + showTabs, + }: { + appearance?: string; + dataTestId?: string; + matchMarkdownPreview?: boolean; + minHeight?: number; + showTabs?: boolean; + }) => + createElement("textarea", { + "data-testid": dataTestId, + "data-appearance": appearance, + "data-match-preview": String(matchMarkdownPreview), + "data-min-height": minHeight, + "data-show-tabs": String(showTabs), + }), +})); + +vi.mock("@src/modules/shared/components/MarkdownContent", () => ({ + MarkdownContent: ({ body }: { body: string }) => + createElement("div", null, body), +})); + +const baseProps = { + timelineEntries: [ + { + id: "event-1", + timestamp: "2026-07-27T18:23:00.000Z", + type: WORK_ITEM_HISTORY_ACTION.COMMENTED, + userName: "Yuki", + userAvatar: "https://example.com/yuki.png", + userColor: "#52c41a", + descriptions: ["updated to-dos"], + }, + ], + currentUser: { + id: "user-1", + name: "Yuki", + email: "yuki@example.com", + avatar: "https://example.com/yuki.png", + color: "#52c41a", + }, + isSubscribed: false, + onToggleSubscribe: vi.fn(), + commentText: "", + onCommentTextChange: vi.fn(), + onCommentSubmit: vi.fn(), + isSubmittingComment: false, +}; + +describe("HistoryTab activity presentation", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + observe() {} + + unobserve() {} + + disconnect() {} + } + ); + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + vi.unstubAllGlobals(); + }); + + const renderHistory = (presentation: "default" | "thread" = "default") => { + act(() => { + root.render(createElement(HistoryTab, { ...baseProps, presentation })); + }); + }; + + it("keeps thread activity collapsed by default and exposes its count", () => { + renderHistory("thread"); + + const toggle = container.querySelector( + "[data-testid='work-item-thread-activity-toggle']" + ); + + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + expect(toggle?.textContent).toContain("workItems.activity.title · 1"); + expect(container.textContent).not.toContain("updated to-dos"); + expect( + container.querySelector("[data-testid='work-item-comment-composer']") + ).toBeNull(); + expect( + container.querySelector("[data-testid='work-item-subscription-toggle']") + ).not.toBeNull(); + }); + + it("expands and re-collapses the compact thread activity surface", () => { + renderHistory("thread"); + + const toggle = container.querySelector( + "[data-testid='work-item-thread-activity-toggle']" + ); + + act(() => toggle?.click()); + + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + expect(container.textContent).toContain("updated to-dos"); + + const composer = container.querySelector( + "[data-testid='work-item-comment-composer']" + ); + + expect(composer).not.toBeNull(); + expect(composer?.className).toContain("flex-row items-end"); + expect( + container + .querySelector("[data-testid='work-item-comment-editor']") + ?.getAttribute("data-appearance") + ).toBe("plain"); + expect( + container.querySelectorAll("img[src='https://example.com/yuki.png']") + ).toHaveLength(2); + + act(() => toggle?.click()); + + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + expect(container.textContent).not.toContain("updated to-dos"); + expect( + container.querySelector("[data-testid='work-item-comment-composer']") + ).toBeNull(); + }); + + it("keeps the full editor treatment in the default presentation", () => { + renderHistory(); + + const editor = container.querySelector( + "[data-testid='work-item-comment-editor']" + ); + + expect( + container.querySelector( + "[data-testid='work-item-thread-activity-toggle']" + ) + ).toBeNull(); + expect(container.textContent).toContain("updated to-dos"); + expect( + container.querySelector("[data-testid='work-item-comment-composer']") + ).toBeNull(); + expect(editor?.getAttribute("data-appearance")).toBe("outlined"); + expect(editor?.getAttribute("data-min-height")).toBe("60"); + expect(editor?.getAttribute("data-show-tabs")).toBe("true"); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/TEST_CASES.md b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/TEST_CASES.md index d6a936b3ab..f0b9e1baf3 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/TEST_CASES.md +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/TEST_CASES.md @@ -13,9 +13,38 @@ - GitHub comments and non-comment events reuse the same timeline renderer as the GitHub Issues page. - Rich Markdown Raw mode opts into the same typography and spacing contract as Preview mode. - Timeline cards render an optional shared footer inside the card border. +- Thread Activity starts collapsed, keeps the subscription action available, and shows the event count in its heading. +- Expanding Activity reveals the existing timeline and compact comment composer; collapsing it hides both again. +- The legacy default presentation keeps its expanded history behavior for callers that have not migrated to the shared thread surface. +- Team Inbox and the full "Open work item" destination both use `WorkItemThreadSurface`, including the same ordered property pills and responsive wrapping policy. +- Both thread entry points pass one resolved project-member identity to the comment composer and history timeline. +- Both thread entry points keep the description read-only until Edit and hide Preview/Raw tabs in the focused editor. +- Legacy one-line descriptions containing escaped Markdown line breaks render and edit with real line breaks without being persisted merely by viewing. +- A single inline `\n` in technical prose remains literal and is not treated as a legacy encoded document. +- New comments persist the current member ID, while mutation history persists the same actor ID and display name. +- Legacy history actor IDs resolve through the project member list to the member's current name, avatar, and color. +- Mutations without a trustworthy interactive actor remain system-authored instead of being attributed to the work-item creator. ## Manual visual checks - Compare Raw and Preview with H1-H6 headings, paragraphs, nested lists, task lists, blockquotes, inline code, fenced code, links, horizontal rules, and images in both light and dark themes. - Confirm the editor and Preview retain identical 12px horizontal and 8px vertical content padding. - Confirm the footer does not alter the card radius and that Cancel / Save use the standard panel-footer spacing. +- Open the same Work Item in Team Inbox and through "Open work item"; confirm the description, metadata pills, To-Do, Agent Workflow and collapsed Activity appear in the same order and use the same spacing. +- Resize each entry point from a wide window to a narrow split pane and confirm the property pills wrap without clipping or forcing horizontal scroll. +- Open two different thread-style work items and confirm each Activity section starts collapsed, then verify the chevron and keyboard activation expose the timeline without shifting the surrounding cards. +- Add a comment as a named project member, expand Activity, and confirm the submitted comment and composer show the same name and avatar. +- Reopen an older work item whose history stores an internal member ID and confirm Activity renders the member profile rather than the raw ID. + +## Entry-point lifecycle matrix + +| Transition | Expected state | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Team Inbox row → detail | Shared thread surface mounts; opening the row marks its receipt read without mutating Work Item content. | +| Team Inbox detail → Open work item | Formal tab mounts the same thread composition from canonical Work Item data; no second property rail or legacy tabs appear. | +| Property / description / To-Do update | Canonical partial update completes, then both mounted projections reconcile through the existing data-change path. | +| Start Agent from Inbox | Navigation carries one pending `start_agent` intent; the formal page consumes it once and the canonical orchestrator owns subsequent state. | +| Start Agent from formal page | The already-mounted canonical orchestrator starts directly; loading/lock state remains in the shared workflow section. | +| Open linked Session | The formal page keeps its session overlay/navigation behavior; closing the Session returns to the unchanged thread. | +| Refresh or remote update | `refreshSelectedWorkItem` replaces the open item atomically; the shared surface rerenders metadata, content and workflow from one Work Item value. | +| Work Item or project deleted remotely | Refresh closes the owning tab; an editable ghost thread is not retained. | diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts index 068aaf36f8..8aa750b45b 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts @@ -48,11 +48,13 @@ vi.mock("@src/modules/ProjectManager/shared", () => ({ onDescriptionChange, editable, descriptionDefaultMode, + descriptionShowTabs, }: { initialDescription: string; onDescriptionChange?: (markdown: string, text: string) => void; editable?: boolean; descriptionDefaultMode?: string; + descriptionShowTabs?: boolean; }, _ref ) { @@ -61,6 +63,7 @@ vi.mock("@src/modules/ProjectManager/shared", () => ({ readOnly: !editable, "data-testid": "description-editor", "data-default-mode": descriptionDefaultMode ?? "", + "data-show-tabs": String(descriptionShowTabs ?? true), onChange: (event: React.ChangeEvent) => onDescriptionChange?.(event.target.value, event.target.value), }); @@ -273,6 +276,7 @@ describe("WorkItemContent description editing", () => { ); expect(editor?.readOnly).toBe(false); expect(editor?.getAttribute("data-default-mode")).toBe(""); + expect(editor?.getAttribute("data-show-tabs")).toBe("true"); expect( container.querySelector("[data-testid='description-footer']") ).toBeNull(); @@ -382,6 +386,11 @@ describe("WorkItemContent description editing", () => { expect( container.querySelector("[data-testid='description-editor']") ).not.toBeNull(); + expect( + container + .querySelector("[data-testid='description-editor']") + ?.getAttribute("data-show-tabs") + ).toBe("false"); expect( container.querySelector( "[data-testid='work-item-description-save']" @@ -411,4 +420,63 @@ describe("WorkItemContent description editing", () => { container.querySelector("[data-testid='description-editor']") ).toBeNull(); }); + + it("renders legacy escaped Markdown as real Markdown without rewriting it on view", () => { + const onUpdateWorkItem = vi.fn(); + const legacyMarkdown = + "## 验收目标\\n- 打开 Team Inbox\\n- 验证 Sidebar 未读数"; + + act(() => { + root.render( + createElement(WorkItemContent, { + workItem: { ...baseWorkItem, spec: legacyMarkdown }, + presentation: "thread", + onUpdateWorkItem, + }) + ); + }); + + const rendered = container.querySelector( + "[data-testid='github-read-only-description']" + ); + expect(rendered?.textContent).toBe( + "## 验收目标\n- 打开 Team Inbox\n- 验证 Sidebar 未读数" + ); + expect(rendered?.textContent).not.toContain("\\n"); + expect(onUpdateWorkItem).not.toHaveBeenCalled(); + + act(() => { + container + .querySelector( + "[data-testid='work-item-description-edit']" + ) + ?.click(); + }); + + expect( + container.querySelector( + "[data-testid='description-editor']" + )?.value + ).toBe("## 验收目标\n- 打开 Team Inbox\n- 验证 Sidebar 未读数"); + }); + + it("preserves a single inline escaped newline in technical prose", () => { + act(() => { + root.render( + createElement(WorkItemContent, { + workItem: { + ...baseWorkItem, + spec: "Use `\\n` as the delimiter.", + }, + presentation: "thread", + onUpdateWorkItem: vi.fn(), + }) + ); + }); + + expect( + container.querySelector("[data-testid='github-read-only-description']") + ?.textContent + ).toBe("Use `\\n` as the delimiter."); + }); }); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts index 15793ddace..4c9b9f3cdf 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useWorkItemTimeline.test.ts @@ -186,14 +186,30 @@ describe("work item history timeline", () => { action: WORK_ITEM_HISTORY_ACTION.UPDATED, timestamp: "2026-01-01T00:00:00Z", actorId: "member-2", + actorName: "member-2", summary: "Updated", }, ], }), translate, - [{ id: "member-2", name: "Lin" }] + [ + { + id: "member-2", + name: "Lin", + avatar: "https://example.com/lin.png", + color: "#1677ff", + }, + ] ); expect(entries.map((entry) => entry.userName)).toEqual(["Lin", "Lin"]); + expect(entries.map((entry) => entry.userAvatar)).toEqual([ + "https://example.com/lin.png", + "https://example.com/lin.png", + ]); + expect(entries.map((entry) => entry.userColor)).toEqual([ + "#1677ff", + "#1677ff", + ]); }); }); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/descriptionMarkdown.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/descriptionMarkdown.ts new file mode 100644 index 0000000000..b8e277e1f7 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/descriptionMarkdown.ts @@ -0,0 +1,23 @@ +/** + * Decode legacy Work Item descriptions that were stored as one line with + * literal `\n` sequences instead of real line breaks. + * + * A single inline `\n` is left untouched so technical prose and code examples + * keep their intended meaning. + */ +export function normalizeLegacyEscapedMarkdown(markdown: string): string { + if (!markdown || /[\r\n]/.test(markdown)) return markdown; + + const escapedLineBreaks = markdown.match(/\\r\\n|\\n/g) ?? []; + if (escapedLineBreaks.length === 0) return markdown; + + const hasEscapedMarkdownBlock = + /(?:\\r\\n|\\n)[ \t]*(?:#{1,6}[ \t]|[-+*][ \t]|\d+[.)][ \t]|>[ \t]?|```)/.test( + markdown + ); + if (escapedLineBreaks.length < 2 && !hasEscapedMarkdownBlock) { + return markdown; + } + + return markdown.replace(/\\r\\n|\\n/g, "\n"); +} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx index b1e0a685b0..6338e6d060 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useWorkItemContentState.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TabPillItem } from "@src/components/TabPill"; import { createLogger } from "@src/hooks/logger"; +import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; import { resolveImagePathsForDisplay, unresolveImagePathsForStorage, @@ -48,12 +49,19 @@ export function useWorkItemContentState( } = options; const { t } = useTranslation("projects"); + const { currentUser: resolvedCurrentUser } = + useCurrentUserMemberIds(teamMembers); - const currentUser = currentUserProp ?? { - id: "current", - name: t("workItems.activity.you"), - color: "#52c41a", - }; + const currentUser = useMemo( + () => + currentUserProp ?? + resolvedCurrentUser ?? { + id: "system", + name: t("workItems.activity.system"), + color: "var(--color-fill-3)", + }, + [currentUserProp, resolvedCurrentUser, t] + ); const [activeSessionTab, setActiveSessionTab] = useState("session"); @@ -164,9 +172,19 @@ export function useWorkItemContentState( // --- Timeline --- + const timelineMembers = useMemo( + () => + currentUser + ? [ + ...teamMembers.filter((member) => member.id !== currentUser.id), + currentUser, + ] + : teamMembers, + [currentUser, teamMembers] + ); const { timelineEntries } = useWorkItemTimeline({ workItem, - teamMembers, + teamMembers: timelineMembers, }); // --- Handlers --- @@ -213,7 +231,7 @@ export function useWorkItemContentState( try { const newComment = { id: `cmt-${Date.now()}`, - author: currentUser.name, + author: currentUser.id, content: commentText.trim(), created_at: new Date().toISOString(), }; @@ -230,7 +248,7 @@ export function useWorkItemContentState( commentText, isSubmittingComment, workItem, - currentUser.name, + currentUser.id, onUpdateWorkItem, ]); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx index c090e83e55..29eefd0fa9 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx @@ -43,6 +43,7 @@ import { WorkItemThreadLayout } from "../WorkItemThread"; import HistoryTab from "./HistoryTab"; import OutputTab from "./OutputTab"; import ThreadTodoChecklist from "./ThreadTodoChecklist"; +import { normalizeLegacyEscapedMarkdown } from "./descriptionMarkdown"; import { useGitHubIssueTimeline } from "./hooks/useGitHubIssueTimeline"; import { useWorkItemContentState } from "./hooks/useWorkItemContentState"; import { resolveWorkItemContentSectionPolicy } from "./presentation"; @@ -227,7 +228,11 @@ const WorkItemContent: React.FC = ({ teamMembers?.find((member) => member.id === workItem.user_id)?.name || workItem.user_id || t("workItems.activity.system"); - const displayedDescription = resolvedDescription ?? rawDescription; + const normalizedRawDescription = + normalizeLegacyEscapedMarkdown(rawDescription); + const displayedDescription = normalizeLegacyEscapedMarkdown( + resolvedDescription ?? rawDescription + ); const displayStatus = workItem.workItemStatus ?? workItem.status; const isGitHubWorkItem = displayStatus === WORK_ITEM_STATUS.GITHUB_OPEN || @@ -314,7 +319,7 @@ const WorkItemContent: React.FC = ({ } > = ({ descriptionMinHeight={isThread ? 120 : 200} descriptionMaxHeight={isThread ? 360 : 600} descriptionDefaultMode={isThread ? "raw" : undefined} + descriptionShowTabs={!isThread} descriptionClassName="no-bottom-border" repoPath={repoPath} className="w-full" @@ -483,6 +489,7 @@ const WorkItemContent: React.FC = ({ const historyContent = ( [member.id, member.name]) - ); + const memberById = new Map(teamMembers.map((member) => [member.id, member])); const entries = workItem.history?.map((event) => - historyEventToTimelineEntry(event, t, memberNameById) + historyEventToTimelineEntry(event, t, memberById) ) ?? []; const existingCommentIds = commentIdsFromHistory(workItem.history ?? []); @@ -59,11 +57,14 @@ export function buildWorkItemTimelineEntries( continue; } + const author = memberById.get(comment.author); entries.push({ id: comment.id, timestamp: comment.created_at, type: WORK_ITEM_HISTORY_ACTION.COMMENTED, - userName: memberNameById.get(comment.author) ?? comment.author, + userName: author?.name ?? comment.author, + userAvatar: author?.avatar, + userColor: author?.color, descriptions: [comment.content || t("workItems.activity.commented")], }); } @@ -90,17 +91,20 @@ function commentIdsFromHistory(history: WorkItemHistoryEvent[]): Set { function historyEventToTimelineEntry( event: WorkItemHistoryEvent, t: TimelineTranslator, - memberNameById: ReadonlyMap + memberById: ReadonlyMap ): TimelineEntry { + const actor = event.actorId ? memberById.get(event.actorId) : undefined; return { id: event.id, timestamp: event.timestamp, type: event.action, userName: + actor?.name || event.actorName || - (event.actorId ? memberNameById.get(event.actorId) : undefined) || event.actorId || t("workItems.activity.system"), + userAvatar: actor?.avatar, + userColor: actor?.color, descriptions: eventDescriptions(event, t), }; } diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx index 3ba4d0b7d5..e06b1e94a6 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/index.tsx @@ -60,6 +60,21 @@ export const WORK_ITEM_PROPERTY_INLINE_FIELDS: WorkItemPropertyFieldKey[] = [ "priority", ]; +/** + * Canonical property summary for thread-style Work Item surfaces. + * + * Keep this list shared so opening the same Work Item from another surface + * does not silently change its visible metadata or ordering. + */ +export const WORK_ITEM_THREAD_PROPERTY_FIELDS: WorkItemPropertyFieldKey[] = [ + "project", + "status", + "priority", + "assignee", + "reviewer", + "date", +]; + const DEFAULT_VISIBLE_FIELDS: WorkItemPropertyFieldKey[] = [ "project", "status", diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/__tests__/WorkItemThreadSurface.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/__tests__/WorkItemThreadSurface.test.ts new file mode 100644 index 0000000000..bfd1760265 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/__tests__/WorkItemThreadSurface.test.ts @@ -0,0 +1,95 @@ +import React, { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import type { WorkItem } from "@src/types/core/workItem"; + +import WorkItemThreadSurface from "../index"; + +vi.mock("../../WorkItemContent", () => ({ + default: ({ + presentation, + headerProperties, + }: { + presentation?: string; + headerProperties?: React.ReactNode; + }) => + createElement( + "div", + { + "data-testid": "work-item-content", + "data-presentation": presentation, + }, + headerProperties + ), +})); + +vi.mock("../../WorkItemProperties", () => ({ + WORK_ITEM_THREAD_PROPERTY_FIELDS: [ + "project", + "status", + "priority", + "assignee", + "reviewer", + "date", + ], + default: ({ + fieldVariant, + pillLayout, + visibleFields, + showMoreMenu, + }: { + fieldVariant?: string; + pillLayout?: string; + visibleFields?: string[]; + showMoreMenu?: boolean; + }) => + createElement("div", { + "data-testid": "work-item-properties", + "data-field-variant": fieldVariant, + "data-pill-layout": pillLayout, + "data-visible-fields": visibleFields?.join(","), + "data-show-more": String(showMoreMenu), + }), +})); + +const workItem = { + session_id: "work-item-1", + user_id: "member-1", + name: "Unify Work Item surfaces", + status: "backlog", + star: false, + target_date: null, + created_time: "2026-07-28T00:00:00.000Z", + updated_time: "2026-07-28T00:00:00.000Z", +} as WorkItem; + +describe("WorkItemThreadSurface", () => { + it("enforces the canonical thread presentation and responsive metadata", () => { + const markup = renderToStaticMarkup( + createElement(WorkItemThreadSurface, { + workItem, + propertyProps: { + onUpdate: vi.fn(), + }, + }) + ); + + expect(markup).toContain('data-presentation="thread"'); + expect(markup).toContain('data-field-variant="pill"'); + expect(markup).toContain('data-pill-layout="wrap"'); + expect(markup).toContain( + 'data-visible-fields="project,status,priority,assignee,reviewer,date"' + ); + expect(markup).toContain('data-show-more="true"'); + }); + + it("keeps read-only threads usable when no property source exists", () => { + const markup = renderToStaticMarkup( + createElement(WorkItemThreadSurface, { workItem }) + ); + + expect(markup).toContain('data-presentation="thread"'); + expect(markup).not.toContain('data-testid="work-item-properties"'); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/index.tsx new file mode 100644 index 0000000000..3f6ae5c417 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThreadSurface/index.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +import WorkItemContent from "../WorkItemContent"; +import type { WorkItemContentProps } from "../WorkItemContent/types"; +import WorkItemProperties, { + WORK_ITEM_THREAD_PROPERTY_FIELDS, +} from "../WorkItemProperties"; +import type { WorkItemPropertiesProps } from "../WorkItemProperties/types"; + +type ThreadPropertyProps = Omit< + WorkItemPropertiesProps, + "workItem" | "fieldVariant" | "pillLayout" | "visibleFields" | "showMoreMenu" +>; + +export interface WorkItemThreadSurfaceProps extends Omit< + WorkItemContentProps, + "presentation" | "headerProperties" +> { + /** + * Omit this configuration when the thread has no editable property source. + * The content remains readable and keeps the same thread presentation. + */ + propertyProps?: ThreadPropertyProps; +} + +/** + * Canonical Work Item thread composition used by embedded and full-page + * surfaces. Navigation shells remain independent, while content hierarchy, + * metadata density, and responsive pill behavior stay identical. + */ +const WorkItemThreadSurface: React.FC = ({ + workItem, + propertyProps, + ...contentProps +}) => { + const headerProperties = propertyProps ? ( + + ) : undefined; + + return ( + + ); +}; + +export default WorkItemThreadSurface; diff --git a/src/modules/ProjectManager/WorkItems/components/index.ts b/src/modules/ProjectManager/WorkItems/components/index.ts index 30aca29a9a..7dbd6a5e91 100644 --- a/src/modules/ProjectManager/WorkItems/components/index.ts +++ b/src/modules/ProjectManager/WorkItems/components/index.ts @@ -7,7 +7,11 @@ export { default as WorkItemContextMenu } from "./WorkItemContextMenu"; export { default as WorkItemDetail } from "./WorkItemDetail"; export type { WorkItemDetailActions } from "./WorkItemDetail"; export { default as WorkItemDetailPage } from "./WorkItemDetailPage"; -export { default as WorkItemProperties } from "./WorkItemProperties"; +export { + default as WorkItemProperties, + WORK_ITEM_THREAD_PROPERTY_FIELDS, +} from "./WorkItemProperties"; +export { default as WorkItemThreadSurface } from "./WorkItemThreadSurface"; export { default as WorkItemRow } from "./WorkItemRow"; export { default as WorkItemSection } from "./WorkItemSection"; export { default as WorkItemsListContent } from "./WorkItemsListContent"; diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts index f8f8c73a7b..f101a72dd4 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts @@ -18,7 +18,6 @@ import type { RustCalendarEvent, RustGanttTask, RustKanbanTask, - WorkItemPartialUpdate, WorkItemsViewData, } from "@src/api/http/project"; import type { CalendarEvent } from "@src/features/CalendarView"; @@ -27,9 +26,11 @@ import type { KanbanTask } from "@src/features/KanbanBoard"; import { createLogger } from "@src/hooks/logger"; import { useDebouncedCallback } from "@src/hooks/perf"; import { useProjectDataChanged } from "@src/hooks/project"; +import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; import type { WorkItem as WorkItemExtended } from "@src/types/core/workItem"; import { type OnAssignmentChanges, type StatusFilterType } from "../types"; +import { toWorkItemPartialUpdate } from "../workItemPartialUpdate"; import { countWorkItemsByStatus, getWorkItemNavigation, @@ -42,95 +43,6 @@ const logger = createLogger("useWorkItemsData"); // Type Converters // ============================================ -/** - * Convert UI WorkItemExtended partial updates to Rust WorkItemPartialUpdate format. - * Only includes fields that are present in the input. - */ -function uiToPartialUpdate( - data: Partial -): WorkItemPartialUpdate { - const updates: WorkItemPartialUpdate = {}; - - if (data.name !== undefined) { - updates.title = data.name; - } - if (data.spec !== undefined) { - updates.body = data.spec; - } - if (data.workItemStatus !== undefined) { - updates.status = data.workItemStatus; - } - if (data.priority !== undefined) { - updates.priority = data.priority; - } - if ("project" in data) { - updates.project = data.project?.id ?? null; - } - // WorkItem uses `star`, not `starred` - if (data.star !== undefined) { - updates.starred = data.star; - } - if ("assignee" in data) { - updates.assignee = data.assignee?.id ?? null; - } - if ("assigneeType" in data) { - updates.assigneeType = data.assigneeType ?? null; - } - if ("labels" in data) { - updates.labels = data.labels?.map((label) => label.id) ?? []; - } - if ("milestone" in data) { - updates.milestone = data.milestone?.id ?? null; - } - if ("startDate" in data) { - updates.startDate = data.startDate ?? null; - } - if ("endDate" in data) { - updates.targetDate = data.endDate ?? null; - } - if ("target_date" in data) { - updates.targetDate = data.target_date ?? null; - } - if (data.todos !== undefined) { - updates.todos = data.todos?.map((todo) => ({ - id: todo.id, - content: todo.content, - status: todo.status, - })); - } - if (data.comments !== undefined) { - updates.comments = data.comments?.map((comment) => ({ - id: comment.id, - author: comment.author, - content: comment.content, - created_at: comment.created_at, - })); - } - if (data.linkedSessions !== undefined) { - updates.linkedSessions = data.linkedSessions; - } - if (data.orchestratorConfig !== undefined) { - updates.orchestratorConfig = data.orchestratorConfig; - } - if (data.orchestratorState !== undefined) { - updates.orchestratorState = data.orchestratorState; - } - if (data.schedule !== undefined) { - updates.schedule = data.schedule ?? null; - } - if (data.executionLock !== undefined) { - updates.executionLock = data.executionLock ?? null; - } - if (data.closeOut !== undefined) { - updates.closeOut = data.closeOut ?? null; - } - if (data.workProducts !== undefined) { - updates.workProducts = data.workProducts; - } - - return updates; -} - function rustKanbanToFrontend(task: RustKanbanTask): KanbanTask { return { id: task.id, @@ -283,6 +195,7 @@ export function useWorkItemsData({ // Members: use shared data from useProjectData, only fetch if not provided const [localMembers, setLocalMembers] = useState([]); const members = sharedMembers?.length ? sharedMembers : localMembers; + const { currentUser } = useCurrentUserMemberIds(members); useEffect(() => { if (sharedMembers?.length || !projectSlug) return; @@ -314,7 +227,7 @@ export function useWorkItemsData({ return false; } - const updates = uiToPartialUpdate(data); + const updates = toWorkItemPartialUpdate(data, currentUser); if (Object.keys(updates).length === 0) { return true; } @@ -343,7 +256,7 @@ export function useWorkItemsData({ return false; } }, - [projectSlug, shortIdMap] + [currentUser, projectSlug, shortIdMap] ); const teamId = "file"; diff --git a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts index ebed82af23..ce63214c9b 100644 --- a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts +++ b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts @@ -1,12 +1,17 @@ -import type { WorkItemPartialUpdate } from "@src/api/http/project"; +import type { + WorkItemMutationActor, + WorkItemPartialUpdate, +} from "@src/api/http/project"; +import type { Person } from "@src/types/core/shared"; import type { WorkItem } from "@src/types/core/workItem"; export type WorkItemUiPatch = Omit< Partial, - "assignee" | "milestone" | "endDate" | "target_date" + "assignee" | "milestone" | "project" | "endDate" | "target_date" > & { assignee?: WorkItem["assignee"] | null; milestone?: WorkItem["milestone"] | null; + project?: WorkItem["project"] | null; endDate?: WorkItem["endDate"] | null; target_date?: WorkItem["target_date"] | null; }; @@ -18,7 +23,8 @@ export type WorkItemUiPatch = Omit< * drift on which fields are persisted. */ export function toWorkItemPartialUpdate( - updates: WorkItemUiPatch + updates: WorkItemUiPatch, + actor?: Pick | null ): WorkItemPartialUpdate { const payload: WorkItemPartialUpdate = {}; @@ -28,7 +34,7 @@ export function toWorkItemPartialUpdate( payload.status = updates.workItemStatus; } if (updates.priority !== undefined) payload.priority = updates.priority; - if (updates.project?.id) payload.project = updates.project.id; + if ("project" in updates) payload.project = updates.project?.id ?? null; if (updates.star !== undefined) payload.starred = updates.star; if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null; if ("assigneeType" in updates) { @@ -78,5 +84,20 @@ export function toWorkItemPartialUpdate( payload.workProducts = updates.workProducts; } - return payload; + return withWorkItemMutationActor(payload, actor); +} + +export function withWorkItemMutationActor( + payload: WorkItemPartialUpdate, + actor?: Pick | null +): WorkItemPartialUpdate { + const hasMutation = Object.keys(payload).some((field) => field !== "actor"); + if (!hasMutation || !actor) return payload; + + const id = actor.id.trim(); + const name = actor.name.trim(); + if (!id || !name) return payload; + + const normalizedActor: WorkItemMutationActor = { id, name }; + return { ...payload, actor: normalizedActor }; } diff --git a/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx b/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx index 1ce2651309..69b09bf9b4 100644 --- a/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx +++ b/src/modules/ProjectManager/shared/components/ProjectContentEditor/index.tsx @@ -71,6 +71,7 @@ export interface ProjectContentEditorProps { descriptionMinHeight?: number; descriptionMaxHeight?: number | string; descriptionDefaultMode?: RichMarkdownEditorMode; + descriptionShowTabs?: boolean; repoPath?: string | null; dataTestId?: string; } @@ -144,6 +145,7 @@ const ProjectContentEditor = forwardRef< descriptionMinHeight = 200, descriptionMaxHeight, descriptionDefaultMode, + descriptionShowTabs = true, repoPath, dataTestId, }, @@ -395,6 +397,7 @@ const ProjectContentEditor = forwardRef< minHeight={descriptionMinHeight} maxHeight={descriptionMaxHeight} defaultMode={descriptionDefaultMode} + showTabs={descriptionShowTabs} editable={editable} toolbarClassName="work-item-toolbar" className={`noDrag flex-1 cursor-text rounded-md text-[14px] text-text-1 ${descriptionClassName}`.trim()} diff --git a/src/modules/shared/layouts/blocks/CollapsibleSection.tsx b/src/modules/shared/layouts/blocks/CollapsibleSection.tsx index bfe06fb848..a3d77a061f 100644 --- a/src/modules/shared/layouts/blocks/CollapsibleSection.tsx +++ b/src/modules/shared/layouts/blocks/CollapsibleSection.tsx @@ -101,6 +101,7 @@ const CollapsibleSection: React.FC = ({