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 && ( + ); + } + ``` + + 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 ` +
+ + {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 ( + + ); + })} +
+ ); +} + +// ============================================================================ +// 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 */} + + + {/* 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 */} + +
+ + {/* 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 */} + +
+ + {/* 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 */} +
+