diff --git a/docs/architecture-audit-2026-07-23/TeamInbox.md b/docs/architecture-audit-2026-07-23/TeamInbox.md index 7d4be898c..609000296 100644 --- a/docs/architecture-audit-2026-07-23/TeamInbox.md +++ b/docs/architecture-audit-2026-07-23/TeamInbox.md @@ -7,7 +7,7 @@ - TypeScript `tsc --noEmit`: passed. - Tauri application `cargo check -p org2`: passed. -- Focused Rust Team Inbox tests: 7 passed. +- Focused Rust Team Inbox tests: 12 passed. ## Layer 2 — Dead code and structural deduplication diff --git a/docs/frontend-ui-audit-2026-07-23/TeamInbox.md b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md index 09b75a445..31a98b45b 100644 --- a/docs/frontend-ui-audit-2026-07-23/TeamInbox.md +++ b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md @@ -1,7 +1,7 @@ # Frontend UI Audit — Team Inbox -**Files:** `src/modules/MainApp/TeamInbox/**/*.tsx` -**Date:** 2026-07-23 +**Files:** `src/modules/MainApp/TeamInbox/**/*.tsx` +**Date:** 2026-07-23 **Auditor:** ORGII implementation session ## D1 — Raw HTML vs Design System diff --git a/docs/frontend-ui-audit-2026-07-24/TeamInboxReactLifecycle.md b/docs/frontend-ui-audit-2026-07-24/TeamInboxReactLifecycle.md new file mode 100644 index 000000000..749e5ba41 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-24/TeamInboxReactLifecycle.md @@ -0,0 +1,28 @@ +# Team Inbox React Lifecycle — Frontend UI Audit + +## Scope + +- `src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx` +- `src/modules/MainApp/TeamInbox/TeamInboxView.tsx` +- `src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts` + +## Summary + +| Verdict | Count | +| ---------------- | ----: | +| fix | 2 | +| keep with reason | 4 | +| abstract | 0 | + +No cross-file design-system sweep candidate was found. + +## Findings + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------------: | ------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `TeamInboxList.tsx:87` | Recency grouping | fix | Calling `Date.now()` during render violates React render purity and makes the memo depend on an untracked value. | Pass the load-time reference timestamp from the owning view and include it in the memo dependencies. | +| `useTeamInboxWorkItemBody.ts:38` | Selected Work Item body effect | fix | Synchronously resetting state inside the effect causes an extra render and trips the React lifecycle rule. | Tag resolved state with the request key and derive the loading fallback during render when keys differ. | +| `TeamInboxView.tsx:64` | Page load lifecycle | keep with reason | The effect owns one abort controller per request, checks cancellation before state writes, and aborts on dependency change or unmount. | Keep. | +| `TeamInboxView.tsx:313` | Loading/error/empty states | keep with reason | The shared `Placeholder` component expresses all three states consistently with the rest of the application. | Keep. | +| `TeamInboxView.tsx:328` | Split list/detail composition | keep with reason | `SplitViewLayout` and `TeamInboxList` are existing shared layout and feature boundaries; another wrapper would add indirection. | Keep. | +| `TeamInboxList.tsx:92` | Filter tabs and unread badges | keep with reason | The implementation uses the shared `TabPill` control, translated labels, and explicit accessible badge labels. | Keep. | diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.ts index b35b1cd12..feb8da568 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.ts @@ -597,6 +597,7 @@ export function useOrg2CloudRealtime(): void { const unsubscribes: Array<() => void> = []; const orgId = activeRealtimeOrgId; + const orgTeardownAt = orgTeardownAtRef.current; if (!broadcastSignals) { unsubscribes.push( connection.subscribe({ @@ -642,7 +643,7 @@ export function useOrg2CloudRealtime(): void { return () => { for (const unsub of unsubscribes) unsub(); - orgTeardownAtRef.current.set(orgId, Date.now()); + orgTeardownAt.set(orgId, Date.now()); setRosterRealtimeConnected((current) => { if (!(orgId in current)) return current; const next = { ...current }; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index c58e50900..a441c43d6 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -1038,6 +1038,101 @@ "markAllAsRead": "Mark all as read", "scrollToUnread": "Go to unread" }, + "teamInbox": { + "title": "Team Inbox", + "listLabel": "Team Inbox list", + "itemsLabel": "Team Inbox items", + "unreadCount": "{{count}} unread", + "allRead": "All caught up", + "loadMore": "Load more", + "filters": { + "all": "All", + "mentions": "Mentions", + "assigned": "Assigned" + }, + "status": { + "read": "Read", + "unread": "Unread" + }, + "row": { + "assignedSummary": "{{status}} · {{priority}}" + }, + "search": { + "placeholder": "Search inbox", + "ariaLabel": "Search Team Inbox" + }, + "groups": { + "today": "Today", + "yesterday": "Yesterday", + "thisWeek": "This week", + "earlier": "Earlier" + }, + "empty": { + "title": "Nothing here yet", + "subtitle": "Mentions and assigned work items will appear here.", + "selectTitle": "Select an item", + "selectSubtitle": "View its comment context or work item details.", + "mentions": { + "title": "No mentions", + "subtitle": "When a teammate @mentions you in a comment, it shows up here." + }, + "assigned": { + "title": "Nothing assigned to you", + "subtitle": "Work items assigned to you will appear here." + }, + "noResults": { + "title": "No matches", + "subtitle": "No items match “{{query}}”." + } + }, + "loading": "Loading Team Inbox…", + "loadMore": "Load more", + "errors": { + "loadTitle": "Unable to load Team Inbox", + "load": "Unable to load Team Inbox", + "refresh": "Unable to refresh Team Inbox", + "markRead": "Unable to mark this item as read. Try again.", + "markUnread": "Unable to mark this item as unread. Try again.", + "markAllRead": "Unable to mark all items as read. Try again." + }, + "detail": { + "assignedSubtitle": "Assigned work item", + "mentionSubtitle": "Mentioned in a comment", + "mentionedYou": "mentioned you" + }, + "actions": { + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "openWorkItem": "Open work item", + "openSession": "Open session" + }, + "fields": { + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "workItemId": "Work item ID", + "session": "Session", + "comments": "Comments", + "threadId": "Thread ID", + "commentId": "Comment ID" + }, + "workItemStatus": { + "backlog": "Backlog", + "todo": "To do", + "in_progress": "In Progress", + "in_review": "In Review", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "none": "No priority", + "low": "Low", + "medium": "Medium", + "high": "High", + "urgent": "Urgent" + } + }, "placeholderTypes": { "mcpServer": "MCP server", "connection": "connection", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 90497d104..5f0a0505c 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -1025,6 +1025,101 @@ "markAllAsRead": "全部标记为已读", "scrollToUnread": "跳转到未读" }, + "teamInbox": { + "title": "团队收件箱", + "listLabel": "团队收件箱列表", + "itemsLabel": "团队收件箱事项", + "unreadCount": "{{count}} 条未读", + "allRead": "已全部阅读", + "loadMore": "加载更多", + "filters": { + "all": "全部", + "mentions": "提及", + "assigned": "分配给我" + }, + "status": { + "read": "已读", + "unread": "未读" + }, + "row": { + "assignedSummary": "{{status}} · {{priority}}" + }, + "search": { + "placeholder": "搜索收件箱", + "ariaLabel": "搜索团队收件箱" + }, + "groups": { + "today": "今天", + "yesterday": "昨天", + "thisWeek": "本周", + "earlier": "更早" + }, + "empty": { + "title": "暂无事项", + "subtitle": "新的提及和分配会显示在这里。", + "selectTitle": "选择一个事项", + "selectSubtitle": "查看评论上下文或工作项详情。", + "mentions": { + "title": "暂无提及", + "subtitle": "当同事在评论中 @ 你时,会显示在这里。" + }, + "assigned": { + "title": "暂无分配给你的事项", + "subtitle": "分配给你的工作项会显示在这里。" + }, + "noResults": { + "title": "无匹配结果", + "subtitle": "没有与「{{query}}」匹配的事项。" + } + }, + "loading": "正在加载团队收件箱…", + "loadMore": "加载更多", + "errors": { + "loadTitle": "无法加载团队收件箱", + "load": "无法加载团队收件箱", + "refresh": "无法刷新团队收件箱", + "markRead": "标记已读失败,请重试。", + "markUnread": "标记未读失败,请重试。", + "markAllRead": "全部标记已读失败,请重试。" + }, + "detail": { + "assignedSubtitle": "分配给你的工作项", + "mentionSubtitle": "评论中提及了你", + "mentionedYou": "提及了你" + }, + "actions": { + "markRead": "标记已读", + "markUnread": "标记未读", + "openWorkItem": "打开工作项", + "openSession": "打开会话" + }, + "fields": { + "status": "状态", + "priority": "优先级", + "assignee": "负责人", + "workItemId": "工作项 ID", + "session": "会话", + "comments": "评论数", + "threadId": "话题 ID", + "commentId": "评论 ID" + }, + "workItemStatus": { + "backlog": "待办池", + "todo": "待办", + "in_progress": "进行中", + "in_review": "审核中", + "blocked": "受阻", + "done": "已完成", + "cancelled": "已取消" + }, + "priority": { + "none": "无优先级", + "low": "低", + "medium": "中", + "high": "高", + "urgent": "紧急" + } + }, "placeholderTypes": { "mcpServer": "MCP 服务器", "connection": "连接", diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx index cec6bc6d2..598eee068 100644 --- a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx +++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx @@ -51,13 +51,13 @@ const TeamInboxView: React.FC = ({ const [filter, setFilter] = useState(initialFilter); const [query, setQuery] = useState(""); const [items, setItems] = useState([]); - const [recencyAnchorMs, setRecencyAnchorMs] = useState(() => Date.now()); const [requestedItemId, setRequestedItemId] = useState(null); const [loadState, setLoadState] = useState({ status: "loading", message: null, }); const [reloadRevision, setReloadRevision] = useState(0); + const [groupingReferenceTime, setGroupingReferenceTime] = useState(0); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); @@ -68,8 +68,8 @@ const TeamInboxView: React.FC = ({ .listPage({ limit: pageSize, signal: abortController.signal }) .then((page) => { if (abortController.signal.aborted) return; + setGroupingReferenceTime(Date.now()); setItems(page.items); - setRecencyAnchorMs(Date.now()); setHasMore(page.nextCursor != null); setLoadState({ status: "ready", message: null }); }) @@ -328,10 +328,10 @@ const TeamInboxView: React.FC = ({ void; @@ -61,10 +61,10 @@ function filterCountBadge(count: number, ariaLabel: string): React.ReactNode { const TeamInboxList: React.FC = ({ filter, items, - recencyAnchorMs, selectedItemId, totalUnread, unreadCounts, + groupingReferenceTime, query, loading, onQueryChange, @@ -85,8 +85,8 @@ const TeamInboxList: React.FC = ({ [items, selectedItemId] ); const groups = useMemo( - () => groupTeamInboxItemsByRecency(items, recencyAnchorMs), - [items, recencyAnchorMs] + () => groupTeamInboxItemsByRecency(items, groupingReferenceTime), + [groupingReferenceTime, items] ); const activeFilterUnread = unreadCounts[filter]; const filterTabs = useMemo( diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts index 3812a0617..815be3175 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts @@ -166,8 +166,6 @@ export function useTeamInboxDataSource(): { 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) @@ -284,7 +282,7 @@ export function useTeamInboxDataSource(): { const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; const overlaidMentions = overlayCloudReadReceipts( mentionItems, - cloudReadReceiptsRef.current, + cloudReadReceipts, cloudScopeKey ); const mergedItems = [...overlaidMentions, ...localItems]; @@ -380,7 +378,7 @@ export function useTeamInboxDataSource(): { const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; const appendedMentions = overlayCloudReadReceipts( mapMentionsToItems(cloudResult.mentions, activeCloudOrgId ?? ""), - cloudReadReceiptsRef.current, + cloudReadReceipts, cloudScopeKey ); const appended = resolveAssigneeDisplayNames( @@ -512,6 +510,7 @@ export function useTeamInboxDataSource(): { cache.error, cache.hasMore, cache.items, + cloudReadReceipts, invalidate, setCache, setCloudReadReceipts, diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.test.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.test.ts new file mode 100644 index 000000000..a26efea95 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { WorkItemTarget } from "./domain"; +import { useTeamInboxWorkItemBody } from "./useTeamInboxWorkItemBody"; + +const mocks = vi.hoisted(() => ({ + readWorkItem: vi.fn(), +})); + +vi.mock("@src/api/http/project", () => ({ + projectApi: { + readWorkItem: mocks.readWorkItem, + readStandaloneWorkItem: vi.fn(), + }, +})); + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +const Harness = ({ target }: { target: WorkItemTarget }) => { + const state = useTeamInboxWorkItemBody(target); + return createElement("output", { + "data-body": state.body ?? "", + "data-loading": String(state.loading), + }); +}; + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +describe("useTeamInboxWorkItemBody", () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + afterAll(() => { + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("shows loading for a new target and discards the stale response", async () => { + const first = deferred<{ body: string }>(); + const second = deferred<{ body: string }>(); + mocks.readWorkItem + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + await act(async () => { + root.render( + createElement(Harness, { + target: { + kind: "work_item", + projectId: "project-a", + workItemId: "item-a", + }, + }) + ); + }); + expect(container.querySelector("output")?.dataset.loading).toBe("true"); + + await act(async () => { + root.render( + createElement(Harness, { + target: { + kind: "work_item", + projectId: "project-b", + workItemId: "item-b", + }, + }) + ); + }); + expect(container.querySelector("output")?.dataset.loading).toBe("true"); + + await act(async () => { + first.resolve({ body: "stale body" }); + await first.promise; + }); + expect(container.querySelector("output")?.dataset.loading).toBe("true"); + expect(container.querySelector("output")?.dataset.body).toBe(""); + + await act(async () => { + second.resolve({ body: "current body" }); + await second.promise; + }); + expect(container.querySelector("output")?.dataset.loading).toBe("false"); + expect(container.querySelector("output")?.dataset.body).toBe( + "current body" + ); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts index 49bf10560..b48211e2b 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItemBody.ts @@ -13,7 +13,7 @@ export interface TeamInboxWorkItemBodyState { loading: boolean; } -interface ResolvedWorkItemBodyState extends TeamInboxWorkItemBodyState { +interface KeyedTeamInboxWorkItemBodyState extends TeamInboxWorkItemBodyState { requestKey: string; } @@ -29,7 +29,7 @@ export function useTeamInboxWorkItemBody( ): TeamInboxWorkItemBodyState { const { projectId, workItemId } = target; const requestKey = `${projectId ?? "standalone"}:${workItemId}`; - const [state, setState] = useState({ + const [state, setState] = useState({ requestKey, body: null, loading: true, @@ -63,7 +63,8 @@ export function useTeamInboxWorkItemBody( }; }, [projectId, requestKey, workItemId]); - return state.requestKey === requestKey - ? state - : { body: null, loading: true }; + if (state.requestKey !== requestKey) { + return { body: null, loading: true }; + } + return { body: state.body, loading: state.loading }; } diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts index 3750676f9..be1870d4b 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts @@ -36,7 +36,7 @@ interface UseWorkstationSidebarPinnedAndRevealDataParams { newSessionLabel: string; runtimeLabel: string; teamInboxLabel: string; - teamInboxUnreadCount?: number; + teamInboxUnreadCount: number; t: TFunction<"navigation">; tSessions: TFunction<"sessions">; } diff --git a/src/store/chatPanel/chatPanelTabsModel.ts b/src/store/chatPanel/chatPanelTabsModel.ts index 4e53514df..c8d9ee78f 100644 --- a/src/store/chatPanel/chatPanelTabsModel.ts +++ b/src/store/chatPanel/chatPanelTabsModel.ts @@ -98,6 +98,7 @@ const PERSISTED_CHAT_PANEL_TAB_TYPES = new Set([ "session", "start-page", "runtime", + "team-inbox", "work-management", "workspace", "organization",