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/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 = 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);