Skip to content

feat: unread session state (TUI + web dashboard)#2088

Merged
njbrake merged 12 commits into
agent-of-empires:mainfrom
Eric162:unread-threads
Jun 18, 2026
Merged

feat: unread session state (TUI + web dashboard)#2088
njbrake merged 12 commits into
agent-of-empires:mainfrom
Eric162:unread-threads

Conversation

@Eric162

@Eric162 Eric162 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a non-intrusive unread indicator on sessions, in both the TUI and the web dashboard, built on top of the existing turn-finish detection (the Running → Idle transition that already drives sound/popup notifications). Notifications are loud and ephemeral; this is the quiet, persistent complement: a glanceable cue distinguishing "this turn finished and I've looked" from "finished and I haven't."

Two kinds of unread, distinguished only by what clears them:

  • auto-unread — set when a turn finishes (Running → Idle). Cleared by viewing the session (TUI: Tab/Enter; web: opening it) or the manual toggle. The session you're actively viewing is exempt.
  • manual-unread — a deliberate "flag for later." Cleared only by the toggle, so it survives viewing.

Enabled by default, with a setting to turn it off

On by default, gated behind the session.unread_indicator toggle (Settings → Session → Interaction) through the single-source settings schema, so it shows up in both the TUI and web settings automatically. Turning it off disables the indicator, auto-marking, sort promotion, and the toggle on every surface.

TUI

  • theme.unread row color on resting (Idle/Unknown) rows; auto-mark on turn finish with an active-view exemption; v to flag/unflag (gated binding, hidden from help/palette when off); a right-click context-menu entry; an Attention-sort promoter that floats unread rows just below Waiting; w jump-to-next includes unread.

Web dashboard

  • An unread dot on the sidebar row (theme accent via --color-status-unread), suppressed on the active row; a right-click "Mark as read / unread" context-menu item — both gated on the setting.
  • PATCH /api/sessions/{id}/unread ({unread:bool}); unread exposed on the session API; optimistic overlay like pin/archive/snooze.
  • The server status-poll loop auto-marks Running → Idle (persisted), so a web-only user with no TUI running still accrues the indicator. Clear-on-view is client-driven (opening a session clears an auto marker; a manual flag survives).

📹 TUI demo video below. Behavior is unchanged; it's just default-on now and mirrored in the web UI.

PR Type

  • New Feature

Checklist

  • New and existing tests pass
  • Documentation was updated where necessary
  • For UI changes: included screenshot or recording

Test Coverage Analysis

Web dashboard / structured view

  • Added/updated tests and a coverage-matrix.json entry (sidebar.unread-indicator): RTL coverage in SessionRowTriage.test.tsx (dot render, active-row suppression, menu label flip, PATCH payloads for mark read/unread, feature-off gating), request-shape + resolver tests in api.test.ts and sidebarOptimistic.test.ts.

TUI / CLI (e2e test)

  • Skipped a dedicated e2e: the indicator is color-only (no text/glyph the screen-text harness can assert). Covered by unit tests instead: Instance mutators + serde, attention_rank promoter, the v binding, the context-menu label flip, SessionResponse serialization, and the read-only-guard audit for the new endpoint.

AI Usage

  • AI was used

AI Model/Tool used: Claude Code (Opus 4.8)

  • I am an AI Agent filling out this form (check box if true)

Overview

This PR introduces a persistent “unread” session indicator across the TUI and web UI so users can immediately tell which idle sessions still need review versus sessions they’ve already seen.

What Changed

Unread tracking + controls

  • New setting: session.unread_indicator (enabled by default) in Settings → Session → Interaction, applied immediately (no restart).
  • Each session now tracks unread state in two ways:
    • Auto-unread: set automatically when a session transitions from running to idle; cleared when you view/attach, or when you manually mark it as read.
    • Manual-unread: toggled by the user and stays until explicitly toggled back.
  • Keyboard shortcut: v (and V in strict mode) toggles unread for the selected session (only when enabled).
  • Right-click actions: context menu now includes Mark unread / Mark read (label flips based on current state).

Sorting + navigation behavior

  • Attention sorting promotion: unread items are elevated in the “Attention” view so they surface just below “Waiting.”
  • The w jump navigation now stops on unread sessions as well as waiting/idle sessions.

Visual updates

  • Added a new theme color (theme.unread) and applied it to resting rows to visually distinguish unread sessions.
  • Built-in themes were updated to include the new unread color.

Web UI support

  • The web sidebar/session rows show an unread dot and context-menu triage toggle when enabled.
  • Viewing an active session clears any auto-unread state.

Tests / QA

  • Adds unit test coverage for unread state rules, serialization/merging behavior, sorting promotion ordering, keybinding/context-menu behavior, and menu positioning.
  • Skips TUI e2e testing for this feature since it’s primarily a color/indicator change.

Benefits

  • Faster triage: clearly separates “idle but already reviewed” from “idle and needs attention.”
  • Low friction rollout: default-on behavior with a single easy-to-disable setting.
  • Consistent UX: same unread concept works in both TUI and web, with matching controls and indicators.

Potential follow-ups

  • Consider further UX/feature modularization (e.g., making unread behavior easier to extend or plug in more places).

@Eric162 Eric162 requested a review from njbrake as a code owner June 10, 2026 15:58
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR introduces a complete session unread marking feature across TUI and web clients. Sessions can be marked unread via Auto (on Running→Idle transitions) or Manual (user toggle via v keybinding or right-click menu), sorted above idle sessions in Attention view, rendered with a theme color, and persisted via the user-action system. Configuration gates the feature; auto-marking respects live-send context to avoid false flagging during active message sends. The server auto-marks sessions during status polling, the web client implements optimistic overlay reconciliation with server state, and help/command palette dynamically hide unread controls when disabled.

Changes

Unread Session Marking Feature

