Skip to content

feat: Unified Inbox with Focus Mode (Encore Feature)#474

Open
felipeggv wants to merge 7 commits intoRunMaestro:mainfrom
felipeggv:feat/rebuild-unified-inbox
Open

feat: Unified Inbox with Focus Mode (Encore Feature)#474
felipeggv wants to merge 7 commits intoRunMaestro:mainfrom
felipeggv:feat/rebuild-unified-inbox

Conversation

@felipeggv
Copy link

@felipeggv felipeggv commented Feb 27, 2026

Summary

Adds a fully gated Unified Inbox feature behind the encoreFeatures.unifiedInbox flag (disabled by default). The inbox aggregates the latest messages from all active agents into a single, keyboard-navigable overlay with focus mode for deep-dive reading and inline replies.

Key changes:

  • 6 new source files: AgentInbox/index.tsx, InboxListView.tsx, FocusModeView.tsx, useAgentInbox.ts, agent-inbox.ts types, tabDisplayName.ts utility
  • App.tsx integration: lazy import, 5 handler functions, ref binding, conditional rendering
  • Keyboard shortcut: Alt+I opens inbox (triple-gated: shortcut match + feature flag + setter exists)
  • Command palette: Unified Inbox wired into Cmd+K quick actions with encore gating
  • Settings UI: Toggle added to SettingsModal Encore section with ARIA attributes
  • Modal system: agentInbox registered in modalStore with AgentInboxModalData type

Fixes applied from review branches:

  • Race condition: Resize observer cleanup prevents stale layout after unmount
  • Resize leak: Observer properly disconnected on component teardown
  • Group keys: Sort-by-agent uses sessionId (stable) not sessionName (mutable)
  • Duplicate logs: Deduplication guard prevents repeated console entries
  • AI mode forcing: Focus mode no longer forces terminal mode switch
  • ARIA compliance: Proper roles, labels, and keyboard navigation attributes
  • Performance: useMemo/useCallback on filters, sorts, and derived data; double-rAF focus instead of setTimeout hack

Test coverage:

  • useAgentInbox.test.ts: 26 tests (filters, sorts, truncation, timestamp fallback)
  • AgentInbox.test.tsx: 18 tests (render, filter pills, keyboard nav, escape, focus mode, reply)
  • modalStore.test.ts: Updated 1 test to reflect new updateModalData behavior (stores data on unopened modals)

Test plan

  • Enable unifiedInbox in Settings > Encore and verify Alt+I opens the inbox
  • Verify inbox shows latest messages from all active agents
  • Test filter pills: all, unread, read, starred
  • Test sort modes: newest, oldest, grouped, by agent
  • Enter focus mode (Enter/double-click) and verify message thread display
  • Test reply input sends to correct agent tab
  • Verify Escape closes inbox and returns focus to previous element
  • Verify Cmd+K shows "Open Unified Inbox" when feature is enabled
  • Verify inbox is completely invisible when feature flag is disabled
  • Run npm run test — all non-baseline failures should pass

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added Unified Inbox feature with dual list and focus modes for managing conversations
    • Filtering options: All, Unread, Read, Starred
    • Sorting options: Newest, Oldest, Grouped, By Agent
    • Focus mode for detailed conversation view with reply composer
    • Keyboard shortcut (Alt+I) and quick action access for unified inbox
    • Context usage indicators and thinking mode toggle
    • Settings toggle to enable/disable unified inbox
  • Tests

    • Comprehensive test coverage for inbox components, hooks, and modal state management

Felipe Gobbi and others added 7 commits February 27, 2026 11:16
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 <noreply@anthropic.com>
The reply input auto-focus in FocusModeView used a setTimeout(60ms) fallback
alongside requestAnimationFrame. Replaced with double requestAnimationFrame
which reliably fires after browser paint without arbitrary timing delays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ection

Adds role="switch" and aria-checked for accessibility compliance.
Follows existing Director's Notes toggle pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…gating

Add onOpenAgentInbox prop through App.tsx → AppModals → QuickActionsModal
chain, gated behind encoreFeatures.unifiedInbox. The action calls the same
setAgentInboxOpen(true) used by the Alt+I keyboard shortcut, ensuring all
entry points share the same open/close state from modalStore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…stamp)

26 tests covering:
- Filter logic: all/unread/read/starred modes
- Sort logic: newest/oldest/grouped/byAgent modes
- Summary truncation at MAX_MESSAGE_LENGTH (90 chars)
- Timestamp fallback chain: log → createdAt → Date.now()
- Exported utilities: truncate() and generateSmartSummary()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ocus mode)

18 tests across 6 describe blocks covering:
- List mode rendering with ARIA attributes
- Filter segmented control (All/Unread/Read/Starred)
- Keyboard navigation (ArrowUp/Down, Enter to navigate)
- Escape/close behavior (overlay click, close button)
- Focus mode entry (F key, double-click) and exit (Escape, Backspace)
- Reply textarea presence in focus mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
updateModalData now stores data even on unopened modals (needed for
AgentInbox pattern). Updated test expectation accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@felipeggv
Copy link
Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

Adds a new "Unified Inbox" feature providing a two-view modal interface (list and focus modes) for browsing and interacting with agent sessions, including filtering, sorting, keyboard navigation, and persistent state management integration.

Changes

Cohort / File(s) Summary
Test Suites
src/__tests__/renderer/components/AgentInbox/AgentInbox.test.tsx, src/__tests__/renderer/hooks/useAgentInbox.test.ts, src/__tests__/renderer/stores/modalStore.test.ts
Added comprehensive test coverage for new AgentInbox component and useAgentInbox hook with mocked dependencies; updated modalStore test to verify data persistence for unopened modals.
Agent Inbox Components
src/renderer/components/AgentInbox/index.tsx, src/renderer/components/AgentInbox/InboxListView.tsx, src/renderer/components/AgentInbox/FocusModeView.tsx
Introduced new two-mode inbox UI: main AgentInbox orchestrator with mode switching, virtualized InboxListView with sorting/filtering/grouping controls and keyboard navigation, and FocusModeView with resizable sidebar and markdown-rich conversation display.
Types and Constants
src/renderer/types/agent-inbox.ts, src/renderer/types/index.ts, src/renderer/constants/modalPriorities.ts, src/renderer/constants/shortcuts.ts
Added InboxItem interface, filter/sort/view mode types, status labels and colors; extended EncoreFeatureFlags with unifiedInbox; added AGENT_INBOX modal priority and agentInbox keyboard shortcut.
Hooks and Utilities
src/renderer/hooks/useAgentInbox.ts, src/renderer/utils/tabDisplayName.ts
Implemented useAgentInbox hook with filtering (all/unread/read/starred), sorting (newest/oldest/grouped/byAgent), and message truncation; added getTabDisplayName utility to derive session display names from various ID formats.
Modal and Store Integration
src/renderer/stores/modalStore.ts, src/renderer/stores/settingsStore.ts
Extended modalStore with agentInbox modal support and AgentInboxModalData type; added unifiedInbox feature flag to DEFAULT_ENCORE_FEATURES; updated getModalActions and useModalActions to expose agentInbox state handlers.
App and Modal Props Wiring
src/renderer/App.tsx, src/renderer/components/AppModals.tsx, src/renderer/components/AppUtilityModals.tsx, src/renderer/components/QuickActionsModal.tsx, src/renderer/components/SettingsModal.tsx
Integrated AgentInbox into main App with lazy loading, session navigation, and quick-reply flow; extended AppModals and QuickActionsModal props to wire onOpenAgentInbox; added UI toggle for unifiedInbox feature in SettingsModal; wired keyboard shortcut handler in useMainKeyboardHandler.

