From 8550d87b978cd2a80ea9b59b4b82ba0b1e37a8a5 Mon Sep 17 00:00:00 2001 From: German Escobar Date: Wed, 19 Aug 2026 23:04:34 -0500 Subject: [PATCH 1/5] Focus queue: recently-finished-first sort, walk the finished pile, await-input priority, toggleable auto-advance, visited/unvisited split, drop Controller Mode (#333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/App.tsx | 340 +++++++++---- client/src/api.ts | 7 + .../__tests__/focus-advance-toast.test.tsx | 14 +- .../sidebar-sort-focus-queue.test.tsx | 295 +++++++++++ client/src/components/focus-advance-toast.tsx | 12 +- client/src/components/shortcuts-section.tsx | 9 +- client/src/components/sidebar.tsx | 295 ++++++++--- .../__tests__/focus-visited-storage.test.ts | 151 ++++++ client/src/lib/focus-advance.test.ts | 470 +++++++++++++++++- client/src/lib/focus-advance.ts | 168 ++++++- client/src/lib/focus-visited-storage.ts | 69 +++ client/src/lib/shortcut-match.ts | 12 +- client/src/lib/useFilePanelShortcuts.ts | 8 +- ...rModeShortcuts.ts => useFocusShortcuts.ts} | 160 +++--- client/src/lib/useShortcutBindings.tsx | 20 +- client/src/pages/SessionView.tsx | 126 ++--- client/src/pages/Settings.tsx | 2 +- .../lib/__tests__/shortcut-settings.test.ts | 162 +++++- server/lib/paths.ts | 2 +- server/lib/session-runtime.ts | 50 +- server/lib/shortcut-settings.ts | 104 +++- server/routes/sessions.ts | 15 + server/routes/shortcuts.ts | 2 +- shared/shortcuts.ts | 39 +- 24 files changed, 2073 insertions(+), 459 deletions(-) create mode 100644 client/src/components/__tests__/sidebar-sort-focus-queue.test.tsx create mode 100644 client/src/lib/__tests__/focus-visited-storage.test.ts create mode 100644 client/src/lib/focus-visited-storage.ts rename client/src/lib/{useControllerModeShortcuts.ts => useFocusShortcuts.ts} (63%) diff --git a/client/src/App.tsx b/client/src/App.tsx index d9d9eece..d6da0719 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -23,7 +23,11 @@ import { type Worktree, } from "./api.ts"; import type { ControllerLinkTarget } from "../../shared/conversation-links.ts"; -import { Sidebar, type FocusQueueItem } from "./components/sidebar.tsx"; +import { + Sidebar, + sortFocusQueue, + type FocusQueueItem, +} from "./components/sidebar.tsx"; import { StatusBar } from "./components/StatusBar.tsx"; import { ProjectSetup } from "./pages/ProjectSetup.tsx"; import { EditProject } from "./pages/EditProject.tsx"; @@ -31,7 +35,7 @@ import { NewWorktree } from "./pages/NewWorktree.tsx"; import { SessionView } from "./pages/SessionView.tsx"; import { SettingsPage, type SettingsSection } from "./pages/Settings.tsx"; import { useResizablePanel } from "./lib/useResizablePanel.ts"; -import { useControllerModeShortcuts } from "./lib/useControllerModeShortcuts.ts"; +import { useFocusShortcuts } from "./lib/useFocusShortcuts.ts"; import { ShortcutBindingsProvider, useShortcutBindingsContext, @@ -39,6 +43,10 @@ import { import { FileIndexProvider } from "./lib/useFileIndex.tsx"; import { FocusAdvanceToast } from "./components/focus-advance-toast.tsx"; import { pickNextFocusItem } from "./lib/focus-advance.ts"; +import { + loadSavedVisitedAt, + persistVisitedAt, +} from "./lib/focus-visited-storage.ts"; /** * Time the focus-advance toast shows a "Moving to next..." countdown @@ -132,12 +140,66 @@ function AppBody() { return saved.page === "session" ? saved.projectId : null; }); const [sidebarOpen, setSidebarOpen] = useState(false); - const [controllerMode, setControllerMode] = useState(false); const [focusQueue, setFocusQueue] = useState([]); const [focusRefreshKey, setFocusRefreshKey] = useState(0); + // Per-session "last visited" timestamp. Updated whenever the user + // lands on a session via any navigation (Next, auto-advance, + // mark-done follow-up, sidebar click, conversation link). Drives + // the visited/unvisited split in `sortFocusQueue` — finished + // sessions the user has never opened sit at the top of the + // finished block ("triage pile"), and any visit sinks them below + // the unvisited pile so the user isn't bounced back to them on + // every cycle. + // + // Hydrated from localStorage on first paint and persisted on + // every update so the triage pile survives a reload. Without + // persistence, a reload re-surfaces every pinned session as + // "fresh," and pressing Next from any visited item walks forward + // through the visited bucket instead of jumping to the top of + // remaining unvisited — the exact regression the user's bug + // report described after a reload. + const [visitedAt, setVisitedAt] = useState>( + () => loadSavedVisitedAt(window.localStorage), + ); + // Write-through to localStorage whenever `visitedAt` changes. A + // dedicated effect is cleaner than wrapping the setter because + // `setVisitedAt` is invoked via updater functions and may receive + // stale references inside React's batching. Watching the state + // guarantees we persist exactly the value that just became + // canonical. Persistence failures are swallowed inside + // `persistVisitedAt`; the in-memory state still works. + useEffect(() => { + persistVisitedAt(window.localStorage, visitedAt); + }, [visitedAt]); + // Epoch ms of the user's most recent "interaction" with the focus + // queue (Next / Reply / Mark Done). Drives the recently-finished + // bucket in `pickNextFocusItem`: an item whose `lastActiveAt` is at + // or after this timestamp is a fresh finish that the user hasn't + // answered yet, and the advance algorithm surfaces those first + // (oldest-arrival first). `0` means "never interacted yet" — the + // recently-finished bucket is empty in that case. + const [lastFocusInteractionAt, setLastFocusInteractionAt] = useState(0); + // Post-reply auto-advance countdown: on by default. When off, + // replies stay on the current session until the user hits Next, + // Mark Done, or re-enables the toggle. The Next chord always + // advances regardless of this setting (it's the manual escape + // hatch). Persisted to localStorage so a user who turns it off + // doesn't have to turn it off again on every reload. + const [autoAdvance, setAutoAdvance] = useState(() => { + try { + const saved = window.localStorage.getItem( + "controller.focus.autoAdvance", + ); + if (saved === "false") return false; + } catch { + // localStorage can throw in private-mode browsers; fall + // through to the default. + } + return true; + }); // Live shortcut bindings shared with the Settings panel and the - // Controller Mode keyboard listener. Read here (top of AppBody) so - // both `handleFocusAdvanceAfterSend` and `useControllerModeShortcuts` + // Focus-queue keyboard listener. Read here (top of AppBody) so + // both `handleFocusAdvanceAfterSend` and `useFocusShortcuts` // can pass the same map into the toast and the keydown handler. const shortcutBindings = useShortcutBindingsContext(); // Scheduled "advance to the next focus item" while a 4-second @@ -187,9 +249,22 @@ function AppBody() { const closeSidebar = () => setSidebarOpen(false); - const handleFocusQueueChange = useCallback((queue: FocusQueueItem[]) => { - setFocusQueue(queue); - }, []); + const handleFocusQueueChange = useCallback( + (queue: FocusQueueItem[]) => { + // Overlay the per-session visit timestamps onto the items the + // sidebar emitted, then sort via the shared helper. The sidebar + // builds the raw items (it owns the runtime / projectData + // fetches); App owns the visit tracking and the canonical + // ordering. + const withVisits = queue.map((item) => { + const visited = visitedAt[item.session.id]; + if (visited === item.lastVisitedAt) return item; + return { ...item, lastVisitedAt: visited }; + }); + setFocusQueue(sortFocusQueue(withVisits)); + }, + [visitedAt], + ); /** * Open a pinned focus item by switching the view to its session @@ -222,6 +297,11 @@ function AppBody() { advanceTimerRef.current = null; } dismissAdvanceToast(); + // Stamp the watermark AFTER navigating so the just-replied + // session's server-side `lastActiveAt` update (which happens a + // moment later when the stream closes) doesn't immediately look + // "fresh" and bounce the user back. + setLastFocusInteractionAt(Date.now()); openFocusItem(pending.next); }, [openFocusItem]); @@ -297,44 +377,6 @@ function AppBody() { }; }, [activeProjectId, scheduleEventsRefetch]); - const handleControllerModeToggle = useCallback(() => { - if (controllerMode) { - setControllerMode(false); - cancelPendingAdvance(); - return; - } - const firstItem = focusQueue[0]; - if (!firstItem) { - toast.info("Add a session to On radar to use Controller Mode"); - return; - } - setControllerMode(true); - openFocusItem(firstItem); - }, [controllerMode, focusQueue, openFocusItem, cancelPendingAdvance]); - - const handleControllerModeEnter = useCallback(() => { - const firstItem = focusQueue[0]; - if (!firstItem) { - toast.info("Add a session to On radar to use Controller Mode"); - return; - } - setControllerMode(true); - openFocusItem(firstItem); - }, [focusQueue, openFocusItem]); - - const handleControllerModeExit = useCallback(() => { - setControllerMode(false); - // Exiting controller mode also cancels any pending advance — the - // target session is no longer relevant once controller mode is off. - if (advanceTimerRef.current !== null) { - window.clearTimeout(advanceTimerRef.current); - advanceTimerRef.current = null; - } - pendingFocusAdvanceRef.current = null; - dismissAdvanceToast(); - setPendingFocusAdvance(null); - }, []); - const handleSelectProject = (projectId: string) => { setActiveProjectId(projectId); setView({ page: "session", projectId }); @@ -425,12 +467,65 @@ function AppBody() { setMobileDiffSummary(null); }, [activeView.page === "session" ? activeView.sessionId : null]); - const focusPosition = - currentFocusIndex >= 0 - ? { current: currentFocusIndex + 1, total: focusQueue.length } - : controllerMode - ? { current: 0, total: focusQueue.length } - : undefined; + // Record a "visit" whenever the user lands on a session — Next, + // auto-advance, mark-done follow-up, sidebar click, conversation + // link, schedule link. The visited timestamp sinks the session + // below the unvisited triage pile in `sortFocusQueue`. We also + // record the *previous* session so leaving a session counts as a + // visit (otherwise auto-advance from A → B would only mark B as + // visited, leaving A marked as fresh-and-unvisited for the next + // cycle). The ref tracks the previous id so the effect can mark + // both endpoints of every transition. + const activeSessionId = + activeView.page === "session" ? activeView.sessionId : null; + const previousActiveSessionIdRef = useRef(null); + useEffect(() => { + const previous = previousActiveSessionIdRef.current; + const idsToMark = new Set(); + if (previous && previous !== activeSessionId) idsToMark.add(previous); + if (activeSessionId) idsToMark.add(activeSessionId); + previousActiveSessionIdRef.current = activeSessionId; + if (idsToMark.size === 0) return; + setVisitedAt((current) => { + const next = new Date().toISOString(); + const updates: Record = {}; + let changed = false; + for (const id of idsToMark) { + if (current[id] !== next) { + updates[id] = next; + changed = true; + } + } + if (!changed) return current; + return { ...current, ...updates }; + }); + }, [activeSessionId]); + + // When `visitedAt` changes, re-sort the queue so visited items + // sink below the unvisited triage pile. Avoid an infinite loop + // by checking that the timestamps are actually different before + // triggering the sort. + useEffect(() => { + setFocusQueue((current) => { + const withVisits = current.map((item) => { + const visited = visitedAt[item.session.id]; + if (visited === item.lastVisitedAt) return item; + return { ...item, lastVisitedAt: visited }; + }); + const sorted = sortFocusQueue(withVisits); + // Bail if nothing actually changed (same array reference, + // same item references). `sortFocusQueue` returns a fresh + // array even when order is unchanged, so we compare items. + if ( + sorted.length === current.length && + sorted.every((item, i) => item === current[i]) + ) { + return current; + } + return sorted; + }); + }, [visitedAt]); + const currentFocusItem = currentFocusIndex >= 0 ? focusQueue[currentFocusIndex] : null; const handleFocusSkip = () => { @@ -442,15 +537,48 @@ function AppBody() { return; } if (focusQueue.length === 0) { - setControllerMode(false); toast.info("Focus queue is empty"); return; } - const nextIndex = - currentFocusIndex >= 0 ? (currentFocusIndex + 1) % focusQueue.length : 0; - openFocusItem(focusQueue[nextIndex]); + // Walk the recently-finished bucket first; otherwise plain + // circular advance. Recording the interaction after computing the + // target keeps the freshly-surfaced item in the bucket for this + // call (see `pickNextFocusItem`). + const sentFromId = + currentFocusItem?.session.id ?? activeView.sessionId ?? ""; + const next = pickNextFocusItem( + focusQueue, + sentFromId, + lastFocusInteractionAt, + ); + setLastFocusInteractionAt(Date.now()); + if (!next) return; + openFocusItem(next); }; + // Toggle the post-reply auto-advance countdown. Persists to + // localStorage so the choice survives reloads. Next, Stay, and Mark + // Done are unaffected — they always work regardless of this + // setting (Next is the manual escape hatch). + // + // 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 auto-advance schedule contradicts that intent. + const handleToggleAutoAdvance = useCallback(() => { + const nextValue = !autoAdvance; + setAutoAdvance(nextValue); + try { + window.localStorage.setItem( + "controller.focus.autoAdvance", + nextValue ? "true" : "false", + ); + } catch { + // localStorage can throw in private-mode browsers; the + // in-memory state still flips for the rest of the session. + } + if (!nextValue) cancelPendingAdvance(); + }, [autoAdvance, cancelPendingAdvance]); + const handleToggleCurrentSessionPin = async () => { if (activeView.page !== "session" || !activeView.sessionId) return; const { projectId, worktreeId, sessionId } = activeView; @@ -518,37 +646,61 @@ function AppBody() { setFocusRefreshKey((key) => key + 1); if (nextQueue.length === 0) { - setControllerMode(false); toast.success("Focus queue complete"); return; } - const nextIndex = - currentFocusIndex >= 0 - ? currentFocusIndex % nextQueue.length - : 0; - openFocusItem(nextQueue[nextIndex]); + // Mark-done counts as an interaction: stamp the watermark so any + // items that finished *since* the last interaction are the next + // stop. The just-done session is already out of the queue, so + // we pass an empty sent-from id and let `pickNextFocusItem` + // resolve the freshest finished first. + setLastFocusInteractionAt(Date.now()); + const next = pickNextFocusItem(nextQueue, "", Date.now()); + if (!next) return; + openFocusItem(next); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to update focus queue"); } }; - // After the user sends a message in controller mode, schedule an - // advance to the next focus item rather than navigating - // immediately. The user just committed a message, and bouncing - // them away from the originating session before the in-flight - // user bubble can render is what made the message look "lost" - // (issue #104). The countdown gives them FOCUS_ADVANCE_COUNTDOWN_MS - // to see the bubble, with the **Stay** chord (default ⌃S / Ctrl+S) - // or Esc for cancelling. + // After the user sends a message, schedule an advance to the next + // focus item rather than navigating immediately. The user just + // committed a message, and bouncing them away from the originating + // session before the in-flight user bubble can render is what made + // the message look "lost" (issue #104). The countdown gives them + // FOCUS_ADVANCE_COUNTDOWN_MS to see the bubble, with the **Stay** + // chord (default ⌃S / Ctrl+S) or Esc for cancelling. // // The "sent from" session id is passed in so we can apply the // stay-put rule when the only pinned item is the one the user // just replied to (queue-of-one, no-op). + // + // When `autoAdvance` is off, replies stay on the current session — + // the user has to hit Next (manual skip), Mark Done (removes from + // queue), or re-enable the toggle. The watermark still bumps so + // the next manual Next press surfaces the just-replied session's + // "recently finished" correctly. const handleFocusAdvanceAfterSend = useCallback( (sentFromSessionId: string) => { - if (!controllerMode) return; - const next = pickNextFocusItem(focusQueue, sentFromSessionId); + // Stamp the watermark now: a reply is an interaction, and the + // just-replied session will get a server-side `lastActiveAt` + // update when its stream closes — that's "freshly finished" + // for the recently-finished bucket logic, which we want to + // kick in immediately on the next Next press. + setLastFocusInteractionAt(Date.now()); + if (!autoAdvance) return; + // Compute the target using the *previous* watermark so any item + // that finished since the last interaction is considered fresh. + // The watermark is updated *after* the countdown commits (in + // `commitPendingAdvance`), not here — otherwise the just-replied + // session's `lastActiveAt` (server-stamped moments later) would + // immediately look "fresh" and bounce the user back. + const next = pickNextFocusItem( + focusQueue, + sentFromSessionId, + lastFocusInteractionAt, + ); if (!next) return; // Replace any existing pending advance (the user sent again // before the previous countdown finished). The new origin @@ -583,7 +735,14 @@ function AppBody() { commitPendingAdvance(); }, FOCUS_ADVANCE_COUNTDOWN_MS); }, - [controllerMode, focusQueue, commitPendingAdvance, cancelPendingAdvance, shortcutBindings.bindings], + [ + autoAdvance, + focusQueue, + lastFocusInteractionAt, + commitPendingAdvance, + cancelPendingAdvance, + shortcutBindings.bindings, + ], ); // Sidebar resizing @@ -594,22 +753,20 @@ function AppBody() { maxWidth: 480, }); - // Controller Mode keyboard shortcuts (defaults: ⌃T toggle, ⌃N next, - // ⌃D done, ⌃S stay; ⌃ on macOS, Ctrl off-mac). We default to Ctrl - // rather than Cmd because Cmd collides with too many macOS system - // shortcuts (Cmd+W, Cmd+Q, Cmd+R, Cmd+T, …). The chord for each - // action is read from `useShortcutBindings`, so users can rebind - // them in Settings (issue #235). The matcher is strict per-platform: - // a stored "ctrl-n" only fires on ⌃N on macOS, never on ⌘N. Esc - // still blurs and (when not in an editable) cancels a pending - // advance. - useControllerModeShortcuts({ + // Focus-queue keyboard shortcuts (defaults: ⌃N next, ⌃D done, ⌃S + // stay, ⌃T toggle auto-advance; ⌃ on macOS, Ctrl off-mac). We + // default to Ctrl rather than Cmd because Cmd collides with too + // many macOS system shortcuts (Cmd+W, Cmd+Q, Cmd+R, …). The chord + // for each action is read from `useShortcutBindings`, so users can + // rebind them in Settings (issue #235). The matcher is strict + // per-platform: a stored "ctrl-n" only fires on ⌃N on macOS, never + // on ⌘N. Esc still blurs and (when not in an editable) cancels a + // pending advance. + useFocusShortcuts({ bindings: shortcutBindings.bindings, - controllerMode, onSkip: handleFocusSkip, onDone: handleFocusDone, - onEnter: handleControllerModeEnter, - onExit: handleControllerModeExit, + onToggleAutoAdvance: handleToggleAutoAdvance, onCancelAdvance: pendingFocusAdvance ? cancelPendingAdvance : undefined, onCommitAdvance: pendingFocusAdvance ? commitPendingAdvance : undefined, }); @@ -668,8 +825,10 @@ function AppBody() { closeSidebar(); }} onFocusQueueChange={handleFocusQueueChange} - controllerMode={controllerMode} - onControllerModeToggle={handleControllerModeToggle} + focusQueue={focusQueue} + autoAdvance={autoAdvance} + onToggleAutoAdvance={handleToggleAutoAdvance} + onSkip={handleFocusSkip} shortcutBindings={shortcutBindings.bindings} focusRefreshKey={focusRefreshKey} eventsRefreshKey={eventsRefreshKey} @@ -792,12 +951,7 @@ function AppBody() { loadProjects(); }} onOpenConversation={handleOpenConversation} - controllerMode={controllerMode} shortcutBindings={shortcutBindings.bindings} - focusPosition={focusPosition} - onFocusDone={handleFocusDone} - onFocusSkip={handleFocusSkip} - onFocusExit={handleControllerModeExit} onFocusPinnedChange={() => setFocusRefreshKey((key) => key + 1)} onTitleChange={() => setFocusRefreshKey((key) => key + 1)} onArchive={handleArchiveCurrentSession} diff --git a/client/src/api.ts b/client/src/api.ts index 63599d82..983d4494 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -122,6 +122,13 @@ export interface SessionRuntimeEntry { provider?: string; projectId?: string; worktreeId?: string; + /** + * True when the agent has paused on a user-input request OR has at + * least one pending tool approval. The focus-queue sidebar uses + * this as the highest-priority "needs your attention" signal + * regardless of the session's `active` state. + */ + awaitingInput?: boolean; } export interface AgentEvent { diff --git a/client/src/components/__tests__/focus-advance-toast.test.tsx b/client/src/components/__tests__/focus-advance-toast.test.tsx index ddfede7c..8fcf40cf 100644 --- a/client/src/components/__tests__/focus-advance-toast.test.tsx +++ b/client/src/components/__tests__/focus-advance-toast.test.tsx @@ -12,7 +12,7 @@ import { FocusAdvanceToast } from "../focus-advance-toast.tsx"; * Regression test for the focus-advance countdown toast (issue * follow-up to #235). * - * Background: issue #235 moved every Controller Mode shortcut to a + * Background: issue #235 moved every focus-queue shortcut to a * modifier-based chord so it fires regardless of focus (default * `Ctrl+S` for stay, `Ctrl+N` for continue). The toast copy was left * untouched, advertising "Press S to stay · N to continue" — strings @@ -55,8 +55,8 @@ function labelFor(chord: string): string { test("toast falls back to default stay / next chips when bindings are null", () => { const html = render(null); - const stayDefault = labelFor(DEFAULT_SHORTCUT_BINDINGS.controllerModeStay); - const nextDefault = labelFor(DEFAULT_SHORTCUT_BINDINGS.controllerModeNext); + const stayDefault = labelFor(DEFAULT_SHORTCUT_BINDINGS.focusStay); + const nextDefault = labelFor(DEFAULT_SHORTCUT_BINDINGS.focusAdvanceNext); assert.ok( html.includes(`>${stayDefault} to stay`), `expected toast to render default stay label "${stayDefault}", got: ${html}`, @@ -79,12 +79,12 @@ test("toast falls back to default stay / next chips when bindings are null", () test("toast surfaces the rebound chord when the user customises it in Settings", () => { const rebound: ShortcutBindings = { ...DEFAULT_SHORTCUT_BINDINGS, - controllerModeStay: "ctrl-shift-s", - controllerModeNext: "ctrl-shift-n", + focusStay: "ctrl-shift-s", + focusAdvanceNext: "ctrl-shift-n", }; const html = render(rebound); - const stayLabel = labelFor(rebound.controllerModeStay); - const nextLabel = labelFor(rebound.controllerModeNext); + const stayLabel = labelFor(rebound.focusStay); + const nextLabel = labelFor(rebound.focusAdvanceNext); assert.ok( html.includes(`>${stayLabel} to stay`), `expected rebound stay label "${stayLabel}", got: ${html}`, diff --git a/client/src/components/__tests__/sidebar-sort-focus-queue.test.tsx b/client/src/components/__tests__/sidebar-sort-focus-queue.test.tsx new file mode 100644 index 00000000..9e21330b --- /dev/null +++ b/client/src/components/__tests__/sidebar-sort-focus-queue.test.tsx @@ -0,0 +1,295 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + sortFocusQueue, + type FocusQueueItem, +} from "../sidebar.tsx"; + +function item( + id: string, + lastActiveAt: string, + active: boolean, + lastVisitedAt?: string, +): FocusQueueItem { + // The sort only inspects `session.lastActiveAt`, `active`, and + // `lastVisitedAt`; the rest of the fields are filler so the + // resulting type matches `FocusQueueItem`. + return { + projectId: "p", + projectName: "P", + worktreeId: "w", + worktreeName: "W", + session: { + id, + workingDirectory: "/tmp", + worktreeId: "w", + model: "test", + messages: [], + createdAt: lastActiveAt, + lastActiveAt, + status: "idle", + }, + active, + lastVisitedAt, + }; +} + +function ids(items: FocusQueueItem[]): string[] { + return items.map((item) => item.session.id); +} + +test("all-finished: oldest-arrival at the top of the finished block (FIFO)", () => { + // Finished items are ordered by `lastActiveAt` ascending, so the + // user walks them in arrival order — the oldest one first. + const result = sortFocusQueue([ + item("newest-arrival", "2024-01-03T00:00:00.000Z", false), + item("oldest-arrival", "2024-01-01T00:00:00.000Z", false), + item("middle-arrival", "2024-01-02T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(result), [ + "oldest-arrival", + "middle-arrival", + "newest-arrival", + ]); +}); + +test("all-running: oldest running at the top, newest at the bottom", () => { + const result = sortFocusQueue([ + item("newest", "2024-01-03T00:00:00.000Z", true), + item("oldest", "2024-01-01T00:00:00.000Z", true), + item("middle", "2024-01-02T00:00:00.000Z", true), + ]); + assert.deepEqual(ids(result), ["oldest", "middle", "newest"]); +}); + +test("mixed: finished block (oldest first) sits above the running block", () => { + const result = sortFocusQueue([ + item("running-newest", "2024-01-10T00:00:00.000Z", true), + item("finished-oldest-arrival", "2024-01-01T00:00:00.000Z", false), + item("running-oldest", "2024-01-05T00:00:00.000Z", true), + item("finished-newest-arrival", "2024-01-04T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(result), [ + "finished-oldest-arrival", + "finished-newest-arrival", + "running-oldest", + "running-newest", + ]); +}); + +test("ties on lastActiveAt fall back to array order within each bucket", () => { + // Array#sort is stable (Node ≥ 12), so equal timestamps keep their + // original relative order. The acceptance criteria explicitly allow + // this fallback. + const result = sortFocusQueue([ + item("a", "2024-01-01T00:00:00.000Z", false), + item("b", "2024-01-01T00:00:00.000Z", false), + item("c", "2024-01-01T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(result), ["a", "b", "c"]); +}); + +test("a freshly-finished running session appends to the bottom of the finished block", () => { + // Two running sessions ("running-newer" is the most recently started, + // so it sits at the bottom of the running bucket) and one finished + // session that arrived a long time ago. + const before = sortFocusQueue([ + item("finished-old-arrival", "2024-01-01T00:00:00.000Z", false), + item("running-older", "2024-01-02T00:00:00.000Z", true), + item("running-newer", "2024-01-05T00:00:00.000Z", true), + ]); + assert.deepEqual(ids(before), [ + "finished-old-arrival", + "running-older", + "running-newer", + ]); + + // "running-newer" finishes in the same render. Its `lastActiveAt` + // is the most recent of the finished pile, so it lands at the + // bottom of the finished block (newest-arrival there), just above + // the running sessions. + const after = sortFocusQueue([ + item("finished-old-arrival", "2024-01-01T00:00:00.000Z", false), + item("running-older", "2024-01-02T00:00:00.000Z", true), + item("running-newer", "2024-01-05T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(after), [ + "finished-old-arrival", + "running-newer", + "running-older", + ]); +}); + +test("does not mutate the input array", () => { + const input: FocusQueueItem[] = [ + item("running", "2024-01-02T00:00:00.000Z", true), + item("finished", "2024-01-01T00:00:00.000Z", false), + ]; + const snapshot = ids(input); + sortFocusQueue(input); + assert.deepEqual(ids(input), snapshot); +}); + +// --------------------------------------------------------------------------- +// Awaiting-input priority (issue #333 follow-up). +// --------------------------------------------------------------------------- + +function awaitingItem( + id: string, + lastActiveAt: string, + active: boolean, +): FocusQueueItem { + return { + ...item(id, lastActiveAt, active), + awaitingInput: true, + }; +} + +test("awaiting-input: surfaces at the top of the queue regardless of `active`", () => { + // Claude's structured-input pause kills the child (active: false) + // but the session still needs the user. The flag wins. + const result = sortFocusQueue([ + awaitingItem("paused", "2024-01-05T00:00:00.000Z", false), + item("running-oldest", "2024-01-01T00:00:00.000Z", true), + item("running-newest", "2024-01-02T00:00:00.000Z", true), + ]); + assert.deepEqual(ids(result), [ + "paused", + "running-oldest", + "running-newest", + ]); +}); + +test("awaiting-input: wins over a recently-finished item with a fresher timestamp", () => { + // The finished item arrived after the awaiting one. The awaiting + // one still wins because the user owes a reply to it. + const result = sortFocusQueue([ + item("finished-just-now", "2024-01-10T00:00:00.000Z", false), + awaitingItem("awaiting", "2024-01-05T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(result), ["awaiting", "finished-just-now"]); +}); + +test("awaiting-input: multiple awaiting sessions are ordered oldest-arrival first", () => { + const result = sortFocusQueue([ + awaitingItem("awaiting-newest-arrival", "2024-01-05T00:00:00.000Z", false), + awaitingItem("awaiting-oldest-arrival", "2024-01-01T00:00:00.000Z", false), + item("running", "2024-01-02T00:00:00.000Z", true), + ]); + assert.deepEqual(ids(result), [ + "awaiting-oldest-arrival", + "awaiting-newest-arrival", + "running", + ]); +}); + +test("awaiting-input: bucket sits above the finished bucket sits above the running bucket", () => { + const result = sortFocusQueue([ + item("running-newest", "2024-01-10T00:00:00.000Z", true), + item("finished-oldest-arrival", "2024-01-01T00:00:00.000Z", false), + item("running-oldest", "2024-01-02T00:00:00.000Z", true), + awaitingItem("awaiting", "2024-01-06T00:00:00.000Z", false), + item("finished-newest-arrival", "2024-01-04T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(result), [ + "awaiting", + "finished-oldest-arrival", + "finished-newest-arrival", + "running-oldest", + "running-newest", + ]); +}); + +// --------------------------------------------------------------------------- +// Visited vs unvisited split (issue #333 follow-up). +// +// The finished block is split into "triage pile" (never visited by +// the user) and "already seen" (visited at some point). The unvisited +// pile stays at the top of the finished block; once a session is +// visited (skipped, replied to, etc.), it sinks to the visited +// sub-block so the user isn't bounced back to it on every cycle. +// --------------------------------------------------------------------------- + +test("finished, unvisited: oldest-arrival first at the top of the finished block", () => { + // No items visited yet — pure triage pile, sorted oldest-first. + const result = sortFocusQueue([ + item("finished-newest-arrival", "2024-01-05T00:00:00.000Z", false), + item("finished-oldest-arrival", "2024-01-01T00:00:00.000Z", false), + item("finished-middle-arrival", "2024-01-03T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(result), [ + "finished-oldest-arrival", + "finished-middle-arrival", + "finished-newest-arrival", + ]); +}); + +test("finished, visited: sinks below the unvisited triage pile", () => { + // Mixing visited and unvisited. Unvisited stays at the top of + // the finished block; visited sinks below. + const result = sortFocusQueue([ + item("visited-newer", "2024-01-05T00:00:00.000Z", false, "2024-01-10T00:00:00.000Z"), + item("unvisited-oldest", "2024-01-01T00:00:00.000Z", false), + item("visited-older", "2024-01-02T00:00:00.000Z", false, "2024-01-08T00:00:00.000Z"), + item("unvisited-newer", "2024-01-04T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(result), [ + "unvisited-oldest", + "unvisited-newer", + "visited-older", + "visited-newer", + ]); +}); + +test("finished, visited: most-recently-visited at the very bottom of the visited sub-block", () => { + // Within the visited sub-block, items are sorted by + // lastVisitedAt asc — the freshest visit sits closest to the + // running pile below. + const result = sortFocusQueue([ + item("visited-fresh", "2024-01-01T00:00:00.000Z", false, "2024-01-10T00:00:00.000Z"), + item("visited-stale", "2024-01-05T00:00:00.000Z", false, "2024-01-08T00:00:00.000Z"), + item("visited-middle", "2024-01-03T00:00:00.000Z", false, "2024-01-09T00:00:00.000Z"), + ]); + assert.deepEqual(ids(result), [ + "visited-stale", + "visited-middle", + "visited-fresh", + ]); +}); + +test("visiting a finished session sinks it from the triage pile", () => { + // The "before" state has A as unvisited (top of finished). + // The "after" state (simulating a visit by adding + // lastVisitedAt to A) puts A in the visited sub-block. + const before = sortFocusQueue([ + item("a", "2024-01-01T00:00:00.000Z", false), + item("b", "2024-01-02T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(before), ["a", "b"]); + + // Same items, but A is now visited. + const after = sortFocusQueue([ + item("a", "2024-01-01T00:00:00.000Z", false, "2024-01-05T00:00:00.000Z"), + item("b", "2024-01-02T00:00:00.000Z", false), + ]); + assert.deepEqual(ids(after), ["b", "a"]); +}); + +test("full queue with visits: awaiting → unvisited → visited → running", () => { + const result = sortFocusQueue([ + item("running-newest", "2024-01-10T00:00:00.000Z", true), + awaitingItem("awaiting", "2024-01-06T00:00:00.000Z", false), + item("finished-visited", "2024-01-01T00:00:00.000Z", false, "2024-01-09T00:00:00.000Z"), + item("finished-unvisited-newer", "2024-01-04T00:00:00.000Z", false), + item("finished-unvisited-older", "2024-01-02T00:00:00.000Z", false), + item("running-oldest", "2024-01-05T00:00:00.000Z", true), + ]); + assert.deepEqual(ids(result), [ + "awaiting", + "finished-unvisited-older", + "finished-unvisited-newer", + "finished-visited", + "running-oldest", + "running-newest", + ]); +}); \ No newline at end of file diff --git a/client/src/components/focus-advance-toast.tsx b/client/src/components/focus-advance-toast.tsx index fe92e460..6acacf1a 100644 --- a/client/src/components/focus-advance-toast.tsx +++ b/client/src/components/focus-advance-toast.tsx @@ -12,7 +12,7 @@ import { formatChord, isMacPlatform } from "../lib/shortcut-match.ts"; * visible shortcut matches the binding the listener fires. * * Background: the original copy read "Press S to stay · N to - * continue" and worked when Controller Mode used single-letter + * continue" and worked when the focus-queue used single-letter * shortcuts. Issue #235 moved every shortcut to a modifier-based * chord (default `Ctrl+S` / `Ctrl+N`) so it fires regardless of * focus — but the toast copy was never updated, leaving the chips @@ -59,13 +59,13 @@ export function FocusAdvanceToast({ // the bundled defaults if bindings haven't loaded yet so the chips // never read "Press S to stay" — that string is misleading because // the listener only fires on the modifier-based chord. `isMacPlatform` - // matches the rest of the Controller Mode UI. + // matches the rest of the focus-queue UI. const stayChord = - bindings?.controllerModeStay ?? - DEFAULT_SHORTCUT_BINDINGS.controllerModeStay; + bindings?.focusStay ?? + DEFAULT_SHORTCUT_BINDINGS.focusStay; const nextChord = - bindings?.controllerModeNext ?? - DEFAULT_SHORTCUT_BINDINGS.controllerModeNext; + bindings?.focusAdvanceNext ?? + DEFAULT_SHORTCUT_BINDINGS.focusAdvanceNext; const onMac = isMacPlatform(); const stayLabel = formatChord(stayChord, onMac); const nextLabel = formatChord(nextChord, onMac); diff --git a/client/src/components/shortcuts-section.tsx b/client/src/components/shortcuts-section.tsx index 46a38729..dc5da8b4 100644 --- a/client/src/components/shortcuts-section.tsx +++ b/client/src/components/shortcuts-section.tsx @@ -22,7 +22,7 @@ import { } from "../../../shared/shortcuts.ts"; /* - * Settings panel for Controller Mode keyboard shortcuts. + * Settings panel for focus-queue keyboard shortcuts. * * Each action shows its current chord and a "Record" button. Clicking * Record waits for the next key chord, then auto-saves it (no separate @@ -181,9 +181,10 @@ function Recorder({ // // We also set the `recordingChord` module flag while recording so the // global App-level listener (registered on window capture too) skips - // its handler — otherwise Ctrl+T / Ctrl+N / Ctrl+D / Ctrl+S would - // actually toggle / navigate / mark-done instead of being captured - // for the new binding (issue #235 P2 review). + // its handler — otherwise Ctrl+N / Ctrl+D / Ctrl+S / Ctrl+T would + // actually navigate / mark-done / cancel / toggle-auto-advance + // instead of being captured for the new binding (issue #235 P2 + // review). useEffect(() => { setRecordingChord(true); return () => setRecordingChord(false); diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index cde5e25f..758daf62 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -16,8 +16,10 @@ import { CheckCircle2, RotateCw, AlertTriangle, + Pause, Play, HelpCircle, + SkipForward, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -83,16 +85,39 @@ interface SidebarProps { onNewWorktree: (projectId: string) => void; onProjectsChanged: () => void; onSettings: () => void; + /** + * The canonical sorted focus queue, owned by App (which overlays + * per-session visit timestamps and runs `sortFocusQueue`). The + * sidebar uses this for rendering; App uses it for navigation + * (`pickNextFocusItem`). The sidebar still emits a *raw* version + * of this via `onFocusQueueChange` so App can apply its visit + * overlay in one place. + */ + focusQueue?: FocusQueueItem[]; onFocusQueueChange?: (queue: FocusQueueItem[]) => void; - controllerMode?: boolean; - onControllerModeToggle?: () => void; /** - * Effective Controller Mode shortcut bindings. Used to render the - * correct chord in the sidebar's Controller Mode button tooltip. - * See issue #235. + * When true (default), replies auto-advance to the next focus-queue + * session after a 4-second countdown. When false, replies stay on + * the current session until the user hits Next or Mark Done + * manually. The Next chord always advances regardless of this + * setting. See issue #333 follow-up. */ - shortcutBindings?: ShortcutBindings | null; + autoAdvance?: boolean; + onToggleAutoAdvance?: () => void; + /** + * Manual "Next" — equivalent to the focus-advance-next chord + * (default ⌃N / Ctrl+N). Surfaced as a button in the radar + * header so the user can click instead of remembering the + * chord. See issue #333 follow-up. + */ + onSkip?: () => void; focusRefreshKey?: number; + /** + * Effective shortcut bindings. Used to render the chord hint on + * the auto-advance toggle button so the visible chip matches the + * key the listener accepts. See issue #235. + */ + shortcutBindings?: ShortcutBindings | null; // Bumped by the App's project-event subscription when an out-of-band // lifecycle change lands (worktree added/removed, session added, // focus state changed, etc.). Triggers a fresh `loadAll` so the @@ -117,6 +142,96 @@ export interface FocusQueueItem { worktreeName: string; session: SessionSummary; active: boolean; + /** + * True when the agent has paused on a user-input request or has + * a pending tool approval. Drives the highest-priority bucket in + * `sortFocusQueue` and the priority preference in + * `pickNextFocusItem`. Independent of `active`: Claude's + * structured-input pause kills the child so the session shows as + * inactive but still owes the user a reply. + */ + awaitingInput?: boolean; + /** + * ISO timestamp of the last time the user landed on this session + * via any navigation (Next, auto-advance, mark-done follow-up, + * sidebar click, conversation link). Drives the + * visited/unvisited split in `sortFocusQueue` — finished sessions + * the user has never opened sit at the top of the finished block + * ("triage pile"), and any visit sinks them below the unvisited + * pile so the user isn't bounced back to them on every cycle. + * Independent of `lastActiveAt`, which still tracks agent + * activity and drives the recently-finished navigation bucket. + */ + lastVisitedAt?: string; +} + +/** + * Order radar (focus-pinned) sessions into priority buckets so the + * most urgent items float to the top: + * + * 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; they sit at + * the very top, oldest-arrival first (`lastActiveAt` asc). + * The `active` flag doesn't matter here — Claude's + * structured-input pause kills the child so a session can be + * inactive and still awaiting. + * 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, ties on array order) 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 (`lastActiveAt` asc). + * + * Within each bucket, ties on the sort key fall back to the + * caller's array order (Array#sort is stable). + * + * Pure: does not mutate the input. + */ +export function sortFocusQueue(items: FocusQueueItem[]): FocusQueueItem[] { + const awaiting = items + .filter((item) => item.awaitingInput) + .sort( + (a, b) => + new Date(a.session.lastActiveAt).getTime() - + new Date(b.session.lastActiveAt).getTime(), + ); + + const finishedUnvisited = items + .filter((item) => !item.awaitingInput && !item.active && !item.lastVisitedAt) + .sort( + (a, b) => + new Date(a.session.lastActiveAt).getTime() - + new Date(b.session.lastActiveAt).getTime(), + ); + + const finishedVisited = items + .filter((item) => !item.awaitingInput && !item.active && item.lastVisitedAt) + .sort( + (a, b) => + new Date(a.lastVisitedAt!).getTime() - + new Date(b.lastVisitedAt!).getTime(), + ); + + const running = items + .filter((item) => !item.awaitingInput && item.active) + .sort( + (a, b) => + new Date(a.session.lastActiveAt).getTime() - + new Date(b.session.lastActiveAt).getTime(), + ); + + return [...awaiting, ...finishedUnvisited, ...finishedVisited, ...running]; } function CodexLogo({ className }: { className?: string }) { @@ -262,9 +377,11 @@ export function Sidebar({ onNewWorktree, onProjectsChanged, onSettings, + focusQueue: focusQueueProp, onFocusQueueChange, - controllerMode = false, - onControllerModeToggle, + autoAdvance = true, + onToggleAutoAdvance, + onSkip, shortcutBindings = null, focusRefreshKey, eventsRefreshKey, @@ -274,6 +391,15 @@ export function Sidebar({ const [activeSessionIds, setActiveSessionIds] = useState>( new Set(), ); + // Sessions whose agent has paused on a user-input request or has + // at least one pending tool approval. The runtime map reports this + // independently of `active` (Claude's structured-input pause kills + // the child process, so the session is `active: false` but still + // needs the user's attention). Surfaced at the very top of the + // focus queue. + const [awaitingInputSessionIds, setAwaitingInputSessionIds] = useState< + Set + >(new Set()); const [visibleSessionCounts, setVisibleSessionCounts] = useState< Record >({}); @@ -327,36 +453,43 @@ export function Sidebar({ } | null>(null); const setupRunCancelRef = useRef<(() => void) | null>(null); - const focusQueue = useMemo(() => { - return projectData - .flatMap((project) => - project.worktrees.flatMap((worktree) => - worktree.sessions - .filter((session) => Boolean(session.focusPinnedAt)) - .map((session) => ({ - projectId: project.id, - projectName: project.name, - worktreeId: worktree.id, - worktreeName: worktree.name, - session, - active: activeSessionIds.has(session.id), - })), - ), - ) - .sort((a, b) => { - const aTime = new Date( - a.session.focusPinnedAt ?? a.session.createdAt, - ).getTime(); - const bTime = new Date( - b.session.focusPinnedAt ?? b.session.createdAt, - ).getTime(); - return aTime - bTime; - }); - }, [activeSessionIds, projectData]); + // Raw focus items: project/worktree/session metadata plus the + // runtime flags (active, awaitingInput). Computed here because + // the sidebar owns the projectData / runtime fetches. App applies + // the visit-timestamp overlay and runs the canonical sort; the + // sorted queue comes back via `focusQueueProp` and is what we + // render. + const rawFocusItems = useMemo(() => { + return projectData.flatMap((project) => + project.worktrees.flatMap((worktree) => + worktree.sessions + .filter((session) => Boolean(session.focusPinnedAt)) + .map((session) => ({ + projectId: project.id, + projectName: project.name, + worktreeId: worktree.id, + worktreeName: worktree.name, + session, + active: activeSessionIds.has(session.id), + awaitingInput: awaitingInputSessionIds.has(session.id) || undefined, + })), + ), + ); + }, [activeSessionIds, awaitingInputSessionIds, projectData]); + + // The canonical, sorted queue lives in App (it has the visit + // overlay + final sort). On the very first paint (before App has + // emitted its first sorted value) fall back to a local sort using + // the raw items so the sidebar has something to render. + const fallbackQueue = useMemo( + () => sortFocusQueue(rawFocusItems), + [rawFocusItems], + ); + const focusQueue = focusQueueProp ?? fallbackQueue; useEffect(() => { - onFocusQueueChange?.(focusQueue); - }, [focusQueue, onFocusQueueChange]); + onFocusQueueChange?.(rawFocusItems); + }, [rawFocusItems, onFocusQueueChange]); const refreshActiveSessions = useCallback(async () => { // One bulk request replaces the previous per-session /runtime polling loop @@ -365,6 +498,13 @@ export function Sidebar({ setActiveSessionIds( new Set(entries.filter((entry) => entry.active).map((entry) => entry.sessionId)), ); + setAwaitingInputSessionIds( + new Set( + entries + .filter((entry) => entry.awaitingInput) + .map((entry) => entry.sessionId), + ), + ); }, []); const loadAll = useCallback(async () => { @@ -757,35 +897,58 @@ export function Sidebar({ return (