diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..14f9b80 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,20 @@ +# The integration suites drive real tmux servers and real git repos, and most +# of their wall time is spent waiting on those subprocesses rather than on the +# CPU. nextest runs test binaries in parallel — which plain `cargo test` does +# not — but at its `num-cpus` default the tmux-backed tests starve each other +# and the tighter `wait_for_async` deadlines (5s) start timing out. Capping the +# concurrency keeps the cross-binary win without the contention. + +[profile.ci] +test-threads = 4 + +# Anything past this is reported as slow while it keeps running, so a genuine +# hang is visible in the log instead of only showing up as a job timeout. +slow-timeout = { period = "30s", terminate-after = 8 } + +failure-output = "immediate-final" +final-status-level = "slow" + +# nextest does not run doctests. There are none today (every ``` fence in the +# crates is `toml` or `text`), so nothing is lost; adding a real doctest means +# adding `cargo test --doc` to CI alongside this. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fda3cdb..4980bc0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -11,6 +11,10 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + jobs: rust: runs-on: ubuntu-latest @@ -30,23 +34,36 @@ jobs: --component rustfmt --component clippy echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Install Node.js + pnpm + # `crates/maestro-web/build.rs` shells out to pnpm whenever + # MAESTRO_WEB_DIST is unset, so the Rust build needs node here too. + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Enable pnpm run: | - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - apt-get install -y --no-install-recommends nodejs corepack enable corepack prepare pnpm@9.15.4 --activate + # Caching all of `target/` under a prefix restore-key let every stale + # artifact a previous run dragged in survive forever, so the entry grew + # without bound and cost ~60s to move. rust-cache keys on the rustc + # version plus the lockfile and prunes target/ to what that lockfile + # actually needs. Only main writes the cache; PRs restore from it and + # skip the save, which also keeps concurrent PRs from thrashing it. - name: Cache cargo - uses: actions/cache@v4 + uses: Swatinem/rust-cache@v2 with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} - restore-keys: | - ${{ runner.os }}-cargo- + save-if: ${{ github.ref == 'refs/heads/main' }} + + # nextest runs test *binaries* in parallel, which plain `cargo test` + # does not — this suite is 20 integration binaries that mostly sit + # waiting on tmux and git. Thread count is capped in + # `.config/nextest.toml`; see the comment there. + - name: Install cargo-nextest + run: | + curl -sSfL https://get.nexte.st/latest/linux \ + | tar zxf - -C "$HOME/.cargo/bin" - name: fmt run: cargo fmt --all -- --check @@ -55,7 +72,7 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: test - run: cargo test --all + run: cargo nextest run --all --profile ci # NAP-271: the umbrella `maestro` binary is feature-gated (cli / daemon / # tui). Verify each subset compiles and its tests pass on its own, and @@ -66,7 +83,8 @@ jobs: for f in cli daemon tui; do echo "::group::maestro --no-default-features --features $f" cargo build -p maestro --no-default-features --features "$f" - cargo test -p maestro --no-default-features --features "$f" + cargo nextest run -p maestro --no-default-features --features "$f" \ + --profile ci echo "::endgroup::" done echo "::group::maestro with no features must fail to compile" @@ -81,12 +99,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Node.js + pnpm + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Enable pnpm run: | - apt-get update - apt-get install -y --no-install-recommends curl ca-certificates - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - apt-get install -y --no-install-recommends nodejs corepack enable corepack prepare pnpm@9.15.4 --activate @@ -110,25 +128,88 @@ jobs: steps: - uses: actions/checkout@v4 + # A cold /nix means every run recompiles the entire release closure — + # every third-party crate under `lto = "thin"` + `codegen-units = 1`, + # which was ~8.5 minutes and the whole pipeline's critical path. With the + # store restored, crane's deps-only derivation is already built and only + # maestro's own crates recompile. + # + # The key is the lockfiles that closure derives from, not the sources: a + # source-only push is an exact hit, which makes actions/cache skip the + # (multi-GB) save. Only the cheap final crates rebuild in that case. + - name: Restore nix store + id: nix-cache + uses: actions/cache/restore@v4 + with: + path: /nix + key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }} + restore-keys: | + nix-${{ runner.os }}- + # The runner container is root with no `nixbld` group, but the installer # writes `build-users-group = nixbld` anyway and then fails on it, so the # config goes in first. Flakes are off by default and `nix build .#…` # needs them. Written again afterwards in case the installer replaced it. + # + # A restored /nix already holds nix itself — the single-user installer + # puts its profile under /nix/var/nix/profiles/per-user/root, which is + # inside the cached path. Only $HOME (the ~/.nix-profile symlink, the + # PATH entry, NIX_SSL_CERT_FILE) lives outside it and has to be redone. - name: Install nix run: | + set -euo pipefail mkdir -p /etc/nix printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf - curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes - printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf + profile=/nix/var/nix/profiles/per-user/root/profile + if [ -x "$profile/bin/nix" ]; then + ln -sfn "$profile" "$HOME/.nix-profile" + else + curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes + printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf + fi echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + # Sourcing the installer's profile script is what normally sets this; + # without it a restored store cannot talk to cache.nixos.org. + if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /etc/ssl/certs/ca-certificates.crt ]; then + echo "NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV" + fi + + # Fail here rather than 8 minutes into a build if a restored store is + # unusable. + - name: Check nix works + run: nix --extra-experimental-features "nix-command flakes" --version # The installer does not enable flakes, so `nix build .#…` needs the # features passed explicitly — a local machine that has them in # nix.conf will build fine while CI fails without this. - - name: build flake packages + # + # This is the step that guards pnpmDeps.hash, and it is cheap, so it runs + # on every event. + - name: build SPA package run: | nix --extra-experimental-features "nix-command flakes" build \ .#webFrontend \ + --no-link + + # The release binaries are a full LTO compile of the workspace. The rust + # job already builds, lints and tests the same sources, so the only extra + # signal here is that the *packaging* works — worth a few minutes on main, + # not on every PR push. Trade-off: a break that is specific to the Nix + # packaging surfaces on main instead of on the PR. + - name: build release packages + if: github.event_name == 'push' + run: | + nix --extra-experimental-features "nix-command flakes" build \ .#maestro \ .#maestro-web \ --no-link + + # Saved only from a push, so a PR that built webFrontend alone can never + # write a store missing the Rust closure and have a later run restore it + # through the prefix key. + - name: Save nix store + if: github.event_name == 'push' && steps.nix-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: /nix + key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'Cargo.lock', 'web/pnpm-lock.yaml', '**/*.nix') }} diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 929bb2a..e60c29a 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -616,7 +616,19 @@ impl App { /// daemon never adopts an existing worktree (docs/WORKTREES.md), same /// rule as web/src/spawnSimilar.ts. fn open_spawn_modal(&mut self) { - let seed_cwd = self.state.active_session().and_then(|sid| { + let mut draft = DraftPane::default(); + draft.seed_cwd = self.focused_seed_cwd(); + self.draft = Some(draft); + self.state.open_spawn_modal(); + } + + /// The project of the session the operator was last on — its base + /// repository for a worktree session, its cwd otherwise. Shared by the + /// plain spawn modal seed and fast-forward's project pick (#67 review): + /// both should land on "where I currently am", not just "whatever's + /// freshest". + fn focused_seed_cwd(&self) -> Option { + self.state.active_session().and_then(|sid| { self.catalog .snapshot() .sessions @@ -626,11 +638,7 @@ impl App { Some(wt) => wt.base_repo.clone(), None => s.cwd.clone(), }) - }); - let mut draft = DraftPane::default(); - draft.seed_cwd = seed_cwd; - self.draft = Some(draft); - self.state.open_spawn_modal(); + }) } /// Close the form and drop its half-filled state — reopening starts clean, @@ -775,9 +783,11 @@ impl App { info, projects, names, + focused_cwd, } = update { - self.fast_forward_spawn(info, projects, names).await; + self.fast_forward_spawn(info, projects, names, focused_cwd) + .await; return; } let want = match &update { @@ -844,6 +854,7 @@ impl App { /// `/api/projects` walks all of `$HOME`, and pressing the key must not /// freeze keys, frames and attached panes until it returns. fn spawn_fast_forward(&self) { + let focused_cwd = self.focused_seed_cwd(); let (daemon, http, base, tx) = self.loader_handles(); tokio::spawn(async move { let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); @@ -852,6 +863,7 @@ impl App { info: info.ok(), projects: projects.ok(), names, + focused_cwd, }); }); } @@ -861,6 +873,7 @@ impl App { info: Option, projects: Option>, names: Vec, + focused_cwd: Option, ) { // Keep the home dir: without it the pre-filled draft renders // unabbreviated paths and "Other directory…" seeds `/` instead of @@ -874,8 +887,13 @@ impl App { _ => Vec::new(), }; let discovered = projects.unwrap_or_default(); - let resolved = - crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); + let resolved = crate::fast_forward::resolve( + &discovered, + &presets, + names.first().cloned(), + Utc::now(), + focused_cwd.as_deref(), + ); if let Some(req) = resolved.build_request() { self.submit_spawn(req).await; return; @@ -1195,6 +1213,7 @@ enum DraftUpdate { info: Option, projects: Option>, names: Vec, + focused_cwd: Option, }, } diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index b799b55..bb78711 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -26,16 +26,25 @@ pub struct FastForwardDefaults { /// Resolve the fast-forward defaults from the daemon's answers. `name` is /// `None` when `/api/names` yielded nothing; `presets` may be empty (the -/// caller falls back to the built-ins it already has). +/// caller falls back to the built-ins it already has). `focused_cwd` is the +/// project of the session the operator was last on ([`crate::app`]'s +/// `focused_seed_cwd`) — when it matches a known project, that project wins +/// over the global recency ranking, so `N` lands where the operator +/// currently is rather than wherever was freshest. pub fn resolve( projects: &[Project], presets: &[SpawnPreset], name: Option, now: DateTime, + focused_cwd: Option<&std::path::Path>, ) -> FastForwardDefaults { - let project = crate::projects::sort_projects(projects.to_vec(), now) - .into_iter() - .next(); + let project = focused_cwd + .and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned()) + .or_else(|| { + crate::projects::sort_projects(projects.to_vec(), now) + .into_iter() + .next() + }); let command = project .as_ref() .and_then(|p| p.last_command.clone()) @@ -155,7 +164,13 @@ mod tests { repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), ]; let presets = vec![preset("aider", "aider"), preset("custom", "")]; - let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now()); + let r = resolve( + &projects, + &presets, + Some("amber-falcon".into()), + Utc::now(), + None, + ); assert_eq!( r.project.as_ref().unwrap().path, PathBuf::from("/repos/fresh") @@ -176,6 +191,46 @@ mod tests { assert!(wt.create_branch, "fresh branch named after the session"); } + #[test] + fn the_focused_sessions_project_wins_over_the_freshest_one() { + let projects = vec![ + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + project("/repos/current", 5, Some("zsh")), + ]; + let presets = vec![preset("claude", "claude")]; + let r = resolve( + &projects, + &presets, + Some("n".into()), + Utc::now(), + Some(std::path::Path::new("/repos/current")), + ); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/current") + ); + assert_eq!(r.command.as_deref(), Some("zsh")); + } + + #[test] + fn an_unknown_focused_cwd_falls_back_to_the_freshest_project() { + let projects = vec![ + project("/repos/old", 30, Some("zsh")), + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + ]; + let r = resolve( + &projects, + &[preset("claude", "claude")], + Some("n".into()), + Utc::now(), + Some(std::path::Path::new("/repos/gone")), + ); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/fresh") + ); + } + #[test] fn worktree_of_the_most_recent_project_targets_its_base_repo() { let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; @@ -184,6 +239,7 @@ mod tests { &[preset("zsh", "zsh")], Some("s".into()), Utc::now(), + None, ); let wt = r.build_request().unwrap().worktree.unwrap(); assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); @@ -193,7 +249,7 @@ mod tests { fn missing_last_command_falls_back_to_the_first_non_custom_preset() { let projects = vec![project("/repos/p", 0, None)]; let presets = vec![preset("custom", ""), preset("claude", "claude")]; - let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None); assert_eq!(r.command.as_deref(), Some("claude")); } @@ -201,7 +257,7 @@ mod tests { fn an_empty_last_command_is_no_command_at_all() { let projects = vec![project("/repos/p", 0, Some(""))]; let presets = vec![preset("custom", "")]; - let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None); assert_eq!(r.command, None); assert!(r.build_request().is_none()); } @@ -213,6 +269,7 @@ mod tests { &[preset("claude", "claude")], Some("n".into()), Utc::now(), + None, ); assert_eq!(r.project, None); // No project → no directory → no request, even with command + name. @@ -222,14 +279,26 @@ mod tests { #[test] fn a_missing_generated_name_downgrades_to_a_draft() { let projects = vec![project("/repos/p", 0, Some("claude"))]; - let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("claude", "claude")], + None, + Utc::now(), + None, + ); assert!(r.build_request().is_none(), "no name → no immediate spawn"); } #[test] fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; - let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("aider", "aider")], + None, + Utc::now(), + None, + ); let mut draft = DraftPane::default(); r.apply_to_draft(&mut draft); @@ -251,6 +320,7 @@ mod tests { &[preset("claude", "claude")], Some("amber-falcon".into()), Utc::now(), + None, ); let mut draft = DraftPane::default(); named.apply_to_draft(&mut draft); @@ -263,7 +333,13 @@ mod tests { #[test] fn an_unresolved_name_still_gets_generated_by_a_later_batch() { let projects = vec![project("/repos/p", 0, Some("claude"))]; - let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("claude", "claude")], + None, + Utc::now(), + None, + ); let mut draft = DraftPane::default(); r.apply_to_draft(&mut draft); draft.seed_names(vec!["late-name".into()]); @@ -273,7 +349,13 @@ mod tests { #[test] fn a_last_command_no_preset_advertises_lands_in_custom_text() { let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; - let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let r = resolve( + &projects, + &[preset("claude", "claude")], + None, + Utc::now(), + None, + ); let mut draft = DraftPane::default(); r.apply_to_draft(&mut draft); assert_eq!(draft.command(), "nvim -d"); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index bc0bd0e..106a161 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -44,6 +44,14 @@ export const FOCUS_PANE_EVENT = "catalog-focus-pane"; * ("Spawn similar"). Detail is the session being cloned. */ export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; +/** A session row was clicked (issue #133). The row's `href` drives normal + * hash navigation, but a split/drag can move focus without ever touching + * `location.hash` — so a click back onto a session whose hash still + * matches fires no `hashchange` and the app never hears about it. This + * event is the click-driven path that doesn't depend on the hash actually + * changing. */ +export const SESSION_SELECT_EVENT = "catalog-session-select"; + /** The rows a keyboard walk stops on, in visual (document) order. Expanded * folder headers are skipped: a directory is not something to focus, and the * cards under it are one row away anyway. A *collapsed* folder still counts — @@ -192,6 +200,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { ev.stopPropagation(); void requestKill(s, name); }); + row.addEventListener("click", () => { + document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id })); + }); row.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts index 9d1d6e5..4366c28 100644 --- a/web/src/fastForward.test.ts +++ b/web/src/fastForward.test.ts @@ -47,6 +47,30 @@ describe("resolveFastForward (#67)", () => { }); }); + it("prefers the focused session's project over the freshest one", () => { + const projects = [ + project({ path: "/repos/fresh", name: "fresh", last_command: "claude" }), + project({ + path: "/repos/current", + name: "current", + last_activity: "2020-01-01T00:00:00Z", + last_command: "zsh", + }), + ]; + const res = resolveFastForward(projects, presets, "n", "/repos/current"); + expect(res.project?.path).toBe("/repos/current"); + expect(res.command).toBe("zsh"); + }); + + it("falls back to the freshest project when the focused cwd is unknown", () => { + const projects = [ + project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }), + project({ path: "/repos/fresh", name: "fresh" }), + ]; + const res = resolveFastForward(projects, presets, "n", "/repos/gone"); + expect(res.project?.path).toBe("/repos/fresh"); + }); + it("falls back to the first non-custom preset when there is no last command", () => { const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); expect(res.command).toBe("claude"); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts index 6a47f44..5f83611 100644 --- a/web/src/fastForward.ts +++ b/web/src/fastForward.ts @@ -13,15 +13,20 @@ export interface FastForwardResolution { name?: string; } -/** The defaults: most recent project (fresh-first, same ordering the picker - * shows), its last command falling back to the first non-custom preset, and - * a freshly generated name when the daemon supplied one. */ +/** The defaults: the currently focused session's project when it's still a + * known project, else the most recent one (fresh-first, same ordering the + * picker shows) — so `N` lands where the operator currently is, matching + * what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command + * falls back to the first non-custom preset, and the name is freshly + * generated when the daemon supplied one. */ export function resolveFastForward( projects: Project[], presets: SpawnPreset[], name?: string, + focusedCwd?: string, ): FastForwardResolution { - const project = sortProjects(projects, new Date())[0]; + const project = + projects.find((p) => p.path === focusedCwd) ?? sortProjects(projects, new Date())[0]; const lastCommand = project?.last_command?.trim(); const command = lastCommand || presets.find((p) => p.command !== "")?.command || undefined; diff --git a/web/src/main.ts b/web/src/main.ts index 23ac046..d9c6f3a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -29,7 +29,12 @@ 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 { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; +import { + FOCUS_PANE_EVENT, + OPEN_SPAWN_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "./catalog.js"; import { initTheme } from "./theme.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { installViewportSizing } from "./viewport.js"; @@ -257,13 +262,28 @@ function requestOpenSpawn(): void { // beside it is the point. // The session you were last on preselects its project: `n` then Enter spawns // another session there without touching the picker. - const focused = mv?.focusedSessionId() ?? null; - const current = focused ? store.snapshot().sessions.get(focused) : undefined; + const current = focusedSession(); openSpawnModal({ seed: current ? spawnSimilarProjectSeed(current) : undefined, onSuccess: (session) => mountSpawned(session, { newTab: true }), }); } + +/** The session the operator was last on, if any. Shared by the plain spawn + * modal seed and fast-forward's project pick: both should land on "where I + * currently am". */ +function focusedSession(): Session | undefined { + const focused = mv?.focusedSessionId() ?? null; + return focused ? store.snapshot().sessions.get(focused) : undefined; +} + +/** The focused session's project — its base repo for a worktree session, its + * cwd otherwise (same rule as spawnSimilarProjectSeed). */ +function focusedProjectCwd(): string | undefined { + const s = focusedSession(); + if (!s) return undefined; + return s.worktree ? s.worktree.base_repo : s.cwd; +} document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { const s = (ev as CustomEvent).detail; @@ -298,7 +318,7 @@ async function requestFastForward(): Promise { } catch { /* keep fallbacks */ } - const resolved = resolveFastForward(discovered, presets, name); + const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd()); const req = buildFastForwardRequest(resolved); if (req) { try { @@ -355,6 +375,20 @@ window.addEventListener("keydown", (e) => { // Escape in the session list hands the keyboard back to the pane. document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal()); +/** Open/focus a session and reflect it in the shell chrome. Shared by the + * hash router and the sidebar's click-driven path (#133), which can't rely + * 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 { + showMultiView(); + mv.openSession(sessionId); + updateShellRoute(); +} + +document.addEventListener(SESSION_SELECT_EVENT, (e) => { + focusSession((e as CustomEvent).detail); +}); + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } @@ -373,9 +407,7 @@ function dispatch(): void { switch (route.kind) { case "attach": // Deep-link: open/focus the session, then show the layout. - showMultiView(); - mv.openSession(route.sessionId); - updateShellRoute(); + focusSession(route.sessionId); return; case "not-found": shellEl.dataset.route = "notfound";