Sequence Diagram

sequenceDiagram
    actor User
    participant KeyboardHandler as Keyboard Handler
    participant App as App Component
    participant ModalStore as Modal Store
    participant AgentInbox as AgentInbox Modal
    participant ListView as InboxListView
    participant FocusView as FocusModeView
    participant SessionMgr as Session Manager

    User->>KeyboardHandler: Alt+I (agentInbox shortcut)
    activate KeyboardHandler
    KeyboardHandler->>ModalStore: setAgentInboxOpen(true)
    deactivate KeyboardHandler
    activate ModalStore
    ModalStore->>ModalStore: openModal('agentInbox')
    ModalStore-->>App: agentInboxOpen=true
    deactivate ModalStore

    App->>AgentInbox: render with sessions/groups
    activate AgentInbox
    AgentInbox->>ListView: render list mode (default)
    activate ListView
    ListView->>ListView: useAgentInbox hook
    ListView->>ListView: filter/sort items
    ListView-->>User: display filtered inbox items

    User->>ListView: ArrowDown/Up navigation
    ListView->>ListView: update selected row
    ListView-->>User: highlight next/prev item

    User->>ListView: Enter key
    ListView->>AgentInbox: onNavigateToSession(sessionId, tabId)
    deactivate ListView
    AgentInbox->>SessionMgr: navigate to session/tab
    SessionMgr-->>User: activate target session

    User->>ListView: F key or double-click
    activate ListView
    ListView->>AgentInbox: switch to focus mode
    deactivate ListView
    AgentInbox->>FocusView: render focus mode
    activate FocusView
    FocusView->>FocusView: render sidebar + conversation
    FocusView-->>User: show logs and reply composer

    User->>FocusView: type reply + Enter
    FocusView->>AgentInbox: onQuickReply(sessionId, message)
    AgentInbox->>SessionMgr: process reply in target session
    SessionMgr-->>User: update session with reply

    User->>FocusView: Escape key
    FocusView->>AgentInbox: switch back to list mode
    deactivate FocusView
    AgentInbox->>ListView: render list mode
    activate ListView
    ListView-->>User: return to inbox list
    deactivate ListView

    User->>AgentInbox: click close / Escape
    deactivate AgentInbox
    AgentInbox->>ModalStore: setAgentInboxOpen(false)
    activate ModalStore
    ModalStore-->>App: agentInboxOpen=false
    deactivate ModalStore
    App->>User: close modal, restore focus
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: introducing a Unified Inbox feature with Focus Mode as an Encore-gated feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (3)
src/renderer/types/agent-inbox.ts (1)

1-11: Narrow toolType to the session model instead of string.

toolType is sourced from Session, so typing it as string weakens safety and can hide invalid values.

♻️ Proposed type-safety refactor
-import type { SessionState } from './index';
+import type { SessionState, Session } from './index';
@@
-	toolType: string;
+	toolType: Session['toolType'];

As per coding guidelines: src/**/*.{ts,tsx}: Use the Session interface in code to represent agents (due to historical naming), but use 'agent' in user-facing language and 'session' for provider-level conversation contexts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/types/agent-inbox.ts` around lines 1 - 11, The
InboxItem.toolType is currently typed as string; narrow it to the session model
type by importing the Session (or Session interface exported from ./index) and
changing toolType's type to Session['toolType'] (or the exact property name on
Session that represents the agent/model), so InboxItem uses the Session-derived
union instead of a plain string; update the import to pull the Session type if
needed and adjust the InboxItem interface accordingly.
src/renderer/components/AgentInbox/FocusModeView.tsx (1)

434-457: Add focus handlers to keyboard-focusable group headers.

These headers are intentionally keyboard-focusable (tabIndex={0}) but don’t implement focus/blur handlers, unlike item rows.

As per coding guidelines: src/renderer/components/**/*.{ts,tsx}: Add tabIndex attribute and focus event handlers when implementing components that need keyboard focus.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx` around lines 434 - 457,
The keyboard-focusable header div (the element using tabIndex={0} and toggling
setCollapsedGroups with row.groupKey) needs onFocus and onBlur handlers like
item rows have; add onFocus and onBlur to this header element that update the
same focus state used by item rows (reuse the existing item-row focus handlers
if present, e.g., onItemFocus/onItemBlur or the state setter that tracks focused
rows/groups, otherwise create matching handlers such as
setFocusedGroup(row.groupKey) and clear it on blur) so headers get the same
focus/blur behavior as items.
src/renderer/App.tsx (1)

4261-4271: Consider passing enterToSendAI prop to AgentInbox for consistency with quick reply keyboard behavior.

The enterToSendAI prop is optional in AgentInboxProps, but it's available in App.tsx scope (line 401) and should be passed to maintain consistency with the user's Enter-to-send setting. The AgentInbox component passes this to FocusModeView, which uses it to determine keyboard behavior for quick replies. Omitting it means the inbox quick reply won't respect the global Enter-to-send preference.

Suggested enhancement
 <AgentInbox
 	sessions={sessions}
 	groups={groups}
 	theme={theme}