Layer / File(s) Summary
Configuration and feature flag infrastructure
src/session/config.rs, src/session/mod.rs
SessionConfig adds unread_indicator: bool field (serde default true) with toggle widget under Interaction category; process-wide cached feature flag via AtomicBool with unread_enabled() and set_unread_enabled() functions; UnreadKind re-exported from instance module.
Session unread state model and mutation API
src/session/instance.rs
UnreadKind enum (Auto, Manual) with semantics documentation; Instance.unread: Option<UnreadKind> persisted field (serde omitted when None); query method is_unread() and mutators mark_unread_auto(), mark_read_auto(), toggle_unread() implementing Auto/Manual preservation rules; merge_user_action_diff extended to propagate unread changes; comprehensive tests validate state transitions, serde behavior, and merge propagation.
Attention rank sorting with unread promotion
src/session/groups.rs
attention_rank(tier, unread, unread_enabled) pure function promotes unread non-Waiting sessions to rank 1 (just below Waiting) when enabled, preserves raw tier when disabled, never affects tier 99; attention_session_key and attention_group_key use computed rank; tests validate ordering across feature modes and status values.
Theme colors and loading logic
src/tui/styles/themes.rs, src/tui/styles/mod.rs, src/tui/styles/resolved.rs, themes/builtin/*, web/src/index.css
Theme.unread: Color field with serde hex deserialization; RawThemeDefaults and conversion impl updated; color_fields_mut and color_fields expand from 25 to 26 elements; theme loading applies post-deserialization rule: omitted unread defaults to theme's accent rather than Empire default; all 8 builtin themes updated with unread color values; web CSS variables extended with --color-status-unread.
TUI input system: keybindings, context menu, and dispatch
src/tui/home/bindings.rs, src/tui/dialogs/context_menu.rs, src/tui/home/input.rs, src/tui/home/operations.rs
ActionId::ToggleUnread with feature-gate docs; Context::UnreadEnabled guard; v/V keybindings under guard with help text and palette metadata; ContextMenuAction::ToggleUnread with dynamic label ("Mark read" / "Mark unread"); ContextMenuDialog::for_session signature extends to unread: Option<bool> parameter; 'v'/'V' key handling in menu; run_action wires toggle to operation; right-click context menu conditionally passes unread state; dispatch wires context menu toggle to operation; jump_to_next_waiting includes unread sessions in actionability predicate; toggle_unread_at_cursor operation toggles selected instance and rebuilds view. Comprehensive test coverage validates keybinding modes, menu row presence/labels, and hotkey behavior.
TUI home view lifecycle: configuration sync and auto-marking
src/tui/home/mod.rs
Home view initializes and updates unread feature enablement from config; applies auto-unread on Running-to-Idle transitions with live-send exclusions; clears auto markers during live-send preparation; lifecycle helper validates Auto marker state before clearing.
TUI home view rendering: unread visual overlay
src/tui/home/render.rs
Structured and terminal row rendering paths add unread overlay behavior that applies unread theme styling for eligible idle and unknown session presentations.
TUI session and app integration
src/tui/app.rs
Post-attach_session clears auto unread state for the attached session.
TUI feature gating in help and command palette
src/tui/components/help.rs, src/tui/dialogs/command_palette.rs
Help overlay omits ToggleUnread row when feature disabled; command palette drops ToggleUnread entry when feature disabled.
TUI test adjustments for menu layout
src/tui/home/tests.rs
Context menu navigation and archived-session label assertions updated to account for new unread row position.
Server session API: response and endpoint
src/server/api/sessions.rs, src/server/api/mod.rs
SessionResponse gains optional unread field derived from Instance.unread; UpdateUnreadBody struct defines request shape; new PATCH /api/sessions/{id}/unread endpoint implements read-only guard, persist-then-mutate pattern, conditional gating on unread feature, manual/read mutations, and response serialization with lowercase enum variants; persist_session_update visibility widened to pub(crate) for reuse; tests validate serialization, omission when None, and mutation behavior; API module re-exports handler and audits it for security requirements.
Server routing and status polling auto-marking
src/server/mod.rs
Server startup applies config's unread_indicator setting to global feature flag; web router adds PATCH /api/sessions/{id}/unread endpoint; status polling loop treats instances as mutable, auto-marks sessions on Running-to-Idle transition when unread enabled and instance unmarked, batches updates per profile, and persists via persist_session_update using existing file-watch mechanism.
Web unread indicator context and settings parsing
web/src/lib/unreadIndicator.ts, web/src/App.tsx
New React utility module defines default-enabled state, exports UnreadIndicatorContext boolean context, and provides parseUnreadIndicatorEnabled to read settings and useUnreadIndicatorEnabled() hook; App wires context provider and settings initialization.
Web API client: unread mutation and session response type
web/src/lib/api.ts, web/src/lib/types.ts, web/src/lib/api.test.ts
SessionResponse gains optional unread field with doc comment describing Auto/Manual semantics and suppression behavior for active session; setSessionUnread(id, unread) PATCH client helper returns updated session or null on failure; comprehensive test coverage validates request payloads and error handling.
Web optimistic triage state and reconciliation
web/src/lib/sidebarOptimistic.ts, web/src/lib/__tests__/sidebarOptimistic.test.ts
OptimisticTriage extends with unread override field using undefined/null/string semantics; EMPTY_OPTIMISTIC and serverTriageOf updated to include unread; new effectiveUnreadOf helper resolves optimistic vs server unread value; reconcileOptimistic tracks and clears reconciled unread overrides; withOverride merges unread patches; comprehensive tests validate override fall-through, server precedence, reconciliation, and entry cleanup.
Web sidebar triage hook: unread toggle handler
web/src/hooks/useSidebarTriage.ts
Hook imports setSessionUnread; adds internal async unread handler that updates optimistic overlay to Manual (mark unread) or null (mark read), calls server API, and rolls back on failure; exports single-row unreadToggle wrapper with toast error surface for mark-unread vs mark-read messaging.
Web sidebar row rendering and context menu
web/src/components/WorkspaceSidebar.tsx, web/src/components/__tests__/SessionRowTriage.test.tsx
Sidebar imports unread indicator enable hook, effective unread helper, CircleDot icon; SessionRow accepts onUnreadToggle prop and computes effective unread combining server state with optimistic triage overlay; renders unread dot next to label when enabled and row not active; context menu includes conditional "Unread" toggle button (label flips "Mark as read"/"Mark as unread") when feature enabled; wires onUnreadToggle across all row render sites (grouped tiers, nested axis tiers, snoozed & archived); test suite validates dot rendering, suppression when active, label flipping, optimistic dot appearance, PATCH correctness, and feature gating.
Web auto-clear unread on active session view
web/src/App.tsx
AppContent implements "clear-on-view" effect: when unread indicators enabled and active session has unread: "auto", calls setSessionUnread(activeSessionId, false) to clear marker after session viewed.
Web coverage matrix update
web/tests/coverage-matrix.json
Coverage matrix adds new sidebar.unread-indicator surface entry tracking unread-indicator UI behavior and linking test specs to WorkspaceSidebar.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

The PR spans multiple subsystems with careful state management (Auto vs Manual unread semantics with non-clobbering rules), sorting integration via pure functions, comprehensive theming with accent fallback logic, keybinding wiring through multiple TUI dispatch layers, context menu updates with dynamic labels, server-side status polling auto-marking with profile-scoped batching, REST API integration with read-only guards, and web client optimistic state reconciliation spanning sidebar triage overlay, prop threading, render logic, and error handling. While individual pieces are straightforward, the feature's spread across config, instance model, sorting, theme, TUI input dispatch, rendering, help system, server polling, API endpoint, and web client state management requires understanding cross-module interactions and subtle behavioral details like auto-marking suppression during active sends and optimistic reconciliation with server state. The test coverage is thorough throughout both TUI and web layers, helping clarify intent.

Possibly related PRs

  • agent-of-empires/agent-of-empires#1788: Both PRs modify the sidebar triage controller and UI plumbing in web/src/hooks/useSidebarTriage.ts and web/src/components/WorkspaceSidebar.tsx, with the main PR extending that hook/components to add unread toggle/overlay behavior on top of the triage + (refactored) action model introduced by the multi-select bulk triage PR.
  • agent-of-empires/agent-of-empires#1791: Both PRs modify Instance::merge_user_action_diff in src/session/instance.rs (main PR conditionally merges unread, retrieved PR conditionally merges worktree fields like project_path/worktree_info).
  • agent-of-empires/agent-of-empires#1959: Both PRs modify web/src/hooks/useSidebarTriage.ts (main PR adds unread toggle/optimistic unread handling; retrieved PR refactors the hook's optimistic reconciliation flow), so the changes overlap at the same hook code.

Suggested reviewers

  • njbrake
  • Seluj78
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title follows Conventional Commits format with 'feat:' prefix, uses imperative mood, and clearly summarizes the main change (unread session state) across both TUI and web dashboard.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description comprehensively covers the feature intent, implementation details, test strategy, and explicitly addresses all checklist items with clear documentation of choices made.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot requested a review from Seluj78 June 10, 2026 16:00
@njbrake

njbrake commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Woah. I love this idea. I find myself in the same situation, I forget which idle sessions i looked at vs didn't look at! Let me think about the best way to fit this into the design.

I'm going back and forth about how we best introduce this as an optional feature or should we even just have it as the default... I think the env var is too conservative, i don't want to make it that tricky for someone to use it.

@Eric162

Eric162 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@njbrake sounds great - let me know what you're thinking on how to intro it, and i can update it.

Maybe Default + an off switch for those who don't like it?

@Eric162 Eric162 changed the title feat(tui): add unread session state (env-gated) feat(tui): add unread session state Jun 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/tui/home/mod.rs (1)

5398-5444: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rebuild the cached list when unread_indicator flips.

This updates the process-wide unread gate, but the cached flat_items ordering is left as-is. Because switch_profile() does reload() before refresh_from_config(), switching between profiles with different session.unread_indicator values can leave Attention sort using the previous unread ranking until some unrelated rebuild happens. Recompute the derived list state when this flag changes.

Suggested fix
 fn apply_config_to_state(
     &mut self,
     config: crate::session::Config,
     origin: ConfigRefreshOrigin,
 ) {
+    let unread_changed = crate::session::unread_enabled() != config.session.unread_indicator;
     self.default_terminal_mode = match config.sandbox.default_terminal_mode {
         DefaultTerminalMode::Host => TerminalMode::Host,
         DefaultTerminalMode::Container => TerminalMode::Container,
     };
@@
     self.idle_decay_window =
         crate::tui::styles::idle_decay_window(config.theme.idle_decay_minutes);
     crate::session::set_unread_enabled(config.session.unread_indicator);
+    if unread_changed {
+        self.flat_items = self.build_flat_items();
+        if self.search_active && !self.search_query.value().is_empty() {
+            self.update_search();
+        } else if !self.search_matches.is_empty() {
+            self.refresh_search_matches();
+        }
+        self.update_selected();
+    }
     self.tool_configs = config.tools;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/home/mod.rs` around lines 5398 - 5444, The code updates the
process-wide unread flag via crate::session::set_unread_enabled but does not
rebuild the cached ordering (flat_items), so flipping session.unread_indicator
can leave Attention sort stale; modify apply_config_to_state to read the prior
unread flag before calling crate::session::set_unread_enabled, call
set_unread_enabled with config.session.unread_indicator, and if the value
changed invoke the existing method that recomputes the derived list/cache (the
function that rebuilds flat_items — e.g. self.rebuild_flat_items() or whatever
method your codebase uses to refresh the cached list ordering) so the UI
reflects the new unread ranking immediately.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/session/groups.rs`:
- Around line 528-540: The grouped sorting key still uses the raw min_tier so
projects with unread lower-priority sessions aren't promoted; update the code
that builds the group key (function attention_group_key) to compute the tier
using the existing attention_rank(tier: u8, unread: bool, unread_enabled: bool)
helper instead of using min_tier directly (e.g., call attention_rank(min_tier,
has_unread, unread_enabled) when assembling the key), and make the same
replacement at the other occurrence referenced (around the second occurrence at
the 588-area) so grouped and flat Attention use the same unread-promoted
ranking.

In `@src/tui/dialogs/context_menu.rs`:
- Around line 22-25: The ContextMenuAction::ToggleUnread enum variant isn't
mapped in handle_key(), so pressing 'v'/'V' in the context menu does nothing;
update the handle_key() match (or key->action mapping) to treat 'v' and 'V' the
same as the existing ToggleUnread menu selection (i.e., return/contextually
trigger ContextMenuAction::ToggleUnread), and add a small regression test that
opens the context menu and asserts that sending 'v' selects/triggers
ToggleUnread (mirror tests for other actions to follow the same pattern).

In `@src/tui/home/bindings.rs`:
- Around line 59-62: The ToggleUnread binding is still present in the static
BINDINGS list so resolve() and UI help still claim the key even when
session.unread_indicator is disabled; remove the binding metadata instead of
relying on toggle_unread_at_cursor() no-op. Update the code that builds BINDINGS
(the constant/vec where ToggleUnread is declared) to conditionally include or
exclude the ToggleUnread entry based on the session config
(session.unread_indicator), and ensure any code paths that populate
help/command-palette entries use that same filtered list; also remove/avoid
matching 'v'/'V' in resolve() when the binding is not present so the key no
longer gets swallowed. Apply the same gating to the other occurrences referenced
around the 609-624 region.

In `@src/tui/home/mod.rs`:
- Around line 4860-4868: The guard in clear_auto_unread prevents clearing an
Auto unread flag when the unread indicator is disabled, allowing Auto to become
stale later; remove the conditional check and always call
self.apply_user_action(id, |i| i.mark_read_auto()) so auto-unread is cleared
unconditionally (mark_read_auto already preserves Manual). Update the function
clear_auto_unread to unconditionally invoke apply_user_action with
mark_read_auto, referencing clear_auto_unread, apply_user_action, and
mark_read_auto to locate the change.

In `@src/tui/styles/themes.rs`:
- Around line 89-96: The unread color should be deserialized as optional and
populated from the theme's accent after parsing: change Theme::unread from Color
to Option<Color> (preserve #[serde(with = "hex_color")] and add
#[serde(default)] if needed), then after deserializing a Theme (e.g. in the
theme-loading path or a Theme::finalize()/fill_defaults() method that runs
post-deserialize), set theme.unread =
theme.unread.or(Some(theme.accent.clone())); apply the same change for the other
mentioned fields (lines ~171-215 and ~233-244) so omitted fields inherit from
the parsed theme's values rather than Theme::default().

In `@themes/builtin/catppuccin-latte.toml`:
- Line 19: The unread color in themes/builtin/catppuccin-latte.toml (key unread)
is too dark compared to the Waiting tone and should be lightened so the intended
priority waiting > unread > idle is preserved; update the unread hex to a
slightly lighter blue (choose a hex lighter than the current `#04a5e5` and
visually less saturated than the Waiting orange) and confirm in
src/tui/home/render.rs that theme.unread will now render below Waiting but above
Idle/Unknown.

---

Outside diff comments:
In `@src/tui/home/mod.rs`:
- Around line 5398-5444: The code updates the process-wide unread flag via
crate::session::set_unread_enabled but does not rebuild the cached ordering
(flat_items), so flipping session.unread_indicator can leave Attention sort
stale; modify apply_config_to_state to read the prior unread flag before calling
crate::session::set_unread_enabled, call set_unread_enabled with
config.session.unread_indicator, and if the value changed invoke the existing
method that recomputes the derived list/cache (the function that rebuilds
flat_items — e.g. self.rebuild_flat_items() or whatever method your codebase
uses to refresh the cached list ordering) so the UI reflects the new unread
ranking immediately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b7a827e-08d3-42cf-9863-2bac81d93271

📥 Commits

Reviewing files that changed from the base of the PR and between 20fb965 and 7dc71d5.

📒 Files selected for processing (21)
  • src/session/config.rs
  • src/session/groups.rs
  • src/session/instance.rs
  • src/session/mod.rs
  • src/tui/app.rs
  • src/tui/dialogs/context_menu.rs
  • src/tui/home/bindings.rs
  • src/tui/home/input.rs
  • src/tui/home/mod.rs
  • src/tui/home/operations.rs
  • src/tui/home/render.rs
  • src/tui/home/tests.rs
  • src/tui/styles/themes.rs
  • themes/builtin/catppuccin-latte.toml
  • themes/builtin/deep-ocean.toml
  • themes/builtin/dracula.toml
  • themes/builtin/empire.toml
  • themes/builtin/phosphor.toml
  • themes/builtin/rose-pine.toml
  • themes/builtin/tokyo-night-storm.toml
  • themes/builtin/zinc.toml

Comment thread src/session/groups.rs
Comment thread src/tui/dialogs/context_menu.rs Outdated
Comment thread src/tui/home/bindings.rs
Comment thread src/tui/home/mod.rs Outdated
Comment thread src/tui/styles/themes.rs
Comment thread themes/builtin/catppuccin-latte.toml Outdated
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 15, 2026
CodeRabbit review on agent-of-empires#2088:

- Promote unread in `attention_group_key` too, so project-grouped Attention
  agrees with the flat view (a group with an unread Idle now outranks a group
  whose best member is a read Error).
- Map `v`/`V` to `ToggleUnread` in the context menu's `handle_key`, with a
  regression test; the quick-pick only fires when the row is present.
- Gate the `v` binding behind the feature: a new `Context::UnreadEnabled`
  drops it from dispatch when off, and the help overlay and command palette
  skip it, so disabling the setting removes it from every surface (not just a
  no-op handler).
- Clear auto-unread unconditionally (only when an `Auto` flag is present) so a
  stale marker can't survive a disable/re-enable; `Manual` is still preserved.
- Fall back an omitted theme `unread` to that theme's own `accent` instead of
  Empire's default, via a load-time `fill_unread_from_accent`.
- Lighten catppuccin-latte's `unread` so it stays below Waiting in that light
  theme (waiting > unread > idle).

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/tui/home/mod.rs (1)

5421-5421: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Rebuild Attention rows when the unread gate changes.

Line 5421 flips the process-wide unread flag, but flat_items can still be ordered with the previous flag. In this file, switch_profile() reloads rows before refresh_from_config(), so changing profiles or toggling session.unread_indicator can leave unread rows promoted after disabling, or not promoted after enabling, until a later rebuild. Please rebuild the list when this flag changes and Attention sort is active.

🐛 Proposed fix
-        crate::session::set_unread_enabled(config.session.unread_indicator);
+        let unread_was_enabled = crate::session::unread_enabled();
+        crate::session::set_unread_enabled(config.session.unread_indicator);
+        let unread_enabled_changed = unread_was_enabled != config.session.unread_indicator;
         self.tool_configs = config.tools;
         self.tool_hotkey_cache = input::build_tool_hotkey_cache(&self.tool_configs);
@@
         if matches!(origin, ConfigRefreshOrigin::Interactive)
             && !hotkey_warnings.is_empty()
             && self.info_dialog.is_none()
         {
             self.info_dialog = Some(InfoDialog::new(
                 "Tool hotkey config errors",
                 &hotkey_warnings.join("\n"),
             ));
         }
+        if unread_enabled_changed && self.sort_order == SortOrder::Attention {
+            let selected_session = self.selected_session.clone();
+            let selected_group = self.selected_group.clone();
+            self.flat_items = self.build_flat_items();
+            if let Some(pos) = selected_session
+                .as_deref()
+                .and_then(|session_id| {
+                    self.flat_items.iter().position(|item| {
+                        matches!(item, Item::Session { id, .. } if id == session_id)
+                    })
+                })
+                .or_else(|| {
+                    selected_group.as_deref().and_then(|group_path| {
+                        self.flat_items.iter().position(|item| {
+                            matches!(item, Item::Group { path, .. } if path == group_path)
+                        })
+                    })
+                })
+            {
+                self.cursor = pos;
+            } else if self.cursor >= self.flat_items.len() {
+                self.cursor = self.flat_items.len().saturating_sub(1);
+            }
+            self.update_selected();
+        }
         // Watcher path: stash for tick-loop dispatch (App owns theme state).

This follows the PR objective that turning the setting off disables Attention-sort promotion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/home/mod.rs` at line 5421, The call to
crate::session::set_unread_enabled() at line 5421 changes the process-wide
unread flag but does not rebuild the flat_items list, causing the Attention sort
ordering to become stale until a later rebuild. After calling
set_unread_enabled(), check if the Attention sort is currently active and if so,
trigger a rebuild of the item rows to ensure they are correctly ordered
according to the new unread flag state. This ensures rows are immediately
promoted or demoted from Attention-sort promotion based on whether the unread
indicator has been enabled or disabled.
🧹 Nitpick comments (3)
src/tui/styles/mod.rs (1)

147-151: ⚖️ Poor tradeoff

Consider avoiding double parse (optional refactor).

The TOML content is parsed twice: once in the caller (toml::from_str::<Theme>) and again here (content.parse::<toml::Table>()). Given that theme files are small and loaded infrequently, the performance impact is negligible, but you could eliminate the redundant parse by having the caller pass in both the Theme and the parsed toml::Table.

That said, the current approach keeps the API simple and the inefficiency is harmless in practice. Feel free to leave as-is unless you're already refactoring this area.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/styles/mod.rs` around lines 147 - 151, The TOML content is being
parsed twice: once in the caller with toml::from_str::<Theme> and again in
fill_unread_from_accent with content.parse::<toml::Table>(). To eliminate this
redundant parse, modify the fill_unread_from_accent function signature to accept
an additional parameter containing the already-parsed toml::Table instead of
re-parsing the content string. Update the caller to parse the content once,
extract the Theme, and pass both the Theme and the parsed toml::Table to
fill_unread_from_accent. Then replace the parse and unwrap_or logic with a
direct check on the passed-in table. Note: this is an optional refactor since
the performance impact is negligible for small theme files loaded infrequently.
src/tui/components/help.rs (1)

53-59: 💤 Low value

Optional micro-optimization and test coverage.

The feature gating logic is correct and the comment clearly explains the "why." Two optional improvements:

  1. Hoist the feature check: Since unread_enabled() returns the same value for all iterations, you could call it once before the loop at line 51 (let unread_enabled = crate::session::unread_enabled();) and reference the local variable inside the loop. This avoids N function calls, though the impact is negligible since help rendering isn't a hot path.

  2. Test coverage: Consider adding a test that verifies the ToggleUnread action doesn't appear in the shortcuts list when unread_enabled() is false. The existing tests in this file check for specific shortcuts being present (e.g., help_lists_snooze, help_lists_command_palette), so a similar test for the absence of the unread toggle when disabled would lock in this gating behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/components/help.rs` around lines 53 - 59, Extract the
`crate::session::unread_enabled()` function call outside the loop (before line
51) into a local variable to avoid redundant calls across iterations, then
reference this variable in the conditional check inside the loop for the
ToggleUnread action. Additionally, add a test case (similar to existing tests
like `help_lists_snooze` and `help_lists_command_palette` in this file) that
verifies the `ToggleUnread` action is properly excluded from the shortcuts list
when `unread_enabled()` returns false, ensuring this gating behavior is locked
in with test coverage.
src/tui/dialogs/command_palette.rs (1)

97-101: 💤 Low value

Optional micro-optimization and test coverage.

The feature gating logic is correct and the comment clearly explains the reasoning. Two optional improvements:

  1. Hoist the feature check: Since unread_enabled() returns the same value for all iterations, you could call it once at the top of builtin_commands (let unread_enabled = crate::session::unread_enabled();) and reference the local variable in the closure. This avoids multiple function calls, though the impact is negligible since palette creation isn't a hot path.

  2. Test coverage: Consider adding a test that verifies the ToggleUnread command doesn't appear in the built-in commands list when unread_enabled() is false. The existing serve_command_only_with_feature test (lines 645-650) demonstrates this pattern for the serve feature, so a similar test for unread gating would be straightforward and would lock in this behavior alongside the other drift guards.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/dialogs/command_palette.rs` around lines 97 - 101, To implement the
optional micro-optimization, move the `crate::session::unread_enabled()` call
outside of the closure in the `builtin_commands` function by capturing it as a
local variable at the start of the function (let unread_enabled =
crate::session::unread_enabled();), then reference this cached variable in the
closure condition where you check if b.id == bindings::ActionId::ToggleUnread.
Additionally, add a test case similar to the existing
`serve_command_only_with_feature` test pattern (referenced at lines 645-650) to
verify that the ToggleUnread command is filtered out from builtin_commands when
unread_enabled() returns false, ensuring this feature gating behavior is locked
in by the test suite.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tui/styles/mod.rs`:
- Around line 142-156: Add dedicated unit tests for the fill_unread_from_accent
function to ensure the unread-to-accent fallback behavior is properly covered.
Create two test cases: first, verify that when a TOML theme string omits the
unread field but defines an accent field, the resulting theme has unread equal
to accent; second, verify that when a TOML theme string explicitly includes an
unread field, that explicit value is preserved and not overridden by the accent
value. These tests will prevent future refactors from silently breaking this
important edge case logic.

---

Outside diff comments:
In `@src/tui/home/mod.rs`:
- Line 5421: The call to crate::session::set_unread_enabled() at line 5421
changes the process-wide unread flag but does not rebuild the flat_items list,
causing the Attention sort ordering to become stale until a later rebuild. After
calling set_unread_enabled(), check if the Attention sort is currently active
and if so, trigger a rebuild of the item rows to ensure they are correctly
ordered according to the new unread flag state. This ensures rows are
immediately promoted or demoted from Attention-sort promotion based on whether
the unread indicator has been enabled or disabled.

---

Nitpick comments:
In `@src/tui/components/help.rs`:
- Around line 53-59: Extract the `crate::session::unread_enabled()` function
call outside the loop (before line 51) into a local variable to avoid redundant
calls across iterations, then reference this variable in the conditional check
inside the loop for the ToggleUnread action. Additionally, add a test case
(similar to existing tests like `help_lists_snooze` and
`help_lists_command_palette` in this file) that verifies the `ToggleUnread`
action is properly excluded from the shortcuts list when `unread_enabled()`
returns false, ensuring this gating behavior is locked in with test coverage.

In `@src/tui/dialogs/command_palette.rs`:
- Around line 97-101: To implement the optional micro-optimization, move the
`crate::session::unread_enabled()` call outside of the closure in the
`builtin_commands` function by capturing it as a local variable at the start of
the function (let unread_enabled = crate::session::unread_enabled();), then
reference this cached variable in the closure condition where you check if b.id
== bindings::ActionId::ToggleUnread. Additionally, add a test case similar to
the existing `serve_command_only_with_feature` test pattern (referenced at lines
645-650) to verify that the ToggleUnread command is filtered out from
builtin_commands when unread_enabled() returns false, ensuring this feature
gating behavior is locked in by the test suite.

In `@src/tui/styles/mod.rs`:
- Around line 147-151: The TOML content is being parsed twice: once in the
caller with toml::from_str::<Theme> and again in fill_unread_from_accent with
content.parse::<toml::Table>(). To eliminate this redundant parse, modify the
fill_unread_from_accent function signature to accept an additional parameter
containing the already-parsed toml::Table instead of re-parsing the content
string. Update the caller to parse the content once, extract the Theme, and pass
both the Theme and the parsed toml::Table to fill_unread_from_accent. Then
replace the parse and unwrap_or logic with a direct check on the passed-in
table. Note: this is an optional refactor since the performance impact is
negligible for small theme files loaded infrequently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f59c7bc6-1da4-45e5-bdd3-fa607f909bbb

📥 Commits

Reviewing files that changed from the base of the PR and between 7dc71d5 and 54bc607.

📒 Files selected for processing (9)
  • src/session/groups.rs
  • src/tui/components/help.rs
  • src/tui/dialogs/command_palette.rs
  • src/tui/dialogs/context_menu.rs
  • src/tui/home/bindings.rs
  • src/tui/home/mod.rs
  • src/tui/styles/mod.rs
  • src/tui/styles/themes.rs
  • themes/builtin/catppuccin-latte.toml
🚧 Files skipped from review as they are similar to previous changes (5)
  • themes/builtin/catppuccin-latte.toml
  • src/tui/home/bindings.rs
  • src/tui/styles/themes.rs
  • src/tui/dialogs/context_menu.rs
  • src/session/groups.rs

Comment thread src/tui/styles/mod.rs
@Seluj78

Seluj78 commented Jun 15, 2026

Copy link
Copy Markdown
Member

I love the idea and I think it would be amazing to get it in the web ui as well, with a right click mark as read/unread context menu options. I think we can add it as a normal feature, and move it to a plugin later @njbrake

Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 15, 2026
CodeRabbit follow-up on agent-of-empires#2088: add unit tests for `fill_unread_from_accent`
so the fallback can't break silently. Covers a custom theme that omits
`unread` (inherits its own accent) and one that sets it explicitly (kept,
not overwritten).

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 16, 2026
CodeRabbit review on agent-of-empires#2088:

- Promote unread in `attention_group_key` too, so project-grouped Attention
  agrees with the flat view (a group with an unread Idle now outranks a group
  whose best member is a read Error).
- Map `v`/`V` to `ToggleUnread` in the context menu's `handle_key`, with a
  regression test; the quick-pick only fires when the row is present.
- Gate the `v` binding behind the feature: a new `Context::UnreadEnabled`
  drops it from dispatch when off, and the help overlay and command palette
  skip it, so disabling the setting removes it from every surface (not just a
  no-op handler).
- Clear auto-unread unconditionally (only when an `Auto` flag is present) so a
  stale marker can't survive a disable/re-enable; `Manual` is still preserved.
- Fall back an omitted theme `unread` to that theme's own `accent` instead of
  Empire's default, via a load-time `fill_unread_from_accent`.
- Lighten catppuccin-latte's `unread` so it stays below Waiting in that light
  theme (waiting > unread > idle).

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 16, 2026
CodeRabbit follow-up on agent-of-empires#2088: add unit tests for `fill_unread_from_accent`
so the fallback can't break silently. Covers a custom theme that omits
`unread` (inherits its own accent) and one that sets it explicitly (kept,
not overwritten).

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tui/home/operations.rs`:
- Around line 1268-1269: After toggling the unread status with
`inst.toggle_unread()` and rebuilding `flat_items` with
`self.build_flat_items()`, the cursor position may shift to a different row when
items are reordered in Attention sort mode. Reseat the cursor by the item's `id`
after rebuilding flat_items to ensure the selection remains anchored to the same
session despite any rank reordering, preventing subsequent actions from
operating on the wrong item.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 25f48679-ebbf-4d4a-ba03-a4792db4ef28

📥 Commits

Reviewing files that changed from the base of the PR and between 6fa148f and c2888e8.

📒 Files selected for processing (24)
  • src/session/config.rs
  • src/session/groups.rs
  • src/session/instance.rs
  • src/session/mod.rs
  • src/tui/app.rs
  • src/tui/components/help.rs
  • src/tui/dialogs/command_palette.rs
  • src/tui/dialogs/context_menu.rs
  • src/tui/home/bindings.rs
  • src/tui/home/input.rs
  • src/tui/home/mod.rs
  • src/tui/home/operations.rs
  • src/tui/home/render.rs
  • src/tui/home/tests.rs
  • src/tui/styles/mod.rs
  • src/tui/styles/themes.rs
  • themes/builtin/catppuccin-latte.toml
  • themes/builtin/deep-ocean.toml
  • themes/builtin/dracula.toml
  • themes/builtin/empire.toml
  • themes/builtin/phosphor.toml
  • themes/builtin/rose-pine.toml
  • themes/builtin/tokyo-night-storm.toml
  • themes/builtin/zinc.toml
✅ Files skipped from review due to trivial changes (8)
  • themes/builtin/zinc.toml
  • themes/builtin/empire.toml
  • themes/builtin/phosphor.toml
  • themes/builtin/rose-pine.toml
  • themes/builtin/tokyo-night-storm.toml
  • src/tui/home/tests.rs
  • themes/builtin/dracula.toml
  • themes/builtin/deep-ocean.toml
🚧 Files skipped from review as they are similar to previous changes (15)
  • themes/builtin/catppuccin-latte.toml
  • src/tui/app.rs
  • src/tui/dialogs/command_palette.rs
  • src/tui/components/help.rs
  • src/session/config.rs
  • src/session/mod.rs
  • src/tui/home/input.rs
  • src/session/instance.rs
  • src/tui/home/bindings.rs
  • src/tui/home/render.rs
  • src/tui/styles/mod.rs
  • src/tui/home/mod.rs
  • src/tui/styles/themes.rs
  • src/tui/dialogs/context_menu.rs
  • src/session/groups.rs

Comment thread src/tui/home/operations.rs
@Seluj78

Seluj78 commented Jun 17, 2026

Copy link
Copy Markdown
Member

Hi there @Eric162, are you still interested in working on this PR ? :)

Nevermind you pushed 13 hours ago I thought it was a few days

@Eric162

Eric162 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

@Seluj78 yes - let me just add the web ui stuff and get it ready for push. i'll tag you then.

@njbrake njbrake left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note: this review was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.

Review

Summary

Adds a quiet, persistent unread indicator for sessions in the TUI, complementing the existing loud, ephemeral turn-finish notifications. Two flavors: auto-unread (set on Running -> Idle, cleared by viewing/attaching) and manual-unread (v toggle, survives viewing). Surfaced as a theme.unread row color, a right-click menu entry, an Attention-sort promoter just below Waiting, and a stop in the w jump cycle. Gated behind a new default-on session.unread_indicator setting wired through the single-source schema.

I built the branch, ran cargo clippy, and ran the new unread tests locally; all clean.

Code review

  • session/instance.rs (UnreadKind, mutators, merge): clean. mark_unread_auto never clobbers Manual, mark_read_auto clears only Auto, toggle_unread round-trips. Serde omits None. Well unit-tested.
  • session/groups.rs (attention_rank): nicely done. Pure, testable, monotonic shift so a feature-on run with no unread rows orders identically to before. Folded into both attention_session_key and attention_group_key.
  • session/mod.rs (atomic flag): process-wide AtomicBool keeps the hot sort path off a threaded flag. Reasonable; the cost is the apply-path sync bug noted below.
  • styles/ (theme.unread, fill_unread_from_accent): accent fallback for themes that omit unread is correct and tested. The double parse is harmless for small theme files.
  • input / bindings / context_menu / help / palette: thorough feature gating via Context::UnreadEnabled, with help and palette filtered separately and the menu row hidden when off. v / V has no conflicts.
  • home/mod.rs (auto-marking, clear-on-view): live-send exemption and attach-return clear are correct; clear_auto_unread runs regardless of the flag so a stale Auto cannot survive a disable then re-enable. Skips disk writes when already-unread/read to avoid flock churn.

Compliance with project guidelines

Good. The setting is added the single-source way (one #[setting(...)] annotation, surfaces in TUI and web automatically), no dead code, conventional commits, and doc comments explain the why. The e2e skip is justified (state is color-only; the harness asserts on screen text) and is compensated with solid unit coverage.

Automated review check-in (CodeRabbit)

Most findings were addressed in follow-up commits (group-key promotion, the v quick-pick, binding gating, unconditional clear_auto_unread, the accent fallback, the latte color, fallback tests). Two remained open on the latest run, and I agree with both:

  1. Stale Attention sort on config flip (apply_config_to_state in home/mod.rs). Real bug. switch_profile calls reload(), which rebuilds flat_items under the old flag, before refresh_from_config() flips it, and the interactive settings-close path never rebuilds afterward. So toggling the setting or switching to a profile with a different value leaves the Attention order stale until the next status tick. The row color self-corrects at render; only the ordering lags. Suggested fix: when the gate actually changes and Attention sort is active, rebuild flat_items and reseat the cursor by id.

  2. Cursor reseat after toggle_unread_at_cursor (operations.rs). True but low priority. The cursor can shift after the reorder, but this matches the existing toggle_favorite_at_cursor and unsnooze paths (neither reseats; only toggle_archive does). I would either leave it for consistency or fix all three together in a separate change rather than here.

The bigger question: readiness

From the thread, this is not only a code question. The shape (default-on with an opt-out) matches the direction discussed here. Seluj78 asked for web UI parity (right-click mark read/unread in the dashboard), and Eric162 replied that he would add the web UI piece and tag reviewers when ready. That web piece is not in the branch yet (the PR's own coverage analysis marks web as N/A). So by the author's own note this is still in progress.

Verdict

  • Implementation (TUI scope): solid, well-tested, idiomatic, with one minor staleness bug to fix.
  • Project fit: accept. Both maintainers want it, and the default-on plus opt-out shape matches the stated preference.

The two axes agree on direction, but I would hold the merge until the web dashboard support lands (or there is an explicit decision to split TUI now and web later), and fold in the staleness fix either way.

Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 17, 2026
CodeRabbit review on agent-of-empires#2088:

- Promote unread in `attention_group_key` too, so project-grouped Attention
  agrees with the flat view (a group with an unread Idle now outranks a group
  whose best member is a read Error).
- Map `v`/`V` to `ToggleUnread` in the context menu's `handle_key`, with a
  regression test; the quick-pick only fires when the row is present.
- Gate the `v` binding behind the feature: a new `Context::UnreadEnabled`
  drops it from dispatch when off, and the help overlay and command palette
  skip it, so disabling the setting removes it from every surface (not just a
  no-op handler).
- Clear auto-unread unconditionally (only when an `Auto` flag is present) so a
  stale marker can't survive a disable/re-enable; `Manual` is still preserved.
- Fall back an omitted theme `unread` to that theme's own `accent` instead of
  Empire's default, via a load-time `fill_unread_from_accent`.
- Lighten catppuccin-latte's `unread` so it stays below Waiting in that light
  theme (waiting > unread > idle).

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 17, 2026
CodeRabbit follow-up on agent-of-empires#2088: add unit tests for `fill_unread_from_accent`
so the fallback can't break silently. Covers a custom theme that omits
`unread` (inherits its own accent) and one that sets it explicitly (kept,
not overwritten).

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 17, 2026
CodeRabbit follow-up on agent-of-empires#2088: toggling unread changes a row's rank in
the Attention sort, so rebuilding flat_items could move the cursor onto a
different session and the next action would target the wrong one. Reseat
by id after the rebuild, matching the snooze/archive paths.

🤖 Generated with Claude Code
@Eric162 Eric162 changed the title feat(tui): add unread session state feat: unread session state (TUI + web dashboard) Jun 17, 2026
@coderabbitai coderabbitai Bot requested a review from njbrake June 17, 2026 12:55
@njbrake

njbrake commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Thanks! Yes I think I am comfortable merging this feature as default on and with a setting to let people disable the feature.

I'm testing it out locally and I think the UI/UX needs some fixes. The highlighting is either too subtle or I'm doing something wrong? marking it read/unread doesn't seem to change anything when i'm not highlighting the session.

Also, I know you probably picked 'v' as the hotkey because 'u' is already a hotkey for the update option, but I think the hotkey really should be 'u', and we just have some sort of handler to handle the case where that update footer is available so that when the footer is there 'u' routes to update but otherwise 'u' routes to read/unread?

WDYT?

Once you get the changes up and ready for review again, another short screen recording of how it looks in TUI as well as web would be great! 🙏

@Seluj78

Seluj78 commented Jun 17, 2026

Copy link
Copy Markdown
Member

On my end, I am waiting for you @Eric162 to ping me when the web part is ready to review and test ;)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (1)
web/src/components/__tests__/SessionRowTriage.test.tsx (1)

534-622: ⚡ Quick win

Add a multi-session unread regression test.

Current unread tests are single-session only. Please add a case where sessions[1] is unread and sessions[0] is read, then verify “Mark as read” clears the aggregated unread state. This protects the workspace-level aggregation path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/__tests__/SessionRowTriage.test.tsx` around lines 534 -
622, Add a new test case within the "SessionRow unread" describe block that
creates a workspace with multiple sessions where sessions[0] is read and
sessions[1] is unread, render the Row for the unread session, trigger the "Mark
as read" action via the context menu, and verify that the PATCH request is made
correctly and that the workspace-level unread aggregation state is properly
cleared after marking the session as read.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/api/sessions.rs`:
- Around line 2503-2508: The doc comment for the PATCH /api/sessions/{id}/unread
endpoint uses an em dash (`—`) separator, which violates the repository's Rust
comment style guidelines. Replace the em dash with a comma or semicolon to
maintain consistency with the codebase style rules. The em dash appears in the
line starting with the backtick-enclosed endpoint path and precedes the text
"flag a session unread".

In `@src/server/mod.rs`:
- Around line 2897-2934: The auto-unread marking logic in this block only
applies to the tmux-polled instances path and misses ACP-routed status changes
that transition through apply_status_intent, causing web-only ACP users to never
accrue the unread indicator when turns finish. Locate where apply_status_intent
is called in the acp_event_listener and add equivalent auto-unread handling
after that call: capture the status before calling apply_status_intent, then
check if the transition is Running to Idle with unread not set, and if so call
mark_unread_auto on the instance and persist the update using the same
persist_session_update pattern as this block, collecting the profile and
instance id for the persistence callback.
- Line 4690: The comment near line 4690 uses `---` as separators which violates
the repo's coding guidelines against using hyphens or emdashes as separators in
comments. Remove the `---` hyphens from both the beginning and end of the
comment while preserving the issue reference `#2248` and the description text
about the structured session healing behavior.
- Around line 2919-2932: The code currently drops storage errors from the
api::persist_session_update call by using let _ = which makes failures
undiagnosable. Instead of discarding the result, capture the Result returned by
the await on api::persist_session_update and log a warning message if an error
occurs. This will make it visible when the auto-unread persistence fails,
allowing the issue to be diagnosed and potentially retried on the next Running
-> Idle transition.
- Around line 652-655: The unread feature gate is only set once at daemon
startup in the initialization block but is never refreshed when users change the
setting through the dashboard. To fix this, locate the `update_settings` and
`update_profile_settings` handler functions in `src/server/api/system.rs`, and
after each one persists the updated configuration, add a call to
`crate::session::set_unread_enabled(...)` passing the new
`config.session.unread_indicator` value. This ensures the
`update_session_unread` function will use the current setting instead of the
stale startup value, allowing manual unread toggles to work correctly
immediately after the user changes the setting.

In `@web/src/App.tsx`:
- Around line 116-117: The unreadIndicatorEnabled state is initialized once and
never updates when session.unread_indicator changes at runtime, causing the
global context provider to serve stale values. Replace the static useState
initialization and one-time effect (the code around line 145-149 where
unreadIndicatorEnabled is set) with a mechanism that derives
unreadIndicatorEnabled from the live settings source and updates whenever
settings are saved or refetched. Ensure the effect that updates this state has
appropriate dependencies on the settings object so it re-syncs whenever the
session.unread_indicator property changes, and verify the context provider at
lines 188-191 exposes the current dynamic value rather than a frozen one.

In `@web/src/hooks/useSidebarTriage.ts`:
- Around line 95-107: The unread function in useSidebarTriage always operates on
the first session (ws.sessions[0]) via the sessionId variable, but the
workspace's unread state aggregates across all sessions. When marking as read
(markUnread is false), if a different session is unread, the setSessionUnread
call may no-op server-side while the optimistic update still sets unread to
null, causing desync. Instead of always targeting ws.sessions[0], determine
which session(s) should be marked read based on the marking operation - consider
whether you need to mark all sessions as read when marking the workspace as
read, or identify and target the specific unread session(s) to ensure the
server-side operation succeeds.

In `@web/src/lib/api.ts`:
- Around line 1397-1403: In the setSessionUnread function, the session id
parameter is being interpolated directly into the URL string without proper
encoding. This causes routing issues if the id contains reserved characters like
forward slashes. Wrap the id parameter with encodeURIComponent() when
constructing the fetch URL path to ensure special characters are properly
encoded before being sent to the API endpoint.

---

Nitpick comments:
In `@web/src/components/__tests__/SessionRowTriage.test.tsx`:
- Around line 534-622: Add a new test case within the "SessionRow unread"
describe block that creates a workspace with multiple sessions where sessions[0]
is read and sessions[1] is unread, render the Row for the unread session,
trigger the "Mark as read" action via the context menu, and verify that the
PATCH request is made correctly and that the workspace-level unread aggregation
state is properly cleared after marking the session as read.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c5bd5e5-c789-40aa-bab4-928749044ec6

📥 Commits

Reviewing files that changed from the base of the PR and between c2888e8 and f461011.

📒 Files selected for processing (40)
  • src/server/api/mod.rs
  • src/server/api/sessions.rs
  • src/server/mod.rs
  • src/session/config.rs
  • src/session/groups.rs
  • src/session/instance.rs
  • src/session/mod.rs
  • src/tui/app.rs
  • src/tui/components/help.rs
  • src/tui/dialogs/command_palette.rs
  • src/tui/dialogs/context_menu.rs
  • src/tui/home/bindings.rs
  • src/tui/home/input.rs
  • src/tui/home/mod.rs
  • src/tui/home/operations.rs
  • src/tui/home/render.rs
  • src/tui/home/tests.rs
  • src/tui/styles/mod.rs
  • src/tui/styles/resolved.rs
  • src/tui/styles/themes.rs
  • themes/builtin/catppuccin-latte.toml
  • themes/builtin/deep-ocean.toml
  • themes/builtin/dracula.toml
  • themes/builtin/empire.toml
  • themes/builtin/phosphor.toml
  • themes/builtin/rose-pine.toml
  • themes/builtin/tokyo-night-storm.toml
  • themes/builtin/zinc.toml
  • web/src/App.tsx
  • web/src/components/WorkspaceSidebar.tsx
  • web/src/components/__tests__/SessionRowTriage.test.tsx
  • web/src/hooks/useSidebarTriage.ts
  • web/src/index.css
  • web/src/lib/__tests__/sidebarOptimistic.test.ts
  • web/src/lib/api.test.ts
  • web/src/lib/api.ts
  • web/src/lib/sidebarOptimistic.ts
  • web/src/lib/types.ts
  • web/src/lib/unreadIndicator.ts
  • web/tests/coverage-matrix.json
✅ Files skipped from review due to trivial changes (8)
  • web/src/index.css
  • web/tests/coverage-matrix.json
  • themes/builtin/empire.toml
  • themes/builtin/zinc.toml
  • themes/builtin/catppuccin-latte.toml
  • themes/builtin/deep-ocean.toml
  • themes/builtin/dracula.toml
  • themes/builtin/phosphor.toml
🚧 Files skipped from review as they are similar to previous changes (17)
  • themes/builtin/rose-pine.toml
  • themes/builtin/tokyo-night-storm.toml
  • src/tui/dialogs/command_palette.rs
  • src/session/config.rs
  • src/tui/home/tests.rs
  • src/tui/home/render.rs
  • src/tui/styles/themes.rs
  • src/tui/components/help.rs
  • src/tui/app.rs
  • src/tui/styles/mod.rs
  • src/tui/home/operations.rs
  • src/session/groups.rs
  • src/tui/home/bindings.rs
  • src/session/instance.rs
  • src/tui/home/mod.rs
  • src/tui/dialogs/context_menu.rs
  • src/session/mod.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 8

🧹 Nitpick comments (1)
web/src/components/__tests__/SessionRowTriage.test.tsx (1)

534-622: ⚡ Quick win

Add a multi-session unread regression test.

Current unread tests are single-session only. Please add a case where sessions[1] is unread and sessions[0] is read, then verify “Mark as read” clears the aggregated unread state. This protects the workspace-level aggregation path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/__tests__/SessionRowTriage.test.tsx` around lines 534 -
622, Add a new test case within the "SessionRow unread" describe block that
creates a workspace with multiple sessions where sessions[0] is read and
sessions[1] is unread, render the Row for the unread session, trigger the "Mark
as read" action via the context menu, and verify that the PATCH request is made
correctly and that the workspace-level unread aggregation state is properly
cleared after marking the session as read.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/api/sessions.rs`:
- Around line 2503-2508: The doc comment for the PATCH /api/sessions/{id}/unread
endpoint uses an em dash (`—`) separator, which violates the repository's Rust
comment style guidelines. Replace the em dash with a comma or semicolon to
maintain consistency with the codebase style rules. The em dash appears in the
line starting with the backtick-enclosed endpoint path and precedes the text
"flag a session unread".

In `@src/server/mod.rs`:
- Around line 2897-2934: The auto-unread marking logic in this block only
applies to the tmux-polled instances path and misses ACP-routed status changes
that transition through apply_status_intent, causing web-only ACP users to never
accrue the unread indicator when turns finish. Locate where apply_status_intent
is called in the acp_event_listener and add equivalent auto-unread handling
after that call: capture the status before calling apply_status_intent, then
check if the transition is Running to Idle with unread not set, and if so call
mark_unread_auto on the instance and persist the update using the same
persist_session_update pattern as this block, collecting the profile and
instance id for the persistence callback.
- Line 4690: The comment near line 4690 uses `---` as separators which violates
the repo's coding guidelines against using hyphens or emdashes as separators in
comments. Remove the `---` hyphens from both the beginning and end of the
comment while preserving the issue reference `#2248` and the description text
about the structured session healing behavior.
- Around line 2919-2932: The code currently drops storage errors from the
api::persist_session_update call by using let _ = which makes failures
undiagnosable. Instead of discarding the result, capture the Result returned by
the await on api::persist_session_update and log a warning message if an error
occurs. This will make it visible when the auto-unread persistence fails,
allowing the issue to be diagnosed and potentially retried on the next Running
-> Idle transition.
- Around line 652-655: The unread feature gate is only set once at daemon
startup in the initialization block but is never refreshed when users change the
setting through the dashboard. To fix this, locate the `update_settings` and
`update_profile_settings` handler functions in `src/server/api/system.rs`, and
after each one persists the updated configuration, add a call to
`crate::session::set_unread_enabled(...)` passing the new
`config.session.unread_indicator` value. This ensures the
`update_session_unread` function will use the current setting instead of the
stale startup value, allowing manual unread toggles to work correctly
immediately after the user changes the setting.

In `@web/src/App.tsx`:
- Around line 116-117: The unreadIndicatorEnabled state is initialized once and
never updates when session.unread_indicator changes at runtime, causing the
global context provider to serve stale values. Replace the static useState
initialization and one-time effect (the code around line 145-149 where
unreadIndicatorEnabled is set) with a mechanism that derives
unreadIndicatorEnabled from the live settings source and updates whenever
settings are saved or refetched. Ensure the effect that updates this state has
appropriate dependencies on the settings object so it re-syncs whenever the
session.unread_indicator property changes, and verify the context provider at
lines 188-191 exposes the current dynamic value rather than a frozen one.

In `@web/src/hooks/useSidebarTriage.ts`:
- Around line 95-107: The unread function in useSidebarTriage always operates on
the first session (ws.sessions[0]) via the sessionId variable, but the
workspace's unread state aggregates across all sessions. When marking as read
(markUnread is false), if a different session is unread, the setSessionUnread
call may no-op server-side while the optimistic update still sets unread to
null, causing desync. Instead of always targeting ws.sessions[0], determine
which session(s) should be marked read based on the marking operation - consider
whether you need to mark all sessions as read when marking the workspace as
read, or identify and target the specific unread session(s) to ensure the
server-side operation succeeds.

In `@web/src/lib/api.ts`:
- Around line 1397-1403: In the setSessionUnread function, the session id
parameter is being interpolated directly into the URL string without proper
encoding. This causes routing issues if the id contains reserved characters like
forward slashes. Wrap the id parameter with encodeURIComponent() when
constructing the fetch URL path to ensure special characters are properly
encoded before being sent to the API endpoint.

---

Nitpick comments:
In `@web/src/components/__tests__/SessionRowTriage.test.tsx`:
- Around line 534-622: Add a new test case within the "SessionRow unread"
describe block that creates a workspace with multiple sessions where sessions[0]
is read and sessions[1] is unread, render the Row for the unread session,
trigger the "Mark as read" action via the context menu, and verify that the
PATCH request is made correctly and that the workspace-level unread aggregation
state is properly cleared after marking the session as read.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c5bd5e5-c789-40aa-bab4-928749044ec6

📥 Commits

Reviewing files that changed from the base of the PR and between c2888e8 and f461011.

📒 Files selected for processing (40)
  • src/server/api/mod.rs
  • src/server/api/sessions.rs
  • src/server/mod.rs
  • src/session/config.rs
  • src/session/groups.rs
  • src/session/instance.rs
  • src/session/mod.rs
  • src/tui/app.rs
  • src/tui/components/help.rs
  • src/tui/dialogs/command_palette.rs
  • src/tui/dialogs/context_menu.rs
  • src/tui/home/bindings.rs
  • src/tui/home/input.rs
  • src/tui/home/mod.rs
  • src/tui/home/operations.rs
  • src/tui/home/render.rs
  • src/tui/home/tests.rs
  • src/tui/styles/mod.rs
  • src/tui/styles/resolved.rs
  • src/tui/styles/themes.rs
  • themes/builtin/catppuccin-latte.toml
  • themes/builtin/deep-ocean.toml
  • themes/builtin/dracula.toml
  • themes/builtin/empire.toml
  • themes/builtin/phosphor.toml
  • themes/builtin/rose-pine.toml
  • themes/builtin/tokyo-night-storm.toml
  • themes/builtin/zinc.toml
  • web/src/App.tsx
  • web/src/components/WorkspaceSidebar.tsx
  • web/src/components/__tests__/SessionRowTriage.test.tsx
  • web/src/hooks/useSidebarTriage.ts
  • web/src/index.css
  • web/src/lib/__tests__/sidebarOptimistic.test.ts
  • web/src/lib/api.test.ts
  • web/src/lib/api.ts
  • web/src/lib/sidebarOptimistic.ts
  • web/src/lib/types.ts
  • web/src/lib/unreadIndicator.ts
  • web/tests/coverage-matrix.json
✅ Files skipped from review due to trivial changes (8)
  • web/src/index.css
  • web/tests/coverage-matrix.json
  • themes/builtin/empire.toml
  • themes/builtin/zinc.toml
  • themes/builtin/catppuccin-latte.toml
  • themes/builtin/deep-ocean.toml
  • themes/builtin/dracula.toml
  • themes/builtin/phosphor.toml
🚧 Files skipped from review as they are similar to previous changes (17)
  • themes/builtin/rose-pine.toml
  • themes/builtin/tokyo-night-storm.toml
  • src/tui/dialogs/command_palette.rs
  • src/session/config.rs
  • src/tui/home/tests.rs
  • src/tui/home/render.rs
  • src/tui/styles/themes.rs
  • src/tui/components/help.rs
  • src/tui/app.rs
  • src/tui/styles/mod.rs
  • src/tui/home/operations.rs
  • src/session/groups.rs
  • src/tui/home/bindings.rs
  • src/session/instance.rs
  • src/tui/home/mod.rs
  • src/tui/dialogs/context_menu.rs
  • src/session/mod.rs
🛑 Comments failed to post (8)
src/server/api/sessions.rs (1)

2503-2508: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Please replace the em dash in the endpoint doc comment.

Line 2503 uses an em dash separator (), which violates the repo’s Rust comment style rule. Please rephrase with a comma or semicolon.

Suggested edit
-/// `PATCH /api/sessions/{id}/unread` — flag a session unread (`{"unread":true}`)
+/// `PATCH /api/sessions/{id}/unread`, flag a session unread (`{"unread":true}`)
📝 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.

/// `PATCH /api/sessions/{id}/unread`, flag a session unread (`{"unread":true}`)
/// or mark it read (`{"unread":false}`). Mirrors the TUI's `v` toggle, but the
/// client computes the target from the current state rather than toggling
/// server-side, so an optimistic UI update can't desync. No-op when the
/// `session.unread_indicator` feature is off (the client hides the control
/// then, but guard here too). Persist-then-mutate, like snooze.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/api/sessions.rs` around lines 2503 - 2508, The doc comment for the
PATCH /api/sessions/{id}/unread endpoint uses an em dash (`—`) separator, which
violates the repository's Rust comment style guidelines. Replace the em dash
with a comma or semicolon to maintain consistency with the codebase style rules.
The em dash appears in the line starting with the backtick-enclosed endpoint
path and precedes the text "flag a session unread".

Source: Coding guidelines

src/server/mod.rs (4)

652-655: ⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the unread feature gate is refreshed after settings changes, not only during serve startup.
# Expectation: at least one settings-update path calls set_unread_enabled(...) after accepting session.unread_indicator.

rg -n -C4 '\bset_unread_enabled\s*\(' --glob '*.rs'
rg -n -C6 '\bunread_indicator\b|\bupdate_settings\b|\bvalidate_patch\b' --glob '*.rs'

Repository: agent-of-empires/agent-of-empires

Length of output: 44839


Refresh the unread feature gate when the setting changes, not only at startup.

The server currently loads session.unread_indicator once during daemon startup (line 655) but does not refresh it when a user changes the setting via the dashboard. This means toggling the unread indicator in settings won't take effect in the API until the daemon restarts.

The unread gate is checked in update_session_unread (line 2547 in src/server/api/sessions.rs), so if it's stale, the PATCH endpoint will silently ignore the user's manual unread toggles.

Fix: Call crate::session::set_unread_enabled(...) in both update_settings and update_profile_settings handlers (in src/server/api/system.rs) after the config is persisted, the same way the TUI does on config refresh (lines 1245 and 5561 in src/tui/home/mod.rs).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/mod.rs` around lines 652 - 655, The unread feature gate is only
set once at daemon startup in the initialization block but is never refreshed
when users change the setting through the dashboard. To fix this, locate the
`update_settings` and `update_profile_settings` handler functions in
`src/server/api/system.rs`, and after each one persists the updated
configuration, add a call to `crate::session::set_unread_enabled(...)` passing
the new `config.session.unread_indicator` value. This ensures the
`update_session_unread` function will use the current setting instead of the
stale startup value, allowing manual unread toggles to work correctly
immediately after the user changes the setting.

2897-2934: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Share auto-unread marking with the ACP status path.

This block only sees the tmux-polled rows before reload_state_instances_from_disk() reapplies the ACP overlay. Structured sessions transition through acp_event_listenerapply_status_intent, where Event::Stopped becomes Status::Idle; after that, the next poll sees prev == Idle, so a web-only ACP turn can finish without ever being auto-marked unread. Please mark and persist unread on the shared Running -> Idle transition path, or add the equivalent handling in the ACP listener after apply_status_intent.

A compact shape for the ACP-side guard would be:

let before = inst.status;
apply_status_intent(inst, status_intent, &state.status_tx);

let auto_unread_profile = if crate::session::unread_enabled()
    && before == Status::Running
    && inst.status == Status::Idle
    && inst.unread.is_none()
{
    inst.mark_unread_auto();
    Some(inst.source_profile.clone())
} else {
    None
};

Based on PR objectives, auto-unread should be set when a turn finishes so web-only users accrue the indicator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/mod.rs` around lines 2897 - 2934, The auto-unread marking logic in
this block only applies to the tmux-polled instances path and misses ACP-routed
status changes that transition through apply_status_intent, causing web-only ACP
users to never accrue the unread indicator when turns finish. Locate where
apply_status_intent is called in the acp_event_listener and add equivalent
auto-unread handling after that call: capture the status before calling
apply_status_intent, then check if the transition is Running to Idle with unread
not set, and if so call mark_unread_auto on the instance and persist the update
using the same persist_session_update pattern as this block, collecting the
profile and instance id for the persistence callback.

2919-2932: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Log auto-unread persistence failures.

let _ = drops storage errors here. If the write fails, the in-memory marker may disappear on the next disk reload, and the Running -> Idle edge will not happen again to retry. A warning is enough to make this diagnosable.

Suggested error handling
                 for (profile, ids) in newly_idle {
-                    let _ = api::persist_session_update(
+                    let profile_for_log = profile.clone();
+                    if let Err(e) = api::persist_session_update(
                         profile,
                         "auto-unread",
                         state.file_watch.clone(),
                         move |insts| {
                             for inst in insts.iter_mut() {
@@
                             }
                         },
                     )
-                    .await;
+                    .await
+                    {
+                        tracing::warn!(
+                            target: "server.unread",
+                            profile = %profile_for_log,
+                            error = %e,
+                            "failed to persist auto-unread marker"
+                        );
+                    }
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/mod.rs` around lines 2919 - 2932, The code currently drops storage
errors from the api::persist_session_update call by using let _ = which makes
failures undiagnosable. Instead of discarding the result, capture the Result
returned by the await on api::persist_session_update and log a warning message
if an error occurs. This will make it visible when the auto-unread persistence
fails, allowing the issue to be diagnosed and potentially retried on the next
Running -> Idle transition.

4690-4690: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the separator hyphens from the test comment.

Line 4690 uses --- as a prose separator, which this repo avoids in human-authored comments.

Suggested cleanup
-    // --- `#2248`: a structured session must heal out of a stale Stopped ---
+    // `#2248`: a structured session must heal out of a stale Stopped.

As per coding guidelines, “Do not use emdashes or -- as separators in docs and comments.”

📝 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.

    // `#2248`: a structured session must heal out of a stale Stopped.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/mod.rs` at line 4690, The comment near line 4690 uses `---` as
separators which violates the repo's coding guidelines against using hyphens or
emdashes as separators in comments. Remove the `---` hyphens from both the
beginning and end of the comment while preserving the issue reference `#2248` and
the description text about the structured session healing behavior.

Source: Coding guidelines

web/src/App.tsx (1)

116-117: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unread-indicator setting is effectively “load-time only” in the app shell.

Line 145-149 sets unreadIndicatorEnabled once, and Line 188-191 provides that frozen value globally. Toggling session.unread_indicator during runtime won’t update unread behavior until reload, so clear-on-view and row markers can stay out of sync with current settings.

Please source this context value from a live settings signal (or re-sync this state whenever settings are saved/refetched), not only the initial fetch.

Also applies to: 144-149, 188-191

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/App.tsx` around lines 116 - 117, The unreadIndicatorEnabled state is
initialized once and never updates when session.unread_indicator changes at
runtime, causing the global context provider to serve stale values. Replace the
static useState initialization and one-time effect (the code around line 145-149
where unreadIndicatorEnabled is set) with a mechanism that derives
unreadIndicatorEnabled from the live settings source and updates whenever
settings are saved or refetched. Ensure the effect that updates this state has
appropriate dependencies on the settings object so it re-syncs whenever the
session.unread_indicator property changes, and verify the context provider at
lines 188-191 exposes the current dynamic value rather than a frozen one.
web/src/hooks/useSidebarTriage.ts (1)

95-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unread “mark read” can desync on multi-session workspaces.

Line 97 always targets ws.sessions[0], but the row’s unread state is aggregated from any session. If a different session is unread, “Mark as read” can no-op server-side while optimistic unread: null remains, leaving the row falsely read.

💡 Proposed fix
   const unread = useCallback(
     async (ws: Workspace, markUnread: boolean): Promise<TriageResult> => {
-      const sessionId = ws.sessions[0]?.id;
-      if (!sessionId) return { workspaceId: ws.id, ok: false, skipped: true };
+      const targetSessionIds = markUnread
+        ? (ws.sessions[0]?.id ? [ws.sessions[0].id] : [])
+        : ws.sessions.filter((s) => s.unread != null).map((s) => s.id);
+      if (targetSessionIds.length === 0) {
+        return { workspaceId: ws.id, ok: false, skipped: true };
+      }
       // "Mark as unread" sets a manual flag; "Mark as read" clears it.
       setOverride(ws.id, { unread: markUnread ? "manual" : null });
-      const result = await setSessionUnread(sessionId, markUnread);
-      if (!result) {
-        setOverride(ws.id, { unread: undefined });
-        return { workspaceId: ws.id, ok: false };
+      for (const sessionId of targetSessionIds) {
+        const result = await setSessionUnread(sessionId, markUnread);
+        if (!result) {
+          setOverride(ws.id, { unread: undefined });
+          return { workspaceId: ws.id, ok: false };
+        }
       }
       return { workspaceId: ws.id, ok: true };
     },
     [setOverride],
   );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/hooks/useSidebarTriage.ts` around lines 95 - 107, The unread function
in useSidebarTriage always operates on the first session (ws.sessions[0]) via
the sessionId variable, but the workspace's unread state aggregates across all
sessions. When marking as read (markUnread is false), if a different session is
unread, the setSessionUnread call may no-op server-side while the optimistic
update still sets unread to null, causing desync. Instead of always targeting
ws.sessions[0], determine which session(s) should be marked read based on the
marking operation - consider whether you need to mark all sessions as read when
marking the workspace as read, or identify and target the specific unread
session(s) to ensure the server-side operation succeeds.
web/src/lib/api.ts (1)

1397-1403: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Encode session IDs in the unread PATCH route.

At Line 1399, id is interpolated without URL encoding. If a session ID contains / or other reserved characters, this request can route incorrectly and fail to clear/toggle unread.

Suggested fix
 export async function setSessionUnread(id: string, unread: boolean): Promise<SessionResponse | null> {
   try {
-    const res = await fetch(`/api/sessions/${id}/unread`, {
+    const res = await fetch(`/api/sessions/${encodeURIComponent(id)}/unread`, {
       method: "PATCH",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ unread }),
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/lib/api.ts` around lines 1397 - 1403, In the setSessionUnread
function, the session id parameter is being interpolated directly into the URL
string without proper encoding. This causes routing issues if the id contains
reserved characters like forward slashes. Wrap the id parameter with
encodeURIComponent() when constructing the fetch URL path to ensure special
characters are properly encoded before being sent to the API endpoint.

Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 17, 2026
CodeRabbit review on agent-of-empires#2088:

- Promote unread in `attention_group_key` too, so project-grouped Attention
  agrees with the flat view (a group with an unread Idle now outranks a group
  whose best member is a read Error).
- Map `v`/`V` to `ToggleUnread` in the context menu's `handle_key`, with a
  regression test; the quick-pick only fires when the row is present.
- Gate the `v` binding behind the feature: a new `Context::UnreadEnabled`
  drops it from dispatch when off, and the help overlay and command palette
  skip it, so disabling the setting removes it from every surface (not just a
  no-op handler).
- Clear auto-unread unconditionally (only when an `Auto` flag is present) so a
  stale marker can't survive a disable/re-enable; `Manual` is still preserved.
- Fall back an omitted theme `unread` to that theme's own `accent` instead of
  Empire's default, via a load-time `fill_unread_from_accent`.
- Lighten catppuccin-latte's `unread` so it stays below Waiting in that light
  theme (waiting > unread > idle).

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 18, 2026
Address @njbrake's review on agent-of-empires#2088:

- TUI unread rows now read at a glance: a solid `●` icon + bold on top of
  the `theme.unread` color, instead of a too-subtle color-only swap. Mirrors
  the web sidebar's unread dot.
- Move the unread toggle from `v` to `u`, the more intuitive key. `u` routes
  by context: it triggers Update while an update is available (the footer is
  showing), otherwise it toggles read/unread. Implemented via a new
  `Context::UpdateAvailable` gate on the Update binding, with ToggleUnread
  listed after it as the fallback (both modes). The context-menu quick-pick
  and docs follow.

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 18, 2026
Two fixes from local testing of agent-of-empires#2088:

- The selected/open session no longer hides a *manual* unread flag. The
  active-row suppression now applies only to `auto` markers (which
  clear-on-view removes a beat later, so hiding them avoids a flash); a
  deliberate "mark unread" stays visible while the session is selected.
- The unread row's *text* now takes the unread color (with bold), not just a
  bolded primary color, so the whole row recolors the way a Running row does
  and matches the TUI.

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 18, 2026
agent-of-empires#2088 review: a Running session that also carried an unread marker kept
showing the solid unread dot — the color was gated to resting states
(Idle/Unknown) but the icon + bold weren't, so the dot replaced the running
spinner. Gate the whole unread treatment (color, dot, bold) on resting
status so any live status (Running/Waiting/Starting/...) keeps its own color
and spinner. Adds a render regression test.

🤖 Generated with Claude Code
Eric162 added a commit to Eric162/agent-of-empires that referenced this pull request Jun 18, 2026
Adversarial-review findings on agent-of-empires#2088:

- Sunk group members no longer promote a group as unread. `attention_group_key`
  aggregated `is_unread()` across all members, including archived/snoozed
  (tier-99) ones — and archive/snooze don't clear `unread` — so a dismissed
  session could drag its whole group to unread rank 1. Now only non-sunk
  members count, matching the session key's tier-99 short-circuit. Regression
  test added.
- Mark `session.unread_indicator` global_only. It's backed by one process-wide
  flag refreshed from the active profile, so a per-profile override couldn't
  be honored; exposing it as profile-overridable was misleading. Scope it
  global to match the implementation.

🤖 Generated with Claude Code
@njbrake

njbrake commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

One note - especially for @njbrake - i changed Update to capital U instead of having a conditional - it feels better both in practice and in code - and also, it is more aligned with the R command to restart the server in some sense. Open to changing it of course but just thought to mention it.

Sounds good to me! Out of curiousity I looked and the command to mark read/unread for the macos mail app is cmd+U (uppercase U) so we are at least matching the convention of one popular email client.

Eric162 added 11 commits June 18, 2026 11:31
Adds a non-intrusive "unread" indicator on sessions, distinct from the
existing turn-finish notifications (sound/popup). Two kinds:

- auto-unread: set when a turn finishes (Running -> Idle), cleared by
  viewing the session (Tab into live-send, Enter to attach) or the manual
  toggle. The actively-viewed session is exempt, so finishing a turn while
  you're watching it doesn't mark it unread.
- manual-unread: a deliberate "flag for later" toggled with `v`, cleared
  only by the manual toggle (survives Tab/Enter).

Surfaced as a dedicated `theme.unread` row color on resting (Idle/Unknown)
rows, a context-menu entry, and an Attention-sort promoter that floats
unread rows just below Waiting. `w` (jump to next waiting/idle) also stops
on unread rows.

The whole feature is gated behind the `AOE_UNREAD` env var so it ships
dark; when unset, behavior is identical to before.

🤖 Generated with Claude Code
Switch the unread feature from the `AOE_UNREAD` env testing-gate to a real
config toggle, on by default:

- Add `session.unread_indicator: bool` (default true) via the single-source
  settings schema, so it surfaces in the TUI and web settings automatically.
- Replace the env-var `unread_enabled()` OnceLock with a config-backed
  AtomicBool (default true) refreshed by the TUI on startup and whenever
  config is re-applied, so a settings change takes effect without a restart.
  Keeps the cheap accessor the hot Attention-sort path relies on.
- Update doc comments and context-menu tests for the now-default-on feature.

🤖 Generated with Claude Code
CodeRabbit review on agent-of-empires#2088:

- Promote unread in `attention_group_key` too, so project-grouped Attention
  agrees with the flat view (a group with an unread Idle now outranks a group
  whose best member is a read Error).
- Map `v`/`V` to `ToggleUnread` in the context menu's `handle_key`, with a
  regression test; the quick-pick only fires when the row is present.
- Gate the `v` binding behind the feature: a new `Context::UnreadEnabled`
  drops it from dispatch when off, and the help overlay and command palette
  skip it, so disabling the setting removes it from every surface (not just a
  no-op handler).
- Clear auto-unread unconditionally (only when an `Auto` flag is present) so a
  stale marker can't survive a disable/re-enable; `Manual` is still preserved.
- Fall back an omitted theme `unread` to that theme's own `accent` instead of
  Empire's default, via a load-time `fill_unread_from_accent`.
- Lighten catppuccin-latte's `unread` so it stays below Waiting in that light
  theme (waiting > unread > idle).

🤖 Generated with Claude Code
CodeRabbit follow-up on agent-of-empires#2088: add unit tests for `fill_unread_from_accent`
so the fallback can't break silently. Covers a custom theme that omits
`unread` (inherits its own accent) and one that sets it explicitly (kept,
not overwritten).

🤖 Generated with Claude Code
CodeRabbit follow-up on agent-of-empires#2088: toggling unread changes a row's rank in
the Attention sort, so rebuilding flat_items could move the cursor onto a
different session and the next action would target the wrong one. Reseat
by id after the rebuild, matching the snooze/archive paths.

🤖 Generated with Claude Code
Bring the unread feature to the web dashboard, matching the TUI:

Backend
- Expose `unread` ("auto"/"manual"/null) on SessionResponse.
- PATCH /api/sessions/{id}/unread ({unread:bool}): flag manually unread or
  mark read (clears both kinds), mirroring the TUI toggle; persist-then-
  mutate like snooze, read-only guarded, no-op when the feature is off.
- Auto-mark Running -> Idle in the server status poll loop (persisted per
  profile) so a web-only user accrues the indicator with no TUI running.
- Feed the `unread_enabled()` gate from the daemon's resolved
  `session.unread_indicator` config at startup.
- Project `theme.unread` to the `--color-status-unread` CSS var.

Frontend
- Unread dot on the sidebar row (theme-accent), suppressed on the active
  row; a right-click "Mark as read/unread" menu item, both gated on the
  `session.unread_indicator` setting via a new context.
- Optimistic overlay support for unread, like pin/archive/snooze.
- Clear-on-view: opening a session (or having it open when its turn
  finishes) clears an `auto` marker; a `manual` flag survives viewing.

Tests: instance + serialization Rust tests; api/overlay/RTL vitest;
coverage-matrix entry (sidebar.unread-indicator).

🤖 Generated with Claude Code
Address @njbrake's review on agent-of-empires#2088:

- TUI unread rows now read at a glance: a solid `●` icon + bold on top of
  the `theme.unread` color, instead of a too-subtle color-only swap. Mirrors
  the web sidebar's unread dot.
- Move the unread toggle from `v` to `u`, the more intuitive key. `u` routes
  by context: it triggers Update while an update is available (the footer is
  showing), otherwise it toggles read/unread. Implemented via a new
  `Context::UpdateAvailable` gate on the Update binding, with ToggleUnread
  listed after it as the fallback (both modes). The context-menu quick-pick
  and docs follow.

🤖 Generated with Claude Code
…glyph

Follow-ups from review:

- Hotkey: instead of routing `u` contextually (Update-when-available, else
  unread), give each its own key — `u` toggles read/unread, `U` updates. They
  form the standard primary/secondary pair on the `u` letter, so strict mode
  relocates cleanly (unread -> `U`, update -> `Ctrl+U`). Drops the
  `Context::UpdateAvailable`/`Ctx.update_available` machinery. The update
  footer hint now reads its key from the binding registry so it can't drift.
- Web: the unread marker now *replaces* the resting status glyph with a solid
  dot (in the unread color) rather than sitting beside the title, matching the
  TUI. Only for resting (Idle/Unknown) rows; a live spinner stays.

🤖 Generated with Claude Code
Two fixes from local testing of agent-of-empires#2088:

- The selected/open session no longer hides a *manual* unread flag. The
  active-row suppression now applies only to `auto` markers (which
  clear-on-view removes a beat later, so hiding them avoids a flash); a
  deliberate "mark unread" stays visible while the session is selected.
- The unread row's *text* now takes the unread color (with bold), not just a
  bolded primary color, so the whole row recolors the way a Running row does
  and matches the TUI.

🤖 Generated with Claude Code
agent-of-empires#2088 review: a Running session that also carried an unread marker kept
showing the solid unread dot — the color was gated to resting states
(Idle/Unknown) but the icon + bold weren't, so the dot replaced the running
spinner. Gate the whole unread treatment (color, dot, bold) on resting
status so any live status (Running/Waiting/Starting/...) keeps its own color
and spinner. Adds a render regression test.

🤖 Generated with Claude Code
Adversarial-review findings on agent-of-empires#2088:

- Sunk group members no longer promote a group as unread. `attention_group_key`
  aggregated `is_unread()` across all members, including archived/snoozed
  (tier-99) ones — and archive/snooze don't clear `unread` — so a dismissed
  session could drag its whole group to unread rank 1. Now only non-sunk
  members count, matching the session key's tier-99 short-circuit. Regression
  test added.
- Mark `session.unread_indicator` global_only. It's backed by one process-wide
  flag refreshed from the active profile, so a per-profile override couldn't
  be honored; exposing it as profile-overridable was misleading. Scope it
  global to match the implementation.

🤖 Generated with Claude Code
…read

Replace the two-kind unread marker (Auto/Manual) with a single boolean
`unread`. Engaging with a session (open/attach, live-send, click, web
open) always clears it; `u` toggles it back on. Drops the kind enum, the
kind-aware web optimistic reconciler, and the wire-format strings in
favor of a bare boolean.

Add dwell-to-read in the TUI: a session kept selected with the list in
the foreground for UNREAD_DWELL (3s) clears its marker, distinguishing
"scrolled past" from "stopped to read." Driven from the app tick loop,
paused during dialogs/live-send, reset on selection change, and
restarted on a manual toggle so re-flagging the current row isn't undone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.94737% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.26%. Comparing base (e0d394d) to head (1705521).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
web/src/App.tsx 33.33% 4 Missing ⚠️
web/src/hooks/useSidebarTriage.ts 69.23% 2 Missing and 2 partials ⚠️
web/src/lib/api.ts 66.66% 2 Missing ⚠️
web/src/components/WorkspaceSidebar.tsx 92.85% 0 Missing and 1 partial ⚠️
web/src/lib/unreadIndicator.ts 88.88% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main    #2088      +/-   ##
==========================================
- Coverage   84.26%   84.26%   -0.01%     
==========================================
  Files         233      234       +1     
  Lines       13547    13600      +53     
  Branches     3954     3971      +17     
==========================================
+ Hits        11416    11460      +44     
- Misses       1822     1829       +7     
- Partials      309      311       +2     
Files with missing lines Coverage Δ
web/src/lib/sidebarOptimistic.ts 94.33% <100.00%> (+0.72%) ⬆️
web/src/components/WorkspaceSidebar.tsx 73.79% <92.85%> (+0.54%) ⬆️
web/src/lib/unreadIndicator.ts 88.88% <88.88%> (ø)
web/src/lib/api.ts 90.60% <66.66%> (-0.33%) ⬇️
web/src/App.tsx 44.94% <33.33%> (+0.05%) ⬆️
web/src/hooks/useSidebarTriage.ts 80.76% <69.23%> (-2.31%) ⬇️

... and 5 files with indirect coverage changes

Components Coverage Δ
Structured View UI 83.96% <ø> (ø)
Diff Viewer 90.33% <ø> (ø)
Session Wizard 84.39% <ø> (ø)
Settings 88.46% <ø> (ø)
Auth 97.93% <ø> (ø)
Dashboard + Sidebar 77.89% <92.85%> (+0.37%) ⬆️
Right Panel + Terminal 74.12% <ø> (ø)
Directory Browser, Devices, Connectivity 87.68% <ø> (ø)
Modals 63.63% <ø> (ø)
Command Palette 87.80% <ø> (ø)
App Shell 55.16% <33.33%> (-0.05%) ⬇️
Hooks 87.74% <69.23%> (-0.07%) ⬇️
Lib (API client + utils) 94.97% <87.50%> (-0.07%) ⬇️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update e0d394d...1705521. Read the comment docs.

@njbrake njbrake left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clean UX with easy to understand usage. Love it.

@njbrake njbrake merged commit 1dddf7e into agent-of-empires:main Jun 18, 2026
23 checks passed
jerome-benoit added a commit to jerome-benoit/agent-of-empires that referenced this pull request Jun 19, 2026
The unread row color #7dcfff (cyan) had a higher Rec. 601 luminance
(188.0) than waiting #e0af68 (181.6), so an unread Idle row visually
outshone a Waiting peer on the dark-themed sort, contradicting the
Attention-sort promoter contract from agent-of-empires#2088 that floats unread rows
just below Waiting. Cyan is also off the upstream spec slot for info:
tokyonight.nvim defines colors.info = colors.blue2 = #0db9d7 directly.

Switch to blue2 #0db9d7 (Rec. 601 = 137.0). The replacement sits
between waiting (181.6) and idle (97.1) on dark bg, restores the
ordering under both Rec. 601 and WCAG 2.1 relative luminance, matches
the upstream info slot, and stays inside the Storm palette.

Extend theme_attention_hierarchy_holds to also assert
waiting > unread > idle for every builtin theme so future drift is
caught at test time. The new assertion fails on the prior #7dcfff
with the exact panic 'tokyo-night-storm (dark bg): waiting luminance
181.6 should exceed unread 188.0'. unread vs fresh_idle ordering is
intentionally unconstrained so themes can tie them (empire and zinc
do, at the same Tailwind step).
@njbrake njbrake mentioned this pull request Jun 24, 2026
4 tasks
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.

3 participants