Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, toggleable auto-advance, visited/unvisited split, drop Controller Mode (#333) - #338
Conversation
3c9fb6e to
a372ded
Compare
03a9cc7 to
8d3bcbd
Compare
8d3bcbd to
a1113bd
Compare
a1113bd to
cefc176
Compare
6a3b291 to
c00a3b7
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.
c00a3b7 to
20ca315
Compare
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20ca31582a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const next = pickFirstFocusItem(reordered, sessionId); | ||
| if (next) openFocusItem(next); |
There was a problem hiding this comment.
Use the recent-finish selector in the live advance path
When a previously handled pinned session runs again, its persisted lastVisitedAt remains set, so after completion sortFocusQueue keeps it in the visited bucket and orders it by the old handled time rather than the new finish time. This live path then always opens the first sorted row; the newly added pickNextFocusItem(..., lastInteractionAt) logic is only referenced by tests. With multiple visited sessions, Next can therefore open a stale result ahead of a session that just finished.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 171640a. I kept the intended two-step semantics (mark/re-sort the current session, then open the visual first row) and changed the canonical sort so a completion newer than the recorded handled timestamp re-enters the unvisited triage bucket. That makes a previously handled session that finishes again become the new first row, which the live advance path then opens. Added a regression test for this case.
| setAwaitingInputSessionIds( | ||
| new Set( | ||
| entries | ||
| .filter((entry) => entry.awaitingInput) | ||
| .map((entry) => entry.sessionId), | ||
| ), |
There was a problem hiding this comment.
Keep refreshing while an inactive session awaits input
For a Claude structured-input pause, a refresh records the session in awaitingInputSessionIds while also making activeSessionIds empty; the polling effect at lines 538-544 then stops because it is gated only on active sessions. If the user subsequently dismisses the prompt, the server clears the flag but no sidebar refresh is triggered, so this set remains stale and the resolved session stays pinned at the highest-priority queue position indefinitely. Poll while either active or awaiting sessions exist, or explicitly refresh after resolving input.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 171640a. The sidebar now keeps polling while there are active sessions OR sessions awaiting input, so an inactive structured-input prompt is refreshed and removed after it is resolved elsewhere.
| for (const [sessionId, state] of runtimes) { | ||
| const awaitingInput = | ||
| state.awaitingUserInput === true || | ||
| (state.pendingApprovals?.size ?? 0) > 0; | ||
| summaries.push({ | ||
| sessionId, | ||
| active: state.active, | ||
| awaitingInput: awaitingInput || undefined, |
There was a problem hiding this comment.
Rebuild persisted input attention after a server restart
The new attention summary is derived exclusively from the process-local runtimes map. After Controller restarts, that map is empty even though user.input_requested and response events are persisted and SessionView reconstructs an unanswered prompt from them, so the same session displays an input card but is no longer marked awaitingInput or prioritized in the focus queue. The runtime listing needs to reconstruct unresolved user-input attention from persisted events, or the attention state itself must be persisted.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 171640a. The bulk runtimes endpoint now merges process-local state with unresolved attention reconstructed from persisted JSONL event logs. The scanner caches by file mtime and size, so cold starts recover pending input/approvals while unchanged transcripts are not reparsed on every poll. Added persistence reconstruction tests.
| {!streaming && canAttachMore && ( | ||
| <button | ||
| type="button" | ||
| onClick={() => fileInputRef.current?.click()} |
There was a problem hiding this comment.
Reveal attachments selected from the compact composer
On a mobile-width viewport the collapsed composer renders this paperclip, but clicking it does not set composerHasFocus; after a file is selected, the attachment chips remain hidden behind showComposerDetails at line 6800. The attachment is nevertheless included in the pending send and enables the Send button, leaving users unable to see or remove what they selected unless they discover that tapping the textarea expands the composer. Selecting a file should expand the composer or show attachment state in compact mode.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 171640a. Selecting a file from the compact mobile composer now expands the composer after the picker returns, making the selected attachment and its remove action visible. Cancelling the picker leaves the composer compact.
| export type ShortcutActionId = | ||
| | "controllerModeToggle" | ||
| | "controllerModeNext" | ||
| | "controllerModeDone" | ||
| | "controllerModeStay" | ||
| | "focusAdvanceNext" | ||
| | "focusStay" | ||
| | "focusDone" | ||
| | "focusAutoAdvance" |
There was a problem hiding this comment.
Update help pages for the removed Controller Mode
These action IDs remove the Controller Mode toggle and replace it with always-on focus controls, but docs/content/docs/sidebar.mdx still tells users to click a Controller Mode toggle and docs/content/docs/controller-mode.mdx still documents the removed mode and toast. Since the in-app Help link opens these pages, users are directed through a workflow that no longer exists; update or replace the affected help content as part of this removal.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 171640a. Updated the sidebar guide, the former Controller Mode page (now Focus navigation), and the first-project link to describe On radar, Next/Done, the conversation panel, responsive shortcut behavior, and auto-advance.
Closes #333.
What changed
Radar ordering and attention
Navigation semantics
Next and auto-advance now perform two explicit operations:
Simply clicking through conversations no longer changes their queue position. This fixes cases where Next selected the last or second-to-last row instead of the first actionable row.
Conversation controls
Mobile composer
Acceptance criteria
Validation