From ba74d3515b04306f7c7b86629c5453097d8cf36c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 08:15:35 +0100 Subject: [PATCH 1/2] fix(maestro): keep sidebar accent on the picked session while previewing A preview moved the active tab's focus, and the sidebar took that as the focused session, so hovering another row dropped the accent from the row the operator had actually opened. Report the pre-preview focus instead. --- docs/PREVIEW.md | 1 + web/src/multiview.ts | 20 +++++++++++++++++--- web/src/preview.test.ts | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/PREVIEW.md b/docs/PREVIEW.md index 5424960..bf98430 100644 --- a/docs/PREVIEW.md +++ b/docs/PREVIEW.md @@ -47,6 +47,7 @@ Web client (`--preview` and friends in `web/src/styles.css`): | The canvas | A `preview` chip, top right, and a dashed inset ring. | | A throwaway tab | Dashed border, italic label. | | The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. | +| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. | | A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. | Rows say it with fill, not with an edge — see the tier ladder in diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 903f6ff..aad876e 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -215,6 +215,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView { return fl?.sessionId ?? null; } + /** The session the operator actually picked — what `focusedLeafSession()` + * would return with the preview undone. A preview moves the active tab and + * its focus, but it is a view: the accent stays on the row the operator + * chose until they choose another (docs/PREVIEW.md). */ + function realFocusedSession(): string | null { + const p = preview; + if (!p) return focusedLeafSession(); + const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout); + const leafId = + p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId; + const fl = leafId ? findLeaf(tab.root, leafId) : null; + return fl?.sessionId ?? null; + } + /** Update focus styling + pool focus + sidebar highlights without rebuilding * the DOM (cheap path for click-to-focus). */ function syncFocus(): void { @@ -233,7 +247,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (!preview) pool.setFocus(focusedLeafSession()); sidebar.setHighlights({ open: realOpenSessions(), - focused: focusedLeafSession(), + focused: realFocusedSession(), }); // The row the preview came from wears the same colour, so the sidebar and // the pane read as one thing rather than two unrelated highlights. @@ -608,7 +622,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { { focus: !preview && !quietRender }, ); if (!preview) pool.setFocus(focused.sessionId ?? null); - sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null }); + sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() }); sidebar.setPreview(preview?.sessionId ?? null); } @@ -817,7 +831,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { openSessions: realOpenSessions, beginSessionDrag, render, - focusedSessionId: focusedLeafSession, + focusedSessionId: realFocusedSession, previewSession, previewedSessionId: () => preview?.sessionId ?? null, }; diff --git a/web/src/preview.test.ts b/web/src/preview.test.ts index cadc4b9..768a74a 100644 --- a/web/src/preview.test.ts +++ b/web/src/preview.test.ts @@ -47,12 +47,20 @@ function fakePool(): Spy { /** Last value the controller pushed to `SidebarHandle.setPreview`. */ const previewRow: { id: string | null } = { id: null }; +/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */ +const rowHighlights: { open: Set; focused: string | null } = { + open: new Set(), + focused: null, +}; function fakeSidebar(): SidebarHandle { return { el: document.createElement("aside"), setActive() {}, - setHighlights() {}, + setHighlights(h) { + rowHighlights.open = h.open; + rowHighlights.focused = h.focused; + }, setPreview(id: string | null) { previewRow.id = id; }, @@ -95,6 +103,8 @@ describe("sidebar preview", () => { stubFetch(); document.body.replaceChildren(); previewRow.id = null; + rowHighlights.open = new Set(); + rowHighlights.focused = null; }); afterEach(() => { @@ -134,6 +144,28 @@ describe("sidebar preview", () => { expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0); }); + it("leaves the accent on the session the pane actually holds", () => { + const layout = twoTabs(); + const spy = fakePool(); + const mv = makeMv(layout, spy.pool); + expect(rowHighlights.focused).toBe("s2"); + + // Walking past other rows never moves the accent off what the operator + // picked: only a real open does (docs/PREVIEW.md). + mv.previewSession("s1"); + expect(rowHighlights.focused).toBe("s2"); + expect(mv.focusedSessionId()).toBe("s2"); + + mv.previewSession("s9"); + expect(rowHighlights.focused).toBe("s2"); + + mv.previewSession(null); + expect(rowHighlights.focused).toBe("s2"); + + mv.openSession("s1"); + expect(rowHighlights.focused).toBe("s1"); + }); + it("shows a session no pane holds in a throwaway tab that leaves no trace", () => { const layout = twoTabs(); const before = serializeLayout(layout); From ff1ffb377caa586bfc857b902daa07f7aed9df73 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 08:17:14 +0100 Subject: [PATCH 2/2] fix(maestro): leave fast-forward spawns unnamed in the web client The web N path sent the generated handle as the session name as well as the worktree branch, so the session was born named and never got its Haiku name from its first prompt. The TUI already spawns unnamed (#131); the handle now only seeds the branch. --- web/src/fastForward.test.ts | 21 +++++++++++++++++---- web/src/fastForward.ts | 32 +++++++++++++++++++------------- web/src/main.ts | 8 ++++---- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts index 4366c28..de74292 100644 --- a/web/src/fastForward.test.ts +++ b/web/src/fastForward.test.ts @@ -34,12 +34,14 @@ describe("resolveFastForward (#67)", () => { const res = resolveFastForward(projects, presets, "amber-falcon"); expect(res.project?.path).toBe("/repos/fresh"); expect(res.command).toBe("aider"); - expect(res.name).toBe("amber-falcon"); + expect(res.handle).toBe("amber-falcon"); const req = buildFastForwardRequest(res)!; expect(req.cwd).toBe("/repos/fresh"); expect(req.command).toBe("aider"); - expect(req.metadata).toEqual({ name: "amber-falcon" }); + // No name (#131): the daemon names the session after its first prompt, + // and a name here would collide with the branch handle. + expect(req.metadata).toBeUndefined(); expect(req.worktree).toEqual({ base_repo: "/repos/fresh", branch: "amber-falcon", @@ -88,9 +90,9 @@ describe("resolveFastForward (#67)", () => { expect(buildFastForwardRequest(res)).toBeNull(); }); - it("downgrades to a draft when the generated name is missing", () => { + it("downgrades to a draft when the generated handle is missing", () => { const res = resolveFastForward([project({ last_command: "claude" })], presets); - expect(res.name).toBeUndefined(); + expect(res.handle).toBeUndefined(); expect(buildFastForwardRequest(res)).toBeNull(); }); @@ -108,6 +110,17 @@ describe("resolveFastForward (#67)", () => { }); }); + it("seeds the handle as the branch and leaves the name field empty (#131)", () => { + const res = resolveFastForward( + [project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })], + presets, + "amber-falcon", + ); + const seed = fastForwardSeed(res); + expect(seed.name).toBe(""); + expect(seed.worktreeBranch).toBe("amber-falcon"); + }); + it("leaves the worktree default alone when not even a project resolved", () => { // Not `false`: the form defaults the toggle on and a false would stick, // so the fallback draft would disagree with a normally-opened draft and diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts index 5f83611..0a140f1 100644 --- a/web/src/fastForward.ts +++ b/web/src/fastForward.ts @@ -10,19 +10,21 @@ import { sortProjects } from "./projectList.js"; export interface FastForwardResolution { project?: Project; command?: string; - name?: string; + /** A generated handle for the worktree branch (#131). Not a session name: + * the fast path names no session. */ + handle?: string; } /** 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. */ + * falls back to the first non-custom preset, and the branch handle is + * freshly generated when the daemon supplied one. */ export function resolveFastForward( projects: Project[], presets: SpawnPreset[], - name?: string, + handle?: string, focusedCwd?: string, ): FastForwardResolution { const project = @@ -30,41 +32,45 @@ export function resolveFastForward( const lastCommand = project?.last_command?.trim(); const command = lastCommand || presets.find((p) => p.command !== "")?.command || undefined; - return { project, command, name: name || undefined }; + return { project, command, handle: handle || undefined }; } /** The immediate spawn request — worktree on, new branch named after the - * generated session name — or `null` when any default is missing. */ + * generated handle — or `null` when any default is missing. + * + * The request carries no name (#131): the daemon assigns a provisional one + * and replaces it once the session sees its first prompt. A name here would + * also collide with the branch handle's own session. */ export function buildFastForwardRequest( res: FastForwardResolution, ): CreateSessionRequest | null { - const { project, command, name } = res; - if (!project || !command?.trim() || !name) return null; + const { project, command, handle } = res; + if (!project || !command?.trim() || !handle) return null; return { cwd: project.path, command, - metadata: { name }, worktree: { base_repo: project.base_repo ?? project.path, - branch: name, + branch: handle, create_branch: true, }, }; } /** Seed for the fallback spawn form: whatever did resolve goes into the - * form; the rest stays with its own defaults. */ + * form; the rest stays with its own defaults. The name field stays empty + * (#131) — the handle seeds the branch, not the session name. */ export function fastForwardSeed(res: FastForwardResolution) { return { cwd: res.project?.path ?? "", command: res.command ?? "", - name: res.name ?? "", + name: "", // Left undefined rather than false when nothing resolved: the form // defaults the toggle on, and `false` would stick, so the fallback form // would disagree with both a normally-opened form and the TUI's fast path // (#67 review). worktreeEnabled: res.project ? true : undefined, worktreeBranch: - res.project && res.name ? res.name : undefined, + res.project && res.handle ? res.handle : undefined, }; } diff --git a/web/src/main.ts b/web/src/main.ts index 2154af1..8bc489f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -356,14 +356,14 @@ async function spawnPresets(): Promise { } async function requestFastForward(): Promise { - const [discovered, name] = await Promise.all([ + const [discovered, handle] = await Promise.all([ fetchProjects().catch(() => [] as Project[]), fetchNames(1) .then((batch) => batch.names[0]) .catch(() => undefined), ]); const presets = await spawnPresets(); - const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd()); + const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd()); const req = buildFastForwardRequest(resolved); if (req) { try { @@ -390,7 +390,7 @@ async function requestFastForward(): Promise { // one outside any repo spawns without a worktree, since there is no repo to cut // a branch from. Anything still unresolved opens the pre-filled form. async function requestSpawnInDirectory(path: string): Promise { - const [discovered, name] = await Promise.all([ + const [discovered, handle] = await Promise.all([ fetchProjects().catch(() => [] as Project[]), fetchNames(1) .then((batch) => batch.names[0]) @@ -399,7 +399,7 @@ async function requestSpawnInDirectory(path: string): Promise { const presets = await spawnPresets(); const known = discovered.find((p) => p.path === path); const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path }; - const resolved = resolveFastForward([project], presets, name, path); + const resolved = resolveFastForward([project], presets, handle, path); const req = buildFastForwardRequest(resolved); if (req) { const inRepo =