+	enterToSendAI={enterToSendAI}
 	onClose={() => setAgentInboxOpen(false)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/App.tsx` around lines 4261 - 4271, App.tsx is rendering
<AgentInbox> but not passing the enterToSendAI prop; update the <AgentInbox>
props to include enterToSendAI={enterToSendAI} so AgentInbox receives the user's
Enter-to-send preference (enterToSendAI is defined in App.tsx scope) and will
forward it through AgentInboxProps to FocusModeView for correct quick-reply
keyboard behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/renderer/App.tsx`:
- Around line 1408-1424: The quick-reply flow currently uses queueMicrotask to
flip back to previous session which can cause a visible flicker; change the
restore step to use React.startTransition to defer the UI update and avoid the
snap. In the useEffect handling pendingInboxQuickReply (references:
pendingInboxQuickReply, inboxProcessInputRef.current, setPendingInboxQuickReply,
setActiveSessionId, activeSession?.id), import startTransition from React and
replace queueMicrotask(() => setActiveSessionId(previousActiveSessionId)) with
startTransition(() => setActiveSessionId(previousActiveSessionId))). This
ensures the session restore is rendered non-urgently and reduces flicker.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx`:
- Around line 228-233: The icon-only button in FocusModeView (the button using
onToggleRaw and showRawMarkdown) currently relies on title for screen readers;
add an explicit aria-label that matches the title logic (e.g.,
aria-label={showRawMarkdown ? 'Show formatted' : 'Show plain text'}) so
assistive tech announces it reliably. Apply the same fix to the other icon-only
buttons referenced (around lines 874-883 and 1107-1137) — locate buttons using
only title/tooltip and add corresponding aria-label attributes that reflect the
same descriptive text as their title.
- Around line 427-438: The header elements in FocusModeView are using
role="option" incorrectly (no listbox and headers aren’t selectable); update the
ARIA semantics by replacing role="option" on the header divs with an appropriate
interactive/structural role such as role="button" (or role="heading" with a
nested button) and expose their collapsed state via aria-expanded and
aria-controls on the clickable element; ensure only actual selectable rows
retain role="option" inside a proper listbox container (or remove option roles
entirely if not using a listbox). Apply the same change to the other header
instance referenced around the rows mapping (the second header block at the
similar code region noted in the comment).

In `@src/renderer/components/AgentInbox/InboxListView.tsx`:
- Around line 1141-1178: The group header button currently removes the native
focus outline (className "outline-none") which leaves keyboard users without a
visible focus state; update the button (the element that calls
toggleGroup(row.groupKey) and uses isRowSelected) to include tabIndex={0} and
add onFocus/onBlur handlers that set a focused state (or apply a CSS class) to
render a visible focus ring (e.g., using theme.colors.accent or a focus-visible
style) when focused, and restore the original look on blur; ensure the onKeyDown
behavior remains and consider replacing unconditional "outline-none" with a
conditional class that hides the outline only when not focused so keyboard users
see the focus indicator.
- Around line 808-811: getHeaderFocusables currently includes disabled controls
which can trap Tab; update it to only return genuinely focusable, enabled
elements by filtering the querySelectorAll results for elements that are not
disabled and not aria-disabled and have a non-negative tabIndex. Specifically,
in getHeaderFocusables (and apply the same change to the other
focusable-collector functions in this component), use
Array.from(headerRef.current.querySelectorAll<HTMLElement>('button,
[tabindex="0"]')).filter(el => !el.hasAttribute('disabled') &&
el.getAttribute('aria-disabled') !== 'true' && el.tabIndex >= 0) so
disabled/aria-disabled elements are excluded from the custom Tab loop.

In `@src/renderer/components/SettingsModal.tsx`:
- Around line 3666-3671: The switch control in SettingsModal (the span element
rendered alongside the "Unified Inbox" label that uses role="switch" and
aria-checked) is missing an accessible name; update the span(s) to include an
accessible name by either adding aria-label="Unified Inbox" (or the appropriate
localized label) or by adding aria-labelledby that references the id of the
visible label element (e.g., give the label element an id and set
aria-labelledby on the span). Apply the same change to the other switch instance
mentioned (the span with role="switch" around the other Unified Inbox toggle) so
both controls are properly labeled for assistive tech.

In `@src/renderer/stores/modalStore.ts`:
- Around line 775-778: The payload type for updateAgentInboxData is too broad
(Record<string, unknown>) and bypasses updateModalData's contract; change the
parameter type to Partial<AgentInboxModalData> so updateAgentInboxData(open:
boolean) remains same for setAgentInboxOpen and updateAgentInboxData accepts
Partial<AgentInboxModalData> to enforce fields filterMode, sortMode, isExpanded
when calling updateModalData('agentInbox', ...); update the signature in
modalStore.ts and any callers to pass the narrowed type.

---

Nitpick comments:
In `@src/renderer/App.tsx`:
- Around line 4261-4271: App.tsx is rendering <AgentInbox> but not passing the
enterToSendAI prop; update the <AgentInbox> props to include
enterToSendAI={enterToSendAI} so AgentInbox receives the user's Enter-to-send
preference (enterToSendAI is defined in App.tsx scope) and will forward it
through AgentInboxProps to FocusModeView for correct quick-reply keyboard
behavior.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx`:
- Around line 434-457: The keyboard-focusable header div (the element using
tabIndex={0} and toggling setCollapsedGroups with row.groupKey) needs onFocus
and onBlur handlers like item rows have; add onFocus and onBlur to this header
element that update the same focus state used by item rows (reuse the existing
item-row focus handlers if present, e.g., onItemFocus/onItemBlur or the state
setter that tracks focused rows/groups, otherwise create matching handlers such
as setFocusedGroup(row.groupKey) and clear it on blur) so headers get the same
focus/blur behavior as items.

In `@src/renderer/types/agent-inbox.ts`:
- Around line 1-11: The InboxItem.toolType is currently typed as string; narrow
it to the session model type by importing the Session (or Session interface
exported from ./index) and changing toolType's type to Session['toolType'] (or
the exact property name on Session that represents the agent/model), so
InboxItem uses the Session-derived union instead of a plain string; update the
import to pull the Session type if needed and adjust the InboxItem interface
accordingly.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9d612b6 and ee926c2.

📒 Files selected for processing (19)
  • src/__tests__/renderer/components/AgentInbox/AgentInbox.test.tsx
  • src/__tests__/renderer/hooks/useAgentInbox.test.ts
  • src/__tests__/renderer/stores/modalStore.test.ts
  • src/renderer/App.tsx
  • src/renderer/components/AgentInbox/FocusModeView.tsx
  • src/renderer/components/AgentInbox/InboxListView.tsx
  • src/renderer/components/AgentInbox/index.tsx
  • src/renderer/components/AppModals.tsx
  • src/renderer/components/QuickActionsModal.tsx
  • src/renderer/components/SettingsModal.tsx
  • src/renderer/constants/modalPriorities.ts
  • src/renderer/constants/shortcuts.ts
  • src/renderer/hooks/keyboard/useMainKeyboardHandler.ts
  • src/renderer/hooks/useAgentInbox.ts
  • src/renderer/stores/modalStore.ts
  • src/renderer/stores/settingsStore.ts
  • src/renderer/types/agent-inbox.ts
  • src/renderer/types/index.ts
  • src/renderer/utils/tabDisplayName.ts

Comment on lines +1408 to +1424
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]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: queueMicrotask may cause brief UI flicker when restoring previous session.

The quick-reply flow switches to the target session, sends the message, then uses queueMicrotask to switch back to the previous session. This creates a brief window where the UI displays the target session before snapping back. Users may perceive a flicker, especially on slower machines.

Consider using a flag or batching mechanism to suppress UI updates during this transient state, or accept the flicker as acceptable UX for this feature.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/App.tsx` around lines 1408 - 1424, The quick-reply flow
currently uses queueMicrotask to flip back to previous session which can cause a
visible flicker; change the restore step to use React.startTransition to defer
the UI update and avoid the snap. In the useEffect handling
pendingInboxQuickReply (references: pendingInboxQuickReply,
inboxProcessInputRef.current, setPendingInboxQuickReply, setActiveSessionId,
activeSession?.id), import startTransition from React and replace
queueMicrotask(() => setActiveSessionId(previousActiveSessionId)) with
startTransition(() => setActiveSessionId(previousActiveSessionId))). This
ensures the session restore is rendered non-urgently and reduces flicker.

Comment on lines +228 to +233
<button
onClick={onToggleRaw}
className="p-1 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity"
style={{ color: showRawMarkdown ? theme.colors.accent : theme.colors.textDim }}
title={showRawMarkdown ? 'Show formatted' : 'Show plain text'}
>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add explicit accessible names to icon-only buttons.

Several icon-only controls rely on title only; they should have aria-label for reliable screen-reader announcements.

♿ Suggested accessibility labels
 						<button
 							onClick={onToggleRaw}
+							aria-label={showRawMarkdown ? 'Show formatted text' : 'Show plain text'}
 							className="p-1 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity"
@@
 				<button
 					onClick={onClose}
+					aria-label="Close focus mode"
 					className="p-1.5 rounded"
@@
 						<button
 							onClick={handleQuickReply}
+							aria-label="Send quick reply"
 							disabled={!replyText.trim()}
@@
 						<button
 							onClick={handleOpenAndReply}
+							aria-label="Open session and reply"
 							disabled={!replyText.trim()}

Also applies to: 874-883, 1107-1137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx` around lines 228 - 233,
The icon-only button in FocusModeView (the button using onToggleRaw and
showRawMarkdown) currently relies on title for screen readers; add an explicit
aria-label that matches the title logic (e.g., aria-label={showRawMarkdown ?
'Show formatted' : 'Show plain text'}) so assistive tech announces it reliably.
Apply the same fix to the other icon-only buttons referenced (around lines
874-883 and 1107-1137) — locate buttons using only title/tooltip and add
corresponding aria-label attributes that reflect the same descriptive text as
their title.

