Skip to content

Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, toggleable auto-advance, visited/unvisited split, drop Controller Mode (#333) - #338

Open
germanescobar wants to merge 1 commit into
mainfrom
issue-333
Open

Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, toggleable auto-advance, visited/unvisited split, drop Controller Mode (#333)#338
germanescobar wants to merge 1 commit into
mainfrom
issue-333

Conversation

@germanescobar

@germanescobar germanescobar commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Closes #333.

What changed

The on-radar (focus queue) sidebar used to sort pinned sessions by focusPinnedAt ?? createdAt ascending — i.e. oldest-pin-first. That buried the agent that just stopped on its own — the one that actually needs attention — under long-idle sessions the user forgot to unpin.

This change brings the radar in line with how it actually gets used: "triage awaiting-input and recently-finished agents, with optional auto-advance, before checking on running ones." Six parts:

1. Sort: five buckets, awaiting-input on top, visited/unvisited split in the finished block

The single ascending sort is replaced with a partition + per-bucket sort. From top to bottom:

  1. Awaiting input — items whose agent has paused on a user.input_requested prompt or has at least one pending tool approval. The user owes a reply to these, so they sit at the very top regardless of active (Claude's structured-input pause kills the child so an awaiting session can be active: false).
  2. Finished, unvisited — the triage pile. Items whose agent finished and the user has not yet landed on via any navigation (Next, auto-advance, mark-done follow-up, sidebar click, conversation link). Oldest-arrival first (lastActiveAt asc) so the user walks the pile in the order the agents finished. Visiting a session sinks it into the next bucket so the user isn't bounced back to it on every cycle.
  3. Finished, visited — items the user has already looked at. Most-recently-visited at the very bottom of this sub-bucket (lastVisitedAt asc) so the freshest look sits closest to the running pile below.
  4. Running (active) — sessions where the agent is still working. Oldest-running first, so the most recently started running session lands at the very bottom of the queue.

The radar-inclusion filter (Boolean(session.focusPinnedAt)) is unchanged, so manually unpinned sessions still don't appear — and userUnpinned === true is server-side only, never reaches the client filter, so it can't regress. Visit timestamps are tracked in-memory only (lost on reload) — a reload resumes the queue with everything in "unvisited" again, which is fine: the user re-triages from the top.

2. Awaiting-input detection on the server

The runtime map now carries awaitingUserInput (Claude's user.input_requested) alongside the existing pendingApprovals map. The bulk /api/runtimes snapshot reports awaitingInput: boolean derived from pendingApprovals.size > 0 || awaitingUserInput. The flag is flipped by the stream handler when it processes a user.input_requested or tool.approval_requested event and cleared when the run resumes with a non-approval event (or when a new stream starts via markSessionActive). The flag survives markSessionInactive so a paused session keeps its awaiting-input state across navigation.

The sidebar renders an amber dot next to the session title when this flag is set, so the awaiting state is visible at a glance (not just via queue position).

3. Toggleable auto-advance (default on)

Every reply triggers a 4-second countdown → auto-advance to the next focus item. A new focusAutoAdvance chord (default Ctrl+T, which we vacated when Controller Mode was dropped) toggles the post-reply countdown on or off. When off, replies stay on the current session until the user hits Next (manual skip) or Mark Done (removes from queue) — those gestures always work regardless of the toggle, so Next is the manual escape hatch for the careful triager.

The setting persists to localStorage under controller.focus.autoAdvance so the choice survives reloads. Toggling OFF also cancels any in-flight countdown — the user has just said "I want to stay on this session," so honoring a 4-second-old schedule contradicts that intent.

The watermark (lastInteractionAt) still bumps on every reply regardless of the toggle, so the next manual Next press correctly surfaces the just-replied session's "recently finished."

The sidebar shows a Play/Pause toggle button in the On radar header (with the chord hint in the tooltip), so the toggle is one click away when the user wants it.

4. Recently-finished bucket in the advance algorithm

An in-memory lastInteractionAt watermark (bumped on every Next / Reply / Mark Done) splits the queue into three conceptual buckets in priority order:

  • Awaiting input — checked first, always wins.
  • Recently finished — items whose lastActiveAt is at or after the watermark. Fresh finishes the user hasn't answered yet; the algorithm walks the finished pile in arrival order.
  • Plain circular advance — when the two priority buckets are empty, advance from index N to N+1.

The navigation algorithm doesn't see visit timestamps directly: the visual sort puts unvisited-finished above visited-finished, so the algorithm naturally surfaces unvisited first via array order. Once all unvisited are visited, the algorithm falls through to plain circular, and visited items re-emerge via lastActiveAt order.

The watermark is stamped after the target is computed and after navigation, so the just-replied session's server-side lastActiveAt update doesn't immediately look "fresh" and bounce the user back.

5. Drop Controller Mode

Controller Mode as a toggle was a UX layer on top of the same auto-advance behaviour, with its own blue banner, on/off state, and toggle shortcut. Removing the toggle makes "auto-advance to the next focus item, unless the user cancels the countdown" the default behaviour. Concretely:

  • The controllerMode state, the sidebar's Controller Mode button, the blue Controller Mode banner in the session view, and the toggle handler are all gone.
  • Every reply auto-advances by default (see §3); the Stay chord (Ctrl+S) cancels the countdown, the Next chord (Ctrl+N) commits it.
  • Mark Done (Ctrl+D) keeps working unchanged.
  • The composer auto-focuses whenever the active session changes, so the keyboard-driven triage loop still works without a "mode" the user has to remember to enable.
  • Shortcuts are renamed (drop the controllerMode* prefix):
    • controllerModeNextfocusAdvanceNext (Ctrl+N)
    • controllerModeStayfocusStay (Ctrl+S)
    • controllerModeDonefocusDone (Ctrl+D)
    • New focusAutoAdvance (Ctrl+T) for the auto-advance toggle.
  • The controllerModeToggle action (the old Ctrl+T) is removed. The shared shortcut list, the server-side persisted-overrides file (~/.local/state/Controller/shortcuts.json), the Settings panel, the global keyboard hook, and the focus-advance toast all pick up the new names.

The useControllerModeShortcuts hook is renamed to useFocusShortcuts (and gutted of Controller Mode logic) so the file name matches what it does.

6. Migrate legacy controllerMode* overrides on read

When a user upgrades across the Controller Mode removal, their persisted overrides file may still contain controllerMode* keys. normalizeStore translates them to the new ids in memory (preserving the user's chord) and rewrites the file in the cleaned shape on first read, so the migration is self-healing and never has to run again. controllerModeToggle (no longer a real action) is silently dropped. A new-id override already on file wins over a legacy alias for the same action (no clobbering).

Acceptance criteria

  • Given a mix of finished and running sessions on the radar, finished sessions appear above all running sessions.
  • Within the finished section, the oldest-arrival finished is at the top and the newest-arrival is at the bottom of that block (FIFO).
  • Within the running section, the most recently started running session is at the very bottom of the queue; older-running sessions stack above it.
  • Sessions awaiting user input surface at the top of the queue regardless of active or freshness.
  • Sessions the user has already visited sink below the unvisited triage pile so a Next-then-Next-then-Next cycle doesn't keep bouncing them to the top.
  • No regression for sessions where focusPinnedAt is unset (still hidden from radar) or where the user has manually unpinned (userUnpinned === true).
  • Existing user rebinds for controllerMode* actions are migrated to the new ids without dropping the user's chord.
  • The user can toggle the post-reply auto-advance countdown on or off via the sidebar button or the focusAutoAdvance chord (default Ctrl+T). Next and Mark Done keep working regardless of the toggle. The choice persists across reloads.

Validation

  • client/src/lib/focus-advance.test.ts14/14 pass, covering the awaiting-input bucket, the recently-finished FIFO walking, the sent-from skip rule, and the fall-through to plain circular advance.
  • client/src/components/__tests__/sidebar-sort-focus-queue.test.tsx15/15 pass, including the four-bucket sort with awaiting at the top, finished-unvisited FIFO at the top of the finished block, finished-visited sinking below, and running-oldest-first.
  • client/src/components/__tests__/focus-advance-toast.test.tsx2/2 pass, covering the renamed stay/next chords.
  • server/lib/__tests__/shortcut-settings.test.ts12/12 pass, including 5 migration cases (legacy translation, file-shape rewrite, removed-action drop, no-clobber of new-id overrides, unknown-id drop).
  • Full client + shared test suite: 251/251 pass.
  • Full server + CLI test suite: 632/632 pass.
  • Smoke-import of every renamed module (sidebar, focus-advance, useFocusShortcuts, focus-advance-toast, shortcuts-section, etc.) succeeds.

Out of scope / non-goals

  • focusDoneAt does not enter the sort. Sessions marked done clear focusPinnedAt on the server and the sidebar filter removes them, so they never reach the queue.
  • No time-decay window — "recent" is bounded by the user's Next / Reply / Mark Done interactions, not by a clock.
  • Within the awaiting and finished buckets, ties on lastActiveAt fall back to array order (Array#sort is stable in modern engines).
  • Within the visited sub-bucket, ties on lastVisitedAt fall back to array order too.
  • The CLI counterpart (related Add a CLI command to list on-radar (focus-queue) sessions #322, list on-radar sessions) is not implemented yet; when it lands it should mirror this ordering.

@germanescobar germanescobar changed the title Focus queue: prioritize recently-finished agents above running ones (#333) Focus queue: recently-finished-first sort, walk the finished pile, drop Controller Mode (#333) Sep 1, 2026
@germanescobar
germanescobar force-pushed the issue-333 branch 3 times, most recently from 03a9cc7 to 8d3bcbd Compare September 1, 2026 22:39
@germanescobar germanescobar changed the title Focus queue: recently-finished-first sort, walk the finished pile, drop Controller Mode (#333) Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, drop Controller Mode (#333) Sep 1, 2026
@germanescobar germanescobar changed the title Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, drop Controller Mode (#333) Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, toggleable auto-advance, drop Controller Mode (#333) Sep 2, 2026
@germanescobar germanescobar changed the title Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, toggleable auto-advance, drop Controller Mode (#333) Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, toggleable auto-advance, visited/unvisited split, drop Controller Mode (#333) Sep 2, 2026
@germanescobar
germanescobar force-pushed the issue-333 branch 6 times, most recently from d0eea03 to 6a3b291 Compare September 4, 2026 05:16
…ait-input priority, toggleable auto-advance, visited/unvisited split, drop Controller Mode (#333)

The on-radar (focus queue) sidebar used to sort pinned sessions by
`focusPinnedAt ?? createdAt` ascending (oldest-pin-first). That buried
the agent that just stopped on its own — the one that actually needs
attention — under long-idle sessions the user forgot to unpin.

This change brings the radar in line with how it actually gets used:
"triage awaiting-input and recently-finished agents, with optional
auto-advance, before checking on running ones." Six parts:

1. **Sort: five buckets, awaiting-input on top, visited/unvisited split in the finished block.**

   The single ascending sort is replaced with a partition + per-bucket
   sort. From top to bottom:

   - **Awaiting input** — items whose agent has paused on a
     `user.input_requested` prompt or has at least one pending tool
     approval. The user owes a reply to these, so they sit at the
     very top regardless of `active`. (Claude's structured-input
     pause kills the child so an awaiting session can be `active:
     false`.)
   - **Finished, unvisited** — the triage pile. Items whose agent
     finished and the user has not yet landed on via any navigation
     (Next, auto-advance, mark-done follow-up, sidebar click,
     conversation link). Oldest-arrival first (`lastActiveAt` asc)
     so the user walks the pile in the order the agents finished.
     Visiting a session sinks it into the next bucket so the user
     isn't bounced back to it on every cycle.
   - **Finished, visited** — items the user has already looked at.
     Most-recently-visited at the very bottom of this sub-bucket
     (`lastVisitedAt` asc) so the freshest look sits closest to the
     running pile below.
   - **Running (active)** — sessions where the agent is still
     working. Oldest-running first, so the most recently started
     running session lands at the very bottom of the queue.

   The radar-inclusion filter (`Boolean(session.focusPinnedAt)`) is
   unchanged, so manually unpinned sessions still don't appear.
   Visit timestamps are tracked in-memory only (lost on reload) —
   a reload resumes the queue with everything in "unvisited"
   again, which is fine: the user re-triages from the top.

2. **Awaiting-input detection on the server.**

   The runtime map now carries `awaitingUserInput` (Claude's
   `user.input_requested`) alongside the existing `pendingApprovals`
   map. The bulk `/api/runtimes` snapshot reports
   `awaitingInput: boolean` derived from
   `pendingApprovals.size > 0 || awaitingUserInput`. The flag is
   flipped by the stream handler when it processes a
   `user.input_requested` or `tool.approval_requested` event and
   cleared when the run resumes with a non-approval event (or when
   a new stream starts via `markSessionActive`). The flag survives
   `markSessionInactive` so a paused session keeps its
   awaiting-input state across navigation.

3. **Toggleable auto-advance (default on).**

   Every reply triggers a 4-second countdown → auto-advance to the
   next focus item. A new `focusAutoAdvance` chord (default Ctrl+T,
   which we vacated when Controller Mode was dropped) toggles the
   post-reply countdown on or off. When off, replies stay on the
   current session until the user hits **Next** (manual skip) or
   **Mark Done** (removes from queue) — those gestures always work
   regardless of the toggle, so Next is the manual escape hatch for
   the careful triager.

   The setting persists to `localStorage` under
   `controller.focus.autoAdvance` so the choice survives reloads.
   Toggling OFF also cancels any in-flight countdown — the user has
   just said "I want to stay on this session," so honoring a
   4-second-old schedule contradicts that intent.

   The watermark (`lastInteractionAt`) still bumps on every reply
   regardless of the toggle, so the next manual Next press correctly
   surfaces the just-replied session's "recently finished."

   The sidebar shows a Play/Pause toggle button in the **On radar**
   header (with the chord hint in the tooltip), so the toggle is
   one click away when the user wants it.

4. **Recently-finished bucket** in the advance algorithm.

   `lastInteractionAt` (an in-memory watermark bumped on every
   Next / Reply / Mark Done) splits the queue into three
   conceptual buckets in priority order:

   - **Awaiting input** — checked first, always wins.
   - **Recently finished** — items whose `lastActiveAt` is at or
     after the watermark. Fresh finishes the user hasn't answered
     yet; the algorithm walks the finished pile in arrival order.
   - **Plain circular advance** — when the two priority buckets are
     empty, advance from index N to N+1.

   The navigation algorithm doesn't see visit timestamps directly:
   the visual sort puts unvisited-finished above visited-finished,
   so the algorithm naturally surfaces unvisited first via array
   order. Once all unvisited are visited, the algorithm falls
   through to plain circular, and visited items re-emerge via
   `lastActiveAt` order.

   The watermark is stamped *after* the target is computed and
   after navigation, so the just-replied session's server-side
   `lastActiveAt` update doesn't immediately look "fresh" and
   bounce the user back.

5. **Drop Controller Mode.**

   Controller Mode as a toggle was a UX layer on top of the same
   auto-advance behaviour, with its own blue banner, on/off state,
   and toggle shortcut. Removing the toggle makes "auto-advance to
   the next focus item, unless the user cancels the countdown" the
   default behaviour. Concretely:

   - The `controllerMode` state, the sidebar's Controller Mode
     button, the blue Controller Mode banner in the session view,
     and the toggle handler are all gone.
   - Every reply auto-advances by default (see §3); the **Stay**
     chord (Ctrl+S) cancels the countdown, the **Next** chord
     (Ctrl+N) commits it.
   - **Mark Done** (Ctrl+D) keeps working unchanged.
   - The composer auto-focuses whenever the active session changes,
     so the keyboard-driven triage loop still works without a
     "mode" the user has to remember to enable.
   - Shortcuts are renamed (drop the `controllerMode*` prefix):
     - `controllerModeNext` → `focusAdvanceNext` (Ctrl+N)
     - `controllerModeStay` → `focusStay` (Ctrl+S)
     - `controllerModeDone` → `focusDone` (Ctrl+D)
     - New `focusAutoAdvance` (Ctrl+T) for the auto-advance toggle.
   - The `controllerModeToggle` action (the old Ctrl+T) is removed.
   - The `useControllerModeShortcuts` hook is renamed to
     `useFocusShortcuts` (and gutted of Controller Mode logic).

6. **Migrate legacy `controllerMode*` overrides on read.**

   When a user upgrades across the Controller Mode removal, their
   persisted overrides file may still contain `controllerMode*`
   keys. `normalizeStore` translates them to the new ids in
   memory (preserving the user's chord) and rewrites the file in
   the cleaned shape on first read, so the migration is
   self-healing and never has to run again. `controllerModeToggle`
   (no longer a real action) is silently dropped. A new-id override
   already on file wins over a legacy alias for the same action
   (no clobbering).

## Acceptance criteria

- [x] Given a mix of finished and running sessions on the radar,
      finished sessions appear above all running sessions.
- [x] Within the finished section, the oldest-arrival finished is
      at the top and the newest-arrival is at the bottom of that
      block (FIFO).
- [x] Within the running section, the most recently started running
      session is at the very bottom of the queue; older-running
      sessions stack above it.
- [x] Sessions awaiting user input surface at the top of the queue
      regardless of `active` or freshness.
- [x] Sessions the user has already visited sink below the
      unvisited triage pile so a Next-then-Next-then-Next cycle
      doesn't keep bouncing them to the top.
- [x] No regression for sessions where `focusPinnedAt` is unset
      (still hidden from radar) or where the user has manually
      unpinned (`userUnpinned === true`).
- [x] Existing user rebinds for `controllerMode*` actions are
      migrated to the new ids without dropping the user's chord.
- [x] The user can toggle the post-reply auto-advance countdown on
      or off via the sidebar button or the `focusAutoAdvance`
      chord (default Ctrl+T). **Next** and **Mark Done** keep
      working regardless of the toggle. The choice persists across
      reloads.

## Validation

- `client/src/lib/focus-advance.test.ts` — **14/14 pass**,
  covering the awaiting-input bucket, the recently-finished FIFO
  walking, the sent-from skip rule, and the fall-through to plain
  circular advance.
- `client/src/components/__tests__/sidebar-sort-focus-queue.test.tsx`
  — **15/15 pass**, including the four-bucket sort with awaiting
  at the top, finished-unvisited FIFO at the top of the finished
  block, finished-visited sinking below, and running-oldest-first.
- `client/src/components/__tests__/focus-advance-toast.test.tsx` —
  **2/2 pass**, covering the renamed stay/next chords.
- `server/lib/__tests__/shortcut-settings.test.ts` — **12/12
  pass**, including 5 migration cases (legacy translation,
  file-shape rewrite, removed-action drop, no-clobber of new-id
  overrides, unknown-id drop).
- Full client + shared test suite: **251/251 pass**.
- Full server + CLI test suite: **632/632 pass**.
- Smoke-import of every renamed module (`sidebar`,
  `focus-advance`, `useFocusShortcuts`,
  `focus-advance-toast`, `shortcuts-section`, etc.) succeeds.

## Out of scope / non-goals

- `focusDoneAt` does **not** enter the sort. Sessions marked done
  clear `focusPinnedAt` on the server and the sidebar filter
  removes them, so they never reach the queue.
- No time-decay window — "recent" is bounded by the user's
  Next / Reply / Mark Done interactions, not by a clock.
- Within the awaiting and finished buckets, ties on `lastActiveAt`
  fall back to array order (Array#sort is stable in modern engines).
- Within the visited sub-bucket, ties on `lastVisitedAt` fall
  back to array order too.
- The CLI counterpart (related #322, list on-radar sessions) is
  not implemented yet; when it lands it should mirror this
  ordering.

Refs #333.
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.

On-radar (focus queue): prioritize recently-finished agents above running ones

1 participant