Compare commits

..
6 Commits
Author SHA1 Message Date
Miguel Palhas 2ad2858fd5 Merge remote-tracking branch 'origin/main' into dorian
ci / web (push) Successful in 2m7s
ci / rust (push) Successful in 5m32s
ci / flake (push) Successful in 10m35s
2026-09-02 17:39:39 +01:00
Miguel Palhas fa2a26faa9 Merge remote-tracking branch 'origin/main' into dorian 2026-09-02 17:37:17 +01:00
Miguel Palhas b5feb3c39a fix(maestro): re-focus a sidebar session already open in a split
Sidebar rows only navigated via location.hash + hashchange. Moving
pane focus by split, drag, or direct pane click never touched the
hash, so a later click back onto a session whose row href matched the
stale hash fired no hashchange and was silently ignored — the pane
had to be clicked directly to regain focus.

Row clicks now also dispatch a session-select event handled the same
way as a hash deep-link, independent of whether the hash text changes.
2026-09-02 17:37:15 +01:00
Miguel Palhas e12baa23c6 fix(maestro): fast-forward spawn prefers the focused session's project
ci / flake (push) Successful in 10m50s
ci / web (push) Successful in 1m6s
ci / rust (push) Successful in 5m17s
N picked the globally freshest project by git-mtime, ignoring the session
the operator was actually on. Plain n's spawn form already seeds from the
focused session (spawnSimilarProjectSeed); fast-forward now matches that,
falling back to recency only when nothing is focused.
2026-09-02 17:36:11 +01:00
Miguel Palhas 63191c7be1 Merge remote-tracking branch 'origin/main' into rubato
ci / web (push) Successful in 1m50s
ci / rust (push) Successful in 5m7s
ci / flake (push) Successful in 8m45s
2026-09-02 17:19:26 +01:00
Miguel Palhas acb84b91ef fix(maestro): pin activity rail to left edge, drop active-state pulse
Rail now spans full row height flush to the left edge with square
corners, instead of a depth-shifted inset bar. Active state stays
solid green since colour alone already signals it; blink/flash stay
for awaiting-input/error.
2026-09-02 17:18:00 +01:00
8 changed files with 224 additions and 53 deletions
+28 -9
View File
@@ -608,7 +608,19 @@ impl App {
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same /// daemon never adopts an existing worktree (docs/WORKTREES.md), same
/// rule as web/src/spawnSimilar.ts. /// rule as web/src/spawnSimilar.ts.
fn open_spawn_modal(&mut self) { 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<PathBuf> {
self.state.active_session().and_then(|sid| {
self.catalog self.catalog
.snapshot() .snapshot()
.sessions .sessions
@@ -618,11 +630,7 @@ impl App {
Some(wt) => wt.base_repo.clone(), Some(wt) => wt.base_repo.clone(),
None => s.cwd.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, /// Close the form and drop its half-filled state — reopening starts clean,
@@ -767,9 +775,11 @@ impl App {
info, info,
projects, projects,
names, names,
focused_cwd,
} = update } = update
{ {
self.fast_forward_spawn(info, projects, names).await; self.fast_forward_spawn(info, projects, names, focused_cwd)
.await;
return; return;
} }
let want = match &update { let want = match &update {
@@ -836,6 +846,7 @@ impl App {
/// `/api/projects` walks all of `$HOME`, and pressing the key must not /// `/api/projects` walks all of `$HOME`, and pressing the key must not
/// freeze keys, frames and attached panes until it returns. /// freeze keys, frames and attached panes until it returns.
fn spawn_fast_forward(&self) { fn spawn_fast_forward(&self) {
let focused_cwd = self.focused_seed_cwd();
let (daemon, http, base, tx) = self.loader_handles(); let (daemon, http, base, tx) = self.loader_handles();
tokio::spawn(async move { tokio::spawn(async move {
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
@@ -844,6 +855,7 @@ impl App {
info: info.ok(), info: info.ok(),
projects: projects.ok(), projects: projects.ok(),
names, names,
focused_cwd,
}); });
}); });
} }
@@ -853,6 +865,7 @@ impl App {
info: Option<maestro_protocol::DaemonInfo>, info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>, projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>, names: Vec<String>,
focused_cwd: Option<PathBuf>,
) { ) {
// Keep the home dir: without it the pre-filled draft renders // Keep the home dir: without it the pre-filled draft renders
// unabbreviated paths and "Other directory…" seeds `/` instead of // unabbreviated paths and "Other directory…" seeds `/` instead of
@@ -866,8 +879,13 @@ impl App {
_ => Vec::new(), _ => Vec::new(),
}; };
let discovered = projects.unwrap_or_default(); let discovered = projects.unwrap_or_default();
let resolved = let resolved = crate::fast_forward::resolve(
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); &discovered,
&presets,
names.first().cloned(),
Utc::now(),
focused_cwd.as_deref(),
);
if let Some(req) = resolved.build_request() { if let Some(req) = resolved.build_request() {
self.submit_spawn(req).await; self.submit_spawn(req).await;
return; return;
@@ -1166,6 +1184,7 @@ enum DraftUpdate {
info: Option<maestro_protocol::DaemonInfo>, info: Option<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>, projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>, names: Vec<String>,
focused_cwd: Option<PathBuf>,
}, },
} }
+93 -11
View File
@@ -26,16 +26,25 @@ pub struct FastForwardDefaults {
/// Resolve the fast-forward defaults from the daemon's answers. `name` is /// Resolve the fast-forward defaults from the daemon's answers. `name` is
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the /// `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( pub fn resolve(
projects: &[Project], projects: &[Project],
presets: &[SpawnPreset], presets: &[SpawnPreset],
name: Option<String>, name: Option<String>,
now: DateTime<Utc>, now: DateTime<Utc>,
focused_cwd: Option<&std::path::Path>,
) -> FastForwardDefaults { ) -> FastForwardDefaults {
let project = crate::projects::sort_projects(projects.to_vec(), now) let project = focused_cwd
.into_iter() .and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned())
.next(); .or_else(|| {
crate::projects::sort_projects(projects.to_vec(), now)
.into_iter()
.next()
});
let command = project let command = project
.as_ref() .as_ref()
.and_then(|p| p.last_command.clone()) .and_then(|p| p.last_command.clone())
@@ -155,7 +164,13 @@ mod tests {
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
]; ];
let presets = vec![preset("aider", "aider"), preset("custom", "")]; 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!( assert_eq!(
r.project.as_ref().unwrap().path, r.project.as_ref().unwrap().path,
PathBuf::from("/repos/fresh") PathBuf::from("/repos/fresh")
@@ -176,6 +191,46 @@ mod tests {
assert!(wt.create_branch, "fresh branch named after the session"); 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] #[test]
fn worktree_of_the_most_recent_project_targets_its_base_repo() { fn worktree_of_the_most_recent_project_targets_its_base_repo() {
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
@@ -184,6 +239,7 @@ mod tests {
&[preset("zsh", "zsh")], &[preset("zsh", "zsh")],
Some("s".into()), Some("s".into()),
Utc::now(), Utc::now(),
None,
); );
let wt = r.build_request().unwrap().worktree.unwrap(); let wt = r.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); 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() { fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
let projects = vec![project("/repos/p", 0, None)]; let projects = vec![project("/repos/p", 0, None)];
let presets = vec![preset("custom", ""), preset("claude", "claude")]; 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")); assert_eq!(r.command.as_deref(), Some("claude"));
} }
@@ -201,7 +257,7 @@ mod tests {
fn an_empty_last_command_is_no_command_at_all() { fn an_empty_last_command_is_no_command_at_all() {
let projects = vec![project("/repos/p", 0, Some(""))]; let projects = vec![project("/repos/p", 0, Some(""))];
let presets = vec![preset("custom", "")]; 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_eq!(r.command, None);
assert!(r.build_request().is_none()); assert!(r.build_request().is_none());
} }
@@ -213,6 +269,7 @@ mod tests {
&[preset("claude", "claude")], &[preset("claude", "claude")],
Some("n".into()), Some("n".into()),
Utc::now(), Utc::now(),
None,
); );
assert_eq!(r.project, None); assert_eq!(r.project, None);
// No project → no directory → no request, even with command + name. // No project → no directory → no request, even with command + name.
@@ -222,14 +279,26 @@ mod tests {
#[test] #[test]
fn a_missing_generated_name_downgrades_to_a_draft() { fn a_missing_generated_name_downgrades_to_a_draft() {
let projects = vec![project("/repos/p", 0, Some("claude"))]; 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"); assert!(r.build_request().is_none(), "no name → no immediate spawn");
} }
#[test] #[test]
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; 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(); let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft); r.apply_to_draft(&mut draft);
@@ -251,6 +320,7 @@ mod tests {
&[preset("claude", "claude")], &[preset("claude", "claude")],
Some("amber-falcon".into()), Some("amber-falcon".into()),
Utc::now(), Utc::now(),
None,
); );
let mut draft = DraftPane::default(); let mut draft = DraftPane::default();
named.apply_to_draft(&mut draft); named.apply_to_draft(&mut draft);
@@ -263,7 +333,13 @@ mod tests {
#[test] #[test]
fn an_unresolved_name_still_gets_generated_by_a_later_batch() { fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
let projects = vec![project("/repos/p", 0, Some("claude"))]; 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(); let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft); r.apply_to_draft(&mut draft);
draft.seed_names(vec!["late-name".into()]); draft.seed_names(vec!["late-name".into()]);
@@ -273,7 +349,13 @@ mod tests {
#[test] #[test]
fn a_last_command_no_preset_advertises_lands_in_custom_text() { fn a_last_command_no_preset_advertises_lands_in_custom_text() {
let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; 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(); let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft); r.apply_to_draft(&mut draft);
assert_eq!(draft.command(), "nvim -d"); assert_eq!(draft.command(), "nvim -d");
+9 -7
View File
@@ -51,13 +51,15 @@ command. `<duration>` follows in the rail's colour when shown. There is no
state word and no second badge: the rail exists so the state is still legible state word and no second badge: the rail exists so the state is still legible
when a row is dimmed or the terminal renders the duration in the default fg. when a row is dimmed or the terminal renders the duration in the default fg.
In the **web client** the rail is a 5px bar positioned inside the row's own In the **web client** the rail is a 5px bar pinned to the row's own left
indent gutter — it shifts outward with nesting depth exactly as the gutter edge, square corners, spanning the full card height edge to edge — it
does — spanning the full card height. It carries motion, reserved for states shifts outward with nesting depth along with the row's own padding, since
that need attention (DESIGN.md's "living signals" rule): a slow breathing it is positioned relative to the row. It carries motion only for states that
opacity while `Active`, a hard blink while `AwaitingInput`, a fast flash while need attention (DESIGN.md's "living signals" rule): a hard blink while
`Error`. `Idle` is solid and still, on purpose. `prefers-reduced-motion` `AwaitingInput`, a fast flash while `Error`. `Active` and `Idle` are both
leaves every state as a solid bar. solid and still — colour alone (green vs. the idle grey) is the signal for
`Active`, so it does not also pulse. `prefers-reduced-motion` leaves every
state as a solid bar.
In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's
palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
+17 -8
View File
@@ -44,6 +44,14 @@ export const FOCUS_PANE_EVENT = "catalog-focus-pane";
* ("Spawn similar"). Detail is the session being cloned. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; 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 /** 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 * 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 — * cards under it are one row away anyway. A *collapsed* folder still counts —
@@ -68,13 +76,11 @@ const STATE_WORDS: Record<string, string> = {
error: "Error", error: "Error",
}; };
/** docs/SIDEBAR.md: the activity rail sits inside the row's own indent /** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
* gutter, so it shifts outward with nesting depth exactly as that gutter * full height. The row's own padding (`renderNode`) shifts outward with
* does. `depth * 16 + 8` is the same expression `renderNode` uses for the * nesting depth, carrying the rail with it. */
* row's own padding — the rail sits just inside it. */ function activityRailMarkup(slug: string, stateWord: string): string {
function activityRailMarkup(slug: string, stateWord: string, depth: number): string { return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
const left = depth * 16 + 2;
return `<span class="activity-rail" data-activity="${slug}" style="left:${left}px" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
} }
function metaLine(s: Session, now: number): string { function metaLine(s: Session, now: number): string {
@@ -172,7 +178,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
); );
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = ` row.innerHTML = `
${activityRailMarkup(slug, stateWord, depth)} ${activityRailMarkup(slug, stateWord)}
<span class="card-line1"> <span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span> <span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-actions"> <span class="leaf-actions">
@@ -194,6 +200,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
ev.stopPropagation(); ev.stopPropagation();
void requestKill(s, name); void requestKill(s, name);
}); });
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
});
row.addEventListener("contextmenu", (ev) => { row.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
+24
View File
@@ -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", () => { it("falls back to the first non-custom preset when there is no last command", () => {
const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
expect(res.command).toBe("claude"); expect(res.command).toBe("claude");
+9 -4
View File
@@ -13,15 +13,20 @@ export interface FastForwardResolution {
name?: string; name?: string;
} }
/** The defaults: most recent project (fresh-first, same ordering the picker /** The defaults: the currently focused session's project when it's still a
* shows), its last command falling back to the first non-custom preset, and * known project, else the most recent one (fresh-first, same ordering the
* a freshly generated name when the daemon supplied one. */ * 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( export function resolveFastForward(
projects: Project[], projects: Project[],
presets: SpawnPreset[], presets: SpawnPreset[],
name?: string, name?: string,
focusedCwd?: string,
): FastForwardResolution { ): 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 lastCommand = project?.last_command?.trim();
const command = const command =
lastCommand || presets.find((p) => p.command !== "")?.command || undefined; lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
+39 -7
View File
@@ -29,7 +29,12 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js"; import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.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 { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js"; import { installViewportSizing } from "./viewport.js";
@@ -257,13 +262,28 @@ function requestOpenSpawn(): void {
// beside it is the point. // beside it is the point.
// The session you were last on preselects its project: `n` then Enter spawns // The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker. // another session there without touching the picker.
const focused = mv?.focusedSessionId() ?? null; const current = focusedSession();
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
openSpawnModal({ openSpawnModal({
seed: current ? spawnSimilarProjectSeed(current) : undefined, seed: current ? spawnSimilarProjectSeed(current) : undefined,
onSuccess: (session) => mountSpawned(session, { newTab: true }), 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(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
@@ -298,7 +318,7 @@ async function requestFastForward(): Promise<void> {
} catch { } catch {
/* keep fallbacks */ /* keep fallbacks */
} }
const resolved = resolveFastForward(discovered, presets, name); const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
const req = buildFastForwardRequest(resolved); const req = buildFastForwardRequest(resolved);
if (req) { if (req) {
try { try {
@@ -355,6 +375,20 @@ window.addEventListener("keydown", (e) => {
// Escape in the session list hands the keyboard back to the pane. // Escape in the session list hands the keyboard back to the pane.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal()); 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<string>).detail);
});
type Route = type Route =
| { kind: "catalog" } | { kind: "catalog" }
| { kind: "attach"; sessionId: string } | { kind: "attach"; sessionId: string }
@@ -373,9 +407,7 @@ function dispatch(): void {
switch (route.kind) { switch (route.kind) {
case "attach": case "attach":
// Deep-link: open/focus the session, then show the layout. // Deep-link: open/focus the session, then show the layout.
showMultiView(); focusSession(route.sessionId);
mv.openSession(route.sessionId);
updateShellRoute();
return; return;
case "not-found": case "not-found":
shellEl.dataset.route = "notfound"; shellEl.dataset.route = "notfound";
+5 -7
View File
@@ -1393,23 +1393,21 @@ input.leaf-rename:focus {
color: var(--danger); color: var(--danger);
} }
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It sits /* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It is
inside the row's own indent gutter (`.tree-leaf` is already positioned), pinned to the row's own left edge, full height, square corners — see
at a `left` set inline per nesting depth — see `activityRailMarkup` in
catalog.ts. Idle stays solid and still on purpose: motion is reserved for catalog.ts. Idle stays solid and still on purpose: motion is reserved for
states that need attention (DESIGN.md's "living signals" rule). */ states that need attention (DESIGN.md's "living signals" rule). */
.activity-rail { .activity-rail {
position: absolute; position: absolute;
top: 4px; top: 0;
bottom: 4px; bottom: 0;
left: 0;
width: 5px; width: 5px;
border-radius: 2px;
background: currentColor; background: currentColor;
} }
.activity-rail[data-activity="active"] { .activity-rail[data-activity="active"] {
color: var(--live); color: var(--live);
animation: pulse 2.4s var(--ease-out) infinite;
} }
.activity-rail[data-activity="idle"] { .activity-rail[data-activity="idle"] {
color: var(--border-strong); color: var(--border-strong);