Comment on lines +427 to +438
<div className="flex-1 overflow-y-auto py-1">
{(() => {
let activeGroup: string | null = null;
return rows.map((row, rowIdx) => {
if (row.type === 'header') {
activeGroup = row.groupKey;
return (
<div
key={`header-${row.groupKey}-${rowIdx}`}
tabIndex={0}
role="option"
aria-selected={false}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix sidebar ARIA semantics (option roles are currently invalid).

role="option" is applied without a listbox container, and collapsible headers are exposed as options even though they’re not selectable items. This creates invalid SR semantics.

♿ Suggested ARIA structure fix
-			<div className="flex-1 overflow-y-auto py-1">
+			<div className="flex-1 overflow-y-auto py-1" role="listbox" aria-label="Inbox items">
@@
-								<div
+								<div
 									key={`header-${row.groupKey}-${rowIdx}`}
 									tabIndex={0}
-									role="option"
-									aria-selected={false}
+									role="button"
+									aria-expanded={!collapsedGroups.has(row.groupKey)}
@@
-								<div
+								<div
 									key={`${itm.sessionId}-${itm.tabId}`}
 									ref={isCurrent ? currentRowRef : undefined}
 									tabIndex={0}
 									role="option"
 									aria-selected={isCurrent}

Also applies to: 495-497

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx` around lines 427 - 438,
The header elements in FocusModeView are using role="option" incorrectly (no
listbox and headers aren’t selectable); update the ARIA semantics by replacing
role="option" on the header divs with an appropriate interactive/structural role
such as role="button" (or role="heading" with a nested button) and expose their
collapsed state via aria-expanded and aria-controls on the clickable element;
ensure only actual selectable rows retain role="option" inside a proper listbox
container (or remove option roles entirely if not using a listbox). Apply the
same change to the other header instance referenced around the rows mapping (the
second header block at the similar code region noted in the comment).

Comment on lines +808 to +811
const getHeaderFocusables = useCallback((): HTMLElement[] => {
if (!headerRef.current) return [];
return Array.from(headerRef.current.querySelectorAll<HTMLElement>('button, [tabindex="0"]'));
}, []);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Filter disabled controls out of the custom Tab loop.

The current focus list can include disabled buttons; when that happens, Tab is prevented but focus may not move, causing keyboard trap behavior.

Suggested fix
 const getHeaderFocusables = useCallback((): HTMLElement[] => {
 	if (!headerRef.current) return [];
-	return Array.from(headerRef.current.querySelectorAll<HTMLElement>('button, [tabindex="0"]'));
+	return Array.from(
+		headerRef.current.querySelectorAll<HTMLElement>('button, [tabindex="0"]')
+	).filter(
+		(el) =>
+			!el.hasAttribute('disabled') &&
+			el.getAttribute('aria-disabled') !== 'true' &&
+			el.tabIndex >= 0
+	);
 }, []);

Also applies to: 817-841

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/InboxListView.tsx` around lines 808 - 811,
getHeaderFocusables currently includes disabled controls which can trap Tab;
update it to only return genuinely focusable, enabled elements by filtering the
querySelectorAll results for elements that are not disabled and not
aria-disabled and have a non-negative tabIndex. Specifically, in
getHeaderFocusables (and apply the same change to the other focusable-collector
functions in this component), use
Array.from(headerRef.current.querySelectorAll<HTMLElement>('button,
[tabindex="0"]')).filter(el => !el.hasAttribute('disabled') &&
el.getAttribute('aria-disabled') !== 'true' && el.tabIndex >= 0) so
disabled/aria-disabled elements are excluded from the custom Tab loop.

Comment on lines +1141 to +1178
<button
type="button"
key={`header-${row.groupKey}`}
className="outline-none"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
alignItems: 'center',
paddingLeft: 16,
paddingRight: 16,
fontSize: 13,
fontWeight: 600,
color: isRowSelected ? theme.colors.accent : theme.colors.textDim,
letterSpacing: '0.5px',
textTransform: 'uppercase',
borderBottom: `2px solid ${theme.colors.border}40`,
borderLeft: isRowSelected
? `3px solid ${theme.colors.accent}`
: '3px solid transparent',
backgroundColor: isRowSelected ? `${theme.colors.accent}10` : 'transparent',
cursor: 'pointer',
borderTop: 'none',
borderRight: 'none',
textAlign: 'left',
}}
onClick={() => toggleGroup(row.groupKey)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleGroup(row.groupKey);
}
}}
>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Group header buttons remove native focus outline without replacement.

These buttons are keyboard-focusable but outline-none suppresses visible focus state, so keyboard users can lose position.

Suggested fix
 	<button
 		type="button"
 		key={`header-${row.groupKey}`}
 		className="outline-none"
+		onFocus={(e) => {
+			e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+			e.currentTarget.style.outlineOffset = '-2px';
+		}}
+		onBlur={(e) => {
+			e.currentTarget.style.outline = 'none';
+		}}
 		style={{
 			position: 'absolute',

As per coding guidelines, src/renderer/components/**/*.{ts,tsx}: Add tabIndex attribute and focus event handlers when implementing components that need keyboard focus.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
key={`header-${row.groupKey}`}
className="outline-none"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
alignItems: 'center',
paddingLeft: 16,
paddingRight: 16,
fontSize: 13,
fontWeight: 600,
color: isRowSelected ? theme.colors.accent : theme.colors.textDim,
letterSpacing: '0.5px',
textTransform: 'uppercase',
borderBottom: `2px solid ${theme.colors.border}40`,
borderLeft: isRowSelected
? `3px solid ${theme.colors.accent}`
: '3px solid transparent',
backgroundColor: isRowSelected ? `${theme.colors.accent}10` : 'transparent',
cursor: 'pointer',
borderTop: 'none',
borderRight: 'none',
textAlign: 'left',
}}
onClick={() => toggleGroup(row.groupKey)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleGroup(row.groupKey);
}
}}
>
<button
type="button"
key={`header-${row.groupKey}`}
className="outline-none"
onFocus={(e) => {
e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
e.currentTarget.style.outlineOffset = '-2px';
}}
onBlur={(e) => {
e.currentTarget.style.outline = 'none';
}}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
alignItems: 'center',
paddingLeft: 16,
paddingRight: 16,
fontSize: 13,
fontWeight: 600,
color: isRowSelected ? theme.colors.accent : theme.colors.textDim,
letterSpacing: '0.5px',
textTransform: 'uppercase',
borderBottom: `2px solid ${theme.colors.border}40`,
borderLeft: isRowSelected
? `3px solid ${theme.colors.accent}`
: '3px solid transparent',
backgroundColor: isRowSelected ? `${theme.colors.accent}10` : 'transparent',
cursor: 'pointer',
borderTop: 'none',
borderRight: 'none',
textAlign: 'left',
}}
onClick={() => toggleGroup(row.groupKey)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleGroup(row.groupKey);
}
}}
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/InboxListView.tsx` around lines 1141 -
1178, The group header button currently removes the native focus outline
(className "outline-none") which leaves keyboard users without a visible focus
state; update the button (the element that calls toggleGroup(row.groupKey) and
uses isRowSelected) to include tabIndex={0} and add onFocus/onBlur handlers that
set a focused state (or apply a CSS class) to render a visible focus ring (e.g.,
using theme.colors.accent or a focus-visible style) when focused, and restore
the original look on blur; ensure the onKeyDown behavior remains and consider
replacing unconditional "outline-none" with a conditional class that hides the
outline only when not focused so keyboard users see the focus indicator.

Comment on lines +3666 to +3671
<div
className="text-sm font-bold flex items-center gap-2"
style={{ color: theme.colors.textMain }}
>
Unified Inbox
<span
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The Unified Inbox switch is missing an accessible name.

role="switch" + aria-checked is present, but there is no aria-label/aria-labelledby, so assistive tech gets an unlabeled control.

Suggested fix
-	<div
+	<div
+		id="unified-inbox-feature-label"
 		className="text-sm font-bold flex items-center gap-2"
 		style={{ color: theme.colors.textMain }}
 	>
 		Unified Inbox
@@
 	<button
 		role="switch"
+		aria-labelledby="unified-inbox-feature-label"
 		aria-checked={encoreFeatures.unifiedInbox}
 		onClick={(e) => {

Also applies to: 3686-3708

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/SettingsModal.tsx` around lines 3666 - 3671, The
switch control in SettingsModal (the span element rendered alongside the
"Unified Inbox" label that uses role="switch" and aria-checked) is missing an
accessible name; update the span(s) to include an accessible name by either
adding aria-label="Unified Inbox" (or the appropriate localized label) or by
adding aria-labelledby that references the id of the visible label element
(e.g., give the label element an id and set aria-labelledby on the span). Apply
the same change to the other switch instance mentioned (the span with
role="switch" around the other Unified Inbox toggle) so both controls are
properly labeled for assistive tech.

