diff --git a/CLAUDE.md b/CLAUDE.md index 74396fa..3aa5054 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,11 @@ or is upgraded under them — the build stamp on `/api/info`, the status indicator's states, the blocking overlay, grace and poll timings. Read it before changing reconnect behaviour on either client. +`docs/COMMAND-BAR.md` is the contract for the `ctrl-k` command bar in both +clients — how it opens, the sigils, the key model, the match-and-rank algorithm +the two matchers must agree on, and the fixture table they are tested against. +Read it before changing either bar or either matcher. + `nix run .#dev` (or `just dev`) brings up the whole local stack under process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index ea25866..0870265 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -87,10 +87,13 @@ pub struct App { /// that can move `Focus::Pane` (attach, tab switch, sidebar toggle, /// mouse) is covered without each one remembering to report it. focused_session: Option, - /// A sidebar highlight waiting out its debounce before it becomes a - /// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must - /// not open an attach per row it passes over. + /// A sidebar highlight waiting for the pane's minimum gap before it + /// becomes a preview (`docs/PREVIEW.md`). Walking the list with `j` held + /// down must not open an attach per row it passes over. pending_preview: Option<(Uuid, std::time::Instant)>, + /// When the pane last swapped what it previews. `None` until the first + /// one, which is why a highlight landing on a still sidebar shows at once. + last_preview_at: Option, /// Sessions attached only because they were previewed, oldest first. Kept /// connected past the preview so stepping back onto a row is instant, and /// bounded so a long walk doesn't leave a socket per session behind. @@ -125,10 +128,11 @@ struct FrameKey { /// else changed. const CLOCK_REFRESH: Duration = Duration::from_secs(1); -/// How long a highlight has to stand still before the pane previews it -/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't -/// attach to every row it crosses, short enough to read as immediate. -const PREVIEW_DELAY: Duration = Duration::from_millis(120); +/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first +/// highlight to land shows at once; this only bounds how often the pane may +/// follow after that, so a fast walk down the list attaches to the row it +/// started on and the row it stopped on rather than every row between. +const PREVIEW_GAP: Duration = Duration::from_millis(120); /// How many preview-only attaches to keep connected behind the current one. const PREVIEW_BUDGET: usize = 3; @@ -213,6 +217,7 @@ impl App { clicks: ClickTracker::default(), focused_session: None, pending_preview: None, + last_preview_at: None, preview_lru: Vec::new(), last_frame: None, last_drawn_at: std::time::Instant::now(), @@ -526,9 +531,10 @@ impl App { } /// Recompute what the pane should be previewing. A session highlighted in - /// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a - /// group row, the pane holding the keyboard, or the highlight already - /// sitting on the attached session — clears the preview at once. + /// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the + /// last swap — immediately, for a highlight landing on a still list. + /// Anything else — a group row, the pane holding the keyboard, or the + /// highlight already sitting on the attached session — clears at once. fn refresh_preview(&mut self) { let wanted = match (self.state.focus, &self.state.sidebar_highlight) { (Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid), @@ -537,7 +543,12 @@ impl App { match wanted { Some(sid) if self.state.active_session() != Some(sid) => { if self.state.preview != Some(sid) { - self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY)); + let now = std::time::Instant::now(); + let due = match self.last_preview_at { + Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP, + _ => now, + }; + self.pending_preview = Some((sid, due)); } } _ => { @@ -547,8 +558,8 @@ impl App { } } - /// The debounce elapsed: show the highlighted session, attaching it if - /// nothing already is. + /// The pane is free to follow: show the highlighted session, attaching it + /// if nothing already is. fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec { let Some((sid, due)) = self.pending_preview else { return Vec::new(); @@ -560,6 +571,7 @@ impl App { if self.state.preview == Some(sid) { return Vec::new(); } + self.last_preview_at = Some(now); self.state.preview = Some(sid); // Set outside the input path, so nothing else marks the frame stale. self.epoch = self.epoch.wrapping_add(1); @@ -1671,24 +1683,22 @@ mod tests { } } - fn after_debounce() -> std::time::Instant { - std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1) + fn after_gap() -> std::time::Instant { + std::time::Instant::now() + PREVIEW_GAP + Duration::from_millis(1) } /// docs/PREVIEW.md: the highlight shows a session in the pane without the /// tab taking it on. #[test] - fn highlighting_a_session_previews_it_once_the_debounce_elapses() { + fn highlighting_a_session_previews_it_without_waiting() { let a = Uuid::from_u128(1); let mut app = app_with_sessions(&[a]); app.dispatch(Action::SidebarNext); - // Nothing yet: the highlight has to stand still first. - assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]); - assert_eq!(app.state.preview, None); - + // The list was still, so the pane follows on the next poll — no gap to + // wait out first (docs/PREVIEW.md). assert_eq!( - app.poll_due_preview_at(after_debounce()), + app.poll_due_preview_at(std::time::Instant::now()), vec![Effect::Attach(a)] ); assert_eq!(app.state.preview, Some(a)); @@ -1697,6 +1707,28 @@ mod tests { assert_eq!(app.state.active_tab().session(), None); } + /// The gap only bounds the *second* swap, so a burst costs two mounts. + #[test] + fn a_second_move_inside_the_gap_waits_for_it() { + let a = Uuid::from_u128(1); + let b = Uuid::from_u128(2); + let mut app = app_with_sessions(&[a, b]); + + app.dispatch(Action::SidebarNext); + let now = std::time::Instant::now(); + assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]); + + app.dispatch(Action::SidebarNext); + assert_eq!(app.poll_due_preview_at(now), vec![]); + assert_eq!(app.state.preview, Some(a)); + + assert_eq!( + app.poll_due_preview_at(after_gap()), + vec![Effect::Attach(b)] + ); + assert_eq!(app.state.preview, Some(b)); + } + /// A fast walk down the list attaches to where it stopped, not to every /// row it crossed. #[test] @@ -1707,7 +1739,7 @@ mod tests { app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext); - let effects = app.poll_due_preview_at(after_debounce()); + let effects = app.poll_due_preview_at(after_gap()); assert_eq!(effects, vec![Effect::Attach(b)]); assert_eq!(app.state.preview, Some(b)); } @@ -1723,7 +1755,7 @@ mod tests { app.state.sidebar_highlight = Some(SidebarItem::Session(b)); app.refresh_preview(); - app.poll_due_preview_at(after_debounce()); + app.poll_due_preview_at(after_gap()); assert_eq!(app.state.preview, Some(b)); app.state.sidebar_highlight = Some(SidebarItem::Session(a)); @@ -1743,7 +1775,7 @@ mod tests { app.state.focus = Focus::Sidebar; app.state.sidebar_highlight = Some(SidebarItem::Session(b)); app.refresh_preview(); - app.poll_due_preview_at(after_debounce()); + app.poll_due_preview_at(after_gap()); app.dispatch(Action::FocusPane(crate::action::Direction::Right)); assert_eq!(app.state.preview, None); @@ -1756,7 +1788,7 @@ mod tests { let a = Uuid::from_u128(1); let mut app = app_with_sessions(&[a]); app.dispatch(Action::SidebarNext); - app.poll_due_preview_at(after_debounce()); + app.poll_due_preview_at(after_gap()); // Stand in for the attach the effect would have opened. app.emulators.insert(a, fake_handle()); @@ -1775,10 +1807,13 @@ mod tests { .map(Uuid::from_u128) .collect(); let mut app = app_with_sessions(&ids); + // Each row is previewed in its own gap window, so all of them land. + let mut clock = std::time::Instant::now(); for id in &ids { app.state.sidebar_highlight = Some(SidebarItem::Session(*id)); app.refresh_preview(); - app.poll_due_preview_at(after_debounce()); + clock += PREVIEW_GAP + Duration::from_millis(1); + app.poll_due_preview_at(clock); app.emulators.insert(*id, fake_handle()); } diff --git a/docs/COMMAND-BAR.md b/docs/COMMAND-BAR.md new file mode 100644 index 0000000..bbf1b5e --- /dev/null +++ b/docs/COMMAND-BAR.md @@ -0,0 +1,479 @@ +# COMMAND-BAR.md — the command bar contract + +One rule for the `ctrl-k` command bar in both clients, so the web bar and the +TUI bar open the same way, rank the same results in the same order, and act on +the same keys. Read this before changing either. Both clients implement exactly +what is written here; if the behaviour needs to change, change it here first. + +The bar is one surface with three jobs: **go to a session**, **run a command**, +**spawn into a directory**. It is not a new place to configure anything and it +does not own any action — it chooses one and hands off to the flow that already +implements it. + +## Opening + +`ctrl-k` opens the bar from anywhere: + +| Client | From | Notes | +| --- | --- | --- | +| TUI | Normal mode | Sidebar has the keyboard. | +| TUI | Insert mode | The pane has the keyboard; the bar still opens. | +| Web | Sidebar focused | Ordinary global binding. | +| Web | Terminal focused | Intercepted before xterm sees it. | + +It is a **keymap entry like any other**, not a hard-wired key. In the TUI it is +bound in both `DEFAULT_NORMAL` and `DEFAULT_INSERT` and is rebindable through +`[tui.keymap.*]`; because the help overlay builds its lines from the live +keymap, it appears there without a second list to maintain. In the web it is an +entry in the command registry, so the `?` overlay lists it for the same reason. + +An attached xterm swallows every keystroke it is given, so the web binding is +handled in the **capture phase**, ahead of the terminal — the same way the +restart overlay swallows the app's shortcuts (`docs/RESTART.md`). + +### Escape hatch — sending a literal Ctrl-K + +Binding `ctrl-k` takes readline's kill-to-end-of-line away from every pane. The +leader gives it back: **leader then `ctrl-k`** — `ctrl-a ctrl-k` by default — +sends the literal `0x0B` byte to the focused pane and does not open the bar. + +The TUI already has a leader (`ctrl-a`, `[tui].leader`), and this chord goes +through the existing chord resolution, so rebinding the leader rebinds the +escape hatch with it — exactly as the leader's Insert-mode escape already +works. **The web gains a leader for this**, defaulting to `ctrl-a` and +mirroring the TUI's. `ctrl-a` is select-all in a browser; the web +`preventDefault`s it, since selecting all of a terminal pane does nothing +useful. + +Only Ctrl-K gets an escape hatch. No other control character is intercepted, so +none of them needs one. + +### Focus round-trip + +Closing the bar returns the keyboard to whatever held it when the bar opened, +the terminal included. A round trip through the bar never silently moves focus, +and never counts as a focus change for the daemon: opening the bar does not +report the pane unfocused, and closing it does not report a new focus. The bar +is a view over the app, not a place in it. + +Opening the bar while a session is previewed (`docs/PREVIEW.md`) discards the +preview first, like any other real change; the bar is not previewed into. + +## Structure + +The bar is a query line over a result list. By default the list is **flat and +mixed** — sessions and commands ranked together — and rendered under **kind +headers**: a header row naming the kind, then that kind's results. + +Grouping is a rendering of the ranking, never a reordering of it: + +- Results are ranked as one list by the algorithm below. +- A kind's group holds its matches in that rank order. +- **Groups are ordered by their best-scoring member.** The first row under the + first header is therefore the globally best result, and the highlight starts + on it. + +Two sigils, typed as the **first character** of the query, narrow the list to +one kind: + +| Sigil | Kind | Candidates | Enter | +| --- | --- | --- | --- | +| `>` | commands | Every entry in the client's command registry — the web registry from the `?` overlay, the TUI's live `(chord, Action)` keymap for the current mode. | Runs the command. | +| `~` | directories | Recent dirs (`GET /api/recent-dirs`) and the project list the spawn form already uses, with `GET /api/dirs` filling in as a path is typed. | Spawns a session there with every default resolved. | + +While a sigil is in force there is one kind, so the kind header is dropped. A +command row shows its **key chord** beside its label, so the bar teaches the +keymap instead of replacing it. A `~` spawn takes the same fast-forward path as +`N` / `FastForwardSpawn`: an unresolvable default opens the pre-filled spawn +form rather than failing silently. + +**Backspacing past a sigil returns to the mixed list.** The sigil is one +character of the query like any other — deleting it is not a special key, it is +the query becoming empty again. + +The sigil is only a sigil in first position. `>` or `~` typed anywhere else is +a literal query character (a directory query is where a `~` is most likely to +be typed twice, and the second one must reach the matcher). + +## Empty query + +With no sigil and an empty query the bar lists **sessions only, most recently +focused first**, so `ctrl-k enter` is "back to where I just was". Commands and +directories need a sigil or a query to appear; an empty bar is a session +switcher. + +**Recency comes from the daemon**, not from client state — the same order in +every client, on every device, including one that just loaded the page. The +daemon stamps a last-focused time per session and ships it on the session like +any other field (#145). Both of the ways focus moves stamp the same field: a +client opening `/api/attach/:id`, and a client reporting focus explicitly at +`POST /api/sessions/:id/focus` when it refocuses a pane that is already open. + +**A session that has never been focused sorts after every session that has**, +and among those, most recently created first. Dead sessions sort after live +ones regardless of recency: a session you cannot attach to is not where you +just were. + +Under `>` an empty query lists every command in registry order. Under `~` it +lists recent dirs, most recent first, then the project list. + +## Keys + +| Key | Action | +| --- | --- | +| `enter` | Open the highlighted result — attach the session, run the command, spawn into the directory. | +| `tab` | Drill into the highlighted **session**'s action page. No effect on any other kind. | +| `esc` | At the top level, close the bar. On an action page, return to the result list. | +| `backspace` | Ordinary editing; at an empty query on an action page, return to the result list. | +| `up` / `down` | Move the highlight. | +| `ctrl-p` / `ctrl-n` | Move the highlight. | + +The highlight does not wrap: it stops at the first and last row. Kind headers +are never landed on — they are labels, not rows. + +Enter on a session goes through the **same open path** the sidebar's click and +the hash router already share, so a bar attach can never leave the layout in a +state a click could not produce. + +### Drill-in — the session action page + +`tab` on a highlighted session opens that session's action page inside the same +overlay: **kill**, **rename**, **spawn similar**. The page is still fuzzy +searchable — the query line keeps working, now over those actions. + +- The overlay names the session being acted on, so the target is never + ambiguous. +- Escape, or backspace at an empty query, returns to the result list **with the + previous query intact**. Drilling in and backing out is free. +- Each action raises the flow it already has: kill goes through the existing + confirm-and-blockers dialog (`docs/WORKTREES.md`), rename through the + existing prompt pre-filled with the session's own name + (`docs/SIDEBAR.md`), spawn similar through the spawn form seeded from the + session's directory and command. The bar picks the action; it never + reimplements one. + +No actions beyond those three, and no action across more than one session. + +## Touch entry + +On the web, **tapping or focusing the sidebar's search field opens the bar** +with an empty query. There is no `ctrl-k` on a phone and the product is +mobile-first, so the field the operator already reaches for is the entry point. + +This **replaces** the sidebar's inline substring filter — one search affordance +in the header, not two beside each other. The tree's "No matches" empty state +belonged to that filter and goes with it; the bar's own no-results state takes +over (see the fixture `F4`). + +At phone width the bar follows the bottom-sheet treatment the spawn modal +already uses below 600px. Visuals otherwise come from `DESIGN.md`'s existing +overlay tokens — the bar is the same family of surface as the spawn modal and +the help overlay, not a new one. + +## What a candidate is + +The matcher is a pure function: `(query, candidates) → ranked subset with +matched ranges`. It knows nothing about sessions, panes or the network. A +candidate is a **kind** plus a set of **named text fields**, each with a fixed +weight. + +| Kind | Field | Weight | Text | +| --- | --- | --- | --- | +| session | `name` | `0` | The name as the sidebar shows it: `metadata.name` if set, else the command line (`docs/SIDEBAR.md`'s fallback). | +| session | `directory` | `-24` | The directory the sidebar groups it under, abbreviated: `~/dev/api`, not `/home/me/dev/api`. | +| session | `command` | `-32` | The full command line, arguments included. | +| session | `tool` | `-40` | The tool id the card's icon stands for (`claude`, `codex`, `opencode`, …), absent when the program is not one we have a mark for. | +| command | `label` | `0` | The plain-language label — the web registry's label, the TUI's `Action::help_label`. | +| directory | `path` | `0` | The path as displayed, abbreviated the same way. | + +A command's **key chord is displayed, not matched**. It is there to teach the +keymap; matching it would make `>` results depend on a user's rebinds, and the +two clients do not share a keymap. + +A candidate **matches** if at least one of its fields matches. Its score is the +**best** `field score + field weight` across its fields, and the matched ranges +it returns are those of that winning field — so a session found through its +directory highlights inside the directory, not inside its name. + +## Match and rank + +Two independent implementations (#146 in TypeScript, #147 in Rust) must produce +the same order for the same input. Everything below is exact. + +### Normalization + +- **Query**: lowercase, then remove every whitespace character. The result is + the *normalized query*. If it is empty, see "Empty query" below. +- **Field text**: matched against its lowercased form. Bonuses are computed + against the **original** text, because case is a signal (`camelCase`) that + lowercasing destroys. +- **Compact form** of a string: lowercase with whitespace removed. Used only + for the exact and prefix tests. + +Lowercasing is per-character simple lowercase (`char::to_lowercase` / +`String.prototype.toLowerCase`). Positions are **character** positions — +Unicode scalar values in Rust, UTF-16 code units in TypeScript, which agree for +every character these fields realistically hold; ranges are returned in the +host language's own unit, since they exist to slice that language's string. + +### What a match is + +Case-insensitive **subsequence**: every character of the normalized query +appears in the lowercased field text, in order, not necessarily adjacent. A +field with no such embedding does not match. + +### Word boundaries + +A matched character at position `p` earns exactly one character bonus, taking +the first rule that applies: + +| Rule | Bonus | +| --- | --- | +| `p == 0` — first character of the field | `+18` | +| The character before it is a **separator**: `/` `\` `-` `_` `.` `:` or a space | `+16` | +| It is uppercase and the character before it is lowercase (a camelCase hump) | `+12` | +| It is a digit and the character before it is not | `+12` | +| Otherwise | `0` | + +That is one rule for both shapes the doc has to cover. In a **path**, `/` is a +separator, so the first character of every segment is a word start: `foo` in +`~/dev/foobar` starts a segment and outscores `foo` in `~/src/myfoo`, which +starts nothing. In a **session name**, `-`, `_`, `.` and spaces do the same +work, so `ws` reaches `web-search` more cheaply than `websearch`. + +### Score + +Let the query characters be matched at positions `p₀ < p₁ < … < pₘ₋₁`, and let +`p₋₁ = -1`. For each matched character `i`, with `gap = pᵢ - pᵢ₋₁ - 1`: + +``` +contribution(i) = 16 // every matched character + + bonus(pᵢ) // the table above + + (gap == 0 ? +8 // adjacent to the previous match + : -min(gap, 12)) +``` + +`gap` for `i = 0` is the number of characters skipped before the first match, so +a match that starts late is penalised exactly like an internal gap, and the cap +is the same one. + +The **field score** is the sum of the contributions, plus at most one whole-field +adjustment: + +- compact field text **equals** the normalized query → `+64` +- else compact field text **starts with** the normalized query → `+32` + +Length is deliberately not in the score — it is a tie-break, so a long field +never loses to a short one that matched worse. + +### Which embedding + +A query can sit in a field many ways. The one that counts is the +**highest-scoring** embedding; among embeddings that score the same, the +**leftmost** — compare position vectors element by element, smaller wins. + +Greedy leftmost-first matching is not sufficient and must not be used: in +`maestro-tui`, `mtu` greedily takes `m₀ a₁ …` and never reaches `tui`. Both +implementations use the obvious dynamic program over +`(query index, text position)`, carrying the best score and its position +vector, since the gap term depends only on the previous matched position. + +The whole-field adjustment is constant for a `(query, field)` pair, so adding it +after the dynamic program cannot change which embedding wins. + +### Ordering + +Sort by score, highest first. Equal scores break in this order: + +1. **Kind** — `session`, then `command`, then `directory`. +2. **Length of the matched field text**, shorter first. +3. **The matched field text**, ascending, compared by code point, case + sensitive. +4. **Input order.** The sort is stable, so candidates the caller ordered + deliberately — sessions most recently focused first, commands in registry + order — keep that order when nothing else separates them. + +Kind leads because that is the ruling the ambiguous cases need: a session named +`kill` and the `kill` command that score alike put the session first, because +the operator who typed a session's name meant the session. + +### Empty query + +A normalized query that is empty **matches every candidate**, each with score +`0` and no ranges, in input order. The bar's own rules decide what is in the +candidate list at that point (sessions only at the top level); the matcher does +not filter by kind. + +## Fixtures + +These are the contract. Both matchers are tested against exactly these cases; +the prose above is the explanation of why they come out this way. + +Candidates are written as `kind(fields)`; unlisted fields are absent. +`session(name, dir, command, tool)`. Scores are shown for diagnosis — **the +order is what is being asserted**, and an implementation that ranks correctly +with different absolute numbers has still failed, because the numbers are how +the next fixture stays predictable. + +### F1 — empty query keeps input order + +``` +query: "" +candidates: session("kill", "~/dev/api", "bash") + session("killer-app", "~/dev/killer-app", "claude --resume", "claude") + command("Kill selected session") +expected: session("kill") score 0 + session("killer-app") score 0 + command("Kill selected session") score 0 +``` + +Every candidate matches, in the order given. No ranges. + +### F2 — a session named `kill` against the `kill` command + +``` +query: "kill" +candidates: (as F1) +expected: session("kill") 178 name + session("killer-app") 146 name + command("Kill selected session") 146 label +``` + +`kill` wins on the exact bonus. The other two **tie at 146** — both are a +contiguous prefix match — and the kind tie-break puts the session first. + +### F3 — a fragment matching mid-segment inside a path + +``` +query: "foo" +candidates: directory("~/dev/foobar") + directory("~/src/myfoo") + directory("~/foo") +expected: directory("~/foo") 78 ranges [2,3,4] + directory("~/dev/foobar") 74 ranges [6,7,8] + directory("~/src/myfoo") 56 ranges [8,9,10] +``` + +The first two start a path segment; the third is mid-segment and loses by the +whole boundary bonus. `~/foo` beats `~/dev/foobar` only because less was +skipped before the match. + +### F4 — a query matching nothing + +``` +query: "zzz" +candidates: session("kill", "~/dev/api", "bash") + command("Kill selected session") +expected: (empty) +``` + +The matcher returns an empty list, never a list of zero-scored candidates. The +bar renders its no-results state. + +### F5 — a non-contiguous subsequence + +``` +query: "mtu" +candidates: session("maestro-tui") + session("maestro-daemon") + session("mtui-scratch") +expected: session("mtui-scratch") 122 ranges [0,1,2] + session("maestro-tui") 91 ranges [0,8,9] +``` + +`maestro-daemon` has no `u` after its `t` and does not match. `maestro-tui` +does, across a gap — which greedy matching would miss entirely. + +### F6 — a name beats a directory + +``` +query: "api" +candidates: session("api", "~/dev/web", "bash") + session("worker", "~/dev/api", "bash") +expected: session("api") 154 name + session("worker") 50 directory +``` + +The second session is found through its directory, and highlights there. + +### F7 — case-insensitive, tie broken by length + +``` +query: "api" +candidates: session("API-Gateway") + session("apiary") +expected: session("apiary") 122 name + session("API-Gateway") 122 name +``` + +Both are prefix matches and score identically. Same kind, so length decides: +6 characters before 11. Input order does not save `API-Gateway`. + +### F8 — the tool field + +``` +query: "claude" +candidates: session("nameless", "~/dev/x", "claude --resume", "claude") + session("clay", "~/dev/y", "bash") +expected: session("nameless") 186 tool +``` + +`clay` has no `claude` subsequence anywhere and drops out. `nameless` matches +its `tool` exactly (`+64`, weight `-40`, giving 186) which beats the prefix +match on its `command` field (`+32`, weight `-32`, giving 162). + +### F9 — a word boundary inside a session name + +``` +query: "ws" +candidates: session("websearch") + session("web-search") +expected: session("web-search") 71 ranges [0,4] + session("websearch") 56 ranges [0,3] +``` + +The `s` after `-` earns the boundary bonus; the `s` inside `websearch` earns +nothing. + +### F10 — command labels + +``` +query: "se" +candidates: command("Kill selected session") + command("Create a session") + command("Show help") +expected: command("Show help") 53 ranges [0,6] + command("Kill selected session") 51 ranges [5,6] + command("Create a session") 47 ranges [9,10] +``` + +`Show help` starts at position 0; the other two start at a word boundary but +later, and pay for the skipped characters. + +### F11 — a query against session directories + +``` +query: "dev" +candidates: session("api", "~/dev/api", "bash") + session("notes", "~/documents/dev-notes", "nvim") + session("scratch", "~/srv/devops", "bash") +expected: session("api") 54 directory, ranges [2,3,4] + session("scratch") 50 directory, ranges [6,7,8] + session("notes") 44 directory, ranges [12,13,14] +``` + +All three start a path segment. They separate purely on how much was skipped +first, and `notes` hits the `-12` gap cap. + +### F12 — exact beats prefix + +``` +query: "quit" +candidates: command("Quit Maestro") + command("Quit") +expected: command("Quit") 178 label + command("Quit Maestro") 146 label +``` + +The 32-point difference between the exact and prefix adjustments is the whole +gap; nothing else about the two matches differs. diff --git a/docs/PREVIEW.md b/docs/PREVIEW.md index 3bbb068..79e2de7 100644 --- a/docs/PREVIEW.md +++ b/docs/PREVIEW.md @@ -77,15 +77,19 @@ Nothing moves a second time on commit. ## Timing -A preview follows the highlight closely enough to be usable as a way to look -around, and lags a pointer enough not to flash rows crossed on the way -somewhere else. +**A preview is never delayed.** Landing on a row — by pointer or by `j`/`k` — +shows it on the next frame, and so does leaving every row. There is no +settling period in front of the first preview, because that period would sit +in front of the one preview the operator actually meant. -| Trigger | Delay | -|---|---| -| Keyboard step (`j`/`k`, arrows) | 60 ms | -| Pointer hover (web, mouse only — never touch) | 150 ms | -| Clearing (highlight left every session) | same delay as the trigger that set it | +What is bounded is how *often* the pane may swap: at most once per 120 ms. +A burst inside one window — `j` held down, or the pointer sweeping the length +of the sidebar — shows the row it started on, then the row it ended on, and +none of the rows in between. So a walk costs a couple of pane mounts rather +than one per row, without the operator ever waiting on one. + +The highlight itself is not bounded by any of this: the row lights up on the +same frame as the input, whatever the pane behind it is doing. Touch has no hover, and a tap already opens a session, so touch never previews. diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 5ce5453..8473a67 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -164,6 +164,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { return wrap; } + // The row outlives the snapshot it was built from: a refresh that changes + // nothing structural patches it in place rather than replacing it, so the + // handlers below have to read the current session, not the one captured here. + let cur = node.session; const s = node.session; const now = Date.now(); const row = document.createElement("a"); @@ -197,33 +201,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); - document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })); + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); - void requestKill(s, name); + void requestKill(cur, sessionName(cur)); }); row.addEventListener("click", () => { - document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id })); + document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id })); }); row.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); openContextMenu(ev.clientX, ev.clientY, [ - { label: "Rename…", onSelect: () => beginRename(row, s) }, + { label: "Rename…", onSelect: () => beginRename(row, cur) }, { label: "Spawn similar", onSelect: () => - document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })), + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })), + }, + { + label: "Kill session", + danger: true, + onSelect: () => void requestKill(cur, sessionName(cur)), }, - { label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) }, ]); }); + leafPatches.set(row, (next) => { + cur = next; + const nextSlug = activityColorSlug(next.activity ?? "Idle"); + const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle; + row.setAttribute( + "aria-label", + `${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`, + ); + const rail = row.querySelector(".activity-rail"); + if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord); + const meta = row.querySelector(".card-line2"); + if (meta) meta.outerHTML = metaLine(next, Date.now()); + }); return row; } +/** Refresh a standing card from a newer snapshot of its session, keeping the + * element (and so the pointer's `:hover`) exactly where it is. Registered by + * `renderNode`; only the bits `treeSignature` leaves out are rewritten. */ +const leafPatches = new WeakMap void>(); + +/** + * Everything about the tree that a patch cannot fix up. + * + * The daemon re-sends a session on every activity, foreground or cwd shift — + * a couple of times a minute per live session — and rebuilding the sidebar for + * each one drops `:hover` from the row under the pointer. Refreshes whose + * signature is unchanged are patched into the standing rows instead; anything + * that moves, renames, appears or dies changes the signature and rebuilds. + */ +function treeSignature(node: TreeNode): string { + if (node.kind === "folder") { + const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`; + if (node.name && collapsed.has(node.path)) return head; + return `${head}(${node.children.map(treeSignature).join(",")})`; + } + const s = node.session; + return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`; +} + /** * Kill on the first click (#81): the daemon is the one that decides whether a * session may go, and it refuses over a worktree holding unsaved work. Only @@ -371,7 +416,7 @@ export interface SidebarOptions { * shell shows it in the content pane without opening it (docs/PREVIEW.md). * `source` is there so a mouse sweep can be debounced differently from a * keyboard step. */ - onPreview?: (sessionId: string | null, source: "key" | "hover") => void; + onPreview?: (sessionId: string | null) => void; } export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle { @@ -555,7 +600,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S navKey = next.dataset.navKey ?? null; applyNav(); next.scrollIntoView({ block: "nearest" }); - opts.onPreview?.(sessionOf(navKey), "key"); + opts.onPreview?.(sessionOf(navKey)); }; /** Enter: open the highlighted session, or expand the highlighted folder. */ @@ -586,7 +631,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S const setHovered = (id: string | null) => { if (hovered === id) return; hovered = id; - opts.onPreview?.(id, "hover"); + opts.onPreview?.(id); }; treeHost.addEventListener("pointerover", (e) => { if (e.pointerType === "touch") return; @@ -603,7 +648,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S treeHost.tabIndex = -1; // The keyboard walk's preview belongs to the list having the keyboard: once // it doesn't, the content pane goes back to what the operator actually opened. - treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key")); + treeHost.addEventListener("blur", () => opts.onPreview?.(null)); /** The session behind the highlighted row, if the highlight is on a card. */ const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => { @@ -643,12 +688,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S e.preventDefault(); }); + /** Signature of what is currently on screen — see `treeSignature`. */ + let renderedSignature: string | null = null; + const refresh = () => { // Rebuilding the tree now would blow away the field being typed into. if (renamingId !== null) return; const state = store.snapshot(); const sessions = [...state.sessions.values()]; const tree = buildTree(sessions, homeDir, filter); + const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree); + if (signature === renderedSignature) { + for (const row of treeHost.querySelectorAll(".tree-leaf")) { + const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined; + if (s) leafPatches.get(row)?.(s); + } + return; + } + renderedSignature = signature; treeHost.replaceChildren(); if (sessions.length === 0) { treeHost.appendChild( @@ -672,7 +729,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S /* absolute paths are a fine fallback */ }); - const rerenderHandler = () => refresh(); + // Dispatched when something outside the store changed what the tree should + // look like — a folder collapsed, a rename ended — none of which the store + // snapshot can show, so the signature check has to be bypassed. + const rerenderHandler = () => { + renderedSignature = null; + refresh(); + }; document.addEventListener("catalog-rerender", rerenderHandler); // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the diff --git a/web/src/catalogPatch.test.ts b/web/src/catalogPatch.test.ts new file mode 100644 index 0000000..453b3c9 --- /dev/null +++ b/web/src/catalogPatch.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom +import { beforeAll, describe, expect, it } from "vitest"; + +import { renderSidebar } from "./catalog.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { Session } from "./types.js"; + +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); + window.fetch = () => Promise.reject(new Error("offline")); +}); + +const ID = "11111111-2222-3333-4444-555555555555"; + +function session(overrides: Partial = {}): Session { + return { + id: ID, + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + ...overrides, + }; +} + +/** A store whose updates actually reach subscribers, unlike the one-shot + * harness in catalog.test.ts. */ +function liveStore(initial: Session[]) { + const state = { sessions: new Map(initial.map((s) => [s.id, s])) }; + const listeners = new Set(); + const store = { + snapshot: () => state, + subscribe: (fn: CatalogListener) => { + listeners.add(fn); + fn(state); + return () => listeners.delete(fn); + }, + } as unknown as CatalogStore; + const push = (sessions: Session[]) => { + state.sessions = new Map(sessions.map((s) => [s.id, s])); + for (const fn of listeners) fn(state); + }; + return { store, push }; +} + +const rowOf = (el: HTMLElement) => el.querySelector(".tree-leaf")!; + +describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => { + it("patches an activity change into the standing row instead of rebuilding it", () => { + const { store, push } = liveStore([session({ activity: "Idle" })]); + const { el } = renderSidebar(store, {}); + const before = rowOf(el); + + push([session({ activity: "AwaitingInput" })]); + + // Same element: a rebuild under the pointer would drop `:hover` and restart + // the row's transitions, which is the flicker this avoids. + expect(rowOf(el)).toBe(before); + // …and the card still tells the truth about the session. + expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe( + "awaiting-input", + ); + expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe( + "awaiting-input", + ); + expect(before.getAttribute("aria-label")).toContain("Awaiting input"); + }); + + it("rebuilds when the row set changes", () => { + const { store, push } = liveStore([session()]); + const { el } = renderSidebar(store, {}); + const before = rowOf(el); + + push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]); + + expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2); + expect(rowOf(el)).not.toBe(before); + }); + + it("rebuilds when a session dies", () => { + const { store, push } = liveStore([session()]); + const { el } = renderSidebar(store, {}); + const before = rowOf(el); + + push([session({ status: "Dead" })]); + + expect(rowOf(el)).not.toBe(before); + expect(rowOf(el).dataset.status).toBe("dead"); + }); + + it("rebuilds when a session is renamed", () => { + const { store, push } = liveStore([session()]); + const { el } = renderSidebar(store, {}); + const before = rowOf(el); + + push([session({ metadata: { name: "harmony" } })]); + + expect(rowOf(el)).not.toBe(before); + expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony"); + }); +}); diff --git a/web/src/main.ts b/web/src/main.ts index 4cc30b2..648710b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -29,6 +29,7 @@ import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; +import { createPreviewSchedule } from "./previewSchedule.js"; import { FOCUS_PANE_EVENT, FOCUS_SIDEBAR_EVENT, @@ -105,20 +106,14 @@ shellEl.dataset.route = "catalog"; // point `mv` is assigned. let mv: MultiView; -// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a -// mouse sweep waits long enough that rows crossed on the way somewhere else -// never flash up. The same delay applies to clearing, so the gap between two -// rows doesn't blink the previous view back. -const PREVIEW_DELAY = { key: 60, hover: 150 } as const; -let previewTimer: number | undefined; -function schedulePreview(sessionId: string | null, source: "key" | "hover"): void { - clearTimeout(previewTimer); - previewTimer = window.setTimeout(() => { - ensureMultiView(); - mv?.previewSession(sessionId); - updateShellRoute(); - }, PREVIEW_DELAY[source]); -} +// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst +// — j held down, or the pointer sweeping the whole list — is coalesced so it +// costs a couple of pane mounts instead of one per row walked. +const schedulePreview = createPreviewSchedule((sessionId) => { + ensureMultiView(); + mv?.previewSession(sessionId); + updateShellRoute(); +}); const sidebar = renderSidebar(store, { onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e), @@ -418,7 +413,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => { * on `hashchange` alone — a split or drag can move focus without the hash * changing, leaving a click back onto that session's row silently ignored. */ function focusSession(sessionId: string): void { - clearTimeout(previewTimer); + schedulePreview.cancel(); showMultiView(); mv.openSession(sessionId); updateShellRoute(); diff --git a/web/src/previewSchedule.test.ts b/web/src/previewSchedule.test.ts new file mode 100644 index 0000000..451e97b --- /dev/null +++ b/web/src/previewSchedule.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js"; + +/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */ +function harness() { + let now = 1_000; + const timers = new Map void }>(); + let nextHandle = 1; + const emitted: (string | null)[] = []; + + const schedule = createPreviewSchedule((id) => emitted.push(id), { + now: () => now, + setTimer: (fn, ms) => { + const h = nextHandle++; + timers.set(h, { at: now + ms, fn }); + return h; + }, + clearTimer: (h) => { + timers.delete(h); + }, + }); + + /** Advance the clock, firing any timer that comes due. */ + const advance = (ms: number) => { + now += ms; + for (const [h, t] of [...timers]) { + if (t.at <= now) { + timers.delete(h); + t.fn(); + } + } + }; + + return { schedule, emitted, advance }; +} + +describe("preview schedule", () => { + it("shows the first preview immediately", () => { + const { schedule, emitted } = harness(); + schedule("s1"); + expect(emitted).toEqual(["s1"]); + }); + + it("coalesces a burst into one trailing preview", () => { + const { schedule, emitted, advance } = harness(); + // A pointer sweep across the list, or j held down: every row in between + // is walked well inside one gap. + schedule("s1"); + schedule("s2"); + schedule("s3"); + expect(emitted).toEqual(["s1"]); + advance(PREVIEW_MIN_GAP_MS); + expect(emitted).toEqual(["s1", "s3"]); + }); + + it("shows the next preview immediately once the gap has passed", () => { + const { schedule, emitted, advance } = harness(); + schedule("s1"); + advance(PREVIEW_MIN_GAP_MS); + schedule("s2"); + expect(emitted).toEqual(["s1", "s2"]); + }); + + it("drops a superseded trailing preview rather than replaying it", () => { + const { schedule, emitted, advance } = harness(); + schedule("s1"); + schedule("s2"); + schedule("s1"); // back where we started inside the same gap + advance(PREVIEW_MIN_GAP_MS); + // s1 is already on screen, so the trailing edge has nothing to do. + expect(emitted).toEqual(["s1"]); + }); + + it("clears through the same gap, so leaving and re-entering does not blink", () => { + const { schedule, emitted, advance } = harness(); + schedule("s1"); + schedule(null); // pointer crosses the gap between two rows + schedule("s2"); + advance(PREVIEW_MIN_GAP_MS); + expect(emitted).toEqual(["s1", "s2"]); + }); + + it("cancel drops a pending trailing preview", () => { + const { schedule, emitted, advance } = harness(); + schedule("s1"); + schedule("s2"); + schedule.cancel(); + advance(PREVIEW_MIN_GAP_MS); + expect(emitted).toEqual(["s1"]); + }); +}); diff --git a/web/src/previewSchedule.ts b/web/src/previewSchedule.ts new file mode 100644 index 0000000..8130aa4 --- /dev/null +++ b/web/src/previewSchedule.ts @@ -0,0 +1,87 @@ +/** + * When the content pane follows the sidebar highlight (docs/PREVIEW.md). + * + * The highlight itself is never scheduled — that is a class on a row and lands + * on the same frame as the input. This governs only the pane swap behind it, + * which mounts a terminal and, for a session the connection pool isn't already + * holding, opens an attach socket the daemon answers with a `tmux capture-pane`. + * + * A plain debounce would put that cost in front of every preview, including the + * one preview the operator actually meant. Instead the first move shows at once + * and the rest of a burst is coalesced: walking a list with `j` held down, or + * sweeping the pointer from the top of the sidebar to the bottom, costs one + * preview at the start and one at the row you stopped on, not one per row. + */ + +/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a + * couple of terminals rather than one per row; short enough that a deliberate + * second move never waits on it. */ +export const PREVIEW_MIN_GAP_MS = 120; + +export interface PreviewScheduleDeps { + now?: () => number; + setTimer?: (fn: () => void, ms: number) => number; + clearTimer?: (handle: number) => void; + /** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */ + minGapMs?: number; +} + +export interface PreviewSchedule { + (sessionId: string | null): void; + /** Drop a pending trailing preview without showing it. */ + cancel(): void; +} + +/** + * Wrap `show` so it fires on the leading edge and at most once per gap. + * + * `show` is called with the session to preview, or `null` to put back whatever + * the operator actually opened. + */ +export function createPreviewSchedule( + show: (sessionId: string | null) => void, + deps: PreviewScheduleDeps = {}, +): PreviewSchedule { + const now = deps.now ?? (() => Date.now()); + const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms)); + const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h)); + const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS; + + let lastShownAt = Number.NEGATIVE_INFINITY; + let shown: string | null | undefined; + let pendingTimer: number | undefined; + let pendingId: string | null = null; + + const emit = (id: string | null) => { + // Nothing to do when the burst ended on the view already up: re-showing it + // would tear down and re-mount the pane for no visible change. + if (shown === id) return; + shown = id; + lastShownAt = now(); + show(id); + }; + + const cancelPending = () => { + if (pendingTimer === undefined) return; + clearTimer(pendingTimer); + pendingTimer = undefined; + }; + + const schedule = ((sessionId: string | null) => { + pendingId = sessionId; + const waited = now() - lastShownAt; + if (waited >= minGap) { + cancelPending(); + emit(sessionId); + return; + } + if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins + pendingTimer = setTimer(() => { + pendingTimer = undefined; + emit(pendingId); + }, minGap - waited); + }) as PreviewSchedule; + + schedule.cancel = cancelPending; + return schedule; +} diff --git a/web/src/sidebarNav.test.ts b/web/src/sidebarNav.test.ts index 3bdd34d..46cb1d9 100644 --- a/web/src/sidebarNav.test.ts +++ b/web/src/sidebarNav.test.ts @@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => { describe("sidebar preview requests (docs/PREVIEW.md)", () => { it("asks for a preview on every keyboard step, and for none once the list blurs", () => { - const seen: [string | null, string][] = []; - const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) }); + const seen: (string | null)[] = []; + const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); press(tree(el), "j"); press(tree(el), "j"); - expect(seen).toEqual([ - [A, "key"], - [B, "key"], - ]); + expect(seen).toEqual([A, B]); tree(el).dispatchEvent(new FocusEvent("blur")); - expect(seen.at(-1)).toEqual([null, "key"]); + expect(seen.at(-1)).toBe(null); }); it("asks for a preview of the hovered card, and clears it on the way out", () => { - const seen: [string | null, string][] = []; - const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) }); + const seen: (string | null)[] = []; + const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); const card = el.querySelectorAll(".tree-leaf")[0]; card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); - expect(seen).toEqual([[A, "hover"]]); + expect(seen).toEqual([A]); // Moving within the same card is not a new preview. card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); expect(seen).toHaveLength(1); tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" })); - expect(seen.at(-1)).toEqual([null, "hover"]); + expect(seen.at(-1)).toBe(null); }); it("ignores touch, where a tap already opens the session", () => { diff --git a/web/src/styles.css b/web/src/styles.css index e8a81d7..0646d49 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1091,7 +1091,6 @@ textarea { cursor: pointer; font-family: var(--font-mono); font-size: var(--fs-12); - transition: color var(--dur-fast) var(--ease-out); } .tree-folder-header:hover { @@ -1103,9 +1102,9 @@ textarea { text-align: center; color: var(--faint); font-size: var(--fs-11); - transition: - transform var(--dur) var(--ease-out), - color var(--dur-fast) var(--ease-out); + /* The rotation is the collapse animation and stays; the hover colour is + immediate, like the row's own hover. */ + transition: transform var(--dur) var(--ease-out); } .tree-folder-header[aria-expanded="false"] .chevron { @@ -1142,9 +1141,11 @@ textarea { border-radius: var(--r-1); color: var(--ink); position: relative; - transition: - background var(--dur-fast) var(--ease-out), - box-shadow var(--dur-fast) var(--ease-out); + /* No transition on `background`: it is what hover paints, and easing it puts + a visible delay between the pointer landing on a row and the row admitting + it. The ring (`.is-highlighted`, `.is-active`) still eases — that one + follows a keyboard walk, not the pointer. */ + transition: box-shadow var(--dur-fast) var(--ease-out); } /* "Alive" reads as a faint wash on the row itself — no per-row status icon. */