diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-02.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-02.md
new file mode 100644
index 000000000..04d83a1b1
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-02.md
@@ -0,0 +1,34 @@
+# ENCORE-INBOX-02: Add `unifiedInbox` flag to EncoreFeatureFlags
+
+## Objective
+Register the Unified Inbox as an Encore Feature with a type flag and default value.
+
+## Context
+- `EncoreFeatureFlags` interface is at `src/renderer/types/index.ts:906` — currently only has `directorNotes: boolean`
+- `DEFAULT_ENCORE_FEATURES` is at `src/renderer/stores/settingsStore.ts:110` — currently `{ directorNotes: false }`
+- The settings store hydration merges saved values with defaults at `settingsStore.ts:1669-1673` using spread: `{ ...DEFAULT_ENCORE_FEATURES, ...(saved) }` — so new fields with defaults are safe
+- Both type AND default MUST be updated in the same task to avoid runtime `undefined`
+
+## Tasks
+
+- [x] In `src/renderer/types/index.ts`, find the `EncoreFeatureFlags` interface at line 906. Add `unifiedInbox: boolean` below `directorNotes`. Also in `src/renderer/stores/settingsStore.ts`, find `DEFAULT_ENCORE_FEATURES` at line 110. Add `unifiedInbox: false` to the object. Both changes must happen together:
+ ```typescript
+ // types/index.ts:906
+ export interface EncoreFeatureFlags {
+ directorNotes: boolean;
+ unifiedInbox: boolean;
+ }
+
+ // stores/settingsStore.ts:110
+ export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = {
+ directorNotes: false,
+ unifiedInbox: false,
+ };
+ ```
+
+- [x] Run `npm run lint` to verify the new field doesn't cause type errors. Existing code spreading `encoreFeatures` will pick up the new field automatically via the default merge at line 1669-1673.
+
+## Gate
+- `npm run lint` passes
+- `EncoreFeatureFlags` has both `directorNotes` and `unifiedInbox` fields
+- `DEFAULT_ENCORE_FEATURES` has `unifiedInbox: false`
diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-04.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-04.md
new file mode 100644
index 000000000..cf795537a
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-04.md
@@ -0,0 +1,46 @@
+# ENCORE-INBOX-04: Port useAgentInbox hook and AgentInbox components
+
+## Objective
+Port the data hook and all 3 component files from `feature/inbox-focus-polish` into the current codebase, adapting imports to the new architecture.
+
+## Context
+- Source branch: `feature/inbox-focus-polish`
+- Files to port:
+ - `src/renderer/hooks/useAgentInbox.ts` — data aggregation hook
+ - `src/renderer/components/AgentInbox/index.tsx` — modal shell + view switching
+ - `src/renderer/components/AgentInbox/InboxListView.tsx` — virtualized list view
+ - `src/renderer/components/AgentInbox/FocusModeView.tsx` — focus mode detail view
+- The new codebase uses `settingsStore.ts` (Zustand) instead of prop-drilling settings
+- `useModalLayer` hook and `MODAL_PRIORITIES` still exist in the same locations
+- `modalStore` still exists at `src/renderer/stores/modalStore.ts`
+- `formatRelativeTime` is at `src/renderer/utils/formatters.ts`
+
+## Tasks
+
+- [x] Create `src/renderer/hooks/useAgentInbox.ts` by extracting from old branch: `git show feature/inbox-focus-polish:src/renderer/hooks/useAgentInbox.ts`. Copy the full content. Verify imports: `Session`, `Group` from `../types`, `InboxItem`, `InboxFilterMode`, `InboxSortMode` from `../types/agent-inbox`. These should resolve since Phase 01 created the types file. Run `npm run lint` to check.
+
+- [x] Create the directory `src/renderer/components/AgentInbox/` and port all 3 component files. For each, extract from old branch and copy:
+ 1. `git show feature/inbox-focus-polish:src/renderer/components/AgentInbox/index.tsx` → `src/renderer/components/AgentInbox/index.tsx`
+ 2. `git show feature/inbox-focus-polish:src/renderer/components/AgentInbox/InboxListView.tsx` → `src/renderer/components/AgentInbox/InboxListView.tsx`
+ 3. `git show feature/inbox-focus-polish:src/renderer/components/AgentInbox/FocusModeView.tsx` → `src/renderer/components/AgentInbox/FocusModeView.tsx`
+ After copying, verify all imports resolve. Key imports to check:
+ - `../../types` should export `Theme`, `Session`, `Group`, `ThinkingMode`, `LogEntry`
+ - `../../types/agent-inbox` should export all inbox types (from Phase 01)
+ - `../../hooks/useAgentInbox` should resolve (created above)
+ - `../../hooks/ui/useModalLayer` — verify this exists: `ls src/renderer/hooks/ui/useModalLayer*`
+ - `../../constants/modalPriorities` — verify: `grep -n "AGENT_INBOX" src/renderer/constants/modalPriorities.ts`. If `AGENT_INBOX` doesn't exist in priorities, add it (use priority 555, same as old branch)
+ - `../../stores/modalStore` — verify `selectModalData` is exported
+ - `../../utils/formatters` — verify `formatRelativeTime` is exported
+ - The ported `InboxListView.tsx` uses `react-window` for virtualization, but this dependency does NOT exist in the project. The codebase already uses `@tanstack/react-virtual` (used by Director's Notes `UnifiedHistoryTab.tsx`). After copying the files, replace ALL `react-window` imports with `@tanstack/react-virtual` equivalents. Specifically: replace `import { FixedSizeList } from 'react-window'` (or similar) with `import { useVirtualizer } from '@tanstack/react-virtual'` and refactor the list rendering to use the `useVirtualizer` hook pattern (see `src/renderer/components/DirectorNotes/UnifiedHistoryTab.tsx` lines 201-221 for reference). This avoids adding an unnecessary dependency.
+ - `../../utils/markdownConfig` — verify `generateTerminalProseStyles` exists
+ - `../../components/MarkdownRenderer` — verify exists
+ **Notes:** react-window → @tanstack/react-virtual migration completed. Added `agentInbox` to ModalId type, AgentInboxModalData interface, ModalDataMap, and getModalActions() in modalStore.ts. AGENT_INBOX priority added at 555 in modalPriorities.ts.
+
+- [x] Run `npm run lint` and fix any import resolution errors. Common issues: renamed types, moved hooks, missing re-exports. Fix each error by finding the new location of the imported symbol.
+ **Notes:** Fixed 2 type narrowing issues in index.tsx (filterMode/sortMode from ModalData needed explicit casts to InboxFilterMode/InboxSortMode). Lint passes clean.
+
+## Gate
+- `src/renderer/hooks/useAgentInbox.ts` exists
+- `src/renderer/components/AgentInbox/` directory has 3 files: `index.tsx`, `InboxListView.tsx`, `FocusModeView.tsx`
+- `npm run lint` passes (zero errors)
+- `AGENT_INBOX` priority registered in `modalPriorities.ts`
diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-05.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-05.md
new file mode 100644
index 000000000..5e27ac1b6
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-05.md
@@ -0,0 +1,61 @@
+# ENCORE-INBOX-05: Integrate Unified Inbox into App.tsx (modal + state + Encore gating)
+
+## Objective
+Wire the Unified Inbox modal into App.tsx with Encore Feature gating, following the Director's Notes pattern exactly.
+
+## Context
+- App.tsx is at `src/renderer/App.tsx` (6,563 lines post-refactor)
+- Director's Notes pattern to mirror:
+ - Line 46-47: lazy import `const DirectorNotesModal = lazy(() => import('./components/DirectorNotes')...)`
+ - Line 356-357: modal state from `useModalActions()` — `directorNotesOpen, setDirectorNotesOpen`
+ - Line 508: `encoreFeatures` destructured from settings
+ - Line 5220: conditional setter for SessionList: `setDirectorNotesOpen: encoreFeatures.directorNotes ? setDirectorNotesOpen : undefined`
+ - Line 5634: conditional handler for QuickActions: `encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined`
+ - Line 6017: gated modal render: `{encoreFeatures.directorNotes && directorNotesOpen && (... )}`
+- `useModalActions()` comes from `src/renderer/stores/modalStore.ts` — need to add `agentInboxOpen`/`setAgentInboxOpen` there first
+- `modalStore.ts` has `agentInbox` in valid modal IDs (check with `grep "agentInbox" src/renderer/stores/modalStore.ts`) — if not, add it
+
+## Tasks
+
+- [x] In `src/renderer/stores/modalStore.ts`, verify that `agentInbox` is registered as a valid modal ID. Search for `type ModalId` or the array/union of valid modal IDs. If `agentInbox` is NOT present, add it following the pattern used by `directorNotes` or other modals. Also verify `useModalActions()` returns `agentInboxOpen` and `setAgentInboxOpen` — if not, add them following the `directorNotesOpen`/`setDirectorNotesOpen` pattern.
+ > `agentInbox` was already registered as ModalId (line 230) with `AgentInboxModalData` and `setAgentInboxOpen` action. Added `agentInboxOpen` reactive selector to `useModalActions()` return object.
+
+- [x] In `src/renderer/App.tsx`, add the lazy import for AgentInbox near line 46 (next to Director's Notes import):
+ ```typescript
+ const AgentInbox = lazy(() => import('./components/AgentInbox'));
+ ```
+ Then destructure `agentInboxOpen` and `setAgentInboxOpen` from `useModalActions()` (find where `directorNotesOpen` is destructured, around line 356).
+ > Added lazy import at line 49. Destructured `agentInboxOpen` and `setAgentInboxOpen` from `useModalActions()` at lines 360-361.
+
+- [x] In `src/renderer/App.tsx`, add the gated modal render. Find the Director's Notes modal block (line 6017: `{encoreFeatures.directorNotes && directorNotesOpen && (`). AFTER that block, add:
+ ```tsx
+ {encoreFeatures.unifiedInbox && agentInboxOpen && (
+
+ setAgentInboxOpen(false)}
+ onNavigateToSession={handleNavigateToSession}
+ />
+
+ )}
+ ```
+ Verify `handleNavigateToSession` or equivalent callback exists (search for the function used by Director's Notes `onResumeSession`). If the exact callback doesn't exist, create a minimal one or use `handleDirectorNotesResumeSession` pattern as reference.
+ > Created `handleAgentInboxNavigateToSession` callback (closes inbox, switches to session). Gated modal render at line 6051 with full props including `enterToSendAI`.
+
+- [x] Wire conditional setters for child components. Find the props assembly area (around line 5220 where Director's Notes does it):
+ - For SessionList: `setAgentInboxOpen: encoreFeatures.unifiedInbox ? setAgentInboxOpen : undefined`
+ - For QuickActionsModal (around line 5634): `onOpenUnifiedInbox: encoreFeatures.unifiedInbox ? () => setAgentInboxOpen(true) : undefined`
+ > Both wired. Also added `setAgentInboxOpen` to keyboard handler ref object for future keybinding support.
+
+- [x] Run `npm run lint` — expect type errors for SessionList/QuickActionsModal props not accepting optional. Note errors for next phases.
+ > 2 expected type errors:
+ > 1. `TS2353`: `setAgentInboxOpen` not in `UseSessionListPropsDeps` type (line 5236)
+ > 2. `TS2322`: `onOpenUnifiedInbox` not in `AppModalsProps` type (line 5652)
+ > Both will be resolved in phases 06-08 when those component prop interfaces are updated.
+
+## Gate
+- `npm run lint` may have type errors (resolved in phases 06-08)
+- `grep -n "agentInboxOpen" src/renderer/App.tsx` returns lazy import, state, gated render, and conditional setters
+- Modal is gated: `encoreFeatures.unifiedInbox && agentInboxOpen`
diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-07.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-07.md
new file mode 100644
index 000000000..e33aa9190
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-07.md
@@ -0,0 +1,70 @@
+# ENCORE-INBOX-07: Add Unified Inbox to hamburger menu (SessionList)
+
+## Objective
+
+Add the Unified Inbox menu item to the hamburger menu, conditionally rendered based on the Encore gate.
+
+## Context
+
+- SessionList is at `src/renderer/components/SessionList.tsx`
+- Director's Notes pattern:
+ - Props: `setDirectorNotesOpen?: (open: boolean) => void` (optional, line 442)
+ - Destructured: line 462
+ - Conditional render: `{setDirectorNotesOpen && ( )}` (line 702)
+ - Shortcut badge: `shortcuts.directorNotes` (line 719)
+- The `setAgentInboxOpen` prop does NOT exist yet — must be added
+- App.tsx passes `undefined` when feature is off (from Phase 05)
+- Props may also need updating in `HamburgerMenuContentProps` (inner component) and the outer `SessionListProps`
+- Check `src/renderer/hooks/props/useSessionListProps.ts` for prop threading
+
+## Tasks
+
+- [x] In `src/renderer/components/SessionList.tsx`, add `setAgentInboxOpen?: (open: boolean) => void` to ALL relevant prop interfaces. Search for `setDirectorNotesOpen` to find all interfaces (there are at least 2: `HamburgerMenuContentProps` around line 442 and the outer interface around line 1081). Add `setAgentInboxOpen` as optional in each.
+
+- [x] In the hamburger menu render section, find the Director's Notes button (line 702: `{setDirectorNotesOpen && (`). AFTER that block (after its closing `)}` and before the next `
` separator), add the Unified Inbox button following the same pattern:
+
+ ```tsx
+ {
+ setAgentInboxOpen && (
+
{
+ setAgentInboxOpen(true);
+ setMenuOpen(false);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
+ >
+
+
+
+ Unified Inbox
+
+
+ Cross-session notifications
+
+
+ {shortcuts.agentInbox && (
+
+ {formatShortcutKeys(shortcuts.agentInbox.keys)}
+
+ )}
+
+ );
+ }
+ ```
+
+ Add `Inbox` to the lucide-react imports if not already there.
+
+- [x] In `src/renderer/hooks/props/useSessionListProps.ts`, add `setAgentInboxOpen` to the returned props if this file threads props to SessionList. Search for `setDirectorNotesOpen` in this file to find the pattern.
+
+- [x] Ensure `setAgentInboxOpen` is properly destructured in all intermediate components that pass it down within SessionList.tsx.
+
+- [x] Run `npm run lint` to verify. (Note: only pre-existing error remains — `onOpenUnifiedInbox` in AppModalsProps from another phase)
+
+## Gate
+
+- `npm run lint` passes (pre-existing `onOpenUnifiedInbox` error in AppModalsProps is from a separate phase and does not block this gate)
+- `grep -n "setAgentInboxOpen" src/renderer/components/SessionList.tsx` returns prop definition + conditional render
+- Menu button only appears when `setAgentInboxOpen` is defined (i.e., feature enabled)
diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-08.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-08.md
new file mode 100644
index 000000000..acaa5838f
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-08.md
@@ -0,0 +1,44 @@
+# ENCORE-INBOX-08: Add Unified Inbox to command palette (QuickActionsModal)
+
+## Objective
+Add the Unified Inbox action to the command palette, conditionally rendered based on Encore gate.
+
+## Context
+- QuickActionsModal is at `src/renderer/components/QuickActionsModal.tsx`
+- Director's Notes pattern:
+ - Props: `onOpenDirectorNotes?: () => void` (optional, line 119)
+ - Destructured: line 206
+ - Conditional action entry: lines 1023-1036 using spread `...(onOpenDirectorNotes ? [{...}] : [])`
+- NO agentInbox entry exists — must be created from scratch
+- App.tsx passes `undefined` for the handler when feature is off (from Phase 05)
+
+## Tasks
+
+- [x] In `src/renderer/components/QuickActionsModal.tsx`, add `onOpenUnifiedInbox?: () => void` to the props interface (find where `onOpenDirectorNotes` is defined, around line 119). Then destructure it alongside `onOpenDirectorNotes` (around line 206).
+
+- [x] In the actions array, find the Director's Notes entry (line 1023: `...(onOpenDirectorNotes`). AFTER that block, add the Unified Inbox entry:
+ ```typescript
+ ...(onOpenUnifiedInbox
+ ? [
+ {
+ id: 'unifiedInbox',
+ label: 'Unified Inbox',
+ shortcut: shortcuts.agentInbox,
+ subtext: 'Cross-session notification center',
+ action: () => {
+ onOpenUnifiedInbox();
+ setQuickActionOpen(false);
+ },
+ },
+ ]
+ : []),
+ ```
+
+- [x] Run `npm run lint` to verify.
+
+> **Note:** Also added `onOpenUnifiedInbox` to both `AppUtilityModalsProps` and `AppModalsProps` interfaces in `AppModals.tsx`, with destructuring and JSX threading to QuickActionsModal, to fix the type error from App.tsx already passing this prop.
+
+## Gate
+- `npm run lint` passes
+- `grep -n "unifiedInbox" src/renderer/components/QuickActionsModal.tsx` returns action entry
+- Action only appears when `onOpenUnifiedInbox` is defined (feature enabled)
diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-10.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-10.md
new file mode 100644
index 000000000..20c38270f
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-10.md
@@ -0,0 +1,46 @@
+# ENCORE-INBOX-10: Fix Focus Mode auto-scroll bug
+
+## Objective
+Fix the bug where Focus Mode forcefully scrolls to the bottom every time logs update, preventing users from reading earlier messages.
+
+## Context
+- Bug location: `src/renderer/components/AgentInbox/FocusModeView.tsx` — search for `Auto-scroll to bottom when logs change`
+- Current code: `useEffect` fires on every `visibleLogs` change and sets `scrollTop = scrollHeight`
+- Problem: when an agent is actively producing output, `visibleLogs` updates constantly, scroll snaps to bottom — user cannot scroll up
+- Fix: proximity-based auto-scroll (only scroll if user is near bottom)
+- Note: line numbers are approximate since the file was just ported in Phase 04
+
+## Tasks
+
+- [x] In `src/renderer/components/AgentInbox/FocusModeView.tsx`, find the auto-scroll useEffect (search for `scrollRef.current.scrollTop = scrollRef.current.scrollHeight`). Replace the entire useEffect block with a proximity-based approach:
+ ```typescript
+ // Auto-scroll to bottom ONLY if user is near bottom (within 150px) or item changed
+ const scrollRef = useRef
(null);
+ const prevItemRef = useRef('');
+ useEffect(() => {
+ if (!scrollRef.current) return;
+ const el = scrollRef.current;
+ const itemKey = `${item.sessionId}:${item.tabId}`;
+ const isNewItem = prevItemRef.current !== itemKey;
+ if (isNewItem) {
+ prevItemRef.current = itemKey;
+ el.scrollTop = el.scrollHeight;
+ return;
+ }
+ // Only auto-scroll if user is near bottom
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ if (distanceFromBottom < 150) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }, [visibleLogs, item.sessionId, item.tabId]);
+ ```
+ Keep the `scrollRef` declaration if it already exists (just replace the useEffect body). If `prevItemRef` doesn't exist, add it. Key behaviors:
+ - **New item (prev/next navigation):** always scroll to bottom (fresh context)
+ - **Same item, user near bottom (<150px):** auto-scroll (following along)
+ - **Same item, user scrolled up (>150px):** don't scroll (reading history)
+
+- [x] Run `npm run lint` to verify no type errors.
+
+## Gate
+- `npm run lint` passes
+- `grep -n "distanceFromBottom" src/renderer/components/AgentInbox/FocusModeView.tsx` returns the proximity check
diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-11.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-11.md
new file mode 100644
index 000000000..c8d7b0dd4
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-11.md
@@ -0,0 +1,61 @@
+# ENCORE-INBOX-11: Fix CodeRabbit bugs — group collapse + keyboard a11y + selection sync
+
+## Objective
+
+Fix three bugs flagged by CodeRabbit PR review in the ported InboxListView.
+
+## Context
+
+- All 3 bugs are in `src/renderer/components/AgentInbox/InboxListView.tsx`
+- Bug 1: Group headers are clickable `` but not keyboard-accessible
+- Bug 2: Collapsed groups hide items even in non-Grouped sort modes
+- Bug 3: Selection index can point to a hidden (collapsed) row
+
+## Tasks
+
+- [x] **Bug 1 — Keyboard-accessible group headers:** Find where group headers are rendered (search `row.type === 'header'`). Change the outer `
` to `
`. Add: `className="outline-none"`, `background: 'transparent'`, `border: 'none'`, `width: '100%'`, `textAlign: 'left'` in style. Add `onKeyDown` handler that calls `onToggleGroup(row.groupName)` on Enter or Space (with `e.preventDefault()`). Keep existing `onClick`.
+
+ > Done — changed `` to `
` with keyboard support at line ~1146.
+
+- [x] **Bug 2 — Collapse scoped to grouped sort:** Find the `rows` useMemo (search `collapsedGroups.size === 0`). Update to only apply collapse filtering when `sortMode === 'grouped'`:
+
+ > Done — collapse filtering is scoped to grouped mode as shown in the snippet below.
+
+ ```typescript
+ const rows = useMemo(() => {
+ if (sortMode !== 'grouped' || collapsedGroups.size === 0) return allRows;
+ return allRows.filter((row) => {
+ if (row.type === 'header') return true;
+ const itemGroup = row.item.groupName ?? 'Ungrouped';
+ return !collapsedGroups.has(itemGroup);
+ });
+ }, [allRows, collapsedGroups, sortMode]);
+ ```
+
+- [x] **Bug 3 — Selection sync after collapse:** After the existing `useEffect` that resets `selectedIndex` on `items` change, add a new useEffect:
+
+ > Done — added guard useEffect that resets parent selectedIndex when it points to a collapsed-away item.
+
+ ```typescript
+ useEffect(() => {
+ if (rows.length === 0) return;
+ const visibleItemIndexes = new Set(
+ rows.filter((row) => row.type === 'item').map((row) => row.index)
+ );
+ if (!visibleItemIndexes.has(selectedIndex)) {
+ const firstItemRow = rows.find((row) => row.type === 'item');
+ if (firstItemRow && firstItemRow.type === 'item') {
+ setSelectedIndex(firstItemRow.index);
+ }
+ }
+ }, [rows, selectedIndex]);
+ ```
+
+- [x] Run `npm run lint` to verify.
+ > Done — `npm run lint` (tsc) and `npm run lint:eslint` both pass clean. Tests: 484 passed, 1 pre-existing failure (unrelated SSH timeout).
+
+## Gate
+
+- `npm run lint` passes
+- `grep -n " Updated `duration-150` → `duration-100`. Shell tokens (`rounded-xl shadow-2xl border`, `bgActivity`, `max-w-[95vw]`) were already correct.
+
+- [x] In `src/renderer/components/AgentInbox/InboxListView.tsx`, find the header bar (the div containing "Unified Inbox" title, filter pills, close button). Update to match Director's Notes header pattern:
+ - Container: `className="flex items-center justify-between px-4 py-3 border-b"` with `borderColor: theme.colors.border`
+ - Title: `className="text-lg font-semibold"` with `color: theme.colors.textMain`
+ - Icon next to title: `theme.colors.accent` color, `w-5 h-5` size
+ - Close button: `w-4 h-4` with `color: theme.colors.textDim`
+ > Changed `px-6` → `px-4 py-3`, `text-base` → `text-lg`, added `Bot` icon (w-5 h-5, accent). Close button already matched. Footer padding also aligned to `px-4`.
+
+- [x] In `src/renderer/components/AgentInbox/FocusModeView.tsx`, find the Focus Mode header bar (back arrow, title, navigation, close). Update:
+ - Container: `px-4 py-3 border-b` with `borderColor: theme.colors.border`
+ - Title: `text-lg font-semibold` with `color: theme.colors.textMain`
+ - Back arrow and close (X): `w-4 h-4` with `color: theme.colors.textDim`
+ > Added `py-3`, removed fixed `height: 48`. Title changed from `text-sm font-bold` → `text-lg font-semibold`. Back arrow (16×16) and close (w-4 h-4, textDim) already matched.
+
+- [x] Run `npm run lint` to verify.
+ > Both `npm run lint` (tsc) and `npm run lint:eslint` pass. Tests pass (3 pre-existing timeouts in session-storage.test.ts unrelated to these changes).
+
+## Gate
+- `npm run lint` passes
+- `grep -n "px-4 py-3 border-b" src/renderer/components/AgentInbox/InboxListView.tsx` returns header
+- `grep -n "px-4 py-3 border-b" src/renderer/components/AgentInbox/FocusModeView.tsx` returns header
+- Animation uses `duration-100`
diff --git a/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-14.md b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-14.md
new file mode 100644
index 000000000..fe7a44cd0
--- /dev/null
+++ b/playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-14.md
@@ -0,0 +1,63 @@
+# ENCORE-INBOX-14: Final lint + test + verification gate
+
+## Objective
+
+Full verification pass ensuring all 13 prior phases integrate cleanly.
+
+## Tasks
+
+- [x] Run `npm run lint` (TypeScript). Fix any errors.
+
+ > ✅ Passes cleanly — zero TypeScript errors across all 3 configs (lint, main, cli).
+
+- [x] Run `npm run test`. Fix any failures. Pay special attention to tests referencing SessionList props, keyboard handler context, or modal store IDs.
+
+ > ✅ 484/485 test files pass, 20,880/20,883 tests pass. 3 failures are **pre-existing** CodexSessionStorage timeout issues (not related to Encore Inbox): the test scans ~32K real `.jsonl` files in `~/.codex/sessions/` without mocking the filesystem, causing 10s timeouts. Verified by checking `git diff main` — no functional changes to this test file. All SessionList, keyboard handler, and modal store tests pass cleanly.
+
+- [x] Run `npm run lint:eslint`. Fix any warnings.
+
+ > ✅ Passes cleanly — zero warnings, zero errors.
+
+- [x] Verify the complete integration by running these checks:
+ ```bash
+ # 1. Type flag exists
+ grep -n "unifiedInbox" src/renderer/types/index.ts
+ # 2. Default is false
+ grep -n "unifiedInbox" src/renderer/stores/settingsStore.ts
+ # 3. Settings toggle card exists
+ grep -c "unifiedInbox" src/renderer/components/SettingsModal.tsx
+ # 4. Modal gating in App.tsx
+ grep -n "encoreFeatures.unifiedInbox" src/renderer/App.tsx
+ # 5. Keyboard shortcut registered and gated
+ grep -n "agentInbox" src/renderer/constants/shortcuts.ts
+ grep -n "agentInbox.*encoreFeatures" src/renderer/hooks/keyboard/useMainKeyboardHandler.ts
+ # 6. Hamburger menu item
+ grep -n "setAgentInboxOpen" src/renderer/components/SessionList.tsx
+ # 7. Command palette entry
+ grep -n "onOpenUnifiedInbox" src/renderer/components/QuickActionsModal.tsx
+ # 8. Components ported
+ ls src/renderer/components/AgentInbox/
+ # 9. Auto-scroll fix
+ grep -n "distanceFromBottom" src/renderer/components/AgentInbox/FocusModeView.tsx
+ # 10. Design alignment
+ grep -n "px-4 py-3" src/renderer/components/AgentInbox/InboxListView.tsx
+ ```
+ > ✅ All 10 verification checks pass:
+ >
+ > 1. Type flag: `types/index.ts:908`
+ > 2. Default false: `settingsStore.ts:112`
+ > 3. Settings toggle: 8 occurrences in SettingsModal.tsx
+ > 4. Modal gating: 3 gating points in App.tsx (lines 5239, 5656, 6054)
+ > 5. Shortcut registered: `shortcuts.ts:81` — gated: `useMainKeyboardHandler.ts:423`
+ > 6. Hamburger menu: 10 references in SessionList.tsx
+ > 7. Command palette: `QuickActionsModal.tsx:1041`
+ > 8. Components: `FocusModeView.tsx`, `InboxListView.tsx`, `index.tsx`
+ > 9. Auto-scroll: `FocusModeView.tsx:681-682` (proximity-based)
+ > 10. Design alignment: `InboxListView.tsx:962` (px-4 py-3)
+
+## Gate
+
+- `npm run lint` passes (zero errors)
+- `npm run test` completes with no regressions (3 known pre-existing CodexSessionStorage timeout failures remain)
+- `npm run lint:eslint` passes (zero warnings)
+- All 10 verification checks above return results
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 27355bc4a..ad97cbf7b 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -46,6 +46,7 @@ const DocumentGraphView = lazy(() =>
const DirectorNotesModal = lazy(() =>
import('./components/DirectorNotes').then((m) => ({ default: m.DirectorNotesModal }))
);
+const AgentInbox = lazy(() => import('./components/AgentInbox'));
// Re-import the type for SymphonyContributionData (types don't need lazy loading)
import type { SymphonyContributionData } from './components/SymphonyModal';
@@ -355,6 +356,9 @@ function MaestroConsoleInner() {
// Director's Notes Modal
directorNotesOpen,
setDirectorNotesOpen,
+ // Agent Inbox Modal
+ agentInboxOpen,
+ setAgentInboxOpen,
} = useModalActions();
// --- MOBILE LANDSCAPE MODE (reading-only view) ---
@@ -628,8 +632,11 @@ function MaestroConsoleInner() {
setSelectedSidebarIndex,
} = useUIStore.getState();
- const { setSelectedFileIndex: _setSelectedFileIndex, setFileTreeFilter: _setFileTreeFilter, setFileTreeFilterOpen } =
- useFileExplorerStore.getState();
+ const {
+ setSelectedFileIndex: _setSelectedFileIndex,
+ setFileTreeFilter: _setFileTreeFilter,
+ setFileTreeFilterOpen,
+ } = useFileExplorerStore.getState();
// --- GROUP CHAT STATE (now in groupChatStore) ---
@@ -1509,6 +1516,151 @@ function MaestroConsoleInner() {
}
}, [activeSession?.id, handleResumeSession]);
+ // --- AGENT INBOX SESSION NAVIGATION ---
+ // Close inbox modal and switch to the target agent session
+ const handleAgentInboxNavigateToSession = useCallback(
+ (sessionId: string, tabId?: string) => {
+ setAgentInboxOpen(false);
+ setActiveSessionId(sessionId);
+ if (tabId) {
+ setSessions((prev) =>
+ prev.map((s) =>
+ s.id === sessionId
+ ? { ...s, activeTabId: tabId, activeFileTabId: null, inputMode: 'ai' as const }
+ : s
+ )
+ );
+ }
+ },
+ [setAgentInboxOpen, setActiveSessionId, setSessions]
+ );
+
+ // Ref for processInput — populated after useInputHandlers (declared later in component).
+ // Handlers below close over this ref so they always call the latest version.
+ const inboxProcessInputRef = useRef<(text?: string) => void>(() => {});
+ const [pendingInboxQuickReply, setPendingInboxQuickReply] = useState<{
+ targetSessionId: string;
+ previousActiveSessionId: string | null;
+ text: string;
+ } | null>(null);
+
+ // Flush pending quick-reply once target session is active (deterministic, no RAF timing chain).
+ useEffect(() => {
+ if (!pendingInboxQuickReply) return;
+ if (activeSession?.id !== pendingInboxQuickReply.targetSessionId) return;
+
+ inboxProcessInputRef.current(pendingInboxQuickReply.text);
+ const previousActiveSessionId = pendingInboxQuickReply.previousActiveSessionId;
+ setPendingInboxQuickReply(null);
+
+ if (
+ previousActiveSessionId &&
+ previousActiveSessionId !== pendingInboxQuickReply.targetSessionId
+ ) {
+ queueMicrotask(() => {
+ setActiveSessionId(previousActiveSessionId);
+ });
+ }
+ }, [pendingInboxQuickReply, activeSession?.id, setActiveSessionId]);
+
+ // Agent Inbox: Quick Reply — sends text to target session/tab via processInput
+ const handleAgentInboxQuickReply = useCallback(
+ (sessionId: string, tabId: string, text: string) => {
+ // Save current active session so we can restore it after sending.
+ // This ensures AI responses mark the tab as unread (since the user
+ // is viewing through Focus Mode, not directly in the session).
+ const previousActiveSessionId = activeSessionIdRef.current;
+
+ // Activate the target tab and mark as read (processInput adds the user log entry)
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ return {
+ ...s,
+ activeTabId: tabId,
+ activeFileTabId: null,
+ inputMode: 'ai' as const,
+ aiTabs: s.aiTabs.map((t) => (t.id === tabId ? { ...t, hasUnread: false } : t)),
+ };
+ })
+ );
+
+ // Switch to target session and let the effect above send once state is committed.
+ setPendingInboxQuickReply({
+ targetSessionId: sessionId,
+ previousActiveSessionId,
+ text,
+ });
+ setActiveSessionId(sessionId);
+ },
+ [setSessions, setActiveSessionId, activeSessionIdRef]
+ );
+
+ // Agent Inbox: Open & Reply — navigates to session with pre-filled input
+ const handleAgentInboxOpenAndReply = useCallback(
+ (sessionId: string, tabId: string, text: string) => {
+ setActiveSessionId(sessionId);
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ return {
+ ...s,
+ activeTabId: tabId,
+ activeFileTabId: null,
+ inputMode: 'ai' as const,
+ aiTabs: s.aiTabs.map((t) =>
+ t.id === tabId ? { ...t, inputValue: text, hasUnread: false } : t
+ ),
+ };
+ })
+ );
+ setAgentInboxOpen(false);
+ },
+ [setActiveSessionId, setSessions, setAgentInboxOpen]
+ );
+
+ // Agent Inbox: Mark as Read — dismiss unread badge without replying
+ const handleAgentInboxMarkAsRead = useCallback(
+ (sessionId: string, tabId: string) => {
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ return {
+ ...s,
+ aiTabs: s.aiTabs.map((t) => (t.id === tabId ? { ...t, hasUnread: false } : t)),
+ };
+ })
+ );
+ },
+ [setSessions]
+ );
+
+ // Agent Inbox: Toggle thinking mode on a specific tab
+ const handleAgentInboxToggleThinking = useCallback(
+ (sessionId: string, tabId: string, mode: ThinkingMode) => {
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ return {
+ ...s,
+ aiTabs: s.aiTabs.map((t) => {
+ if (t.id !== tabId) return t;
+ if (mode === 'off') {
+ return {
+ ...t,
+ showThinking: 'off',
+ logs: t.logs.filter((l) => l.source !== 'thinking' && l.source !== 'tool'),
+ };
+ }
+ return { ...t, showThinking: mode };
+ }),
+ };
+ })
+ );
+ },
+ [setSessions]
+ );
+
// --- BATCH HANDLERS (Auto Run processing, quit confirmation, error handling) ---
const {
startBatchRun,
@@ -2429,6 +2581,9 @@ function MaestroConsoleInner() {
activeSessionIdRef,
});
+ // Bind the ref so Inbox Quick Reply handlers always call the latest processInput.
+ inboxProcessInputRef.current = processInput;
+
// This is used by context transfer to automatically send the transferred context to the agent
useEffect(() => {
if (!activeSession) return;
@@ -4710,6 +4865,7 @@ function MaestroConsoleInner() {
setMarketplaceModalOpen,
setSymphonyModalOpen,
setDirectorNotesOpen,
+ setAgentInboxOpen,
encoreFeatures,
setShowNewGroupChatModal,
deleteGroupChatWithConfirmation,
@@ -5218,6 +5374,7 @@ function MaestroConsoleInner() {
setUsageDashboardOpen,
setSymphonyModalOpen,
setDirectorNotesOpen: encoreFeatures.directorNotes ? setDirectorNotesOpen : undefined,
+ setAgentInboxOpen: encoreFeatures.unifiedInbox ? setAgentInboxOpen : undefined,
setGroups,
setSessions,
setRenameInstanceModalOpen,
@@ -5633,6 +5790,9 @@ function MaestroConsoleInner() {
onOpenDirectorNotes={
encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined
}
+ onOpenUnifiedInbox={
+ encoreFeatures.unifiedInbox ? () => setAgentInboxOpen(true) : undefined
+ }
autoScrollAiMode={autoScrollAiMode}
setAutoScrollAiMode={setAutoScrollAiMode}
tabSwitcherOpen={tabSwitcherOpen}
@@ -6028,6 +6188,24 @@ function MaestroConsoleInner() {
)}
+ {/* --- AGENT INBOX MODAL (lazy-loaded, Encore Feature) --- */}
+ {encoreFeatures.unifiedInbox && agentInboxOpen && (
+
+ setAgentInboxOpen(false)}
+ onNavigateToSession={handleAgentInboxNavigateToSession}
+ onQuickReply={handleAgentInboxQuickReply}
+ onOpenAndReply={handleAgentInboxOpenAndReply}
+ onMarkAsRead={handleAgentInboxMarkAsRead}
+ onToggleThinking={handleAgentInboxToggleThinking}
+ />
+
+ )}
+
{/* --- GIST PUBLISH MODAL --- */}
{/* Supports both file preview tabs and tab context gist publishing */}
{gistPublishModalOpen && (activeFileTab || tabGistContent) && (
diff --git a/src/renderer/components/AgentInbox/FocusModeView.tsx b/src/renderer/components/AgentInbox/FocusModeView.tsx
new file mode 100644
index 000000000..2016fd041
--- /dev/null
+++ b/src/renderer/components/AgentInbox/FocusModeView.tsx
@@ -0,0 +1,1218 @@
+import { useMemo, useRef, useEffect, useState, useCallback } from 'react';
+import {
+ ArrowLeft,
+ X,
+ Bot,
+ User,
+ ArrowUp,
+ ExternalLink,
+ ChevronLeft,
+ ChevronRight,
+ ChevronDown,
+ Eye,
+ Brain,
+ Pin,
+ FileText,
+} from 'lucide-react';
+import type { Theme, Session, LogEntry, ThinkingMode } from '../../types';
+import type { InboxItem, InboxFilterMode, InboxSortMode } from '../../types/agent-inbox';
+import { STATUS_LABELS, STATUS_COLORS } from '../../types/agent-inbox';
+import { resolveContextUsageColor } from './InboxListView';
+import { formatRelativeTime } from '../../utils/formatters';
+import { MarkdownRenderer } from '../MarkdownRenderer';
+import { generateTerminalProseStyles } from '../../utils/markdownConfig';
+
+/* POLISH-04 Token Audit (@architect)
+ * Line 166: bgSidebar in user bubble color-mix — CORRECT (chrome blend for user messages)
+ * Line 210: bgActivity for AI bubble — CORRECT (content)
+ * Line 429: bgSidebar for sidebar group header — CORRECT (chrome)
+ * Line 722: bgSidebar for focus header — CORRECT (chrome)
+ * Line 818: bgActivity for subheader info bar — CORRECT (content)
+ * Line 904: bgSidebar for sidebar bg — CORRECT (chrome)
+ * Line 1025: bgActivity → bgMain (textarea is nested input, needs contrast)
+ * All other usages: CORRECT
+ */
+
+/* POLISH-03 Design Spec (@ux-design-expert)
+ * BUBBLES:
+ * - All corners: rounded-xl (uniform, no sharp edges)
+ * - Padding: p-4 (remove pb-10 hack)
+ * - Timestamp: inline flex row below content, text-[10px] textDim opacity 0.6, justify-end mt-2
+ * - Left border: user = 3px solid success, AI = 3px solid accent
+ * - Max width: 85% (unchanged)
+ *
+ * SIDEBAR ITEMS:
+ * - Height: 48px (was 36px)
+ * - Layout: status dot + vertical(name, preview) + indicators
+ * - Preview: text-[10px] truncate, textDim opacity 0.5, max 60 chars, strip markdown
+ * - Indicators: alignSelf flex-start, marginTop 2
+ */
+
+// @architect: lastMessage available via InboxItem type (agent-inbox.ts:13) — sidebar scroll OK at 48px (overflow-y-auto, no max-height constraint)
+
+const MAX_LOG_ENTRIES = 50;
+
+function FocusLogEntry({
+ log,
+ theme,
+ showRawMarkdown,
+ onToggleRaw,
+}: {
+ log: LogEntry;
+ theme: Theme;
+ showRawMarkdown: boolean;
+ onToggleRaw: () => void;
+}) {
+ const isUser = log.source === 'user';
+ const isAI = log.source === 'ai' || log.source === 'stdout';
+ const isThinking = log.source === 'thinking';
+ const isTool = log.source === 'tool';
+
+ // Thinking entry — left border accent + badge
+ if (isThinking) {
+ return (
+
+
+
+ thinking
+
+
+ {formatRelativeTime(log.timestamp)}
+
+
+
+ {log.text}
+
+
+ );
+ }
+
+ // Tool entry — compact badge with status
+ if (isTool) {
+ const toolInput = (log.metadata as any)?.toolState?.input as
+ | Record
+ | undefined;
+ const safeStr = (v: unknown): string | null => (typeof v === 'string' ? v : null);
+ const toolDetail = toolInput
+ ? safeStr(toolInput.command) ||
+ safeStr(toolInput.pattern) ||
+ safeStr(toolInput.file_path) ||
+ safeStr(toolInput.query) ||
+ safeStr(toolInput.description) ||
+ safeStr(toolInput.prompt) ||
+ safeStr(toolInput.task_id) ||
+ null
+ : null;
+ const toolStatus = (log.metadata as any)?.toolState?.status as string | undefined;
+
+ return (
+
+
+
+ {log.text}
+
+ {toolStatus === 'running' && (
+
+ ●
+
+ )}
+ {toolStatus === 'completed' && (
+
+ ✓
+
+ )}
+ {toolDetail && (
+
+ {toolDetail}
+
+ )}
+
+
+ );
+ }
+
+ // User entry — right-aligned with User icon
+ if (isUser) {
+ return (
+
+
+
+
+
+
+ {log.text}
+
+
+
+ {formatRelativeTime(log.timestamp)}
+
+
+
+
+ );
+ }
+
+ // AI / stdout entry — left-aligned with Bot icon + markdown
+ if (isAI) {
+ const handleCopy = (text: string) => {
+ navigator.clipboard.writeText(text).catch(() => {});
+ };
+
+ return (
+
+
+
+
+
+ {/* Raw/rendered toggle */}
+
+
+ {showRawMarkdown ? (
+
+ ) : (
+
+ )}
+
+
+
+ {showRawMarkdown ? (
+
+ {log.text}
+
+ ) : (
+
+ )}
+
+
+
+ {formatRelativeTime(log.timestamp)}
+
+
+
+
+ );
+ }
+
+ // Fallback — should not reach here given the filter
+ return null;
+}
+
+interface FocusModeViewProps {
+ theme: Theme;
+ item: InboxItem;
+ items: InboxItem[]; // Full filtered+sorted list for prev/next
+ sessions: Session[]; // For accessing AITab.logs
+ currentIndex: number; // Position of item in items[]
+ enterToSendAI?: boolean; // false = Cmd+Enter sends, true = Enter sends
+ filterMode?: InboxFilterMode;
+ setFilterMode?: (mode: InboxFilterMode) => void;
+ sortMode?: InboxSortMode;
+ onClose: () => void; // Close the entire modal
+ onExitFocus: () => void; // Return to list view
+ onNavigateItem: (index: number) => void; // Jump to item at index
+ onNavigateToSession?: (sessionId: string, tabId?: string) => void;
+ onQuickReply?: (sessionId: string, tabId: string, text: string) => void;
+ onOpenAndReply?: (sessionId: string, tabId: string, text: string) => void;
+ onMarkAsRead?: (sessionId: string, tabId: string) => void;
+ onToggleThinking?: (sessionId: string, tabId: string, mode: ThinkingMode) => void;
+}
+
+// Maps STATUS_COLORS key to actual hex from theme
+function resolveStatusColor(state: InboxItem['state'], theme: Theme): string {
+ const colorKey = STATUS_COLORS[state];
+ const colorMap: Record = {
+ success: theme.colors.success,
+ warning: theme.colors.warning,
+ error: theme.colors.error,
+ info: theme.colors.accent,
+ textMuted: theme.colors.textDim,
+ };
+ return colorMap[colorKey] ?? theme.colors.textDim;
+}
+
+// ============================================================================
+// Compact filter control for sidebar
+// ============================================================================
+const FILTER_OPTIONS: { value: InboxFilterMode; label: string }[] = [
+ { value: 'all', label: 'All' },
+ { value: 'unread', label: 'Unread' },
+ { value: 'starred', label: 'Starred' },
+];
+
+function SidebarFilter({
+ value,
+ onChange,
+ theme,
+}: {
+ value: InboxFilterMode;
+ onChange: (v: InboxFilterMode) => void;
+ theme: Theme;
+}) {
+ return (
+
+ {FILTER_OPTIONS.map((opt) => {
+ const isActive = value === opt.value;
+ return (
+ onChange(opt.value)}
+ className="text-[11px] px-2.5 py-1 rounded-full cursor-pointer transition-all"
+ style={{
+ backgroundColor: isActive ? `${theme.colors.accent}25` : 'transparent',
+ color: isActive ? theme.colors.accentText : theme.colors.textDim,
+ border: isActive ? `1px solid ${theme.colors.accent}50` : '1px solid transparent',
+ outline: 'none',
+ opacity: isActive ? 1 : 0.6,
+ }}
+ >
+ {opt.label}
+
+ );
+ })}
+
+ );
+}
+
+// ============================================================================
+// FocusSidebar — condensed navigable list of inbox items with agent grouping
+// ============================================================================
+function FocusSidebar({
+ items,
+ currentIndex,
+ theme,
+ sortMode,
+ filterMode,
+ setFilterMode,
+ onNavigateItem,
+}: {
+ items: InboxItem[];
+ currentIndex: number;
+ theme: Theme;
+ sortMode?: InboxSortMode;
+ filterMode?: InboxFilterMode;
+ setFilterMode?: (mode: InboxFilterMode) => void;
+ onNavigateItem: (index: number) => void;
+}) {
+ const currentRowRef = useRef(null);
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set());
+
+ // Auto-scroll to keep the current item visible
+ useEffect(() => {
+ currentRowRef.current?.scrollIntoView({ block: 'nearest' });
+ }, [currentIndex]);
+
+ // Build grouped rows — always group by agent/group to avoid duplicate headers
+ const rows = useMemo(() => {
+ const effectiveSort = sortMode ?? 'newest';
+ const useGroupName = effectiveSort === 'grouped';
+
+ // Collect items per unique group key, preserving original index
+ const groupMap = new Map<
+ string,
+ { groupName: string; items: { item: InboxItem; index: number }[] }
+ >();
+ const groupOrder: string[] = [];
+ items.forEach((itm, idx) => {
+ const groupKey = useGroupName ? (itm.groupName ?? 'Ungrouped') : itm.sessionId;
+ const groupName = useGroupName ? (itm.groupName ?? 'Ungrouped') : itm.sessionName;
+ if (!groupMap.has(groupKey)) {
+ groupMap.set(groupKey, { groupName, items: [] });
+ groupOrder.push(groupKey);
+ }
+ groupMap.get(groupKey)!.items.push({ item: itm, index: idx });
+ });
+
+ const result: (
+ | { type: 'header'; groupKey: string; groupName: string; count: number }
+ | { type: 'item'; item: InboxItem; index: number }
+ )[] = [];
+ for (const groupKey of groupOrder) {
+ const group = groupMap.get(groupKey)!;
+ result.push({
+ type: 'header',
+ groupKey,
+ groupName: group.groupName,
+ count: group.items.length,
+ });
+ for (const entry of group.items) {
+ result.push({ type: 'item', item: entry.item, index: entry.index });
+ }
+ }
+ return result;
+ }, [items, sortMode]);
+
+ return (
+
+ {/* Filter control header */}
+ {filterMode !== undefined && setFilterMode && (
+
+
+
+ )}
+ {/* Item list */}
+
+ {(() => {
+ let activeGroup: string | null = null;
+ return rows.map((row, rowIdx) => {
+ if (row.type === 'header') {
+ activeGroup = row.groupKey;
+ return (
+
{
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(row.groupKey)) next.delete(row.groupKey);
+ else next.add(row.groupKey);
+ return next;
+ });
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(row.groupKey)) next.delete(row.groupKey);
+ else next.add(row.groupKey);
+ return next;
+ });
+ }
+ }}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider cursor-pointer"
+ style={{
+ color: theme.colors.textDim,
+ fontWeight: 600,
+ backgroundColor: theme.colors.bgSidebar,
+ }}
+ >
+ {collapsedGroups.has(row.groupKey) ? (
+
+ ) : (
+
+ )}
+ {row.groupName}
+
+ {row.type === 'header' ? row.count : 0}
+
+
+ );
+ }
+
+ // Skip items in collapsed groups
+ if (activeGroup && collapsedGroups.has(activeGroup)) return null;
+
+ const itm = row.item;
+ const idx = row.index;
+ const isCurrent = idx === currentIndex;
+ const statusColor = resolveStatusColor(itm.state, theme);
+
+ const previewText = itm.lastMessage
+ ? itm.lastMessage.replace(/[#*`>]/g, '').slice(0, 60)
+ : '';
+
+ return (
+
onNavigateItem(idx)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onNavigateItem(idx);
+ }
+ }}
+ className="flex items-center gap-2 px-3 cursor-pointer transition-colors"
+ style={{
+ height: 48,
+ backgroundColor: isCurrent ? `${theme.colors.accent}15` : 'transparent',
+ borderLeft: isCurrent
+ ? `2px solid ${theme.colors.accent}`
+ : '2px solid transparent',
+ }}
+ onMouseEnter={(e) => {
+ if (!isCurrent)
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}08`;
+ }}
+ onMouseLeave={(e) => {
+ if (!isCurrent) e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ onFocus={(e) => {
+ if (!isCurrent)
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}08`;
+ }}
+ onBlur={(e) => {
+ if (!isCurrent) e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+ {/* Status dot */}
+
+ {/* Name + preview vertical stack */}
+
+
+ {itm.tabName || 'Tab'}
+
+ {previewText && (
+
+ {previewText}
+
+ )}
+
+ {/* Indicators: unread */}
+ {itm.hasUnread && (
+
+ )}
+
+ );
+ });
+ })()}
+
+
+ );
+}
+
+export default function FocusModeView({
+ theme,
+ item,
+ items,
+ sessions,
+ currentIndex,
+ enterToSendAI,
+ filterMode,
+ setFilterMode,
+ sortMode,
+ onClose,
+ onExitFocus,
+ onNavigateItem,
+ onQuickReply,
+ onOpenAndReply,
+ onMarkAsRead: _onMarkAsRead,
+ onToggleThinking,
+}: FocusModeViewProps) {
+ const statusColor = resolveStatusColor(item.state, theme);
+ const hasValidContext = item.contextUsage !== undefined && !isNaN(item.contextUsage);
+
+ // ---- Resizable sidebar ----
+ const [sidebarWidth, setSidebarWidth] = useState(220);
+ const isResizingRef = useRef(false);
+ const resizeCleanupRef = useRef<(() => void) | null>(null);
+
+ // Unmount safety: clean up resize listeners if component unmounts mid-drag
+ useEffect(() => {
+ return () => {
+ resizeCleanupRef.current?.();
+ resizeCleanupRef.current = null;
+ };
+ }, []);
+
+ const handleResizeStart = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ isResizingRef.current = true;
+ const startX = e.clientX;
+ const startWidth = sidebarWidth;
+
+ const onMouseMove = (ev: MouseEvent) => {
+ if (!isResizingRef.current) return;
+ const newWidth = Math.max(160, Math.min(400, startWidth + (ev.clientX - startX)));
+ setSidebarWidth(newWidth);
+ };
+
+ const cleanup = () => {
+ isResizingRef.current = false;
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ resizeCleanupRef.current = null;
+ };
+
+ const onMouseUp = () => {
+ cleanup();
+ };
+
+ resizeCleanupRef.current = cleanup;
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ },
+ [sidebarWidth]
+ );
+ const contextColor = hasValidContext
+ ? resolveContextUsageColor(item.contextUsage!, theme)
+ : undefined;
+
+ // Truncate helper
+ const truncate = (str: string, max: number) =>
+ str.length > max ? str.slice(0, max) + '...' : str;
+
+ // Session existence check (session may be deleted while focus mode is open)
+ const sessionExists = sessions.some((s) => s.id === item.sessionId);
+
+ // ---- Thinking toggle state (3-state: off → on → sticky → off) ----
+ // Read showThinking from the actual tab property (synced with main app)
+ const showThinking: ThinkingMode = useMemo(() => {
+ const session = sessions.find((s) => s.id === item.sessionId);
+ if (!session) return 'off';
+ const tab = session.aiTabs.find((t) => t.id === item.tabId);
+ return tab?.showThinking ?? 'off';
+ }, [sessions, item.sessionId, item.tabId]);
+
+ const cycleThinking = useCallback(() => {
+ const nextMode: ThinkingMode =
+ showThinking === 'off' ? 'on' : showThinking === 'on' ? 'sticky' : 'off';
+ if (onToggleThinking) {
+ onToggleThinking(item.sessionId, item.tabId, nextMode);
+ }
+ }, [showThinking, item.sessionId, item.tabId, onToggleThinking]);
+
+ // ---- Raw markdown toggle (per-session, not per-log) ----
+ const [showRawMarkdown, setShowRawMarkdown] = useState(false);
+
+ // Compute conversation tail — last N renderable log entries
+ const logs = useMemo(() => {
+ const session = sessions.find((s) => s.id === item.sessionId);
+ if (!session) return [];
+ const tab = session.aiTabs.find((t) => t.id === item.tabId);
+ if (!tab) return [];
+ // Include all renderable log types
+ const relevant = tab.logs.filter(
+ (log) =>
+ log.source === 'ai' ||
+ log.source === 'stdout' ||
+ log.source === 'user' ||
+ log.source === 'thinking' ||
+ log.source === 'tool'
+ );
+ // Take last N entries
+ return relevant.slice(-MAX_LOG_ENTRIES);
+ }, [sessions, item.sessionId, item.tabId]);
+
+ // Filter out thinking/tool when toggle is off
+ const visibleLogs = useMemo(() => {
+ if (showThinking !== 'off') return logs;
+ return logs.filter((log) => log.source !== 'thinking' && log.source !== 'tool');
+ }, [logs, showThinking]);
+
+ // Memoized prose styles — same as TerminalOutput, scoped to .focus-mode-prose
+ const proseStyles = useMemo(
+ () => generateTerminalProseStyles(theme, '.focus-mode-prose'),
+ [theme]
+ );
+
+ // Auto-scroll to bottom ONLY if user is near bottom (within 150px) or item changed
+ const scrollRef = useRef(null);
+ const prevScrollItemRef = useRef('');
+
+ useEffect(() => {
+ if (!scrollRef.current) return;
+ const el = scrollRef.current;
+ const itemKey = `${item.sessionId}:${item.tabId}`;
+ const isNewItem = prevScrollItemRef.current !== itemKey;
+ if (isNewItem) {
+ prevScrollItemRef.current = itemKey;
+ el.scrollTop = el.scrollHeight;
+ return;
+ }
+ // Only auto-scroll if user is near bottom
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ if (distanceFromBottom < 150) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }, [visibleLogs, item.sessionId, item.tabId]);
+
+ // ---- Reply state ----
+ const [replyText, setReplyText] = useState('');
+ const replyInputRef = useRef(null);
+
+ // Auto-focus reply input when entering focus mode or switching items
+ const innerRafRef = useRef(0);
+ useEffect(() => {
+ const raf1 = requestAnimationFrame(() => {
+ const raf2 = requestAnimationFrame(() => {
+ replyInputRef.current?.focus();
+ });
+ innerRafRef.current = raf2;
+ });
+ return () => {
+ cancelAnimationFrame(raf1);
+ if (innerRafRef.current) cancelAnimationFrame(innerRafRef.current);
+ };
+ }, [item.sessionId, item.tabId]);
+
+ // Reset reply text when item changes (prev/next navigation)
+ useEffect(() => {
+ setReplyText('');
+ }, [item.sessionId, item.tabId]);
+
+ const handleQuickReply = useCallback(() => {
+ const text = replyText.trim();
+ if (!text) return;
+ if (onQuickReply) {
+ onQuickReply(item.sessionId, item.tabId, text);
+ }
+ setReplyText('');
+ }, [replyText, item, onQuickReply]);
+
+ const handleOpenAndReply = useCallback(() => {
+ const text = replyText.trim();
+ if (!text) return;
+ if (onOpenAndReply) {
+ onOpenAndReply(item.sessionId, item.tabId, text);
+ }
+ }, [replyText, item, onOpenAndReply]);
+
+ // ---- Smooth transition on item change ----
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const prevItemRef = useRef(`${item.sessionId}-${item.tabId}`);
+
+ useEffect(() => {
+ const currentKey = `${item.sessionId}-${item.tabId}`;
+ if (prevItemRef.current !== currentKey) {
+ setIsTransitioning(true);
+ const timer = setTimeout(() => setIsTransitioning(false), 150);
+ prevItemRef.current = currentKey;
+ return () => clearTimeout(timer);
+ }
+ }, [item.sessionId, item.tabId]);
+
+ // Mark as read only on explicit interaction (reply), not on view.
+ // This preserves the Unread filter — items stay unread until the user acts.
+
+ return (
+
+ {/* Header bar */}
+
+ {/* Left: Back button */}
+
(e.currentTarget.style.color = theme.colors.textMain)}
+ onMouseLeave={(e) => (e.currentTarget.style.color = theme.colors.textDim)}
+ >
+
+ Inbox
+
+
+ {/* Center: GROUP | Agent name · tab */}
+
+ {item.groupName && (
+ <>
+
+ {item.groupName}
+
+
+ |
+
+ >
+ )}
+
+ {truncate(item.sessionName, 30)}
+
+ {item.tabName && (
+ <>
+
+ ·
+
+
+ {item.tabName}
+
+ >
+ )}
+ {/* TODO: cost badge — needs InboxItem.cost field */}
+
+
+ {/* Right: Close button */}
+
(e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)}
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
+ title="Close (Esc)"
+ >
+
+
+
+
+ {/* Subheader info bar — 32px */}
+
+ {item.gitBranch && (
+
+ {truncate(item.gitBranch, 25)}
+
+ )}
+ {hasValidContext && (
+
Context: {item.contextUsage}%
+ )}
+
+ {STATUS_LABELS[item.state]}
+
+ {/* Thinking toggle — 3-state: off → on → sticky → off */}
+
+
+ Thinking
+ {showThinking === 'sticky' && }
+
+
+
+ {/* Prose styles for markdown rendering — injected once at container level */}
+
+
+ {/* Two-column layout: sidebar + main content */}
+
+ {/* Sidebar mini-list */}
+
+
+
+
+ {/* Resize handle */}
+
{
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}30`;
+ }}
+ onMouseLeave={(e) => {
+ if (!isResizingRef.current) {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }
+ }}
+ />
+
+ {/* Main content: conversation body + reply input */}
+
+ {/* Body — conversation tail */}
+ {!sessionExists ? (
+
+ Agent no longer available
+
+ ) : (
+
+ {visibleLogs.length === 0 ? (
+
+ No conversation yet
+
+ ) : (
+
+ {visibleLogs.map((log) => (
+ setShowRawMarkdown((v) => !v)}
+ />
+ ))}
+
+ )}
+
+ )}
+
+ {/* Reply input bar */}
+
+
+
+
+ {/* Footer — 44px */}
+
+ {/* Prev button */}
+
onNavigateItem((currentIndex - 1 + items.length) % items.length)}
+ disabled={items.length <= 1}
+ aria-disabled={items.length <= 1 ? 'true' : undefined}
+ className="flex items-center gap-1 text-xs px-3 py-1.5 rounded transition-colors"
+ style={{
+ border: `1px solid ${theme.colors.border}`,
+ color: items.length > 1 ? theme.colors.textMain : theme.colors.textDim,
+ backgroundColor: 'transparent',
+ cursor: items.length > 1 ? 'pointer' : 'default',
+ opacity: items.length <= 1 ? 0.4 : 1,
+ }}
+ onMouseEnter={(e) => {
+ if (items.length > 1)
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ title="Previous item (⌘←)"
+ >
+
+ Prev
+
+
+ {/* Center: counter + keyboard hints */}
+
+
+ {currentIndex + 1} / {items.length}
+
+
+ ⌘←→ Navigate · Esc Back
+
+
+
+ {/* Next button */}
+
onNavigateItem((currentIndex + 1) % items.length)}
+ disabled={items.length <= 1}
+ aria-disabled={items.length <= 1 ? 'true' : undefined}
+ className="flex items-center gap-1 text-xs px-3 py-1.5 rounded transition-colors"
+ style={{
+ border: `1px solid ${theme.colors.border}`,
+ color: items.length > 1 ? theme.colors.textMain : theme.colors.textDim,
+ backgroundColor: 'transparent',
+ cursor: items.length > 1 ? 'pointer' : 'default',
+ opacity: items.length <= 1 ? 0.4 : 1,
+ }}
+ onMouseEnter={(e) => {
+ if (items.length > 1)
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ title="Next item (⌘→)"
+ >
+ Next
+
+
+
+
+ );
+}
diff --git a/src/renderer/components/AgentInbox/InboxListView.tsx b/src/renderer/components/AgentInbox/InboxListView.tsx
new file mode 100644
index 000000000..e5b59691a
--- /dev/null
+++ b/src/renderer/components/AgentInbox/InboxListView.tsx
@@ -0,0 +1,1296 @@
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { X, CheckCircle, ChevronDown, ChevronRight, Maximize2, Minimize2, Bot } from 'lucide-react';
+import type { Theme, SessionState } from '../../types';
+import type { InboxItem, InboxFilterMode, InboxSortMode } from '../../types/agent-inbox';
+import { STATUS_LABELS, STATUS_COLORS } from '../../types/agent-inbox';
+import { formatRelativeTime } from '../../utils/formatters';
+import { formatShortcutKeys } from '../../utils/shortcutFormatter';
+import { getModalActions } from '../../stores/modalStore';
+
+interface InboxListViewProps {
+ theme: Theme;
+ items: InboxItem[];
+ selectedIndex: number;
+ setSelectedIndex: React.Dispatch
>;
+ filterMode: InboxFilterMode;
+ setFilterMode: (mode: InboxFilterMode) => void;
+ sortMode: InboxSortMode;
+ setSortMode: (mode: InboxSortMode) => void;
+ onClose: () => void;
+ onNavigateToSession?: (sessionId: string, tabId?: string) => void;
+ onEnterFocus: (item: InboxItem) => void;
+ containerRef: React.RefObject;
+ keyDownRef?: React.MutableRefObject<((e: React.KeyboardEvent) => void) | null>;
+ isExpanded: boolean;
+ onToggleExpanded: (expanded: boolean | ((prev: boolean) => boolean)) => void;
+}
+
+const ITEM_HEIGHT = 132;
+const GROUP_HEADER_HEIGHT = 36;
+const MODAL_HEADER_HEIGHT = 80;
+const MODAL_FOOTER_HEIGHT = 36;
+const STATS_BAR_HEIGHT = 32;
+
+// ============================================================================
+// Empty state messages per filter mode
+// ============================================================================
+const EMPTY_STATE_MESSAGES: Record = {
+ all: { text: 'No active agents to show.', showIcon: true },
+ unread: { text: 'No unread agents.', showIcon: false },
+ read: { text: 'No read agents with activity.', showIcon: false },
+ starred: { text: 'No starred agents.', showIcon: false },
+};
+
+// ============================================================================
+// Grouped list model: interleaves group headers with items when sort = 'grouped'
+// ============================================================================
+type ListRow =
+ | { type: 'header'; groupKey: string; groupName: string }
+ | { type: 'item'; item: InboxItem; index: number };
+
+function buildRows(items: InboxItem[], sortMode: InboxSortMode): ListRow[] {
+ if (sortMode !== 'grouped' && sortMode !== 'byAgent') {
+ return items.map((item, index) => ({ type: 'item' as const, item, index }));
+ }
+ const rows: ListRow[] = [];
+ let lastGroupKey: string | null = null;
+ let itemIndex = 0;
+ for (const item of items) {
+ // For 'grouped': group by Left Bar group name
+ // For 'byAgent': group by sessionId (unique) and display sessionName
+ const groupKey = sortMode === 'byAgent' ? item.sessionId : (item.groupName ?? 'Ungrouped');
+ const groupName = sortMode === 'byAgent' ? item.sessionName : (item.groupName ?? 'Ungrouped');
+ if (groupKey !== lastGroupKey) {
+ rows.push({ type: 'header', groupKey, groupName });
+ lastGroupKey = groupKey;
+ }
+ rows.push({ type: 'item', item, index: itemIndex });
+ itemIndex++;
+ }
+ return rows;
+}
+
+// ============================================================================
+// STATUS color resolver — maps STATUS_COLORS key to actual hex
+// ============================================================================
+function resolveStatusColor(state: SessionState, theme: Theme): string {
+ const colorKey = STATUS_COLORS[state];
+ const colorMap: Record = {
+ success: theme.colors.success,
+ warning: theme.colors.warning,
+ error: theme.colors.error,
+ info: theme.colors.accent,
+ textMuted: theme.colors.textDim,
+ };
+ return colorMap[colorKey] ?? theme.colors.textDim;
+}
+
+// ============================================================================
+// Context usage color resolver — green/orange/red thresholds
+// ============================================================================
+export function resolveContextUsageColor(percentage: number, theme: Theme): string {
+ if (percentage >= 80) return theme.colors.error;
+ if (percentage >= 60) return theme.colors.warning;
+ return theme.colors.success;
+}
+
+// ============================================================================
+// InboxItemCard — rendered inside each row
+// ============================================================================
+function InboxItemCardContent({
+ item,
+ theme,
+ isSelected,
+ onClick,
+ onDoubleClick,
+}: {
+ item: InboxItem;
+ theme: Theme;
+ isSelected: boolean;
+ onClick: () => void;
+ onDoubleClick?: () => void;
+}) {
+ const statusColor = resolveStatusColor(item.state, theme);
+ const hasValidContext = item.contextUsage !== undefined && !isNaN(item.contextUsage);
+ const contextColor = hasValidContext
+ ? resolveContextUsageColor(item.contextUsage!, theme)
+ : undefined;
+
+ return (
+ {
+ e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+ e.currentTarget.style.outlineOffset = '-2px';
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.outline = 'none';
+ }}
+ >
+ {/* Card content — horizontal flex with agent icon + details */}
+
+ {/* Agent icon */}
+
+
+
+ {/* Card details */}
+
+ {/* Row 1: GROUP | session | tab timestamp */}
+
+ {item.groupName && (
+ <>
+
+ {item.groupName}
+
+
+ |
+
+ >
+ )}
+
+ {item.sessionName}
+ {item.tabName && (
+ <>
+
+ |
+
+
+ {item.tabName}
+
+ >
+ )}
+
+
+ {formatRelativeTime(item.timestamp)}
+
+
+
+ {/* Row 2: last message (2-line clamp) */}
+
+ {item.lastMessage}
+
+
+ {/* Row 3: badges */}
+
+ {item.gitBranch && (
+
+ ⎇{' '}
+ {item.gitBranch.length > 25 ? item.gitBranch.slice(0, 25) + '...' : item.gitBranch}
+
+ )}
+
+ {hasValidContext ? `Context: ${item.contextUsage}%` : 'Context: \u2014'}
+
+
+ {STATUS_LABELS[item.state]}
+
+
+
+
+
+ {/* Context usage bar — 4px at bottom of card */}
+ {hasValidContext && (
+
+
= 100 ? 0 : '0 2px 2px 0',
+ transition: 'width 0.3s ease',
+ }}
+ />
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// SegmentedControl
+// ============================================================================
+interface SegmentedControlProps
{
+ options: { value: T; label: string }[];
+ value: T;
+ onChange: (value: T) => void;
+ theme: Theme;
+ ariaLabel?: string;
+}
+
+function SegmentedControl({
+ options,
+ value,
+ onChange,
+ theme,
+ ariaLabel,
+}: SegmentedControlProps) {
+ return (
+
+ {options.map((opt) => {
+ const isActive = value === opt.value;
+ return (
+ onChange(opt.value)}
+ style={{
+ padding: '4px 12px',
+ fontSize: 12,
+ border: 'none',
+ borderRadius: 8,
+ cursor: 'pointer',
+ transition: 'background 150ms ease',
+ backgroundColor: isActive ? `${theme.colors.accent}20` : 'transparent',
+ color: isActive ? theme.colors.accent : theme.colors.textDim,
+ outline: 'none',
+ }}
+ onMouseEnter={(e) => {
+ if (!isActive) {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isActive) {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }
+ }}
+ onFocus={(e) => {
+ e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+ e.currentTarget.style.outlineOffset = '-2px';
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.outline = 'none';
+ }}
+ >
+ {opt.label}
+
+ );
+ })}
+
+ );
+}
+
+// ============================================================================
+// InboxStatsStrip — compact 32px metric bar between header and list
+// ============================================================================
+function InboxStatsStrip({ items, theme }: { items: InboxItem[]; theme: Theme }) {
+ const stats = useMemo(() => {
+ const uniqueAgents = new Set(items.map((i) => i.sessionId)).size;
+ const unread = items.filter((i) => i.hasUnread).length;
+ const needsInput = items.filter((i) => i.state === 'waiting_input').length;
+ const highContext = items.filter(
+ (i) => i.contextUsage !== undefined && i.contextUsage >= 80
+ ).length;
+ return { uniqueAgents, unread, needsInput, highContext };
+ }, [items]);
+
+ const metrics = [
+ { label: 'Agents', value: stats.uniqueAgents },
+ { label: 'Unread', value: stats.unread },
+ { label: 'Needs Input', value: stats.needsInput },
+ { label: 'Context \u226580%', value: stats.highContext },
+ ];
+
+ return (
+
+ {metrics.map((m) => (
+
+
+ {m.label}
+
+
+ {m.value}
+
+
+ ))}
+
+ );
+}
+
+// ============================================================================
+// Human-readable agent display names for group headers
+// ============================================================================
+const TOOL_TYPE_LABELS: Record = {
+ 'claude-code': 'Claude Code',
+ codex: 'Codex',
+ opencode: 'OpenCode',
+ 'factory-droid': 'Factory Droid',
+ terminal: 'Terminal',
+};
+
+// ============================================================================
+// InboxListView Component
+// ============================================================================
+const SORT_OPTIONS: { value: InboxSortMode; label: string }[] = [
+ { value: 'newest', label: 'Newest' },
+ { value: 'oldest', label: 'Oldest' },
+ { value: 'grouped', label: 'Grouped' },
+ { value: 'byAgent', label: 'By Agent' },
+];
+
+const FILTER_OPTIONS: { value: InboxFilterMode; label: string }[] = [
+ { value: 'all', label: 'All' },
+ { value: 'unread', label: 'Unread' },
+ { value: 'read', label: 'Read' },
+ { value: 'starred', label: 'Starred' },
+];
+
+// Track the identity of the selected row so we can re-find it after rows change
+type RowIdentity =
+ | { type: 'header'; groupKey: string }
+ | { type: 'item'; sessionId: string; tabId: string };
+
+export default function InboxListView({
+ theme,
+ items,
+ selectedIndex,
+ setSelectedIndex,
+ filterMode,
+ setFilterMode,
+ sortMode,
+ setSortMode,
+ onClose,
+ onNavigateToSession,
+ onEnterFocus,
+ containerRef,
+ keyDownRef,
+ isExpanded,
+ onToggleExpanded,
+}: InboxListViewProps) {
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set());
+
+ // Initial mount flag — enables staggered entrance animation only on first render
+ const [isInitialMount, setIsInitialMount] = useState(true);
+ useEffect(() => {
+ const timer = setTimeout(() => setIsInitialMount(false), 600);
+ return () => clearTimeout(timer);
+ }, []);
+
+ // Write state changes back to modalStore for persistence
+ useEffect(() => {
+ const { updateAgentInboxData } = getModalActions();
+ updateAgentInboxData({ filterMode, sortMode, isExpanded });
+ }, [filterMode, sortMode, isExpanded]);
+
+ const toggleGroup = useCallback((groupKey: string) => {
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupKey)) {
+ next.delete(groupKey);
+ } else {
+ next.add(groupKey);
+ }
+ return next;
+ });
+ }, []);
+
+ // Auto-collapse zero-unread agents ONLY on initial transition into byAgent mode.
+ // After that, manual toggles are preserved — items changes do NOT reset collapse state.
+ const prevSortModeRef = useRef(sortMode);
+ useEffect(() => {
+ const prev = prevSortModeRef.current;
+ prevSortModeRef.current = sortMode;
+
+ if (sortMode === 'byAgent' && prev !== 'byAgent') {
+ const agentUnreads = new Map();
+ for (const item of items) {
+ const count = agentUnreads.get(item.sessionId) ?? 0;
+ agentUnreads.set(item.sessionId, count + (item.hasUnread ? 1 : 0));
+ }
+ const toCollapse = new Set();
+ for (const [agent, count] of agentUnreads) {
+ if (count === 0) toCollapse.add(agent);
+ }
+ setCollapsedGroups(toCollapse);
+ } else if (sortMode !== 'byAgent' && prev === 'byAgent') {
+ setCollapsedGroups(new Set());
+ }
+ }, [sortMode, items]);
+
+ const allRows = useMemo(() => buildRows(items, sortMode), [items, sortMode]);
+ const rows = useMemo(() => {
+ if ((sortMode !== 'grouped' && sortMode !== 'byAgent') || collapsedGroups.size === 0)
+ return allRows;
+ return allRows.filter((row) => {
+ if (row.type === 'header') return true;
+ const collapseKey =
+ sortMode === 'byAgent' ? row.item.sessionId : (row.item.groupName ?? 'Ungrouped');
+ return !collapsedGroups.has(collapseKey);
+ });
+ }, [allRows, collapsedGroups, sortMode]);
+
+ // Map from row index to visible-item-number (1-based, only for item rows)
+ // Also build reverse map: visibleItemNumber -> row index (for Cmd+N)
+ const { visibleItemNumbers, visibleItemByNumber } = useMemo(() => {
+ const numbers = new Map(); // rowIndex -> 1-based visible number
+ const byNumber = new Map(); // 1-based visible number -> rowIndex
+ let counter = 0;
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].type === 'item') {
+ counter++;
+ numbers.set(i, counter);
+ byNumber.set(counter, i);
+ }
+ }
+ return { visibleItemNumbers: numbers, visibleItemByNumber: byNumber };
+ }, [rows]);
+
+ // ============================================================================
+ // Row-based navigation — navigates over rows (headers + items), no useListNavigation
+ // ============================================================================
+ // Initialize to first item row (skip leading headers)
+ const firstItemRow = useMemo(() => {
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].type === 'item') return i;
+ }
+ return 0;
+ }, [rows]);
+ const [selectedRowIndex, setSelectedRowIndex] = useState(firstItemRow);
+ const selectedRowIdentityRef = useRef(null);
+
+ // Keep the identity ref in sync with selectedRowIndex
+ useEffect(() => {
+ const row = rows[selectedRowIndex];
+ if (!row) {
+ selectedRowIdentityRef.current = null;
+ return;
+ }
+ if (row.type === 'header') {
+ selectedRowIdentityRef.current = { type: 'header', groupKey: row.groupKey };
+ } else {
+ selectedRowIdentityRef.current = {
+ type: 'item',
+ sessionId: row.item.sessionId,
+ tabId: row.item.tabId,
+ };
+ }
+ }, [selectedRowIndex, rows]);
+
+ // Ref to the scrollable list container
+ const scrollContainerRef = useRef(null);
+ const headerRef = useRef(null);
+
+ // Stabilize selectedRowIndex after rows change (collapse/expand/filter)
+ useEffect(() => {
+ if (rows.length === 0) {
+ setSelectedRowIndex(0);
+ return;
+ }
+
+ const identity = selectedRowIdentityRef.current;
+ if (!identity) return;
+
+ // Check if the current index still points at the same identity
+ const currentRow = rows[selectedRowIndex];
+ if (currentRow) {
+ if (
+ identity.type === 'header' &&
+ currentRow.type === 'header' &&
+ currentRow.groupKey === identity.groupKey
+ ) {
+ return; // Still correct
+ }
+ if (
+ identity.type === 'item' &&
+ currentRow.type === 'item' &&
+ currentRow.item.sessionId === identity.sessionId &&
+ currentRow.item.tabId === identity.tabId
+ ) {
+ return; // Still correct
+ }
+ }
+
+ // Identity drifted — search for the old identity in the new rows
+ for (let i = 0; i < rows.length; i++) {
+ const r = rows[i];
+ if (identity.type === 'header' && r.type === 'header' && r.groupKey === identity.groupKey) {
+ setSelectedRowIndex(i);
+ return;
+ }
+ if (
+ identity.type === 'item' &&
+ r.type === 'item' &&
+ r.item.sessionId === identity.sessionId &&
+ r.item.tabId === identity.tabId
+ ) {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+
+ // Old identity no longer in rows (collapsed away) — find nearest item or clamp
+ const clamped = Math.min(selectedRowIndex, rows.length - 1);
+ // Search downward from clamped position for an item row
+ for (let i = clamped; i < rows.length; i++) {
+ if (rows[i].type === 'item') {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+ // Search upward
+ for (let i = clamped - 1; i >= 0; i--) {
+ if (rows[i].type === 'item') {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+ // Only headers remain — select the first header
+ setSelectedRowIndex(0);
+ }, [rows, selectedRowIndex]);
+
+ // When sort mode or filter mode changes, reset selection to first item row
+ const prevSortForResetRef = useRef(sortMode);
+ const prevFilterForResetRef = useRef(filterMode);
+ useEffect(() => {
+ if (sortMode !== prevSortForResetRef.current || filterMode !== prevFilterForResetRef.current) {
+ prevSortForResetRef.current = sortMode;
+ prevFilterForResetRef.current = filterMode;
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].type === 'item') {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+ setSelectedRowIndex(0);
+ }
+ }, [sortMode, filterMode, rows]);
+
+ // Sync selectedRowIndex -> parent selectedIndex (used by Focus Mode entry)
+ useEffect(() => {
+ const row = rows[selectedRowIndex];
+ if (!row) return;
+
+ if (row.type === 'item') {
+ setSelectedIndex(row.index);
+ return;
+ }
+
+ // Header selected — find nearest item below, then above
+ for (let i = selectedRowIndex + 1; i < rows.length; i++) {
+ const r = rows[i];
+ if (r.type === 'header') break; // hit next group, stop
+ if (r.type === 'item') {
+ setSelectedIndex(r.index);
+ return;
+ }
+ }
+ // No item below in same group — search upward
+ for (let i = selectedRowIndex - 1; i >= 0; i--) {
+ const r = rows[i];
+ if (r.type === 'item') {
+ setSelectedIndex(r.index);
+ return;
+ }
+ }
+ }, [selectedRowIndex, rows, setSelectedIndex]);
+
+ // Guard: ensure parent selectedIndex points to a visible item after collapse
+ useEffect(() => {
+ if (rows.length === 0) return;
+ const visibleItemIndexes = new Set(
+ rows.filter((row) => row.type === 'item').map((row) => row.index)
+ );
+ if (!visibleItemIndexes.has(selectedIndex)) {
+ const firstItemRow = rows.find((row) => row.type === 'item');
+ if (firstItemRow && firstItemRow.type === 'item') {
+ setSelectedIndex(firstItemRow.index);
+ }
+ }
+ }, [rows, selectedIndex, setSelectedIndex]);
+
+ // Row height getter for variable-size rows
+ const getRowHeight = useCallback(
+ (index: number): number => {
+ const row = rows[index];
+ if (!row) return ITEM_HEIGHT;
+ return row.type === 'header' ? GROUP_HEADER_HEIGHT : ITEM_HEIGHT;
+ },
+ [rows]
+ );
+
+ // Calculate list height
+ const listHeight = useMemo(() => {
+ if (typeof window === 'undefined') return 400;
+ if (isExpanded) {
+ return Math.min(
+ window.innerHeight * 0.85 -
+ MODAL_HEADER_HEIGHT -
+ MODAL_FOOTER_HEIGHT -
+ STATS_BAR_HEIGHT -
+ 80,
+ 1000
+ );
+ }
+ return Math.min(
+ window.innerHeight * 0.8 - MODAL_HEADER_HEIGHT - MODAL_FOOTER_HEIGHT - STATS_BAR_HEIGHT - 80,
+ 700
+ );
+ }, [isExpanded]);
+
+ // Virtualizer
+ const virtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => scrollContainerRef.current,
+ estimateSize: getRowHeight,
+ overscan: 5,
+ });
+
+ // Scroll to selected row
+ useEffect(() => {
+ if (rows.length > 0 && selectedRowIndex < rows.length) {
+ virtualizer.scrollToIndex(selectedRowIndex, { align: 'auto' });
+ }
+ }, [selectedRowIndex, rows.length, virtualizer]);
+
+ const handleNavigate = useCallback(
+ (item: InboxItem) => {
+ if (onNavigateToSession) {
+ onNavigateToSession(item.sessionId, item.tabId);
+ }
+ onClose();
+ },
+ [onNavigateToSession, onClose]
+ );
+
+ // Get the selected item's element ID for aria-activedescendant
+ const selectedItemId = useMemo(() => {
+ const row = rows[selectedRowIndex];
+ if (!row || row.type !== 'item') return undefined;
+ return `inbox-item-${row.item.sessionId}-${row.item.tabId}`;
+ }, [rows, selectedRowIndex]);
+
+ // Collect focusable header elements for Tab cycling
+ const getHeaderFocusables = useCallback((): HTMLElement[] => {
+ if (!headerRef.current) return [];
+ return Array.from(headerRef.current.querySelectorAll('button, [tabindex="0"]'));
+ }, []);
+
+ // Row-based keyboard handler — arrows navigate rows (headers + items)
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ // Tab cycling for header controls
+ if (e.key === 'Tab') {
+ const focusables = getHeaderFocusables();
+ if (focusables.length === 0) return;
+ const active = document.activeElement;
+ const focusIdx = focusables.indexOf(active as HTMLElement);
+
+ if (e.shiftKey) {
+ if (focusIdx <= 0) {
+ e.preventDefault();
+ containerRef.current?.focus();
+ } else {
+ e.preventDefault();
+ focusables[focusIdx - 1].focus();
+ }
+ } else {
+ if (focusIdx === -1) {
+ e.preventDefault();
+ focusables[0].focus();
+ } else if (focusIdx >= focusables.length - 1) {
+ e.preventDefault();
+ containerRef.current?.focus();
+ } else {
+ e.preventDefault();
+ focusables[focusIdx + 1].focus();
+ }
+ }
+ return;
+ }
+
+ // Arrow navigation over rows (headers + items)
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (rows.length === 0) return;
+ setSelectedRowIndex((prev) => Math.min(prev + 1, rows.length - 1));
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (rows.length === 0) return;
+ setSelectedRowIndex((prev) => Math.max(prev - 1, 0));
+ return;
+ }
+
+ // T / Enter on a header → toggle group
+ // Enter on an item → navigate to session
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const row = rows[selectedRowIndex];
+ if (!row) return;
+ if (row.type === 'header') {
+ toggleGroup(row.groupKey);
+ } else {
+ handleNavigate(row.item);
+ }
+ return;
+ }
+
+ // T to toggle group (works on headers AND items)
+ if ((e.key === 't' || e.key === 'T') && !e.metaKey && !e.ctrlKey && !e.altKey) {
+ if (sortMode === 'grouped' || sortMode === 'byAgent') {
+ e.preventDefault();
+ const row = rows[selectedRowIndex];
+ if (!row) return;
+ if (row.type === 'header') {
+ toggleGroup(row.groupKey);
+ } else {
+ const groupKey =
+ sortMode === 'byAgent' ? row.item.sessionId : (row.item.groupName ?? 'Ungrouped');
+ toggleGroup(groupKey);
+ }
+ }
+ return;
+ }
+
+ // Cmd/Ctrl+1-9, 0 hotkeys for quick select (visible-item based)
+ if (
+ (e.metaKey || e.ctrlKey) &&
+ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'].includes(e.key)
+ ) {
+ e.preventDefault();
+ const number = e.key === '0' ? 10 : parseInt(e.key);
+ const targetRowIndex = visibleItemByNumber.get(number);
+ if (targetRowIndex !== undefined) {
+ const targetRow = rows[targetRowIndex];
+ if (targetRow && targetRow.type === 'item') {
+ handleNavigate(targetRow.item);
+ }
+ }
+ return;
+ }
+ },
+ [
+ getHeaderFocusables,
+ containerRef,
+ rows,
+ selectedRowIndex,
+ sortMode,
+ visibleItemByNumber,
+ toggleGroup,
+ handleNavigate,
+ ]
+ );
+
+ // Expose keyboard handler to shell via ref
+ useEffect(() => {
+ if (keyDownRef) keyDownRef.current = handleKeyDown;
+ return () => {
+ if (keyDownRef) keyDownRef.current = null;
+ };
+ }, [keyDownRef, handleKeyDown]);
+
+ const actionCount = items.length;
+
+ // Filter-aware count label
+ const countLabel =
+ filterMode === 'unread'
+ ? `${actionCount} unread`
+ : filterMode === 'starred'
+ ? `${actionCount} starred`
+ : filterMode === 'read'
+ ? `${actionCount} read`
+ : `${actionCount} need action`;
+
+ return (
+ <>
+ {/* Header — 80px, two rows */}
+
+ {/* Header row 1: title + badge + close */}
+
+
+
+
+ Unified Inbox
+
+
+ {countLabel}
+
+
+
+ {
+ if (items.length > 0 && items[selectedIndex]) {
+ onEnterFocus(items[selectedIndex]);
+ }
+ }}
+ disabled={items.length === 0}
+ className="text-xs px-2.5 py-1 rounded transition-colors"
+ style={{
+ backgroundColor: items.length > 0 ? `${theme.colors.accent}15` : 'transparent',
+ color: items.length > 0 ? theme.colors.accent : theme.colors.textDim,
+ cursor: items.length > 0 ? 'pointer' : 'default',
+ opacity: items.length === 0 ? 0.5 : 1,
+ }}
+ onMouseEnter={(e) => {
+ if (items.length > 0) {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}25`;
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (items.length > 0) {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}15`;
+ }
+ }}
+ title="Enter Focus Mode (F)"
+ >
+ Focus ▶
+
+ onToggleExpanded((prev) => !prev)}
+ className="p-1.5 rounded"
+ style={{ color: theme.colors.textDim }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)
+ }
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
+ title={isExpanded ? 'Collapse' : 'Expand'}
+ aria-label={isExpanded ? 'Collapse modal' : 'Expand modal'}
+ >
+ {isExpanded ? : }
+
+
+ (e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)
+ }
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
+ onFocus={(e) => {
+ e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.outline = 'none';
+ }}
+ title="Close (Esc)"
+ >
+
+
+
+
+ {/* Header row 2: sort + filter controls */}
+
+
+
+
+
+
+ {/* Stats strip — 32px aggregate metrics */}
+
+
+ {/* Body — virtualized list */}
+ {
+ e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+ e.currentTarget.style.outlineOffset = '-2px';
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.outline = 'none';
+ }}
+ >
+ {rows.length === 0 ? (
+
+ {EMPTY_STATE_MESSAGES[filterMode].showIcon && (
+
+ )}
+
+ {EMPTY_STATE_MESSAGES[filterMode].text}
+
+
+ ) : (
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index];
+ if (!row) return null;
+ const isRowSelected = virtualRow.index === selectedRowIndex;
+
+ if (row.type === 'header') {
+ const isCollapsed = collapsedGroups.has(row.groupKey);
+
+ // For byAgent mode: derive agent type label and unread count from subsequent rows
+ let agentToolType: string | undefined;
+ let unreadCount = 0;
+ if (sortMode === 'byAgent') {
+ for (let i = virtualRow.index + 1; i < rows.length; i++) {
+ const r = rows[i];
+ if (r.type === 'header') break;
+ if (r.type === 'item') {
+ if (!agentToolType) agentToolType = r.item.toolType;
+ if (r.item.hasUnread) unreadCount++;
+ }
+ }
+ }
+
+ return (
+
toggleGroup(row.groupKey)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggleGroup(row.groupKey);
+ }
+ }}
+ >
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+ {row.groupName}
+ {sortMode === 'byAgent' && agentToolType && (
+
+ ({TOOL_TYPE_LABELS[agentToolType] ?? agentToolType})
+
+ )}
+ {sortMode === 'byAgent' && unreadCount > 0 && (
+
+ {unreadCount} unread
+
+ )}
+
+ );
+ }
+
+ const isLastRow = virtualRow.index === rows.length - 1;
+ const visibleNum = visibleItemNumbers.get(virtualRow.index);
+ const showNumber = visibleNum !== undefined && visibleNum >= 1 && visibleNum <= 10;
+ const numberBadge = visibleNum === 10 ? 0 : visibleNum;
+
+ // Stagger animation: only on initial mount, capped at 300ms (10 items)
+ const animationDelay = isInitialMount
+ ? `${Math.min(virtualRow.index * 30, 300)}ms`
+ : undefined;
+
+ return (
+
+
+ {showNumber ? (
+
+ {numberBadge}
+
+ ) : (
+
+ )}
+
+ handleNavigate(row.item)}
+ onDoubleClick={() => onEnterFocus(row.item)}
+ />
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Footer — 36px */}
+
+ {countLabel}
+ {`↑↓ navigate • ${sortMode === 'grouped' || sortMode === 'byAgent' ? 'T collapse • ' : ''}F focus • Enter open • ${formatShortcutKeys(['Meta'])}1-9 quick select • Esc close`}
+
+ >
+ );
+}
diff --git a/src/renderer/components/AgentInbox/index.tsx b/src/renderer/components/AgentInbox/index.tsx
new file mode 100644
index 000000000..4a014e720
--- /dev/null
+++ b/src/renderer/components/AgentInbox/index.tsx
@@ -0,0 +1,366 @@
+/* POLISH-04 Token Audit (@architect)
+ * Line 267: bgActivity for dialog container — CORRECT (content)
+ * All other usages: CORRECT
+ */
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import InboxListView from './InboxListView';
+import FocusModeView from './FocusModeView';
+import type { Theme, Session, Group, ThinkingMode } from '../../types';
+import type {
+ InboxItem,
+ InboxViewMode,
+ InboxFilterMode,
+ InboxSortMode,
+} from '../../types/agent-inbox';
+import { useModalLayer } from '../../hooks/ui/useModalLayer';
+import { MODAL_PRIORITIES } from '../../constants/modalPriorities';
+import { useModalStore, selectModalData } from '../../stores/modalStore';
+import { useAgentInbox } from '../../hooks/useAgentInbox';
+
+// Re-export so existing test imports don't break
+export { resolveContextUsageColor } from './InboxListView';
+
+interface AgentInboxProps {
+ theme: Theme;
+ sessions: Session[];
+ groups: Group[];
+ enterToSendAI?: boolean;
+ onClose: () => void;
+ onNavigateToSession?: (sessionId: string, tabId?: string) => void;
+ onQuickReply?: (sessionId: string, tabId: string, text: string) => void;
+ onOpenAndReply?: (sessionId: string, tabId: string, text: string) => void;
+ onMarkAsRead?: (sessionId: string, tabId: string) => void;
+ onToggleThinking?: (sessionId: string, tabId: string, mode: ThinkingMode) => void;
+}
+
+export default function AgentInbox({
+ theme,
+ sessions,
+ groups,
+ enterToSendAI,
+ onClose,
+ onNavigateToSession,
+ onQuickReply,
+ onOpenAndReply,
+ onMarkAsRead,
+ onToggleThinking,
+}: AgentInboxProps) {
+ // ---- Focus restoration ----
+ // Capture trigger element synchronously during initial render (before child effects)
+ const triggerRef = useRef(
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
+ );
+
+ const handleClose = useCallback(() => {
+ const trigger = triggerRef.current;
+ onClose();
+ // Schedule focus restoration after React unmounts the modal.
+ // No cleanup needed — the RAF fires once post-unmount and is harmless if trigger is gone.
+ requestAnimationFrame(() => {
+ trigger?.focus();
+ });
+ }, [onClose]);
+
+ // ---- View mode state ----
+ const [viewMode, setViewMode] = useState('list');
+ const [focusIndex, setFocusIndex] = useState(0);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ // ---- Filter/sort state (lifted from InboxListView for shared access) ----
+ const inboxData = useModalStore(selectModalData('agentInbox'));
+ const [filterMode, setFilterMode] = useState(
+ (inboxData?.filterMode as InboxFilterMode) ?? 'unread'
+ );
+ const [sortMode, setSortMode] = useState(
+ (inboxData?.sortMode as InboxSortMode) ?? 'newest'
+ );
+
+ // ---- Compute live items (used in list mode) ----
+ const liveItems = useAgentInbox(sessions, groups, filterMode, sortMode);
+
+ // ---- Frozen snapshot for Focus Mode ----
+ // Simple ref-based approach: freeze item order on entry, resolve against live data
+ // for real-time updates (logs, status), but keep the ORDER stable.
+ const frozenOrderRef = useRef<{ sessionId: string; tabId: string }[]>([]);
+
+ // Resolve frozen order against live items (live data, frozen order)
+ const resolveFrozenItems = useCallback(
+ (frozen: { sessionId: string; tabId: string }[]): InboxItem[] => {
+ const liveMap = new Map();
+ for (const item of liveItems) {
+ liveMap.set(`${item.sessionId}:${item.tabId}`, item);
+ }
+ const resolved: InboxItem[] = [];
+ for (const key of frozen) {
+ const live = liveMap.get(`${key.sessionId}:${key.tabId}`);
+ if (live) resolved.push(live);
+ }
+ return resolved.length > 0 ? resolved : liveItems;
+ },
+ [liveItems]
+ );
+
+ // Items: in focus mode use frozen-order resolved against live data, else live
+ const items =
+ viewMode === 'focus' && frozenOrderRef.current.length > 0
+ ? resolveFrozenItems(frozenOrderRef.current)
+ : liveItems;
+
+ const handleSetFilterMode = useCallback(
+ (mode: InboxFilterMode) => {
+ setFilterMode(mode);
+ if (viewMode === 'focus') {
+ // Re-snapshot: resolve new filter results immediately
+ // We need a render with the new filterMode first, so defer the snapshot
+ frozenOrderRef.current = [];
+ setFocusIndex(0);
+ }
+ },
+ [viewMode]
+ );
+
+ const handleExitFocus = useCallback(() => {
+ setViewMode('list');
+ frozenOrderRef.current = [];
+ }, []);
+
+ // Re-snapshot after filter change empties the ref (needs liveItems from new filter)
+ useEffect(() => {
+ if (viewMode === 'focus' && frozenOrderRef.current.length === 0 && liveItems.length > 0) {
+ frozenOrderRef.current = liveItems.map((i) => ({
+ sessionId: i.sessionId,
+ tabId: i.tabId,
+ }));
+ }
+ }, [viewMode, liveItems]);
+
+ // ---- Edge case: items shrink while in focus mode ----
+ useEffect(() => {
+ if (viewMode === 'focus' && items.length > 0 && focusIndex >= items.length) {
+ setFocusIndex(items.length - 1);
+ }
+ }, [items.length, focusIndex, viewMode]);
+
+ const handleEnterFocus = useCallback(
+ (item: InboxItem) => {
+ const idx = liveItems.findIndex(
+ (i) => i.sessionId === item.sessionId && i.tabId === item.tabId
+ );
+ setFocusIndex(idx >= 0 ? idx : 0);
+ // Freeze current order as simple identity pairs
+ frozenOrderRef.current = liveItems.map((i) => ({
+ sessionId: i.sessionId,
+ tabId: i.tabId,
+ }));
+ setViewMode('focus');
+ },
+ [liveItems]
+ );
+
+ // ---- Navigate item wrapper ----
+ const handleNavigateItem = useCallback((idx: number) => {
+ setFocusIndex(idx);
+ }, []);
+
+ // ---- Layer stack: viewMode-aware Escape ----
+ const handleLayerEscape = useCallback(() => {
+ if (viewMode === 'focus') {
+ handleExitFocus();
+ } else {
+ handleClose();
+ }
+ }, [viewMode, handleExitFocus, handleClose]);
+
+ useModalLayer(MODAL_PRIORITIES.AGENT_INBOX, 'Unified Inbox', handleLayerEscape);
+
+ // ---- Container ref for keyboard focus ----
+ const containerRef = useRef(null);
+
+ // Auto-focus container on mount for immediate keyboard navigation
+ useEffect(() => {
+ const raf = requestAnimationFrame(() => {
+ containerRef.current?.focus();
+ });
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ // ---- Expanded state (lifted to shell for dialog width control) ----
+ const [isExpanded, setIsExpanded] = useState(inboxData?.isExpanded ?? false);
+
+ // ---- Compute dialog dimensions (focus mode or expanded → wide) ----
+ const isWide = isExpanded || viewMode === 'focus';
+ const expandedWidth = Math.min(
+ typeof window !== 'undefined' ? window.innerWidth * 0.92 : 1400,
+ 1400
+ );
+ const dialogWidth = viewMode === 'focus' ? expandedWidth : isWide ? expandedWidth : 780;
+ const dialogHeight = viewMode === 'focus' ? '85vh' : undefined;
+ const dialogMaxHeight = viewMode === 'focus' ? undefined : isWide ? '90vh' : '80vh';
+
+ // ---- Keyboard handler ref from InboxListView ----
+ const listKeyDownRef = useRef<((e: React.KeyboardEvent) => void) | null>(null);
+
+ // ---- CAPTURE phase: Cmd+[/] must fire BEFORE textarea consumes the event ----
+ const handleCaptureKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (viewMode !== 'focus') return;
+ if ((e.metaKey || e.ctrlKey) && (e.key === '[' || e.key === ']')) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (items.length > 1) {
+ setFocusIndex((prev) =>
+ e.key === '[' ? Math.max(prev - 1, 0) : Math.min(prev + 1, items.length - 1)
+ );
+ }
+ }
+ },
+ [viewMode, items]
+ );
+
+ // ---- BUBBLE phase: all other keyboard shortcuts ----
+ const handleShellKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (viewMode === 'focus') {
+ switch (e.key) {
+ case 'Escape':
+ e.preventDefault();
+ e.stopPropagation();
+ handleExitFocus();
+ return;
+ case 'Backspace':
+ case 'b':
+ case 'B':
+ // Guard: only exit if NOT typing in the reply textarea
+ if (document.activeElement?.tagName !== 'TEXTAREA') {
+ e.preventDefault();
+ handleExitFocus();
+ }
+ return;
+ }
+ return;
+ }
+
+ // List mode: F to enter focus
+ if ((e.key === 'f' || e.key === 'F') && !e.metaKey && !e.ctrlKey && !e.altKey) {
+ e.preventDefault();
+ if (items.length > 0 && items[selectedIndex]) {
+ handleEnterFocus(items[selectedIndex]);
+ }
+ return;
+ }
+
+ // Delegate only when the shell container itself is focused.
+ // Avoid re-processing events already handled inside InboxListView.
+ if (
+ viewMode === 'list' &&
+ listKeyDownRef.current &&
+ !e.defaultPrevented &&
+ e.target === e.currentTarget
+ ) {
+ e.stopPropagation();
+ listKeyDownRef.current(e);
+ }
+ },
+ [viewMode, items, selectedIndex, handleEnterFocus, handleExitFocus]
+ );
+
+ return (
+
+
e.stopPropagation()}
+ onKeyDownCapture={handleCaptureKeyDown}
+ onKeyDown={handleShellKeyDown}
+ onFocus={() => {}}
+ onBlur={() => {}}
+ >
+
+ {viewMode === 'list' ? (
+
+ ) : items[focusIndex] ? (
+
+ ) : (
+
+
+ {filterMode === 'unread'
+ ? 'No unread items'
+ : filterMode === 'starred'
+ ? 'No starred items'
+ : 'No items to focus on'}
+
+ {filterMode !== 'all' && (
+ handleSetFilterMode('all')}
+ className="text-xs px-3 py-1.5 rounded-full"
+ style={{
+ backgroundColor: `${theme.colors.accent}20`,
+ color: theme.colors.accent,
+ border: 'none',
+ cursor: 'pointer',
+ }}
+ >
+ Show all items
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx
index fb8de50c2..3088ed4fe 100644
--- a/src/renderer/components/AppModals.tsx
+++ b/src/renderer/components/AppModals.tsx
@@ -859,6 +859,9 @@ export interface AppUtilityModalsProps {
// Director's Notes
onOpenDirectorNotes?: () => void;
+ // Unified Inbox
+ onOpenUnifiedInbox?: () => void;
+
// Auto-scroll
autoScrollAiMode?: boolean;
setAutoScrollAiMode?: (value: boolean) => void;
@@ -1060,6 +1063,8 @@ export const AppUtilityModals = memo(function AppUtilityModals({
onOpenSymphony,
// Director's Notes
onOpenDirectorNotes,
+ // Unified Inbox
+ onOpenUnifiedInbox,
// Auto-scroll
autoScrollAiMode,
setAutoScrollAiMode,
@@ -1218,6 +1223,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({
onOpenLastDocumentGraph={onOpenLastDocumentGraph}
onOpenSymphony={onOpenSymphony}
onOpenDirectorNotes={onOpenDirectorNotes}
+ onOpenUnifiedInbox={onOpenUnifiedInbox}
autoScrollAiMode={autoScrollAiMode}
setAutoScrollAiMode={setAutoScrollAiMode}
/>
@@ -2013,6 +2019,8 @@ export interface AppModalsProps {
onOpenSymphony?: () => void;
// Director's Notes
onOpenDirectorNotes?: () => void;
+ // Unified Inbox
+ onOpenUnifiedInbox?: () => void;
// Auto-scroll
autoScrollAiMode?: boolean;
setAutoScrollAiMode?: (value: boolean) => void;
@@ -2337,6 +2345,8 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) {
onOpenSymphony,
// Director's Notes
onOpenDirectorNotes,
+ // Unified Inbox
+ onOpenUnifiedInbox,
// Auto-scroll
autoScrollAiMode,
setAutoScrollAiMode,
@@ -2651,6 +2661,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) {
onOpenMarketplace={onOpenMarketplace}
onOpenSymphony={onOpenSymphony}
onOpenDirectorNotes={onOpenDirectorNotes}
+ onOpenUnifiedInbox={onOpenUnifiedInbox}
autoScrollAiMode={autoScrollAiMode}
setAutoScrollAiMode={setAutoScrollAiMode}
tabSwitcherOpen={tabSwitcherOpen}
diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx
index 144c8e229..a377db5df 100644
--- a/src/renderer/components/QuickActionsModal.tsx
+++ b/src/renderer/components/QuickActionsModal.tsx
@@ -117,6 +117,8 @@ interface QuickActionsModalProps {
onOpenSymphony?: () => void;
// Director's Notes
onOpenDirectorNotes?: () => void;
+ // Unified Inbox
+ onOpenUnifiedInbox?: () => void;
// Auto-scroll
autoScrollAiMode?: boolean;
setAutoScrollAiMode?: (value: boolean) => void;
@@ -204,6 +206,7 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct
onOpenLastDocumentGraph,
onOpenSymphony,
onOpenDirectorNotes,
+ onOpenUnifiedInbox,
autoScrollAiMode,
setAutoScrollAiMode,
} = props;
@@ -1034,6 +1037,21 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct
},
]
: []),
+ // Unified Inbox - cross-session notification center
+ ...(onOpenUnifiedInbox
+ ? [
+ {
+ id: 'unifiedInbox',
+ label: 'Unified Inbox',
+ shortcut: shortcuts.agentInbox,
+ subtext: 'Cross-session notification center',
+ action: () => {
+ onOpenUnifiedInbox();
+ setQuickActionOpen(false);
+ },
+ },
+ ]
+ : []),
// Auto-scroll toggle
...(setAutoScrollAiMode
? [
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index 6bb322cec..c5ea3673d 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -36,6 +36,7 @@ import {
Server,
Music,
Command,
+ Inbox,
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import type {
@@ -54,7 +55,12 @@ import { getStatusColor, getContextColor, formatActiveTime } from '../utils/them
import { formatShortcutKeys } from '../utils/shortcutFormatter';
import { SessionItem } from './SessionItem';
import { GroupChatList } from './GroupChatList';
-import { useLiveOverlay, useClickOutside, useResizablePanel, useContextMenuPosition } from '../hooks';
+import {
+ useLiveOverlay,
+ useClickOutside,
+ useResizablePanel,
+ useContextMenuPosition,
+} from '../hooks';
import { useGitFileStatus } from '../contexts/GitStatusContext';
import { useUIStore } from '../stores/uiStore';
@@ -440,6 +446,7 @@ interface HamburgerMenuContentProps {
setUsageDashboardOpen: (open: boolean) => void;
setSymphonyModalOpen: (open: boolean) => void;
setDirectorNotesOpen?: (open: boolean) => void;
+ setAgentInboxOpen?: (open: boolean) => void;
setUpdateCheckModalOpen: (open: boolean) => void;
setAboutModalOpen: (open: boolean) => void;
setMenuOpen: (open: boolean) => void;
@@ -460,6 +467,7 @@ function HamburgerMenuContent({
setUsageDashboardOpen,
setSymphonyModalOpen,
setDirectorNotesOpen,
+ setAgentInboxOpen,
setUpdateCheckModalOpen,
setAboutModalOpen,
setMenuOpen,
@@ -726,6 +734,33 @@ function HamburgerMenuContent({
)}
)}
+ {setAgentInboxOpen && (
+ {
+ setAgentInboxOpen(true);
+ setMenuOpen(false);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
+ >
+
+
+
+ Unified Inbox
+
+
+ Cross-agent notifications
+
+
+ {shortcuts.agentInbox && (
+
+ {formatShortcutKeys(shortcuts.agentInbox.keys)}
+
+ )}
+
+ )}
{
@@ -1079,6 +1114,7 @@ interface SessionListProps {
setUsageDashboardOpen: (open: boolean) => void;
setSymphonyModalOpen: (open: boolean) => void;
setDirectorNotesOpen?: (open: boolean) => void;
+ setAgentInboxOpen?: (open: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
toggleGroup: (groupId: string) => void;
handleDragStart: (sessionId: string) => void;
@@ -1204,6 +1240,7 @@ function SessionListInner(props: SessionListProps) {
setUsageDashboardOpen,
setSymphonyModalOpen,
setDirectorNotesOpen,
+ setAgentInboxOpen,
setQuickActionOpen,
toggleGroup,
handleDragStart,
@@ -2074,7 +2111,7 @@ function SessionListInner(props: SessionListProps) {
className="text-[11px] leading-relaxed"
style={{ color: theme.colors.textDim }}
>
- Control your AI sessions from your phone or tablet.
+ Control your AI agents from your phone or tablet.
{tunnelStatus === 'connected' ? (
{' '}
@@ -2503,6 +2540,7 @@ function SessionListInner(props: SessionListProps) {
setUsageDashboardOpen={setUsageDashboardOpen}
setSymphonyModalOpen={setSymphonyModalOpen}
setDirectorNotesOpen={setDirectorNotesOpen}
+ setAgentInboxOpen={setAgentInboxOpen}
setUpdateCheckModalOpen={setUpdateCheckModalOpen}
setAboutModalOpen={setAboutModalOpen}
setMenuOpen={setMenuOpen}
@@ -2548,6 +2586,7 @@ function SessionListInner(props: SessionListProps) {
setUsageDashboardOpen={setUsageDashboardOpen}
setSymphonyModalOpen={setSymphonyModalOpen}
setDirectorNotesOpen={setDirectorNotesOpen}
+ setAgentInboxOpen={setAgentInboxOpen}
setUpdateCheckModalOpen={setUpdateCheckModalOpen}
setAboutModalOpen={setAboutModalOpen}
setMenuOpen={setMenuOpen}
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index e67f7b9ea..c41908f29 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -37,6 +37,7 @@ import {
Clapperboard,
HelpCircle,
AppWindow,
+ Inbox,
} from 'lucide-react';
import { useSettings } from '../hooks';
import type {
@@ -3252,6 +3253,8 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
>
{/* Feature Toggle Header */}
setEncoreFeatures({
@@ -3638,6 +3641,91 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
);
})()}
+
+ {/* Unified Inbox Feature Section */}
+
+ {/* Feature Toggle Header */}
+
+ setEncoreFeatures({
+ ...encoreFeatures,
+ unifiedInbox: !encoreFeatures.unifiedInbox,
+ })
+ }
+ >
+
+
+
+
+ Unified Inbox
+
+ Beta
+
+
+
+ Cross-session notification center with keyboard-first navigation
+
+
+
+
+
+
+ {/* Unified Inbox Info (shown when enabled) */}
+ {encoreFeatures.unifiedInbox && (
+
+
+ Access via Option+I (Mac) or Alt+I (Windows). Aggregates status updates,
+ errors, and action items from all running agents into a single
+ keyboard-navigable view.
+
+
+ )}
+
)}
diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts
index a8222eb46..e5fd60556 100644
--- a/src/renderer/constants/modalPriorities.ts
+++ b/src/renderer/constants/modalPriorities.ts
@@ -191,6 +191,9 @@ export const MODAL_PRIORITIES = {
/** Update check modal */
UPDATE_CHECK: 610,
+ /** Unified Inbox modal — agent inbox with focus mode */
+ AGENT_INBOX: 555,
+
/** Process monitor modal */
PROCESS_MONITOR: 550,
diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts
index 37f530386..cdee93147 100644
--- a/src/renderer/constants/shortcuts.ts
+++ b/src/renderer/constants/shortcuts.ts
@@ -78,6 +78,7 @@ export const DEFAULT_SHORTCUTS: Record = {
label: "Director's Notes",
keys: ['Meta', 'Shift', 'o'],
},
+ agentInbox: { id: 'agentInbox', label: 'Unified Inbox', keys: ['Alt', 'i'] },
};
// Non-editable shortcuts (displayed in help but not configurable)
diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts
index 93675698d..30501b1ac 100644
--- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts
+++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts
@@ -420,6 +420,14 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
e.preventDefault();
ctx.setDirectorNotesOpen?.(true);
trackShortcut('directorNotes');
+ } else if (
+ ctx.isShortcut(e, 'agentInbox') &&
+ ctx.encoreFeatures?.unifiedInbox &&
+ ctx.setAgentInboxOpen
+ ) {
+ e.preventDefault();
+ ctx.setAgentInboxOpen(true);
+ trackShortcut('agentInbox');
} else if (ctx.isShortcut(e, 'jumpToBottom')) {
e.preventDefault();
// Jump to the bottom of the current main panel output (AI logs or terminal output)
diff --git a/src/renderer/hooks/props/useSessionListProps.ts b/src/renderer/hooks/props/useSessionListProps.ts
index 1e04ab179..03e788d29 100644
--- a/src/renderer/hooks/props/useSessionListProps.ts
+++ b/src/renderer/hooks/props/useSessionListProps.ts
@@ -92,6 +92,7 @@ export interface UseSessionListPropsDeps {
setUsageDashboardOpen: (open: boolean) => void;
setSymphonyModalOpen: (open: boolean) => void;
setDirectorNotesOpen?: (open: boolean) => void;
+ setAgentInboxOpen?: (open: boolean) => void;
setGroups: React.Dispatch>;
setSessions: React.Dispatch>;
setRenameInstanceModalOpen: (open: boolean) => void;
@@ -201,6 +202,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
setUsageDashboardOpen: deps.setUsageDashboardOpen,
setSymphonyModalOpen: deps.setSymphonyModalOpen,
setDirectorNotesOpen: deps.setDirectorNotesOpen,
+ setAgentInboxOpen: deps.setAgentInboxOpen,
setQuickActionOpen: deps.setQuickActionOpen,
// Handlers
@@ -331,6 +333,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
deps.setUsageDashboardOpen,
deps.setSymphonyModalOpen,
deps.setDirectorNotesOpen,
+ deps.setAgentInboxOpen,
deps.setQuickActionOpen,
deps.setGroups,
deps.setSessions,
diff --git a/src/renderer/hooks/useAgentInbox.ts b/src/renderer/hooks/useAgentInbox.ts
new file mode 100644
index 000000000..a3fcbfa6b
--- /dev/null
+++ b/src/renderer/hooks/useAgentInbox.ts
@@ -0,0 +1,246 @@
+import { useMemo } from 'react';
+import type { Session, Group } from '../types';
+import type { InboxItem, InboxFilterMode, InboxSortMode } from '../types/agent-inbox';
+
+const MAX_MESSAGE_LENGTH = 90;
+const DEFAULT_MESSAGE = 'No activity yet';
+const ELLIPSIS = '...';
+
+/**
+ * Determines whether a session/tab combination should be included
+ * based on the current filter mode.
+ */
+function matchesFilter(
+ sessionState: Session['state'],
+ hasUnread: boolean,
+ filterMode: InboxFilterMode,
+ isStarred: boolean
+): boolean {
+ switch (filterMode) {
+ case 'all':
+ return true;
+ case 'unread':
+ return hasUnread === true;
+ case 'read':
+ return hasUnread === false;
+ case 'starred':
+ return isStarred === true;
+ default:
+ return false;
+ }
+}
+
+/**
+ * Computes a display name for a tab.
+ * Uses tab.name if set, otherwise falls back to "Tab N".
+ */
+function getTabDisplayName(
+ tab: { name: string | null; agentSessionId: string | null; id: string },
+ tabIndex: number
+): string {
+ if (tab.name) return tab.name;
+ return `Tab ${tabIndex + 1}`;
+}
+
+/**
+ * Truncates text to MAX_MESSAGE_LENGTH with ellipsis.
+ */
+export function truncate(text: string): string {
+ if (text.length <= MAX_MESSAGE_LENGTH) return text;
+ const maxWithoutEllipsis = Math.max(0, MAX_MESSAGE_LENGTH - ELLIPSIS.length);
+ return text.slice(0, maxWithoutEllipsis) + ELLIPSIS;
+}
+
+/**
+ * Extracts the first sentence from text (up to the first period, exclamation, or newline).
+ */
+function firstSentence(text: string): string {
+ const match = text.match(/^[^.!?\n]+[.!?]?/);
+ return match ? match[0].trim() : text.trim();
+}
+
+/**
+ * Generates a 1-line smart summary from a tab's logs and session state.
+ *
+ * Rules:
+ * - waiting_input: "Waiting: " + last AI message snippet
+ * - Last AI message ends with "?": show the question directly
+ * - Last AI message is a statement: "Done: " + first sentence
+ * - Empty logs: "No activity yet"
+ */
+export function generateSmartSummary(
+ logs: Session['aiLogs'] | undefined,
+ sessionState: Session['state']
+): string {
+ const safeLogs = logs ?? [];
+ if (safeLogs.length === 0) return DEFAULT_MESSAGE;
+
+ // Look at the last 3 entries to find the most recent AI message
+ const recentLogs = safeLogs.slice(-3);
+ let lastAiText: string | undefined;
+ for (let i = recentLogs.length - 1; i >= 0; i--) {
+ const entry = recentLogs[i];
+ if (!entry?.text) continue;
+ if (entry.source === 'ai') {
+ lastAiText = entry.text.trim();
+ break;
+ }
+ }
+
+ // If session is waiting for input, prefix with "Waiting: "
+ if (sessionState === 'waiting_input') {
+ if (lastAiText) return truncate('Waiting: ' + lastAiText);
+ return truncate('Waiting: awaiting your response');
+ }
+
+ // If we found an AI message
+ if (lastAiText) {
+ // If it ends with a question mark, show the question directly
+ if (lastAiText.endsWith('?')) return truncate(lastAiText);
+ // Statement — prefix with "Done: " + first sentence
+ return truncate('Done: ' + firstSentence(lastAiText));
+ }
+
+ // Fallback: use last log entry text regardless of source
+ const lastLog = safeLogs[safeLogs.length - 1];
+ if (lastLog?.text) return truncate(lastLog.text);
+
+ return DEFAULT_MESSAGE;
+}
+
+/**
+ * Derives a valid timestamp from available data.
+ * Falls back through: last log entry → tab createdAt → Date.now()
+ */
+function deriveTimestamp(logs: Session['aiLogs'] | undefined, tabCreatedAt: number): number {
+ // Try last log entry timestamp
+ if (logs && logs.length > 0) {
+ const lastTs = logs[logs.length - 1]?.timestamp;
+ if (lastTs && Number.isFinite(lastTs) && lastTs > 0) return lastTs;
+ }
+ // Try tab createdAt
+ if (Number.isFinite(tabCreatedAt) && tabCreatedAt > 0) return tabCreatedAt;
+ // Fallback
+ return Date.now();
+}
+
+/**
+ * Sorts InboxItems based on the selected sort mode.
+ */
+function sortItems(items: InboxItem[], sortMode: InboxSortMode): InboxItem[] {
+ const sorted = [...items];
+ switch (sortMode) {
+ case 'newest':
+ sorted.sort((a, b) => b.timestamp - a.timestamp);
+ break;
+ case 'oldest':
+ sorted.sort((a, b) => a.timestamp - b.timestamp);
+ break;
+ case 'grouped':
+ sorted.sort((a, b) => {
+ // Ungrouped (no groupName) goes last
+ const aGroup = a.groupName ?? '\uffff';
+ const bGroup = b.groupName ?? '\uffff';
+ const groupCompare = aGroup.localeCompare(bGroup);
+ if (groupCompare !== 0) return groupCompare;
+ // Within same group, sort by timestamp descending
+ return b.timestamp - a.timestamp;
+ });
+ break;
+ case 'byAgent': {
+ // Step 1: Group items by sessionId (unique) — sessionName is display-only
+ const agentGroups = new Map();
+ for (const item of sorted) {
+ const key = item.sessionId;
+ if (!agentGroups.has(key)) agentGroups.set(key, { label: item.sessionName, items: [] });
+ agentGroups.get(key)!.items.push(item);
+ }
+
+ // Step 2: Pre-compute metadata per group
+ const groupMeta: { key: string; label: string; unreadCount: number; items: InboxItem[] }[] =
+ [];
+ for (const [key, group] of agentGroups) {
+ const unreadCount = group.items.filter((i) => i.hasUnread).length;
+ // Sort items within group: newest first
+ group.items.sort((a, b) => b.timestamp - a.timestamp);
+ groupMeta.push({ key, label: group.label, unreadCount, items: group.items });
+ }
+
+ // Step 3: Sort groups — unreads first (by count desc), then zero-unreads (alphabetical by label)
+ groupMeta.sort((a, b) => {
+ if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
+ if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
+ if (a.unreadCount > 0 && b.unreadCount > 0) return b.unreadCount - a.unreadCount;
+ return a.label.localeCompare(b.label);
+ });
+
+ // Step 4: Flatten back
+ sorted.length = 0;
+ for (const group of groupMeta) {
+ sorted.push(...group.items);
+ }
+ break;
+ }
+ }
+ return sorted;
+}
+
+/**
+ * Data aggregation hook for Agent Inbox.
+ *
+ * Iterates all sessions and their AI tabs, filters based on session state
+ * and tab unread status, then sorts the resulting InboxItems.
+ *
+ * Uses useMemo with exact dependency values (not refs) to prevent stale data.
+ */
+export function useAgentInbox(
+ sessions: Session[],
+ groups: Group[],
+ filterMode: InboxFilterMode,
+ sortMode: InboxSortMode
+): InboxItem[] {
+ return useMemo(() => {
+ // Build group lookup map for O(1) access
+ const groupMap = new Map();
+ for (const group of groups) {
+ groupMap.set(group.id, group);
+ }
+
+ const items: InboxItem[] = [];
+
+ for (const session of sessions) {
+ // Skip sessions with falsy id
+ if (!session.id) continue;
+
+ const tabs = session.aiTabs ?? [];
+
+ for (let tabIdx = 0; tabIdx < tabs.length; tabIdx++) {
+ const tab = tabs[tabIdx];
+ const hasUnread = tab.hasUnread === true;
+
+ if (!matchesFilter(session.state, hasUnread, filterMode, tab.starred === true)) continue;
+
+ const parentGroup = session.groupId ? groupMap.get(session.groupId) : undefined;
+
+ items.push({
+ sessionId: session.id,
+ tabId: tab.id,
+ groupId: session.groupId ?? undefined,
+ groupName: parentGroup?.name ?? undefined,
+ sessionName: session.name,
+ tabName: getTabDisplayName(tab, tabIdx),
+ toolType: session.toolType,
+ gitBranch: session.worktreeBranch ?? undefined,
+ contextUsage: session.contextUsage ?? undefined,
+ lastMessage: generateSmartSummary(tab.logs, session.state),
+ timestamp: deriveTimestamp(tab.logs, tab.createdAt),
+ state: session.state,
+ hasUnread,
+ starred: tab.starred === true,
+ });
+ }
+ }
+
+ return sortItems(items, sortMode);
+ }, [sessions, groups, filterMode, sortMode]);
+}
diff --git a/src/renderer/stores/modalStore.ts b/src/renderer/stores/modalStore.ts
index b385ea745..c3fc8a886 100644
--- a/src/renderer/stores/modalStore.ts
+++ b/src/renderer/stores/modalStore.ts
@@ -138,6 +138,15 @@ export interface KeyboardMasteryData {
level: number;
}
+// Note: filterMode/sortMode typed as string (not InboxFilterMode/InboxSortMode)
+// to avoid circular dependency with types/agent-inbox.ts
+/** Agent Inbox modal data (persisted filter/sort/expand state) */
+export interface AgentInboxModalData {
+ filterMode?: string;
+ sortMode?: string;
+ isExpanded?: boolean;
+}
+
// ============================================================================
// Modal ID Registry
// ============================================================================
@@ -218,7 +227,9 @@ export type ModalId =
// Platform Warnings
| 'windowsWarning'
// Director's Notes
- | 'directorNotes';
+ | 'directorNotes'
+ // Agent Inbox
+ | 'agentInbox';
/**
* Type mapping from ModalId to its data type.
@@ -249,6 +260,7 @@ export interface ModalDataMap {
firstRunCelebration: FirstRunCelebrationData;
keyboardMastery: KeyboardMasteryData;
lightbox: LightboxData;
+ agentInbox: AgentInboxModalData;
}
// Helper type to get data type for a modal ID
@@ -353,9 +365,11 @@ export const useModalStore = create()((set, get) => ({
updateModalData: (id, data) => {
set((state) => {
const current = state.modals.get(id);
- if (!current || !current.data) return state;
+ if (!current) return state;
const newModals = new Map(state.modals);
- const mergedData = Object.assign({}, current.data, data);
+
+ const baseData = current.data ?? {};
+ const mergedData = Object.assign({}, baseData, data);
newModals.set(id, {
...current,
data: mergedData,
@@ -651,6 +665,11 @@ export function getModalActions() {
}
},
+ // Agent Inbox Modal
+ setAgentInboxOpen: (open: boolean) =>
+ open ? openModal('agentInbox') : closeModal('agentInbox'),
+ updateAgentInboxData: (data: Record) => updateModalData('agentInbox', data),
+
// Agent Sessions Browser
setAgentSessionsOpen: (open: boolean) =>
open
@@ -846,6 +865,7 @@ export function useModalActions() {
const symphonyModalOpen = useModalStore(selectModalOpen('symphony'));
const windowsWarningModalOpen = useModalStore(selectModalOpen('windowsWarning'));
const directorNotesOpen = useModalStore(selectModalOpen('directorNotes'));
+ const agentInboxOpen = useModalStore(selectModalOpen('agentInbox'));
// Get stable actions
const actions = getModalActions();
@@ -1014,6 +1034,9 @@ export function useModalActions() {
// Director's Notes Modal
directorNotesOpen,
+ // Agent Inbox Modal
+ agentInboxOpen,
+
// Lightbox ref replacements (now stored as data)
lightboxIsGroupChat: lightboxData?.isGroupChat ?? false,
lightboxAllowDelete: lightboxData?.allowDelete ?? false,
diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts
index c10e6071e..5e58cc4ba 100644
--- a/src/renderer/stores/settingsStore.ts
+++ b/src/renderer/stores/settingsStore.ts
@@ -109,6 +109,7 @@ export const DEFAULT_ONBOARDING_STATS: OnboardingStats = {
export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = {
directorNotes: false,
+ unifiedInbox: false,
};
export const DEFAULT_DIRECTOR_NOTES_SETTINGS: DirectorNotesSettings = {
diff --git a/src/renderer/types/agent-inbox.ts b/src/renderer/types/agent-inbox.ts
new file mode 100644
index 000000000..c677396c2
--- /dev/null
+++ b/src/renderer/types/agent-inbox.ts
@@ -0,0 +1,47 @@
+import type { SessionState } from './index';
+
+export interface InboxItem {
+ sessionId: string;
+ tabId: string;
+ groupId?: string;
+ groupName?: string;
+ sessionName: string;
+ tabName?: string;
+ toolType: string;
+ gitBranch?: string;
+ contextUsage?: number; // 0-100, undefined = unknown
+ lastMessage: string; // truncated to 90 chars
+ timestamp: number; // Unix ms, must be validated > 0
+ state: SessionState;
+ hasUnread: boolean;
+ starred?: boolean;
+}
+
+/** UI labels: "Newest", "Oldest", "Grouped", "By Agent" */
+export type InboxSortMode = 'newest' | 'oldest' | 'grouped' | 'byAgent';
+
+/** UI labels: "All", "Unread", "Read", "Starred" */
+export type InboxFilterMode = 'all' | 'unread' | 'read' | 'starred';
+
+/** Human-readable status badges */
+export const STATUS_LABELS: Record = {
+ idle: 'Ready',
+ waiting_input: 'Needs Input',
+ busy: 'Processing',
+ connecting: 'Connecting',
+ error: 'Error',
+};
+
+// Note: Values are not keyof ThemeColors — 'info' and 'textMuted' are resolved
+// through a local colorMap in resolveStatusColor(), not direct ThemeColors keys.
+/** Status badge color keys (map to theme.colors.* via resolveStatusColor()) */
+export const STATUS_COLORS: Record = {
+ idle: 'success',
+ waiting_input: 'warning',
+ busy: 'info',
+ connecting: 'textMuted',
+ error: 'error',
+};
+
+/** View mode inside the AgentInbox modal */
+export type InboxViewMode = 'list' | 'focus';
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index 74fdb2b14..163c64761 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -905,6 +905,7 @@ export interface LeaderboardSubmitResponse {
// Each key is a feature ID, value indicates whether it's enabled
export interface EncoreFeatureFlags {
directorNotes: boolean;
+ unifiedInbox: boolean;
}
// Director's Notes settings for synopsis generation