Comment on lines +775 to +778
setAgentInboxOpen: (open: boolean) =>
open ? openModal('agentInbox') : closeModal('agentInbox'),
updateAgentInboxData: (data: Record<string, unknown>) => updateModalData('agentInbox', data),

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/renderer/stores/modalStore.ts | sed -n '770,785p'

Repository: RunMaestro/Maestro

Length of output: 803


🏁 Script executed:

rg -nP "AgentInboxModalData" src/renderer/stores/modalStore.ts

Repository: RunMaestro/Maestro

Length of output: 142


🏁 Script executed:

rg -nP "type AgentInboxModalData|interface AgentInboxModalData" src/renderer/stores/

Repository: RunMaestro/Maestro

Length of output: 138


🏁 Script executed:

rg -nP "function updateModalData|const updateModalData" src/renderer/stores/modalStore.ts -A 3

Repository: RunMaestro/Maestro

Length of output: 44


🏁 Script executed:

rg -nP "updateAgentInboxData\s*\(" src/ --type ts --type tsx -C 2

Repository: RunMaestro/Maestro

Length of output: 89


🏁 Script executed:

cat -n src/renderer/stores/modalStore.ts | sed -n '144,160p'

Repository: RunMaestro/Maestro

Length of output: 641


🏁 Script executed:

rg -nP "updateModalData" src/renderer/stores/modalStore.ts -B 2 -A 5 | head -50

Repository: RunMaestro/Maestro

Length of output: 1976


🏁 Script executed:

rg -nP "updateAgentInboxData" src/ -C 2

Repository: RunMaestro/Maestro

Length of output: 1565


Tighten updateAgentInboxData payload type to match modal contract.

Line 777 uses Record<string, unknown>, which bypasses the type safety of updateModalData. The function should accept Partial<AgentInboxModalData> to enforce the correct shape for agentInbox modal data (filterMode, sortMode, isExpanded).

Proposed fix
		// Agent Inbox Modal
		setAgentInboxOpen: (open: boolean) =>
			open ? openModal('agentInbox') : closeModal('agentInbox'),
-		updateAgentInboxData: (data: Record<string, unknown>) => updateModalData('agentInbox', data),
+		updateAgentInboxData: (data: Partial<AgentInboxModalData>) =>
+			updateModalData('agentInbox', data),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setAgentInboxOpen: (open: boolean) =>
open ? openModal('agentInbox') : closeModal('agentInbox'),
updateAgentInboxData: (data: Record<string, unknown>) => updateModalData('agentInbox', data),
setAgentInboxOpen: (open: boolean) =>
open ? openModal('agentInbox') : closeModal('agentInbox'),
updateAgentInboxData: (data: Partial<AgentInboxModalData>) =>
updateModalData('agentInbox', data),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/stores/modalStore.ts` around lines 775 - 778, The payload type
for updateAgentInboxData is too broad (Record<string, unknown>) and bypasses
updateModalData's contract; change the parameter type to
Partial<AgentInboxModalData> so updateAgentInboxData(open: boolean) remains same
for setAgentInboxOpen and updateAgentInboxData accepts
Partial<AgentInboxModalData> to enforce fields filterMode, sortMode, isExpanded
when calling updateModalData('agentInbox', ...); update the signature in
modalStore.ts and any callers to pass the narrowed type.

@greptile-apps
Copy link

greptile-apps bot commented Feb 27, 2026

Greptile Summary

Adds a fully-gated Unified Inbox feature (disabled by default) that aggregates latest messages from all active agents into a keyboard-navigable overlay with focus mode for deep reading and inline replies.

Key architectural decisions:

  • Triple-gated activation: feature flag + keyboard shortcut + setter existence check prevents accidental exposure
  • Frozen snapshot pattern: focus mode freezes item order on entry while resolving against live data for real-time updates
  • Capture/bubble phase keyboard handling: Cmd+[/] navigation fires before textarea consumes events
  • Quick reply deterministic flush: uses effect + pending state instead of RAF timing chains
  • Modal data persistence: updateModalData now stores filter/sort/expand state even when modal is closed

Integration points:

  • 6 new source files (types, hooks, 3 view components, utility)
  • App.tsx: 5 handler functions, ref binding for processInput, conditional rendering
  • Settings UI: toggle in Encore section with Beta badge and ARIA attributes
  • Command palette: wired into Cmd+K with encore gating
  • Modal system: registered at priority 555 with AgentInboxModalData type

Test coverage:

  • useAgentInbox.test.ts: 26 tests (filters, sorts, truncation, timestamp fallback)
  • AgentInbox.test.tsx: 18 tests (render, keyboard nav, focus mode, reply)
  • modalStore.test.ts: 1 updated test for new updateModalData behavior

Bug fixes applied from review branches:

  • Race condition: resize observer cleanup
  • Resize leak: proper observer disconnection
  • Group keys: sort-by-agent uses stable sessionId not mutable sessionName
  • Duplicate logs: deduplication guard
  • Performance: useMemo/useCallback on filters, sorts, derived data

Confidence Score: 5/5

  • Safe to merge with high confidence - well-architected feature with comprehensive testing and proper gating
  • Score reflects excellent implementation quality: comprehensive test coverage (44 tests), proper feature gating prevents accidental exposure, follows all CLAUDE.md patterns (tabs not spaces, error handling, performance optimization), clean integration with existing modal/keyboard systems, and multiple bug fixes already applied from review branches. The modalStore behavioral change is intentional and properly tested.
  • No files require special attention - all changes are well-tested and follow project conventions

Important Files Changed

Filename Overview
src/renderer/components/AgentInbox/index.tsx Main inbox container with view mode orchestration, frozen snapshot pattern for focus mode, proper keyboard handling with capture/bubble phases
src/renderer/components/AgentInbox/InboxListView.tsx Virtualized list view with filter pills, sort modes, grouped headers, and keyboard navigation
src/renderer/components/AgentInbox/FocusModeView.tsx Deep-dive view for individual agent conversations with log rendering, inline reply, thinking mode toggle, and markdown support
src/renderer/hooks/useAgentInbox.ts Data aggregation hook with filtering, sorting (newest/oldest/grouped/by-agent), smart summary generation, and proper memoization
src/renderer/App.tsx Adds 5 inbox handler functions (navigate, quick reply, open & reply, mark as read, toggle thinking), ref binding for processInput, and conditional rendering with encore gating
src/renderer/stores/modalStore.ts Adds agentInbox modal registration and updates updateModalData to allow storing data on unopened modals (behavioral change with test update)
src/tests/renderer/hooks/useAgentInbox.test.ts 26 tests covering truncation, smart summary generation, filtering, sorting, timestamp derivation, and group handling
src/tests/renderer/components/AgentInbox/AgentInbox.test.tsx 18 tests covering rendering, filter pills, keyboard navigation (arrows/enter/escape), focus mode entry/exit, and reply functionality

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Start([User presses Alt+I]) --> CheckGate{Feature enabled?}
    CheckGate -->|No| End1([Nothing happens])
    CheckGate -->|Yes| OpenModal[Open AgentInbox Modal]
    
    OpenModal --> LoadData[useAgentInbox hook aggregates data]
    LoadData --> FilterSort[Filter + Sort items]
    FilterSort --> ListView[InboxListView renders]
    
    ListView --> UserAction{User Action}
    
    UserAction -->|Arrow Keys| Navigate[Navigate items]
    Navigate --> ListView
    
    UserAction -->|F or Enter| EnterFocus[Enter Focus Mode]
    EnterFocus --> FreezeOrder[Freeze item order in ref]
    FreezeOrder --> FocusView[FocusModeView renders]
    
    FocusView --> FocusAction{User Action in Focus}
    
    FocusAction -->|Cmd+Bracket Keys| PrevNext[Navigate prev/next]
    PrevNext --> FocusView
    
    FocusAction -->|Type reply| QuickReply[Quick Reply handler]
    QuickReply --> SwitchSession[Switch to target session]
    SwitchSession --> ProcessInput[processInput sends text]
    ProcessInput --> RestoreSession[Restore previous session]
    
    FocusAction -->|Escape or B| ExitFocus[Exit to List View]
    ExitFocus --> ClearFreeze[Clear frozen order]
    ClearFreeze --> ListView
    
    UserAction -->|Escape| CloseModal[Close Modal]
    FocusAction -->|Click Navigate| CloseAndNav[Navigate to session]
    
    CloseModal --> RestoreFocus[Restore focus to trigger element]
    CloseAndNav --> RestoreFocus
    RestoreFocus --> End2([Complete])
Loading

Last reviewed commit: ee926c2

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (7)
src/renderer/components/SettingsModal.tsx (1)

3686-3689: ⚠️ Potential issue | 🟠 Major

Unified Inbox switch still has no accessible name.

Line 3686 defines a switch control with role="switch" and aria-checked, but it’s still unlabeled for assistive tech.

Proposed fix
-											<div
+											<div
+												id="unified-inbox-feature-label"
 												className="text-sm font-bold flex items-center gap-2"
 												style={{ color: theme.colors.textMain }}
 											>
 												Unified Inbox
@@
 									<button
 										role="switch"
+										aria-labelledby="unified-inbox-feature-label"
 										aria-checked={encoreFeatures.unifiedInbox}
 										onClick={(e) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/SettingsModal.tsx` around lines 3686 - 3689, The
