diff --git a/docs/architecture-audit-2026-07-23/TeamInbox.md b/docs/architecture-audit-2026-07-23/TeamInbox.md new file mode 100644 index 000000000..609000296 --- /dev/null +++ b/docs/architecture-audit-2026-07-23/TeamInbox.md @@ -0,0 +1,82 @@ +# Architecture Audit — Team Inbox + +**Scope:** Team Inbox TypeScript domain/UI/data source, managed-cloud mention RPC client, project-management SQLite projection, Tauri commands, Sidebar and Chat Panel tab integration. +**Date:** 2026-07-23 + +## Layer 1 — Compilation correctness + +- TypeScript `tsc --noEmit`: passed. +- Tauri application `cargo check -p org2`: passed. +- Focused Rust Team Inbox tests: 12 passed. + +## Layer 2 — Dead code and structural deduplication + +- Production entry path is Sidebar row → singleton Team Inbox tab → connected view → shared cache/data source → local Tauri projection plus managed-cloud mention RPC. +- Sidebar badge and rendered page consume the same cache; no second unread query implementation remains. +- Local assignment reads remain in SQLite; the frontend does not rescan every project Work Item. +- Mention response mapping is centralized in the Team Inbox data source; sorting/filtering/deduplication remain pure domain selectors. + +## Layer 3 — Naming consistency + +- Wire `work_item_assigned` is mapped once to UI `assigned_work_item`; names are explicit at the boundary. +- `viewerMemberIds` is used consistently for the local viewer identity. The cloud RPC deliberately accepts no viewer ID because JWT identity is authoritative. +- Sidebar/menu/tab terms consistently use `team-inbox` / `Team Inbox`. + +## Layer 4 — Semantic overloading + +| Term | Meaning in this change | Verdict | +| ------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| viewer | Explicit local project member IDs, or managed-cloud JWT subject | Kept separate at transport boundaries; never inferred from an agent/session ID. | +| read receipt | SQLite viewer-scoped receipt for local assignment; endpoint+user+org scoped persisted receipt for cloud mention | Separate storage owners with one UI read state. | +| projectId | Project slug for project-store navigation; empty for standalone Work Items | Boundary is explicit and standalone navigation uses the standalone API. | + +## Layer 5 — Default branch analysis + +- Item-kind branching uses discriminated unions with explicit mention/assignment cases; unsupported wire combinations throw. +- Local mentions filter returns an explicit empty page rather than falling through to assignments. +- Cloud RPC failure degrades to local items only; it does not fabricate mention data or scan comment bodies. + +## Layer 6 — Cross-domain concept leakage + +- Project-management owns only local assigned Work Item projection and receipt DDL. +- Managed-cloud mention transport remains under `features/Org2Cloud`. +- Presentation consumes a transport-independent Team Inbox domain contract. + +## Layer 7 — New developer confusion test + +- `ConnectedTeamInboxView` identifies the production-wired surface; `TeamInboxView` remains injectable for tests/reuse. +- `useTeamInboxDataSource` names local/cloud composition and identity resolution explicitly. +- `useTeamInboxNavigation` separates Session comment navigation from project/standalone Work Item navigation. + +## Layer 8 — Wire protocol and serialization + +- Local DTOs use serde-tagged target/payload variants and camelCase fields, covered by Rust serialization tests. +- Cloud request body contains only `p_org_id`, `p_cursor`, and `p_limit`; tests assert no caller-supplied viewer/user ID. +- Cloud response is Zod-validated; malformed counts and pagination input are rejected. + +## Layer 9 — Init parity + +| Entry point | Canonical schema init | Explicit viewer | Blocking DB isolation | +| ------------- | --------------------: | --------------: | --------------------: | +| list page | yes | yes | `spawn_blocking` | +| unread count | yes | yes | `spawn_blocking` | +| mark read | yes | yes | `spawn_blocking` | +| mark all read | yes | yes | `spawn_blocking` | +| mark unread | yes | yes | `spawn_blocking` | + +All five commands (`team_inbox_list_page`, `team_inbox_unread_count`, `team_inbox_mark_read`, `team_inbox_mark_all_read`, `team_inbox_mark_unread`) are registered in the same Tauri handler list. + +## Layer 10 — Resolver symmetry + +- Local viewer identity uses the same current-user member resolver for list, single read, and bulk read. +- Cloud cache and persisted receipt keys use the same endpoint + authenticated user + org scope. +- Project and standalone navigation both resolve raw Work Item data through the same adapter chain before opening the canonical Chat Panel Work Item tab. + +## Completion verdict + +- Canonical DDL changed directly; no `ALTER TABLE` compatibility path was introduced. +- Local cursor ordering and viewer-scoped receipt idempotence are tested. +- Cloud receipt storage is bounded to 1,000 entries. +- No timer or polling loop was introduced; refresh is driven by initial demand, existing project-change signals, cloud comment signals, and mutations. + +**Architecture verdict: pass for the audited Team Inbox scope.** diff --git a/docs/frontend-ui-audit-2026-07-23/TeamInbox.md b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md new file mode 100644 index 000000000..31a98b45b --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md @@ -0,0 +1,50 @@ +# Frontend UI Audit — Team Inbox + +**Files:** `src/modules/MainApp/TeamInbox/**/*.tsx` +**Date:** 2026-07-23 +**Auditor:** ORGII implementation session + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +|---|---|---|---|---| +| `TeamInboxRow.tsx:42` | raw ` + ) : undefined + ) : onMarkUnread && markUnreadLabel ? ( + + ) : undefined + } + /> + + {contentLayout === "fill" ? ( +
+ {children} +
+ ) : ( +
+
+ {children ? ( +
{children}
+ ) : null} + {metadata && metadata.length > 0 ? ( + + ) : null} +
+
+ )} + + {onOpen ? ( + + ) : null} + +); + +export default TeamInboxDetailLayout; diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx new file mode 100644 index 000000000..0d346f1cb --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxList.tsx @@ -0,0 +1,259 @@ +import { AtSign, ClipboardList, Inbox } from "lucide-react"; +import React, { useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import { + LIST_PANEL_SECTIONS, + LIST_PANEL_SECTION_HEADER, +} from "@src/components/ListPanel"; +import SearchInput from "@src/components/SearchInput"; +import TabPill, { type TabPillItem } from "@src/components/TabPill"; +import { + ListPanelScrollArea, + ListPanelTabPillRow, + Placeholder, +} from "@src/modules/shared/layouts/blocks"; + +import { + type TeamInboxFilter, + type TeamInboxItem, + type TeamInboxUnreadCounts, + getTeamInboxItemKey, + groupTeamInboxItemsByRecency, +} from "../domain"; +import TeamInboxRow from "./TeamInboxRow"; + +export interface TeamInboxListProps { + filter: TeamInboxFilter; + items: readonly TeamInboxItem[]; + selectedItemId: string | null; + unreadCounts: TeamInboxUnreadCounts; + query: string; + onQueryChange: (query: string) => void; + onFilterChange: (filter: TeamInboxFilter) => void; + onSelectItem: (item: TeamInboxItem) => void; + hasMore?: boolean; + loadingMore?: boolean; + onLoadMore?: () => void; +} + +function filterCountBadge(count: number, ariaLabel: string): React.ReactNode { + if (count <= 0) return undefined; + return ( + + {count > 99 ? "99+" : count} + + ); +} + +const TeamInboxList: React.FC = ({ + filter, + items, + selectedItemId, + unreadCounts, + query, + onQueryChange, + onFilterChange, + onSelectItem, + hasMore = false, + loadingMore = false, + onLoadMore, +}) => { + const { t } = useTranslation(); + const hasQuery = query.trim().length > 0; + const rowRefs = useRef(new Map()); + const selectedIndex = useMemo( + () => + items.findIndex((item) => getTeamInboxItemKey(item) === selectedItemId), + [items, selectedItemId] + ); + const groups = useMemo(() => groupTeamInboxItemsByRecency(items), [items]); + const filterTabs = useMemo( + () => [ + { + key: "all", + label: t("teamInbox.filters.all"), + icon: , + badge: filterCountBadge( + unreadCounts.all, + t("teamInbox.unreadCount", { count: unreadCounts.all }) + ), + }, + { + key: "mentions", + label: t("teamInbox.filters.mentions"), + icon: , + badge: filterCountBadge( + unreadCounts.mentions, + t("teamInbox.unreadCount", { count: unreadCounts.mentions }) + ), + }, + { + key: "assigned", + label: t("teamInbox.filters.assigned"), + icon: , + badge: filterCountBadge( + unreadCounts.assigned, + t("teamInbox.unreadCount", { count: unreadCounts.assigned }) + ), + }, + ], + [t, unreadCounts.all, unreadCounts.mentions, unreadCounts.assigned] + ); + + const selectAt = useCallback( + (index: number) => { + const item = items[index]; + if (!item) return; + onSelectItem(item); + rowRefs.current.get(getTeamInboxItemKey(item))?.focus(); + }, + [items, onSelectItem] + ); + + const handleListKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (items.length === 0) return; + const currentIndex = selectedIndex >= 0 ? selectedIndex : 0; + let nextIndex: number | null = null; + switch (event.key) { + case "ArrowDown": + nextIndex = Math.min(currentIndex + 1, items.length - 1); + break; + case "ArrowUp": + nextIndex = Math.max(currentIndex - 1, 0); + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = items.length - 1; + break; + default: + return; + } + event.preventDefault(); + selectAt(nextIndex); + }, + [items.length, selectAt, selectedIndex] + ); + + return ( +
+ + onFilterChange(key as TeamInboxFilter)} + variant="pill" + colorScheme="ghost" + size="mini" + fillWidth + /> + + +
+ +
+ + {items.length === 0 ? ( + hasQuery ? ( + + ) : ( + + ) + ) : ( + +
+ {groups.map((group) => { + const groupLabel = t(`teamInbox.groups.${group.key}`); + return ( +
+
+ {groupLabel} +
+
+ {group.items.map((item) => { + const key = getTeamInboxItemKey(item); + return ( + { + if (node) rowRefs.current.set(key, node); + else rowRefs.current.delete(key); + }} + item={item} + itemKey={key} + selected={key === selectedItemId} + onSelect={onSelectItem} + /> + ); + })} +
+
+ ); + })} +
+ {hasMore && onLoadMore ? ( +
+ +
+ ) : null} +
+ )} +
+ ); +}; + +export default TeamInboxList; diff --git a/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx new file mode 100644 index 000000000..afcb0c23b --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/TeamInboxRow.tsx @@ -0,0 +1,117 @@ +import { AtSign, ClipboardList } from "lucide-react"; +import { forwardRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import IntegrationIcon from "@src/components/IntegrationIcon"; +import { getListItemClasses } from "@src/components/ListPanel"; +import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; + +import { + type TeamInboxItem, + humanizeToken, + isGitHubIssueStatus, + workItemPriorityLabelKey, + workItemStatusLabelKey, +} from "../domain"; + +export interface TeamInboxRowProps { + item: TeamInboxItem; + itemKey: string; + selected: boolean; + onSelect: (item: TeamInboxItem) => void; +} + +const TeamInboxRow = forwardRef( + ({ item, itemKey, selected, onSelect }, ref) => { + const { t } = useTranslation(); + const isMention = item.kind === "comment_mention"; + const isGitHubIssue = + item.kind === "assigned_work_item" && + isGitHubIssueStatus(item.payload.status); + const title = isMention ? item.target.sessionTitle : item.payload.title; + const summary = useMemo(() => { + if (item.kind === "comment_mention") return item.payload.commentBody; + if (item.payload.summary) return item.payload.summary; + const status = t(workItemStatusLabelKey(item.payload.status), { + defaultValue: humanizeToken(item.payload.status), + }); + const priority = t(workItemPriorityLabelKey(item.payload.priority), { + defaultValue: humanizeToken(item.payload.priority), + }); + return t("teamInbox.row.assignedSummary", { status, priority }); + }, [item, t]); + const personName = isMention + ? item.actor.displayName + : (item.payload.assigneeName ?? item.payload.assigneeMemberId); + const relativeTime = useMemo( + () => formatRelativeTime(item.occurredAt, "nano"), + [item.occurredAt] + ); + const unread = item.readAt === null; + const readLabel = t( + unread ? "teamInbox.status.unread" : "teamInbox.status.read" + ); + + return ( + + ); + } +); + +TeamInboxRow.displayName = "TeamInboxRow"; + +export default TeamInboxRow; diff --git a/src/modules/MainApp/TeamInbox/components/index.ts b/src/modules/MainApp/TeamInbox/components/index.ts new file mode 100644 index 000000000..c495c4303 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/index.ts @@ -0,0 +1,10 @@ +export { default as AssignedWorkItemDetail } from "./AssignedWorkItemDetail"; +export type { AssignedWorkItemDetailProps } from "./AssignedWorkItemDetail"; +export { default as CommentMentionDetail } from "./CommentMentionDetail"; +export type { CommentMentionDetailProps } from "./CommentMentionDetail"; +export { default as TeamInboxDetailLayout } from "./TeamInboxDetailLayout"; +export type { TeamInboxDetailLayoutProps } from "./TeamInboxDetailLayout"; +export { default as TeamInboxList } from "./TeamInboxList"; +export type { TeamInboxListProps } from "./TeamInboxList"; +export { default as TeamInboxRow } from "./TeamInboxRow"; +export type { TeamInboxRowProps } from "./TeamInboxRow"; diff --git a/src/modules/MainApp/TeamInbox/domain/cursor.ts b/src/modules/MainApp/TeamInbox/domain/cursor.ts new file mode 100644 index 000000000..bc92b14ad --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/cursor.ts @@ -0,0 +1,13 @@ +/** + * Encodes a Team Inbox cursor item key into the backend cursor `itemId`. + * + * The local read model's cursor already carries the backend source id + * (`work_item_assigned:`), and the Rust `list_page` command strips + * that `work_item_assigned:` source prefix itself. Only the UI kind prefix + * (`assigned_work_item:`) — if a UI item key is passed by mistake — must be + * removed here. The `work_item_assigned:` source prefix MUST be preserved, or + * the backend rejects the cursor with "Unsupported Team Inbox cursor item id". + */ +export function toWireCursorItemId(itemKey: string): string { + return itemKey.replace(/^assigned_work_item:/, ""); +} diff --git a/src/modules/MainApp/TeamInbox/domain/index.ts b/src/modules/MainApp/TeamInbox/domain/index.ts new file mode 100644 index 000000000..516b2ffde --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/index.ts @@ -0,0 +1,40 @@ +export { + countUnreadTeamInboxItems, + countUnreadTeamInboxItemsByFilter, + dedupeTeamInboxItems, + filterItemKind, + filterTeamInboxItems, + getTeamInboxItemKey, + groupTeamInboxItemsByRecency, + searchTeamInboxItems, + selectTeamInboxItems, + sortTeamInboxItems, + toTeamInboxNavigationIntent, +} from "./selectors"; +export type { + TeamInboxRecencyGroup, + TeamInboxRecencyGroupKey, + TeamInboxUnreadCounts, +} from "./selectors"; +export { + humanizeToken, + isGitHubIssueStatus, + workItemPriorityLabelKey, + workItemStatusLabelKey, +} from "./labels"; +export { toWireCursorItemId } from "./cursor"; +export type { + AssignedWorkItem, + CommentMentionItem, + ListTeamInboxInput, + SessionCommentTarget, + TeamInboxActor, + TeamInboxCursor, + TeamInboxDataSource, + TeamInboxFilter, + TeamInboxItem, + TeamInboxNavigationIntent, + TeamInboxPage, + TeamInboxTarget, + WorkItemTarget, +} from "./types"; diff --git a/src/modules/MainApp/TeamInbox/domain/labels.ts b/src/modules/MainApp/TeamInbox/domain/labels.ts new file mode 100644 index 000000000..38ce70ad7 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/labels.ts @@ -0,0 +1,54 @@ +import { WORK_ITEM_STATUS } from "@src/types/core/workItem"; + +/** + * GitHub-backed work items are identified by their status vocabulary: the sync + * adapter writes `open` / `closed` where local items use `todo` / `done`. + * + * Checked against the shared `WORK_ITEM_STATUS` constant rather than importing + * ProjectManager's equivalent helper, for the same isolation reason described + * on the label keys below. + */ +export function isGitHubIssueStatus(status: string): boolean { + return ( + status === WORK_ITEM_STATUS.GITHUB_OPEN || + status === WORK_ITEM_STATUS.GITHUB_CLOSED + ); +} + +/** + * Turns a raw enum token from the work-item read model (e.g. `in_progress`, + * `HIGH`, `in-review`) into a human sentence-cased label (`In progress`, + * `High`, `In review`). + * + * This is the deterministic fallback for values that have no explicit localized + * key; callers pass the result as the i18next `defaultValue` so a translated + * label wins when present and raw enum strings never leak to the UI. + */ +export function humanizeToken(value: string): string { + const normalized = value.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " "); + if (!normalized) return ""; + const lower = normalized.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +/** + * i18n key for a work-item status/priority token, with a humanized default. + * + * Team Inbox deliberately owns the `teamInbox.workItemStatus.*` / + * `teamInbox.priority.*` namespaces instead of reusing ProjectManager's + * `workItems.statusLabels.*` / `workItems.priorityLabels.*`. The two label sets + * model *different* status vocabularies — Team Inbox surfaces read-model tokens + * like `todo` / `done` / `blocked`, while ProjectManager uses `planned` / + * `completed` and omits `blocked` — so pointing at the shared keys would drop + * those labels to the humanized fallback. Keeping the namespaces separate is + * intentional isolation, not accidental duplication; the humanized default keeps + * any unmapped token readable. + */ +export function workItemStatusLabelKey(status: string): string { + return `teamInbox.workItemStatus.${status}`; +} + +/** i18n key for a work-item priority token, with a humanized default value. */ +export function workItemPriorityLabelKey(priority: string): string { + return `teamInbox.priority.${priority}`; +} diff --git a/src/modules/MainApp/TeamInbox/domain/selectors.ts b/src/modules/MainApp/TeamInbox/domain/selectors.ts new file mode 100644 index 000000000..cdda63dd7 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/selectors.ts @@ -0,0 +1,252 @@ +import { + type SessionDateBucket, + getSessionDateBucketRanges, +} from "@src/util/session/sessionDateBuckets"; + +import type { + TeamInboxFilter, + TeamInboxItem, + TeamInboxNavigationIntent, +} from "./types"; + +const INVALID_TIMESTAMP = Number.NEGATIVE_INFINITY; + +function timestamp(value: string): number { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? INVALID_TIMESTAMP : parsed; +} + +export function getTeamInboxItemKey(item: TeamInboxItem): string { + return `${item.kind}:${item.id}`; +} + +/** + * De-duplicates pages by canonical item identity. When a later page contains a + * fresher copy of the same item, the fresher copy wins. + */ +export function dedupeTeamInboxItems( + items: readonly TeamInboxItem[] +): TeamInboxItem[] { + const byKey = new Map(); + + for (const item of items) { + const key = getTeamInboxItemKey(item); + const current = byKey.get(key); + if ( + !current || + timestamp(item.occurredAt) > timestamp(current.occurredAt) + ) { + byKey.set(key, item); + } + } + + return [...byKey.values()]; +} + +/** Newest first; identity is a deterministic tie-breaker for cursor stability. */ +export function sortTeamInboxItems( + items: readonly TeamInboxItem[] +): TeamInboxItem[] { + return [...items].sort((left, right) => { + const timeDifference = + timestamp(right.occurredAt) - timestamp(left.occurredAt); + if (timeDifference !== 0) return timeDifference; + return getTeamInboxItemKey(left).localeCompare(getTeamInboxItemKey(right)); + }); +} + +export function filterTeamInboxItems( + items: readonly TeamInboxItem[], + filter: TeamInboxFilter +): TeamInboxItem[] { + if (filter === "all") return [...items]; + const kind = filter === "mentions" ? "comment_mention" : "assigned_work_item"; + return items.filter((item) => item.kind === kind); +} + +export function selectTeamInboxItems( + items: readonly TeamInboxItem[], + filter: TeamInboxFilter +): TeamInboxItem[] { + return filterTeamInboxItems( + sortTeamInboxItems(dedupeTeamInboxItems(items)), + filter + ); +} + +/** Fields searched for each item kind, so the free-text query stays discoverable. */ +function searchableText(item: TeamInboxItem): string[] { + if (item.kind === "comment_mention") { + return [ + item.target.sessionTitle, + item.payload.commentBody, + item.payload.context ?? "", + item.actor.displayName, + ]; + } + return [ + item.payload.title, + item.payload.summary ?? "", + item.payload.assigneeName ?? item.payload.assigneeMemberId, + item.payload.status, + item.payload.priority, + item.actor.displayName, + ]; +} + +/** + * Case-insensitive free-text filter over the already-loaded items. An empty or + * whitespace-only query returns every item unchanged; otherwise an item is kept + * when any of its searchable fields contains the query. + */ +export function searchTeamInboxItems( + items: readonly TeamInboxItem[], + query: string +): TeamInboxItem[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...items]; + return items.filter((item) => + searchableText(item).some((text) => text.toLowerCase().includes(needle)) + ); +} + +export type TeamInboxRecencyGroupKey = + | "today" + | "yesterday" + | "thisWeek" + | "earlier"; + +export interface TeamInboxRecencyGroup { + key: TeamInboxRecencyGroupKey; + items: TeamInboxItem[]; +} + +const RECENCY_GROUP_ORDER: TeamInboxRecencyGroupKey[] = [ + "today", + "yesterday", + "thisWeek", + "earlier", +]; + +/** + * Maps the shared session date-bucket keys onto the Team Inbox recency keys, so + * both surfaces derive day boundaries from one source of truth + * (`getSessionDateBucketRanges`). Only the presentation key name differs + * ("earlier" here vs "older" in the shared helper). + */ +const SESSION_BUCKET_TO_RECENCY: Record< + SessionDateBucket, + TeamInboxRecencyGroupKey +> = { + today: "today", + yesterday: "yesterday", + thisWeek: "thisWeek", + older: "earlier", +}; + +/** + * Buckets already-ordered items into recency sections relative to `nowMs` + * (Today / Yesterday / This week / Earlier). Day boundaries are reused from the + * shared `getSessionDateBucketRanges` helper so the "this week" window stays + * consistent with the rest of the app. Empty groups are omitted and group order + * is stable; unparseable timestamps fall into "earlier". + */ +export function groupTeamInboxItemsByRecency( + items: readonly TeamInboxItem[], + /** + * Defaults to the current time. Kept out of the calling component so the + * bucket boundaries are not derived from an impure call during render; + * tests and any caller needing determinism pass an explicit value. + */ + nowMs: number = Date.now() +): TeamInboxRecencyGroup[] { + const ranges = getSessionDateBucketRanges(new Date(nowMs)); + + const buckets: Record = { + today: [], + yesterday: [], + thisWeek: [], + earlier: [], + }; + + for (const item of items) { + const occurred = Date.parse(item.occurredAt); + let key: TeamInboxRecencyGroupKey = "earlier"; + if (!Number.isNaN(occurred)) { + const match = ranges.find( + ({ startMs, endMs }) => + (startMs === undefined || occurred >= startMs) && + (endMs === undefined || occurred < endMs) + ); + if (match) key = SESSION_BUCKET_TO_RECENCY[match.bucket]; + } + buckets[key].push(item); + } + + return RECENCY_GROUP_ORDER.filter((key) => buckets[key].length > 0).map( + (key) => ({ key, items: buckets[key] }) + ); +} + +export function countUnreadTeamInboxItems( + items: readonly TeamInboxItem[] +): number { + return dedupeTeamInboxItems(items).reduce( + (count, item) => count + (item.readAt === null ? 1 : 0), + 0 + ); +} + +export interface TeamInboxUnreadCounts { + all: number; + mentions: number; + assigned: number; +} + +/** + * Unread totals split by the surfaces the filter tabs expose. Canonical items + * are de-duplicated first so a duplicated page never double-counts a badge. + */ +export function countUnreadTeamInboxItemsByFilter( + items: readonly TeamInboxItem[] +): TeamInboxUnreadCounts { + return dedupeTeamInboxItems(items).reduce( + (counts, item) => { + if (item.readAt !== null) return counts; + counts.all += 1; + if (item.kind === "comment_mention") counts.mentions += 1; + else counts.assigned += 1; + return counts; + }, + { all: 0, mentions: 0, assigned: 0 } + ); +} + +/** Maps a filter tab to the item kind it exposes, or null for the combined view. */ +export function filterItemKind( + filter: TeamInboxFilter +): TeamInboxItem["kind"] | null { + if (filter === "mentions") return "comment_mention"; + if (filter === "assigned") return "assigned_work_item"; + return null; +} + +export function toTeamInboxNavigationIntent( + item: TeamInboxItem +): TeamInboxNavigationIntent { + if (item.target.kind === "session_comment") { + return { + kind: "open_session_comment", + sessionId: item.target.sessionId, + commentId: item.target.commentId, + threadId: item.target.threadId, + ...(item.target.anchor ? { anchor: item.target.anchor } : {}), + }; + } + + return { + kind: "open_work_item", + projectId: item.target.projectId, + workItemId: item.target.workItemId, + }; +} diff --git a/src/modules/MainApp/TeamInbox/domain/types.ts b/src/modules/MainApp/TeamInbox/domain/types.ts new file mode 100644 index 000000000..7da20f9cc --- /dev/null +++ b/src/modules/MainApp/TeamInbox/domain/types.ts @@ -0,0 +1,109 @@ +export type TeamInboxFilter = "all" | "mentions" | "assigned"; + +export interface TeamInboxActor { + id: string; + displayName: string; + avatarUrl?: string; +} + +export interface SessionCommentTarget { + kind: "session_comment"; + sessionId: string; + sessionTitle: string; + commentId: string; + threadId: string; + anchor?: string; +} + +export interface WorkItemTarget { + kind: "work_item"; + projectId: string; + workItemId: string; +} + +export type TeamInboxTarget = SessionCommentTarget | WorkItemTarget; + +interface TeamInboxItemBase { + id: string; + occurredAt: string; + readAt: string | null; + actor: TeamInboxActor; +} + +export interface CommentMentionItem extends TeamInboxItemBase { + kind: "comment_mention"; + target: SessionCommentTarget; + payload: { + commentBody: string; + context?: string; + commentCount: number; + }; +} + +export interface AssignedWorkItem extends TeamInboxItemBase { + kind: "assigned_work_item"; + target: WorkItemTarget; + payload: { + title: string; + status: string; + priority: string; + /** Raw member id from the read model; the stable assignee identity. */ + assigneeMemberId: string; + /** Display name resolved from project members; absent until resolved. */ + assigneeName?: string; + summary?: string; + updatedAt: string; + }; +} + +export type TeamInboxItem = CommentMentionItem | AssignedWorkItem; + +export interface TeamInboxCursor { + occurredAt: string; + itemKey: string; +} + +export interface TeamInboxPage { + items: TeamInboxItem[]; + nextCursor: TeamInboxCursor | null; +} + +export interface ListTeamInboxInput { + cursor?: TeamInboxCursor | null; + limit?: number; + signal?: AbortSignal; +} + +/** + * Transport-independent Team Inbox boundary. + * + * The feature owns presentation and local selection only. Its host supplies an + * implementation backed by the canonical comment/work-item read model. + */ +export interface TeamInboxDataSource { + listPage(input: ListTeamInboxInput): Promise; + markRead?(item: TeamInboxItem): Promise; + markUnread?(item: TeamInboxItem): Promise; + markAllRead?(items: readonly TeamInboxItem[]): Promise; + refresh?(): Promise; + /** + * Loads the next page from every source that still has one and appends the + * results to the current page. A no-op when nothing more is available. + */ + loadMore?(): Promise; + subscribe?(listener: () => void): () => void; +} + +export type TeamInboxNavigationIntent = + | { + kind: "open_session_comment"; + sessionId: string; + commentId: string; + threadId: string; + anchor?: string; + } + | { + kind: "open_work_item"; + projectId: string; + workItemId: string; + }; diff --git a/src/modules/MainApp/TeamInbox/index.ts b/src/modules/MainApp/TeamInbox/index.ts new file mode 100644 index 000000000..afc88e3cd --- /dev/null +++ b/src/modules/MainApp/TeamInbox/index.ts @@ -0,0 +1,7 @@ +export { default } from "./ConnectedTeamInboxView"; +export { default as ConnectedTeamInboxView } from "./ConnectedTeamInboxView"; +export { default as TeamInboxView } from "./TeamInboxView"; +export type { TeamInboxViewProps } from "./TeamInboxView"; +export * from "./components"; +export * from "./domain"; +export { teamInboxUnreadCountAtom } from "./store"; diff --git a/src/modules/MainApp/TeamInbox/store.ts b/src/modules/MainApp/TeamInbox/store.ts new file mode 100644 index 000000000..e0b26f36e --- /dev/null +++ b/src/modules/MainApp/TeamInbox/store.ts @@ -0,0 +1,87 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +import type { TeamInboxItem } from "./domain"; + +export interface TeamInboxCacheState { + items: TeamInboxItem[]; + unreadCount: number; + loading: boolean; + error: string | null; + revision: number; + loadedForViewerKey: string | null; + /** True when either the local or cloud source still has a next page. */ + hasMore: boolean; +} + +export const teamInboxCacheAtom = atom({ + items: [], + unreadCount: 0, + loading: false, + error: null, + revision: 0, + loadedForViewerKey: null, + hasMore: false, +}); +teamInboxCacheAtom.debugLabel = "teamInboxCacheAtom"; + +export const teamInboxUnreadCountAtom = atom( + (get) => get(teamInboxCacheAtom).unreadCount +); +teamInboxUnreadCountAtom.debugLabel = "teamInboxUnreadCountAtom"; + +export const teamInboxInvalidationAtom = atom(0); +teamInboxInvalidationAtom.debugLabel = "teamInboxInvalidationAtom"; + +export type TeamInboxCloudReadReceipts = Record; +export const MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS = 1_000; + +export function addTeamInboxCloudReadReceipts( + current: TeamInboxCloudReadReceipts, + additions: TeamInboxCloudReadReceipts +): TeamInboxCloudReadReceipts { + const next = { ...current }; + for (const [key, readAt] of Object.entries(additions)) { + delete next[key]; + next[key] = readAt; + } + const keys = Object.keys(next); + for ( + let index = 0; + index < keys.length - MAX_TEAM_INBOX_CLOUD_READ_RECEIPTS; + index += 1 + ) { + delete next[keys[index]!]; + } + return next; +} + +export function removeTeamInboxCloudReadReceipts( + current: TeamInboxCloudReadReceipts, + keys: readonly string[] +): TeamInboxCloudReadReceipts { + if (keys.length === 0) return current; + let changed = false; + const next = { ...current }; + for (const key of keys) { + if (key in next) { + delete next[key]; + changed = true; + } + } + return changed ? next : current; +} + +export const teamInboxCloudReadReceiptsAtom = + atomWithStorage( + "orgii:team-inbox:cloud-read-receipts", + {}, + undefined, + { getOnInit: true } + ); +teamInboxCloudReadReceiptsAtom.debugLabel = "teamInboxCloudReadReceiptsAtom"; + +export const invalidateTeamInboxAtom = atom(null, (get, set) => { + set(teamInboxInvalidationAtom, get(teamInboxInvalidationAtom) + 1); +}); +invalidateTeamInboxAtom.debugLabel = "invalidateTeamInboxAtom"; diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts new file mode 100644 index 000000000..815be3175 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxDataSource.ts @@ -0,0 +1,526 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { invalidateProjectCache, projectApi } from "@src/api/http/project"; +import type { MemberEntry } from "@src/api/http/project"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudCommentsSignalAtom, + orgCommentsKey, +} from "@src/features/Org2Cloud/org2CloudCommentsBus"; +import { sidebarActiveCloudOrgIdAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { + type TeamInboxMention, + listTeamInboxMentions, +} from "@src/features/Org2Cloud/teamInboxMentionsClient"; +import { useProjectDataChanged } from "@src/hooks/project"; +import { useCurrentUserMemberIds } from "@src/hooks/project/useCurrentUserMemberId"; + +import { + listLocalTeamInboxPage, + markAllLocalTeamInboxRead, + markLocalTeamInboxItemRead, + markLocalTeamInboxItemUnread, +} from "./api"; +import { dedupeTeamInboxItems } from "./domain"; +import type { + TeamInboxCursor, + TeamInboxDataSource, + TeamInboxFilter, + TeamInboxItem, +} from "./domain"; +import { + type TeamInboxCloudReadReceipts, + addTeamInboxCloudReadReceipts, + invalidateTeamInboxAtom, + removeTeamInboxCloudReadReceipts, + teamInboxCacheAtom, + teamInboxCloudReadReceiptsAtom, + teamInboxInvalidationAtom, +} from "./store"; + +const listeners = new Set<() => void>(); +let membersRequest: Promise | null = null; +let inboxRequest: { + key: string; + promise: Promise<{ + mentionItems: TeamInboxItem[]; + localItems: TeamInboxItem[]; + localUnread: number; + localNextCursor: TeamInboxCursor | null; + cloudNextCursor: string | null; + }>; +} | null = null; + +function notifyTeamInboxListeners(): void { + for (const listener of listeners) listener(); +} + +/** + * Maps raw cloud mentions into Team Inbox items with `readAt` left unresolved; + * the caller overlays the latest local read receipts afterwards. Shared by the + * initial load and `loadMore` so both pages produce identical item shapes. + */ +function mapMentionsToItems( + mentions: readonly TeamInboxMention[], + activeCloudOrgId: string +): TeamInboxItem[] { + return mentions.map((mention) => { + const itemId = `cloud-comment:${activeCloudOrgId}:${mention.comment.id}`; + return { + id: itemId, + kind: "comment_mention" as const, + occurredAt: mention.createdAt, + readAt: null, + actor: { + id: mention.author.userId, + displayName: mention.author.displayName ?? "Team member", + }, + target: { + kind: "session_comment" as const, + sessionId: mention.session.id, + sessionTitle: mention.session.title ?? "Session", + commentId: mention.comment.id, + threadId: mention.comment.parentId ?? mention.comment.id, + anchor: mention.comment.id, + }, + payload: { + commentBody: mention.body, + commentCount: mention.commentCount, + context: `${mention.threadCount} thread comments`, + }, + }; + }); +} + +/** Overlays the current cloud read receipts onto freshly-mapped mention items. */ +function overlayCloudReadReceipts( + mentionItems: readonly TeamInboxItem[], + cloudReadReceipts: TeamInboxCloudReadReceipts, + cloudScopeKey: string +): TeamInboxItem[] { + return mentionItems.map((item) => ({ + ...item, + readAt: cloudReadReceipts[`${cloudScopeKey}|${item.id}`] ?? null, + })); +} + +/** + * Resolves each assigned item's display name from its stable `assigneeMemberId` + * into the optional `assigneeName` field. When the member cannot be resolved the + * name is left unset and consumers fall back to the id, so a row never renders + * blank. + */ +function resolveAssigneeDisplayNames( + items: readonly TeamInboxItem[], + members: readonly MemberEntry[] +): TeamInboxItem[] { + if (members.length === 0) return [...items]; + const nameById = new Map(members.map((member) => [member.id, member.name])); + return items.map((item) => { + if (item.kind !== "assigned_work_item") return item; + const resolved = nameById.get(item.payload.assigneeMemberId); + if (!resolved || resolved === item.payload.assigneeName) return item; + return { + ...item, + payload: { ...item.payload, assigneeName: resolved }, + }; + }); +} + +async function readAllProjectMembers(): Promise { + if (membersRequest) return membersRequest; + membersRequest = (async () => { + const projects = await projectApi.readProjects(); + const memberFiles = await Promise.all( + projects.map((project) => projectApi.readMembers(project.slug)) + ); + const members = new Map(); + for (const file of memberFiles) { + for (const member of file.members) members.set(member.id, member); + } + return [...members.values()]; + })(); + try { + return await membersRequest; + } finally { + membersRequest = null; + } +} + +export function useTeamInboxDataSource(): { + dataSource: TeamInboxDataSource; + viewerMemberIds: readonly string[]; +} { + const [members, setMembers] = useState([]); + const membersRef = useRef([]); + const { memberIds } = useCurrentUserMemberIds(members); + const viewerMemberIds = useMemo(() => [...memberIds].sort(), [memberIds]); + const cache = useAtomValue(teamInboxCacheAtom); + const auth = useAtomValue(org2CloudAuthAtom); + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const activeCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom); + const viewerKey = `${viewerMemberIds.join("|")}::${authIdentityKey ?? "signed-out"}::${activeCloudOrgId ?? "local"}`; + const commentsSignals = useAtomValue(org2CloudCommentsSignalAtom); + const cloudReadReceipts = useAtomValue(teamInboxCloudReadReceiptsAtom); + const setCloudReadReceipts = useSetAtom(teamInboxCloudReadReceiptsAtom); + const activeCloudCommentsRevision = activeCloudOrgId + ? (commentsSignals[orgCommentsKey(activeCloudOrgId)] ?? 0) + : 0; + const invalidation = useAtomValue(teamInboxInvalidationAtom); + const setCache = useSetAtom(teamInboxCacheAtom); + const invalidate = useSetAtom(invalidateTeamInboxAtom); + const loadGeneration = useRef(0); + const localCursorRef = useRef(null); + const cloudCursorRef = useRef(null); + const loadingMoreRef = useRef(false); + + useEffect(() => { + let cancelled = false; + void readAllProjectMembers() + .then((nextMembers) => { + if (!cancelled) { + membersRef.current = nextMembers; + setMembers(nextMembers); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setCache((current) => ({ + ...current, + error: + error instanceof Error + ? error.message + : "Failed to resolve current Team Inbox member identity", + })); + } + }); + return () => { + cancelled = true; + }; + }, [invalidation, setCache]); + + const refresh = useCallback(async (): Promise => { + const canLoadLocalAssignments = viewerMemberIds.length > 0; + const canLoadCloudMentions = Boolean(auth && activeCloudOrgId); + if (!canLoadLocalAssignments && !canLoadCloudMentions) { + localCursorRef.current = null; + cloudCursorRef.current = null; + setCache((current) => ({ + ...current, + items: [], + unreadCount: 0, + loading: false, + hasMore: false, + loadedForViewerKey: viewerKey, + error: + members.length > 0 + ? "No project member matches the current Git identity" + : null, + })); + notifyTeamInboxListeners(); + return; + } + const generation = ++loadGeneration.current; + setCache((current) => ({ ...current, loading: true, error: null })); + try { + const requestKey = viewerKey; + if (!inboxRequest || inboxRequest.key !== requestKey) { + const promise = Promise.all([ + canLoadLocalAssignments + ? listLocalTeamInboxPage(viewerMemberIds, "all") + : Promise.resolve({ + page: { items: [], nextCursor: null }, + unreadCount: 0, + }), + auth && activeCloudOrgId + ? listTeamInboxMentions( + auth.accessToken, + activeCloudOrgId, + null, + 50 + ).catch(() => ({ mentions: [], nextCursor: undefined })) + : Promise.resolve({ mentions: [], nextCursor: undefined }), + ]).then(([{ page, unreadCount }, mentionPage]) => { + // Read state is intentionally NOT baked in here: the cached request + // promise stays receipt-independent so a mention marked read while + // this request is in flight is not reverted when the page resolves. + // The current cloud read receipts are overlaid after the await below. + const mentionItems = mapMentionsToItems( + mentionPage.mentions, + activeCloudOrgId ?? "" + ); + return { + mentionItems, + localItems: page.items, + localUnread: unreadCount, + localNextCursor: page.nextCursor, + cloudNextCursor: mentionPage.nextCursor ?? null, + }; + }); + inboxRequest = { key: requestKey, promise }; + void promise.finally(() => { + if (inboxRequest?.promise === promise) inboxRequest = null; + }); + } + const { + mentionItems, + localItems, + localUnread, + localNextCursor, + cloudNextCursor, + } = await inboxRequest.promise; + if (generation !== loadGeneration.current) return; + localCursorRef.current = localNextCursor; + cloudCursorRef.current = cloudNextCursor; + // Overlay the latest cloud read receipts here (not inside the cached + // request promise) so optimistic mark-read/unread survives a concurrent + // in-flight list request. + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + const overlaidMentions = overlayCloudReadReceipts( + mentionItems, + cloudReadReceipts, + cloudScopeKey + ); + const mergedItems = [...overlaidMentions, ...localItems]; + const unreadCount = + localUnread + + overlaidMentions.filter((item) => item.readAt === null).length; + const resolvedItems = resolveAssigneeDisplayNames( + mergedItems, + membersRef.current + ); + setCache((current) => ({ + ...current, + items: resolvedItems, + unreadCount, + loading: false, + error: null, + loadedForViewerKey: viewerKey, + hasMore: Boolean(localNextCursor || cloudNextCursor), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + } catch (error) { + if (generation !== loadGeneration.current) return; + setCache((current) => ({ + ...current, + loading: false, + error: + error instanceof Error ? error.message : "Failed to load Team Inbox", + })); + notifyTeamInboxListeners(); + } + }, [ + activeCloudOrgId, + auth, + authIdentityKey, + cloudReadReceipts, + members.length, + setCache, + viewerKey, + viewerMemberIds, + ]); + + useEffect(() => { + if (activeCloudCommentsRevision > 0) void refresh(); + }, [activeCloudCommentsRevision, refresh]); + useEffect(() => { + if (cache.loadedForViewerKey === viewerKey && invalidation === 0) return; + void refresh(); + }, [cache.loadedForViewerKey, invalidation, refresh, viewerKey]); + + useProjectDataChanged(() => invalidate()); + + const dataSource = useMemo( + () => ({ + listPage: async () => { + if (cache.error && cache.items.length === 0) + throw new Error(cache.error); + // A non-null nextCursor signals the view that a further page exists; the + // exact value is a sentinel because `loadMore` owns the real per-source + // cursors internally. + return { + items: cache.items, + nextCursor: cache.hasMore + ? { occurredAt: "", itemKey: "team-inbox-has-more" } + : null, + }; + }, + loadMore: async () => { + if (loadingMoreRef.current) return; + const localCursor = localCursorRef.current; + const cloudCursor = cloudCursorRef.current; + if (!localCursor && !cloudCursor) return; + loadingMoreRef.current = true; + try { + const [localResult, cloudResult] = await Promise.all([ + localCursor && viewerMemberIds.length > 0 + ? listLocalTeamInboxPage(viewerMemberIds, "all", localCursor) + : Promise.resolve({ + page: { items: [], nextCursor: null }, + unreadCount: 0, + }), + cloudCursor && auth && activeCloudOrgId + ? listTeamInboxMentions( + auth.accessToken, + activeCloudOrgId, + cloudCursor, + 50 + ).catch(() => ({ mentions: [], nextCursor: undefined })) + : Promise.resolve({ mentions: [], nextCursor: undefined }), + ]); + localCursorRef.current = localResult.page.nextCursor ?? null; + cloudCursorRef.current = cloudResult.nextCursor ?? null; + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + const appendedMentions = overlayCloudReadReceipts( + mapMentionsToItems(cloudResult.mentions, activeCloudOrgId ?? ""), + cloudReadReceipts, + cloudScopeKey + ); + const appended = resolveAssigneeDisplayNames( + [...appendedMentions, ...localResult.page.items], + membersRef.current + ); + // Unread badge semantics are intentionally left unchanged here (the + // single-source-of-truth question is tracked separately); loadMore + // only extends the loaded window. + setCache((current) => ({ + ...current, + items: dedupeTeamInboxItems([...current.items, ...appended]), + hasMore: Boolean(localCursorRef.current || cloudCursorRef.current), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + } finally { + loadingMoreRef.current = false; + } + }, + refresh: async () => { + invalidateProjectCache(); + membersRequest = null; + const nextMembers = await readAllProjectMembers(); + membersRef.current = nextMembers; + setMembers(nextMembers); + setCache((current) => ({ + ...current, + loadedForViewerKey: null, + loading: true, + error: null, + })); + invalidate(); + }, + markRead: async (item: TeamInboxItem) => { + const readAt = new Date().toISOString(); + if (item.kind === "comment_mention") { + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + setCloudReadReceipts((current) => + addTeamInboxCloudReadReceipts(current, { + [`${cloudScopeKey}|${item.id}`]: readAt, + }) + ); + } else { + await markLocalTeamInboxItemRead(viewerMemberIds, item.id); + } + setCache((current) => ({ + ...current, + items: current.items.map((candidate) => + candidate.id === item.id ? { ...candidate, readAt } : candidate + ), + unreadCount: Math.max(0, current.unreadCount - 1), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + }, + markUnread: async (item: TeamInboxItem) => { + if (item.kind === "comment_mention") { + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + setCloudReadReceipts((current) => + removeTeamInboxCloudReadReceipts(current, [ + `${cloudScopeKey}|${item.id}`, + ]) + ); + } else { + await markLocalTeamInboxItemUnread(viewerMemberIds, item.id); + } + setCache((current) => ({ + ...current, + items: current.items.map((candidate) => + candidate.id === item.id + ? { ...candidate, readAt: null } + : candidate + ), + unreadCount: current.unreadCount + 1, + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + }, + markAllRead: async (items) => { + const assigned = items.filter( + ( + item + ): item is Extract => + item.kind === "assigned_work_item" + ); + if (assigned.length > 0) { + await markAllLocalTeamInboxRead(viewerMemberIds, "assigned"); + } + const readAt = new Date().toISOString(); + const cloudScopeKey = `${authIdentityKey ?? "signed-out"}|${activeCloudOrgId ?? "local"}`; + const mentionReceipts = items + .filter((item) => item.kind === "comment_mention") + .reduce>((next, item) => { + next[`${cloudScopeKey}|${item.id}`] = readAt; + return next; + }, {}); + if (Object.keys(mentionReceipts).length > 0) { + setCloudReadReceipts((current) => + addTeamInboxCloudReadReceipts(current, mentionReceipts) + ); + } + const itemIds = new Set(items.map((item) => item.id)); + // Decrement only by the items that were actually unread; counting the + // whole set would over-subtract when some passed items were already read. + const newlyReadCount = items.reduce( + (count, item) => count + (item.readAt === null ? 1 : 0), + 0 + ); + setCache((current) => ({ + ...current, + items: current.items.map((item) => + itemIds.has(item.id) ? { ...item, readAt } : item + ), + unreadCount: Math.max(0, current.unreadCount - newlyReadCount), + revision: current.revision + 1, + })); + notifyTeamInboxListeners(); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }), + [ + activeCloudOrgId, + auth, + authIdentityKey, + cache.error, + cache.hasMore, + cache.items, + cloudReadReceipts, + invalidate, + setCache, + setCloudReadReceipts, + viewerMemberIds, + ] + ); + + return { dataSource, viewerMemberIds }; +} + +export function filterForItem(item: TeamInboxItem): TeamInboxFilter { + return item.kind === "comment_mention" ? "mentions" : "assigned"; +} diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts new file mode 100644 index 000000000..29d5dc1a3 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxNavigation.ts @@ -0,0 +1,84 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback } from "react"; + +import { + enrichedWorkItemToUI, + projectApi, + standaloneWorkItemDataToEnriched, +} from "@src/api/http/project"; +import { createLogger } from "@src/hooks/logger"; +import { + openOrFocusSessionInChatPanelTabAtom, + openWorkItemInChatPanelTabAtom, +} from "@src/store/chatPanel/chatPanelTabsAtom"; +import { sessionsAtom } from "@src/store/session"; + +import type { TeamInboxNavigationIntent } from "./domain"; + +const log = createLogger("TeamInboxNavigation"); + +export function useTeamInboxNavigation(): ( + intent: TeamInboxNavigationIntent +) => void { + const sessions = useAtomValue(sessionsAtom); + const openSession = useSetAtom(openOrFocusSessionInChatPanelTabAtom); + const openWorkItem = useSetAtom(openWorkItemInChatPanelTabAtom); + + return useCallback( + (intent: TeamInboxNavigationIntent) => { + if (intent.kind === "open_session_comment") { + const session = sessions.find( + (candidate) => candidate.session_id === intent.sessionId + ); + openSession({ + sessionId: intent.sessionId, + sessionName: session?.name, + repoPath: session?.repoPath, + }); + window.requestAnimationFrame(() => { + document + .getElementById(intent.anchor ?? `comment-${intent.commentId}`) + ?.scrollIntoView({ block: "center", behavior: "smooth" }); + }); + return; + } + + const openResolvedWorkItem = ( + workItem: Awaited>, + project?: Awaited> + ) => { + const shortId = workItem.frontmatter.short_id; + openWorkItem({ + workItem: enrichedWorkItemToUI( + standaloneWorkItemDataToEnriched(workItem) + ), + shortId, + projectId: project?.meta.id ?? "", + projectSlug: project?.slug ?? "", + projectName: project?.meta.name ?? "Standalone", + orgId: project?.meta.org_id, + }); + }; + + if (!intent.projectId) { + void projectApi + .readStandaloneWorkItem(intent.workItemId) + .then((workItem) => openResolvedWorkItem(workItem)) + .catch((error: unknown) => { + log.warn("Failed to open standalone Team Inbox Work Item", error); + }); + return; + } + + void Promise.all([ + projectApi.readProject(intent.projectId), + projectApi.readWorkItem(intent.projectId, intent.workItemId), + ]) + .then(([project, workItem]) => openResolvedWorkItem(workItem, project)) + .catch((error: unknown) => { + log.warn("Failed to open project Team Inbox Work Item", error); + }); + }, + [openSession, openWorkItem, sessions] + ); +} diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts new file mode 100644 index 000000000..14e67d324 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + enrichedWorkItemToUI, + projectApi, + standaloneWorkItemDataToEnriched, +} from "@src/api/http/project"; +import { createLogger } from "@src/hooks/logger"; +import { toWorkItemPartialUpdate } from "@src/modules/ProjectManager/WorkItems/workItemPartialUpdate"; +import type { WorkItem } from "@src/types/core/workItem"; + +import type { WorkItemTarget } from "./domain"; + +const log = createLogger("TeamInboxWorkItem"); + +export interface TeamInboxWorkItemState { + /** Full work item once resolved, or null while loading / failed. */ + workItem: WorkItem | null; + loading: boolean; + /** Persists a property edit and swaps in the returned item. */ + updateWorkItem: (updates: Partial) => void; +} + +/** + * Loads the full Work Item behind an assigned inbox row so the detail pane can + * render the same `WorkItemContent` / `WorkItemProperties` pair the work-item + * pane uses, instead of a reduced summary of the list payload. + * + * The read is demand-driven (one per selection, no polling) and stale responses + * are discarded when the selection changes. + */ +export function useTeamInboxWorkItem( + target: WorkItemTarget +): TeamInboxWorkItemState { + const { projectId, workItemId } = target; + const requestKey = `${projectId}:${workItemId}`; + /** + * Holds the resolved item together with the key it was fetched for. Loading + * is derived by comparing that key against the current target rather than + * reset by a synchronous setState in the effect. + */ + const [resolved, setResolved] = useState<{ + key: string; + workItem: WorkItem | null; + } | null>(null); + + useEffect(() => { + let cancelled = false; + + const request = projectId + ? projectApi.readWorkItem(projectId, workItemId) + : projectApi.readStandaloneWorkItem(workItemId); + + void request + .then((data) => { + if (cancelled) return; + setResolved({ + key: requestKey, + workItem: enrichedWorkItemToUI( + standaloneWorkItemDataToEnriched(data) + ), + }); + }) + .catch((error: unknown) => { + if (cancelled) return; + log.warn("Failed to load Team Inbox Work Item", error); + setResolved({ key: requestKey, workItem: null }); + }); + + return () => { + cancelled = true; + }; + }, [projectId, workItemId, requestKey]); + + const updateWorkItem = useCallback( + (updates: Partial) => { + // Standalone items are written back through a full frontmatter + // round-trip that only the owning pane assembles, so the inbox edits + // project-scoped items and leaves standalone ones read-only. + if (!projectId) return; + + const payload = toWorkItemPartialUpdate(updates); + if (Object.keys(payload).length === 0) return; + + void projectApi + .updateWorkItemPartial(projectId, workItemId, payload) + .then((updated) => { + setResolved({ + key: requestKey, + workItem: enrichedWorkItemToUI(updated), + }); + }) + .catch((error: unknown) => { + log.warn("Failed to update Team Inbox Work Item", error); + }); + }, + [projectId, workItemId, requestKey] + ); + + const isResolved = resolved?.key === requestKey; + + return { + workItem: isResolved ? resolved.workItem : null, + loading: !isResolved, + updateWorkItem, + }; +} diff --git a/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts new file mode 100644 index 000000000..a64c97a37 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/workItemPartialUpdate.ts @@ -0,0 +1,73 @@ +import type { WorkItemPartialUpdate } from "@src/api/http/project"; +import type { WorkItem } from "@src/types/core/workItem"; + +/** + * Map a UI-shaped partial work-item update onto the wire payload the project + * store accepts. + * + * Shared so every surface that edits a work item — the chat panel's work-item + * pane and the Team Inbox detail — persists the same fields the same way. + */ +export function toWorkItemPartialUpdate( + updates: Partial +): WorkItemPartialUpdate { + const payload: WorkItemPartialUpdate = {}; + + if (updates.name !== undefined) payload.title = updates.name; + if (updates.spec !== undefined) payload.body = updates.spec; + if (updates.workItemStatus !== undefined) { + payload.status = updates.workItemStatus; + } + if (updates.priority !== undefined) payload.priority = updates.priority; + if (updates.project?.id) payload.project = updates.project.id; + if (updates.star !== undefined) payload.starred = updates.star; + if ("assignee" in updates) payload.assignee = updates.assignee?.id ?? null; + if ("assigneeType" in updates) { + payload.assigneeType = updates.assigneeType ?? null; + } + if ("labels" in updates) { + payload.labels = updates.labels?.map((label) => label.id) ?? []; + } + if ("milestone" in updates) { + payload.milestone = updates.milestone?.id ?? null; + } + if ("startDate" in updates) payload.startDate = updates.startDate ?? null; + if ("endDate" in updates) payload.targetDate = updates.endDate ?? null; + if ("target_date" in updates) { + payload.targetDate = updates.target_date ?? null; + } + if (updates.todos !== undefined) { + payload.todos = updates.todos.map((todo) => ({ + id: todo.id, + content: todo.content, + status: todo.status, + })); + } + if (updates.comments !== undefined) { + payload.comments = updates.comments.map((comment) => ({ + id: comment.id, + author: comment.author, + content: comment.content, + created_at: comment.created_at, + })); + } + if (updates.linkedSessions !== undefined) { + payload.linkedSessions = updates.linkedSessions; + } + if (updates.orchestratorConfig !== undefined) { + payload.orchestratorConfig = updates.orchestratorConfig; + } + if (updates.orchestratorState !== undefined) { + payload.orchestratorState = updates.orchestratorState; + } + if (updates.schedule !== undefined) payload.schedule = updates.schedule; + if (updates.executionLock !== undefined) { + payload.executionLock = updates.executionLock; + } + if (updates.closeOut !== undefined) payload.closeOut = updates.closeOut; + if (updates.workProducts !== undefined) { + payload.workProducts = updates.workProducts; + } + + return payload; +} diff --git a/src/modules/index.tsx b/src/modules/index.tsx index 455fe3b16..4d73f96a0 100644 --- a/src/modules/index.tsx +++ b/src/modules/index.tsx @@ -32,7 +32,6 @@ import { useProjectDataChangedListener } from "@src/hooks/project"; import { useBackgroundImage } from "@src/hooks/theme/useBackgroundImage"; import { useOpenUrlInBrowser } from "@src/hooks/workStation/browser/useOpenUrlInBrowser"; import { useUrlPreviewEvents } from "@src/hooks/workStation/tabs"; -import { useNarrowChatFocus } from "@src/hooks/workStation/useNarrowChatFocus"; import { useGlobalBrowserWebviewLayering } from "@src/modules/WorkStation/Browser/hooks"; import { CODE_EDITOR_TOUR_EVENT } from "@src/scaffold/Tutorials/codeEditorTourConfig"; import { @@ -374,7 +373,6 @@ const AppShell = () => { const shouldBridgeWorkStationPipeline = !isSettingsRoute && activeChatPanelTab?.type === "session"; - useNarrowChatFocus({ enabled: true }); useWorkStationPipelineBridge(shouldBridgeWorkStationPipeline); const workStationChatPosition = useAtomValue(workStationChatPositionAtom); diff --git a/src/modules/shared/layouts/blocks/InfoCard.tsx b/src/modules/shared/layouts/blocks/InfoCard.tsx index 141d14989..8d60fe28d 100644 --- a/src/modules/shared/layouts/blocks/InfoCard.tsx +++ b/src/modules/shared/layouts/blocks/InfoCard.tsx @@ -22,20 +22,31 @@ export interface InfoCardProps { /** Extra content rendered above the card (e.g. badges) */ header?: React.ReactNode; className?: string; + /** + * `surface` (default) paints the filled card. `plain` drops the fill and + * padding so the host panel's own background shows through. + */ + variant?: "surface" | "plain"; } const InfoCard: React.FC = ({ rows, header, className = "", + variant = "surface", }) => { const visibleRows = rows.filter((row) => !row.hidden); if (visibleRows.length === 0 && !header) return null; + const container = + variant === "plain" + ? INFO_CARD_TOKENS.containerPlain + : INFO_CARD_TOKENS.container; + return (
{header} -
+
{visibleRows.map((row) => (
diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx index 022161f32..dcb3fb513 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/index.tsx @@ -6,6 +6,8 @@ import { useLocation, useNavigate } from "react-router-dom"; import { useAppNavigation } from "@src/hooks/navigation/useAppNavigation"; import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; +import { teamInboxUnreadCountAtom } from "@src/modules/MainApp/TeamInbox/store"; +import { useTeamInboxDataSource } from "@src/modules/MainApp/TeamInbox/useTeamInboxDataSource"; import { activeSessionCreatorDraftIdAtom, deleteSessionCreatorDraftAtom, @@ -65,6 +67,8 @@ export const WorkstationSidebarConnector: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); const sessions = useAtomValue(sessionsAtom); + useTeamInboxDataSource(); + const teamInboxUnreadCount = useAtomValue(teamInboxUnreadCountAtom); const sessionsLoading = useAtomValue(sessionLoadingAtom); const sessionPagination = useAtomValue(sessionPaginationAtom); const sessionSidebarRevealRequest = useAtomValue( @@ -103,6 +107,7 @@ export const WorkstationSidebarConnector: React.FC = () => { openStartPageTab, openCreateTargetInStartPage, openRuntimeTab, + openTeamInboxTab, closeAndDestroyChatPanelTab, } = useWorkstationSidebarChatPanelAtoms(); @@ -203,6 +208,7 @@ export const WorkstationSidebarConnector: React.FC = () => { createWorkItemLabel, workItemsLabel, runtimeLabel, + teamInboxLabel, importGithubIssuesLabel, addOrgLabel, manageOrgLabel, @@ -296,6 +302,8 @@ export const WorkstationSidebarConnector: React.FC = () => { importGithubIssuesLabel, newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, t, tSessions, }); @@ -487,6 +495,8 @@ export const WorkstationSidebarConnector: React.FC = () => { openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, handleProjectsMenuItemClick, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts index 9c1b3e69c..64745bac7 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.test.ts @@ -43,6 +43,23 @@ describe("resolveSelectedMenuItemIds", () => { ).toBe("runtime"); }); + it("selects Team Inbox from the active team inbox tab", () => { + expect( + resolveSelectedMenuItemIds({ + activeSessionCreatorDraftId: null, + activeSessionId: "session-1", + activeSidebarKey: "workstation", + activeChatPanelTabType: "team-inbox", + chatPanelContentMode: CHAT_PANEL_CONTENT_MODE.SESSION, + chatPanelCreateTarget: CHAT_PANEL_CREATE_TARGET.AGENT_SESSION, + chatPanelSelectedProject: null, + chatPanelSelectedWorkItem: null, + projectsSelectedMenuItemId: "", + sessionCreatorDrafts: [], + }).selectedMenuItemId + ).toBe("team-inbox"); + }); + it("selects Add Org by default on the projects sidebar for the collab org create target", () => { expect( resolveSelectedMenuItemIds({ diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts index d9afb4b70..1f9dc8916 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/menuSelection.ts @@ -13,6 +13,7 @@ import { COLLAB_ADD_ORG_MENU_ITEM_ID, KANBAN_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, } from "../sidebarConnectorUtils"; import { getSelectedDraftMenuItemId, @@ -59,7 +60,9 @@ export function resolveSelectedMenuItemIds({ ? KANBAN_MENU_ITEM_ID : activeChatPanelTabType === "runtime" ? RUNTIME_MENU_ITEM_ID - : ""; + : activeChatPanelTabType === "team-inbox" + ? TEAM_INBOX_MENU_ITEM_ID + : ""; const isChatPanelProjectsContentSelected = chatPanelContentMode === CHAT_PANEL_CONTENT_MODE.NON_SESSION || Boolean(chatPanelSelectedWorkItem) || diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts index 56f15eba0..8ee68e752 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chatPanelAtoms.ts @@ -18,6 +18,7 @@ import { openOrganizationInChatPanelTabAtom, openRuntimeInChatPanelTabAtom, openSessionInNewChatTabAtom, + openTeamInboxInChatPanelTabAtom, openWorkManagementChatPanelTabAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; import { openSessionInWorkstationAtom } from "@src/store/session/sessionTabPlacementAtom"; @@ -60,6 +61,7 @@ export function useWorkstationSidebarChatPanelAtoms() { openCreateTargetInChatPanelStartPageAtom ); const openRuntimeTab = useSetAtom(openRuntimeInChatPanelTabAtom); + const openTeamInboxTab = useSetAtom(openTeamInboxInChatPanelTabAtom); const closeAndDestroyChatPanelTab = useSetAtom( closeAndDestroyChatPanelTabAtom ); @@ -85,6 +87,7 @@ export function useWorkstationSidebarChatPanelAtoms() { openStartPageTab, openCreateTargetInStartPage, openRuntimeTab, + openTeamInboxTab, closeAndDestroyChatPanelTab, }; } diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx index 84c6f8a42..68312648d 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.chrome.tsx @@ -67,6 +67,8 @@ interface UseWorkstationSidebarChromeParams { openWorkManagementTab: MenuItemRoutingParams["openWorkManagementTab"]; openRuntimeTab: MenuItemRoutingParams["openRuntimeTab"]; runtimeLabel: string; + openTeamInboxTab: MenuItemRoutingParams["openTeamInboxTab"]; + teamInboxLabel: string; activateChatPanelTab: MenuItemRoutingParams["activateChatPanelTab"]; handleMenuItemClick: MenuItemRoutingParams["handleMenuItemClick"]; handleProjectsMenuItemClick: MenuItemRoutingParams["handleProjectsMenuItemClick"]; @@ -106,6 +108,8 @@ export function useWorkstationSidebarChrome({ openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, handleProjectsMenuItemClick, @@ -144,6 +148,8 @@ export function useWorkstationSidebarChrome({ openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, workItemsContentVisible, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts index 02f86f1eb..36207bf98 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.labels.ts @@ -28,6 +28,9 @@ export function buildWorkstationSidebarLabels({ const createWorkItemLabel = tProjects("workItems.createWorkItem"); const workItemsLabel = t("labels.workItems"); const runtimeLabel = tSessions("chat.startPage.tabs.runtime"); + const teamInboxLabel = t("labels.teamInbox", { + defaultValue: "Team Inbox", + }); const importGithubIssuesLabel = tProjects("githubIssuesImport.menuLabel"); const addOrgLabel = t("collaboration.addOrg"); const manageOrgLabel = t("collaboration.manageOrg"); @@ -43,6 +46,7 @@ export function buildWorkstationSidebarLabels({ createWorkItemLabel, workItemsLabel, runtimeLabel, + teamInboxLabel, importGithubIssuesLabel, addOrgLabel, manageOrgLabel, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts index 651c9ff3c..1d7b12c5a 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.menuItemRouting.ts @@ -22,6 +22,7 @@ import { KANBAN_MENU_ITEM_ID, NEW_SESSION_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_GITHUB_ISSUES_MENU_ITEM_ID, WORK_ITEMS_GITHUB_PRS_MENU_ITEM_ID, WORK_ITEMS_PROJECTS_MENU_ITEM_ID, @@ -60,6 +61,8 @@ interface UseWorkstationSidebarMenuItemRoutingParams { }) => void; openRuntimeTab: (title: string) => void; runtimeLabel: string; + openTeamInboxTab: (title: string) => void; + teamInboxLabel: string; activateChatPanelTab: (tabId: string) => void; handleMenuItemClick: (key: string, item: NavigationMenuItem) => void; workItemsContentVisible: boolean; @@ -79,6 +82,8 @@ export function useWorkstationSidebarMenuItemRouting({ openWorkManagementTab, openRuntimeTab, runtimeLabel, + openTeamInboxTab, + teamInboxLabel, activateChatPanelTab, handleMenuItemClick, workItemsContentVisible, @@ -129,6 +134,10 @@ export function useWorkstationSidebarMenuItemRouting({ openRuntimeTab(runtimeLabel); return; } + if (item.id === TEAM_INBOX_MENU_ITEM_ID) { + openTeamInboxTab(teamInboxLabel); + return; + } if (isChatTerminalSidebarItem(item.id)) { activateChatPanelTab(getChatTerminalTabId(item.id)); return; @@ -161,7 +170,9 @@ export function useWorkstationSidebarMenuItemRouting({ handleProjectsMenuItemClick, handleOpenInNewTab, openRuntimeTab, + openTeamInboxTab, runtimeLabel, + teamInboxLabel, sessionMap, workItemsContentVisible, ] diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts index 86f7cd06a..be1870d4b 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.pinnedAndRevealData.ts @@ -35,6 +35,8 @@ interface UseWorkstationSidebarPinnedAndRevealDataParams { importGithubIssuesLabel: string; newSessionLabel: string; runtimeLabel: string; + teamInboxLabel: string; + teamInboxUnreadCount: number; t: TFunction<"navigation">; tSessions: TFunction<"sessions">; } @@ -51,6 +53,8 @@ export function useWorkstationSidebarPinnedAndRevealData({ importGithubIssuesLabel, newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, t, tSessions, }: UseWorkstationSidebarPinnedAndRevealDataParams) { @@ -87,6 +91,8 @@ export function useWorkstationSidebarPinnedAndRevealData({ kanbanLabel: tSessions("simulator.tabs.kanban"), newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, workItemDestinations: workItemsSidebarMenuItems, t, }); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts index 678480c0b..21dd54714 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarMenuCollections.ts @@ -27,6 +27,8 @@ interface UsePinnedMenuItemsParams { kanbanLabel: string; newSessionLabel: string; runtimeLabel: string; + teamInboxLabel: string; + teamInboxUnreadCount?: number; workItemDestinations: NavigationMenuItem[]; t: TFunction<"navigation">; } @@ -44,6 +46,8 @@ export function usePinnedMenuItems({ kanbanLabel, newSessionLabel, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, workItemDestinations, t, }: UsePinnedMenuItemsParams): UsePinnedMenuItemsResult { @@ -57,8 +61,18 @@ export function usePinnedMenuItems({ kanbanLabel, kanbanShortcut: getShortcutKeys("open_kanban"), runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, }), - [kanbanLabel, newSessionLabel, runtimeLabel, workItemDestinations, t] + [ + kanbanLabel, + newSessionLabel, + runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount, + workItemDestinations, + t, + ] ); const projectsPinnedMenuItems = useMemo( () => diff --git a/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts index 88dcf99c0..fbec02293 100644 --- a/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts +++ b/src/scaffold/NavigationSidebar/connectors/sidebarConnectorUtils.ts @@ -18,6 +18,7 @@ export const PROJECTS_NEW_WORK_ITEM_MENU_ITEM_ID = "projects-new-work-item"; export const WORK_ITEMS_MENU_ITEM_ID = "work-items"; export const KANBAN_MENU_ITEM_ID = "kanban"; export const RUNTIME_MENU_ITEM_ID = "runtime"; +export const TEAM_INBOX_MENU_ITEM_ID = "team-inbox"; export const WORK_ITEMS_PROJECTS_MENU_ITEM_ID = "work-items:projects"; export const WORK_ITEMS_GITHUB_ISSUES_MENU_ITEM_ID = "work-items:github-issues"; export const WORK_ITEMS_GITHUB_PRS_MENU_ITEM_ID = "work-items:github-prs"; diff --git a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts index 6dac574da..d65b85290 100644 --- a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { KANBAN_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_MENU_ITEM_ID, WORK_ITEMS_PROJECTS_MENU_ITEM_ID, } from "./sidebarConnectorUtils"; @@ -27,18 +28,24 @@ describe("buildPinnedMenuItems", () => { kanbanLabel: "Kanban", kanbanShortcut: "⌘O", runtimeLabel: "Runtime", + teamInboxLabel: "Team Inbox", }); expect(items.map((item) => item.id)).toEqual([ "new-session", KANBAN_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_MENU_ITEM_ID, ]); - expect(items[3]?.children?.map((item) => item.id)).toEqual([ + expect(items[4]?.children?.map((item) => item.id)).toEqual([ WORK_ITEMS_PROJECTS_MENU_ITEM_ID, ]); - expect(items[3]?.routePath).toBeUndefined(); + expect(items[4]?.routePath).toBeUndefined(); + expect(items[3]).toMatchObject({ + label: "Team Inbox", + dataTestId: "sidebar-team-inbox", + }); expect(items[2]).toMatchObject({ label: "Runtime", dataTestId: "sidebar-runtime", diff --git a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx index 30cc91c19..afb89c26d 100644 --- a/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx +++ b/src/scaffold/NavigationSidebar/connectors/workstationSidebarMenuItems.tsx @@ -3,6 +3,7 @@ import { Columns3, Gauge, Github, + Inbox, ListTodo, Plus, SquarePen, @@ -21,6 +22,7 @@ import { PROJECTS_NEW_PROJECT_MENU_ITEM_ID, PROJECTS_NEW_WORK_ITEM_MENU_ITEM_ID, RUNTIME_MENU_ITEM_ID, + TEAM_INBOX_MENU_ITEM_ID, WORK_ITEMS_MENU_ITEM_ID, getDraftMenuItemId, getDraftPreviewText, @@ -34,6 +36,8 @@ interface BuildPinnedMenuItemsParams { kanbanLabel: string; kanbanShortcut: string; runtimeLabel: string; + teamInboxLabel: string; + teamInboxUnreadCount?: number; } interface BuildProjectsPinnedMenuItemsParams { @@ -51,6 +55,8 @@ export function buildPinnedMenuItems({ kanbanLabel, kanbanShortcut, runtimeLabel, + teamInboxLabel, + teamInboxUnreadCount = 0, }: BuildPinnedMenuItemsParams): NavigationMenuItem[] { return [ { @@ -78,6 +84,23 @@ export function buildPinnedMenuItems({ iconName: "gauge", dataTestId: "sidebar-runtime", }, + { + id: TEAM_INBOX_MENU_ITEM_ID, + key: TEAM_INBOX_MENU_ITEM_ID, + label: teamInboxLabel, + icon: Inbox, + iconName: "inbox", + dataTestId: "sidebar-team-inbox", + trailingElement: + teamInboxUnreadCount > 0 ? ( + + {teamInboxUnreadCount > 99 ? "99+" : teamInboxUnreadCount} + + ) : undefined, + }, { id: WORK_ITEMS_MENU_ITEM_ID, key: WORK_ITEMS_MENU_ITEM_ID, diff --git a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts index c0dea6856..bfd60313a 100644 --- a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts +++ b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts @@ -52,6 +52,7 @@ async function loadChatPanelTabAtoms() { openWorkManagementChatPanelTabAtom, openOrFocusChatPanelStartPageTabAtom, openRuntimeInChatPanelTabAtom, + openTeamInboxInChatPanelTabAtom, openOrFocusSessionInChatPanelTabAtom, openOrReplaceSessionInChatPanelTabAtom, openProjectInChatPanelTabAtom, @@ -118,6 +119,7 @@ async function loadChatPanelTabAtoms() { openWorkManagementChatPanelTabAtom, openOrFocusChatPanelStartPageTabAtom, openRuntimeInChatPanelTabAtom, + openTeamInboxInChatPanelTabAtom, openOrFocusSessionInChatPanelTabAtom, openOrReplaceSessionInChatPanelTabAtom, openProjectInChatPanelTabAtom, @@ -828,6 +830,33 @@ describe("ChatPanel navigation tabs", () => { ).toHaveLength(1); }); + it("opens Team Inbox as its own singleton tab", async () => { + const { chatPanelTabsAtom, openTeamInboxInChatPanelTabAtom, store } = + await loadChatPanelTabAtoms(); + + const teamInboxTabId = store.set( + openTeamInboxInChatPanelTabAtom, + "Team Inbox" + ); + const focusedTabId = store.set( + openTeamInboxInChatPanelTabAtom, + "Team Inbox" + ); + + expect(focusedTabId).toBe(teamInboxTabId); + expect(store.get(chatPanelTabsAtom).activeTabId).toBe(teamInboxTabId); + expect( + store + .get(chatPanelTabsAtom) + .tabs.filter((tab) => tab.type === "team-inbox") + ).toEqual([ + expect.objectContaining({ + id: teamInboxTabId, + title: "Team Inbox", + }), + ]); + }); + it("opens org management in its own singleton tab and restores the selected org", async () => { const { activateChatPanelTabAtom, diff --git a/src/store/chatPanel/chatPanelTabFactories.ts b/src/store/chatPanel/chatPanelTabFactories.ts index 8a9119a11..8b8bec8a0 100644 --- a/src/store/chatPanel/chatPanelTabFactories.ts +++ b/src/store/chatPanel/chatPanelTabFactories.ts @@ -30,6 +30,8 @@ export const DEFAULT_LAUNCHPAD_TAB_ID = "launchpad-default"; export const WORK_MANAGEMENT_TAB_ID_PREFIX = "chat-work-management"; /** Fixed id of the singleton Runtime tab. */ export const RUNTIME_TAB_ID = "chat-runtime"; +/** Fixed id of the singleton Team Inbox tab. */ +export const TEAM_INBOX_TAB_ID = "chat-team-inbox"; // --------------------------------------------------------------------------- // start-page (Launchpad) @@ -80,6 +82,18 @@ export const createRuntimeTab = defineChatPanelTabFactory<{ title?: string }>({ getTitle: (data) => data.title ?? "Runtime", }); +// --------------------------------------------------------------------------- +// team-inbox — singleton +// --------------------------------------------------------------------------- + +export const createTeamInboxTab = defineChatPanelTabFactory<{ title?: string }>( + { + tabType: "team-inbox", + idStrategy: { type: "fixed", id: TEAM_INBOX_TAB_ID }, + getTitle: (data) => data.title ?? "Team Inbox", + } +); + // --------------------------------------------------------------------------- // workspace (overview) — one pill per workspace, deduped by openers // --------------------------------------------------------------------------- diff --git a/src/store/chatPanel/chatPanelTabOpenAtoms.ts b/src/store/chatPanel/chatPanelTabOpenAtoms.ts index b31cfb35a..7cd0f2c5b 100644 --- a/src/store/chatPanel/chatPanelTabOpenAtoms.ts +++ b/src/store/chatPanel/chatPanelTabOpenAtoms.ts @@ -26,6 +26,7 @@ import { createProjectTab, createRuntimeTab, createSessionTab, + createTeamInboxTab, createTerminalTab, createWorkItemTab, createWorkManagementTab, @@ -123,6 +124,25 @@ export const openRuntimeInChatPanelTabAtom = atom( ); openRuntimeInChatPanelTabAtom.debugLabel = "openRuntimeInChatPanelTab"; +/** Open or focus the singleton Team Inbox tab. */ +export const openTeamInboxInChatPanelTabAtom = atom( + null, + (get, set, title: string = "Team Inbox") => { + const existingTab = get(chatPanelTabsAtom).tabs.find( + (tab) => tab.type === "team-inbox" + ); + if (existingTab) { + set(activateChatPanelTabAtom, existingTab.id); + return existingTab.id; + } + + const tab = createTeamInboxTab({ title }); + set(appendAndActivateChatPanelTabAtom, { tab }); + return tab.id; + } +); +openTeamInboxInChatPanelTabAtom.debugLabel = "openTeamInboxInChatPanelTab"; + interface OpenWorkManagementTabOptions { section?: WorkManagementSection; title?: string; diff --git a/src/store/chatPanel/chatPanelTabsAtom.ts b/src/store/chatPanel/chatPanelTabsAtom.ts index e892ffb4d..f1ed72182 100644 --- a/src/store/chatPanel/chatPanelTabsAtom.ts +++ b/src/store/chatPanel/chatPanelTabsAtom.ts @@ -30,6 +30,7 @@ export { openWorkManagementChatPanelTabAtom, openOrFocusChatPanelStartPageTabAtom, openRuntimeInChatPanelTabAtom, + openTeamInboxInChatPanelTabAtom, openOrFocusSessionInChatPanelTabAtom, openOrReplaceSessionInChatPanelTabAtom, openProjectInChatPanelTabAtom, @@ -44,6 +45,7 @@ export { createLaunchpadTab, createRuntimeTab, createSessionTab, + createTeamInboxTab, createTerminalTab, createWorkManagementTab, createWorkspaceTab, diff --git a/src/store/chatPanel/chatPanelTabsModel.ts b/src/store/chatPanel/chatPanelTabsModel.ts index bf4ee0e41..c8d9ee78f 100644 --- a/src/store/chatPanel/chatPanelTabsModel.ts +++ b/src/store/chatPanel/chatPanelTabsModel.ts @@ -16,6 +16,7 @@ export type ChatPanelTabType = | "terminal" | "start-page" | "runtime" + | "team-inbox" | "work-management" | "workspace" | "organization" @@ -97,6 +98,7 @@ const PERSISTED_CHAT_PANEL_TAB_TYPES = new Set([ "session", "start-page", "runtime", + "team-inbox", "work-management", "workspace", "organization", @@ -209,6 +211,10 @@ export function normalizePersistedChatPanelTabsState( activeMappedTab?.type === "runtime" ? activeMappedTab.id : mappedTabs.find((tab) => tab.type === "runtime")?.id; + const preferredTeamInboxTabId = + activeMappedTab?.type === "team-inbox" + ? activeMappedTab.id + : mappedTabs.find((tab) => tab.type === "team-inbox")?.id; const preferredOrganizationTab = activeMappedTab?.type === "organization" ? activeMappedTab @@ -228,6 +234,7 @@ export function normalizePersistedChatPanelTabsState( tab.id === preferredWorkManagementTabIds.get(tab.managementSection))) && (tab.type !== "runtime" || tab.id === preferredRuntimeTabId) && + (tab.type !== "team-inbox" || tab.id === preferredTeamInboxTabId) && (tab.type !== "organization" || tab === preferredOrganizationTab) && (tab.type !== "start-page" || tab.id === preferredStartPageTabId) )