From 2381c054fdf91fd12c41d588dedacde5341b079b Mon Sep 17 00:00:00 2001 From: Felipe Gobbi Date: Fri, 27 Feb 2026 11:16:05 -0300 Subject: [PATCH 1/7] MAESTRO: port Unified Inbox with all CRITICAL fixes from review branches Port 6 inbox files from feat/unified-inbox-encore (which already incorporates all 8 rounds of CodeRabbit/Greptile review fixes) and integrate into the existing codebase with full App.tsx handler wiring. CRITICAL fixes included: - Race condition: effect-driven pendingInboxQuickReply replaces setTimeout - Memory leak: resizeCleanupRef + useEffect unmount guard for drag listeners - Group collision: sessionId as group key, sessionName as display-only label - Ref binding: inboxProcessInputRef.current = processInput after useInputHandlers - Duplicate log: quick reply delegates to processInput (no manual log push) - AI mode: force inputMode='ai' before sending inbox replies - Modal data: openModal preserves data, updateModalData handles undefined base Co-Authored-By: Claude Opus 4.6 --- src/renderer/App.tsx | 168 +++ .../components/AgentInbox/FocusModeView.tsx | 1218 ++++++++++++++++ .../components/AgentInbox/InboxListView.tsx | 1296 +++++++++++++++++ src/renderer/components/AgentInbox/index.tsx | 366 +++++ src/renderer/constants/modalPriorities.ts | 3 + src/renderer/constants/shortcuts.ts | 1 + .../hooks/keyboard/useMainKeyboardHandler.ts | 8 + src/renderer/hooks/useAgentInbox.ts | 234 +++ src/renderer/stores/modalStore.ts | 35 +- src/renderer/stores/settingsStore.ts | 1 + src/renderer/types/agent-inbox.ts | 47 + src/renderer/types/index.ts | 1 + src/renderer/utils/tabDisplayName.ts | 40 + 13 files changed, 3412 insertions(+), 6 deletions(-) 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 create mode 100644 src/renderer/types/agent-inbox.ts create mode 100644 src/renderer/utils/tabDisplayName.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4f31351c31..128277d9ac 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -41,6 +41,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'; @@ -344,6 +345,9 @@ function MaestroConsoleInner() { // Director's Notes Modal directorNotesOpen, setDirectorNotesOpen, + // Agent Inbox Modal + agentInboxOpen, + setAgentInboxOpen, } = useModalActions(); // --- MOBILE LANDSCAPE MODE (reading-only view) --- @@ -1372,6 +1376,149 @@ 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. + 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, @@ -1590,6 +1737,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; @@ -3009,6 +3159,7 @@ function MaestroConsoleInner() { setMarketplaceModalOpen, setSymphonyModalOpen, setDirectorNotesOpen, + setAgentInboxOpen: encoreFeatures.unifiedInbox ? setAgentInboxOpen : undefined, encoreFeatures, setShowNewGroupChatModal, deleteGroupChatWithConfirmation, @@ -4103,6 +4254,23 @@ 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 0000000000..ea0116c2b0 --- /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 ? ( +
+ {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 + useEffect(() => { + const rafId = requestAnimationFrame(() => { + replyInputRef.current?.focus(); + }); + // Fallback for heavy render frames where the first focus attempt loses timing. + const timeoutId = window.setTimeout(() => { + replyInputRef.current?.focus(); + }, 60); + return () => { + cancelAnimationFrame(rafId); + window.clearTimeout(timeoutId); + }; + }, [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 */} +
+