switch button in SettingsModal.tsx (the <button role="switch"> that reads
aria-checked={encoreFeatures.unifiedInbox} and has the onClick handler) lacks an
accessible name; update that element to provide one (either via aria-label or
aria-labelledby referencing a visible label) so screen readers can announce it
(e.g., aria-label="Toggle unified inbox" or aria-labelledby pointing to the
corresponding label element). Ensure the chosen label accurately reflects the
control state/context and keep the existing role and aria-checked attributes
intact.
src/renderer/stores/modalStore.ts (1)

774-777: ⚠️ Potential issue | 🟠 Major

Narrow updateAgentInboxData payload to modal-specific shape.

Line 777 still accepts Record<string, unknown>, which bypasses the agentInbox modal contract and permits invalid state payloads.

🔧 Proposed fix
 		// Agent Inbox Modal
 		setAgentInboxOpen: (open: boolean) =>
 			open ? openModal('agentInbox') : closeModal('agentInbox'),
-		updateAgentInboxData: (data: Record<string, unknown>) => updateModalData('agentInbox', data),
+		updateAgentInboxData: (data: Partial<AgentInboxModalData>) =>
+			updateModalData('agentInbox', data),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/stores/modalStore.ts` around lines 774 - 777, The
updateAgentInboxData currently accepts a wide Record<string, unknown> which
breaks the agentInbox modal contract; tighten the payload by defining and using
a concrete modal-specific type/interface (e.g., AgentInboxData) and change the
signature of updateAgentInboxData to (data: AgentInboxData) and pass that into
updateModalData('agentInbox', data); update any callers to construct/validate
the AgentInboxData shape and import the type where needed so invalid payloads
are rejected at compile time.
src/renderer/components/AgentInbox/InboxListView.tsx (2)

808-811: ⚠️ Potential issue | 🟠 Major

Exclude disabled elements from the custom Tab cycle.

Line 810 collects disabled/aria-disabled elements, which can break your custom Tab loop when focus does not move.

⌨️ Suggested fix
 	const getHeaderFocusables = useCallback((): HTMLElement[] => {
 		if (!headerRef.current) return [];
-		return Array.from(headerRef.current.querySelectorAll<HTMLElement>('button, [tabindex="0"]'));
+		return Array.from(
+			headerRef.current.querySelectorAll<HTMLElement>('button, [tabindex="0"]')
+		).filter(
+			(el) =>
+				!el.hasAttribute('disabled') &&
+				el.getAttribute('aria-disabled') !== 'true' &&
+				el.tabIndex >= 0
+		);
 	}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/InboxListView.tsx` around lines 808 - 811,
The custom focusable collector getHeaderFocusables currently includes disabled
or aria-disabled elements which can stall the tab loop; update
getHeaderFocusables (used with headerRef) to only return truly focusable
elements by excluding elements with the disabled attribute and those with
aria-disabled="true" (and optionally filter out tabindex="-1"), e.g. adjust the
querySelectorAll to omit disabled/aria-disabled and/or post-filter the NodeList
so the returned array only contains elements without
element.hasAttribute('disabled') and element.getAttribute('aria-disabled') !==
'true'.

1141-1178: ⚠️ Potential issue | 🟠 Major

Restore visible focus styles for group header buttons.

Line 1144 removes native outline, but there is no replacement focus style. Keyboard users can lose track of focus on headers.

♿ Suggested fix
 										<button
 											type="button"
 											key={`header-${row.groupKey}`}
 											className="outline-none"
+											tabIndex={0}
@@
 											onClick={() => toggleGroup(row.groupKey)}
