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
Conversation
germanescobar
force-pushed
the
issue-333
branch
from
September 1, 2026 19:46
3c9fb6e to
a372ded
Compare
germanescobar
force-pushed
the
issue-333
branch
3 times, most recently
from
September 1, 2026 22:39
03a9cc7 to
8d3bcbd
Compare
germanescobar
force-pushed
the
issue-333
branch
from
September 2, 2026 01:43
8d3bcbd to
a1113bd
Compare
germanescobar
force-pushed
the
issue-333
branch
from
September 2, 2026 19:30
a1113bd to
cefc176
Compare
germanescobar
force-pushed
the
issue-333
branch
6 times, most recently
from
September 4, 2026 05:16
d0eea03 to
6a3b291
Compare
…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.
germanescobar
force-pushed
the
issue-333
branch
from
September 4, 2026 06:02
6a3b291 to
c00a3b7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #333.
What changed
The on-radar (focus queue) sidebar used to sort pinned sessions by
focusPinnedAt ?? createdAtascending — 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:
user.input_requestedprompt or has at least one pending tool approval. The user owes a reply to these, so they sit at the very top regardless ofactive(Claude's structured-input pause kills the child so an awaiting session can beactive: false).lastActiveAtasc) 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.lastVisitedAtasc) so the freshest look sits closest to the running pile below.The radar-inclusion filter (
Boolean(session.focusPinnedAt)) is unchanged, so manually unpinned sessions still don't appear — anduserUnpinned === trueis 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'suser.input_requested) alongside the existingpendingApprovalsmap. The bulk/api/runtimessnapshot reportsawaitingInput: booleanderived frompendingApprovals.size > 0 || awaitingUserInput. The flag is flipped by the stream handler when it processes auser.input_requestedortool.approval_requestedevent and cleared when the run resumes with a non-approval event (or when a new stream starts viamarkSessionActive). The flag survivesmarkSessionInactiveso 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
focusAutoAdvancechord (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
localStorageundercontroller.focus.autoAdvanceso 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
lastInteractionAtwatermark (bumped on every Next / Reply / Mark Done) splits the queue into three conceptual buckets in priority order:lastActiveAtis at or after the watermark. Fresh finishes the user hasn't answered yet; the algorithm walks the finished pile in arrival order.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
lastActiveAtorder.The watermark is stamped after the target is computed and after navigation, so the just-replied session's server-side
lastActiveAtupdate 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:
controllerModestate, the sidebar's Controller Mode button, the blue Controller Mode banner in the session view, and the toggle handler are all gone.controllerMode*prefix):controllerModeNext→focusAdvanceNext(Ctrl+N)controllerModeStay→focusStay(Ctrl+S)controllerModeDone→focusDone(Ctrl+D)focusAutoAdvance(Ctrl+T) for the auto-advance toggle.controllerModeToggleaction (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
useControllerModeShortcutshook is renamed touseFocusShortcuts(and gutted of Controller Mode logic) so the file name matches what it does.6. Migrate legacy
controllerMode*overrides on readWhen a user upgrades across the Controller Mode removal, their persisted overrides file may still contain
controllerMode*keys.normalizeStoretranslates 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
activeor freshness.focusPinnedAtis unset (still hidden from radar) or where the user has manually unpinned (userUnpinned === true).controllerMode*actions are migrated to the new ids without dropping the user's chord.focusAutoAdvancechord (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).sidebar,focus-advance,useFocusShortcuts,focus-advance-toast,shortcuts-section, etc.) succeeds.Out of scope / non-goals
focusDoneAtdoes not enter the sort. Sessions marked done clearfocusPinnedAton the server and the sidebar filter removes them, so they never reach the queue.lastActiveAtfall back to array order (Array#sort is stable in modern engines).lastVisitedAtfall back to array order too.