+ {/* Header bar — single h-16 bar matching MainPanel */}
+
+ {/* Left: Back button */}
+
+
+ {/* Center: Group | Agent Name | Tab — matches app title bar pattern */}
+
+
+ {(() => {
+ const parts: string[] = [];
+ if (item.groupName) parts.push(item.groupName);
+ parts.push(item.sessionName);
+ if (item.tabName) parts.push(item.tabName);
+ return parts.join(' | ');
+ })()}
+
+
+
+ {/* Right: metadata badges + thinking toggle + close */}
+
+ {item.gitBranch && (
+
+ {truncate(item.gitBranch, 20)}
+
+ )}
+ {hasValidContext && (
+
+ {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 */}
+
{
+ if (e.key === 'ArrowLeft') {
+ e.preventDefault();
+ setSidebarWidth((w) => Math.max(200, w - 16));
+ } else if (e.key === 'ArrowRight') {
+ e.preventDefault();
+ setSidebarWidth((w) => Math.min(440, w + 16));
+ }
+ }}
+ style={{
+ width: 4,
+ cursor: 'col-resize',
+ backgroundColor: 'transparent',
+ borderRight: `1px solid ${theme.colors.border}`,
+ flexShrink: 0,
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}30`;
+ }}
+ onMouseLeave={(e) => {
+ if (!isResizingRef.current) {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }
+ }}
+ onFocus={(e) => {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}30`;
+ }}
+ onBlur={(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 */}
+
+ {/* Slash Command Autocomplete Dropdown */}
+ {slashCommandOpen && filteredSlashCommands.length > 0 && (
+
+
+ {filteredSlashCommands.map((cmd, idx) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* Footer — 44px */}
+
+ {/* Prev button with shortcut badge */}
+
+
+ {/* Center: counter + Esc hint */}
+
+
+ {currentIndex + 1} / {items.length}
+
+
+ Esc Back
+
+
+
+ {/* Next button with shortcut badge */}
+
+
+
+ );
+}
diff --git a/src/renderer/components/AgentInbox/InboxListView.tsx b/src/renderer/components/AgentInbox/InboxListView.tsx
new file mode 100644
index 000000000..4b8f31051
--- /dev/null
+++ b/src/renderer/components/AgentInbox/InboxListView.tsx
@@ -0,0 +1,1321 @@
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { X, CheckCircle, ChevronDown, ChevronRight, Maximize2, Minimize2, Bot } from 'lucide-react';
+import type { Theme, SessionState } from '../../types';
+import type { InboxItem, InboxFilterMode, InboxSortMode } from '../../types/agent-inbox';
+import { STATUS_LABELS, STATUS_COLORS } from '../../types/agent-inbox';
+import { formatRelativeTime } from '../../utils/formatters';
+import { formatShortcutKeys } from '../../utils/shortcutFormatter';
+import { getModalActions } from '../../stores/modalStore';
+
+interface InboxListViewProps {
+ theme: Theme;
+ items: InboxItem[];
+ selectedIndex: number;
+ setSelectedIndex: React.Dispatch
>;
+ filterMode: InboxFilterMode;
+ setFilterMode: (mode: InboxFilterMode) => void;
+ sortMode: InboxSortMode;
+ setSortMode: (mode: InboxSortMode) => void;
+ onClose: () => void;
+ onNavigateToSession?: (sessionId: string, tabId?: string) => void;
+ onEnterFocus: (item: InboxItem) => void;
+ containerRef: React.RefObject;
+ keyDownRef?: React.MutableRefObject<((e: React.KeyboardEvent) => void) | null>;
+ isExpanded: boolean;
+ onToggleExpanded: (expanded: boolean | ((prev: boolean) => boolean)) => void;
+}
+
+const ITEM_HEIGHT = 132;
+const GROUP_HEADER_HEIGHT = 36;
+const MODAL_HEADER_HEIGHT = 80;
+const MODAL_FOOTER_HEIGHT = 36;
+const STATS_BAR_HEIGHT = 32;
+
+// ============================================================================
+// Empty state messages per filter mode
+// ============================================================================
+const EMPTY_STATE_MESSAGES: Record = {
+ all: { text: 'No active agents to show.', showIcon: true },
+ unread: { text: 'No unread agents.', showIcon: false },
+ read: { text: 'No read agents with activity.', showIcon: false },
+ starred: { text: 'No starred agents.', showIcon: false },
+};
+
+// ============================================================================
+// Grouped list model: interleaves group headers with items when sort = 'grouped'
+// ============================================================================
+type ListRow =
+ | { type: 'header'; groupKey: string; groupName: string }
+ | { type: 'item'; item: InboxItem; index: number };
+
+function buildRows(items: InboxItem[], sortMode: InboxSortMode): ListRow[] {
+ if (sortMode !== 'grouped' && sortMode !== 'byAgent') {
+ return items.map((item, index) => ({ type: 'item' as const, item, index }));
+ }
+ const rows: ListRow[] = [];
+ let lastGroupKey: string | null = null;
+ let itemIndex = 0;
+ for (const item of items) {
+ // For 'grouped': group by Left Bar group name
+ // For 'byAgent': group by sessionId (unique) and display sessionName
+ const groupKey =
+ sortMode === 'byAgent' ? item.sessionId : (item.groupId ?? item.groupName ?? 'Ungrouped');
+ const groupName = sortMode === 'byAgent' ? item.sessionName : (item.groupName ?? 'Ungrouped');
+ if (groupKey !== lastGroupKey) {
+ rows.push({ type: 'header', groupKey, groupName });
+ lastGroupKey = groupKey;
+ }
+ rows.push({ type: 'item', item, index: itemIndex });
+ itemIndex++;
+ }
+ return rows;
+}
+
+/** Derive the collapse key for a given item row — must stay aligned with buildRows groupKey. */
+function getGroupCollapseKey(item: InboxItem, sortMode: InboxSortMode): string {
+ return sortMode === 'byAgent' ? item.sessionId : (item.groupId ?? item.groupName ?? 'Ungrouped');
+}
+
+// ============================================================================
+// STATUS color resolver — maps STATUS_COLORS key to actual hex
+// ============================================================================
+function resolveStatusColor(state: SessionState, theme: Theme): string {
+ const colorKey = STATUS_COLORS[state];
+ const colorMap: Record = {
+ success: theme.colors.success,
+ warning: theme.colors.warning,
+ error: theme.colors.error,
+ info: theme.colors.accent,
+ textMuted: theme.colors.textDim,
+ };
+ return colorMap[colorKey] ?? theme.colors.textDim;
+}
+
+// ============================================================================
+// Context usage color resolver — green/orange/red thresholds
+// ============================================================================
+export function resolveContextUsageColor(percentage: number, theme: Theme): string {
+ if (percentage >= 80) return theme.colors.error;
+ if (percentage >= 60) return theme.colors.warning;
+ return theme.colors.success;
+}
+
+// ============================================================================
+// InboxItemCard — rendered inside each row
+// ============================================================================
+function InboxItemCardContent({
+ item,
+ theme,
+ isSelected,
+ onClick,
+ onDoubleClick,
+}: {
+ item: InboxItem;
+ theme: Theme;
+ isSelected: boolean;
+ onClick: () => void;
+ onDoubleClick?: () => void;
+}) {
+ const statusColor = resolveStatusColor(item.state, theme);
+ const hasValidContext = item.contextUsage !== undefined && !isNaN(item.contextUsage);
+ const contextColor = hasValidContext
+ ? resolveContextUsageColor(item.contextUsage!, theme)
+ : undefined;
+
+ return (
+ {
+ e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+ e.currentTarget.style.outlineOffset = '-2px';
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.outline = 'none';
+ }}
+ >
+ {/* Card content — horizontal flex with agent icon + details */}
+
+ {/* Agent icon */}
+
+
+
+ {/* Card details */}
+
+ {/* Row 1: GROUP | session | tab timestamp */}
+
+ {item.groupName && (
+ <>
+
+ {item.groupName}
+
+
+ |
+
+ >
+ )}
+
+ {item.sessionName}
+ {item.tabName && (
+ <>
+
+ |
+
+
+ {item.tabName}
+
+ >
+ )}
+
+
+ {formatRelativeTime(item.timestamp)}
+
+
+
+ {/* Row 2: last message (2-line clamp) */}
+
+ {item.lastMessage}
+
+
+ {/* Row 3: badges */}
+
+ {item.gitBranch && (
+
+ ⎇{' '}
+ {item.gitBranch.length > 25 ? item.gitBranch.slice(0, 25) + '...' : item.gitBranch}
+
+ )}
+
+ {hasValidContext ? `Context: ${item.contextUsage}%` : 'Context: \u2014'}
+
+
+ {STATUS_LABELS[item.state]}
+
+
+
+
+
+ {/* Context usage bar — 4px at bottom of card */}
+ {hasValidContext && (
+
+
= 100 ? 0 : '0 2px 2px 0',
+ transition: 'width 0.3s ease',
+ }}
+ />
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// SegmentedControl
+// ============================================================================
+interface SegmentedControlProps
{
+ options: { value: T; label: string }[];
+ value: T;
+ onChange: (value: T) => void;
+ theme: Theme;
+ ariaLabel?: string;
+}
+
+function SegmentedControl({
+ options,
+ value,
+ onChange,
+ theme,
+ ariaLabel,
+}: SegmentedControlProps) {
+ return (
+
+ {options.map((opt) => {
+ const isActive = value === opt.value;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// ============================================================================
+// InboxStatsStrip — compact 32px metric bar between header and list
+// ============================================================================
+function InboxStatsStrip({ items, theme }: { items: InboxItem[]; theme: Theme }) {
+ const stats = useMemo(() => {
+ const uniqueAgents = new Set(items.map((i) => i.sessionId)).size;
+ const unread = items.filter((i) => i.hasUnread).length;
+ const needsInput = items.filter((i) => i.state === 'waiting_input').length;
+ const highContext = items.filter(
+ (i) => i.contextUsage !== undefined && i.contextUsage >= 80
+ ).length;
+ return { uniqueAgents, unread, needsInput, highContext };
+ }, [items]);
+
+ const metrics = [
+ { label: 'Agents', value: stats.uniqueAgents },
+ { label: 'Unread', value: stats.unread },
+ { label: 'Needs Input', value: stats.needsInput },
+ { label: 'Context \u226580%', value: stats.highContext },
+ ];
+
+ return (
+
+ {metrics.map((m) => (
+
+
+ {m.label}
+
+
+ {m.value}
+
+
+ ))}
+
+ );
+}
+
+// ============================================================================
+// Human-readable agent display names for group headers
+// ============================================================================
+const TOOL_TYPE_LABELS: Record = {
+ 'claude-code': 'Claude Code',
+ codex: 'Codex',
+ opencode: 'OpenCode',
+ 'factory-droid': 'Factory Droid',
+ terminal: 'Terminal',
+};
+
+// ============================================================================
+// InboxListView Component
+// ============================================================================
+const SORT_OPTIONS: { value: InboxSortMode; label: string }[] = [
+ { value: 'newest', label: 'Newest' },
+ { value: 'oldest', label: 'Oldest' },
+ { value: 'grouped', label: 'Grouped' },
+ { value: 'byAgent', label: 'By Agent' },
+];
+
+const FILTER_OPTIONS: { value: InboxFilterMode; label: string }[] = [
+ { value: 'all', label: 'All' },
+ { value: 'unread', label: 'Unread' },
+ { value: 'read', label: 'Read' },
+ { value: 'starred', label: 'Starred' },
+];
+
+// Track the identity of the selected row so we can re-find it after rows change
+type RowIdentity =
+ | { type: 'header'; groupKey: string }
+ | { type: 'item'; sessionId: string; tabId: string };
+
+export default function InboxListView({
+ theme,
+ items,
+ selectedIndex,
+ setSelectedIndex,
+ filterMode,
+ setFilterMode,
+ sortMode,
+ setSortMode,
+ onClose,
+ onNavigateToSession,
+ onEnterFocus,
+ containerRef,
+ keyDownRef,
+ isExpanded,
+ onToggleExpanded,
+}: InboxListViewProps) {
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set());
+
+ // Initial mount flag — enables staggered entrance animation only on first render
+ const [isInitialMount, setIsInitialMount] = useState(true);
+ useEffect(() => {
+ const timer = setTimeout(() => setIsInitialMount(false), 600);
+ return () => clearTimeout(timer);
+ }, []);
+
+ // Write state changes back to modalStore for persistence
+ useEffect(() => {
+ const { updateAgentInboxData } = getModalActions();
+ updateAgentInboxData({ filterMode, sortMode, isExpanded });
+ }, [filterMode, sortMode, isExpanded]);
+
+ const toggleGroup = useCallback((groupKey: string) => {
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupKey)) {
+ next.delete(groupKey);
+ } else {
+ next.add(groupKey);
+ }
+ return next;
+ });
+ }, []);
+
+ // Auto-collapse zero-unread agents ONLY on initial transition into byAgent mode.
+ // After that, manual toggles are preserved — items changes do NOT reset collapse state.
+ const prevSortModeRef = useRef(sortMode);
+ useEffect(() => {
+ const prev = prevSortModeRef.current;
+ prevSortModeRef.current = sortMode;
+
+ if (sortMode === 'byAgent' && prev !== 'byAgent') {
+ const agentUnreads = new Map();
+ for (const item of items) {
+ const count = agentUnreads.get(item.sessionId) ?? 0;
+ agentUnreads.set(item.sessionId, count + (item.hasUnread ? 1 : 0));
+ }
+ const toCollapse = new Set();
+ for (const [agent, count] of agentUnreads) {
+ if (count === 0) toCollapse.add(agent);
+ }
+ setCollapsedGroups(toCollapse);
+ } else if (sortMode !== 'byAgent' && prev === 'byAgent') {
+ setCollapsedGroups(new Set());
+ }
+ }, [sortMode, items]);
+
+ const allRows = useMemo(() => buildRows(items, sortMode), [items, sortMode]);
+ const rows = useMemo(() => {
+ if ((sortMode !== 'grouped' && sortMode !== 'byAgent') || collapsedGroups.size === 0)
+ return allRows;
+ return allRows.filter((row) => {
+ if (row.type === 'header') return true;
+ return !collapsedGroups.has(getGroupCollapseKey(row.item, sortMode));
+ });
+ }, [allRows, collapsedGroups, sortMode]);
+
+ // Map from row index to visible-item-number (1-based, only for item rows)
+ // Also build reverse map: visibleItemNumber -> row index (for Cmd+N)
+ const { visibleItemNumbers, visibleItemByNumber } = useMemo(() => {
+ const numbers = new Map(); // rowIndex -> 1-based visible number
+ const byNumber = new Map(); // 1-based visible number -> rowIndex
+ let counter = 0;
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].type === 'item') {
+ counter++;
+ numbers.set(i, counter);
+ byNumber.set(counter, i);
+ }
+ }
+ return { visibleItemNumbers: numbers, visibleItemByNumber: byNumber };
+ }, [rows]);
+
+ // ============================================================================
+ // Row-based navigation — navigates over rows (headers + items), no useListNavigation
+ // ============================================================================
+ // Initialize to first item row (skip leading headers)
+ const firstItemRow = useMemo(() => {
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].type === 'item') return i;
+ }
+ return 0;
+ }, [rows]);
+ const [selectedRowIndex, setSelectedRowIndex] = useState(firstItemRow);
+ const selectedRowIdentityRef = useRef(null);
+
+ // Keep the identity ref in sync with selectedRowIndex
+ useEffect(() => {
+ const row = rows[selectedRowIndex];
+ if (!row) {
+ selectedRowIdentityRef.current = null;
+ return;
+ }
+ if (row.type === 'header') {
+ selectedRowIdentityRef.current = { type: 'header', groupKey: row.groupKey };
+ } else {
+ selectedRowIdentityRef.current = {
+ type: 'item',
+ sessionId: row.item.sessionId,
+ tabId: row.item.tabId,
+ };
+ }
+ }, [selectedRowIndex, rows]);
+
+ // Ref to the scrollable list container
+ const scrollContainerRef = useRef(null);
+ const headerRef = useRef(null);
+
+ // Stabilize selectedRowIndex after rows change (collapse/expand/filter)
+ useEffect(() => {
+ if (rows.length === 0) {
+ setSelectedRowIndex(0);
+ return;
+ }
+
+ const identity = selectedRowIdentityRef.current;
+ if (!identity) return;
+
+ // Check if the current index still points at the same identity
+ const currentRow = rows[selectedRowIndex];
+ if (currentRow) {
+ if (
+ identity.type === 'header' &&
+ currentRow.type === 'header' &&
+ currentRow.groupKey === identity.groupKey
+ ) {
+ return; // Still correct
+ }
+ if (
+ identity.type === 'item' &&
+ currentRow.type === 'item' &&
+ currentRow.item.sessionId === identity.sessionId &&
+ currentRow.item.tabId === identity.tabId
+ ) {
+ return; // Still correct
+ }
+ }
+
+ // Identity drifted — search for the old identity in the new rows
+ for (let i = 0; i < rows.length; i++) {
+ const r = rows[i];
+ if (identity.type === 'header' && r.type === 'header' && r.groupKey === identity.groupKey) {
+ setSelectedRowIndex(i);
+ return;
+ }
+ if (
+ identity.type === 'item' &&
+ r.type === 'item' &&
+ r.item.sessionId === identity.sessionId &&
+ r.item.tabId === identity.tabId
+ ) {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+
+ // Old identity no longer in rows (collapsed away) — find nearest item or clamp
+ const clamped = Math.min(selectedRowIndex, rows.length - 1);
+ // Search downward from clamped position for an item row
+ for (let i = clamped; i < rows.length; i++) {
+ if (rows[i].type === 'item') {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+ // Search upward
+ for (let i = clamped - 1; i >= 0; i--) {
+ if (rows[i].type === 'item') {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+ // Only headers remain — select the first header
+ setSelectedRowIndex(0);
+ }, [rows, selectedRowIndex]);
+
+ // When sort mode or filter mode changes, reset selection to first item row
+ const prevSortForResetRef = useRef(sortMode);
+ const prevFilterForResetRef = useRef(filterMode);
+ useEffect(() => {
+ if (sortMode !== prevSortForResetRef.current || filterMode !== prevFilterForResetRef.current) {
+ prevSortForResetRef.current = sortMode;
+ prevFilterForResetRef.current = filterMode;
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].type === 'item') {
+ setSelectedRowIndex(i);
+ return;
+ }
+ }
+ setSelectedRowIndex(0);
+ }
+ }, [sortMode, filterMode, rows]);
+
+ // Sync selectedRowIndex -> parent selectedIndex (used by Focus Mode entry)
+ useEffect(() => {
+ const row = rows[selectedRowIndex];
+ if (!row) return;
+
+ if (row.type === 'item') {
+ setSelectedIndex(row.index);
+ return;
+ }
+
+ // Header selected — find nearest item below, then above
+ for (let i = selectedRowIndex + 1; i < rows.length; i++) {
+ const r = rows[i];
+ if (r.type === 'header') break; // hit next group, stop
+ if (r.type === 'item') {
+ setSelectedIndex(r.index);
+ return;
+ }
+ }
+ // No item below in same group — search upward
+ for (let i = selectedRowIndex - 1; i >= 0; i--) {
+ const r = rows[i];
+ if (r.type === 'item') {
+ setSelectedIndex(r.index);
+ return;
+ }
+ }
+ }, [selectedRowIndex, rows, setSelectedIndex]);
+
+ // Guard: ensure parent selectedIndex points to a visible item after collapse
+ useEffect(() => {
+ if (rows.length === 0) return;
+ const visibleItemIndexes = new Set(
+ rows.filter((row) => row.type === 'item').map((row) => row.index)
+ );
+ if (!visibleItemIndexes.has(selectedIndex)) {
+ const firstItemRow = rows.find((row) => row.type === 'item');
+ if (firstItemRow && firstItemRow.type === 'item') {
+ setSelectedIndex(firstItemRow.index);
+ }
+ }
+ }, [rows, selectedIndex, setSelectedIndex]);
+
+ // Row height getter for variable-size rows
+ const getRowHeight = useCallback(
+ (index: number): number => {
+ const row = rows[index];
+ if (!row) return ITEM_HEIGHT;
+ return row.type === 'header' ? GROUP_HEADER_HEIGHT : ITEM_HEIGHT;
+ },
+ [rows]
+ );
+
+ // Calculate list height
+ const listHeight = useMemo(() => {
+ if (typeof window === 'undefined') return 400;
+ if (isExpanded) {
+ return Math.min(
+ window.innerHeight * 0.85 -
+ MODAL_HEADER_HEIGHT -
+ MODAL_FOOTER_HEIGHT -
+ STATS_BAR_HEIGHT -
+ 80,
+ 1000
+ );
+ }
+ return Math.min(
+ window.innerHeight * 0.8 - MODAL_HEADER_HEIGHT - MODAL_FOOTER_HEIGHT - STATS_BAR_HEIGHT - 80,
+ 700
+ );
+ }, [isExpanded]);
+
+ // Virtualizer
+ const virtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => scrollContainerRef.current,
+ estimateSize: getRowHeight,
+ overscan: 5,
+ });
+
+ // Scroll to selected row
+ useEffect(() => {
+ if (rows.length > 0 && selectedRowIndex < rows.length) {
+ virtualizer.scrollToIndex(selectedRowIndex, { align: 'auto' });
+ }
+ }, [selectedRowIndex, rows.length, virtualizer]);
+
+ const handleNavigate = useCallback(
+ (item: InboxItem) => {
+ if (onNavigateToSession) {
+ onNavigateToSession(item.sessionId, item.tabId);
+ }
+ onClose();
+ },
+ [onNavigateToSession, onClose]
+ );
+
+ // Get the selected item's element ID for aria-activedescendant
+ const selectedItemId = useMemo(() => {
+ const row = rows[selectedRowIndex];
+ if (!row || row.type !== 'item') return undefined;
+ return `inbox-item-${row.item.sessionId}-${row.item.tabId}`;
+ }, [rows, selectedRowIndex]);
+
+ // Collect focusable header elements for Tab cycling
+ const getHeaderFocusables = useCallback((): HTMLElement[] => {
+ if (!headerRef.current) return [];
+ return Array.from(
+ headerRef.current.querySelectorAll(
+ 'button:not(:disabled), [tabindex="0"]:not([aria-disabled="true"])'
+ )
+ );
+ }, []);
+
+ // Row-based keyboard handler — arrows navigate rows (headers + items)
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ // Tab cycling for header controls
+ if (e.key === 'Tab') {
+ const focusables = getHeaderFocusables();
+ if (focusables.length === 0) return;
+ const active = document.activeElement;
+ const focusIdx = focusables.indexOf(active as HTMLElement);
+
+ if (e.shiftKey) {
+ if (focusIdx <= 0) {
+ e.preventDefault();
+ containerRef.current?.focus();
+ } else {
+ e.preventDefault();
+ focusables[focusIdx - 1].focus();
+ }
+ } else {
+ if (focusIdx === -1) {
+ e.preventDefault();
+ focusables[0].focus();
+ } else if (focusIdx >= focusables.length - 1) {
+ e.preventDefault();
+ containerRef.current?.focus();
+ } else {
+ e.preventDefault();
+ focusables[focusIdx + 1].focus();
+ }
+ }
+ return;
+ }
+
+ // Arrow navigation over rows (headers + items)
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (rows.length === 0) return;
+ setSelectedRowIndex((prev) => Math.min(prev + 1, rows.length - 1));
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (rows.length === 0) return;
+ setSelectedRowIndex((prev) => Math.max(prev - 1, 0));
+ return;
+ }
+
+ // T / Enter on a header → toggle group
+ // Enter on an item → navigate to session
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const row = rows[selectedRowIndex];
+ if (!row) return;
+ if (row.type === 'header') {
+ toggleGroup(row.groupKey);
+ } else {
+ handleNavigate(row.item);
+ }
+ return;
+ }
+
+ // T to toggle group (works on headers AND items)
+ if ((e.key === 't' || e.key === 'T') && !e.metaKey && !e.ctrlKey && !e.altKey) {
+ if (sortMode === 'grouped' || sortMode === 'byAgent') {
+ e.preventDefault();
+ const row = rows[selectedRowIndex];
+ if (!row) return;
+ if (row.type === 'header') {
+ toggleGroup(row.groupKey);
+ } else {
+ toggleGroup(getGroupCollapseKey(row.item, sortMode));
+ }
+ }
+ return;
+ }
+
+ // Cmd/Ctrl+1-9, 0 hotkeys for quick select (visible-item based)
+ if (
+ (e.metaKey || e.ctrlKey) &&
+ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'].includes(e.key)
+ ) {
+ e.preventDefault();
+ const number = e.key === '0' ? 10 : parseInt(e.key);
+ const targetRowIndex = visibleItemByNumber.get(number);
+ if (targetRowIndex !== undefined) {
+ const targetRow = rows[targetRowIndex];
+ if (targetRow && targetRow.type === 'item') {
+ handleNavigate(targetRow.item);
+ }
+ }
+ return;
+ }
+ },
+ [
+ getHeaderFocusables,
+ containerRef,
+ rows,
+ selectedRowIndex,
+ sortMode,
+ visibleItemByNumber,
+ toggleGroup,
+ handleNavigate,
+ ]
+ );
+
+ // Expose keyboard handler to shell via ref
+ useEffect(() => {
+ if (keyDownRef) keyDownRef.current = handleKeyDown;
+ return () => {
+ if (keyDownRef) keyDownRef.current = null;
+ };
+ }, [keyDownRef, handleKeyDown]);
+
+ const actionCount = items.length;
+
+ // Filter-aware count label
+ const countLabel =
+ filterMode === 'unread'
+ ? `${actionCount} unread`
+ : filterMode === 'starred'
+ ? `${actionCount} starred`
+ : filterMode === 'read'
+ ? `${actionCount} read`
+ : `${actionCount} need action`;
+
+ return (
+ <>
+ {/* Header — 80px, two rows */}
+
+ {/* Header row 1: title + badge + close */}
+
+
+
+
+ Unified Inbox
+
+
+ {countLabel}
+
+
+
+
+
+
+
+
+ {/* Header row 2: sort + filter controls */}
+
+
+
+
+
+
+ {/* Stats strip — 32px aggregate metrics */}
+
+
+ {/* Body — virtualized list */}
+ }
+ role="listbox"
+ tabIndex={0}
+ onKeyDown={handleKeyDown}
+ aria-activedescendant={selectedItemId}
+ aria-label="Inbox items"
+ className="outline-none"
+ style={{ flex: 1, overflow: 'hidden' }}
+ onFocus={(e) => {
+ e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+ e.currentTarget.style.outlineOffset = '-2px';
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.outline = 'none';
+ }}
+ >
+ {rows.length === 0 ? (
+
+ {EMPTY_STATE_MESSAGES[filterMode].showIcon && (
+
+ )}
+
+ {EMPTY_STATE_MESSAGES[filterMode].text}
+
+
+ ) : (
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index];
+ if (!row) return null;
+ const isRowSelected = virtualRow.index === selectedRowIndex;
+
+ if (row.type === 'header') {
+ const isCollapsed = collapsedGroups.has(row.groupKey);
+
+ // For byAgent mode: derive agent type label and unread count from subsequent rows
+ let agentToolType: string | undefined;
+ let unreadCount = 0;
+ if (sortMode === 'byAgent') {
+ for (let i = virtualRow.index + 1; i < rows.length; i++) {
+ const r = rows[i];
+ if (r.type === 'header') break;
+ if (r.type === 'item') {
+ if (!agentToolType) agentToolType = r.item.toolType;
+ if (r.item.hasUnread) unreadCount++;
+ }
+ }
+ }
+
+ return (
+
+ );
+ }
+
+ const isLastRow = virtualRow.index === rows.length - 1;
+ const visibleNum = visibleItemNumbers.get(virtualRow.index);
+ const showNumber = visibleNum !== undefined && visibleNum >= 1 && visibleNum <= 10;
+ const numberBadge = visibleNum === 10 ? 0 : visibleNum;
+
+ // Stagger animation: only on initial mount, capped at 300ms (10 items)
+ const animationDelay = isInitialMount
+ ? `${Math.min(virtualRow.index * 30, 300)}ms`
+ : undefined;
+
+ return (
+
+
+ {showNumber ? (
+
+ {numberBadge}
+
+ ) : (
+
+ )}
+
+ setSelectedRowIndex(virtualRow.index)}
+ onDoubleClick={() => onEnterFocus(row.item)}
+ />
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Footer — 36px */}
+
+ {countLabel}
+ {`↑↓ navigate • ${sortMode === 'grouped' || sortMode === 'byAgent' ? 'T collapse • ' : ''}F focus • Enter open • ${formatShortcutKeys(['Meta'])}1-9 quick select • Esc close`}
+
+ >
+ );
+}
diff --git a/src/renderer/components/AgentInbox/index.tsx b/src/renderer/components/AgentInbox/index.tsx
new file mode 100644
index 000000000..82d26c566
--- /dev/null
+++ b/src/renderer/components/AgentInbox/index.tsx
@@ -0,0 +1,396 @@
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import InboxListView from './InboxListView';
+import FocusModeView from './FocusModeView';
+import type { Theme, Session, Group, ThinkingMode } from '../../types';
+import type {
+ InboxItem,
+ InboxViewMode,
+ InboxFilterMode,
+ InboxSortMode,
+} from '../../types/agent-inbox';
+import { isValidFilterMode, isValidSortMode } from '../../types/agent-inbox';
+import { useModalLayer } from '../../hooks/ui/useModalLayer';
+import { MODAL_PRIORITIES } from '../../constants/modalPriorities';
+import { useModalStore, selectModalData } from '../../stores/modalStore';
+import { useAgentInbox } from '../../hooks/useAgentInbox';
+
+// Re-export so existing test imports don't break
+export { resolveContextUsageColor } from './InboxListView';
+
+interface AgentInboxProps {
+ theme: Theme;
+ sessions: Session[];
+ groups: Group[];
+ enterToSendAI?: boolean;
+ slashCommands?: Array<{
+ command: string;
+ description: string;
+ terminalOnly?: boolean;
+ aiOnly?: boolean;
+ }>;
+ onClose: () => void;
+ onNavigateToSession?: (sessionId: string, tabId?: string) => void;
+ onQuickReply?: (sessionId: string, tabId: string, text: string) => void;
+ onOpenAndReply?: (sessionId: string, tabId: string, text: string) => void;
+ onMarkAsRead?: (sessionId: string, tabId: string) => void;
+ onToggleThinking?: (sessionId: string, tabId: string, mode: ThinkingMode) => void;
+}
+
+export default function AgentInbox({
+ theme,
+ sessions,
+ groups,
+ enterToSendAI,
+ slashCommands: slashCommandsProp,
+ onClose,
+ onNavigateToSession,
+ onQuickReply,
+ onOpenAndReply,
+ onMarkAsRead,
+ onToggleThinking,
+}: AgentInboxProps) {
+ // ---- Focus restoration ----
+ // Capture trigger element synchronously during initial render (before child effects)
+ const triggerRef = useRef(
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
+ );
+
+ const handleClose = useCallback(() => {
+ const trigger = triggerRef.current;
+ onClose();
+ // Schedule focus restoration after React unmounts the modal.
+ // No cleanup needed — the RAF fires once post-unmount and is harmless if trigger is gone.
+ requestAnimationFrame(() => {
+ trigger?.focus();
+ });
+ }, [onClose]);
+
+ // ---- View mode state ----
+ const [viewMode, setViewMode] = useState('list');
+ // Identity-based focus tracking: survives items array reordering/membership changes.
+ // Prevents focus disruption when agents respond and items re-enter the filtered list.
+ const [focusId, setFocusId] = useState<{ sessionId: string; tabId: string } | null>(null);
+ // Sync ref for rapid keyboard navigation (prevents stale closure reads)
+ const focusIdRef = useRef(focusId);
+ focusIdRef.current = focusId;
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ // ---- Filter/sort state (lifted from InboxListView for shared access) ----
+ const inboxData = useModalStore(selectModalData('agentInbox'));
+ const [filterMode, setFilterMode] = useState(
+ isValidFilterMode(inboxData?.filterMode) ? inboxData.filterMode : 'unread'
+ );
+ const [sortMode, setSortMode] = useState(
+ isValidSortMode(inboxData?.sortMode) ? inboxData.sortMode : 'newest'
+ );
+
+ // ---- Compute live items (used in list mode) ----
+ const liveItems = useAgentInbox(sessions, groups, filterMode, sortMode);
+
+ // ---- Frozen snapshot for Focus Mode ----
+ // Simple ref-based approach: freeze item order on entry, resolve against live data
+ // for real-time updates (logs, status), but keep the ORDER stable.
+ const frozenOrderRef = useRef<{ sessionId: string; tabId: string }[]>([]);
+
+ // Resolve frozen order against live items (live data, frozen order)
+ const resolveFrozenItems = useCallback(
+ (frozen: { sessionId: string; tabId: string }[]): InboxItem[] => {
+ const liveMap = new Map();
+ for (const item of liveItems) {
+ liveMap.set(`${item.sessionId}:${item.tabId}`, item);
+ }
+ const resolved: InboxItem[] = [];
+ for (const key of frozen) {
+ const live = liveMap.get(`${key.sessionId}:${key.tabId}`);
+ if (live) resolved.push(live);
+ }
+ return resolved.length > 0 ? resolved : liveItems;
+ },
+ [liveItems]
+ );
+
+ // Items: in focus mode use frozen-order resolved against live data, else live
+ const items =
+ viewMode === 'focus' && frozenOrderRef.current.length > 0
+ ? resolveFrozenItems(frozenOrderRef.current)
+ : liveItems;
+
+ // Derive numeric index from identity at render time.
+ // If focusId's item left the array (deleted session), fall back to nearest valid index.
+ const safeFocusIndex = useMemo(() => {
+ if (!focusId || items.length === 0) return 0;
+ const idx = items.findIndex(
+ (i) => i.sessionId === focusId.sessionId && i.tabId === focusId.tabId
+ );
+ return idx >= 0 ? idx : Math.min(items.length - 1, 0);
+ }, [focusId, items]);
+
+ const handleSetFilterMode = useCallback(
+ (mode: InboxFilterMode) => {
+ setFilterMode(mode);
+ if (viewMode === 'focus') {
+ // Re-snapshot: resolve new filter results immediately
+ // We need a render with the new filterMode first, so defer the snapshot
+ frozenOrderRef.current = [];
+ setFocusId(null);
+ }
+ },
+ [viewMode]
+ );
+
+ const handleExitFocus = useCallback(() => {
+ setViewMode('list');
+ frozenOrderRef.current = [];
+ }, []);
+
+ // Re-snapshot after filter change empties the ref (needs liveItems from new filter).
+ // Also recover focusId when null (after filter change) to avoid permanent empty state.
+ useEffect(() => {
+ if (viewMode === 'focus' && frozenOrderRef.current.length === 0 && liveItems.length > 0) {
+ frozenOrderRef.current = liveItems.map((i) => ({
+ sessionId: i.sessionId,
+ tabId: i.tabId,
+ }));
+ // Recover focusId: after a filter change clears it, point to the first item
+ if (!focusIdRef.current && liveItems[0]) {
+ setFocusId({ sessionId: liveItems[0].sessionId, tabId: liveItems[0].tabId });
+ }
+ }
+ }, [viewMode, liveItems]);
+
+ const handleEnterFocus = useCallback(
+ (item: InboxItem) => {
+ setFocusId({ sessionId: item.sessionId, tabId: item.tabId });
+ // Freeze current order as simple identity pairs
+ frozenOrderRef.current = liveItems.map((i) => ({
+ sessionId: i.sessionId,
+ tabId: i.tabId,
+ }));
+ setViewMode('focus');
+ },
+ [liveItems]
+ );
+
+ // ---- Navigate item wrapper (keeps numeric contract with FocusModeView) ----
+ const handleNavigateItem = useCallback(
+ (idx: number) => {
+ const target = items[idx];
+ if (target) {
+ setFocusId({ sessionId: target.sessionId, tabId: target.tabId });
+ }
+ },
+ [items]
+ );
+
+ // ---- Layer stack: viewMode-aware Escape ----
+ const handleLayerEscape = useCallback(() => {
+ if (viewMode === 'focus') {
+ handleExitFocus();
+ } else {
+ handleClose();
+ }
+ }, [viewMode, handleExitFocus, handleClose]);
+
+ useModalLayer(MODAL_PRIORITIES.AGENT_INBOX, 'Unified Inbox', handleLayerEscape);
+
+ // ---- Container ref for keyboard focus ----
+ const containerRef = useRef(null);
+
+ // Auto-focus container on mount for immediate keyboard navigation
+ useEffect(() => {
+ const raf = requestAnimationFrame(() => {
+ containerRef.current?.focus();
+ });
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ // ---- Expanded state (lifted to shell for dialog width control) ----
+ const [isExpanded, setIsExpanded] = useState(inboxData?.isExpanded ?? false);
+
+ // ---- Compute dialog dimensions (focus mode or expanded → wide) ----
+ const isWide = isExpanded || viewMode === 'focus';
+ const dialogWidth = '75vw';
+ const dialogHeight = viewMode === 'focus' ? '85vh' : undefined;
+ const dialogMaxHeight = viewMode === 'focus' ? undefined : isWide ? '90vh' : '80vh';
+
+ // ---- Keyboard handler ref from InboxListView ----
+ const listKeyDownRef = useRef<((e: React.KeyboardEvent) => void) | null>(null);
+
+ // ---- CAPTURE phase: Cmd+[/] must fire BEFORE textarea consumes the event ----
+ const handleCaptureKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (viewMode !== 'focus') return;
+ if ((e.metaKey || e.ctrlKey) && (e.key === '[' || e.key === ']')) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (items.length > 1) {
+ // Read from ref for accurate position during rapid keypresses
+ const currentId = focusIdRef.current;
+ const currentIdx = currentId
+ ? items.findIndex(
+ (i) => i.sessionId === currentId.sessionId && i.tabId === currentId.tabId
+ )
+ : 0;
+ const safeIdx = currentIdx >= 0 ? currentIdx : 0;
+ const newIdx =
+ e.key === '[' ? Math.max(safeIdx - 1, 0) : Math.min(safeIdx + 1, items.length - 1);
+ const target = items[newIdx];
+ if (target) {
+ const newId = { sessionId: target.sessionId, tabId: target.tabId };
+ focusIdRef.current = newId; // Sync ref immediately for next rapid keypress
+ setFocusId(newId);
+ }
+ }
+ }
+ },
+ [viewMode, items]
+ );
+
+ // ---- BUBBLE phase: all other keyboard shortcuts ----
+ const handleShellKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (viewMode === 'focus') {
+ switch (e.key) {
+ case 'Escape':
+ e.preventDefault();
+ e.stopPropagation();
+ handleExitFocus();
+ return;
+ case 'Backspace':
+ case 'b':
+ case 'B':
+ // Guard: only exit if NOT typing in the reply textarea
+ if (document.activeElement?.tagName !== 'TEXTAREA') {
+ e.preventDefault();
+ handleExitFocus();
+ }
+ return;
+ }
+ return;
+ }
+
+ // List mode: F to enter focus
+ if ((e.key === 'f' || e.key === 'F') && !e.metaKey && !e.ctrlKey && !e.altKey) {
+ e.preventDefault();
+ if (items.length > 0 && items[selectedIndex]) {
+ handleEnterFocus(items[selectedIndex]);
+ }
+ return;
+ }
+
+ // Delegate only when the shell container itself is focused.
+ // Avoid re-processing events already handled inside InboxListView.
+ if (
+ viewMode === 'list' &&
+ listKeyDownRef.current &&
+ !e.defaultPrevented &&
+ e.target === e.currentTarget
+ ) {
+ e.stopPropagation();
+ listKeyDownRef.current(e);
+ }
+ },
+ [viewMode, items, selectedIndex, handleEnterFocus, handleExitFocus]
+ );
+
+ return (
+
+
e.stopPropagation()}
+ onKeyDownCapture={handleCaptureKeyDown}
+ onKeyDown={handleShellKeyDown}
+ onFocus={() => {}}
+ onBlur={() => {}}
+ >
+
+ {viewMode === 'list' ? (
+
+ ) : items[safeFocusIndex] ? (
+
+ ) : (
+
+
+ {filterMode === 'unread'
+ ? 'No unread items'
+ : filterMode === 'starred'
+ ? 'No starred items'
+ : 'No items to focus on'}
+
+ {filterMode !== 'all' && (
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx
index fb8de50c2..39f8ca335 100644
--- a/src/renderer/components/AppModals.tsx
+++ b/src/renderer/components/AppModals.tsx
@@ -859,6 +859,9 @@ export interface AppUtilityModalsProps {
// Director's Notes
onOpenDirectorNotes?: () => void;
+ // Agent Inbox (Unified Inbox)
+ onOpenAgentInbox?: () => void;
+
// Auto-scroll
autoScrollAiMode?: boolean;
setAutoScrollAiMode?: (value: boolean) => void;
@@ -1060,6 +1063,8 @@ export const AppUtilityModals = memo(function AppUtilityModals({
onOpenSymphony,
// Director's Notes
onOpenDirectorNotes,
+ // Agent Inbox (Unified Inbox)
+ onOpenAgentInbox,
// Auto-scroll
autoScrollAiMode,
setAutoScrollAiMode,
@@ -1218,6 +1223,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({
onOpenLastDocumentGraph={onOpenLastDocumentGraph}
onOpenSymphony={onOpenSymphony}
onOpenDirectorNotes={onOpenDirectorNotes}
+ onOpenAgentInbox={onOpenAgentInbox}
autoScrollAiMode={autoScrollAiMode}
setAutoScrollAiMode={setAutoScrollAiMode}
/>
@@ -2013,6 +2019,8 @@ export interface AppModalsProps {
onOpenSymphony?: () => void;
// Director's Notes
onOpenDirectorNotes?: () => void;
+ // Agent Inbox (Unified Inbox)
+ onOpenAgentInbox?: () => void;
// Auto-scroll
autoScrollAiMode?: boolean;
setAutoScrollAiMode?: (value: boolean) => void;
@@ -2337,6 +2345,8 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) {
onOpenSymphony,
// Director's Notes
onOpenDirectorNotes,
+ // Agent Inbox (Unified Inbox)
+ onOpenAgentInbox,
// Auto-scroll
autoScrollAiMode,
setAutoScrollAiMode,
@@ -2651,6 +2661,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) {
onOpenMarketplace={onOpenMarketplace}
onOpenSymphony={onOpenSymphony}
onOpenDirectorNotes={onOpenDirectorNotes}
+ onOpenAgentInbox={onOpenAgentInbox}
autoScrollAiMode={autoScrollAiMode}
setAutoScrollAiMode={setAutoScrollAiMode}
tabSwitcherOpen={tabSwitcherOpen}
diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx
index 041d87325..2a808f6e2 100644
--- a/src/renderer/components/InputArea.tsx
+++ b/src/renderer/components/InputArea.tsx
@@ -23,6 +23,7 @@ import {
formatEnterToSend,
formatEnterToSendTooltip,
} from '../utils/shortcutFormatter';
+import { fuzzyMatchWithScore } from '../utils/search';
import type { TabCompletionSuggestion, TabCompletionFilter } from '../hooks';
import type {
SummarizeProgress,
@@ -318,14 +319,20 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
// recalculating on every render - inputValue changes on every keystroke
const inputValueLower = useMemo(() => inputValue.toLowerCase(), [inputValue]);
const filteredSlashCommands = useMemo(() => {
- return slashCommands.filter((cmd) => {
- // Check if command is only available in terminal mode
- if (cmd.terminalOnly && !isTerminalMode) return false;
- // Check if command is only available in AI mode
- if (cmd.aiOnly && isTerminalMode) return false;
- // Check if command matches input
- return cmd.command.toLowerCase().startsWith(inputValueLower);
- });
+ return slashCommands
+ .map((cmd) => {
+ // Check if command is only available in terminal mode
+ if (cmd.terminalOnly && !isTerminalMode) return null;
+ // Check if command is only available in AI mode
+ if (cmd.aiOnly && isTerminalMode) return null;
+ // Fuzzy match command against input
+ const result = fuzzyMatchWithScore(cmd.command, inputValueLower);
+ if (!result.matches) return null;
+ return { cmd, score: result.score };
+ })
+ .filter((item): item is { cmd: SlashCommand; score: number } => item !== null)
+ .sort((a, b) => b.score - a.score)
+ .map((item) => item.cmd);
}, [slashCommands, isTerminalMode, inputValueLower]);
// Ensure selectedSlashCommandIndex is valid for the filtered list
diff --git a/src/renderer/components/LogViewer.tsx b/src/renderer/components/LogViewer.tsx
index 342a3ef38..2af390e81 100644
--- a/src/renderer/components/LogViewer.tsx
+++ b/src/renderer/components/LogViewer.tsx
@@ -98,16 +98,16 @@ export function LogViewer({
) => Set<'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'>)
) => {
setSelectedLevelsState((prev) => {
- const newSet = typeof updater === 'function' ? updater(prev) : updater;
- // Persist to settings
- if (onSelectedLevelsChange) {
- onSelectedLevelsChange(Array.from(newSet));
- }
- return newSet;
+ return typeof updater === 'function' ? updater(prev) : updater;
});
},
- [onSelectedLevelsChange]
+ []
);
+
+ // Persist selectedLevels to parent via effect (avoids setState-during-render warning)
+ useEffect(() => {
+ onSelectedLevelsChange?.(Array.from(selectedLevels));
+ }, [selectedLevels, onSelectedLevelsChange]);
const [expandedData, setExpandedData] = useState>(new Set());
const [showClearConfirm, setShowClearConfirm] = useState(false);
const searchInputRef = useRef(null);
diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx
index 86fe27ff2..9384d5af2 100644
--- a/src/renderer/components/MainPanel.tsx
+++ b/src/renderer/components/MainPanel.tsx
@@ -167,6 +167,7 @@ interface MainPanelProps {
onRequestTabRename?: (tabId: string) => void;
onTabReorder?: (fromIndex: number, toIndex: number) => void;
onUnifiedTabReorder?: (fromIndex: number, toIndex: number) => void;
+ onUpdateTabDescription?: (tabId: string, description: string) => void;
onTabStar?: (tabId: string, starred: boolean) => void;
onTabMarkUnread?: (tabId: string) => void;
onUpdateTabByClaudeSessionId?: (
@@ -455,6 +456,7 @@ export const MainPanel = React.memo(
onRequestTabRename,
onTabReorder,
onUnifiedTabReorder,
+ onUpdateTabDescription,
onTabStar,
onTabMarkUnread,
onToggleUnreadFilter,
@@ -1470,6 +1472,7 @@ export const MainPanel = React.memo(
onRequestRename={onRequestTabRename}
onTabReorder={onTabReorder}
onUnifiedTabReorder={onUnifiedTabReorder}
+ onUpdateTabDescription={onUpdateTabDescription}
onTabStar={onTabStar}
onTabMarkUnread={onTabMarkUnread}
onMergeWith={onMergeWith}
diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx
index 144c8e229..c91f909ac 100644
--- a/src/renderer/components/QuickActionsModal.tsx
+++ b/src/renderer/components/QuickActionsModal.tsx
@@ -117,6 +117,8 @@ interface QuickActionsModalProps {
onOpenSymphony?: () => void;
// Director's Notes
onOpenDirectorNotes?: () => void;
+ // Agent Inbox (Unified Inbox)
+ onOpenAgentInbox?: () => void;
// Auto-scroll
autoScrollAiMode?: boolean;
setAutoScrollAiMode?: (value: boolean) => void;
@@ -204,6 +206,7 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct
onOpenLastDocumentGraph,
onOpenSymphony,
onOpenDirectorNotes,
+ onOpenAgentInbox,
autoScrollAiMode,
setAutoScrollAiMode,
} = props;
@@ -1034,6 +1037,21 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct
},
]
: []),
+ // Agent Inbox (Unified Inbox) - centralized agent conversations
+ ...(onOpenAgentInbox
+ ? [
+ {
+ id: 'agentInbox',
+ label: 'Unified Inbox',
+ shortcut: shortcuts.agentInbox,
+ subtext: 'Centralized view of all agent conversations with Focus Mode',
+ action: () => {
+ onOpenAgentInbox();
+ setQuickActionOpen(false);
+ },
+ },
+ ]
+ : []),
// Auto-scroll toggle
...(setAutoScrollAiMode
? [
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index 492e40247..ab9611935 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -20,6 +20,7 @@ import {
Bot,
Clock,
ScrollText,
+ Inbox,
Cpu,
Menu,
Bookmark,
@@ -444,6 +445,7 @@ function HamburgerMenuContent({
}: HamburgerMenuContentProps) {
const shortcuts = useSettingsStore((s) => s.shortcuts);
const directorNotesEnabled = useSettingsStore((s) => s.encoreFeatures.directorNotes);
+ const unifiedInboxEnabled = useSettingsStore((s) => s.encoreFeatures.unifiedInbox);
const {
setShortcutsHelpOpen,
setSettingsModalOpen,
@@ -453,6 +455,7 @@ function HamburgerMenuContent({
setUsageDashboardOpen,
setSymphonyModalOpen,
setDirectorNotesOpen,
+ setAgentInboxOpen,
setUpdateCheckModalOpen,
setAboutModalOpen,
setQuickActionOpen,
@@ -718,6 +721,33 @@ function HamburgerMenuContent({
)}
)}
+ {unifiedInboxEnabled && (
+
+ )}
@@ -3638,6 +3640,134 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
);
})()}
+
+ {/* Unified Inbox Feature Section */}
+
+
+
+
+
+
+ Unified Inbox
+
+ Beta
+
+
+
+ Cross-agent inbox to triage and reply to all active conversations (Alt+I)
+
+
+
+
+
+
+
+ {/* Tab Descriptions Feature Card */}
+
+
+
+
+
+
+ Tab Descriptions
+
+
+ Show AI-generated descriptions below tab names for quick context
+
+
+
+
+
+
)}
diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx
index 6cd3090e9..77c49afeb 100644
--- a/src/renderer/components/TabBar.tsx
+++ b/src/renderer/components/TabBar.tsx
@@ -20,6 +20,7 @@ import {
Loader2,
ExternalLink,
FolderOpen,
+ FileText,
} from 'lucide-react';
import type { AITab, Theme, FilePreviewTab, UnifiedTab } from '../types';
import { hasDraft } from '../utils/tabHelpers';
@@ -38,6 +39,7 @@ interface TabBarProps {
onTabReorder?: (fromIndex: number, toIndex: number) => void;
/** Handler to reorder tabs in unified tab order (AI + file tabs) */
onUnifiedTabReorder?: (fromIndex: number, toIndex: number) => void;
+ onUpdateTabDescription?: (tabId: string, description: string) => void;
onTabStar?: (tabId: string, starred: boolean) => void;
onTabMarkUnread?: (tabId: string) => void;
/** Handler to open merge session modal with this tab as source */
@@ -118,6 +120,8 @@ interface TabProps {
onExportHtml?: (tabId: string) => void;
/** Stable callback - receives tabId */
onPublishGist?: (tabId: string) => void;
+ /** Stable callback - receives tabId and new description */
+ onUpdateTabDescription?: (tabId: string, description: string) => void;
/** Stable callback - receives tabId */
onMoveToFirst?: (tabId: string) => void;
/** Stable callback - receives tabId */
@@ -214,6 +218,7 @@ const Tab = memo(function Tab({
onCopyContext,
onExportHtml,
onPublishGist,
+ onUpdateTabDescription,
onMoveToFirst,
onMoveToLast,
isFirstTab,
@@ -231,6 +236,9 @@ const Tab = memo(function Tab({
const [isHovered, setIsHovered] = useState(false);
const [overlayOpen, setOverlayOpen] = useState(false);
const [showCopied, setShowCopied] = useState(false);
+ const [isEditingDescription, setIsEditingDescription] = useState(false);
+ const [descriptionDraft, setDescriptionDraft] = useState(tab.description ?? '');
+ const descriptionDraftRef = useRef(descriptionDraft);
const [overlayPosition, setOverlayPosition] = useState<{
top: number;
left: number;
@@ -453,6 +461,83 @@ const Tab = memo(function Tab({
[onCloseTabsRight, tabId]
);
+ // Description editing handlers
+ const descriptionButtonRef = useRef