diff --git a/docs/architecture-audit-2026-07-23/TeamInbox.md b/docs/architecture-audit-2026-07-23/TeamInbox.md new file mode 100644 index 0000000000..7d4be898cb --- /dev/null +++ b/docs/architecture-audit-2026-07-23/TeamInbox.md @@ -0,0 +1,82 @@ +# Architecture Audit — Team Inbox + +**Scope:** Team Inbox TypeScript domain/UI/data source, managed-cloud mention RPC client, project-management SQLite projection, Tauri commands, Sidebar and Chat Panel tab integration. +**Date:** 2026-07-23 + +## Layer 1 — Compilation correctness + +- TypeScript `tsc --noEmit`: passed. +- Tauri application `cargo check -p org2`: passed. +- Focused Rust Team Inbox tests: 7 passed. + +## Layer 2 — Dead code and structural deduplication + +- Production entry path is Sidebar row → singleton Team Inbox tab → connected view → shared cache/data source → local Tauri projection plus managed-cloud mention RPC. +- Sidebar badge and rendered page consume the same cache; no second unread query implementation remains. +- Local assignment reads remain in SQLite; the frontend does not rescan every project Work Item. +- Mention response mapping is centralized in the Team Inbox data source; sorting/filtering/deduplication remain pure domain selectors. + +## Layer 3 — Naming consistency + +- Wire `work_item_assigned` is mapped once to UI `assigned_work_item`; names are explicit at the boundary. +- `viewerMemberIds` is used consistently for the local viewer identity. The cloud RPC deliberately accepts no viewer ID because JWT identity is authoritative. +- Sidebar/menu/tab terms consistently use `team-inbox` / `Team Inbox`. + +## Layer 4 — Semantic overloading + +| Term | Meaning in this change | Verdict | +| ------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| viewer | Explicit local project member IDs, or managed-cloud JWT subject | Kept separate at transport boundaries; never inferred from an agent/session ID. | +| read receipt | SQLite viewer-scoped receipt for local assignment; endpoint+user+org scoped persisted receipt for cloud mention | Separate storage owners with one UI read state. | +| projectId | Project slug for project-store navigation; empty for standalone Work Items | Boundary is explicit and standalone navigation uses the standalone API. | + +## Layer 5 — Default branch analysis + +- Item-kind branching uses discriminated unions with explicit mention/assignment cases; unsupported wire combinations throw. +- Local mentions filter returns an explicit empty page rather than falling through to assignments. +- Cloud RPC failure degrades to local items only; it does not fabricate mention data or scan comment bodies. + +## Layer 6 — Cross-domain concept leakage + +- Project-management owns only local assigned Work Item projection and receipt DDL. +- Managed-cloud mention transport remains under `features/Org2Cloud`. +- Presentation consumes a transport-independent Team Inbox domain contract. + +## Layer 7 — New developer confusion test + +- `ConnectedTeamInboxView` identifies the production-wired surface; `TeamInboxView` remains injectable for tests/reuse. +- `useTeamInboxDataSource` names local/cloud composition and identity resolution explicitly. +- `useTeamInboxNavigation` separates Session comment navigation from project/standalone Work Item navigation. + +## Layer 8 — Wire protocol and serialization + +- Local DTOs use serde-tagged target/payload variants and camelCase fields, covered by Rust serialization tests. +- Cloud request body contains only `p_org_id`, `p_cursor`, and `p_limit`; tests assert no caller-supplied viewer/user ID. +- Cloud response is Zod-validated; malformed counts and pagination input are rejected. + +## Layer 9 — Init parity + +| Entry point | Canonical schema init | Explicit viewer | Blocking DB isolation | +| ------------- | --------------------: | --------------: | --------------------: | +| list page | yes | yes | `spawn_blocking` | +| unread count | yes | yes | `spawn_blocking` | +| mark read | yes | yes | `spawn_blocking` | +| mark all read | yes | yes | `spawn_blocking` | +| mark unread | yes | yes | `spawn_blocking` | + +All five commands (`team_inbox_list_page`, `team_inbox_unread_count`, `team_inbox_mark_read`, `team_inbox_mark_all_read`, `team_inbox_mark_unread`) are registered in the same Tauri handler list. + +## Layer 10 — Resolver symmetry + +- Local viewer identity uses the same current-user member resolver for list, single read, and bulk read. +- Cloud cache and persisted receipt keys use the same endpoint + authenticated user + org scope. +- Project and standalone navigation both resolve raw Work Item data through the same adapter chain before opening the canonical Chat Panel Work Item tab. + +## Completion verdict + +- Canonical DDL changed directly; no `ALTER TABLE` compatibility path was introduced. +- Local cursor ordering and viewer-scoped receipt idempotence are tested. +- Cloud receipt storage is bounded to 1,000 entries. +- No timer or polling loop was introduced; refresh is driven by initial demand, existing project-change signals, cloud comment signals, and mutations. + +**Architecture verdict: pass for the audited Team Inbox scope.** diff --git a/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md new file mode 100644 index 0000000000..99aed8d570 --- /dev/null +++ b/docs/architecture-audit-2026-07-27/TeamInboxCollaboration.md @@ -0,0 +1,85 @@ +# Architecture Audit — Team Inbox Multi-User Collaboration + +**Scope:** structured member mentions, durable viewer-scoped read receipts, authoritative unread counts, full-roster Work Item identity projection, and dual-instance UI coverage. +**Date:** 2026-07-27 + +## Layer 1 — Compilation correctness + +- TypeScript `tsc --noEmit`: passed. +- Focused ESLint over all changed collaboration/UI files: passed. +- Twenty-four focused Vitest files: 229 tests passed after rebasing onto the latest `develop` and adding capability-gate regression coverage. +- Cloud migration was statically reviewed; live apply and live two-account E2E remain deployment validation. + +## Layer 2 — Dead code and structural deduplication + +- Removed the cloud mention localStorage receipt owner; server receipts are now the sole cross-device source of truth. +- `resolveMentions` and `MemberMentionChip` own repeated UUID-to-name and pill UI logic. +- Session comments load the active roster through the existing shared roster coordinator rather than adding a second fetch/cache. +- Work Item history, description creator, assignee, and reviewer all project the same roster identities. + +## Layer 3 — Naming consistency + +- `mentionedUserIds` is used consistently on client wire/domain models; PostgreSQL uses `mentioned_user_ids`. +- `readAt` denotes the viewer-specific receipt timestamp, while `unreadCount` denotes the authoritative full-result total. +- `markAllTeamInboxMentionsRead` is explicitly org/viewer scoped rather than implying a global Inbox mutation. + +## Layer 4 — Semantic overloading + +| Term | Meaning | Verdict | +| --------------- | -------------------------------------------------------------- | ----------------------------------------------------------- | +| mention | Explicit active-org member UUID attached to a comment | Never inferred from display text. | +| read | Receipt for one authenticated viewer and one mentioned comment | Separate from comment resolution or Session state. | +| unread count | Full eligible mention total outside the current page | Owned by the server response, not derived from loaded rows. | +| member identity | Stable user UUID with roster-projected display name | IDs persist; names may change without rewriting history. | + +## Layer 5 — Default branch analysis + +- Old cloud deployments report no `teamInboxMentions` capability, so the structured picker stays hidden. +- Comment adds without mentions retain the legacy RPC; adds with mentions require the atomic 0010 RPC and never silently drop recipients. +- The view owns optimistic read/unread presentation and per-item rollback generations. The data source serializes the corresponding durable mutations through a bounded queue, so rapid opposite actions cannot commit out of order. +- Empty, loading, pagination, filtered, and partially loaded Inbox states preserve the server unread total. + +## Layer 6 — Cross-domain concept leakage + +- PostgreSQL owns durable receipts, recipient validation, visibility, retention, and authoritative totals. +- Org2Cloud clients own wire validation and transport retry only. +- Team Inbox owns list/filter/optimistic presentation, not receipt persistence. +- Session comments own member selection and mention rendering. +- Work Item components own assignee/reviewer/history identity presentation. + +## Layer 7 — New developer confusion test + +- No caller supplies a viewer ID to receipt RPCs; `auth.uid()` is always authoritative. +- The server accepts recipient UUIDs only after validating active membership in the target org. +- The capability flag documents the required server/client rollout order. +- Local single-user assigned items and cloud mention items remain distinct data-source branches with one normalized Inbox model. + +## Layer 8 — Wire protocol and serialization + +- `cloud_add_session_comment_with_mentions` atomically writes the comment and its deduplicated recipient UUIDs. +- Existing `cloud_list_session_comments` keeps its signature and legacy keys, adding `mentionedUserIds`. +- Mention list rows add `readAt`; the page adds `unreadCount` and a keyset `nextCursor`. +- Receipt mutations return both the resulting `readAt` and a fresh authoritative `unreadCount`. +- Zod schemas reject malformed wire state before it enters UI state. + +## Layer 9 — Init parity + +- Initial Inbox load and pagination both use the same mention projection; only the first page replaces the authoritative count. +- The initial cloud projection is capability-gated. A pre-0010 backend keeps local assigned items available without attempting a missing RPC. +- Reopened comment surfaces load persisted recipient IDs from the ordinary comment list. +- Roster loading is keyed by endpoint/account/org/revision and discards stale identity results. +- Account/org changes evict the previous projection in a layout effect before paint; page and mutation completions carry a load generation and cannot repopulate the new identity with old rows. +- Both primary and secondary desktop instances exercise the production UI/data paths in the extended E2E scenario. + +## Layer 10 — Resolver symmetry + +- Owner, assignee, reviewer, comment author, and mentioned recipient all resolve through the active org roster. +- Mark-read, mark-unread, and mark-all share the same eligibility rules as list/count: membership, retention, deletion, visibility, and active sharing. +- Restricted Sessions are visible only to owner or active grantees across both list and count paths. +- The owner does not receive another member's targeted mention projection unless explicitly included as a recipient. + +## Completion verdict + +- Architecture verdict: pass for Layers 1–10 in the implemented scope. +- Deployment gate: apply cloud migration `0010_team_inbox_mentions.sql` before shipping the desktop capability-enabled experience. +- Remaining production proof: run the managed-cloud two-account E2E after the migration is applied. diff --git a/docs/architecture-audit-2026-07-27/TeamInboxThread.md b/docs/architecture-audit-2026-07-27/TeamInboxThread.md new file mode 100644 index 0000000000..dc306ab99e --- /dev/null +++ b/docs/architecture-audit-2026-07-27/TeamInboxThread.md @@ -0,0 +1,98 @@ +# Architecture Audit — Team Inbox Thread and Kanban Refresh + +**Scope:** Team Inbox full Work Item loading/editing, shared Work Item presentation policy, canonical Start Agent handoff, Session-tab navigation, and local/cloud Kanban manual refresh. +**Date:** 2026-07-27 + +## Layer 1 — Compilation correctness + +- Focused Vitest suites: passed. +- TypeScript `tsc --noEmit`: passed. +- Focused ESLint: passed. + +## Layer 2 — Dead code and structural deduplication + +- Removed the body-only `useTeamInboxWorkItemBody` path. +- `useTeamInboxWorkItem` now resolves the full canonical item used by both `WorkItemContent` and `WorkItemProperties`. +- Moved `toWorkItemPartialUpdate` out of `WorkItemPanelView` so the Chat Panel and Team Inbox share one write-payload mapper. +- The ordinary Work Item view and Team Inbox both use one `WorkItemContent`; only an explicit presentation policy differs. +- `WorkItemThreadLayout` now owns the centered reading frame and metadata-band composition; `WorkItemThreadSection` owns the static card shell. +- To-Do and Workflow share `WORK_ITEM_THREAD_TOKENS`, while Workflow retains the existing `CollapsibleSection` state owner rather than introducing a second collapsible abstraction. +- Thread-only To-Do draft state is component-local and is never persisted until a non-empty item is committed. +- `ChatPanelWorkItemActionRequest` is a transient one-slot command envelope. It carries intent only; the canonical Work Item orchestrator remains the sole execution owner. + +## Layer 3 — Naming consistency + +- Added `presentation: "default" | "thread"` rather than an ambiguous boolean such as `hideSessions`. +- `start_agent` is named as a navigation action request instead of overloading ordinary `open_work_item`. +- `usePendingWorkItemAction` names the only bridge from the transient request to the canonical Work Item start command. +- Added a dedicated `open_session` navigation intent. Opening a Session no longer overloads `open_session_comment` with empty comment/thread IDs. +- `refreshKanbanSources` names the local/cloud fan-out without claiming ownership of either cache. + +## Layer 4 — Semantic overloading + +| Term | Meaning | Verdict | +| ------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| thread | One Work Item activity flow containing workflow/session cards and history | Distinct from a Session comment thread; scoped to `WorkItemContent` presentation. | +| thread layout | Stateless Work Item-domain presentation primitives | Owns composition/tokens only; it does not own persistence, collapse state, or orchestration. | +| Session open | Open/focus a Session Chat Panel tab | Explicit `open_session`; comment anchoring remains `open_session_comment`. | +| refresh | User-triggered authoritative revalidation | Local roster and cloud teammate snapshots keep their own identity/single-flight owners. | +| start request | One-shot UI intent for the matching canonical Work Item | Not workflow state and not persisted in the tab; claimed before async orchestration begins. | + +## Layer 5 — Default branch analysis + +- `resolveWorkItemContentSectionPolicy` handles both closed presentation variants and is unit-tested. +- `default` preserves the legacy tabs plus linked-Session table for existing consumers. +- `thread` omits that table, renders workflow/history inline, and renders output only when proof of work exists. +- Thread description transitions are explicit: read → editing → dirty → saved/cancelled. Save is disabled in editing/clean state. +- Start transitions are explicit: Inbox idle action → resolve/open canonical Work Item → publish matching request → atomically claim request → existing orchestrator validates configuration/locks and starts or reports failure. +- A claimed request is cleared before the async call, so remounts and repeated React effects cannot replay it. A non-matching Work Item cannot claim it. +- The transient channel holds at most one unclaimed request. A newer navigation intent supersedes an older unclaimed intent, preventing a hidden tab from starting unexpectedly when visited later. +- Read/update failures are explicit UI states; they do not fall back to fabricated data. + +## Layer 6 — Cross-domain concept leakage + +- Project persistence stays behind `projectApi` and the shared Work Item payload mapper. +- Team Inbox owns selection and presentation only. +- Thread primitives live under the Work Item component domain rather than a global shared package because the reading width, metadata band, and density are Work Item-specific. +- Agent execution remains exclusively owned by the canonical Work Item surface. Team Inbox publishes intent but does not mount a second orchestrator (which would duplicate collaboration-lock, auto-review, and stale-session lifecycles). +- Chat Panel atoms remain the sole owner of Session tab creation/focus. +- Kanban composes refresh callbacks but does not take ownership of session/cloud caches. + +## Layer 7 — New developer confusion test + +- The presentation policy documents exactly which legacy elements are absent. +- The thread layout API uses semantic slots (`path`, `properties`, `title`, `meta`, `action`) instead of exposing consumer-defined class bags. +- Static versus collapsible cards remain visibly consistent through one token source, while their interaction semantics stay explicit in their owning components. +- The full Work Item hook exposes `loading / ready / error` rather than conflating missing data with loading. +- Standalone items remain readable but do not expose non-functional edit controls. +- Project-scoped items expose compact shared property pills; the full property editor remains available through the canonical Work Item surface. + +## Layer 8 — Wire protocol and serialization + +- No new wire format was introduced. +- The extracted presentation primitives are stateless and introduce no new IPC, persistence, cache, subscription, timer, or request lifecycle. +- The start request is process-local transient UI state; it never enters tab persistence, project persistence, IPC, or the Agent wire payload. +- Work Item writes reuse the existing `WorkItemPartialUpdate` contract. +- To-Do drafts never cross that boundary; only normalized committed rows are serialized. +- Team/shared `+/-` impact is not synthesized: Kanban continues to consume authoritative local impact and cloud session metadata only. + +## Layer 9 — Init parity + +- No Agent initialization entry point changed. The request terminates at the same `handleStartAgent` used by the existing Work Item button. +- Manual refresh uses the same production local roster coordinator and cloud remote-session hook used by initial demand/realtime recovery. +- Tests call the source-composition helper only; rendered acceptance must still drive the real button. + +## Layer 10 — Resolver symmetry + +- Project-scoped reads resolve Work Item plus project metadata/repo identity; standalone reads use the standalone API and stay read-only. +- Local and cloud Kanban sources are both invoked by the manual action, while each source preserves its own scope/identity rules. +- Existing Session tabs are focused; missing tabs are created through the same open-or-focus atom for both Session cards and mention navigation. +- Work Item action resolution is symmetric for newly created and already-open tabs: both are activated first, then receive the same keyed one-shot request. + +## Completion verdict + +- One persistent Work Item owner, one Agent start dispatcher, one Session-tab dispatcher, and one cache owner per Kanban source. +- The Team Inbox navigation wrapper now forwards explicit child intents, so Session cards no longer collapse back to the selected row's generic Work Item destination. +- Stale Work Item reads are cancelled on selection change; overlapping writes use a monotonic generation before replacing UI state. +- Manual workflow refresh preserves the currently rendered Work Item on read failure and exposes the error banner; it does not replace success data with a transient empty state. +- Architecture verdict: pass for Layers 1–10 in the changed scope. diff --git a/docs/architecture-audit-2026-07-28/ActivityTimelineGrouping.md b/docs/architecture-audit-2026-07-28/ActivityTimelineGrouping.md new file mode 100644 index 0000000000..6c4b9d8b3d --- /dev/null +++ b/docs/architecture-audit-2026-07-28/ActivityTimelineGrouping.md @@ -0,0 +1,58 @@ +# Architecture Audit — Activity timeline grouping + +**Scope:** Work-item history projection, derived activity grouping, shared history rendering, localization and regression coverage. +**Date:** 2026-07-28 +**Auditor:** Codex + +## Acceptance criteria + +- [x] Canonical history remains append-only and unmodified. +- [x] Only consecutive update events from the same actor within five minutes are grouped. +- [x] Comments and lifecycle events remain standalone chronological boundaries. +- [x] Every grouped event remains available through an expandable raw audit trail. +- [x] Stored status and priority enums resolve through product-localized labels. +- [x] Team Inbox and formal Work Item entry points inherit the same renderer. + +## Term overloading + +| Term | Meaning | Owner | Verdict | +| ---------------------- | ------------------------------------------------------------------------------- | ----------------------- | ---------------------------- | +| `TimelineEntry` | One canonical display projection of a persisted history event or legacy comment | `useWorkItemTimeline` | Keep | +| `ActivityTimelineItem` | A render-only entry or group derived from ordered timeline entries | `activityTimelineModel` | Keep distinct | +| `change-group` | Consecutive field-update events collapsed for reading, never a persisted event | `activityTimelineModel` | Explicitly presentation-only | +| `actorId` | Stable grouping identity; display name remains presentation metadata | Timeline projection | Keep separate | + +## Ten-layer audit + +| Layer | Coverage | Verdict | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| 1. Compilation correctness | Targeted Vitest, changed-file ESLint and repository TypeScript typecheck pass. | Pass | +| 2. Dead code and structural deduplication | `HistoryTab` delegates timeline rendering to one wired `WorkItemActivityTimeline`; the former inline renderer was removed in the same change. | Pass | +| 3. Naming consistency | Persisted entries, derived items, change groups, fields and actor identity use distinct names. | Pass | +| 4. Semantic overloading | A grouped summary is not represented as a history event and cannot be mistaken for canonical data. | Pass | +| 5. Default branches | Only `updated` events are eligible; comments, create/delete/restore/move, invalid timestamps, actor changes and time gaps all flush the pending group. | Pass | +| 6. Cross-domain leakage | Grouping stays in Work Item presentation; shared timeline primitives remain domain-neutral and persistence types remain unchanged. | Pass | +| 7. New-developer clarity | The model documents grouping boundaries and exposes a discriminated union consumed exhaustively by the renderer. | Pass | +| 8. Wire protocol and serialization | No API, Tauri, cloud, database or serialized history shape changed; `actorId` and field labels exist only in the frontend projection. | Not applicable / safe | +| 9. Entry-point parity | Team Inbox and formal Work Item both reach `HistoryTab` through the shared thread surface and therefore use the same derived grouping. | Pass | +| 10. Resolver symmetry | Persisted history and legacy comments both resolve stable actor IDs; agent delegation classification prefers `actorId` and uses display-name matching only for legacy rows; status and priority old/new values follow the same localization path. | Pass | + +## State and edge-case matrix + +| Input transition | Derived result | Data invariant | +| ----------------------------------- | ------------------------ | ------------------------------------- | +| Same actor + update + ≤5 minute gap | Append to current group | Original entries retained in order | +| Different actor or >5 minute gap | Flush, begin a new run | No cross-actor/time merge | +| Comment or lifecycle event | Flush, render standalone | Human communication remains prominent | +| Invalid/out-of-order timestamp | Flush | Ambiguous events are never merged | +| Expand/collapse group | Native disclosure only | No mutation or persistence write | + +## Systematic sweep + +The ownership sweep covered both Work Item entry points, `HistoryTab`, canonical timeline projection, shared activity primitives, persisted/legacy comment identity, status/priority label dictionaries and the focused tests. No second Work Item timeline renderer or persistence-side grouping path remains. + +## Completion verdict + +- The feature is a pure, memoized presentation projection with no timers, polling, subscriptions, caches or background lifecycle. +- The raw audit trail remains complete and reversible from every compact summary. +- No wire, schema or persistence migration is required. diff --git a/docs/architecture-audit-2026-07-28/TeamInbox.md b/docs/architecture-audit-2026-07-28/TeamInbox.md new file mode 100644 index 0000000000..08da89468e --- /dev/null +++ b/docs/architecture-audit-2026-07-28/TeamInbox.md @@ -0,0 +1,57 @@ +# Architecture Audit — Team Inbox + +**Scope:** `src/modules/MainApp/TeamInbox`, local Team Inbox Rust read/write model, cloud mention client, current-user member resolver, Sidebar consumer + +**Date:** 2026-07-28 +**Auditor:** Codex + +## Findings + +| Priority | Area | Final verdict | Evidence | Resolution | +| -------- | ---------------------------------------- | ------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | Shared pagination and request ownership | fixed | `teamInboxCoordinator.ts`; `teamInboxCoordinator.test.ts` shared-cursor case | A `WeakMap` now owns scope generation, local/cloud cursors, single-flight refresh/load-more, mutation ordering and cancellation for every consumer in one Jotai store. | +| P1 | Independent source failure semantics | fixed | Coordinator partial-initial and partial-pagination tests | Local and cloud promises settle independently. Successful rows/counts commit; only the failed source retains its previous projection/cursor and emits a structured issue. | +| P1 | Ambiguous viewer identity | fixed | `useCurrentUserMemberId.test.ts` exact-domain/name-negative cases | Resolution accepts stable account/member ids, exact full emails/linked emails and exact provider usernames. Display-name and email-local-part guesses were removed. | +| P2 | Assignment episode receipts | fixed | Rust `assignee_change_atomically_resets_team_inbox_receipts` test | `assigned_human_id` and receipt deletion now commit in the same SQLite transaction whenever assignee identity/type changes. Non-human assignees are excluded. | +| P2 | Duplicate mutable UI/cache state | fixed | `TeamInboxView.tsx`; coordinator optimistic rollback test | The coordinator is authoritative for items, counts, optimistic receipt state and Work Item reconciliation. The view retains only a subscribed render snapshot plus filter/query/selection intent. | +| P2 | Empty-result pagination | fixed | `TeamInboxList.test.ts` | `Load more` remains mounted when the current filter/search has no visible rows, so later pages remain reachable. | +| P2 | Retry and stale request behavior | fixed | View retry test; coordinator scope-switch test; cloud abort test | Retry calls the backing refresh boundary. Scope switches abort cloud work, synchronously clear cross-identity data and reject late commits by generation. | +| P2 | Work Item context and update ordering | fixed | `useTeamInboxWorkItem.test.ts` | The body is the required read; project/member context degrades independently. Same-item partial writes run through a bounded invocation-order queue. | +| P2 | Unbounded retained work | fixed | 500-row coordinator bound test; eight-worker member loader | The app-lifetime row snapshot and mutation queue are capped; project member I/O is single-flight, concurrency-bounded and partial-failure aware. | +| P3 | Presentation fallbacks and accessibility | fixed | localized issue codes/thread counts; row/list tests | Semantic cloud values are localized in presentation, internal thread/comment ids were removed from normal metadata, row names are localized, and the conflicting active-descendant model was removed. | + +## Ten-layer coverage + +| Layer | Verdict | Notes | +| -------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1. Compilation | pass with external clippy blocker | TypeScript typecheck, scoped ESLint, Rust format/check and targeted tests pass. Strict clippy remains blocked by the pre-existing `search/src/file/index_cache.rs:34` `type_complexity` warning. | +| 2. Dead code / duplication | pass | Both mounted consumers use the production coordinator; the old instance-local cursor/request/mutation implementation was removed. | +| 3. Naming | pass | Wire `work_item_assigned` and UI `assigned_work_item` remain explicitly translated and cursor-tested. Structured `TeamInboxIssue` replaces user-visible raw failure strings. | +| 4. Semantic overload | pass | The hook resolves prerequisites and binds React; the coordinator owns state transitions; SQLite/cloud clients own durable writes. | +| 5. Defaults | pass | Identity is fail-closed rather than guessed. Cloud DTO fallbacks use stable ids only; localized copy is selected in presentation. | +| 6. Layer boundaries | pass | Persisted receipts remain behind Rust/cloud commands; runtime coordination is store-scoped; filter/query/selection remain component-local. | +| 7. Control flow / FSM | pass | Request versions, scope generations, AbortControllers, shared single-flight promises and per-item mutation epochs define supersession/rollback. | +| 8. Wire protocol | pass | Zod validates mention pages/mutations; viewer identity remains JWT-derived; timeout/cancellation do not alter RPC bodies. | +| 9. Init parity | pass | Sidebar and full Inbox instantiate the same hook and converge on the same per-store runtime/cache/cursors. | +| 10. Resolver symmetry | pass | Account id, primary email, linked email and provider username follow exact matching rules across all member entries; assignee display enrichment uses the same member roster. | + +## Ownership map + +| Value | Owner | Lifetime | Write boundary | Readers | +| -------------------------------------------------- | ------------------------------------ | -------------------------------- | -------------------------------------------- | ----------------------- | +| Assignment and local receipt | SQLite Work Item / Team Inbox tables | durable | atomic Work Item update and receipt commands | coordinator local page | +| Cloud mention and receipt | managed-cloud RPC | durable/collaborative | JWT-scoped mention RPCs | coordinator cloud page | +| Items, totals, cursors, request and mutation state | `TeamInboxCoordinator` + Jotai atom | current store and identity scope | coordinator only | Sidebar and full Inbox | +| Filter, query and selected row | `TeamInboxView` | component mount | React handlers | list/detail composition | +| Full selected Work Item | `useTeamInboxWorkItem` | selected target | canonical partial-update API | shared Work Item thread | + +## Verification + +- `npm run typecheck` — passed. +- Scoped ESLint for Team Inbox, cloud clients and identity resolver — passed. +- `pnpm test` — 751 files / 6,609 tests passed. +- `npx vitest run src/modules/MainApp/TeamInbox/__tests__ src/features/Org2Cloud/teamInboxMentionsClient.test.ts src/features/Org2Cloud/org2CloudFetchRetry.test.ts src/hooks/project/useCurrentUserMemberId.test.ts` — 81 tests passed. +- `cargo test --manifest-path src-tauri/Cargo.toml -p project_management projects::io::work_items::atomic::tests -- --nocapture` — 32 tests passed. +- `cargo check --manifest-path src-tauri/Cargo.toml -p project_management --all-targets` — passed. +- `cargo fmt --manifest-path src-tauri/Cargo.toml -- --check` — passed. +- `cargo clippy --manifest-path src-tauri/Cargo.toml -p project_management --all-targets -- -D warnings` — blocked outside this module by the existing `crates/search/src/file/index_cache.rs:34` warning. diff --git a/docs/architecture-audit-2026-07-28/TeamInboxSessionHandoff.md b/docs/architecture-audit-2026-07-28/TeamInboxSessionHandoff.md new file mode 100644 index 0000000000..8b7739b6d7 --- /dev/null +++ b/docs/architecture-audit-2026-07-28/TeamInboxSessionHandoff.md @@ -0,0 +1,89 @@ +# Architecture Audit — Team Inbox Session Handoff + +**Scope:** Session-to-Work-Item creation, human handoff persistence, Team Inbox projection, recipient response, collaboration payloads, and shared Work Item presentation. +**Date:** 2026-07-28 +**Auditor:** Codex + +## Acceptance criteria + +- [x] Dropping a Session opens a review step instead of immediately mutating data. +- [x] The Session context-menu action opens the same review path for keyboard/pointer users who do not drag. +- [x] A standalone Session requires an explicit eligible project when its destination is ambiguous. +- [x] Project membership and recipient eligibility are re-read at submit time. +- [x] Self is selected by default; selecting another alias of the current user remains a self-assignment. +- [x] A teammate handoff is persisted with the Work Item in the initial write. +- [x] Reusing an existing project Work Item applies the selected assignee and handoff instead of silently returning stale state. +- [x] A later handoff after an accepted/returned episode receives a new durable handoff id. +- [x] Only the intended recipient can accept or return a pending handoff. +- [x] Return requires a reason and atomically reassigns the Work Item to the sender. +- [x] Repeating the same response is idempotent; conflicting resolved transitions are rejected. +- [x] Team Inbox and formal Work Item entry points render the same canonical handoff state. +- [x] Team Inbox projection and collaboration payloads retain the handoff field. +- [x] Collaboration apply updates handoff state on an already-existing remote project Work Item. +- [x] Every production, agent, routine, test, and E2E Work Item constructor initializes the new optional field. + +## Domain terms + +| Term | Meaning | Owner | Verdict | +| --------------------- | ------------------------------------------------------------------------ | ------------------------- | ------------------------------ | +| Session handoff draft | Ephemeral, editable preview produced before any Work Item write | Team Inbox frontend | Keep presentation-only | +| Work Item handoff | Durable sender/recipient decision record attached to one Work Item | Project Management domain | Canonical source of truth | +| Assignment | Current Work Item owner used by ordinary assignment and Inbox projection | Work Item frontmatter | Separate from handoff decision | +| Read receipt | Per-viewer Team Inbox visibility state | Team Inbox store | Independent from accept/return | + +## Ten-layer audit + +| Layer | Coverage | Verdict | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| 1. Compilation correctness | TypeScript typecheck, changed-surface ESLint, 146 focused frontend tests, 41 focused Rust tests, project-management formatting, and full `org2` cargo check pass. The workspace-wide formatter still reports pre-existing drift outside this scope. | Pass | +| 2. Dead code and structural deduplication | Response behavior is owned by `WorkItemContent`; the former Team-Inbox-only response surface and data-source response method were removed. | Pass | +| 3. Naming consistency | `pending`, `accepted`, `returned`, sender, recipient, response note, and transition have one meaning across Rust and TypeScript. | Pass | +| 4. Semantic overloading | Assignment, handoff decision, Inbox read state, and Session provenance remain distinct fields and transitions. | Pass | +| 5. Default branches | The Rust transition function exhaustively handles Accept/Return; unknown or conflicting states cannot fall through to a permissive default. | Pass | +| 6. Cross-domain leakage | Session parsing stays in Team Inbox; durable transition rules stay in Project Management; shared Work Item UI consumes the public model only. | Pass | +| 7. New-developer clarity | Pure form helpers, creation mapper, caller-local shared-operation observer, domain FSM, atomic command, and shared notice each have a single named responsibility. | Pass | +| 8. Wire protocol and serialization | `handoff` is included in extras mapping, enrichment, Team Inbox payloads, atomic fingerprints/history, collaboration outbox payloads, new-item apply, and existing-item partial apply. | Pass | +| 9. Entry-point parity | Drag and both Session tab context menus converge on one request/review state machine; frontend, agent tool, routine, production tests, and E2E constructors initialize `handoff`; both Work Item render entry points use the shared notice. | Pass | +| 10. Resolver symmetry | Sender and recipient identity use a freshly read project-local roster and the full current-user alias set; standalone Sessions select the project before recipient resolution; submit revalidates both before mutation. | Pass | + +## State machine + +| Current state | Actor/action | Result | Assignment effect | +| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------- | +| No handoff | Create for self or a current-user alias | No handoff record | Assign to selected self identity | +| No handoff | Create for teammate | `pending` | Assign to recipient | +| Existing project Work Item | Recreate for teammate | Apply a new `pending` episode or retain the equivalent pending retry | Assign to recipient | +| Existing resolved handoff | Hand off again | New handoff id and `pending` episode | Assign to new recipient | +| `pending` | Recipient accepts | `accepted` | Keep recipient | +| `pending` | Recipient returns with reason | `returned` | Reassign sender and clear prior assignment receipts | +| `accepted` / `returned` | Repeat same action | No-op, same persisted result | No change | +| `accepted` / `returned` | Opposite action | Reject as already resolved | No change | +| Any handoff | Non-recipient responds | Reject | No change | + +## Entry-point parity + +| Entry point | Initializes optional handoff | Reads canonical handoff | Can transition | +| -------------------------------- | --------------------------------------- | ------------------------ | --------------- | +| Session drop → Work Item | `pending` for teammate, `None` for self | Yes | Recipient only | +| Session context menu → Work Item | Same canonical creation path as drop | Yes | Recipient only | +| Ordinary frontend creation | `None` unless explicitly supplied | Yes | Recipient only | +| Agent tool creation | `None` | Yes | Recipient only | +| Routine creation | `None` | Yes | Recipient only | +| Team Inbox detail | N/A | Shared `WorkItemContent` | Recipient only | +| Formal Work Item detail | N/A | Shared `WorkItemContent` | Recipient only | +| Test/E2E seed paths | `None` | Yes | Test-controlled | + +## Systematic sweep + +The sweep covered every `WorkItemFrontmatter` initializer, extras serialization, enrichment mapping, sync/collaboration payload, Team Inbox row projection, both Work Item detail entry points, and the Tauri command registry. Full-application compilation found and fixed the remaining two initialization sites outside the primary project-management crate. + +The audit also found and fixed four cross-layer failure modes: + +1. Single-flight keys now include the complete creation intent, so different project, recipient, title, or note choices cannot share the wrong result. +2. Each caller observes a shared operation with its own `AbortSignal`; closing one UI no longer cancels another consumer. +3. Standalone Session membership is read again at preparation and submit, closing the stale-roster authorization window. +4. Existing Work Items are reconciled atomically with the requested assignee/handoff and can start a new handoff episode after a prior resolution. + +## Completion verdict + +The lifecycle is closed in code: preview → create/reconcile → project → inspect → accept/return → reconcile. The remaining risk is rendered two-account, multi-device operational QA (presence, eventual sync timing, and human-readable error copy), not a missing implementation branch. diff --git a/docs/architecture-audit-2026-07-28/WorkItemThreadSurface.md b/docs/architecture-audit-2026-07-28/WorkItemThreadSurface.md new file mode 100644 index 0000000000..ac779d8378 --- /dev/null +++ b/docs/architecture-audit-2026-07-28/WorkItemThreadSurface.md @@ -0,0 +1,48 @@ +# Architecture Audit — Work Item Thread Surface + +**Scope:** Team Inbox assigned detail, formal Chat Panel Work Item page, shared Work Item presentation and property composition + +**Date:** 2026-07-28 +**Auditor:** Codex + +## Findings + +| Priority | Area | Final verdict | Evidence | Resolution | +| -------- | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| P1 | Presentation ownership | fixed | Both entry points import `WorkItemThreadSurface` | Presentation selection and metadata density are owned once; navigation shells cannot independently drift back to legacy UI. | +| P1 | Agent action ownership | pass | Inbox emits one pending action; formal page retains `usePendingWorkItemAction` and `useWorkItemOrchestrator` | Visual unification does not create a second orchestrator or duplicate Start Agent execution. | +| P2 | Property configuration | fixed | `WORK_ITEM_THREAD_PROPERTY_FIELDS` and `WorkItemThreadSurface` | Field order, pill variant, wrapping and overflow menu are canonical shared policy. | +| P2 | Update state path | pass | Both callers provide their existing canonical partial-update handlers | The surface remains stateless with respect to persistence; updates still reconcile through the owning data source. | +| P2 | Session navigation | pass | Formal page retains `handleOpenSession` and the floating Session view | Removing the legacy linked-session table does not remove access to sessions exposed by the inline workflow/activity content. | + +## Ten-layer coverage + +| Layer | Verdict | Notes | +| -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1. Compilation | pass | TypeScript typecheck and scoped ESLint pass. | +| 2. Dead code / duplication | pass | Formal-page Properties rail state, resize plumbing and toggle were removed; both entry points use one composition. | +| 3. Naming | pass | `WorkItemThreadSurface` names a presentation boundary, not a persistence or navigation owner. | +| 4. Semantic overload | pass | `propertyProps` configures existing controls; `WorkItemContentProps` continues to own workflow/content behavior. | +| 5. Defaults | pass | The wrapper forces `thread`, pill fields, wrapping and overflow menu; omitting `propertyProps` produces a readable thread without fake controls. | +| 6. Layer boundaries | pass | Entry points own navigation and data mutation; the shared surface owns presentation composition only. | +| 7. Control flow / FSM | pass | Existing update, refresh, orchestrator lock/loading and pending-action flows are forwarded unchanged. | +| 8. Wire protocol | skipped | No RPC, persistence schema or serialization shape changed. | +| 9. Init parity | pass | Inbox and formal entry points initialize the same surface from the same `WorkItem` domain shape. | +| 10. Resolver symmetry | pass | Both entry points pass resolved members/current user into the same content implementation. | + +## Ownership map + +| Value | Owner | Lifetime | Write boundary | Readers | +| ------------------------------------ | ---------------------------------------- | --------------------------- | ---------------------------------------- | --------------------------------------- | +| Thread hierarchy and metadata policy | `WorkItemThreadSurface` | component version | source code | Inbox and formal Work Item entry points | +| Work Item value | entry-point data owner | selected item / mounted tab | canonical Work Item partial update API | shared surface | +| Agent lifecycle | `useWorkItemOrchestrator` in formal page | mounted formal Work Item | orchestrator actions | inline workflow section | +| Pending Inbox Start Agent intent | Work Item navigation state | one navigation handoff | `usePendingWorkItemAction` consumes once | formal page | +| Linked Session overlay | `WorkItemPanelView` | formal tab | local UI state + active Session atom | formal page only | + +## Verification + +- `pnpm typecheck` — passed. +- Scoped ESLint for all changed TypeScript files — passed. +- Targeted Vitest suite — 4 files / 11 tests passed. +- Full Vitest suite — 752 files / 6,611 tests passed. diff --git a/docs/architecture-audit-2026-07-29/TeamInboxDevelopIntegration.md b/docs/architecture-audit-2026-07-29/TeamInboxDevelopIntegration.md new file mode 100644 index 0000000000..8487f23b9e --- /dev/null +++ b/docs/architecture-audit-2026-07-29/TeamInboxDevelopIntegration.md @@ -0,0 +1,44 @@ +# Architecture Audit — Team Inbox on current `develop` + +**Scope:** Clean extraction of Team Inbox, collaborative Work Item threads, and Session handoff from the mixed RPC branch onto `origin/develop`. +**Date:** 2026-07-29 +**Auditor:** Codex + +## Acceptance criteria + +- [x] The branch is based on the current `origin/develop`. +- [x] RPC, Onboarding, performance-refactor, and Kanban-refresh source changes are absent. +- [x] Team Inbox is a persisted singleton ChatPanel tab with one sidebar route. +- [x] Sidebar unread state and Team Inbox content share the canonical Team Inbox cache. +- [x] Session drag and context-menu entry points converge on the same review/create state machine. +- [x] Work Item handoff persistence, collaboration projection, accept/return transitions, and retry behavior remain intact. +- [x] Current `develop` organization-tab and modular-sidebar ownership are preserved. + +## Ten-layer audit + +| Layer | Coverage | Verdict | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| 1. Compilation correctness | Full TypeScript typecheck, 305 focused frontend tests, focused Rust tests, full `org2` cargo check, changed-file ESLint, changed-Rust rustfmt, and `git diff --check`. | Pass | +| 2. Dead code and structural deduplication | Sidebar click routing opens the same singleton tab factory used by all Team Inbox entry points; Work Item details use the shared thread surface. | Pass | +| 3. Naming consistency | `team-inbox`, `TEAM_INBOX_MENU_ITEM_ID`, `openTeamInboxTab`, and localized `teamInboxLabel` retain one meaning across model, routing, and UI. | Pass | +| 4. Semantic overloading | Inbox read receipts, Work Item assignment, human handoff status, and Session provenance remain separate facts. | Pass | +| 5. Default branches | Tab rendering and sidebar routing handle Team Inbox explicitly; it cannot fall into Runtime, Work Management, or Organization defaults. | Pass | +| 6. Cross-domain leakage | Inbox orchestration stays in `modules/MainApp/TeamInbox`; durable Work Item transitions stay in project management; sidebar modules only route and display unread state. | Pass | +| 7. New-developer clarity | The latest modular sidebar keeps atom binding, labels, pinned data, routing, and chrome forwarding in their named owner modules. | Pass | +| 8. Wire protocol and serialization | The extraction retains the tested Team Inbox DTO and Work Item `handoff` serialization without importing RPC protocol changes. | Pass | +| 9. Init parity | Sidebar selection, persisted-tab restore, context-menu handoff, and drag/drop all reach the same canonical owners. | Pass | +| 10. Resolver symmetry | Sender and recipient identities use project-member resolution consistently; the latest `develop` organization model is retained rather than reintroducing old cloud/local tab variants. | Pass | + +## Lifecycle and ownership + +| Transition | Owner | Completion / recovery | +| -------------------- | ---------------------------------- | -------------------------------------------------------------------- | +| Sidebar → Team Inbox | ChatPanel tab atoms | Focus existing singleton or create one tab | +| Session → preview | Team Inbox handoff request state | Cancel without mutation, or submit one bounded intent | +| Preview → Work Item | Project Management atomic write | Reuse compatible item or create; surface retryable failure | +| Recipient response | Work Item handoff state machine | Accept, return with reason, idempotent replay, or explicit rejection | +| Collaboration update | Work Item collaboration projection | Reconcile the canonical persisted handoff and Inbox projection | + +## Completion verdict + +The clean branch preserves the complete Team Inbox collaboration lifecycle while removing the unrelated RPC/performance stack. Remaining risk is rendered two-account operational QA, not an unresolved code or persistence path. diff --git a/docs/frontend-ui-audit-2026-07-23/TeamInbox.md b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md new file mode 100644 index 0000000000..09b75a4451 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-23/TeamInbox.md @@ -0,0 +1,50 @@ +# Frontend UI Audit — Team Inbox + +**Files:** `src/modules/MainApp/TeamInbox/**/*.tsx` +**Date:** 2026-07-23 +**Auditor:** ORGII implementation session + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +|---|---|---|---|---| +| `TeamInboxRow.tsx:42` | raw ` ) : null} + {mentionOptions.length > 0 ? ( +
+ + setMentionedUserIds(Array.isArray(value) ? value.map(String) : []) + } + > + + + {mentionedNames.map((member) => ( + + ))} +
+ ) : null}
{onCancel && (
) : ( -
- {agentMention ? ( - <> - - - {agentMention.brief} - - ) : ( - comment.body - )} -
+ <> + {mentionedMembers.length > 0 ? ( +
+ {mentionedMembers.map((member) => ( + + ))} +
+ ) : null} +
+ {agentMention ? ( + <> + + + {agentMention.brief} + + ) : ( + comment.body + )} +
+ )} ); @@ -461,6 +567,7 @@ interface ThreadBlockProps { thread: CommentThread; viewerUserId: string | null; viewerIsAdmin: boolean; + mentionableMembers: readonly CloudOrgMember[]; onAdd: CommentThreadListProps["onAdd"]; onEdit: CommentThreadListProps["onEdit"]; onDelete: CommentThreadListProps["onDelete"]; @@ -471,6 +578,7 @@ const ThreadBlock: React.FC = ({ thread, viewerUserId, viewerIsAdmin, + mentionableMembers, onAdd, onEdit, onDelete, @@ -500,6 +608,7 @@ const ThreadBlock: React.FC = ({
= ({ = ({ placeholder={t("cloud.comments.replyPlaceholder")} submitLabel={t("cloud.comments.reply")} autoFocus - onSubmit={async (body) => { - await onAdd(body, thread.top.id); + mentionableMembers={mentionableMembers} + onSubmit={async (body, mentionedUserIds) => { + await onAdd(body, thread.top.id, mentionedUserIds); setReplying(false); }} onCancel={() => setReplying(false)} @@ -569,6 +680,7 @@ const CommentThreadList: React.FC = ({ composerPlaceholder, onComposerCancel, emptyLabel, + mentionableMembers: mentionableMembersOverride, onAdd, onEdit, onDelete, @@ -576,6 +688,11 @@ const CommentThreadList: React.FC = ({ }) => { const { t } = useTranslation("navigation"); const context = useSessionCommentsContext(); + const mentionableMembers = ( + mentionableMembersOverride ?? + context?.mentionableMembers ?? + [] + ).filter((member) => member.userId !== viewerUserId); const [showResolved, setShowResolved] = useState(false); const openThreads = threads.filter((thread) => !isThreadResolved(thread)); @@ -583,8 +700,8 @@ const CommentThreadList: React.FC = ({ const requestAgent = context?.requestAgent; const submitTopLevel = useCallback( - async (body: string): Promise => { - const comment = await onAdd(body); + async (body: string, mentionedUserIds: string[]): Promise => { + const comment = await onAdd(body, undefined, mentionedUserIds); // Beyond here the comment IS posted — never throw (a throw would // trigger the composer's draft restore for a send that succeeded). if (!comment || comment.parentId) return; @@ -612,6 +729,7 @@ const CommentThreadList: React.FC = ({ submitLabel={t("cloud.comments.send")} disabled={composerDisabled} allowAgentMention={Boolean(requestAgent && context?.canRunAgent)} + mentionableMembers={mentionableMembers} onSubmit={submitTopLevel} onCancel={onComposerCancel} testId="session-comment-composer" @@ -629,6 +747,7 @@ const CommentThreadList: React.FC = ({ thread={thread} viewerUserId={viewerUserId} viewerIsAdmin={viewerIsAdmin} + mentionableMembers={mentionableMembers} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} @@ -653,6 +772,7 @@ const CommentThreadList: React.FC = ({ thread={thread} viewerUserId={viewerUserId} viewerIsAdmin={viewerIsAdmin} + mentionableMembers={mentionableMembers} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx index 09e6e2ce37..e9c7dc7575 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx @@ -16,7 +16,7 @@ * session-id-keyed registry atom written here and read by * `SessionCommentsHeaderExtras` (the header renders outside ChatView). */ -import { atom, useAtomValue, useSetAtom } from "jotai"; +import { atom, useAtomValue, useSetAtom, useStore } from "jotai"; import React, { createContext, useCallback, @@ -24,6 +24,7 @@ import React, { useEffect, useId, useMemo, + useState, } from "react"; import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; @@ -34,14 +35,21 @@ import { getSessionForkedFrom } from "../../TeamCollaboration/forkSession"; import { collectAddressableThreads } from "../addressComments"; import { addressRunActiveAtom } from "../addressCommentsRun"; import { + commitRefreshedAuth, org2CloudAuthAtom, org2CloudAuthIdentityKey, } from "../org2CloudAuthAtom"; +import { getCloudCapabilities } from "../org2CloudCapabilities"; +import type { CloudOrgMember } from "../org2CloudClient"; import type { CloudCommentResolution, CloudSessionComment, } from "../org2CloudCommentsClient"; -import { org2CloudOrgsAtom } from "../org2CloudOrgsAtom"; +import { loadCloudOrgMembers } from "../org2CloudMembersCoordinator"; +import { + org2CloudOrgsAtom, + org2CloudRosterVersionAtom, +} from "../org2CloudOrgsAtom"; import { org2CloudRemoteSessionsAtom, remoteSessionsEntryForIdentity, @@ -134,6 +142,8 @@ export interface SessionCommentsContextValue { viewerUserId: string | null; /** Org admin/owner — may delete any comment (moderation surface). */ viewerIsAdmin: boolean; + /** Active org members available for identity-stable mentions. */ + mentionableMembers: readonly CloudOrgMember[]; refresh: () => void; addComment: (input: AddCommentInput) => Promise; /** @@ -172,6 +182,64 @@ export function useSessionCommentsContext(): SessionCommentsContextValue | null return useContext(SessionCommentsContext); } +/** + * Roster reads share the app-wide coordinator and are keyed by account, + * endpoint, org, and roster revision. Late identity responses are discarded. + */ +export function useSessionCommentMentionableMembers( + target: SessionCommentTarget | null +): readonly CloudOrgMember[] { + const store = useStore(); + const auth = useAtomValue(org2CloudAuthAtom); + const setAuth = useSetAtom(org2CloudAuthAtom); + const rosterVersions = useAtomValue(org2CloudRosterVersionAtom); + const identityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const orgId = target?.orgId ?? null; + const rosterVersion = orgId ? (rosterVersions[orgId] ?? 0) : 0; + const requestKey = + identityKey && orgId ? `${identityKey}|${orgId}|${rosterVersion}` : null; + const [resolved, setResolved] = useState<{ + key: string; + members: CloudOrgMember[]; + } | null>(null); + + useEffect(() => { + let cancelled = false; + if (!auth || !identityKey || !orgId || !requestKey) return; + const requestAuth = auth; + void Promise.all([ + loadCloudOrgMembers(store, requestAuth, orgId, rosterVersion), + getCloudCapabilities(requestAuth.accessToken), + ]) + .then(([loaded, capabilities]) => { + if (!loaded || cancelled) return; + commitRefreshedAuth(setAuth, requestAuth, loaded.auth); + const latestAuth = store.get(org2CloudAuthAtom); + if ( + !latestAuth || + org2CloudAuthIdentityKey(latestAuth) !== identityKey || + (store.get(org2CloudRosterVersionAtom)[orgId] ?? 0) > rosterVersion + ) { + return; + } + setResolved({ + key: requestKey, + members: capabilities.teamInboxMentions + ? loaded.members.filter((member) => member.status === "active") + : [], + }); + }) + .catch(() => { + if (!cancelled) setResolved({ key: requestKey, members: [] }); + }); + return () => { + cancelled = true; + }; + }, [auth, identityKey, orgId, requestKey, rosterVersion, setAuth, store]); + + return resolved?.key === requestKey ? resolved.members : []; +} + /** * Viewer-side capability probes shared by the provider and the header * extras (which runs its own instance because it mounts outside ChatView). @@ -284,6 +352,7 @@ export const SessionCommentsProvider: React.FC< originSessionId ); const viewer = useSessionCommentViewer(target); + const mentionableMembers = useSessionCommentMentionableMembers(target); const setPresentRegistry = useSetAtom(sessionCommentPresentEventIdsAtom); // Publish the replay stream's event ids for the header notes dialog — @@ -376,6 +445,7 @@ export const SessionCommentsProvider: React.FC< canAnchorTurns: viewer.canAnchorTurns, viewerUserId: viewer.viewerUserId, viewerIsAdmin: viewer.viewerIsAdmin, + mentionableMembers, refresh, addComment, editComment, @@ -395,6 +465,7 @@ export const SessionCommentsProvider: React.FC< toSourceEventId, turnAnchorsVisible, viewer, + mentionableMembers, refresh, addComment, editComment, diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx index a1a7ef10b7..6a4ea3fede 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsHeaderExtras.tsx @@ -37,6 +37,7 @@ import { useSessionCommentTarget } from "../sessionCommentTarget"; import CommentThreadList from "./CommentThreadList"; import { sessionCommentPresentEventIdsAtom, + useSessionCommentMentionableMembers, useSessionCommentViewer, } from "./SessionCommentsContext"; @@ -65,6 +66,7 @@ const SessionCommentsHeaderExtras: React.FC< : null ); const viewer = useSessionCommentViewer(target); + const mentionableMembers = useSessionCommentMentionableMembers(target); const presentRegistry = useAtomValue(sessionCommentPresentEventIdsAtom); const [open, setOpen] = useState(false); @@ -84,18 +86,22 @@ const SessionCommentsHeaderExtras: React.FC< ); const handleAddNote = useCallback( - async (body: string, parentId?: string) => + async (body: string, parentId?: string, mentionedUserIds?: string[]) => // Session-level notes carry NO anchor; replies inherit the parent's. // Returning the row satisfies the list's onAdd contract; the agent // affordances stay dormant here regardless (no provider ⇒ null // context in this dialog's tree). - addComment(parentId ? { body, parentId } : { body }), + addComment( + parentId + ? { body, parentId, mentionedUserIds } + : { body, mentionedUserIds } + ), [addComment] ); const handleReplyOnly = useCallback( - async (body: string, parentId?: string) => { + async (body: string, parentId?: string, mentionedUserIds?: string[]) => { if (!parentId) return undefined; - return addComment({ body, parentId }); + return addComment({ body, parentId, mentionedUserIds }); }, [addComment] ); @@ -149,6 +155,7 @@ const SessionCommentsHeaderExtras: React.FC< threads={grouped.sessionLevel} viewerUserId={viewer.viewerUserId} viewerIsAdmin={viewer.viewerIsAdmin} + mentionableMembers={mentionableMembers} emptyLabel={ state === "error" ? t("cloud.comments.loadError") @@ -171,6 +178,7 @@ const SessionCommentsHeaderExtras: React.FC< threads={grouped.orphaned} viewerUserId={viewer.viewerUserId} viewerIsAdmin={viewer.viewerIsAdmin} + mentionableMembers={mentionableMembers} // New top-level anchors into a dropped event would be // meaningless — replies/resolve on existing threads stay. showComposer={false} diff --git a/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx b/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx index 66a852aa5c..5ccea10a1e 100644 --- a/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx +++ b/src/features/Org2Cloud/SessionComments/TurnCommentChrome.tsx @@ -44,7 +44,8 @@ const TurnCommentChrome: React.FC = ({ const handleAdd = useCallback( async ( body: string, - parentId?: string + parentId?: string, + mentionedUserIds?: string[] ): Promise => { if (!addComment) return undefined; // Replies inherit the parent's anchor — never send both (0014 @@ -53,9 +54,10 @@ const TurnCommentChrome: React.FC = ({ // the SOURCE plane, so a fork/import's namespaced local id is stripped. return addComment( parentId - ? { body, parentId } + ? { body, parentId, mentionedUserIds } : { body, + mentionedUserIds, eventId: toSourceEventId ? toSourceEventId(anchorEventId) : anchorEventId, diff --git a/src/features/Org2Cloud/org2CloudCapabilities.test.ts b/src/features/Org2Cloud/org2CloudCapabilities.test.ts index f94e74b3fc..c739d929a3 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.test.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.test.ts @@ -27,11 +27,13 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -45,6 +47,7 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); }); @@ -58,6 +61,22 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: true, homeEndpoints: true, + teamInboxMentions: false, + }); + }); + + it("parses the 0010 Team Inbox mention capability", async () => { + rawMock.mockResolvedValueOnce({ + broadcastSignals: true, + storageSegments: true, + homeEndpoints: true, + teamInboxMentions: true, + }); + expect(await getCloudCapabilities("jwt-1")).toEqual({ + broadcastSignals: true, + storageSegments: true, + homeEndpoints: true, + teamInboxMentions: true, }); }); @@ -67,12 +86,14 @@ describe("getCloudCapabilities", () => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); rawMock.mockResolvedValueOnce({ broadcastSignals: true }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(2); }); @@ -87,11 +108,13 @@ describe("getCloudCapabilities", () => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -110,11 +133,13 @@ describe("getCloudCapabilities", () => { broadcastSignals: true, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); expect(await second).toEqual({ broadcastSignals: true, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); diff --git a/src/features/Org2Cloud/org2CloudCapabilities.ts b/src/features/Org2Cloud/org2CloudCapabilities.ts index b59d7b2e8c..4649840c4c 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.ts @@ -9,23 +9,29 @@ import { z } from "zod/v4"; import { getCloudEndpoint } from "./config"; import { getCloudCapabilitiesRaw } from "./org2CloudClient"; +import { runCloudRequestWithTimeout } from "./org2CloudFetchRetry"; + +const CLOUD_CAPABILITIES_TIMEOUT_MS = 15_000; const CloudCapabilitiesWireSchema = z.object({ broadcastSignals: z.boolean().nullish().catch(undefined), storageSegments: z.boolean().nullish().catch(undefined), homeEndpoints: z.boolean().nullish().catch(undefined), + teamInboxMentions: z.boolean().nullish().catch(undefined), }); export interface CloudCapabilities { broadcastSignals: boolean; storageSegments: boolean; homeEndpoints: boolean; + teamInboxMentions: boolean; } const LEGACY_CAPABILITIES: CloudCapabilities = { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }; const capabilitiesByEndpoint = new Map(); @@ -40,7 +46,10 @@ export async function getCloudCapabilities( const inFlight = inFlightByEndpoint.get(endpointKey); if (inFlight) return inFlight; const probe = (async () => { - const payload = await getCloudCapabilitiesRaw(accessToken); + const payload = await runCloudRequestWithTimeout( + (signal) => getCloudCapabilitiesRaw(accessToken, signal), + CLOUD_CAPABILITIES_TIMEOUT_MS + ); const parsed = CloudCapabilitiesWireSchema.safeParse(payload); if (payload === null || !parsed.success) { // 404 (pre-0005) and transient failures are indistinguishable here, so @@ -52,6 +61,7 @@ export async function getCloudCapabilities( broadcastSignals: parsed.data.broadcastSignals ?? false, storageSegments: parsed.data.storageSegments ?? false, homeEndpoints: parsed.data.homeEndpoints ?? false, + teamInboxMentions: parsed.data.teamInboxMentions ?? false, }; capabilitiesByEndpoint.set(endpointKey, capabilities); return capabilities; diff --git a/src/features/Org2Cloud/org2CloudClient.ts b/src/features/Org2Cloud/org2CloudClient.ts index eda9218949..8940b02cfa 100644 --- a/src/features/Org2Cloud/org2CloudClient.ts +++ b/src/features/Org2Cloud/org2CloudClient.ts @@ -98,7 +98,8 @@ async function callRpc( functionName: string, accessToken?: string, body?: Record, - endpoint: CloudRpcEndpoint = getCloudEndpoint() + endpoint: CloudRpcEndpoint = getCloudEndpoint(), + signal?: AbortSignal ): Promise { try { const response = await fetchWithTransportRetry( @@ -107,6 +108,7 @@ async function callRpc( method: "POST", headers: rpcHeaders(accessToken, endpoint), body: JSON.stringify(body ?? {}), + signal, } ); if (!response.ok) { @@ -132,9 +134,16 @@ export async function schemaVersion(): Promise { * transport failure. Interpretation/caching live in `org2CloudCapabilities`. */ export async function getCloudCapabilitiesRaw( - accessToken: string + accessToken: string, + signal?: AbortSignal ): Promise { - return callRpc("get_cloud_capabilities", accessToken); + return callRpc( + "get_cloud_capabilities", + accessToken, + undefined, + getCloudEndpoint(), + signal + ); } /** diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts index 9b1b46e168..2842dfcec5 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts @@ -173,6 +173,30 @@ describe("addSessionComment", () => { expect(lastBody().p_event_id).toBeNull(); }); + it("uses the atomic mentions RPC with deduplicated member ids", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + comment: { + ...WIRE_COMMENT, + mentionedUserIds: ["user-2", "user-3"], + }, + }) + ); + + const comment = await addSessionComment("jwt-1", { + orgId: "org-1", + sessionId: "sess-1", + body: "Please review", + mentionedUserIds: ["user-2", "user-2", "user-3"], + }); + + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_add_session_comment_with_mentions` + ); + expect(lastBody().p_mentioned_user_ids).toEqual(["user-2", "user-3"]); + expect(comment.mentionedUserIds).toEqual(["user-2", "user-3"]); + }); + it("sends JWT bearer + Content-Profile", async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ comment: WIRE_COMMENT })); await addSessionComment("jwt-9", { diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.ts b/src/features/Org2Cloud/org2CloudCommentsClient.ts index 98615ef918..55c8d625f2 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.ts @@ -173,6 +173,8 @@ const CloudSessionCommentWireSchema = z.object({ .nullish() .transform((value) => value ?? undefined) .optional(), + /** Explicit user ids targeted by the comment (0010 Team Inbox). */ + mentionedUserIds: z.array(z.string()).max(50).optional(), }); export type CloudSessionComment = z.output< @@ -217,6 +219,11 @@ export interface AddSessionCommentInput { parentId?: string; /** 'agent_report' — accepted only from the cloud-session owner. */ kind?: "agent_report"; + /** + * Explicit active org-member ids to notify. Display names are never parsed + * server-side because they are mutable and may not be unique. + */ + mentionedUserIds?: string[]; /** * Local session the comment ORIGINATED from (the fork the author is * viewing). Stored server-side for per-fork count attribution; omitted / @@ -248,10 +255,21 @@ export async function addSessionComment( // pre-extension-compat rule as p_kind). Only forks/imports set it — a // source-plane comment omits it and coalesces to the source at count time. if (input.originSessionId) body.p_origin_session_id = input.originSessionId; + const mentionedUserIds = [ + ...new Set(input.mentionedUserIds?.filter(Boolean) ?? []), + ]; + if (mentionedUserIds.length > 50) { + throw new Org2CloudCommentError("ORG2_VALIDATION"); + } + if (mentionedUserIds.length > 0) { + body.p_mentioned_user_ids = mentionedUserIds; + } let payload: unknown; try { payload = await callCommentRpc( - "cloud_add_session_comment", + mentionedUserIds.length > 0 + ? "cloud_add_session_comment_with_mentions" + : "cloud_add_session_comment", accessToken, body ); @@ -262,6 +280,7 @@ export async function addSessionComment( // plane); per-fork attribution just waits for the migration. if ( "p_origin_session_id" in body && + mentionedUserIds.length === 0 && error instanceof Org2CloudCommentError && error.status === 404 ) { diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts index 738777dbfe..7bdd577605 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts @@ -489,6 +489,7 @@ export function useSessionComments( body: input.body, eventId: input.eventId, parentId: input.parentId, + mentionedUserIds: input.mentionedUserIds, ...(originSessionId && originSessionId !== sessionId ? { originSessionId } : {}), diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts index 847c457a54..6252dc2e53 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts @@ -61,6 +61,8 @@ export interface AddCommentInput { body: string; eventId?: string; parentId?: string; + /** Active cloud-org members explicitly notified by this comment. */ + mentionedUserIds?: string[]; } export interface UseSessionCommentsResult { diff --git a/src/features/Org2Cloud/org2CloudSyncClient.test.ts b/src/features/Org2Cloud/org2CloudSyncClient.test.ts index aead57f075..d3ebafd063 100644 --- a/src/features/Org2Cloud/org2CloudSyncClient.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncClient.test.ts @@ -62,6 +62,7 @@ beforeEach(() => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); }); @@ -260,6 +261,7 @@ describe("storage segment offload (0006)", () => { broadcastSignals: false, storageSegments: true, homeEndpoints: false, + teamInboxMentions: false, }); }); @@ -340,6 +342,7 @@ describe("storage segment offload (0006)", () => { broadcastSignals: false, storageSegments: false, homeEndpoints: false, + teamInboxMentions: false, }); await appendSessionEvents("jwt-1", appendInput([makeEvent("f1")], null)); expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.test.ts b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts new file mode 100644 index 0000000000..e60b640608 --- /dev/null +++ b/src/features/Org2Cloud/teamInboxMentionsClient.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ZodError } from "zod/v4"; + +import { + ORG2_CLOUD_OFFICIAL_ANON_KEY, + ORG2_CLOUD_OFFICIAL_SUPABASE_URL, + ORG2_CLOUD_POSTGREST_SCHEMA, +} from "./config"; +import { __CAPABILITIES_INTERNALS } from "./org2CloudCapabilities"; +import { Org2CloudCommentError } from "./org2CloudCommentsClient"; +import { + listInitialTeamInboxMentions, + listTeamInboxMentions, + markAllTeamInboxMentionsRead, + setTeamInboxMentionRead, +} from "./teamInboxMentionsClient"; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function lastCall(): { url: string; init: RequestInit } { + const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit]; + return { url, init }; +} + +function lastBody(): Record { + return JSON.parse(String(lastCall().init.body)) as Record; +} + +const WIRE_MENTION = { + comment: { id: "comment-2", parentId: "comment-1" }, + session: { id: "session-1", title: "Fix Team Inbox" }, + author: { userId: "user-a", displayName: "Alice" }, + body: "Please review this change", + createdAt: "2026-07-23T10:00:00.000Z", + readAt: null, + commentCount: 4, + threadCount: 2, +}; + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockReset(); + __CAPABILITIES_INTERNALS.reset(); +}); + +describe("listInitialTeamInboxMentions", () => { + it("keeps older endpoints on the local-only path without probing a missing RPC", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + broadcastSignals: true, + storageSegments: true, + teamInboxMentions: false, + }) + ); + + await expect( + listInitialTeamInboxMentions("jwt-viewer", "org-1") + ).resolves.toEqual({ mentions: [], unreadCount: 0 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/get_cloud_capabilities` + ); + }); + + it("loads the first page after the endpoint advertises mention support", async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + broadcastSignals: true, + storageSegments: true, + teamInboxMentions: true, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + mentions: [WIRE_MENTION], + nextCursor: null, + unreadCount: 1, + }) + ); + + await expect( + listInitialTeamInboxMentions("jwt-viewer", "org-1", 25) + ).resolves.toEqual({ + mentions: [WIRE_MENTION], + nextCursor: undefined, + unreadCount: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_list_team_inbox_mentions` + ); + expect(lastBody()).toEqual({ + p_org_id: "org-1", + p_cursor: null, + p_limit: 25, + }); + }); +}); + +describe("listTeamInboxMentions", () => { + it("posts the managed-cloud wire contract without a viewer identity", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + mentions: [WIRE_MENTION], + nextCursor: "cursor-2", + unreadCount: 7, + }) + ); + + await listTeamInboxMentions("jwt-viewer", "org-1", "cursor-1", 25); + + const { url, init } = lastCall(); + expect(url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_list_team_inbox_mentions` + ); + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + apikey: ORG2_CLOUD_OFFICIAL_ANON_KEY, + authorization: "Bearer jwt-viewer", + "content-type": "application/json", + "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, + }); + expect(lastBody()).toEqual({ + p_org_id: "org-1", + p_cursor: "cursor-1", + p_limit: 25, + }); + expect(lastBody()).not.toHaveProperty("p_viewer_id"); + expect(lastBody()).not.toHaveProperty("p_user_id"); + }); + + it("sends a null cursor for the first page", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ mentions: [], nextCursor: null, unreadCount: 0 }) + ); + + await listTeamInboxMentions("jwt-viewer", "org-1", null, 50); + + expect(lastBody()).toEqual({ + p_org_id: "org-1", + p_cursor: null, + p_limit: 50, + }); + }); + + it("parses the stable mention response contract", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + mentions: [WIRE_MENTION], + nextCursor: "cursor-2", + unreadCount: 7, + }) + ); + + const page = await listTeamInboxMentions("jwt-viewer", "org-1", null, 25); + + expect(page).toEqual({ + mentions: [WIRE_MENTION], + nextCursor: "cursor-2", + unreadCount: 7, + }); + }); + + it("normalizes nullable optional fields and terminal cursor", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + mentions: [ + { + ...WIRE_MENTION, + comment: { id: "comment-2", parentId: null }, + session: { id: "session-1", title: null }, + author: { userId: "user-a", displayName: null }, + }, + ], + nextCursor: null, + unreadCount: 1, + }) + ); + + const page = await listTeamInboxMentions("jwt-viewer", "org-1", null, 25); + + expect(page.nextCursor).toBeUndefined(); + expect(page.mentions[0]).toMatchObject({ + comment: { id: "comment-2", parentId: undefined }, + session: { id: "session-1", title: undefined }, + author: { userId: "user-a", displayName: undefined }, + }); + }); + + it("rejects malformed response fields instead of leaking raw wire data", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + mentions: [{ ...WIRE_MENTION, commentCount: -1 }], + nextCursor: null, + unreadCount: 1, + }) + ); + + await expect( + listTeamInboxMentions("jwt-viewer", "org-1", null, 25) + ).rejects.toBeInstanceOf(ZodError); + }); + + it("validates pagination input before making a request", async () => { + await expect( + listTeamInboxMentions("jwt-viewer", "org-1", null, 0) + ).rejects.toBeInstanceOf(ZodError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("throws the comments client RPC error without backend fallback", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ message: "ORG2_MEMBER_REQUIRED" }, 403) + ); + + const error = await listTeamInboxMentions( + "jwt-viewer", + "org-1", + null, + 25 + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Org2CloudCommentError); + expect(error).toMatchObject({ code: "ORG2_MEMBER_REQUIRED", status: 403 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("cancels an in-flight RPC when its owning Inbox scope is disposed", async () => { + fetchMock.mockImplementationOnce( + () => new Promise(() => undefined) + ); + const controller = new AbortController(); + + const request = listTeamInboxMentions( + "jwt-viewer", + "org-1", + null, + 25, + controller.signal + ); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect((lastCall().init.signal as AbortSignal).aborted).toBe(true); + }); +}); + +describe("Team Inbox read receipts", () => { + it("persists a single receipt without sending a viewer id", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + readAt: "2026-07-27T12:00:00.000Z", + unreadCount: 2, + }) + ); + + const result = await setTeamInboxMentionRead( + "jwt-viewer", + "org-1", + "comment-2", + true + ); + + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_set_team_inbox_mention_read` + ); + expect(lastBody()).toEqual({ + p_org_id: "org-1", + p_comment_id: "comment-2", + p_read: true, + }); + expect(lastBody()).not.toHaveProperty("p_viewer_user_id"); + expect(result).toEqual({ + readAt: "2026-07-27T12:00:00.000Z", + unreadCount: 2, + }); + }); + + it("marks all server-side, including unloaded pages", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + readAt: "2026-07-27T12:00:00.000Z", + unreadCount: 0, + }) + ); + + await markAllTeamInboxMentionsRead("jwt-viewer", "org-1"); + + expect(lastCall().url).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_mark_all_team_inbox_mentions_read` + ); + expect(lastBody()).toEqual({ p_org_id: "org-1" }); + }); +}); diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.ts b/src/features/Org2Cloud/teamInboxMentionsClient.ts new file mode 100644 index 0000000000..6a87fa44ce --- /dev/null +++ b/src/features/Org2Cloud/teamInboxMentionsClient.ts @@ -0,0 +1,204 @@ +import { z } from "zod/v4"; + +import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config"; +import { getCloudCapabilities } from "./org2CloudCapabilities"; +import { Org2CloudCommentError } from "./org2CloudCommentsClient"; +import { + fetchWithTransportRetry, + runCloudRequestWithTimeout, +} from "./org2CloudFetchRetry"; + +const TEAM_INBOX_MENTIONS_RPC = "cloud_list_team_inbox_mentions"; +const SET_TEAM_INBOX_MENTION_READ_RPC = "cloud_set_team_inbox_mention_read"; +const MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC = + "cloud_mark_all_team_inbox_mentions_read"; +const TEAM_INBOX_REQUEST_TIMEOUT_MS = 15_000; + +const TeamInboxMentionRequestSchema = z.object({ + orgId: z.string().min(1), + cursor: z.string().min(1).nullable(), + limit: z.number().int().min(1).max(100), +}); + +const NullableStringSchema = z + .string() + .nullish() + .transform((value) => value ?? undefined) + .optional(); + +const TeamInboxMentionSchema = z.object({ + comment: z.object({ + id: z.string(), + parentId: NullableStringSchema, + }), + session: z.object({ + id: z.string(), + title: NullableStringSchema, + }), + author: z.object({ + userId: z.string(), + displayName: NullableStringSchema, + }), + body: z.string(), + createdAt: z.string(), + readAt: z.string().nullable(), + commentCount: z.number().int().nonnegative(), + threadCount: z.number().int().nonnegative(), +}); + +const TeamInboxMentionsPageSchema = z.object({ + mentions: z.array(TeamInboxMentionSchema).default([]), + nextCursor: NullableStringSchema, + unreadCount: z.number().int().nonnegative(), +}); + +export type TeamInboxMention = z.output; + +export interface TeamInboxMentionsPage { + mentions: TeamInboxMention[]; + nextCursor?: string; + unreadCount: number; +} + +const EMPTY_TEAM_INBOX_MENTIONS_PAGE: TeamInboxMentionsPage = { + mentions: [], + unreadCount: 0, +}; + +const TeamInboxReadMutationSchema = z.object({ + readAt: z.string().nullable(), + unreadCount: z.number().int().nonnegative(), +}); + +export interface TeamInboxReadMutation { + readAt: string | null; + unreadCount: number; +} + +async function callTeamInboxRpc( + functionName: string, + accessToken: string, + body: Record, + sourceSignal?: AbortSignal +): Promise { + const endpoint = getCloudEndpoint(); + return runCloudRequestWithTimeout( + async (signal) => { + const response = await fetchWithTransportRetry( + `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, + { + method: "POST", + headers: { + apikey: endpoint.anonKey, + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, + }, + body: JSON.stringify(body), + signal, + } + ); + + const text = await response.text(); + let payload: unknown = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + + if (!response.ok) { + const message = + payload && typeof payload === "object" && "message" in payload + ? String((payload as { message: unknown }).message) + : `org2_cloud rpc ${functionName} failed with ${response.status}`; + throw new Org2CloudCommentError(message, response.status); + } + return payload; + }, + TEAM_INBOX_REQUEST_TIMEOUT_MS, + sourceSignal + ); +} + +/** + * Lists managed-cloud comment mentions for the authenticated viewer. + * + * The viewer is derived by the RPC from the JWT bearer token. The client does + * not accept or send a viewer/user id, inspect comment bodies for mentions, or + * maintain a local projection of the result. + */ +export async function listTeamInboxMentions( + accessToken: string, + orgId: string, + cursor: string | null, + limit: number, + signal?: AbortSignal +): Promise { + const input = TeamInboxMentionRequestSchema.parse({ orgId, cursor, limit }); + const payload = await callTeamInboxRpc( + TEAM_INBOX_MENTIONS_RPC, + accessToken, + { + p_org_id: input.orgId, + p_cursor: input.cursor, + p_limit: input.limit, + }, + signal + ); + return TeamInboxMentionsPageSchema.parse(payload); +} + +/** + * Lists the first mention page only when the endpoint advertises migration + * 0010. Older deployments keep local assigned work available without probing + * a missing RPC. + */ +export async function listInitialTeamInboxMentions( + accessToken: string, + orgId: string, + limit = 50, + signal?: AbortSignal +): Promise { + const capabilities = await getCloudCapabilities(accessToken); + if (!capabilities.teamInboxMentions) { + return EMPTY_TEAM_INBOX_MENTIONS_PAGE; + } + return listTeamInboxMentions(accessToken, orgId, null, limit, signal); +} + +/** Persists one viewer-scoped mention receipt. The viewer comes from JWT. */ +export async function setTeamInboxMentionRead( + accessToken: string, + orgId: string, + commentId: string, + read: boolean, + signal?: AbortSignal +): Promise { + const payload = await callTeamInboxRpc( + SET_TEAM_INBOX_MENTION_READ_RPC, + accessToken, + { + p_org_id: z.string().min(1).parse(orgId), + p_comment_id: z.string().min(1).parse(commentId), + p_read: read, + }, + signal + ); + return TeamInboxReadMutationSchema.parse(payload); +} + +/** Marks every currently visible mention read, including unloaded pages. */ +export async function markAllTeamInboxMentionsRead( + accessToken: string, + orgId: string, + signal?: AbortSignal +): Promise { + const payload = await callTeamInboxRpc( + MARK_ALL_TEAM_INBOX_MENTIONS_READ_RPC, + accessToken, + { p_org_id: z.string().min(1).parse(orgId) }, + signal + ); + return TeamInboxReadMutationSchema.parse(payload); +} diff --git a/src/hooks/project/useCurrentUserMemberId.test.ts b/src/hooks/project/useCurrentUserMemberId.test.ts new file mode 100644 index 0000000000..c51ecfc5d5 --- /dev/null +++ b/src/hooks/project/useCurrentUserMemberId.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import type { IUserInfo } from "@src/types/core/user"; + +import { + findMemberIdsByUser, + resolveCurrentUserIdentity, +} from "./useCurrentUserMemberId"; + +function user(overrides: Partial = {}): IUserInfo { + return { + uuid: "", + name: "", + authing_id: "", + profile: "", + picture: "", + profile_image_url: "", + openai_api_key: "", + deepseek_api_key: "", + git_user_name: "", + git_user_email: "", + github_infos: [], + gitlab_infos: [], + ...overrides, + }; +} + +describe("current Work Item identity", () => { + it("uses the project member identity for a consistent name and avatar", () => { + const members = [ + { + id: "user-ea821852", + name: "hanafish", + email: "hanafish@example.com", + avatar: "https://example.com/hanafish.png", + color: "#1677ff", + }, + ]; + const account = user({ + uuid: "user-ea821852", + name: "Account fallback", + git_user_email: "hanafish@example.com", + }); + const memberIds = findMemberIdsByUser(members, account); + + expect( + resolveCurrentUserIdentity(members, memberIds, account, null) + ).toEqual({ + id: "user-ea821852", + name: "hanafish", + email: "hanafish@example.com", + avatar: "https://example.com/hanafish.png", + color: "#1677ff", + }); + }); + + it("falls back to the signed-in profile instead of the generic You label", () => { + const account = user({ + uuid: "user-ea821852", + name: "hanafish", + profile_image_url: "https://example.com/hanafish.png", + }); + + expect( + resolveCurrentUserIdentity([], new Set(), account, null) + ).toMatchObject({ + id: "user-ea821852", + name: "hanafish", + avatar: "https://example.com/hanafish.png", + }); + }); + + it("enriches an opaque member record with the signed-in profile", () => { + const account = user({ + uuid: "user-ea821852", + name: "Yuki", + profile_image_url: "https://example.com/yuki.png", + }); + + expect( + resolveCurrentUserIdentity( + [{ id: "user-ea821852", name: "user-ea821852" }], + new Set(), + account, + null + ) + ).toMatchObject({ + id: "user-ea821852", + name: "Yuki", + avatar: "https://example.com/yuki.png", + }); + }); + + it("returns no actor when neither account nor git identity is trustworthy", () => { + expect(resolveCurrentUserIdentity([], new Set(), user(), null)).toBeNull(); + }); + + it("does not merge different people who share an email local-part", () => { + const members = [ + { + id: "member-company-alice", + name: "Alice Company", + email: "alice@company.example", + }, + { + id: "member-personal-alice", + name: "Alice Personal", + email: "alice@personal.example", + }, + ]; + + expect( + findMemberIdsByUser( + members, + user({ git_user_email: "alice@company.example" }) + ) + ).toEqual(new Set(["member-company-alice"])); + }); + + it("does not infer a member id from a non-unique display name", () => { + const members = [ + { + id: "member-1", + name: "Alex", + email: "alex-one@example.com", + }, + { + id: "member-2", + name: "Alex", + email: "alex-two@example.com", + }, + ]; + + expect(findMemberIdsByUser(members, user({ name: "Alex" }))).toEqual( + new Set() + ); + }); + + it("matches exact account ids and verified linked emails", () => { + const members = [ + { + id: "account-1", + name: "Account member", + }, + { + id: "member-linked", + name: "Linked member", + linked_emails: [{ email: "linked@example.com" }], + }, + ]; + + expect( + findMemberIdsByUser( + members, + user({ + uuid: "account-1", + git_user_email: "linked@example.com", + }) + ) + ).toEqual(new Set(["account-1", "member-linked"])); + }); +}); diff --git a/src/hooks/project/useCurrentUserMemberId.ts b/src/hooks/project/useCurrentUserMemberId.ts index e4cfe9c9b5..f898e2a008 100644 --- a/src/hooks/project/useCurrentUserMemberId.ts +++ b/src/hooks/project/useCurrentUserMemberId.ts @@ -3,15 +3,16 @@ * * Resolves the current user's project member ID(s) by matching against * all known user identities: - * - Local git config user.email (from Tauri command — most reliable) - * - Local git config user.name (fallback) + * - Stable account/member IDs + * - Local git config user.email (from Tauri command) * - userAtom.git_user_email (if populated) - * - github_infos / gitlab_infos usernames (matched against email prefix) + * - Exact GitHub/GitLab usernames when a member carries that provider field * * A single person often has multiple member entries (from git shortlog) * because they commit with different emails. This hook returns ALL - * matching member IDs so assignment notifications work regardless of - * which member entry was used. + * exact matching member IDs so assignment notifications work regardless of + * which verified member entry was used. Display names and email local-parts + * are deliberately excluded because they are not unique identities. */ import { invoke } from "@tauri-apps/api/core"; import { useAtomValue } from "jotai"; @@ -19,13 +20,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { MemberEntry } from "@src/api/http/project"; import { userAtom } from "@src/store/user/userAtom"; +import type { Person } from "@src/types/core/shared"; import type { IUserInfo } from "@src/types/core/user"; // ============================================ // Git identity from Tauri // ============================================ -interface GitUserIdentity { +export interface GitUserIdentity { email: string | null; name: string | null; /** GitHub username from gh CLI config (~/.config/gh/hosts.yml) */ @@ -72,7 +74,80 @@ export function resetGitIdentityCache() { interface UserIdentities { emails: string[]; - userName: string; + accountIds: string[]; + usernames: string[]; +} + +export type MemberIdentity = Pick< + MemberEntry, + "id" | "name" | "email" | "avatar" | "github_username" | "linked_emails" +> & { + color?: string; +}; + +export function resolveCurrentUserIdentity( + members: readonly MemberIdentity[], + memberIds: ReadonlySet, + user: IUserInfo, + gitIdentity: GitUserIdentity | null +): Person | null { + const accountIds = new Set( + [user.uuid, user.authing_id].map((value) => value.trim()).filter(Boolean) + ); + const currentMember = members.find( + (member) => memberIds.has(member.id) || accountIds.has(member.id) + ); + if (currentMember) { + const memberName = currentMember.name.trim(); + const accountName = ( + user.name || + gitIdentity?.name || + user.git_user_name || + "" + ).trim(); + const memberNameIsOpaque = + !memberName || + memberName === currentMember.id || + /^user-[a-z0-9]+$/i.test(memberName); + + return { + id: currentMember.id, + name: + memberNameIsOpaque && accountName + ? accountName + : memberName || accountName, + email: currentMember.email, + avatar: + currentMember.avatar || + user.profile_image_url || + user.picture || + undefined, + color: currentMember.color, + }; + } + + const name = ( + user.name || + gitIdentity?.name || + user.git_user_name || + "" + ).trim(); + const id = ( + user.uuid || + user.authing_id || + gitIdentity?.email || + user.git_user_email || + name + ).trim(); + if (!id || !name) return null; + + return { + id, + name, + email: gitIdentity?.email || user.git_user_email || undefined, + avatar: user.profile_image_url || user.picture || undefined, + color: "#52c41a", + }; } /** @@ -83,42 +158,46 @@ function collectIdentities( gitIdentity: GitUserIdentity | null ): UserIdentities { const emailSet = new Set(); + const usernameSet = new Set(); + const accountIdSet = new Set(); + + for (const accountId of [user.uuid, user.authing_id]) { + const normalized = accountId.trim(); + if (normalized) accountIdSet.add(normalized); + } - // 1. GitHub username from gh CLI (most reliable for matching) + // GitHub username from gh CLI. if (gitIdentity?.github_username) { - emailSet.add(gitIdentity.github_username.toLowerCase().trim()); + usernameSet.add(gitIdentity.github_username.toLowerCase().trim()); } - // 2. Local git config email (matches git shortlog entries) + // Exact email identities. if (gitIdentity?.email) { emailSet.add(gitIdentity.email.toLowerCase().trim()); } - // 3. userAtom git_user_email (if populated by backend) if (user.git_user_email) { emailSet.add(user.git_user_email.toLowerCase().trim()); } - // 4. GitHub usernames from linked accounts + // Exact provider usernames. for (const gh of user.github_infos ?? []) { if (gh.user_name) { - emailSet.add(gh.user_name.toLowerCase().trim()); + usernameSet.add(gh.user_name.toLowerCase().trim()); } } - // 5. GitLab usernames for (const gl of user.gitlab_infos ?? []) { if (gl.user_name) { - emailSet.add(gl.user_name.toLowerCase().trim()); + usernameSet.add(gl.user_name.toLowerCase().trim()); } } - // Best user name: prefer git config, then userAtom - const userName = (gitIdentity?.name || user.git_user_name || "") - .toLowerCase() - .trim(); - - return { emails: [...emailSet], userName }; + return { + emails: [...emailSet], + accountIds: [...accountIdSet], + usernames: [...usernameSet], + }; } // ============================================ @@ -129,30 +208,22 @@ function collectIdentities( * Check if a member entry matches any of the user's known identities. */ function memberMatchesUser( - member: MemberEntry, + member: MemberIdentity, identities: UserIdentities ): boolean { const memberEmail = (member.email || "").toLowerCase().trim(); - const memberName = (member.name || "").toLowerCase().trim(); - - for (const email of identities.emails) { - // Direct email match - if (memberEmail === email) return true; + const memberUsername = (member.github_username || "").toLowerCase().trim(); - // Email prefix match (e.g. github username "alice" matches "alice@example.com") - if (memberEmail && memberEmail.split("@")[0] === email) return true; - - // Reverse: member email prefix matches user email - if ( - email.includes("@") && - email.split("@")[0] === memberEmail.split("@")[0] - ) { - return true; - } + if (identities.accountIds.includes(member.id)) return true; + if (memberEmail && identities.emails.includes(memberEmail)) return true; + if (memberUsername && identities.usernames.includes(memberUsername)) { + return true; } - // Name-based fallback - if (identities.userName && memberName === identities.userName) return true; + for (const linked of member.linked_emails ?? []) { + const email = linked.email.toLowerCase().trim(); + if (email && identities.emails.includes(email)) return true; + } return false; } @@ -161,9 +232,9 @@ function memberMatchesUser( * Find a member entry by exact email match. */ export function findMemberByEmail( - members: MemberEntry[], + members: readonly MemberIdentity[], email: string -): MemberEntry | undefined { +): MemberIdentity | undefined { const normalized = email.toLowerCase().trim(); return members.find( (member) => (member.email || "").toLowerCase().trim() === normalized @@ -182,7 +253,7 @@ export function findMemberByEmail( * For the async version that fetches git config, use the hook. */ export function findMemberIdsByUser( - members: MemberEntry[], + members: readonly MemberIdentity[], user: IUserInfo, gitIdentity?: GitUserIdentity | null ): Set { @@ -207,6 +278,8 @@ interface UseCurrentUserMemberIdsReturn { memberIds: Set; /** Current user's git email (primary) */ gitEmail: string; + /** Display identity used by Work Item comments and mutation history. */ + currentUser: Person | null; } /** @@ -214,7 +287,7 @@ interface UseCurrentUserMemberIdsReturn { * Fetches git identity from local config on mount. */ export function useCurrentUserMemberIds( - members: MemberEntry[] + members: readonly MemberIdentity[] ): UseCurrentUserMemberIdsReturn { const user = useAtomValue(userAtom); const [gitIdentity, setGitIdentity] = useState( @@ -244,6 +317,10 @@ export function useCurrentUserMemberIds( ); const gitEmail = gitIdentity?.email || user.git_user_email || ""; + const currentUser = useMemo( + () => resolveCurrentUserIdentity(members, memberIds, user, gitIdentity), + [gitIdentity, memberIds, members, user] + ); - return { memberIds, gitEmail }; + return { memberIds, gitEmail, currentUser }; } diff --git a/src/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json index 876781859c..6a295dc9b3 100644 --- a/src/i18n/locales/de/navigation.json +++ b/src/i18n/locales/de/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "{{count}} Kommentare bearbeiten", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Erwähnen", + "searchMembers": "Mitglieder suchen" }, "sharingFloor": { "label": "Minimale Freigabestufe", diff --git a/src/i18n/locales/de/projects.json b/src/i18n/locales/de/projects.json index 5051d58527..ba411b0f60 100644 --- a/src/i18n/locales/de/projects.json +++ b/src/i18n/locales/de/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Aktivität", + "discussionTitle": "Diskussion", + "activityHistory": "Aktivitätsverlauf", + "activityHistoryCount": "{{count}} Ereignisse", + "noComments": "Noch keine Kommentare", "subscribe": "Abonnieren", "unsubscribe": "Abbestellen", "commentPlaceholder": "Kommentar hinterlassen...", @@ -389,6 +393,16 @@ "clearedField": "hat {{field}} gelöscht", "changedDescription": "hat die Beschreibung aktualisiert", "editedFields": "hat {{count}} Felder bearbeitet:", + "groupedChanges": "hat {{count}} Änderungen vorgenommen", + "groupedTodoChanges": "hat To-dos aktualisiert · {{count}} Aktionen", + "todoAdded": "hat „{{todo}}“ hinzugefügt", + "todoRemoved": "hat „{{todo}}“ entfernt", + "todoCompleted": "hat „{{todo}}“ abgeschlossen", + "todoReopened": "hat „{{todo}}“ wieder geöffnet", + "todoStarted": "hat „{{todo}}“ begonnen", + "todoMarkedPending": "hat „{{todo}}“ als ausstehend markiert", + "todoRenamed": "hat „{{from}}“ in „{{to}}“ umbenannt", + "todoUpdated": "hat „{{todo}}“ aktualisiert", "justNow": "gerade eben", "daysAgo": "vor {{count}}T", "hoursAgo": "vor {{count}}Std", @@ -411,7 +425,8 @@ "todos": "Aufgaben", "comments": "Kommentare", "schedule": "Zeitplan", - "orchestratorConfig": "Orchestrator-Konfiguration" + "orchestratorConfig": "Orchestrator-Konfiguration", + "handoff": "Übergabe" } }, "todos": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 391f3cc5bf..52fc1dbbe1 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -2449,6 +2449,164 @@ "noRepo": "No repo" } }, + "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}}", + "ariaLabel": "{{title}}, {{status}}" + }, + "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…", + "drop": { + "title": "Drop to create a Work Item", + "subtitle": "Review the Session snapshot, then create or hand off the Work Item.", + "processing": "Creating a Work Item from “{{title}}”…", + "processingHint": "Reading the Session and resolving project members.", + "success": "Work Item created", + "reused": "Existing Work Item updated", + "failed": "Couldn’t create the Work Item", + "error": "Unable to create a Work Item from this Session.", + "open": "Open", + "dismiss": "Dismiss" + }, + "handoff": { + "title": "Create from Session", + "createFromSession": "Create team Work Item…", + "project": "Destination project", + "chooseProject": "Choose a project", + "recipientSelf": "{{name}} (me)", + "chooseRecipient": "Choose a recipient", + "todoCount_one": "{{count}} to-do", + "todoCount_other": "{{count}} to-dos", + "workItemTitle": "Work Item title", + "assignTo": "Assign to", + "note": "Handoff note", + "notePlaceholder": "Share what is ready, what is unresolved, and what should happen next.", + "selfHint": "Assigning this to yourself creates a normal Work Item without a handoff request.", + "submitHandoff": "Create & hand off", + "submitCreate": "Create Work Item", + "preparing": "Preparing “{{title}}”…", + "preparationError": { + "session_unavailable": "This Session is no longer available. Reopen it and try again.", + "project_unavailable": "This Session’s project is no longer available.", + "identity_unavailable": "Your identity is not a member of this Session’s project.", + "no_project": "No eligible project is available. Create or join a project, then try again.", + "unknown": "Unable to prepare this Session. Refresh Team Inbox and try again." + }, + "submitError": "The Work Item could not be created. Review the recipient and try again.", + "pendingTitle": "Handoff from {{name}}", + "acceptedTitle": "Accepted by {{name}}", + "returnedTitle": "Returned by {{name}}", + "noNote": "No handoff note.", + "statusLabel": "Work Item handoff", + "return": "Return", + "accept": "Accept", + "returnTitle": "Return this handoff?", + "confirmReturn": "Return to sender", + "returnHint": "Tell {{name}} what needs clarification or follow-up. The Work Item will be reassigned to them.", + "returnPlaceholder": "What needs to change before this can be handed off?", + "responseError": "The handoff response could not be saved. Try again.", + "identityUnavailable": "Your team identity could not be verified. Check your project profile before responding.", + "rowPending": "From {{name}} · Awaiting response", + "rowAccepted": "Handoff accepted · {{status}} · {{priority}}", + "rowReturned": "Returned by {{name}} · {{status}} · {{priority}}" + }, + "errors": { + "loadTitle": "Unable to load Team Inbox", + "load": "Unable to load Team Inbox", + "loadMore": "Unable to load more Team Inbox items. Try again.", + "refresh": "Unable to refresh Team Inbox", + "markRead": "Unable to mark this item as read. Try again.", + "markUnread": "Unable to mark this item as unread. Try again.", + "markAllRead": "Unable to mark all items as read. Try again.", + "identity": "Your account could not be matched to a project member. Check your project profile email.", + "partialLoad": "Some Team Inbox sources could not be refreshed. Available items are still shown.", + "workItemContext": "Some project context is unavailable. The work item remains usable.", + "workItemLoad": "Unable to load this work item. Try again.", + "workItemUpdate": "Unable to save the latest work item change. Try again." + }, + "detail": { + "assignedSubtitle": "Assigned work item", + "standaloneProject": "Standalone", + "mentionSubtitle": "Mentioned in a comment", + "mentionedYou": "mentioned you", + "threadComments_one": "{{count}} comment in this thread", + "threadComments_other": "{{count}} comments in this thread" + }, + "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" + } + }, "globalToolbar": { "selectWorkspaceToStart": "Select a workspace to start", "selectRepoToStart": "Select a repo to start" diff --git a/src/i18n/locales/en/navigation.json b/src/i18n/locales/en/navigation.json index 4669845aef..0347ede927 100644 --- a/src/i18n/locales/en/navigation.json +++ b/src/i18n/locales/en/navigation.json @@ -725,7 +725,9 @@ "addressRoundScope": "Round comments", "addressConfirm_one": "Address {{count}} comment", "addressConfirm_other": "Address {{count}} comments", - "agentAuthor": "Agent @{{name}}" + "agentAuthor": "Agent @{{name}}", + "mentionMembers": "Mention", + "searchMembers": "Search members" }, "billing": { "openFailed": "Couldn't open billing. Please try again." diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index f2dedea372..ab018437a8 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -367,6 +367,10 @@ }, "activity": { "title": "Activity", + "discussionTitle": "Discussion", + "activityHistory": "Activity history", + "activityHistoryCount": "{{count}} events", + "noComments": "No comments yet", "subscribe": "Subscribe", "unsubscribe": "Unsubscribe", "commentPlaceholder": "Leave a comment...", @@ -388,6 +392,16 @@ "clearedField": "cleared {{field}}", "changedDescription": "updated the description", "editedFields": "edited {{count}} fields:", + "groupedChanges": "made {{count}} changes", + "groupedTodoChanges": "updated to-dos · {{count}} actions", + "todoAdded": "added “{{todo}}”", + "todoRemoved": "removed “{{todo}}”", + "todoCompleted": "completed “{{todo}}”", + "todoReopened": "reopened “{{todo}}”", + "todoStarted": "started “{{todo}}”", + "todoMarkedPending": "marked “{{todo}}” as pending", + "todoRenamed": "renamed “{{from}}” to “{{to}}”", + "todoUpdated": "updated “{{todo}}”", "justNow": "just now", "daysAgo": "{{count}}d ago", "hoursAgo": "{{count}}h ago", @@ -410,7 +424,8 @@ "todos": "to-dos", "comments": "comments", "schedule": "schedule", - "orchestratorConfig": "orchestrator config" + "orchestratorConfig": "orchestrator config", + "handoff": "handoff" } }, "todos": { diff --git a/src/i18n/locales/es/navigation.json b/src/i18n/locales/es/navigation.json index 9925098947..8c5c9494cf 100644 --- a/src/i18n/locales/es/navigation.json +++ b/src/i18n/locales/es/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "Atender {{count}} comentarios", "agentAuthor": "Agente @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Mencionar", + "searchMembers": "Buscar miembros" }, "sharingFloor": { "label": "Nivel mínimo de uso compartido", diff --git a/src/i18n/locales/es/projects.json b/src/i18n/locales/es/projects.json index 8f3fcf6c32..2f334f20dc 100644 --- a/src/i18n/locales/es/projects.json +++ b/src/i18n/locales/es/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Actividad", + "discussionTitle": "Discusión", + "activityHistory": "Historial de actividad", + "activityHistoryCount": "{{count}} eventos", + "noComments": "Aún no hay comentarios", "subscribe": "Suscribirse", "unsubscribe": "Cancelar suscripción", "commentPlaceholder": "Deja un comentario...", @@ -389,6 +393,16 @@ "clearedField": "borró {{field}}", "changedDescription": "actualizó la descripción", "editedFields": "editó {{count}} campos:", + "groupedChanges": "hizo {{count}} cambios", + "groupedTodoChanges": "actualizó tareas · {{count}} acciones", + "todoAdded": "añadió “{{todo}}”", + "todoRemoved": "eliminó “{{todo}}”", + "todoCompleted": "completó “{{todo}}”", + "todoReopened": "reabrió “{{todo}}”", + "todoStarted": "inició “{{todo}}”", + "todoMarkedPending": "marcó “{{todo}}” como pendiente", + "todoRenamed": "cambió el nombre de “{{from}}” a “{{to}}”", + "todoUpdated": "actualizó “{{todo}}”", "justNow": "justo ahora", "daysAgo": "hace {{count}}d", "hoursAgo": "hace {{count}}h", @@ -411,7 +425,8 @@ "todos": "tareas pendientes", "comments": "comentarios", "schedule": "programación", - "orchestratorConfig": "configuración de Orchestrator" + "orchestratorConfig": "configuración de Orchestrator", + "handoff": "transferencia" } }, "todos": { diff --git a/src/i18n/locales/fr/navigation.json b/src/i18n/locales/fr/navigation.json index df12cf29f9..c79e823e33 100644 --- a/src/i18n/locales/fr/navigation.json +++ b/src/i18n/locales/fr/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "Traiter {{count}} commentaires", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Mentionner", + "searchMembers": "Rechercher des membres" }, "sharingFloor": { "label": "Niveau de partage minimal", diff --git a/src/i18n/locales/fr/projects.json b/src/i18n/locales/fr/projects.json index 4a80c798fd..200759ae26 100644 --- a/src/i18n/locales/fr/projects.json +++ b/src/i18n/locales/fr/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Activité", + "discussionTitle": "Discussion", + "activityHistory": "Historique d’activité", + "activityHistoryCount": "{{count}} événements", + "noComments": "Aucun commentaire pour le moment", "subscribe": "S'abonner", "unsubscribe": "Se désabonner", "commentPlaceholder": "Laisser un commentaire...", @@ -389,6 +393,16 @@ "clearedField": "a effacé {{field}}", "changedDescription": "a mis à jour la description", "editedFields": "a modifié {{count}} champs :", + "groupedChanges": "a effectué {{count}} modifications", + "groupedTodoChanges": "a mis à jour les tâches · {{count}} actions", + "todoAdded": "a ajouté « {{todo}} »", + "todoRemoved": "a supprimé « {{todo}} »", + "todoCompleted": "a terminé « {{todo}} »", + "todoReopened": "a rouvert « {{todo}} »", + "todoStarted": "a commencé « {{todo}} »", + "todoMarkedPending": "a marqué « {{todo}} » comme en attente", + "todoRenamed": "a renommé « {{from}} » en « {{to}} »", + "todoUpdated": "a mis à jour « {{todo}} »", "justNow": "à l'instant", "daysAgo": "il y a {{count}}j", "hoursAgo": "il y a {{count}}h", @@ -411,7 +425,8 @@ "todos": "tâches", "comments": "commentaires", "schedule": "planning", - "orchestratorConfig": "configuration Orchestrator" + "orchestratorConfig": "configuration Orchestrator", + "handoff": "transfert" } }, "todos": { diff --git a/src/i18n/locales/ja/navigation.json b/src/i18n/locales/ja/navigation.json index 7f5a875063..198f5bbf3d 100644 --- a/src/i18n/locales/ja/navigation.json +++ b/src/i18n/locales/ja/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "{{count}} 件のコメントに対応", "agentAuthor": "エージェント @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "メンバーをメンション", + "searchMembers": "メンバーを検索" }, "sharingFloor": { "label": "最小共有レベル", diff --git a/src/i18n/locales/ja/projects.json b/src/i18n/locales/ja/projects.json index a0a1ba30fe..6866b9e149 100644 --- a/src/i18n/locales/ja/projects.json +++ b/src/i18n/locales/ja/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "アクティビティ", + "discussionTitle": "ディスカッション", + "activityHistory": "アクティビティ履歴", + "activityHistoryCount": "{{count}}件のイベント", + "noComments": "コメントはまだありません", "subscribe": "購読する", "unsubscribe": "購読解除", "commentPlaceholder": "コメントを残す...", @@ -389,6 +393,16 @@ "clearedField": "{{field}}をクリアしました", "changedDescription": "説明を更新しました", "editedFields": "{{count}} 件のフィールドを編集しました:", + "groupedChanges": "{{count}} 件変更しました", + "groupedTodoChanges": "To-Doを更新 · {{count}} 件の操作", + "todoAdded": "「{{todo}}」を追加しました", + "todoRemoved": "「{{todo}}」を削除しました", + "todoCompleted": "「{{todo}}」を完了しました", + "todoReopened": "「{{todo}}」を再開しました", + "todoStarted": "「{{todo}}」を開始しました", + "todoMarkedPending": "「{{todo}}」を保留に戻しました", + "todoRenamed": "「{{from}}」を「{{to}}」に変更しました", + "todoUpdated": "「{{todo}}」を更新しました", "justNow": "たった今", "daysAgo": "{{count}}日前", "hoursAgo": "{{count}}時間前", @@ -411,7 +425,8 @@ "todos": "To-Do", "comments": "コメント", "schedule": "スケジュール", - "orchestratorConfig": "Orchestrator 設定" + "orchestratorConfig": "Orchestrator 設定", + "handoff": "引き継ぎ" } }, "todos": { diff --git a/src/i18n/locales/ko/navigation.json b/src/i18n/locales/ko/navigation.json index 5c84b31cbe..2912fb73d3 100644 --- a/src/i18n/locales/ko/navigation.json +++ b/src/i18n/locales/ko/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "댓글 {{count}}개 처리", "agentAuthor": "에이전트 @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "멤버 언급", + "searchMembers": "멤버 검색" }, "sharingFloor": { "label": "최소 공유 수준", diff --git a/src/i18n/locales/ko/projects.json b/src/i18n/locales/ko/projects.json index a7035c26d3..adb6bf2e31 100644 --- a/src/i18n/locales/ko/projects.json +++ b/src/i18n/locales/ko/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "활동", + "discussionTitle": "토론", + "activityHistory": "활동 기록", + "activityHistoryCount": "이벤트 {{count}}개", + "noComments": "아직 댓글이 없습니다", "subscribe": "구독", "unsubscribe": "구독 취소", "commentPlaceholder": "댓글 남기기...", @@ -389,6 +393,16 @@ "clearedField": "{{field}}을(를) 지웠습니다", "changedDescription": "설명을 업데이트했습니다", "editedFields": "{{count}}개 필드를 수정했습니다:", + "groupedChanges": "{{count}}개 변경했습니다", + "groupedTodoChanges": "할 일을 업데이트했습니다 · {{count}}개 작업", + "todoAdded": "“{{todo}}”을(를) 추가했습니다", + "todoRemoved": "“{{todo}}”을(를) 삭제했습니다", + "todoCompleted": "“{{todo}}”을(를) 완료했습니다", + "todoReopened": "“{{todo}}”을(를) 다시 열었습니다", + "todoStarted": "“{{todo}}”을(를) 시작했습니다", + "todoMarkedPending": "“{{todo}}”을(를) 대기 중으로 표시했습니다", + "todoRenamed": "“{{from}}”을(를) “{{to}}”(으)로 이름을 변경했습니다", + "todoUpdated": "“{{todo}}”을(를) 업데이트했습니다", "justNow": "방금", "daysAgo": "{{count}}일 전", "hoursAgo": "{{count}}시간 전", @@ -411,7 +425,8 @@ "todos": "할 일", "comments": "댓글", "schedule": "일정", - "orchestratorConfig": "Orchestrator 설정" + "orchestratorConfig": "Orchestrator 설정", + "handoff": "인계" } }, "todos": { diff --git a/src/i18n/locales/pl/navigation.json b/src/i18n/locales/pl/navigation.json index fa8f216e6b..61c7651e32 100644 --- a/src/i18n/locales/pl/navigation.json +++ b/src/i18n/locales/pl/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "Obsłuż {{count}} komentarzy", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Wspomnij", + "searchMembers": "Szukaj członków" }, "sharingFloor": { "label": "Minimalny poziom udostępniania", diff --git a/src/i18n/locales/pl/projects.json b/src/i18n/locales/pl/projects.json index e119e51b8f..f4d4050be0 100644 --- a/src/i18n/locales/pl/projects.json +++ b/src/i18n/locales/pl/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Aktywność", + "discussionTitle": "Dyskusja", + "activityHistory": "Historia aktywności", + "activityHistoryCount": "{{count}} zdarzeń", + "noComments": "Brak komentarzy", "subscribe": "Subskrybuj", "unsubscribe": "Anuluj subskrypcję", "commentPlaceholder": "Zostaw komentarz...", @@ -389,6 +393,16 @@ "clearedField": "wyczyścił {{field}}", "changedDescription": "zaktualizował opis", "editedFields": "edytował {{count}} pól:", + "groupedChanges": "wprowadził {{count}} zmian", + "groupedTodoChanges": "zaktualizował zadania · {{count}} operacji", + "todoAdded": "dodał „{{todo}}”", + "todoRemoved": "usunął „{{todo}}”", + "todoCompleted": "ukończył „{{todo}}”", + "todoReopened": "ponownie otworzył „{{todo}}”", + "todoStarted": "rozpoczął „{{todo}}”", + "todoMarkedPending": "oznaczył „{{todo}}” jako oczekujące", + "todoRenamed": "zmienił nazwę „{{from}}” na „{{to}}”", + "todoUpdated": "zaktualizował „{{todo}}”", "justNow": "przed chwilą", "daysAgo": "{{count}}d temu", "hoursAgo": "{{count}}g temu", @@ -411,7 +425,8 @@ "todos": "lista zadań", "comments": "komentarze", "schedule": "harmonogram", - "orchestratorConfig": "konfiguracja Orchestrator" + "orchestratorConfig": "konfiguracja Orchestrator", + "handoff": "przekazanie" } }, "todos": { diff --git a/src/i18n/locales/pt/navigation.json b/src/i18n/locales/pt/navigation.json index f519c6e91f..75b15754b3 100644 --- a/src/i18n/locales/pt/navigation.json +++ b/src/i18n/locales/pt/navigation.json @@ -698,7 +698,9 @@ "addressConfirm_other": "Atender {{count}} comentários", "agentAuthor": "Agente @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Mencionar", + "searchMembers": "Pesquisar membros" }, "sharingFloor": { "label": "Nível mínimo de compartilhamento", diff --git a/src/i18n/locales/pt/projects.json b/src/i18n/locales/pt/projects.json index 12488dde6c..69cf9a67b5 100644 --- a/src/i18n/locales/pt/projects.json +++ b/src/i18n/locales/pt/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Atividade", + "discussionTitle": "Discussão", + "activityHistory": "Histórico de atividades", + "activityHistoryCount": "{{count}} eventos", + "noComments": "Ainda não há comentários", "subscribe": "Inscrever-se", "unsubscribe": "Cancelar inscrição", "commentPlaceholder": "Deixe um comentário...", @@ -389,6 +393,16 @@ "clearedField": "limpou {{field}}", "changedDescription": "atualizou a descrição", "editedFields": "editou {{count}} campos:", + "groupedChanges": "fez {{count}} alterações", + "groupedTodoChanges": "atualizou tarefas · {{count}} ações", + "todoAdded": "adicionou “{{todo}}”", + "todoRemoved": "removeu “{{todo}}”", + "todoCompleted": "concluiu “{{todo}}”", + "todoReopened": "reabriu “{{todo}}”", + "todoStarted": "iniciou “{{todo}}”", + "todoMarkedPending": "marcou “{{todo}}” como pendente", + "todoRenamed": "renomeou “{{from}}” para “{{to}}”", + "todoUpdated": "atualizou “{{todo}}”", "justNow": "agora mesmo", "daysAgo": "há {{count}}d", "hoursAgo": "há {{count}}h", @@ -411,7 +425,8 @@ "todos": "tarefas", "comments": "comentários", "schedule": "agenda", - "orchestratorConfig": "configuração do Orchestrator" + "orchestratorConfig": "configuração do Orchestrator", + "handoff": "transferência" } }, "todos": { diff --git a/src/i18n/locales/ru/navigation.json b/src/i18n/locales/ru/navigation.json index b9eae94ea3..7b4fd5c78b 100644 --- a/src/i18n/locales/ru/navigation.json +++ b/src/i18n/locales/ru/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "Обработать {{count}} комментариев", "agentAuthor": "Агент @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Упомянуть", + "searchMembers": "Поиск участников" }, "sharingFloor": { "label": "Минимальный уровень доступа", diff --git a/src/i18n/locales/ru/projects.json b/src/i18n/locales/ru/projects.json index 1ed4943d07..02dd4636e2 100644 --- a/src/i18n/locales/ru/projects.json +++ b/src/i18n/locales/ru/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Активность", + "discussionTitle": "Обсуждение", + "activityHistory": "История активности", + "activityHistoryCount": "{{count}} событий", + "noComments": "Комментариев пока нет", "subscribe": "Подписаться", "unsubscribe": "Отписаться", "commentPlaceholder": "Оставить комментарий...", @@ -389,6 +393,16 @@ "clearedField": "очистил {{field}}", "changedDescription": "обновил описание", "editedFields": "изменил {{count}} полей:", + "groupedChanges": "внёс {{count}} изменений", + "groupedTodoChanges": "обновил задачи · {{count}} действий", + "todoAdded": "добавил «{{todo}}»", + "todoRemoved": "удалил «{{todo}}»", + "todoCompleted": "завершил «{{todo}}»", + "todoReopened": "снова открыл «{{todo}}»", + "todoStarted": "начал «{{todo}}»", + "todoMarkedPending": "пометил «{{todo}}» как ожидающее", + "todoRenamed": "переименовал «{{from}}» в «{{to}}»", + "todoUpdated": "обновил «{{todo}}»", "justNow": "только что", "daysAgo": "{{count}}д назад", "hoursAgo": "{{count}}ч назад", @@ -411,7 +425,8 @@ "todos": "задачи", "comments": "комментарии", "schedule": "расписание", - "orchestratorConfig": "конфигурация Orchestrator" + "orchestratorConfig": "конфигурация Orchestrator", + "handoff": "передача" } }, "todos": { diff --git a/src/i18n/locales/tr/navigation.json b/src/i18n/locales/tr/navigation.json index 1e28eee2f3..449dc253df 100644 --- a/src/i18n/locales/tr/navigation.json +++ b/src/i18n/locales/tr/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "{{count}} yorumu ele al", "agentAuthor": "Ajan @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Bahset", + "searchMembers": "Üye ara" }, "sharingFloor": { "label": "En düşük paylaşım düzeyi", diff --git a/src/i18n/locales/tr/projects.json b/src/i18n/locales/tr/projects.json index 8a2f9043e2..d3d4ff01d9 100644 --- a/src/i18n/locales/tr/projects.json +++ b/src/i18n/locales/tr/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Etkinlik", + "discussionTitle": "Tartışma", + "activityHistory": "Etkinlik geçmişi", + "activityHistoryCount": "{{count}} etkinlik", + "noComments": "Henüz yorum yok", "subscribe": "Abone ol", "unsubscribe": "Abonelikten çık", "commentPlaceholder": "Yorum bırakın...", @@ -389,6 +393,16 @@ "clearedField": "{{field}} alanını temizledi", "changedDescription": "açıklamayı güncelledi", "editedFields": "{{count}} alanı düzenledi:", + "groupedChanges": "{{count}} değişiklik yaptı", + "groupedTodoChanges": "yapılacakları güncelledi · {{count}} işlem", + "todoAdded": "“{{todo}}” öğesini ekledi", + "todoRemoved": "“{{todo}}” öğesini kaldırdı", + "todoCompleted": "“{{todo}}” öğesini tamamladı", + "todoReopened": "“{{todo}}” öğesini yeniden açtı", + "todoStarted": "“{{todo}}” öğesini başlattı", + "todoMarkedPending": "“{{todo}}” öğesini beklemede olarak işaretledi", + "todoRenamed": "“{{from}}” adını “{{to}}” olarak değiştirdi", + "todoUpdated": "“{{todo}}” öğesini güncelledi", "justNow": "az önce", "daysAgo": "{{count}}g önce", "hoursAgo": "{{count}}s önce", @@ -411,7 +425,8 @@ "todos": "yapılacaklar", "comments": "yorumlar", "schedule": "zamanlama", - "orchestratorConfig": "Orchestrator yapılandırması" + "orchestratorConfig": "Orchestrator yapılandırması", + "handoff": "devir" } }, "todos": { diff --git a/src/i18n/locales/vi/navigation.json b/src/i18n/locales/vi/navigation.json index 5ab9a985a3..48b3521ef0 100644 --- a/src/i18n/locales/vi/navigation.json +++ b/src/i18n/locales/vi/navigation.json @@ -696,7 +696,9 @@ "addressConfirm_other": "Xử lý {{count}} bình luận", "agentAuthor": "Agent @{{name}}", "addressSessionScope": "Session notes", - "addressRoundScope": "Round comments" + "addressRoundScope": "Round comments", + "mentionMembers": "Nhắc đến", + "searchMembers": "Tìm thành viên" }, "sharingFloor": { "label": "Mức chia sẻ tối thiểu", diff --git a/src/i18n/locales/vi/projects.json b/src/i18n/locales/vi/projects.json index 72717c49c1..d3c39f157c 100644 --- a/src/i18n/locales/vi/projects.json +++ b/src/i18n/locales/vi/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "Hoạt động", + "discussionTitle": "Thảo luận", + "activityHistory": "Lịch sử hoạt động", + "activityHistoryCount": "{{count}} sự kiện", + "noComments": "Chưa có bình luận", "subscribe": "Theo dõi", "unsubscribe": "Bỏ theo dõi", "commentPlaceholder": "Để lại bình luận...", @@ -389,6 +393,16 @@ "clearedField": "đã xóa {{field}}", "changedDescription": "đã cập nhật mô tả", "editedFields": "đã chỉnh sửa {{count}} trường:", + "groupedChanges": "đã thực hiện {{count}} thay đổi", + "groupedTodoChanges": "đã cập nhật việc cần làm · {{count}} thao tác", + "todoAdded": "đã thêm “{{todo}}”", + "todoRemoved": "đã xóa “{{todo}}”", + "todoCompleted": "đã hoàn thành “{{todo}}”", + "todoReopened": "đã mở lại “{{todo}}”", + "todoStarted": "đã bắt đầu “{{todo}}”", + "todoMarkedPending": "đã đánh dấu “{{todo}}” là đang chờ", + "todoRenamed": "đã đổi tên “{{from}}” thành “{{to}}”", + "todoUpdated": "đã cập nhật “{{todo}}”", "justNow": "vừa xong", "daysAgo": "{{count}} ngày trước", "hoursAgo": "{{count}} giờ trước", @@ -411,7 +425,8 @@ "todos": "việc cần làm", "comments": "bình luận", "schedule": "lịch trình", - "orchestratorConfig": "cấu hình Orchestrator" + "orchestratorConfig": "cấu hình Orchestrator", + "handoff": "bàn giao" } }, "todos": { diff --git a/src/i18n/locales/zh-Hant/navigation.json b/src/i18n/locales/zh-Hant/navigation.json index f0aacf6246..ecc6e12724 100644 --- a/src/i18n/locales/zh-Hant/navigation.json +++ b/src/i18n/locales/zh-Hant/navigation.json @@ -788,7 +788,9 @@ "addressRoundScope": "逐輪評論", "addressConfirm_one": "處理 {{count}} 條評論", "addressConfirm_other": "處理 {{count}} 條評論", - "agentAuthor": "Agent @{{name}}" + "agentAuthor": "Agent @{{name}}", + "mentionMembers": "提及成員", + "searchMembers": "搜尋成員" }, "billing": { "openFailed": "無法開啟帳單頁,請重試。" diff --git a/src/i18n/locales/zh-Hant/projects.json b/src/i18n/locales/zh-Hant/projects.json index ee33a3dcac..8f93605c6f 100644 --- a/src/i18n/locales/zh-Hant/projects.json +++ b/src/i18n/locales/zh-Hant/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "動態", + "discussionTitle": "討論", + "activityHistory": "活動記錄", + "activityHistoryCount": "{{count}} 項事件", + "noComments": "尚無留言", "subscribe": "訂閱", "unsubscribe": "取消訂閱", "commentPlaceholder": "留下評論...", @@ -389,6 +393,16 @@ "clearedField": "清除了{{field}}", "changedDescription": "更新了描述", "editedFields": "編輯了 {{count}} 個字段:", + "groupedChanges": "進行了 {{count}} 項更改", + "groupedTodoChanges": "更新了待辦 · {{count}} 項操作", + "todoAdded": "新增了「{{todo}}」", + "todoRemoved": "刪除了「{{todo}}」", + "todoCompleted": "完成了「{{todo}}」", + "todoReopened": "重新開啟了「{{todo}}」", + "todoStarted": "開始了「{{todo}}」", + "todoMarkedPending": "將「{{todo}}」標記為待處理", + "todoRenamed": "將「{{from}}」重新命名為「{{to}}」", + "todoUpdated": "更新了「{{todo}}」", "justNow": "剛剛", "daysAgo": "{{count}}天前", "hoursAgo": "{{count}}小時前", @@ -411,7 +425,8 @@ "todos": "待辦事項", "comments": "評論", "schedule": "計劃", - "orchestratorConfig": "Orchestrator 配置" + "orchestratorConfig": "Orchestrator 配置", + "handoff": "工作交接" } }, "todos": { diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index dc5fa92e74..2323138cd9 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -2329,6 +2329,162 @@ "noRepo": "无仓库" } }, + "teamInbox": { + "title": "团队收件箱", + "listLabel": "团队收件箱列表", + "itemsLabel": "团队收件箱事项", + "unreadCount": "{{count}} 条未读", + "allRead": "已全部阅读", + "loadMore": "加载更多", + "filters": { + "all": "全部", + "mentions": "提及", + "assigned": "分配给我" + }, + "status": { + "read": "已读", + "unread": "未读" + }, + "row": { + "assignedSummary": "{{status}} · {{priority}}", + "ariaLabel": "{{title}},{{status}}" + }, + "search": { + "placeholder": "搜索收件箱", + "ariaLabel": "搜索团队收件箱" + }, + "groups": { + "today": "今天", + "yesterday": "昨天", + "thisWeek": "本周", + "earlier": "更早" + }, + "empty": { + "title": "暂无事项", + "subtitle": "新的提及和分配会显示在这里。", + "selectTitle": "选择一个事项", + "selectSubtitle": "查看评论上下文或工作项详情。", + "mentions": { + "title": "暂无提及", + "subtitle": "当同事在评论中 @ 你时,会显示在这里。" + }, + "assigned": { + "title": "暂无分配给你的事项", + "subtitle": "分配给你的工作项会显示在这里。" + }, + "noResults": { + "title": "无匹配结果", + "subtitle": "没有与「{{query}}」匹配的事项。" + } + }, + "loading": "正在加载团队收件箱…", + "drop": { + "title": "拖到这里创建工作项", + "subtitle": "先确认会话摘要,再创建或交接工作项。", + "processing": "正在从「{{title}}」创建工作项…", + "processingHint": "正在读取会话并解析项目成员。", + "success": "工作项已创建", + "reused": "已更新现有工作项", + "failed": "无法创建工作项", + "error": "无法根据该会话创建工作项。", + "open": "打开", + "dismiss": "关闭" + }, + "handoff": { + "title": "从会话创建", + "createFromSession": "创建团队工作项…", + "project": "目标项目", + "chooseProject": "选择项目", + "recipientSelf": "{{name}}(我)", + "chooseRecipient": "选择接收人", + "todoCount": "{{count}} 个待办", + "workItemTitle": "工作项标题", + "assignTo": "分配给", + "note": "交接说明", + "notePlaceholder": "说明已经完成什么、还有哪些未决事项,以及下一步建议。", + "selfHint": "分配给自己会创建普通工作项,不会发起交接请求。", + "submitHandoff": "创建并交接", + "submitCreate": "创建工作项", + "preparing": "正在准备「{{title}}」…", + "preparationError": { + "session_unavailable": "该会话已不可用,请重新打开后再试。", + "project_unavailable": "该会话所属的项目已不可用。", + "identity_unavailable": "你的团队身份不属于该会话的项目。", + "no_project": "没有可用项目,请先创建或加入项目后再试。", + "unknown": "无法准备该会话,请刷新团队收件箱后重试。" + }, + "submitError": "无法创建工作项,请检查接收人后重试。", + "pendingTitle": "来自 {{name}} 的交接", + "acceptedTitle": "{{name}} 已接收", + "returnedTitle": "{{name}} 已退回", + "noNote": "没有交接说明。", + "statusLabel": "工作项交接", + "return": "退回", + "accept": "接收", + "returnTitle": "退回这次交接?", + "confirmReturn": "退回给发起人", + "returnHint": "告诉 {{name}} 需要补充或调整的内容。工作项会重新分配给对方。", + "returnPlaceholder": "再次交接前需要修改什么?", + "responseError": "无法保存交接操作,请重试。", + "identityUnavailable": "无法验证你的团队身份,请先检查项目个人资料再处理交接。", + "rowPending": "来自 {{name}} · 等待你处理", + "rowAccepted": "已接收交接 · {{status}} · {{priority}}", + "rowReturned": "{{name}} 已退回 · {{status}} · {{priority}}" + }, + "errors": { + "loadTitle": "无法加载团队收件箱", + "load": "无法加载团队收件箱", + "loadMore": "加载更多团队收件箱事项失败,请重试。", + "refresh": "无法刷新团队收件箱", + "markRead": "标记已读失败,请重试。", + "markUnread": "标记未读失败,请重试。", + "markAllRead": "全部标记已读失败,请重试。", + "identity": "当前账户无法匹配到项目成员,请检查项目个人资料中的邮箱。", + "partialLoad": "部分团队收件箱来源刷新失败,当前可用事项仍会保留显示。", + "workItemContext": "部分项目上下文暂不可用,工作项仍可继续查看和操作。", + "workItemLoad": "无法加载此工作项,请重试。", + "workItemUpdate": "无法保存刚才的工作项修改,请重试。" + }, + "detail": { + "assignedSubtitle": "分配给你的工作项", + "standaloneProject": "独立工作项", + "mentionSubtitle": "评论中提及了你", + "mentionedYou": "提及了你", + "threadComments": "该话题中有 {{count}} 条评论" + }, + "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": "紧急" + } + }, "globalToolbar": { "selectWorkspaceToStart": "选择一个工作区以开始", "selectRepoToStart": "选择一个 Repo 开始" diff --git a/src/i18n/locales/zh/navigation.json b/src/i18n/locales/zh/navigation.json index cc7ec75b11..a876f3e1df 100644 --- a/src/i18n/locales/zh/navigation.json +++ b/src/i18n/locales/zh/navigation.json @@ -788,7 +788,9 @@ "addressRoundScope": "逐轮评论", "addressConfirm_one": "处理 {{count}} 条评论", "addressConfirm_other": "处理 {{count}} 条评论", - "agentAuthor": "Agent @{{name}}" + "agentAuthor": "Agent @{{name}}", + "mentionMembers": "提及成员", + "searchMembers": "搜索成员" }, "billing": { "openFailed": "无法打开账单页,请重试。" diff --git a/src/i18n/locales/zh/projects.json b/src/i18n/locales/zh/projects.json index 073f8cbe2f..cf9367225f 100644 --- a/src/i18n/locales/zh/projects.json +++ b/src/i18n/locales/zh/projects.json @@ -368,6 +368,10 @@ }, "activity": { "title": "动态", + "discussionTitle": "讨论", + "activityHistory": "活动记录", + "activityHistoryCount": "{{count}} 条事件", + "noComments": "暂无评论", "subscribe": "订阅", "unsubscribe": "取消订阅", "commentPlaceholder": "留下评论...", @@ -389,6 +393,16 @@ "clearedField": "清除了{{field}}", "changedDescription": "更新了描述", "editedFields": "编辑了 {{count}} 个字段:", + "groupedChanges": "进行了 {{count}} 项更改", + "groupedTodoChanges": "更新了待办 · {{count}} 项操作", + "todoAdded": "新增了「{{todo}}」", + "todoRemoved": "删除了「{{todo}}」", + "todoCompleted": "完成了「{{todo}}」", + "todoReopened": "重新打开了「{{todo}}」", + "todoStarted": "开始了「{{todo}}」", + "todoMarkedPending": "将「{{todo}}」标记为待处理", + "todoRenamed": "将「{{from}}」重命名为「{{to}}」", + "todoUpdated": "更新了「{{todo}}」", "justNow": "刚刚", "daysAgo": "{{count}}天前", "hoursAgo": "{{count}}小时前", @@ -411,7 +425,8 @@ "todos": "待办事项", "comments": "评论", "schedule": "计划", - "orchestratorConfig": "Orchestrator 配置" + "orchestratorConfig": "Orchestrator 配置", + "handoff": "工作交接" } }, "todos": { diff --git a/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx new file mode 100644 index 0000000000..f32d84922c --- /dev/null +++ b/src/modules/MainApp/TeamInbox/ConnectedTeamInboxView.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import TeamInboxView from "./TeamInboxView"; +import { useTeamInboxDataSource } from "./useTeamInboxDataSource"; +import { useTeamInboxNavigation } from "./useTeamInboxNavigation"; + +const ConnectedTeamInboxView: React.FC = () => { + const { dataSource, viewerMemberIds } = useTeamInboxDataSource(); + const navigate = useTeamInboxNavigation(); + return ( + + ); +}; + +export default ConnectedTeamInboxView; diff --git a/src/modules/MainApp/TeamInbox/TEST_CASES.md b/src/modules/MainApp/TeamInbox/TEST_CASES.md new file mode 100644 index 0000000000..5e5debc922 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/TEST_CASES.md @@ -0,0 +1,176 @@ +# Team Inbox acceptance cases + +## Automated + +- The Sidebar pinned menu renders Team Inbox immediately below Runtime. +- Opening Team Inbox twice focuses the same singleton Chat Panel tab. +- `all`, `mentions`, and `assigned` filters operate on one discriminated item model. +- Mixed items are deduplicated and sorted by `occurredAt`, then stable item identity. +- Local assigned Work Items require explicit current-user member IDs. +- Local cursor pagination is stable when timestamps tie and when newer rows arrive. +- Local assignment and managed-cloud mention receipts are viewer-scoped and idempotent. +- Managed-cloud mention responses are Zod-validated, include server-owned `readAt` + full-page-independent unread totals, and never accept a caller-supplied viewer ID. +- Structured comment mentions send stable cloud user ids selected from the active roster; mutable/non-unique display names are never parsed as identities. +- Raw work-item status/priority enum tokens are humanized (`humanizeToken`) when no localized key exists, and never leak to the row or detail. +- Per-filter unread counts (`countUnreadTeamInboxItemsByFilter`) de-duplicate before counting and back the filter-tab badges. +- `filterItemKind` maps `all → null`, `mentions → comment_mention`, `assigned → assigned_work_item`. +- `searchTeamInboxItems` is case-insensitive, matches title/body/summary/people, returns a fresh copy for empty queries, and empty for no match. +- `groupTeamInboxItemsByRecency` buckets by local calendar day (Today/Yesterday/This week/Earlier), omits empty groups, keeps input order, and files unparseable timestamps under "earlier". +- Assigned items carry a trimmed, whitespace-folded, 240-char body excerpt as `summary`; blank bodies omit the field (`work_item_summary_excerpt`). +- `mark_unread` deletes the viewer-scoped local or cloud receipt so the item returns to unread and remains idempotent; cloud receipts are not owned by localStorage. +- `toWireCursorItemId` preserves the backend `work_item_assigned:` source prefix (strips only the UI `assigned_work_item:` kind prefix) so `Load more` cursor pagination round-trips instead of erroring. +- Sidebar and full Inbox consumers in the same Jotai store share one scope-keyed coordinator, including initial request identity, local/cloud cursors, mutation ordering, cancellation, and the bounded 500-row snapshot. +- Local and cloud reads settle independently: one successful source remains visible with a localized partial-success notice, and a failed pagination cursor remains retryable. +- Switching account, organization, or resolved viewer identity synchronously evicts the old snapshot, aborts cloud work, and prevents late responses from committing into the new scope. +- Exact account IDs, verified full email addresses, linked emails, and provider usernames may resolve a viewer; matching display names or equal email local-parts across domains never does. +- Reassigning a Work Item changes `assigned_human_id` and deletes the prior assignment episode's read receipt in the same SQLite transaction; agent assignments never enter the human-assignment projection. +- Failed read/unread persistence rolls back the coordinator-owned optimistic snapshot, while a newer per-item mutation supersedes an older response. + +## Presentation / polish + +1. Filter tabs (`All` / `Mentions` / `Assigned`) show a primary count badge only when that surface has unread items; badge clamps to `99+`. +2. Unread rows render a leading primary dot and bold title; read rows drop the dot and use medium weight. +3. Assigned rows show one title line, at most two plain-text excerpt lines, and a localized `status · priority` metadata line; Markdown syntax, escaped newlines, and redundant assignee names do not leak into the card. +4. Successful edits in the selected Work Item immediately update the matching list row's title, summary, status, priority, and assignee; reassigning away from the viewer removes the stale assigned row. +5. The list excerpt and detail Markdown body use the same `text-text-1` content token; hierarchy comes from size and weight rather than mismatched foreground colors. +6. Assigned detail shows localized `Status` and `Priority` rows and no misleading `Assigned by` row when no assigner is known. +7. `Mark all as read` in the header marks **only the active filter's** unread items (Mentions view never marks Assigned, and vice versa). +8. Empty state copy is filter-specific (`No mentions` vs `Nothing assigned to you`), falling back to the generic empty copy for `All`. +9. A `SearchInput` toolbar row filters the loaded items live; typing a non-matching query shows a dedicated `No matches` empty state (distinct from the filter-empty copy); clearing the query restores the list. +10. Rows are grouped under recency headers (`Today` / `Yesterday` / `This week` / `Earlier`); empty groups are hidden, and Arrow/Home/End keyboard navigation still traverses the flat visible order across group boundaries. +11. Selecting an assigned item lazily loads the full Work Item body and renders it as Markdown; while loading / on failure / when empty it falls back to the short list excerpt. Selecting a mention renders the comment body as Markdown. Stale body responses are discarded when the selection changes. +12. A read item's detail exposes a `Mark as unread` action; invoking it returns the row + Sidebar unread badge to the unread state (local assignment deletes the SQLite receipt; cloud mention deletes the managed-cloud receipt). Re-marking read still works after refresh or on another device. +13. When a source still has a next page, the list shows a `Load more` control—even when the active filter/search has no visible first-page result; invoking it appends the next page (local cursor round-trips with the `work_item_assigned:` prefix intact) and de-duplicates against the loaded set. The control hides once no source has more. +14. Activating Retry after an initial load error calls the backing source's refresh boundary before reading a new snapshot; it never loops on the same failed cache entry. +15. Partial-source degradation uses a warning treatment and preserves readable results; a total failure uses the blocking error state. + +## Session → Work Item drop + +| # | Steps | Expected result | +| --- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Drag a Session tab over Team Inbox, then leave without dropping. | A localized dashed Drop Zone appears only during the eligible drag, highlights on entry, and disappears on leave/cancel without mutating data. | +| 2 | Drop an unlinked Session on Team Inbox. | A review composer opens with the parsed title, request/impact preview, project roster, self selected by default, and an optional handoff note. | +| 3 | Keep self selected and submit. | One normal assigned Work Item is created with the Session snapshot and no handoff state. | +| 4 | Select an active teammate and submit. | The Work Item, teammate assignment, Session provenance, creator, and `pending` handoff record are persisted in one initial write. | +| 5 | Drop the same Session again. | The existing linked Work Item is reused; no duplicate Work Item or second handoff is created. | +| 6 | Remove the selected teammate before submission. | Submission revalidates the project roster and fails visibly instead of assigning to a stale member. | +| 7 | Fail the reverse Session link after the Work Item write, then Retry. | The retry finds the Work Item by `linked_sessions`, repairs the reverse link, and reports success without creating a second Work Item. | +| 8 | Fail the Work Item write. | The configured title, recipient and note remain in the composer so the same atomic submission can be retried or cancelled. | +| 9 | Complete creation and activate Open. | The canonical Work Item navigation opens the created/reused item; Team Inbox refreshes through its coordinator invalidation. | +| 10 | Switch scope or unmount Team Inbox while preparation/write is pending. | The request is aborted best-effort and late completion cannot overwrite the current UI. | +| 11 | Open Team Inbox without an exactly resolved viewer member identity. | Session drop creation is unavailable; no unassigned Work Item is silently created. | +| 12 | Select another member id that resolves to the current user. | The operation remains a self-assignment and does not create a misleading human-to-human handoff. | +| 13 | Open a standalone Session that belongs to no project and has two eligible shared projects. | The composer requires an explicit destination project, then limits sender/recipient identities to that project's roster. | +| 14 | Right-click a Session tab and choose `Create team Work Item…`. | Team Inbox opens/focuses and displays the same review composer used by drag-and-drop; the Session tab remains in place. | +| 15 | Remove the selected project or recipient after the composer opens. | Submit re-reads the current roster and fails visibly without writing into another project or retaining a stale recipient. | +| 16 | Address the handoff to a second member id owned by the same signed-in person. | That person can Accept/Return using the exact addressed member id; the UI does not reject a valid alias. | +| 17 | With a Cloud Org selected in the Sidebar, start a handoff from a standalone Session while also belonging to a local project. | The local project remains available as an explicit destination; Sidebar message scope does not incorrectly filter Work Item destinations. | + +### Session-drop acceptance criteria + +- [ ] Dragging is copy semantics: the source Session tab is never moved or closed. +- [ ] `pointermove` is subscribed only for an active eligible drag, hit-tests at most once per animation frame, and updates React state only when the over-boundary changes. +- [ ] The Work Item and its `linked_sessions` provenance are written together before the Session reverse link is attempted. +- [ ] The composer defaults to self, requires an available recipient, and never turns another current-user alias into a team handoff. +- [ ] A standalone Session requires an explicit project when more than one eligible project exists; changing project resets the recipient to a valid project-local identity. +- [ ] Eligible destination projects are derived from project membership, independently of the Sidebar's managed-cloud message scope. +- [ ] Drag and the Session context-menu action converge on one request atom, one review composer, and one idempotent creation command. +- [ ] A teammate handoff persists `pending / accepted / returned`, sender/recipient identities, timestamps, and bounded notes in canonical Work Item extras. +- [ ] Creation is single-flight per viewer scope and Session, and retry after a partial link failure is idempotent. +- [ ] Session parsing is deterministic and bounded: title 120 chars, request 4,000 chars, eight touched files, and twenty explicit Markdown checkbox to-dos. +- [ ] The Drop Zone uses localized copy, design-system `Button`, semantic status/alert roles, and no raw color values. +- [ ] A project-scoped Session whose project no longer exists fails visibly instead of silently creating a standalone Work Item. +- [ ] Automated coverage exercises mapping, atomic provenance, teammate/self selection, alias handling, duplicate reuse, reverse-link repair, progress/success/open, and error/retry. + +## Human handoff state machine + +| State | Owner / visible action | Durable transition | +| -------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| Pending | Recipient sees `Accept` and `Return`; sender sees status when opening the linked Work Item | Accept or Return only; opening/marking read does not accept. | +| Accepted | Both Work Item entry points show the accepted status | Assignment stays with the recipient; retrying Accept is idempotent; Return is no longer allowed. | +| Returned | Sender sees the item reassigned and unread; return reason remains visible | Handoff and reassignment commit in one SQLite transaction; the recipient's prior read receipt is cleared. | + +### Handoff acceptance criteria + +- [ ] Pending handoffs are distinguishable from ordinary assignments in the compact Inbox row. +- [ ] Only the resolved recipient sees decision actions; other viewers can read the status but cannot act. +- [ ] If the signed-in identity cannot be resolved, a targeted pending handoff explains why actions are unavailable instead of silently hiding them. +- [ ] Return requires a non-blank reason of at most 500 characters. +- [ ] The shared Work Item content renders the same handoff notice in Team Inbox and the formal Work Item destination. +- [ ] Accept/Return uses one actor-attributed backend command; validation, history, extras persistence, receipt reset, and collab outbox emission share the atomic Work Item boundary. +- [ ] After Accept, the left row updates from the refreshed Work Item; after Return, reassignment removes it from the recipient and makes it unread for the sender. +- [ ] Collaboration apply updates `handoff` on an existing remote Work Item, so Accept/Return reaches another device and triggers the normal project/Inbox invalidation path. + +## Coordinator state machine + +| State | Entry | Visible behavior | Allowed transition | Ownership / persistence | +| -------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------- | +| Unavailable identity | Member files loaded but no exact viewer identity matches | Cloud results may remain visible; local assignment availability is explicitly degraded | Refresh after profile/account correction | Identity is derived; no guessed member id is persisted | +| Loading | New viewer/account/org scope or explicit refresh | Old scope is synchronously removed; the new scope shows loading | Success, partial success, empty, error, scope switch | Coordinator owns request generation and AbortController | +| Ready | Every requested source succeeds | Shared list, counts, cursors, filters and detail are usable | Load more, mutation, refresh, scope switch | Jotai cache is the canonical runtime snapshot | +| Empty | Successful sources return no rows | Filter-specific empty state; Load more stays available when a cursor exists | Load more or refresh | Empty is a successful snapshot, not an error | +| Partial success | At least one source/prerequisite succeeds and one degrades | Successful rows stay actionable under a localized warning | Retry, pagination of remaining cursors, scope switch | Successful source data replaces only that source's projection | +| Error / timeout | Every requested source fails or prerequisite loading fails | Blocking error only when no usable rows remain; retained rows otherwise stay visible | Retry invokes the real refresh boundary | Diagnostic details remain internal; UI maps issue codes to localized copy | +| Mutating | Read/unread operation enters the shared mutation queue | Snapshot updates optimistically once | Commit authoritative receipt, rollback, or supersede | Durable receipt is SQLite/cloud; optimistic state is coordinator-owned | +| Superseded | Scope generation changes or a newer same-item mutation starts | Late completion is ignored; cloud work is aborted best-effort | New scope/request continues | No stale completion may write the current snapshot | + +## Unified Work Item thread + +| # | Steps | Expected result | +| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Select a project-scoped assigned Work Item. | The full Work Item uses the shared content and property components; the reduced Markdown/metadata preview is not rendered. | +| 2 | Inspect a Work Item with linked Sessions. | Workflow and Session run cards appear inline in one continuous thread. The legacy `Session / Output / History` tab strip and linked-Session table are absent in Team Inbox. | +| 3 | Activate `View live chat` / `View conversation` on a Session card. | A separate Session Chat Panel tab opens or the existing tab for that Session is focused. Team Inbox remains open as its singleton tab. | +| 4 | Inspect a Work Item with proof of work and comments/history. | The primary body contains task execution content; Discussion is a drill-in where comments lead and system history stays collapsed. | +| 5 | Switch assigned rows while the first full Work Item is still loading. | A late response from the first row never replaces the newly selected Work Item. | +| 6 | Make two property changes in quick succession. | Same-item writes run in invocation order through a bounded queue, so the final response contains both atomic partial updates and an older response cannot overwrite newer intent. | +| 7 | Open a standalone assigned Work Item. | The thread remains readable, but edit controls/property rail are not exposed because standalone persistence requires the owning frontmatter round-trip. | +| 8 | Fail the selected Work Item read. | A visible error placeholder is shown; the short list row remains available for retry/navigation. | +| 9 | Open a project Work Item with a short description. | The description renders at its natural Markdown height. `Preview / Raw` and the editor are absent until `Edit` is activated. | +| 10 | Activate `Edit`, change the description, then cancel. | A compact editor and Cancel/Save footer appear; Save is disabled until content changes, and Cancel restores the original Markdown. | +| 11 | Inspect a Work Item containing a persisted blank To-Do row. | The blank row is not rendered. The add input appears only after `Add` / `Add a to-do item` is activated. | +| 12 | Add a To-Do with Enter, then rapidly toggle and remove items. | Only committed, trimmed items persist; every change uses the canonical Work Item update boundary. | +| 13 | Open Discussion and inspect comments. | Discussion replaces the Work Item body, prioritizes comments, keeps Activity history collapsed, and owns subscription plus the sticky current-user composer. | +| 14 | Activate `Start Agent` on an idle Inbox Work Item. | The canonical Work Item tab opens/focuses, claims the one-shot `start_agent` request, and starts through its existing orchestrator. The Inbox never mounts a second orchestrator. | +| 15 | Resize the detail from narrow to wide. | The thread remains a centered single reading column; compact property pills scroll horizontally instead of creating a competing right rail. | +| 16 | Rapidly activate `Start Agent`, remount the Work Item panel, or request another Work Item before the first is claimed. | A claimed request starts exactly once and cannot replay; the newest unclaimed navigation intent supersedes the older one, which can never start later. | +| 17 | Compare the To-Do and Agent Workflow cards, then collapse Workflow. | Both cards share one Work Item thread visual shell; Workflow retains its existing collapse behavior and To-Do remains independently interactive. | +| 18 | Open Assignee or Reviewer in a project-scoped Inbox Work Item. | The picker contains the complete active project roster, resolves stored member ids to names, and persists through the canonical partial-update boundary. | +| 19 | Inspect creator, comments, and history written with stored member ids. | Known ids resolve to project-member names; unknown ids remain visible instead of being guessed or silently blanked. | +| 20 | Load a Work Item while its project or member context read fails. | The successfully loaded Work Item remains usable under a localized warning; only failure of the required Work Item read replaces it with an error state. | + +### Unified thread acceptance criteria + +- [ ] Team Inbox uses `presentation="thread"` while ordinary Work Item surfaces retain their existing default tabs/table. +- [ ] `data-testid="work-item-thread-section"` is present and `data-testid="work-item-lower-tabs-section"` / `data-testid="work-item-linked-sessions"` are absent in Team Inbox. +- [ ] The description is read-first and enters edit mode only through `data-testid="work-item-description-edit"`. +- [ ] Blank To-Do rows are removed from the thread projection; the To-Do composer is demand-mounted. +- [ ] Properties use the shared pill fields in the thread header and no separate heavy property-card rail is rendered. +- [ ] `Open work item`, read/unread, subscription, and comment actions are grouped with their owning header/composer instead of occupying disconnected footer rows. +- [ ] Team Inbox and the formal Work Item both default to the Work Item body, place Discussion after primary content, and keep it outside the property metadata band. +- [ ] Session-card navigation uses the explicit `open_session` intent and the canonical open-or-focus Session-tab atom. +- [ ] Team Inbox does not mount a second Work Item orchestrator; `Start Agent` forwards a one-shot action to the canonical Work Item tab, where lock validation, start, failure recovery, and refresh remain owned. +- [ ] The one-shot action is consumed only by its matching Work Item and is cleared before the async start begins, preventing remount/double-effect replay. +- [ ] At most one unclaimed start intent exists; a newer Work Item request explicitly supersedes the older intent instead of leaving a delayed start behind. +- [ ] The centered reading frame and metadata band are composed by `WorkItemThreadLayout`; static card shells use `WorkItemThreadSection`, while collapsible Workflow shares tokens without duplicating collapse state. +- [ ] No Session/comment transcript scan or frontend-fabricated impact data is introduced. + +## Rendered product path + +1. Seed or create a project member that matches the current Git identity. +2. Assign a Work Item to that member through the normal Work Item UI. +3. Click the real `Team Inbox` Sidebar row (`data-testid=sidebar-team-inbox`). +4. Verify the assigned item appears and `分配给我` keeps it visible. +5. Open its detail, mark it read, and verify the row and Sidebar unread badge update together. +6. Close and reopen Team Inbox; verify the durable local receipt remains read. +7. In a managed cloud org, use the normal Session comment member picker to mention user B. +8. In user B's independent app instance, verify `@ 提及` shows the stable comment/session target and unread badge. +9. Open the row and verify the production click persists `readAt`; list again with user B's JWT and observe `unreadCount = 0`. +10. List with user A's JWT and verify B's targeted mention is absent; refresh/reopen B's Inbox and verify it remains read. + +## Degraded states + +- No member identity: show an explicit identity error; do not guess from an agent/session ID. +- Signed out or local scope: skip the cloud RPC and retain local assigned items. +- Cloud mention RPC unavailable: retain local assigned items; do not scan every Session body as a fallback. +- Empty result: show the Team Inbox empty state without starting a poller. diff --git a/src/modules/MainApp/TeamInbox/TeamInboxView.tsx b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx new file mode 100644 index 0000000000..40fc601e97 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/TeamInboxView.tsx @@ -0,0 +1,443 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import SplitViewLayout from "@src/modules/shared/layouts/SplitViewLayout"; +import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import type { WorkItem } from "@src/types/core/workItem"; + +import { + AssignedWorkItemDetail, + CommentMentionDetail, + TeamInboxList, +} from "./components"; +import TeamInboxSessionDropSurface from "./components/TeamInboxSessionDropSurface"; +import { + type TeamInboxDataSource, + type TeamInboxFilter, + type TeamInboxIssue, + type TeamInboxItem, + type TeamInboxNavigationIntent, + type TeamInboxUnreadCounts, + countUnreadTeamInboxItemsByFilter, + getTeamInboxItemKey, + searchTeamInboxItems, + selectTeamInboxItems, + toTeamInboxNavigationIntent, +} from "./domain"; + +export interface TeamInboxViewProps { + dataSource?: TeamInboxDataSource; + onNavigate?: (intent: TeamInboxNavigationIntent) => void; + initialFilter?: TeamInboxFilter; + pageSize?: number; + viewerMemberIds?: readonly string[]; +} + +const EMPTY_TEAM_INBOX_DATA_SOURCE: TeamInboxDataSource = { + async listPage() { + return { items: [], nextCursor: null }; + }, +}; + +interface LoadState { + status: "loading" | "ready" | "warning" | "error"; + message: string | null; +} + +const TeamInboxView: React.FC = ({ + dataSource = EMPTY_TEAM_INBOX_DATA_SOURCE, + onNavigate, + initialFilter = "all", + pageSize = 50, + viewerMemberIds = [], +}) => { + const { t } = useTranslation(); + const [filter, setFilter] = useState(initialFilter); + const [query, setQuery] = useState(""); + const [items, setItems] = useState([]); + const [authoritativeUnreadCounts, setAuthoritativeUnreadCounts] = + useState(null); + const [recencyAnchorMs, setRecencyAnchorMs] = useState(() => Date.now()); + const [requestedItemId, setRequestedItemId] = useState(null); + const [loadState, setLoadState] = useState({ + status: "loading", + message: null, + }); + const [reloadRevision, setReloadRevision] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const issueMessage = useCallback( + (issue: TeamInboxIssue): string => { + if (issue.code === "identity_unresolved") { + return t("teamInbox.errors.identity"); + } + if (issue.code === "partial_load") { + return t("teamInbox.errors.partialLoad"); + } + return t("teamInbox.errors.load"); + }, + [t] + ); + + useEffect(() => { + const abortController = new AbortController(); + + void dataSource + .listPage({ limit: pageSize, signal: abortController.signal }) + .then((page) => { + if (abortController.signal.aborted) return; + setItems(page.items); + setAuthoritativeUnreadCounts(page.unreadCounts ?? null); + setRecencyAnchorMs(Date.now()); + setHasMore(page.nextCursor != null); + setLoadState( + page.loading + ? { status: "loading", message: null } + : page.issue + ? { + status: + page.issue.code === "partial_load" ? "warning" : "error", + message: issueMessage(page.issue), + } + : { status: "ready", message: null } + ); + }) + .catch((reason: unknown) => { + if (abortController.signal.aborted) return; + setLoadState({ + status: "error", + message: + reason instanceof Error + ? "issue" in reason && + reason.issue && + typeof reason.issue === "object" && + "code" in reason.issue + ? issueMessage(reason.issue as TeamInboxIssue) + : reason.message + : t("teamInbox.errors.load"), + }); + }); + + return () => abortController.abort(); + }, [dataSource, issueMessage, pageSize, reloadRevision, t]); + + useEffect(() => { + if (!dataSource.subscribe) return; + return dataSource.subscribe(() => { + setReloadRevision((value) => value + 1); + }); + }, [dataSource]); + + const visibleItems = useMemo( + () => searchTeamInboxItems(selectTeamInboxItems(items, filter), query), + [filter, items, query] + ); + const loadedUnreadCounts = useMemo( + () => countUnreadTeamInboxItemsByFilter(items), + [items] + ); + const unreadCounts = authoritativeUnreadCounts ?? loadedUnreadCounts; + const totalUnread = unreadCounts.all; + const selectedItem = useMemo(() => { + if (visibleItems.length === 0) return null; + return ( + visibleItems.find( + (item) => getTeamInboxItemKey(item) === requestedItemId + ) ?? visibleItems[0] + ); + }, [requestedItemId, visibleItems]); + const selectedItemId = selectedItem + ? getTeamInboxItemKey(selectedItem) + : null; + + const handleLoadMore = () => { + if (!dataSource.loadMore || loadingMore) return; + setLoadingMore(true); + void dataSource + .loadMore() + .then(() => { + if (mountedRef.current) { + setReloadRevision((value) => value + 1); + } + }) + .catch(() => { + setLoadState({ + status: "error", + message: t("teamInbox.errors.loadMore"), + }); + }) + .finally(() => { + if (mountedRef.current) setLoadingMore(false); + }); + }; + + const handleRefresh = () => { + setLoadState({ status: "loading", message: null }); + if (!dataSource.refresh) { + setReloadRevision((value) => value + 1); + return; + } + void dataSource + .refresh() + .then(() => { + if (mountedRef.current) { + setReloadRevision((value) => value + 1); + } + }) + .catch(() => { + setLoadState({ + status: "error", + message: t("teamInbox.errors.refresh"), + }); + }); + }; + + const handleSelect = (item: TeamInboxItem) => { + setRequestedItemId(getTeamInboxItemKey(item)); + if (item.readAt !== null) return; + void dataSource.markRead?.(item).catch(() => { + setLoadState({ + status: "error", + message: t("teamInbox.errors.markRead"), + }); + }); + }; + + const handleMarkRead = (item: TeamInboxItem) => { + if (item.readAt !== null) return; + void dataSource.markRead?.(item).catch(() => { + setLoadState({ + status: "error", + message: t("teamInbox.errors.markRead"), + }); + }); + }; + + const handleMarkUnread = (item: TeamInboxItem) => { + if (item.readAt === null) return; + void dataSource.markUnread?.(item).catch(() => { + setLoadState({ + status: "error", + message: t("teamInbox.errors.markUnread"), + }); + }); + }; + + const handleMarkAllRead = () => { + const filterUnreadCount = + filter === "all" + ? unreadCounts.all + : filter === "mentions" + ? unreadCounts.mentions + : unreadCounts.assigned; + if (filterUnreadCount === 0) return; + void dataSource.markAllRead?.([], filter).catch(() => { + setLoadState({ + status: "error", + message: t("teamInbox.errors.markAllRead"), + }); + }); + }; + + const handleWorkItemUpdated = useCallback( + (sourceItem: TeamInboxItem, workItem: WorkItem) => { + if (sourceItem.kind !== "assigned_work_item") return; + const sourceKey = getTeamInboxItemKey(sourceItem); + const assignee = workItem.assignee; + const belongsToViewer = assignee + ? viewerMemberIds.length > 0 + ? viewerMemberIds.includes(assignee.id) + : assignee.id === sourceItem.payload.assigneeMemberId + : false; + const status = + workItem.workItemStatus ?? workItem.status ?? sourceItem.payload.status; + const updatedAt = workItem.updated_time || sourceItem.payload.updatedAt; + const nextItem: TeamInboxItem | null = + assignee && belongsToViewer + ? { + ...sourceItem, + occurredAt: updatedAt, + payload: { + ...sourceItem.payload, + title: workItem.name || sourceItem.payload.title, + status, + priority: workItem.priority ?? sourceItem.payload.priority, + assigneeMemberId: assignee.id, + assigneeName: assignee.name, + summary: workItem.spec?.trim() || undefined, + handoff: workItem.handoff, + updatedAt, + }, + } + : null; + if (dataSource.reconcileItem) { + dataSource.reconcileItem(sourceKey, nextItem); + return; + } + setItems((current) => + current.flatMap((candidate) => + getTeamInboxItemKey(candidate) === sourceKey + ? nextItem + ? [nextItem] + : [] + : [candidate] + ) + ); + }, + [dataSource, viewerMemberIds] + ); + + const detail = (() => { + if (loadState.status === "loading") { + return ( + + ); + } + if (loadState.status === "error" && items.length === 0) { + return ( + + ); + } + if (!selectedItem) { + return ( + + ); + } + if (selectedItem.kind === "comment_mention") { + return ( + onNavigate(toTeamInboxNavigationIntent(selectedItem)) + : undefined + } + /> + ); + } + return ( + + handleWorkItemUpdated(selectedItem, workItem) + } + /> + ); + })(); + + return ( + +
+ {(loadState.status === "error" || loadState.status === "warning") && + items.length > 0 ? ( +
+ {loadState.message} +
+ ) : null} + + ) : loadState.status === "error" && items.length === 0 ? ( + + ) : ( + + ) + } + mainContent={detail} + /> +
+
+ ); +}; + +export default TeamInboxView; diff --git a/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts new file mode 100644 index 0000000000..38b2db2175 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/AssignedWorkItemDetail.test.ts @@ -0,0 +1,241 @@ +// @vitest-environment jsdom +import React, { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { WorkItem } from "@src/types/core/workItem"; + +import AssignedWorkItemDetail from "../components/AssignedWorkItemDetail"; +import type { AssignedWorkItem } from "../domain"; + +const mocks = vi.hoisted(() => ({ + workItem: { + session_id: "work-item-1", + user_id: "member-2", + name: "Add Team Inbox", + status: "backlog", + spec: "Build the reusable feature surface.", + star: false, + target_date: null, + created_time: "2026-07-23T10:00:00.000Z", + updated_time: "2026-07-23T10:00:00.000Z", + todos: [], + linkedSessions: [], + orchestratorConfig: { + review_enabled: true, + follow_up_enabled: true, + auto_retry_on_failure: false, + max_retry_count: 1, + auto_create_pr: false, + selected_account_id: "account-1", + selected_model_id: "model-1", + }, + } as WorkItem, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("../useTeamInboxWorkItem", () => ({ + useTeamInboxWorkItem: () => ({ + workItem: mocks.workItem, + status: "ready", + issue: null, + repoPath: "/repo", + members: [], + currentUser: { + id: "user-ea821852", + name: "hanafish", + avatar: "https://example.com/hanafish.png", + color: "#52c41a", + }, + updateWorkItem: vi.fn(), + refreshWorkItem: vi.fn(), + }), +})); + +vi.mock("@src/modules/ProjectManager/WorkItems/components", () => ({ + WorkItemThreadSurface: ({ + onStartAgent, + onOpenSession, + propertyProps, + currentUser, + }: { + onStartAgent?: () => void; + onOpenSession?: (sessionId: string) => void; + propertyProps?: Record; + currentUser?: { id: string; name: string; avatar?: string }; + }) => + createElement( + "div", + { + "data-testid": "work-item-content", + "data-current-user-id": currentUser?.id, + "data-current-user-name": currentUser?.name, + "data-current-user-avatar": currentUser?.avatar, + }, + propertyProps + ? createElement("div", { + "data-testid": "work-item-properties", + "data-property-configured": "true", + }) + : null, + createElement( + "button", + { + type: "button", + "data-testid": "start-agent", + onClick: onStartAgent, + }, + "Start Agent" + ), + createElement( + "button", + { + type: "button", + "data-testid": "open-session", + onClick: () => onOpenSession?.("session-1"), + }, + "Open session" + ) + ), +})); + +vi.mock("../components/TeamInboxDetailLayout", () => ({ + default: ({ children }: { children?: React.ReactNode }) => + createElement("div", null, children), +})); + +const item: AssignedWorkItem = { + id: "work-item-1", + kind: "assigned_work_item", + occurredAt: "2026-07-23T10:00:00.000Z", + readAt: null, + actor: { id: "member-2", displayName: "Lin" }, + target: { + kind: "work_item", + projectId: "project-1", + workItemId: "work-item-1", + }, + payload: { + title: "Add Team Inbox", + status: "in_progress", + priority: "high", + assigneeMemberId: "member-2", + updatedAt: "2026-07-23T10:00:00.000Z", + }, +}; + +describe("AssignedWorkItemDetail navigation actions", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("requests canonical Work Item start instead of mounting an Inbox orchestrator", () => { + const onNavigate = vi.fn(); + act(() => { + root.render( + createElement(AssignedWorkItemDetail, { + item, + onNavigate, + }) + ); + }); + + act(() => { + container + .querySelector("[data-testid='start-agent']") + ?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith({ + kind: "open_work_item", + projectId: "project-1", + workItemId: "work-item-1", + action: "start_agent", + }); + }); + + it("provides editable properties to the shared thread surface", () => { + act(() => { + root.render(createElement(AssignedWorkItemDetail, { item })); + }); + + expect( + container + .querySelector("[data-testid='work-item-properties']") + ?.getAttribute("data-property-configured") + ).toBe("true"); + }); + + it("passes one resolved identity to the comment composer and history surface", () => { + act(() => { + root.render(createElement(AssignedWorkItemDetail, { item })); + }); + + const content = container.querySelector( + "[data-testid='work-item-content']" + ); + expect(content?.getAttribute("data-current-user-id")).toBe("user-ea821852"); + expect(content?.getAttribute("data-current-user-name")).toBe("hanafish"); + expect(content?.getAttribute("data-current-user-avatar")).toBe( + "https://example.com/hanafish.png" + ); + }); + + it("preserves linked-session navigation as a distinct Session tab intent", () => { + const onNavigate = vi.fn(); + act(() => { + root.render( + createElement(AssignedWorkItemDetail, { + item, + onNavigate, + }) + ); + }); + + act(() => { + container + .querySelector("[data-testid='open-session']") + ?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith({ + kind: "open_session", + sessionId: "session-1", + }); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md new file mode 100644 index 0000000000..34f3f4b950 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TEST_CASES.md @@ -0,0 +1,77 @@ +# Test Cases: TeamInbox "Load more" pagination (A1) + +Covers the load-more pagination feature wired through +`useTeamInboxDataSource.loadMore` → `TeamInboxView` → `TeamInboxList`. +Behavior is derived from the shipped implementation, not aspirational. + +## Preconditions + +- Team Inbox tab is open and the connected data source (`ConnectedTeamInboxView`) + is mounted, or the injectable `TeamInboxView` is rendered with a + `dataSource` implementing `listPage` (+ optional `loadMore`). +- Local source page size is 50 (`listLocalTeamInboxPage(..., 50)`); cloud + mentions page size is 50 (`listTeamInboxMentions(..., 50)`). +- `hasMore` is surfaced to the view via `listPage().nextCursor != null`; the + cursor value itself is an inert sentinel — the per-store coordinator owns the + real local/cloud cursors shared by Sidebar and full Inbox consumers. +- The load-more control renders whenever `hasMore === true` and `onLoadMore` is + defined, including filter/search empty-result states. + +## Happy Path + +| # | Steps | Expected Result | +| --- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Open inbox with > 50 assigned local items (or > 50 mentions). | First page (≤ 50 per source) renders; "Load more" button is visible at list bottom. | +| 2 | Click "Load more". | Button shows loading/disabled; next page of each source with a remaining cursor is fetched, appended, de-duplicated (`dedupeTeamInboxItems`), re-sorted by the view selectors; new items appear. | +| 3 | Keep clicking "Load more" until exhausted. | Each click appends the next page; when both shared coordinator cursors are null, `hasMore` becomes false and the button disappears. | +| 4 | Load more with both local + cloud having further pages. | Both sources advance one page; merged list stays newest-first after the view's `selectTeamInboxItems` (dedupe + sort). | +| 5 | After load-more, mark a newly-loaded item read. | Optimistic read state applies to the appended item exactly as for first-page items. | + +## Edge Cases + +| # | Scenario | Steps | Expected Result | +| --- | ------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Empty first page with a remaining cursor | Open an active filter/search with 0 visible items and `hasMore`. | Empty `Placeholder` renders together with "Load more", so a matching later page remains reachable. | +| 2 | Single page | Open inbox where both sources returned `nextCursor == null`. | `hasMore === false`; **no** "Load more" button; list is complete. | +| 3 | Exactly one source paginates | Local has a next page, cloud does not (or vice versa). | Button shown while either cursor is non-null; each click advances only the source that still has a cursor; the exhausted source contributes nothing. | +| 4 | Multi-page to exhaustion | Click load-more repeatedly. | Cursors advance each call; button hides once both cursors are null; no duplicate rows (dedupe by canonical `kind:id`). | +| 5 | Rapid repeated clicks | Click "Load more" several times quickly. | View loading state plus the coordinator single-flight promise ensure only one in-flight load; extra clicks reuse/no-op; no duplicated/skipped pages. | +| 6 | Load-more with active search query | Type a query, then click "Load more". | Load-more fetches more raw items into the cache; the client-side search (`searchTeamInboxItems`) re-applies over the enlarged set. | +| 7 | Load-more with a filter tab active (mentions/assigned) | Switch filter, then load more. | Raw items append to the shared cache; the active filter (`selectTeamInboxItems`) still narrows the rendered list. | +| 8 | Duplicate item across pages | A canonical item appears in two fetched pages. | Deduped to one; the freshest `occurredAt` copy wins (`dedupeTeamInboxItems`). | +| 9 | Refresh after paginating | Load more, then trigger refresh (manual or project-change signal). | Cursors reset to page 1; `hasMore` recomputed from page-1 cursors; list resets to first page. | + +## Error / Degraded States + +| # | Scenario | Steps | Expected Result | +| --- | ---------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Cloud fetch fails during load-more | Cloud RPC throws while paginating. | Local page still commits; cloud cursor is preserved for retry; a localized partial-success warning appears. | +| 2 | Local fetch fails during load-more | `listLocalTeamInboxPage` rejects. | Cloud page still commits; local cursor is preserved for retry; a localized partial-success warning appears. | +| 3 | Every requested source fails | Both active source reads reject. | Existing rows remain; load-more rejects to the view, which shows localized non-blocking error copy and resets loading in `finally`. | +| 4 | Load-more called with no cursors | `hasMore` stale-true but both cursors null. | `loadMore` early-returns (no-op); no fetch; `loadingMore` never gets stuck. | +| 5 | Signed-out / no active cloud org | Only local paginates. | No cloud request is started; only local advances. | + +## Accessibility + +- [ ] "Load more" uses the design-system `Button` (keyboard focusable, Enter/Space activate). +- [ ] While loading, the button is `disabled` and shows `loading` state (no double submit). +- [ ] The button has a visible localized label (`teamInbox.loadMore`) — no raw fallback string or i18n key leaks. +- [ ] Load-more does not steal focus from the list; existing roving-tabindex list navigation is unaffected. + +## Acceptance Criteria + +- [ ] Items beyond the first 50 per source are reachable (no silent truncation) via load-more. +- [ ] `hasMore` accurately reflects "either source has a next page" and the button visibility follows it. +- [ ] Appended pages are de-duplicated and correctly ordered by the view selectors. +- [ ] Concurrent/rapid load-more is guarded (single in-flight request). +- [ ] Either source may fail independently; the successful source still paginates, the failed cursor remains retryable, and loaded items are preserved. +- [ ] Load-more never derives the badge from the loaded window; the server's authoritative mention count remains unchanged until a read mutation succeeds. +- [ ] `pnpm test` for `src/modules/MainApp/TeamInbox` passes; no new TypeScript/lint errors in edited files. + +## Notes / Known limitations + +- The unread badge uses the cloud RPC's authoritative full-result count, so + unread mentions on page 2+ are included before those rows are loaded. +- Coordinator behavior is unit-tested at the shared Jotai-store seam for cursor + continuity, partial failure, scope switching, optimistic rollback and cache + bounds. Component composition tests cover empty-result pagination and retry. diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts new file mode 100644 index 0000000000..a6ccbea8ee --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxList.test.ts @@ -0,0 +1,44 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import TeamInboxList from "../components/TeamInboxList"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +function renderEmptyList(query: string): string { + return renderToStaticMarkup( + createElement(TeamInboxList, { + filter: "all", + items: [], + recencyAnchorMs: Date.UTC(2026, 6, 28), + selectedItemId: null, + totalUnread: 0, + unreadCounts: { all: 0, mentions: 0, assigned: 0 }, + query, + loading: false, + onQueryChange: vi.fn(), + onFilterChange: vi.fn(), + onSelectItem: vi.fn(), + hasMore: true, + onLoadMore: vi.fn(), + }) + ); +} + +describe("TeamInboxList pagination", () => { + it("keeps Load more reachable when the current search has no visible rows", () => { + const markup = renderEmptyList("missing"); + + expect(markup).toContain("teamInbox.empty.noResults.title"); + expect(markup).toContain("teamInbox.loadMore"); + }); + + it("does not point assistive technology at an unmounted active row", () => { + expect(renderEmptyList("")).not.toContain("aria-activedescendant"); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts new file mode 100644 index 0000000000..4265cce47f --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxRow.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import TeamInboxRow from "../components/TeamInboxRow"; +import type { AssignedWorkItem } from "../domain"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string; name?: string }) => { + if (key === "teamInbox.handoff.rowPending") { + return `From ${options?.name} · Awaiting response`; + } + return options?.defaultValue ?? key; + }, + }), +})); + +const assignedItem: AssignedWorkItem = { + id: "assigned-1", + kind: "assigned_work_item", + occurredAt: new Date().toISOString(), + readAt: "2026-07-28T00:00:00.000Z", + actor: { id: "member-1", displayName: "Yuki" }, + target: { kind: "work_item", projectId: "demo", workItemId: "AAA-0001" }, + payload: { + title: "验收 Team Inbox 的真实分配与已读流程", + status: "todo", + priority: "medium", + assigneeMemberId: "member-1", + assigneeName: "Yuki", + summary: + "## 验收目标\\n- 在 Team Inbox 的“全部”和“分配给我”中看到此事项\\n- 打开详情并标记已读", + updatedAt: "2026-07-28T00:00:00.000Z", + }, +}; + +describe("TeamInboxRow", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("renders a compact plain-text excerpt and useful Work Item metadata", () => { + act(() => { + root.render( + createElement(TeamInboxRow, { + item: assignedItem, + itemKey: "assigned_work_item:assigned-1", + selected: true, + onSelect: vi.fn(), + }) + ); + }); + + const summary = container.querySelector("[title]"); + expect(summary?.textContent).toBe( + "验收目标 在 Team Inbox 的“全部”和“分配给我”中看到此事项 打开详情并标记已读" + ); + expect(summary?.textContent).not.toContain("\\n"); + expect(summary?.textContent).not.toContain("##"); + expect(summary?.className).toContain("max-h-10"); + expect(summary?.className).toContain("text-text-1"); + expect(container.textContent).toContain("Todo · Medium"); + expect(container.textContent).not.toContain("Yuki"); + }); + + it("omits the excerpt row when an assigned item has no summary", () => { + act(() => { + root.render( + createElement(TeamInboxRow, { + item: { + ...assignedItem, + payload: { ...assignedItem.payload, summary: undefined }, + }, + itemKey: "assigned_work_item:assigned-1", + selected: false, + onSelect: vi.fn(), + }) + ); + }); + + expect(container.querySelector("[title]")).toBeNull(); + expect(container.textContent).toContain("Todo · Medium"); + }); + + it("prioritizes pending handoff context over ordinary Work Item metadata", () => { + act(() => { + root.render( + createElement(TeamInboxRow, { + item: { + ...assignedItem, + payload: { + ...assignedItem.payload, + handoff: { + id: "handoff-1", + status: "pending", + senderMemberId: "member-2", + senderName: "Lin", + recipientMemberId: "member-1", + recipientName: "Yuki", + note: "Please verify the sync path.", + requestedAt: "2026-07-28T00:00:00.000Z", + }, + }, + }, + itemKey: "assigned_work_item:assigned-1", + selected: false, + onSelect: vi.fn(), + }) + ); + }); + + expect(container.textContent).toContain("From Lin · Awaiting response"); + expect(container.textContent).toContain("Please verify the sync path."); + expect(container.textContent).not.toContain("Todo · Medium"); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxSessionDropSurface.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxSessionDropSurface.test.ts new file mode 100644 index 0000000000..2593d5aab8 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxSessionDropSurface.test.ts @@ -0,0 +1,435 @@ +// @vitest-environment jsdom +import { getDefaultStore } from "jotai"; +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + SESSION_TAB_DRAG_CANCEL_EVENT, + SESSION_TAB_DRAG_END_EVENT, + SESSION_TAB_DRAG_START_EVENT, + type SessionTabDragEndDetail, + type SessionTabDragStartDetail, + type SessionTabTransfer, +} from "@src/shared/dnd/sessionTabDrag"; + +import TeamInboxSessionDropSurface from "../components/TeamInboxSessionDropSurface"; +import type { + TeamInboxDataSource, + TeamInboxSessionHandoffDraft, +} from "../domain"; +import { SessionHandoffPreparationError } from "../sessionHandoffError"; +import { + requestTeamInboxSessionHandoffAtom, + teamInboxSessionHandoffRequestAtom, +} from "../store"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("../components/SessionHandoffComposer", () => ({ + default: ({ + error, + form, + onChange, + onSubmit, + }: { + error?: string | null; + form: { + title: string; + projectSlug: string; + assigneeMemberId: string; + note: string; + }; + onChange: (form: { + title: string; + projectSlug: string; + assigneeMemberId: string; + note: string; + }) => void; + onSubmit: () => void; + }) => + createElement( + "div", + { "data-testid": "team-inbox-session-handoff-composer" }, + error ? createElement("p", null, error) : null, + createElement( + "button", + { + type: "button", + onClick: () => + onChange({ + ...form, + assigneeMemberId: "member-teammate", + note: "Continue from the failing test.", + }), + }, + "assign teammate" + ), + createElement( + "button", + { type: "button", onClick: onSubmit }, + "submit handoff" + ) + ), +})); + +const TRANSFER: SessionTabTransfer = { + source: "chat-panel", + sourceTabId: "tab-1", + sessionId: "session-1", + title: "Fix Team Inbox", +}; + +const DRAFT: TeamInboxSessionHandoffDraft = { + sessionId: TRANSFER.sessionId, + title: TRANSFER.title, + sourceProjectSlug: "project", + projects: [ + { + id: "project-id", + slug: "project", + name: "Project", + sender: { + id: "member-me", + name: "Me", + isCurrentUser: true, + }, + recipients: [ + { + id: "member-me", + name: "Me", + isCurrentUser: true, + }, + { + id: "member-teammate", + name: "Teammate", + isCurrentUser: false, + }, + ], + }, + ], + todoCount: 0, +}; + +function dataSource( + overrides: Partial = {} +): TeamInboxDataSource { + return { + listPage: async () => ({ items: [], nextCursor: null }), + prepareSessionHandoff: vi.fn(async () => DRAFT), + createWorkItemFromSession: vi.fn(async () => ({ + projectId: "project", + workItemId: "PRO-0001", + reused: false, + })), + ...overrides, + }; +} + +function dispatchDrop(): void { + document.dispatchEvent( + new CustomEvent(SESSION_TAB_DRAG_START_EVENT, { + detail: { transfer: TRANSFER }, + }) + ); + document.dispatchEvent( + new CustomEvent(SESSION_TAB_DRAG_END_EVENT, { + detail: { transfer: TRANSFER, clientX: 50, clientY: 50 }, + }) + ); +} + +function findButton(container: HTMLElement, label: string): HTMLButtonElement { + const button = [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent === label + ); + if (!button) throw new Error(`Missing button: ${label}`); + return button; +} + +describe("TeamInboxSessionDropSurface", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + getDefaultStore().set(teamInboxSessionHandoffRequestAtom, null); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function renderSurface(source: TeamInboxDataSource): HTMLDivElement { + act(() => { + root.render( + createElement( + TeamInboxSessionDropSurface, + { dataSource: source }, + createElement("div", null, "Inbox") + ) + ); + }); + const surface = container.firstElementChild as HTMLDivElement; + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + toJSON: () => ({}), + }); + return surface; + } + + it("previews the Session, then submits the selected teammate handoff once", async () => { + let resolveCreation: + | ((result: { + projectId: string; + workItemId: string; + reused: boolean; + }) => void) + | undefined; + const createWorkItemFromSession = vi.fn( + () => + new Promise<{ + projectId: string; + workItemId: string; + reused: boolean; + }>((resolve) => { + resolveCreation = resolve; + }) + ); + const source = dataSource({ createWorkItemFromSession }); + + renderSurface(source); + await act(async () => { + dispatchDrop(); + await Promise.resolve(); + }); + + expect(source.prepareSessionHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + title: "Fix Team Inbox", + signal: expect.any(AbortSignal), + }) + ); + expect( + container.querySelector( + '[data-testid="team-inbox-session-handoff-composer"]' + ) + ).not.toBeNull(); + + act(() => findButton(container, "assign teammate").click()); + act(() => findButton(container, "submit handoff").click()); + + expect(createWorkItemFromSession).toHaveBeenCalledOnce(); + expect(createWorkItemFromSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + title: "Fix Team Inbox", + projectSlug: "project", + assigneeMemberId: "member-teammate", + handoffNote: "Continue from the failing test.", + signal: expect.any(AbortSignal), + }) + ); + + await act(async () => { + resolveCreation?.({ + projectId: "project", + workItemId: "PRO-0001", + reused: false, + }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="team-inbox-session-drop-success"]') + ).not.toBeNull(); + }); + + it("shows a transient Drop Zone and cancels without preparing", () => { + const source = dataSource(); + renderSurface(source); + + act(() => { + document.dispatchEvent( + new CustomEvent( + SESSION_TAB_DRAG_START_EVENT, + { detail: { transfer: TRANSFER } } + ) + ); + }); + expect( + container.querySelector('[data-testid="team-inbox-session-drop-zone"]') + ).not.toBeNull(); + + act(() => { + document.dispatchEvent(new Event(SESSION_TAB_DRAG_CANCEL_EVENT)); + }); + expect( + container.querySelector('[data-testid="team-inbox-session-drop-zone"]') + ).toBeNull(); + expect(source.prepareSessionHandoff).not.toHaveBeenCalled(); + expect(source.createWorkItemFromSession).not.toHaveBeenCalled(); + }); + + it("shows an actionable reason when no eligible project exists", async () => { + const source = dataSource({ + prepareSessionHandoff: vi.fn(() => + Promise.reject(new SessionHandoffPreparationError("no_project")) + ), + }); + renderSurface(source); + + await act(async () => { + dispatchDrop(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain( + "teamInbox.handoff.preparationError.no_project" + ); + expect(findButton(container, "common:actions.retry")).toBeDefined(); + }); + + it("opens the same review composer from the non-drag Session action", async () => { + const source = dataSource(); + renderSurface(source); + + await act(async () => { + getDefaultStore().set(requestTeamInboxSessionHandoffAtom, { + sessionId: TRANSFER.sessionId, + title: TRANSFER.title, + }); + await Promise.resolve(); + }); + + expect(source.prepareSessionHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: TRANSFER.sessionId, + title: TRANSFER.title, + }) + ); + expect( + container.querySelector( + '[data-testid="team-inbox-session-handoff-composer"]' + ) + ).not.toBeNull(); + expect( + getDefaultStore().get(teamInboxSessionHandoffRequestAtom) + ).toBeNull(); + }); + + it("retains the configured handoff after a submit failure for an idempotent retry", async () => { + const createWorkItemFromSession = vi + .fn() + .mockRejectedValueOnce(new Error("write failed")) + .mockResolvedValueOnce({ + projectId: "project", + workItemId: "PRO-0001", + reused: true, + }); + const source = dataSource({ createWorkItemFromSession }); + renderSurface(source); + + await act(async () => { + dispatchDrop(); + await Promise.resolve(); + }); + act(() => findButton(container, "assign teammate").click()); + await act(async () => { + findButton(container, "submit handoff").click(); + await Promise.resolve(); + }); + + expect( + container.querySelector( + '[data-testid="team-inbox-session-handoff-composer"]' + ) + ).not.toBeNull(); + expect(container.textContent).toContain("teamInbox.handoff.submitError"); + + await act(async () => { + findButton(container, "submit handoff").click(); + await Promise.resolve(); + }); + expect(createWorkItemFromSession).toHaveBeenCalledTimes(2); + expect( + container.querySelector('[data-testid="team-inbox-session-drop-success"]') + ).not.toBeNull(); + }); + + it("aborts preparation and ignores its stale completion when scope changes", async () => { + let resolvePreparation: + | ((draft: TeamInboxSessionHandoffDraft) => void) + | undefined; + let observedSignal: AbortSignal | undefined; + const firstDataSource = dataSource({ + prepareSessionHandoff: vi.fn( + (input: { signal?: AbortSignal }) => + new Promise((resolve) => { + observedSignal = input.signal; + resolvePreparation = resolve; + }) + ), + }); + + renderSurface(firstDataSource); + act(dispatchDrop); + expect(observedSignal?.aborted).toBe(false); + + await act(async () => { + root.render( + createElement( + TeamInboxSessionDropSurface, + { dataSource: dataSource() }, + createElement("div", null, "Other scope") + ) + ); + await Promise.resolve(); + }); + expect(observedSignal?.aborted).toBe(true); + + await act(async () => { + resolvePreparation?.(DRAFT); + await Promise.resolve(); + }); + expect( + container.querySelector( + '[data-testid="team-inbox-session-handoff-composer"]' + ) + ).toBeNull(); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts new file mode 100644 index 0000000000..e888e42931 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/TeamInboxView.layout.test.ts @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +import React, { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { WorkItem } from "@src/types/core/workItem"; + +import TeamInboxView from "../TeamInboxView"; +import type { AssignedWorkItem } from "../domain"; + +const splitViewProps = vi.hoisted(() => ({ + current: null as Record | null, +})); +const componentProps = vi.hoisted(() => ({ + assignedDetail: null as Record | null, + list: null as Record | null, + placeholder: null as Record | null, +})); +const translate = vi.hoisted(() => vi.fn((key: string) => key)); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: translate, + }), +})); + +vi.mock("@src/modules/shared/layouts/SplitViewLayout", () => ({ + default: (props: Record) => { + splitViewProps.current = props; + return createElement( + "div", + { "data-testid": "team-inbox-split" }, + props.listContent as React.ReactNode, + props.mainContent as React.ReactNode + ); + }, +})); + +vi.mock("@src/modules/shared/layouts/blocks", () => ({ + Placeholder: (props: Record) => { + componentProps.placeholder = props; + return null; + }, +})); + +vi.mock("../components", () => ({ + AssignedWorkItemDetail: (props: Record) => { + componentProps.assignedDetail = props; + return null; + }, + CommentMentionDetail: () => null, + TeamInboxList: (props: Record) => { + componentProps.list = props; + return null; + }, +})); + +describe("TeamInboxView split layout", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + splitViewProps.current = null; + componentProps.assignedDetail = null; + componentProps.list = null; + componentProps.placeholder = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("does not leak the global Code Editor breadcrumb into Team Inbox", () => { + act(() => { + root.render( + createElement(TeamInboxView, { + dataSource: { + listPage: () => new Promise(() => undefined), + }, + }) + ); + }); + + expect(splitViewProps.current?.alwaysShowBreadcrumb).toBeUndefined(); + expect(splitViewProps.current?.hideBreadcrumbWhenSidebarCollapsed).toBe( + true + ); + }); + + it("projects successful detail edits back into the matching Inbox row", async () => { + const assignedItem: AssignedWorkItem = { + id: "assigned-1", + kind: "assigned_work_item", + occurredAt: "2026-07-28T00:00:00.000Z", + readAt: "2026-07-28T00:01:00.000Z", + actor: { id: "member-1", displayName: "Yuki" }, + target: { + kind: "work_item", + projectId: "demo", + workItemId: "AAA-0001", + }, + payload: { + title: "Old title", + status: "todo", + priority: "medium", + assigneeMemberId: "member-1", + assigneeName: "Yuki", + summary: "Old summary", + updatedAt: "2026-07-28T00:00:00.000Z", + }, + }; + + await act(async () => { + root.render( + createElement(TeamInboxView, { + dataSource: { + listPage: async () => ({ + items: [assignedItem], + nextCursor: null, + }), + }, + }) + ); + await Promise.resolve(); + }); + + const onWorkItemUpdated = componentProps.assignedDetail + ?.onWorkItemUpdated as ((workItem: WorkItem) => void) | undefined; + expect(onWorkItemUpdated).toBeTypeOf("function"); + + const updatedWorkItem: WorkItem = { + session_id: "AAA-0001", + user_id: "member-1", + name: "Updated title", + status: "in_review", + workItemStatus: "in_review", + priority: "high", + spec: "## Updated summary", + assignee: { id: "member-1", name: "Yuki" }, + star: false, + target_date: null, + created_time: "2026-07-28T00:00:00.000Z", + updated_time: "2026-07-28T00:05:00.000Z", + linkedSessions: [], + todos: [], + }; + + act(() => onWorkItemUpdated?.(updatedWorkItem)); + + const updatedItems = componentProps.list?.items as AssignedWorkItem[]; + expect(updatedItems[0].payload).toMatchObject({ + title: "Updated title", + status: "in_review", + priority: "high", + assigneeMemberId: "member-1", + assigneeName: "Yuki", + summary: "## Updated summary", + updatedAt: "2026-07-28T00:05:00.000Z", + }); + + act(() => + onWorkItemUpdated?.({ + ...updatedWorkItem, + assignee: { id: "member-2", name: "Lin" }, + }) + ); + + expect(componentProps.list?.items).toEqual([]); + }); + + it("retries the backing source instead of rereading a failed snapshot", async () => { + const listPage = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce({ items: [], nextCursor: null }); + const refresh = vi.fn(async () => undefined); + + await act(async () => { + root.render( + createElement(TeamInboxView, { + dataSource: { listPage, refresh }, + }) + ); + await Promise.resolve(); + }); + + const action = componentProps.placeholder?.action as + | { onClick?: () => void } + | undefined; + expect(action?.onClick).toBeTypeOf("function"); + + await act(async () => { + action?.onClick?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(refresh).toHaveBeenCalledOnce(); + expect(listPage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/createWorkItemFromSession.test.ts b/src/modules/MainApp/TeamInbox/__tests__/createWorkItemFromSession.test.ts new file mode 100644 index 0000000000..9ea69839f3 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/createWorkItemFromSession.test.ts @@ -0,0 +1,656 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Session } from "@src/store/session"; + +import { + type CreateFromSessionDependencies, + createWorkItemFromSession, + linkedSessionSnapshot, + sessionHandoffDraft, + sessionWorkItemDescription, + sessionWorkItemHandoff, + sessionWorkItemTitle, + sessionWorkItemTodos, +} from "../createWorkItemFromSession"; + +const SESSION: Session = { + session_id: "session-1", + status: "completed", + created_at: "2026-07-28T00:00:00.000Z", + updated_at: "2026-07-28T01:00:00.000Z", + completed_at: "2026-07-28T01:00:00.000Z", + name: "Fix Team Inbox", + user_input: "Make Session drops create a Work Item.", + category: "rust_agent", + filesChanged: 3, + linesAdded: 42, + linesRemoved: 7, + touchedFiles: ["src/a.ts", "src/b.ts"], + totalTokens: 99, +}; + +function dependencies(): CreateFromSessionDependencies & { + create: ReturnType; + link: ReturnType; + readProjects: ReturnType; + readProjectWorkItems: ReturnType; + readStandaloneWorkItems: ReturnType; + updateProjectWorkItem: ReturnType; +} { + return { + create: vi.fn(async () => ({ + shortId: "WI-0001", + item: undefined, + })), + link: vi.fn(async () => ({ session_id: "session-1" })), + readProjects: vi.fn(async () => []), + readProjectWorkItems: vi.fn(async () => []), + readStandaloneWorkItems: vi.fn(async () => []), + updateProjectWorkItem: vi.fn(async () => undefined), + }; +} + +describe("Session to Work Item mapping", () => { + it("builds a bounded title and a linked Markdown snapshot", () => { + expect(sessionWorkItemTitle(SESSION, " Dragged title ")).toBe( + "Dragged title" + ); + expect(sessionWorkItemDescription(SESSION, "Dragged title")).toContain( + "[Dragged title](session://session-1)" + ); + expect(sessionWorkItemDescription(SESSION, "Dragged title")).toContain( + "3 files changed · +42 · −7" + ); + expect(linkedSessionSnapshot(SESSION)).toMatchObject({ + session_id: "session-1", + session_type: "native", + status: "completed", + total_tokens: 99, + }); + }); + + it("parses explicit Markdown checkboxes without inventing tasks", () => { + expect( + sessionWorkItemTodos({ + ...SESSION, + user_input: [ + "Ship this change", + "- [ ] Verify the drop target", + "- [x] Confirm the source link", + "- ordinary context", + ].join("\n"), + }) + ).toEqual([ + { + id: "session-2", + content: "Verify the drop target", + status: "pending", + }, + { + id: "session-3", + content: "Confirm the source link", + status: "completed", + }, + ]); + }); + + it("creates a standalone assigned Work Item with atomic provenance", async () => { + const deps = dependencies(); + const result = await createWorkItemFromSession( + { + session: SESSION, + title: "Dragged title", + assigneeMemberId: "member-1", + activeOrgId: "org-1", + }, + deps + ); + + expect(deps.create).toHaveBeenCalledWith( + expect.objectContaining({ + draft: expect.objectContaining({ + assigneeId: "member-1", + orgId: "org-1", + }), + linkedSessions: [expect.objectContaining({ session_id: "session-1" })], + }) + ); + expect(deps.link).not.toHaveBeenCalled(); + expect(result).toEqual({ + projectId: "", + workItemId: "WI-0001", + reused: false, + }); + }); + + it("builds a review draft without changing the Session", () => { + const draft = sessionHandoffDraft( + { + ...SESSION, + user_input: [ + "Investigate the failed sync.", + "- [ ] Add a regression test", + ].join("\n"), + }, + [ + { + id: "project-1", + slug: "platform", + name: "Platform", + sender: { id: "member-me", name: "Me", isCurrentUser: true }, + recipients: [ + { id: "member-me", name: "Me", isCurrentUser: true }, + { id: "member-lin", name: "Lin", isCurrentUser: false }, + ], + }, + ], + "Sync follow-up", + "platform" + ); + + expect(draft).toMatchObject({ + sessionId: "session-1", + title: "Sync follow-up", + sourceProjectSlug: "platform", + todoCount: 1, + projects: [ + { + slug: "platform", + sender: { id: "member-me" }, + recipients: [{ id: "member-me" }, { id: "member-lin" }], + }, + ], + }); + expect(SESSION.user_input).toBe("Make Session drops create a Work Item."); + }); + + it("persists a teammate handoff in the same initial Work Item write", async () => { + const deps = dependencies(); + await createWorkItemFromSession( + { + session: SESSION, + title: "Continue the investigation", + assigneeMemberId: "member-lin", + assigneeMemberName: "Lin", + senderMemberId: "member-me", + senderMemberName: "Me", + handoffNote: "The failing path is isolated.", + }, + deps + ); + + const createOptions = deps.create.mock.calls[0]?.[0]; + expect(createOptions).toMatchObject({ + createdByMemberId: "member-me", + draft: { assigneeId: "member-lin" }, + handoff: { + id: expect.stringMatching(/^session-handoff:session-1:member-lin:/), + status: "pending", + senderMemberId: "member-me", + senderName: "Me", + recipientMemberId: "member-lin", + recipientName: "Lin", + note: "The failing path is isolated.", + }, + }); + }); + + it("does not create a handoff state when assigning the draft to self", () => { + expect( + sessionWorkItemHandoff({ + session: SESSION, + assigneeMemberId: "member-me", + senderMemberId: "member-me", + }) + ).toBeUndefined(); + }); + + it("does not create a handoff between aliases of the current user", () => { + expect( + sessionWorkItemHandoff({ + session: SESSION, + assigneeMemberId: "member-alias", + senderMemberId: "member-me", + recipientIsCurrentUser: true, + }) + ).toBeUndefined(); + }); + + it("reuses a previously linked standalone item instead of duplicating it", async () => { + const deps = dependencies(); + deps.readStandaloneWorkItems.mockResolvedValue([ + { + filename: "WI-0042.md", + body: "", + frontmatter: { + id: "WI-0042", + short_id: "WI-0042", + title: "Existing", + status: "planned", + priority: "none", + labels: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + starred: false, + todos: [], + linked_sessions: [linkedSessionSnapshot(SESSION)], + }, + }, + ]); + + const result = await createWorkItemFromSession({ session: SESSION }, deps); + + expect(deps.create).not.toHaveBeenCalled(); + expect(result).toEqual({ + projectId: "", + workItemId: "WI-0042", + reused: true, + }); + }); + + it("reuses and repairs a project Work Item link after a partial failure", async () => { + const deps = dependencies(); + deps.readProjects.mockResolvedValue([ + { + slug: "inbox", + description: "", + meta: { + id: "project-1", + name: "Inbox", + org_id: "org-1", + status: "active", + priority: "none", + health: "no_updates", + members: [], + labels: [], + linked_repos: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + next_work_item_id: 2, + work_item_prefix: "INB", + work_item_prefix_custom: false, + }, + }, + ]); + deps.readProjectWorkItems.mockResolvedValue([ + { + filename: "INB-0001.md", + body: "", + frontmatter: { + id: "INB-0001", + short_id: "INB-0001", + title: "Existing", + project: "project-1", + status: "planned", + priority: "none", + labels: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + starred: false, + todos: [], + linked_sessions: [linkedSessionSnapshot(SESSION)], + }, + }, + ]); + + const result = await createWorkItemFromSession( + { + session: { ...SESSION, projectId: "project-1" }, + }, + deps + ); + + expect(deps.create).not.toHaveBeenCalled(); + expect(deps.updateProjectWorkItem).not.toHaveBeenCalled(); + expect(deps.link).toHaveBeenCalledWith({ + sessionId: "session-1", + projectSlug: "inbox", + workItemId: "INB-0001", + agentRole: "custom", + }); + expect(result).toEqual({ + projectId: "inbox", + workItemId: "INB-0001", + reused: true, + }); + }); + + it("applies a new teammate handoff when reusing a linked project item", async () => { + const deps = dependencies(); + deps.readProjects.mockResolvedValue([ + { + slug: "inbox", + description: "", + meta: { + id: "project-1", + name: "Inbox", + org_id: "org-1", + status: "active", + priority: "none", + health: "no_updates", + members: [], + labels: [], + linked_repos: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + next_work_item_id: 2, + work_item_prefix: "INB", + work_item_prefix_custom: false, + }, + }, + ]); + deps.readProjectWorkItems.mockResolvedValue([ + { + filename: "INB-0001.md", + body: "", + frontmatter: { + id: "INB-0001", + short_id: "INB-0001", + title: "Existing", + project: "project-1", + status: "planned", + priority: "none", + assignee: "member-me", + assignee_type: "member", + labels: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + starred: false, + todos: [], + linked_sessions: [linkedSessionSnapshot(SESSION)], + }, + }, + ]); + + await createWorkItemFromSession( + { + session: { ...SESSION, projectId: "project-1" }, + assigneeMemberId: "member-lin", + assigneeMemberName: "Lin", + senderMemberId: "member-me", + senderMemberName: "Me", + handoffNote: "Continue from the isolated failure.", + }, + deps + ); + + expect(deps.create).not.toHaveBeenCalled(); + expect(deps.updateProjectWorkItem).toHaveBeenCalledWith( + "inbox", + "INB-0001", + expect.objectContaining({ + assignee: "member-lin", + assigneeType: "member", + actor: { id: "member-me", name: "Me" }, + handoff: expect.objectContaining({ + id: expect.stringMatching(/^session-handoff:session-1:member-lin:/), + status: "pending", + recipientMemberId: "member-lin", + }), + }) + ); + }); + + it("starts a new handoff episode after a matching handoff was resolved", async () => { + const deps = dependencies(); + const acceptedAt = "2026-07-28T02:00:00.000Z"; + const acceptedHandoff = { + id: `session-handoff:session-1:member-lin:${SESSION.updated_at}`, + status: "accepted" as const, + senderMemberId: "member-me", + senderName: "Me", + recipientMemberId: "member-lin", + recipientName: "Lin", + requestedAt: SESSION.updated_at, + respondedAt: acceptedAt, + }; + deps.readProjects.mockResolvedValue([ + { + slug: "inbox", + description: "", + meta: { + id: "project-1", + name: "Inbox", + org_id: "org-1", + status: "active", + priority: "none", + health: "no_updates", + members: [], + labels: [], + linked_repos: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + next_work_item_id: 2, + work_item_prefix: "INB", + work_item_prefix_custom: false, + }, + }, + ]); + deps.readProjectWorkItems.mockResolvedValue([ + { + filename: "INB-0001.md", + body: "", + frontmatter: { + id: "INB-0001", + short_id: "INB-0001", + title: "Existing", + project: "project-1", + status: "planned", + priority: "none", + assignee: "member-lin", + assignee_type: "member", + labels: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + starred: false, + todos: [], + handoff: acceptedHandoff, + linked_sessions: [linkedSessionSnapshot(SESSION)], + }, + }, + ]); + + await createWorkItemFromSession( + { + session: { ...SESSION, projectId: "project-1" }, + assigneeMemberId: "member-lin", + assigneeMemberName: "Lin", + senderMemberId: "member-me", + senderMemberName: "Me", + }, + deps + ); + + expect(deps.updateProjectWorkItem).toHaveBeenCalledWith( + "inbox", + "INB-0001", + expect.objectContaining({ + handoff: expect.objectContaining({ + id: expect.stringMatching(/^session-handoff:session-1:member-lin:/), + status: "pending", + recipientMemberId: "member-lin", + }), + }) + ); + const nextHandoff = deps.updateProjectWorkItem.mock.calls[0]?.[2]?.handoff; + expect(nextHandoff?.id).not.toBe(acceptedHandoff.id); + }); + + it("preserves an equivalent pending handoff during idempotent retry", async () => { + const deps = dependencies(); + const pendingHandoff = { + id: `session-handoff:session-1:member-lin:${SESSION.updated_at}`, + status: "pending" as const, + senderMemberId: "member-me", + senderName: "Me", + recipientMemberId: "member-lin", + recipientName: "Lin", + requestedAt: SESSION.updated_at, + }; + deps.readProjects.mockResolvedValue([ + { + slug: "inbox", + description: "", + meta: { + id: "project-1", + name: "Inbox", + org_id: "org-1", + status: "active", + priority: "none", + health: "no_updates", + members: [], + labels: [], + linked_repos: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + next_work_item_id: 2, + work_item_prefix: "INB", + work_item_prefix_custom: false, + }, + }, + ]); + deps.readProjectWorkItems.mockResolvedValue([ + { + filename: "INB-0001.md", + body: "", + frontmatter: { + id: "INB-0001", + short_id: "INB-0001", + title: "Existing", + project: "project-1", + status: "planned", + priority: "none", + assignee: "member-lin", + assignee_type: "member", + labels: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + starred: false, + todos: [], + handoff: pendingHandoff, + linked_sessions: [linkedSessionSnapshot(SESSION)], + }, + }, + ]); + + await createWorkItemFromSession( + { + session: { ...SESSION, projectId: "project-1" }, + assigneeMemberId: "member-lin", + assigneeMemberName: "Lin", + senderMemberId: "member-me", + senderMemberName: "Me", + }, + deps + ); + + expect(deps.updateProjectWorkItem).not.toHaveBeenCalled(); + }); + + it("does not reuse a standalone Work Item id inside a selected project", async () => { + const deps = dependencies(); + deps.readProjects.mockResolvedValue([ + { + slug: "inbox", + description: "", + meta: { + id: "project-1", + name: "Inbox", + org_id: "org-1", + status: "active", + priority: "none", + health: "no_updates", + members: [], + labels: [], + linked_repos: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + next_work_item_id: 2, + work_item_prefix: "INB", + work_item_prefix_custom: false, + }, + }, + ]); + + const result = await createWorkItemFromSession( + { + session: { ...SESSION, workItemId: "STANDALONE-1" }, + selectedProjectSlug: "inbox", + assigneeMemberId: "member-lin", + }, + deps + ); + + expect(deps.create).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ workItemId: "WI-0001", reused: false }); + }); + + it("creates an unscoped Session handoff in the explicitly selected project", async () => { + const deps = dependencies(); + deps.readProjects.mockResolvedValue([ + { + slug: "inbox", + description: "", + meta: { + id: "project-1", + name: "Inbox", + org_id: "org-1", + status: "active", + priority: "none", + health: "no_updates", + members: [], + labels: [], + linked_repos: [], + created_at: SESSION.created_at, + updated_at: SESSION.updated_at, + next_work_item_id: 2, + work_item_prefix: "INB", + work_item_prefix_custom: false, + }, + }, + ]); + + const result = await createWorkItemFromSession( + { + session: SESSION, + selectedProjectSlug: "inbox", + assigneeMemberId: "member-lin", + }, + deps + ); + + expect(deps.create).toHaveBeenCalledWith( + expect.objectContaining({ + draft: expect.objectContaining({ + projectId: "project-1", + assigneeId: "member-lin", + }), + selectedProjectSlug: "inbox", + }) + ); + expect(deps.link).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + projectSlug: "inbox", + workItemId: "WI-0001", + }) + ); + expect(result.projectId).toBe("inbox"); + }); + + it("does not silently move a project Session into standalone scope", async () => { + const deps = dependencies(); + + await expect( + createWorkItemFromSession( + { + session: { ...SESSION, projectId: "missing-project" }, + }, + deps + ) + ).rejects.toThrow("Session project is no longer available"); + + expect(deps.create).not.toHaveBeenCalled(); + expect(deps.readStandaloneWorkItems).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts b/src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts new file mode 100644 index 0000000000..a789b612f5 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/cursor.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { toWireCursorItemId } from "../domain/cursor"; + +describe("toWireCursorItemId", () => { + it("preserves the backend source prefix so the cursor round-trips", () => { + // The backend returns this as the cursor item id and strips the + // `work_item_assigned:` prefix itself; dropping it here would break paging. + expect(toWireCursorItemId("work_item_assigned:work-1")).toBe( + "work_item_assigned:work-1" + ); + }); + + it("strips only the UI kind prefix when a UI item key is passed", () => { + expect( + toWireCursorItemId("assigned_work_item:work_item_assigned:work-1") + ).toBe("work_item_assigned:work-1"); + }); + + it("leaves an unprefixed id untouched", () => { + expect(toWireCursorItemId("work-1")).toBe("work-1"); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts new file mode 100644 index 0000000000..988f187a47 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/labels.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + humanizeToken, + isGitHubIssueStatus, + workItemPriorityLabelKey, + workItemStatusLabelKey, +} from "../domain/labels"; + +describe("isGitHubIssueStatus", () => { + it("recognizes the GitHub issue status vocabulary", () => { + expect(isGitHubIssueStatus("open")).toBe(true); + expect(isGitHubIssueStatus("closed")).toBe(true); + }); + + it("rejects local Work Item statuses", () => { + expect(isGitHubIssueStatus("todo")).toBe(false); + expect(isGitHubIssueStatus("in_progress")).toBe(false); + expect(isGitHubIssueStatus("completed")).toBe(false); + expect(isGitHubIssueStatus("")).toBe(false); + }); +}); + +describe("humanizeToken", () => { + it("sentence-cases a snake_case enum token", () => { + expect(humanizeToken("in_progress")).toBe("In progress"); + }); + + it("normalizes dashes and mixed casing", () => { + expect(humanizeToken("IN-REVIEW")).toBe("In review"); + }); + + it("capitalizes a single word", () => { + expect(humanizeToken("high")).toBe("High"); + }); + + it("collapses repeated separators and surrounding whitespace", () => { + expect(humanizeToken(" to__do ")).toBe("To do"); + }); + + it("returns an empty string for empty or whitespace input", () => { + expect(humanizeToken("")).toBe(""); + expect(humanizeToken(" ")).toBe(""); + }); +}); + +describe("label key builders", () => { + it("namespaces status keys under teamInbox.workItemStatus", () => { + expect(workItemStatusLabelKey("in_progress")).toBe( + "teamInbox.workItemStatus.in_progress" + ); + }); + + it("namespaces priority keys under teamInbox.priority", () => { + expect(workItemPriorityLabelKey("high")).toBe("teamInbox.priority.high"); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts b/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts new file mode 100644 index 0000000000..725e817756 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/selectors.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; + +import { + countUnreadTeamInboxItems, + countUnreadTeamInboxItemsByFilter, + dedupeTeamInboxItems, + filterItemKind, + filterTeamInboxItems, + getTeamInboxItemKey, + groupTeamInboxItemsByRecency, + searchTeamInboxItems, + selectTeamInboxItems, + sortTeamInboxItems, + toTeamInboxNavigationIntent, +} from "../domain/selectors"; +import type { + AssignedWorkItem, + CommentMentionItem, + TeamInboxItem, +} from "../domain/types"; + +const mention = ( + overrides: Partial = {} +): CommentMentionItem => ({ + id: "comment-1", + kind: "comment_mention", + occurredAt: "2026-07-23T09:00:00.000Z", + readAt: null, + actor: { id: "member-1", displayName: "Ada" }, + target: { + kind: "session_comment", + sessionId: "session-1", + sessionTitle: "Fix canvas preview", + commentId: "comment-1", + threadId: "thread-1", + anchor: "comment-comment-1", + }, + payload: { + commentBody: "@you Can you review this?", + context: "The first pass is ready.", + commentCount: 3, + }, + ...overrides, +}); + +const assignment = ( + overrides: Partial = {} +): AssignedWorkItem => ({ + id: "work-item-1", + kind: "assigned_work_item", + occurredAt: "2026-07-23T10:00:00.000Z", + readAt: "2026-07-23T10:05:00.000Z", + actor: { id: "member-2", displayName: "Lin" }, + target: { + kind: "work_item", + projectId: "project-1", + workItemId: "work-item-1", + }, + payload: { + title: "Add Team Inbox", + status: "in_progress", + priority: "high", + assigneeMemberId: "member-2", + assigneeName: "You", + summary: "Build the reusable feature surface.", + updatedAt: "2026-07-23T10:00:00.000Z", + }, + ...overrides, +}); + +describe("Team Inbox selectors", () => { + it("builds identity from kind and canonical id", () => { + expect(getTeamInboxItemKey(mention())).toBe("comment_mention:comment-1"); + }); + + it("dedupes repeated pages and keeps the freshest copy", () => { + const oldCopy = mention({ + occurredAt: "2026-07-23T08:00:00.000Z", + payload: { + commentBody: "old", + commentCount: 1, + }, + }); + const freshCopy = mention({ + occurredAt: "2026-07-23T11:00:00.000Z", + payload: { + commentBody: "fresh", + commentCount: 2, + }, + }); + + expect(dedupeTeamInboxItems([oldCopy, freshCopy])).toEqual([freshCopy]); + }); + + it("sorts newest first with a deterministic identity tie-breaker", () => { + const sameTime = "2026-07-23T10:00:00.000Z"; + const items: TeamInboxItem[] = [ + assignment({ id: "z", occurredAt: sameTime }), + mention({ id: "a", occurredAt: sameTime }), + mention({ id: "older", occurredAt: "2026-07-22T10:00:00.000Z" }), + ]; + + expect(sortTeamInboxItems(items).map(getTeamInboxItemKey)).toEqual([ + "assigned_work_item:z", + "comment_mention:a", + "comment_mention:older", + ]); + }); + + it("filters mentions and assignments without mutating the input", () => { + const items = [mention(), assignment()]; + + expect(filterTeamInboxItems(items, "mentions")).toEqual([items[0]]); + expect(filterTeamInboxItems(items, "assigned")).toEqual([items[1]]); + expect(filterTeamInboxItems(items, "all")).not.toBe(items); + }); + + it("dedupes, sorts, then filters through the composed selector", () => { + const duplicate = mention({ readAt: "2026-07-23T09:10:00.000Z" }); + expect( + selectTeamInboxItems([mention(), assignment(), duplicate], "all") + ).toEqual([assignment(), mention()]); + }); + + it("counts unread canonical items only once", () => { + expect( + countUnreadTeamInboxItems([mention(), mention(), assignment()]) + ).toBe(1); + }); + + it("splits unread counts per filter and de-duplicates first", () => { + const unreadAssignment = assignment({ id: "unread", readAt: null }); + expect( + countUnreadTeamInboxItemsByFilter([ + mention(), + mention(), + assignment(), + unreadAssignment, + ]) + ).toEqual({ all: 2, mentions: 1, assigned: 1 }); + }); + + it("returns zeroed counts for an empty inbox", () => { + expect(countUnreadTeamInboxItemsByFilter([])).toEqual({ + all: 0, + mentions: 0, + assigned: 0, + }); + }); + + it("maps filters to the item kind they expose", () => { + expect(filterItemKind("all")).toBeNull(); + expect(filterItemKind("mentions")).toBe("comment_mention"); + expect(filterItemKind("assigned")).toBe("assigned_work_item"); + }); + + it("maps both targets to typed navigation intents", () => { + expect(toTeamInboxNavigationIntent(mention())).toEqual({ + kind: "open_session_comment", + sessionId: "session-1", + commentId: "comment-1", + threadId: "thread-1", + anchor: "comment-comment-1", + }); + expect(toTeamInboxNavigationIntent(assignment())).toEqual({ + kind: "open_work_item", + projectId: "project-1", + workItemId: "work-item-1", + }); + }); + + it("returns a fresh copy of all items for an empty query", () => { + const items = [mention(), assignment()]; + expect(searchTeamInboxItems(items, "")).toEqual(items); + expect(searchTeamInboxItems(items, " ")).toEqual(items); + expect(searchTeamInboxItems(items, "")).not.toBe(items); + }); + + it("matches case-insensitively across title, body, summary and people", () => { + const items = [mention(), assignment()]; + expect( + searchTeamInboxItems(items, "CANVAS").map(getTeamInboxItemKey) + ).toEqual(["comment_mention:comment-1"]); + expect( + searchTeamInboxItems(items, "team inbox").map(getTeamInboxItemKey) + ).toEqual(["assigned_work_item:work-item-1"]); + expect( + searchTeamInboxItems(items, "review").map(getTeamInboxItemKey) + ).toEqual(["comment_mention:comment-1"]); + expect( + searchTeamInboxItems(items, "reusable feature").map(getTeamInboxItemKey) + ).toEqual(["assigned_work_item:work-item-1"]); + }); + + it("returns no items when nothing matches", () => { + expect(searchTeamInboxItems([mention(), assignment()], "zzzz")).toEqual([]); + }); + + it("buckets items into ordered recency groups relative to now", () => { + const now = Date.parse("2026-07-24T12:00:00.000Z"); + const DAY = 86_400_000; + const at = (offsetMs: number) => new Date(now - offsetMs).toISOString(); + const items = [ + mention({ id: "today", occurredAt: at(0) }), + assignment({ id: "yesterday", occurredAt: at(DAY) }), + mention({ id: "week", occurredAt: at(3 * DAY) }), + assignment({ id: "old", occurredAt: at(30 * DAY) }), + mention({ id: "bad", occurredAt: "not-a-date" }), + ]; + + const groups = groupTeamInboxItemsByRecency(items, now); + expect(groups.map((group) => group.key)).toEqual([ + "today", + "yesterday", + "thisWeek", + "earlier", + ]); + expect(groups[3]!.items.map((item) => item.id)).toEqual(["old", "bad"]); + }); + + it("omits empty recency groups and keeps input order within a group", () => { + const now = Date.parse("2026-07-24T12:00:00.000Z"); + const at = (offsetMs: number) => new Date(now - offsetMs).toISOString(); + const groups = groupTeamInboxItemsByRecency( + [ + mention({ id: "a", occurredAt: at(0) }), + mention({ id: "b", occurredAt: at(1000) }), + ], + now + ); + expect(groups.map((group) => group.key)).toEqual(["today"]); + expect(groups[0]!.items.map((item) => item.id)).toEqual(["a", "b"]); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffForm.test.ts b/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffForm.test.ts new file mode 100644 index 0000000000..7b2d813bda --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffForm.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import type { TeamInboxSessionHandoffDraft } from "../domain"; +import { + createSessionHandoffForm, + isTeamHandoff, + normalizedSessionHandoffForm, + sessionHandoffFormError, + sessionHandoffFormForProject, +} from "../sessionHandoffForm"; + +function draft( + overrides: Partial = {} +): TeamInboxSessionHandoffDraft { + return { + sessionId: "session-1", + title: "Investigate sync", + sourceProjectSlug: "project-alpha", + projects: [ + { + id: "project-1", + slug: "project-alpha", + name: "Project Alpha", + sender: { + id: "member-me", + name: "Me", + isCurrentUser: true, + }, + recipients: [ + { id: "member-lin", name: "Lin", isCurrentUser: false }, + { id: "member-me", name: "Me", isCurrentUser: true }, + ], + }, + ], + todoCount: 2, + ...overrides, + }; +} + +describe("sessionHandoffForm", () => { + it("defaults to the current user to prevent accidental handoff", () => { + expect(createSessionHandoffForm(draft())).toEqual({ + title: "Investigate sync", + projectSlug: "project-alpha", + assigneeMemberId: "member-me", + note: "", + }); + }); + + it("distinguishes self creation from a team handoff", () => { + const model = draft(); + expect(isTeamHandoff(createSessionHandoffForm(model), model)).toBe(false); + expect( + isTeamHandoff( + { + title: model.title, + projectSlug: "project-alpha", + assigneeMemberId: "member-lin", + note: "", + }, + model + ) + ).toBe(true); + }); + + it("treats another current-user alias as self assignment", () => { + const model = draft({ + projects: [ + { + id: "project-1", + slug: "project-alpha", + name: "Project Alpha", + sender: { + id: "member-me", + name: "Me", + isCurrentUser: true, + }, + recipients: [ + { id: "member-me", name: "Me", isCurrentUser: true }, + { + id: "member-alias", + name: "Me (work)", + isCurrentUser: true, + }, + { id: "member-lin", name: "Lin", isCurrentUser: false }, + ], + }, + ], + }); + expect( + isTeamHandoff( + { + title: model.title, + projectSlug: "project-alpha", + assigneeMemberId: "member-alias", + note: "", + }, + model + ) + ).toBe(false); + }); + + it("rejects blank titles and stale recipients", () => { + const model = draft(); + expect( + sessionHandoffFormError( + { + title: " ", + projectSlug: "project-alpha", + assigneeMemberId: "member-me", + note: "", + }, + model + ) + ).toBe("title_required"); + expect( + sessionHandoffFormError( + { + title: "Valid", + projectSlug: "project-alpha", + assigneeMemberId: "removed", + note: "", + }, + model + ) + ).toBe("recipient_unavailable"); + }); + + it("requires an explicit destination when an unscoped Session has multiple projects", () => { + const model = draft({ + sourceProjectSlug: undefined, + projects: [ + ...draft().projects, + { + id: "project-2", + slug: "project-beta", + name: "Project Beta", + sender: { + id: "member-me-beta", + name: "Me", + isCurrentUser: true, + }, + recipients: [ + { id: "member-me-beta", name: "Me", isCurrentUser: true }, + { id: "member-zoe", name: "Zoe", isCurrentUser: false }, + ], + }, + ], + }); + const form = createSessionHandoffForm(model); + expect(form.projectSlug).toBe(""); + expect(sessionHandoffFormError(form, model)).toBe("project_required"); + + expect( + sessionHandoffFormForProject(form, "project-beta", model) + ).toMatchObject({ + projectSlug: "project-beta", + assigneeMemberId: "member-me-beta", + }); + }); + + it("trims submission values and bounds the optional note", () => { + const normalized = normalizedSessionHandoffForm({ + title: " Follow up ", + projectSlug: "project-alpha", + assigneeMemberId: "member-lin", + note: ` ${"x".repeat(1_100)} `, + }); + expect(normalized.title).toBe("Follow up"); + expect(normalized.note).toHaveLength(1_000); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffProjects.test.ts b/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffProjects.test.ts new file mode 100644 index 0000000000..89821ceb5a --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/sessionHandoffProjects.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import type { MemberEntry, ProjectData } from "@src/api/http/project"; + +import { + eligibleSessionHandoffProjects, + handoffProjectFromRoster, +} from "../sessionHandoffProjects"; + +function project(slug: string, name: string, orgId = "org-1"): ProjectData { + return { + slug, + description: "", + meta: { + id: `id-${slug}`, + name, + org_id: orgId, + status: "active", + priority: "none", + health: "no_updates", + members: [], + labels: [], + linked_repos: [], + created_at: "2026-07-28T00:00:00Z", + updated_at: "2026-07-28T00:00:00Z", + next_work_item_id: 1, + work_item_prefix: "TST", + work_item_prefix_custom: false, + }, + }; +} + +function member(id: string, name: string, active = true): MemberEntry { + return { id, name, active }; +} + +describe("Session handoff project resolution", () => { + it("uses the matching project-local alias as sender", () => { + const resolved = handoffProjectFromRoster( + project("alpha", "Alpha"), + [ + member("me-work", "Me"), + member("teammate", "Lin"), + member("inactive", "Former teammate", false), + ], + ["me-personal", "me-work"] + ); + + expect(resolved).toMatchObject({ + slug: "alpha", + sender: { id: "me-work", isCurrentUser: true }, + recipients: [ + { id: "me-work", isCurrentUser: true }, + { id: "teammate", isCurrentUser: false }, + ], + }); + }); + + it("keeps every project where the viewer is a member across sidebar scopes", () => { + const projects = eligibleSessionHandoffProjects( + [ + { + project: project("beta", "Beta", "org-1"), + members: [member("me", "Me")], + }, + { + project: project("alpha", "Alpha", "org-1"), + members: [member("me", "Me"), member("lin", "Lin")], + }, + { + project: project("other-org", "Other", "org-2"), + members: [member("me", "Me")], + }, + { + project: project("not-a-member", "Hidden", "org-1"), + members: [member("lin", "Lin")], + }, + ], + ["me"] + ); + + expect(projects.map((candidate) => candidate.slug)).toEqual([ + "alpha", + "beta", + "other-org", + ]); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/sharedOperation.test.ts b/src/modules/MainApp/TeamInbox/__tests__/sharedOperation.test.ts new file mode 100644 index 0000000000..4a9e1a5c6f --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/sharedOperation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { observeSharedOperation } from "../sharedOperation"; + +describe("observeSharedOperation", () => { + it("cancels one observer without cancelling equivalent consumers", async () => { + let finish: ((value: string) => void) | undefined; + const shared = new Promise((resolve) => { + finish = resolve; + }); + const controller = new AbortController(); + const cancelled = observeSharedOperation(shared, controller.signal); + const active = observeSharedOperation(shared); + + controller.abort(); + finish?.("created"); + + await expect(cancelled).rejects.toMatchObject({ name: "AbortError" }); + await expect(active).resolves.toBe("created"); + }); + + it("rejects immediately when the caller is already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + observeSharedOperation(Promise.resolve("unused"), controller.signal) + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts new file mode 100644 index 0000000000..4e0bd67011 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/teamInboxCoordinator.test.ts @@ -0,0 +1,385 @@ +import { createStore } from "jotai"; +import { describe, expect, it, vi } from "vitest"; + +import type { TeamInboxMention } from "@src/features/Org2Cloud/teamInboxMentionsClient"; + +import type { AssignedWorkItem } from "../domain"; +import { teamInboxCacheAtom } from "../store"; +import { + TEAM_INBOX_CACHE_LIMIT, + TeamInboxCoordinator, + type TeamInboxCoordinatorDependencies, + type TeamInboxCoordinatorScope, +} from "../teamInboxCoordinator"; + +function assignedItem( + id: string, + occurredAt = "2026-07-28T10:00:00.000Z" +): AssignedWorkItem { + return { + id, + kind: "assigned_work_item", + occurredAt, + readAt: null, + actor: { id: "assigner", displayName: "Assigner" }, + target: { + kind: "work_item", + projectId: "project-1", + workItemId: id, + }, + payload: { + title: id, + status: "todo", + priority: "medium", + assigneeMemberId: "viewer-1", + updatedAt: occurredAt, + }, + }; +} + +function mention(id: string): TeamInboxMention { + return { + comment: { id }, + session: { id: `session-${id}` }, + author: { userId: "author-1" }, + body: `Mention ${id}`, + createdAt: "2026-07-28T11:00:00.000Z", + readAt: null, + commentCount: 1, + threadCount: 1, + }; +} + +function dependencies( + overrides: Partial = {} +): TeamInboxCoordinatorDependencies { + return { + listLocalPage: vi.fn(async () => ({ + page: { items: [], nextCursor: null }, + unreadCount: 0, + })), + listInitialMentions: vi.fn(async () => ({ + mentions: [], + unreadCount: 0, + })), + listMentions: vi.fn(async () => ({ + mentions: [], + unreadCount: 0, + })), + markLocalRead: vi.fn(async () => true), + markLocalUnread: vi.fn(async () => true), + markAllLocalRead: vi.fn(async () => 0), + setMentionRead: vi.fn(async () => ({ + readAt: "2026-07-28T12:00:00.000Z", + unreadCount: 0, + })), + markAllMentionsRead: vi.fn(async () => ({ + readAt: "2026-07-28T12:00:00.000Z", + unreadCount: 0, + })), + now: () => "2026-07-28T12:00:00.000Z", + ...overrides, + }; +} + +function scope( + overrides: Partial = {} +): TeamInboxCoordinatorScope { + return { + key: "viewer-1::local", + viewerMemberIds: ["viewer-1"], + accessToken: null, + activeCloudOrgId: null, + members: [], + ...overrides, + }; +} + +describe("TeamInboxCoordinator", () => { + it("shares the first-page cursor across consumers using the same store", async () => { + const firstCursor = { + occurredAt: "2026-07-28T10:00:00.000Z", + itemKey: "assigned_work_item:first", + }; + const listLocalPage = vi + .fn() + .mockResolvedValueOnce({ + page: { items: [assignedItem("first")], nextCursor: firstCursor }, + unreadCount: 2, + }) + .mockResolvedValueOnce({ + page: { + items: [assignedItem("second", "2026-07-28T09:00:00.000Z")], + nextCursor: null, + }, + unreadCount: 2, + }); + const coordinator = new TeamInboxCoordinator( + dependencies({ listLocalPage }) + ); + const store = createStore(); + const viewerScope = scope(); + + await coordinator.refresh(store, viewerScope, "version-1"); + await coordinator.loadMore(store, viewerScope); + + expect(listLocalPage).toHaveBeenNthCalledWith( + 2, + ["viewer-1"], + "all", + firstCursor + ); + expect(store.get(teamInboxCacheAtom).items.map((item) => item.id)).toEqual([ + "first", + "second", + ]); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(false); + }); + + it("publishes a usable partial snapshot when one source fails", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async () => ({ + page: { items: [assignedItem("local")], nextCursor: null }, + unreadCount: 1, + })), + listInitialMentions: vi.fn(async () => { + throw new Error("cloud unavailable"); + }), + }) + ); + const store = createStore(); + + await coordinator.refresh( + store, + scope({ + key: "viewer-1::org-1", + accessToken: "token", + activeCloudOrgId: "org-1", + }), + "version-1" + ); + + expect(store.get(teamInboxCacheAtom)).toMatchObject({ + unreadCount: 1, + unreadCounts: { all: 1, assigned: 1, mentions: 0 }, + issue: { code: "partial_load", detail: "cloud unavailable" }, + }); + expect(store.get(teamInboxCacheAtom).items).toHaveLength(1); + }); + + it("keeps cloud results visible while reporting an unresolved local identity", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listInitialMentions: vi.fn(async () => ({ + mentions: [mention("cloud-1")], + unreadCount: 1, + })), + }) + ); + const store = createStore(); + + await coordinator.refresh( + store, + scope({ + key: "::org-1", + viewerMemberIds: [], + accessToken: "token", + activeCloudOrgId: "org-1", + members: [ + { + id: "someone-else", + name: "Someone Else", + email: "else@example.com", + active: true, + }, + ], + }), + "version-1" + ); + + expect(store.get(teamInboxCacheAtom).issue?.code).toBe( + "identity_unresolved" + ); + expect(store.get(teamInboxCacheAtom).items).toHaveLength(1); + }); + + it("keeps a failed source cursor retryable while appending a successful page", async () => { + const localCursor = { + occurredAt: "2026-07-28T10:00:00.000Z", + itemKey: "assigned_work_item:first", + }; + const listLocalPage = vi + .fn() + .mockResolvedValueOnce({ + page: { items: [assignedItem("first")], nextCursor: localCursor }, + unreadCount: 2, + }) + .mockResolvedValueOnce({ + page: { + items: [assignedItem("second", "2026-07-28T09:00:00.000Z")], + nextCursor: null, + }, + unreadCount: 2, + }); + const listMentions = vi + .fn() + .mockRejectedValueOnce(new Error("temporary cloud failure")) + .mockResolvedValueOnce({ + mentions: [mention("cloud-2")], + unreadCount: 2, + }); + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage, + listInitialMentions: vi.fn(async () => ({ + mentions: [mention("cloud-1")], + nextCursor: "cloud-cursor", + unreadCount: 2, + })), + listMentions, + }) + ); + const store = createStore(); + const viewerScope = scope({ + key: "viewer-1::org-1", + accessToken: "token", + activeCloudOrgId: "org-1", + }); + + await coordinator.refresh(store, viewerScope, "version-1"); + await coordinator.loadMore(store, viewerScope); + + expect(store.get(teamInboxCacheAtom).issue?.code).toBe("partial_load"); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(true); + expect( + store.get(teamInboxCacheAtom).items.map((item) => item.id) + ).toContain("second"); + + await coordinator.loadMore(store, viewerScope); + + expect(listMentions).toHaveBeenNthCalledWith( + 2, + "token", + "org-1", + "cloud-cursor", + 50, + expect.any(AbortSignal) + ); + expect( + store.get(teamInboxCacheAtom).items.map((item) => item.id) + ).toContain("cloud-comment:org-1:cloud-2"); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(false); + }); + + it("ignores a late response after the viewer scope changes", async () => { + let resolveOldCloud: + | ((value: { mentions: TeamInboxMention[]; unreadCount: number }) => void) + | undefined; + const oldCloud = new Promise<{ + mentions: TeamInboxMention[]; + unreadCount: number; + }>((resolve) => { + resolveOldCloud = resolve; + }); + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async (viewerIds) => ({ + page: { + items: [assignedItem(viewerIds[0] ?? "unknown")], + nextCursor: null, + }, + unreadCount: 1, + })), + listInitialMentions: vi + .fn() + .mockImplementationOnce(async () => oldCloud) + .mockResolvedValueOnce({ mentions: [], unreadCount: 0 }), + }) + ); + const store = createStore(); + const oldScope = scope({ + key: "viewer-1::org-1", + accessToken: "token", + activeCloudOrgId: "org-1", + }); + const nextScope = scope({ + key: "viewer-2::org-2", + viewerMemberIds: ["viewer-2"], + accessToken: "token", + activeCloudOrgId: "org-2", + }); + + const staleRefresh = coordinator.refresh(store, oldScope, "version-1"); + await coordinator.refresh(store, nextScope, "version-1"); + resolveOldCloud?.({ mentions: [mention("stale")], unreadCount: 1 }); + await staleRefresh; + + expect(store.get(teamInboxCacheAtom).loadedForViewerKey).toBe( + "viewer-2::org-2" + ); + expect(store.get(teamInboxCacheAtom).items.map((item) => item.id)).toEqual([ + "viewer-2", + ]); + }); + + it("rolls back an optimistic read mutation when persistence fails", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async () => ({ + page: { items: [assignedItem("first")], nextCursor: null }, + unreadCount: 1, + })), + markLocalRead: vi.fn(async () => { + throw new Error("write failed"); + }), + }) + ); + const store = createStore(); + const viewerScope = scope(); + await coordinator.refresh(store, viewerScope, "version-1"); + const item = store.get(teamInboxCacheAtom).items[0]; + + const mutation = coordinator.markRead(store, viewerScope, item); + expect(store.get(teamInboxCacheAtom).items[0].readAt).toBe( + "2026-07-28T12:00:00.000Z" + ); + expect(store.get(teamInboxCacheAtom).unreadCount).toBe(0); + + await expect(mutation).rejects.toThrow("write failed"); + expect(store.get(teamInboxCacheAtom).items[0].readAt).toBeNull(); + expect(store.get(teamInboxCacheAtom).unreadCount).toBe(1); + }); + + it("caps retained rows and closes cursors at the cache boundary", async () => { + const coordinator = new TeamInboxCoordinator( + dependencies({ + listLocalPage: vi.fn(async () => ({ + page: { + items: Array.from( + { length: TEAM_INBOX_CACHE_LIMIT + 25 }, + (_, index) => + assignedItem( + `item-${index}`, + new Date(Date.UTC(2026, 6, 28, 12, 0, index)).toISOString() + ) + ), + nextCursor: { + occurredAt: "2026-07-28T00:00:00.000Z", + itemKey: "more", + }, + }, + unreadCount: TEAM_INBOX_CACHE_LIMIT + 25, + })), + }) + ); + const store = createStore(); + + await coordinator.refresh(store, scope(), "version-1"); + + expect(store.get(teamInboxCacheAtom).items).toHaveLength( + TEAM_INBOX_CACHE_LIMIT + ); + expect(store.get(teamInboxCacheAtom).hasMore).toBe(false); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts new file mode 100644 index 0000000000..6f1bbe50dd --- /dev/null +++ b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { WorkItem } from "@src/types/core/workItem"; + +import { + type TeamInboxWorkItemState, + useTeamInboxWorkItem, +} from "../useTeamInboxWorkItem"; + +const mocks = vi.hoisted(() => ({ + readWorkItem: vi.fn(), + readProject: vi.fn(), + readMembers: vi.fn(), + readStandaloneWorkItem: vi.fn(), + updateWorkItemPartial: vi.fn(), +})); + +vi.mock("@src/api/http/project", () => ({ + projectApi: { + readWorkItem: mocks.readWorkItem, + readProject: mocks.readProject, + readMembers: mocks.readMembers, + readStandaloneWorkItem: mocks.readStandaloneWorkItem, + updateWorkItemPartial: mocks.updateWorkItemPartial, + }, + standaloneWorkItemDataToEnriched: (value: unknown) => value, + enrichedWorkItemToUI: (value: unknown) => value, +})); + +vi.mock("@src/hooks/project/useCurrentUserMemberId", () => ({ + useCurrentUserMemberIds: () => ({ currentUser: null }), +})); + +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ warn: vi.fn() }), +})); + +vi.mock("@src/modules/ProjectManager/WorkItems/workItemPartialUpdate", () => ({ + toWorkItemPartialUpdate: (value: unknown) => value, +})); + +const WORK_ITEM: WorkItem = { + session_id: "AAA-0001", + user_id: "member-1", + name: "Inbox item", + status: "planned", + workItemStatus: "planned", + priority: "medium", + spec: "Body", + assignee: { id: "member-1", name: "Ada" }, + star: false, + target_date: null, + created_time: "2026-07-28T00:00:00.000Z", + updated_time: "2026-07-28T00:00:00.000Z", + linkedSessions: [], + todos: [], +}; + +let latestState: TeamInboxWorkItemState | null = null; + +function Probe() { + const state = useTeamInboxWorkItem({ + kind: "work_item", + projectId: "demo", + workItemId: "AAA-0001", + }); + useEffect(() => { + latestState = state; + }, [state]); + return null; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +describe("useTeamInboxWorkItem", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + latestState = null; + vi.clearAllMocks(); + mocks.readWorkItem.mockResolvedValue(WORK_ITEM); + mocks.readProject.mockResolvedValue({ + slug: "demo", + meta: { name: "Demo", linked_repos: [] }, + }); + mocks.readMembers.mockResolvedValue({ members: [] }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps the Work Item usable when optional project context fails", async () => { + mocks.readMembers.mockRejectedValueOnce(new Error("members unavailable")); + + await act(async () => { + root.render(createElement(Probe)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(latestState).toMatchObject({ + status: "ready", + workItem: WORK_ITEM, + members: [], + issue: "context_unavailable", + }); + }); + + it("uses the blocking state only when the required Work Item read fails", async () => { + mocks.readWorkItem.mockRejectedValueOnce(new Error("item unavailable")); + + await act(async () => { + root.render(createElement(Probe)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(latestState).toMatchObject({ + status: "error", + workItem: null, + issue: "load_failed", + }); + expect(mocks.readProject).not.toHaveBeenCalled(); + expect(mocks.readMembers).not.toHaveBeenCalled(); + }); + + it("serializes same-item updates so response order follows user intent", async () => { + const first = deferred(); + const second = deferred(); + mocks.updateWorkItemPartial + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + await act(async () => { + root.render(createElement(Probe)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + act(() => { + latestState?.updateWorkItem({ workItemStatus: "in_review" }); + latestState?.updateWorkItem({ priority: "high" }); + }); + await Promise.resolve(); + expect(mocks.updateWorkItemPartial).toHaveBeenCalledTimes(1); + + await act(async () => { + first.resolve({ + ...WORK_ITEM, + status: "in_review", + workItemStatus: "in_review", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.updateWorkItemPartial).toHaveBeenCalledTimes(2); + + await act(async () => { + second.resolve({ + ...WORK_ITEM, + status: "in_review", + workItemStatus: "in_review", + priority: "high", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(latestState?.workItem).toMatchObject({ + workItemStatus: "in_review", + priority: "high", + }); + }); +}); diff --git a/src/modules/MainApp/TeamInbox/api.ts b/src/modules/MainApp/TeamInbox/api.ts new file mode 100644 index 0000000000..2e03cd3f01 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/api.ts @@ -0,0 +1,205 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { WorkItemHandoff } from "@src/api/http/project"; + +import { toWireCursorItemId } from "./domain"; +import type { + TeamInboxCursor, + TeamInboxFilter, + TeamInboxItem, + TeamInboxPage, +} from "./domain"; + +interface TeamInboxWireCursor { + occurredAt: number; + itemId: string; +} + +interface TeamInboxWireActor { + id: string; + displayName: string; + avatarUrl?: string; +} + +type TeamInboxWireTarget = + | { + type: "comment"; + sessionId: string; + commentId: string; + anchor?: string; + } + | { + type: "work_item"; + workItemId: string; + shortId: string; + orgId: string; + projectId?: string; + projectSlug?: string; + }; + +type TeamInboxWirePayload = + | { + type: "comment_mention"; + sessionTitle: string; + commentExcerpt: string; + commentCount: number; + } + | { + type: "work_item_assigned"; + title: string; + status: string; + priority: string; + assigneeMemberId: string; + summary?: string; + handoff?: WorkItemHandoff; + }; + +interface TeamInboxWireItem { + id: string; + kind: "comment_mention" | "work_item_assigned"; + occurredAt: number; + readAt?: number; + actor?: TeamInboxWireActor; + target: TeamInboxWireTarget; + payload: TeamInboxWirePayload; +} + +interface TeamInboxWirePage { + items: TeamInboxWireItem[]; + nextCursor?: TeamInboxWireCursor; + unreadCount: number; +} + +function toIso(timestamp: number | undefined): string | null { + return timestamp === undefined ? null : new Date(timestamp).toISOString(); +} + +function mapWireItem(item: TeamInboxWireItem): TeamInboxItem { + const occurredAt = new Date(item.occurredAt).toISOString(); + const actor = item.actor ?? { + id: "system", + displayName: "", + }; + + if ( + item.kind === "comment_mention" && + item.target.type === "comment" && + item.payload.type === "comment_mention" + ) { + return { + id: item.id, + kind: "comment_mention", + occurredAt, + readAt: toIso(item.readAt), + actor, + target: { + kind: "session_comment", + sessionId: item.target.sessionId, + sessionTitle: item.payload.sessionTitle, + commentId: item.target.commentId, + threadId: item.target.commentId, + anchor: item.target.anchor, + }, + payload: { + commentBody: item.payload.commentExcerpt, + commentCount: item.payload.commentCount, + }, + }; + } + + if ( + item.kind === "work_item_assigned" && + item.target.type === "work_item" && + item.payload.type === "work_item_assigned" + ) { + return { + id: item.id, + kind: "assigned_work_item", + occurredAt, + readAt: toIso(item.readAt), + actor, + target: { + kind: "work_item", + projectId: item.target.projectSlug ?? item.target.projectId ?? "", + workItemId: item.target.shortId, + }, + payload: { + title: item.payload.title, + status: item.payload.status, + priority: item.payload.priority, + assigneeMemberId: item.payload.assigneeMemberId, + summary: item.payload.summary, + updatedAt: occurredAt, + handoff: item.payload.handoff, + }, + }; + } + + throw new Error(`Unsupported Team Inbox wire item: ${item.id}`); +} + +function toWireCursor( + cursor?: TeamInboxCursor | null +): TeamInboxWireCursor | null { + if (!cursor) return null; + return { + occurredAt: Date.parse(cursor.occurredAt), + itemId: toWireCursorItemId(cursor.itemKey), + }; +} + +export async function listLocalTeamInboxPage( + viewerMemberIds: readonly string[], + filter: TeamInboxFilter, + cursor?: TeamInboxCursor | null, + limit = 50 +): Promise<{ page: TeamInboxPage; unreadCount: number }> { + const wire = await invoke("team_inbox_list_page", { + viewerMemberIds: [...viewerMemberIds], + filter, + cursor: toWireCursor(cursor), + limit, + }); + return { + page: { + items: wire.items.map(mapWireItem), + nextCursor: wire.nextCursor + ? { + occurredAt: new Date(wire.nextCursor.occurredAt).toISOString(), + itemKey: wire.nextCursor.itemId, + } + : null, + }, + unreadCount: wire.unreadCount, + }; +} + +export async function markLocalTeamInboxItemRead( + viewerMemberIds: readonly string[], + itemId: string +): Promise { + return invoke("team_inbox_mark_read", { + viewerMemberIds: [...viewerMemberIds], + itemId, + }); +} + +export async function markAllLocalTeamInboxRead( + viewerMemberIds: readonly string[], + filter: TeamInboxFilter +): Promise { + return invoke("team_inbox_mark_all_read", { + viewerMemberIds: [...viewerMemberIds], + filter, + }); +} + +export async function markLocalTeamInboxItemUnread( + viewerMemberIds: readonly string[], + itemId: string +): Promise { + return invoke("team_inbox_mark_unread", { + viewerMemberIds: [...viewerMemberIds], + itemId, + }); +} diff --git a/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx new file mode 100644 index 0000000000..201ffdd5b9 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/AssignedWorkItemDetail.tsx @@ -0,0 +1,210 @@ +import { ClipboardList, ExternalLink } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { WorkItemThreadSurface } from "@src/modules/ProjectManager/WorkItems/components"; +import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import type { Person } from "@src/types/core/shared"; +import type { WorkItem } from "@src/types/core/workItem"; + +import { + type AssignedWorkItem, + type TeamInboxNavigationIntent, + isGitHubIssueStatus, +} from "../domain"; +import { useTeamInboxWorkItem } from "../useTeamInboxWorkItem"; +import type { TeamInboxWorkItemIssue } from "../useTeamInboxWorkItem"; +import TeamInboxDetailLayout from "./TeamInboxDetailLayout"; + +export interface AssignedWorkItemDetailProps { + item: AssignedWorkItem; + onNavigate?: (intent: TeamInboxNavigationIntent) => void; + onMarkRead?: (item: AssignedWorkItem) => void; + onMarkUnread?: (item: AssignedWorkItem) => void; + onWorkItemUpdated?: (workItem: WorkItem) => void; +} + +interface AssignedWorkItemThreadProps { + item: AssignedWorkItem; + workItem: WorkItem; + repoPath: string | null; + members: Person[]; + currentUser: Person | null; + issueMessage: string | null; + issueTone: "warning" | "error" | null; + updateWorkItem: (updates: Partial) => void; + refreshWorkItem: () => void; + onNavigate?: (intent: TeamInboxNavigationIntent) => void; +} + +const AssignedWorkItemThread: React.FC = ({ + item, + workItem, + repoPath, + members, + currentUser, + issueMessage, + issueTone, + updateWorkItem, + refreshWorkItem, + onNavigate, +}) => { + const canUpdate = Boolean(item.target.projectId); + const isGitHubIssue = isGitHubIssueStatus(item.payload.status); + + return ( +
+ {issueMessage ? ( +
+ {issueMessage} +
+ ) : null} +
+
+ + onNavigate({ + kind: "open_work_item", + projectId: item.target.projectId, + workItemId: item.target.workItemId, + action: "start_agent", + }) + : undefined + } + onOpenSession={ + onNavigate + ? (sessionId) => + onNavigate({ + kind: "open_session", + sessionId, + }) + : undefined + } + onRefreshWorkflow={refreshWorkItem} + /> +
+
+
+ ); +}; + +const AssignedWorkItemDetail: React.FC = ({ + item, + onNavigate, + onMarkRead, + onMarkUnread, + onWorkItemUpdated, +}) => { + const { t } = useTranslation(); + const { + workItem, + status, + issue, + repoPath, + members, + currentUser, + updateWorkItem, + refreshWorkItem, + } = useTeamInboxWorkItem(item.target, onWorkItemUpdated); + const issueMessage = ((): string | null => { + const keyByIssue: Record = { + context_unavailable: "teamInbox.errors.workItemContext", + load_failed: "teamInbox.errors.workItemLoad", + update_failed: "teamInbox.errors.workItemUpdate", + }; + return issue ? t(keyByIssue[issue]) : null; + })(); + + return ( + } + openPlacement="header" + onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined} + onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined} + onOpen={ + onNavigate + ? () => + onNavigate({ + kind: "open_work_item", + projectId: item.target.projectId, + workItemId: item.target.workItemId, + }) + : undefined + } + > + {status === "loading" ? ( + + ) : status === "ready" && workItem ? ( + + ) : ( + + )} + + ); +}; + +export default AssignedWorkItemDetail; diff --git a/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx new file mode 100644 index 0000000000..2af5cd68ca --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/CommentMentionDetail.tsx @@ -0,0 +1,91 @@ +import { AtSign, MessageSquare } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Markdown from "@src/components/MarkDown"; +import { CARD_ROW_TOKENS } from "@src/modules/shared/layouts/blocks"; + +import type { CommentMentionItem, TeamInboxNavigationIntent } from "../domain"; +import TeamInboxDetailLayout from "./TeamInboxDetailLayout"; + +export interface CommentMentionDetailProps { + item: CommentMentionItem; + onNavigate?: (intent: TeamInboxNavigationIntent) => void; + onMarkRead?: (item: CommentMentionItem) => void; + onMarkUnread?: (item: CommentMentionItem) => void; +} + +const CommentMentionDetail: React.FC = ({ + item, + onNavigate, + onMarkRead, + onMarkUnread, +}) => { + const { t } = useTranslation(); + + return ( + } + onMarkRead={onMarkRead ? () => onMarkRead(item) : undefined} + onMarkUnread={onMarkUnread ? () => onMarkUnread(item) : undefined} + onOpen={ + onNavigate + ? () => + onNavigate({ + kind: "open_session_comment", + sessionId: item.target.sessionId, + commentId: item.target.commentId, + threadId: item.target.threadId, + ...(item.target.anchor ? { anchor: item.target.anchor } : {}), + }) + : undefined + } + metadata={[ + { + label: t("teamInbox.fields.session"), + value: item.target.sessionTitle, + }, + { + label: t("teamInbox.fields.comments"), + value: item.payload.commentCount, + }, + ]} + > +
+
+ + {item.actor.displayName} + + {t("teamInbox.detail.mentionedYou")} + {item.readAt === null ? ( + + {t("teamInbox.status.unread")} + + ) : null} +
+ {item.payload.threadCommentCount !== undefined || + item.payload.context ? ( +

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

+ ) : null} +
+ +
+
+
+ ); +}; + +export default CommentMentionDetail; diff --git a/src/modules/MainApp/TeamInbox/components/SessionHandoffComposer.tsx b/src/modules/MainApp/TeamInbox/components/SessionHandoffComposer.tsx new file mode 100644 index 0000000000..c47576c239 --- /dev/null +++ b/src/modules/MainApp/TeamInbox/components/SessionHandoffComposer.tsx @@ -0,0 +1,209 @@ +import { ArrowRight, CheckSquare, FolderKanban } from "lucide-react"; +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import Input from "@src/components/Input"; +import Select from "@src/components/Select"; +import type { SelectOption } from "@src/components/Select"; +import Textarea from "@src/components/Textarea"; +import Modal from "@src/scaffold/ModalSystem"; + +import type { TeamInboxSessionHandoffDraft } from "../domain"; +import { + MAX_HANDOFF_NOTE_LENGTH, + type SessionHandoffForm, + isTeamHandoff, + selectedHandoffProject, + sessionHandoffFormError, + sessionHandoffFormForProject, +} from "../sessionHandoffForm"; + +interface SessionHandoffComposerProps { + draft: TeamInboxSessionHandoffDraft; + error?: string | null; + form: SessionHandoffForm; + submitting: boolean; + onCancel: () => void; + onChange: (form: SessionHandoffForm) => void; + onSubmit: () => void; +} + +const SessionHandoffComposer: React.FC = ({ + draft, + error, + form, + submitting, + onCancel, + onChange, + onSubmit, +}) => { + const { t } = useTranslation(); + const validationError = sessionHandoffFormError(form, draft); + const teamHandoff = isTeamHandoff(form, draft); + const selectedProject = selectedHandoffProject(form, draft); + const recipient = selectedProject?.recipients.find( + (member) => member.id === form.assigneeMemberId + ); + const projectOptions = useMemo( + () => + draft.projects.map((project) => ({ + value: project.slug, + label: project.name, + })), + [draft.projects] + ); + const recipientOptions = useMemo( + () => + (selectedProject?.recipients ?? []).map((member) => ({ + value: member.id, + label: member.isCurrentUser + ? t("teamInbox.handoff.recipientSelf", { name: member.name }) + : member.name, + })), + [selectedProject?.recipients, t] + ); + + return ( + +
+
+
+ + {selectedProject?.sender.name ?? + t("teamInbox.handoff.chooseProject")} + + + + {recipient?.name ?? t("teamInbox.handoff.chooseRecipient")} + + {selectedProject ? ( + <> + · + + {selectedProject.name} + + ) : null} +
+ {draft.requestPreview ? ( +

+ {draft.requestPreview} +

+ ) : null} + {draft.impactSummary || draft.todoCount > 0 ? ( +
+ {draft.impactSummary ? {draft.impactSummary} : null} + {draft.todoCount > 0 ? ( + + + {t("teamInbox.handoff.todoCount", { + count: draft.todoCount, + })} + + ) : null} +
+ ) : null} +
+ +
+ {!draft.sourceProjectSlug ? ( + + +