+											onFocus={(e) => {
+												e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+												e.currentTarget.style.outlineOffset = '-2px';
+											}}
+											onBlur={(e) => {
+												e.currentTarget.style.outline = 'none';
+											}}
 											onKeyDown={(e) => {

As per coding guidelines, src/renderer/components/**/*.{ts,tsx}: Add tabIndex attribute and focus event handlers when implementing components that need keyboard focus.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/InboxListView.tsx` around lines 1141 -
1178, The header button removed the native outline but never provided an
accessible focus replacement; restore keyboard focusability by adding
tabIndex={0} to the group header button and provide visible focus styling via
focus handlers or a focused state: add onFocus={() =>
setFocusedGroup(row.groupKey)} and onBlur={() => setFocusedGroup(undefined)} (or
use a focus-visible CSS class) and when focused apply a clear visual indicator
(e.g., a 2px accent outline/boxShadow or change background/border similar to
isRowSelected). Keep existing onKeyDown/toggleGroup behavior and reference the
button rendering in InboxListView (the button using row.groupKey, virtualRow,
isRowSelected and toggleGroup) when making the change.
src/renderer/components/AgentInbox/FocusModeView.tsx (2)

228-233: ⚠️ Potential issue | 🟠 Major

Add explicit accessible names to icon-only buttons.

Line 228, Line 874, Line 1107, and Line 1123 use icon-only controls with title only. Add aria-label so screen readers announce these actions reliably.

♿ Suggested fix
 						<button
 							onClick={onToggleRaw}
+							aria-label={showRawMarkdown ? 'Show formatted text' : 'Show plain text'}
 							className="p-1 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity"
 							style={{ color: showRawMarkdown ? theme.colors.accent : theme.colors.textDim }}
 							title={showRawMarkdown ? 'Show formatted' : 'Show plain text'}
 						>
@@
 				<button
 					onClick={onClose}
+					aria-label="Close focus mode"
 					className="p-1.5 rounded"
 					style={{ color: theme.colors.textDim }}
@@
 						<button
 							onClick={handleQuickReply}
+							aria-label={enterToSendAI ? 'Quick reply (Enter)' : 'Quick reply (⌘Enter)'}
 							disabled={!replyText.trim()}
 							className="p-2 rounded-lg transition-colors flex-shrink-0"
@@
 						<button
 							onClick={handleOpenAndReply}
+							aria-label="Open session and reply (Shift+Enter)"
 							disabled={!replyText.trim()}
 							className="p-1.5 rounded-lg transition-colors flex-shrink-0 text-xs"

Also applies to: 874-883, 1107-1137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx` around lines 228 - 233,
Add explicit aria-label attributes to all icon-only buttons in the FocusModeView
component that currently only use title for accessibility; for example, the
button using onToggleRaw (which reads showRawMarkdown and theme.colors) should
get an aria-label (e.g., aria-label={showRawMarkdown ? 'Show formatted' : 'Show
plain text'}). Do the same for the other icon-only controls in this component
(the ones with only title attributes) so screen readers can reliably announce
their actions. Ensure the aria-label matches the existing title text and toggles
where appropriate.

427-438: ⚠️ Potential issue | 🟠 Major

Fix sidebar ARIA semantics and header keyboard-focus feedback.

Line 437 exposes collapsible headers as role="option" without a listbox context. Headers are controls, not selectable options. Also, header focus targets need explicit focus styling for keyboard visibility.

♿ Suggested fix
-			<div className="flex-1 overflow-y-auto py-1">
+			<div className="flex-1 overflow-y-auto py-1" role="listbox" aria-label="Inbox items">
@@
 								<div
 									key={`header-${row.groupKey}-${rowIdx}`}
 									tabIndex={0}
-									role="option"
-									aria-selected={false}
+									role="button"
+									aria-expanded={!collapsedGroups.has(row.groupKey)}
 									onClick={() => {
@@
 									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,
 									}}
+									onFocus={(e) => {
+										e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
+										e.currentTarget.style.outlineOffset = '-2px';
+									}}
+									onBlur={(e) => {
+										e.currentTarget.style.outline = 'none';
+									}}
 								>

As per coding guidelines, src/renderer/components/**/*.{ts,tsx}: Add tabIndex attribute and focus event handlers when implementing components that need keyboard focus.

Also applies to: 434-474, 495-497

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx` around lines 427 - 438,
The header div inside the rows.map (where activeGroup and row.groupKey are used)
should be treated as a control, not an option: replace role="option" and
aria-selected with role="button", add aria-expanded={activeGroup ===
row.groupKey} and aria-controls={`group-${row.groupKey}`}, keep tabIndex={0},
and wire a keyboard handler (onKeyDown handling Enter/Space) to call the same
toggle/expand function used for clicks (or create a small toggleHeader /
onHeaderToggle handler if none exists). Also add explicit focus-visible handling
for keyboard users by applying a focus class (or onFocus/onBlur to set a focused
state) so the header displays visible focus styling when focused. Ensure these
changes are applied to the header rendering in FocusModeView (the header div
created in rows.map).
src/renderer/App.tsx (1)

1408-1424: ⚠️ Potential issue | 🟡 Minor

Quick-reply restore still has a transient session-switch flicker risk.

Line 1420 restores the previous session in a microtask right after sending, so the UI can briefly jump to the target session and snap back.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/App.tsx` around lines 1408 - 1424, The restore logic for
quick-reply causes a UI flicker because
setActiveSessionId(previousActiveSessionId) is deferred via queueMicrotask;
change the effect in useEffect so that after capturing previousActiveSessionId
and clearing pendingInboxQuickReply you restore synchronously (remove
queueMicrotask) or use React startTransition to update the active session
without a frame jump; specifically update the block that reads
previousActiveSessionId, setPendingInboxQuickReply(null), and the conditional
that calls setActiveSessionId(previousActiveSessionId) (all in the useEffect
handling pendingInboxQuickReply, activeSession?.id, setActiveSessionId) so the
session restore happens immediately (or inside startTransition) to avoid the
transient switch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/renderer/components/AgentInbox/FocusModeView.tsx`:
- Around line 676-677: The current single boolean showRawMarkdown in
FocusModeView causes raw-markdown to bleed across sessions; replace it with a
keyed state (e.g. a map keyed by `${sessionId}:${tabId}`) so raw mode is stored
per-session/tab. Update the state declaration (replace
showRawMarkdown/setShowRawMarkdown with something like rawMarkdownByKey and
setRawMarkdownByKey), compute a key from the existing sessionId and tabId
values, and change all reads/writes (the toggle handler and any consumers
referenced around the showRawMarkdown usage) to read/write the boolean at that
key; apply the same keyed-state change to the other instance of this logic
referenced (around the 1039–1045 usage) so both places use the per-session/tab
map.

In `@src/renderer/components/AgentInbox/InboxListView.tsx`:
- Around line 1016-1033: The close button in the InboxListView component (the
button with onClick={onClose} and the <X /> icon) is icon-only and needs an
explicit accessible name; add an aria-label (e.g., aria-label="Close" or
aria-label="Close conversation") to the button element so screen readers
announce it consistently while keeping the existing title and event handlers
intact.

---

Duplicate comments:
In `@src/renderer/App.tsx`:
- Around line 1408-1424: The restore logic for quick-reply causes a UI flicker
because setActiveSessionId(previousActiveSessionId) is deferred via
queueMicrotask; change the effect in useEffect so that after capturing
previousActiveSessionId and clearing pendingInboxQuickReply you restore
synchronously (remove queueMicrotask) or use React startTransition to update the
active session without a frame jump; specifically update the block that reads
previousActiveSessionId, setPendingInboxQuickReply(null), and the conditional
that calls setActiveSessionId(previousActiveSessionId) (all in the useEffect
handling pendingInboxQuickReply, activeSession?.id, setActiveSessionId) so the
session restore happens immediately (or inside startTransition) to avoid the
transient switch.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx`:
- Around line 228-233: Add explicit aria-label attributes to all icon-only
buttons in the FocusModeView component that currently only use title for
accessibility; for example, the button using onToggleRaw (which reads
showRawMarkdown and theme.colors) should get an aria-label (e.g.,
aria-label={showRawMarkdown ? 'Show formatted' : 'Show plain text'}). Do the
same for the other icon-only controls in this component (the ones with only
title attributes) so screen readers can reliably announce their actions. Ensure
the aria-label matches the existing title text and toggles where appropriate.
- Around line 427-438: The header div inside the rows.map (where activeGroup and
row.groupKey are used) should be treated as a control, not an option: replace
role="option" and aria-selected with role="button", add
aria-expanded={activeGroup === row.groupKey} and
aria-controls={`group-${row.groupKey}`}, keep tabIndex={0}, and wire a keyboard
handler (onKeyDown handling Enter/Space) to call the same toggle/expand function
used for clicks (or create a small toggleHeader / onHeaderToggle handler if none
exists). Also add explicit focus-visible handling for keyboard users by applying
a focus class (or onFocus/onBlur to set a focused state) so the header displays
visible focus styling when focused. Ensure these changes are applied to the
header rendering in FocusModeView (the header div created in rows.map).

In `@src/renderer/components/AgentInbox/InboxListView.tsx`:
- Around line 808-811: The custom focusable collector getHeaderFocusables
currently includes disabled or aria-disabled elements which can stall the tab
loop; update getHeaderFocusables (used with headerRef) to only return truly
focusable elements by excluding elements with the disabled attribute and those
with aria-disabled="true" (and optionally filter out tabindex="-1"), e.g. adjust
the querySelectorAll to omit disabled/aria-disabled and/or post-filter the
NodeList so the returned array only contains elements without
element.hasAttribute('disabled') and element.getAttribute('aria-disabled') !==
'true'.
- Around line 1141-1178: The header button removed the native outline but never
provided an accessible focus replacement; restore keyboard focusability by
adding tabIndex={0} to the group header button and provide visible focus styling
via focus handlers or a focused state: add onFocus={() =>
setFocusedGroup(row.groupKey)} and onBlur={() => setFocusedGroup(undefined)} (or
use a focus-visible CSS class) and when focused apply a clear visual indicator
(e.g., a 2px accent outline/boxShadow or change background/border similar to
isRowSelected). Keep existing onKeyDown/toggleGroup behavior and reference the
button rendering in InboxListView (the button using row.groupKey, virtualRow,
isRowSelected and toggleGroup) when making the change.

In `@src/renderer/components/SettingsModal.tsx`:
- Around line 3686-3689: The switch button in SettingsModal.tsx (the <button
role="switch"> that reads aria-checked={encoreFeatures.unifiedInbox} and has the
onClick handler) lacks an accessible name; update that element to provide one
(either via aria-label or aria-labelledby referencing a visible label) so screen
readers can announce it (e.g., aria-label="Toggle unified inbox" or
aria-labelledby pointing to the corresponding label element). Ensure the chosen
label accurately reflects the control state/context and keep the existing role
and aria-checked attributes intact.

In `@src/renderer/stores/modalStore.ts`:
- Around line 774-777: The updateAgentInboxData currently accepts a wide
Record<string, unknown> which breaks the agentInbox modal contract; tighten the
payload by defining and using a concrete modal-specific type/interface (e.g.,
AgentInboxData) and change the signature of updateAgentInboxData to (data:
AgentInboxData) and pass that into updateModalData('agentInbox', data); update
any callers to construct/validate the AgentInboxData shape and import the type
where needed so invalid payloads are rejected at compile time.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9d612b6 and ee926c2.

📒 Files selected for processing (19)
  • src/__tests__/renderer/components/AgentInbox/AgentInbox.test.tsx
  • src/__tests__/renderer/hooks/useAgentInbox.test.ts
  • src/__tests__/renderer/stores/modalStore.test.ts
  • src/renderer/App.tsx
  • src/renderer/components/AgentInbox/FocusModeView.tsx
  • src/renderer/components/AgentInbox/InboxListView.tsx
  • src/renderer/components/AgentInbox/index.tsx
  • src/renderer/components/AppModals.tsx
  • src/renderer/components/QuickActionsModal.tsx
  • src/renderer/components/SettingsModal.tsx
  • src/renderer/constants/modalPriorities.ts
  • src/renderer/constants/shortcuts.ts
  • src/renderer/hooks/keyboard/useMainKeyboardHandler.ts
  • src/renderer/hooks/useAgentInbox.ts
  • src/renderer/stores/modalStore.ts
  • src/renderer/stores/settingsStore.ts
  • src/renderer/types/agent-inbox.ts
  • src/renderer/types/index.ts
  • src/renderer/utils/tabDisplayName.ts

Comment on lines +676 to +677
// ---- Raw markdown toggle (per-session, not per-log) ----
const [showRawMarkdown, setShowRawMarkdown] = useState(false);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope raw-markdown state by session/tab key.

Line 677 keeps a single boolean, so toggling raw mode in one thread carries into other threads when navigating. This should be keyed per sessionId:tabId.

🛠️ Suggested fix
-	const [showRawMarkdown, setShowRawMarkdown] = useState(false);
+	const [showRawByItem, setShowRawByItem] = useState<Record<string, boolean>>({});
+	const currentItemKey = `${item.sessionId}:${item.tabId}`;
+	const showRawMarkdown = showRawByItem[currentItemKey] ?? false;
@@
 									{visibleLogs.map((log) => (
 										<FocusLogEntry
 											key={log.id}
 											log={log}
 											theme={theme}
 											showRawMarkdown={showRawMarkdown}
-											onToggleRaw={() => setShowRawMarkdown((v) => !v)}
+											onToggleRaw={() =>
+												setShowRawByItem((prev) => ({
+													...prev,
+													[currentItemKey]: !prev[currentItemKey],
+												}))
+											}
 										/>
 									))}

Also applies to: 1039-1045

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/FocusModeView.tsx` around lines 676 - 677,
The current single boolean showRawMarkdown in FocusModeView causes raw-markdown
to bleed across sessions; replace it with a keyed state (e.g. a map keyed by
`${sessionId}:${tabId}`) so raw mode is stored per-session/tab. Update the state
declaration (replace showRawMarkdown/setShowRawMarkdown with something like
rawMarkdownByKey and setRawMarkdownByKey), compute a key from the existing
sessionId and tabId values, and change all reads/writes (the toggle handler and
any consumers referenced around the showRawMarkdown usage) to read/write the
boolean at that key; apply the same keyed-state change to the other instance of
this logic referenced (around the 1039–1045 usage) so both places use the
per-session/tab map.

Comment on lines +1016 to +1033
<button
onClick={onClose}
className="p-1.5 rounded"
style={{ color: theme.colors.textDim }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)
}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onFocus={(e) => {
e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
}}
onBlur={(e) => {
e.currentTarget.style.outline = 'none';
}}
title="Close (Esc)"
>
<X className="w-4 h-4" />
</button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add an explicit accessible name to the close icon button.

Line 1016 is icon-only and currently relies on title. Add aria-label for consistent screen-reader announcement.

♿ Suggested fix
 						<button
 							onClick={onClose}
+							aria-label="Close inbox"
 							className="p-1.5 rounded"
 							style={{ color: theme.colors.textDim }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
onClick={onClose}
className="p-1.5 rounded"
style={{ color: theme.colors.textDim }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)
}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onFocus={(e) => {
e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
}}
onBlur={(e) => {
e.currentTarget.style.outline = 'none';
}}
title="Close (Esc)"
>
<X className="w-4 h-4" />
</button>
<button
onClick={onClose}
aria-label="Close inbox"
className="p-1.5 rounded"
style={{ color: theme.colors.textDim }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)
}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
onFocus={(e) => {
e.currentTarget.style.outline = `2px solid ${theme.colors.accent}`;
}}
onBlur={(e) => {
e.currentTarget.style.outline = 'none';
}}
title="Close (Esc)"
>
<X className="w-4 h-4" />
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/AgentInbox/InboxListView.tsx` around lines 1016 -
1033, The close button in the InboxListView component (the button with
onClick={onClose} and the <X /> icon) is icon-only and needs an explicit
accessible name; add an aria-label (e.g., aria-label="Close" or
aria-label="Close conversation") to the button element so screen readers
announce it consistently while keeping the existing title and event handlers
intact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant