From 8b4d71514f156841e5bd9b4cac806ce50cc5d5f5 Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 08:14:14 -0300 Subject: [PATCH 01/24] MAESTRO: port agent-inbox shared types from feature/inbox-focus-polish Adds src/renderer/types/agent-inbox.ts with InboxItem, InboxFilterMode, InboxSortMode, InboxViewMode, STATUS_LABELS, and STATUS_COLORS. All imports resolve against current codebase (SessionState at types/index.ts:53). Co-Authored-By: Claude Opus 4.6 --- src/renderer/types/agent-inbox.ts | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/renderer/types/agent-inbox.ts diff --git a/src/renderer/types/agent-inbox.ts b/src/renderer/types/agent-inbox.ts new file mode 100644 index 000000000..736a5dcc7 --- /dev/null +++ b/src/renderer/types/agent-inbox.ts @@ -0,0 +1,45 @@ +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', +}; + +/** Status badge color keys (map to theme.colors.*) */ +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'; From 8d9174559a6a11df509a4aace88caef59165cca9 Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 08:15:27 -0300 Subject: [PATCH 02/24] MAESTRO: add unifiedInbox flag to EncoreFeatureFlags Register Unified Inbox as an Encore Feature with type flag (boolean) and default value (false) in both EncoreFeatureFlags interface and DEFAULT_ENCORE_FEATURES constant. Co-Authored-By: Claude Opus 4.6 --- .../ENCORE-INBOX-02.md | 34 +++++++++++++++++++ src/renderer/stores/settingsStore.ts | 1 + src/renderer/types/index.ts | 1 + 3 files changed, 36 insertions(+) create mode 100644 playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-02.md 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/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/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 From 10d97be44080d17e44c8c4e2e03e35161c5ec00d Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 08:17:30 -0300 Subject: [PATCH 03/24] MAESTRO: add Unified Inbox toggle card in Settings Encore tab Co-Authored-By: Claude Opus 4.6 --- src/renderer/components/SettingsModal.tsx | 84 +++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index e67f7b9ea..2f8b71a1d 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 { @@ -3638,6 +3639,89 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro ); })()} + + {/* Unified Inbox Feature Section */} +
+ {/* Feature Toggle Header */} + + + {/* 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. +

+
+ )} +
)} From 4cf633c57cb550f6ccdb919aa777bfbe6da69926 Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Thu, 26 Feb 2026 08:24:55 -0300 Subject: [PATCH 04/24] MAESTRO: port useAgentInbox hook and AgentInbox components from feature branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port 4 files from feature/inbox-focus-polish into current architecture: - useAgentInbox.ts: data aggregation hook (filter, sort, smart summaries) - AgentInbox/index.tsx: modal shell with list/focus view switching - AgentInbox/InboxListView.tsx: virtualized list (react-window → @tanstack/react-virtual) - AgentInbox/FocusModeView.tsx: focus mode detail view with reply input Also adds: - AGENT_INBOX priority (555) in modalPriorities.ts - agentInbox ModalId, AgentInboxModalData, and modal actions in modalStore.ts Co-Authored-By: Claude Opus 4.6 --- .../ENCORE-INBOX-04.md | 46 + .../components/AgentInbox/FocusModeView.tsx | 1162 +++++++++++++++ .../components/AgentInbox/InboxListView.tsx | 1276 +++++++++++++++++ src/renderer/components/AgentInbox/index.tsx | 359 +++++ src/renderer/constants/modalPriorities.ts | 3 + src/renderer/hooks/useAgentInbox.ts | 243 ++++ src/renderer/stores/modalStore.ts | 17 +- 7 files changed, 3105 insertions(+), 1 deletion(-) create mode 100644 playbooks/agent-inbox/2026-02-26-Encore-Inbox-Refactor/ENCORE-INBOX-04.md create mode 100644 src/renderer/components/AgentInbox/FocusModeView.tsx create mode 100644 src/renderer/components/AgentInbox/InboxListView.tsx create mode 100644 src/renderer/components/AgentInbox/index.tsx create mode 100644 src/renderer/hooks/useAgentInbox.ts 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/src/renderer/components/AgentInbox/FocusModeView.tsx b/src/renderer/components/AgentInbox/FocusModeView.tsx new file mode 100644 index 000000000..5c40356a8 --- /dev/null +++ b/src/renderer/components/AgentInbox/FocusModeView.tsx @@ -0,0 +1,1162 @@ +import { useMemo, useRef, useEffect, useState, useCallback } from 'react'; +import { + ArrowLeft, + X, + Bot, + User, + ArrowUp, + ExternalLink, + ChevronLeft, + ChevronRight, + ChevronDown, + Eye, + Brain, + Pin, + FileText, + Star, +} 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 ? ( +
+ {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 group key, preserving original index + const groupMap = new Map(); + const groupOrder: string[] = []; + items.forEach((itm, idx) => { + const groupKey = useGroupName ? (itm.groupName ?? 'Ungrouped') : itm.sessionName; + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, []); + groupOrder.push(groupKey); + } + groupMap.get(groupKey)!.push({ item: itm, index: idx }); + }); + + const result: ( + | { type: 'header'; groupName: string; count: number } + | { type: 'item'; item: InboxItem; index: number } + )[] = []; + for (const groupKey of groupOrder) { + const groupItems = groupMap.get(groupKey)!; + result.push({ type: 'header', groupName: groupKey, count: groupItems.length }); + for (const entry of groupItems) { + 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.groupName; + return ( +
{ + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(row.groupName)) next.delete(row.groupName); + else next.add(row.groupName); + 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.groupName) ? ( + + ) : ( + + )} + {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)} + 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'; + }} + > + {/* Status dot */} + + {/* Name + preview vertical stack */} +
+ + {itm.tabName || 'Tab'} + + {previewText && ( + + {previewText} + + )} +
+ {/* Indicators: starred + unread */} + {itm.starred && ( + + )} + {itm.hasUnread && ( + + )} +
+ ); + }); + })()} +
+
+ ); +} + +export default function FocusModeView({ + theme, + item, + items, + sessions, + currentIndex, + enterToSendAI, + filterMode, + setFilterMode, + sortMode, + onClose, + onExitFocus, + onNavigateItem, + onQuickReply, + onOpenAndReply, + 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 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 onMouseUp = () => { + isResizingRef.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + 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 when logs change or item changes + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.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 + useEffect(() => { + const timer = setTimeout(() => { + replyInputRef.current?.focus(); + }, 200); + return () => clearTimeout(timer); + }, [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 — 48px */} +
+ {/* 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}% + )} + {item.starred && ( + + + Starred + + )} + + {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 ? ( +
+ Session no longer available +
+ ) : ( +
+ {visibleLogs.length === 0 ? ( +
+ No conversation yet +
+ ) : ( +
+ {visibleLogs.map((log) => ( + setShowRawMarkdown((v) => !v)} + /> + ))} +
+ )} +
+ )} + + {/* Reply input bar */} +
+