From b86bcf82f85a48e3d38d0f270da1fb6318db27de Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 6 Oct 2026 14:03:56 -0400 Subject: [PATCH] feat(ui): New tab always opens a new tab (RIG-4676) The open action gains an optional `fresh` flag that skips the same-path dedupe. Only `tab.new` sets it, so W N and the palette's "New tab" always open a Bridge tab after the active one, even when Bridge is already open. Links, Mod+click and deep links still dedupe. The 10-tab cap still refuses with the layout notice. Records the ruling as DL-409, amending DL-390's dedupe clause for this command. Spec-impact: none Refs RIG-4676 Co-authored-by: Matt Wilkinson --- apps/ui/src/keyboard/spine.ts | 1 + apps/ui/src/tab-keep-alive.test.tsx | 5 +++-- apps/ui/src/window-layout.test.ts | 19 +++++++++++++++++++ apps/ui/src/window-layout.ts | 19 ++++++++++++++----- docs/designs/DECISIONS.md | 1 + 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/ui/src/keyboard/spine.ts b/apps/ui/src/keyboard/spine.ts index 2d50e2893..bd1483218 100644 --- a/apps/ui/src/keyboard/spine.ts +++ b/apps/ui/src/keyboard/spine.ts @@ -175,6 +175,7 @@ export function createKeyboardSpine(deps: { registerLayoutCommand("tab.new", "New tab", ["open", "bridge"], () => ({ kind: "open", path: "/", + fresh: true, })); registerGlobal("tab.close", "Close tab", ["close", "current"], () => { deps.closeTab(deps.layout().activeTabId); diff --git a/apps/ui/src/tab-keep-alive.test.tsx b/apps/ui/src/tab-keep-alive.test.tsx index c1451ab86..7199b75d1 100644 --- a/apps/ui/src/tab-keep-alive.test.tsx +++ b/apps/ui/src/tab-keep-alive.test.tsx @@ -40,12 +40,13 @@ const panelForView = (container: HTMLElement, viewId: string): HTMLElement => { afterEach(() => cleanup()); describe("tab and pane commands on the real store", () => { - test("New tab on the Bridge tab keeps one tab with Bridge focused", async () => { + test("New tab on the Bridge tab opens a second Bridge tab and focuses it", async () => { const { store } = mountApp("/"); await flush(); runCommand(store, "tab.new"); await flush(); - expect(store.layout().tabs).toHaveLength(1); + expect(store.layout().tabs).toHaveLength(2); + expect(store.layout().activeTabId).toBe(tabIdAt(store, 1)); expect(store.view()).toBe("bridge"); }); diff --git a/apps/ui/src/window-layout.test.ts b/apps/ui/src/window-layout.test.ts index f5f9b6db6..dcd51c9b8 100644 --- a/apps/ui/src/window-layout.test.ts +++ b/apps/ui/src/window-layout.test.ts @@ -64,6 +64,25 @@ describe("reduceLayout", () => { expect(next.activeTabId).toBe(backlog?.id ?? ""); }); + test("a fresh open skips the dedupe and opens a second tab on the same path", () => { + const layout = openAll(["/", "/backlog"]); + const onBridge = reduce(layout, { + kind: "focusTab", + tabId: layout.tabs[0]?.id ?? "", + }); + const next = reduce(onBridge, { kind: "open", path: "/", fresh: true }); + expect(tabPaths(next)).toEqual(["/", "/", "/backlog"]); + expect(next.activeTabId).toBe(next.tabs[1]?.id ?? ""); + }); + + test("a fresh open at the tab cap is refused even when the path is open", () => { + const paths = Array.from({ length: MAX_TABS }, (_, i) => `/agent/a-${i}`); + const full = openAll(paths); + expect( + reduceLayout(full, { kind: "open", path: "/agent/a-0", fresh: true }), + ).toEqual({ refused: "tab-cap" }); + }); + test("a new tab opens after the active one", () => { const layout = openAll(["/", "/backlog"]); const focused = reduce(layout, { diff --git a/apps/ui/src/window-layout.ts b/apps/ui/src/window-layout.ts index b3f3b05e9..70009f4a2 100644 --- a/apps/ui/src/window-layout.ts +++ b/apps/ui/src/window-layout.ts @@ -21,7 +21,7 @@ const STORAGE_KEY = "compass.windowLayout"; const MAX_SEED = 1e9; export type LayoutAction = - | { kind: "open"; path: string; background?: boolean } + | { kind: "open"; path: string; background?: boolean; fresh?: boolean } | { kind: "close"; tabId: string } | { kind: "focusTab"; tabId: string } | { kind: "move"; tabId: string; toIndex: number } @@ -115,14 +115,18 @@ function withFocusedPath(tab: TabLayout, path: string): TabLayout { : { ...tab, second: moved }; } +// `fresh` skips the dedupe: New tab always opens a tab, as in a browser. function open( layout: WindowLayout, path: string, background: boolean, + fresh = false, ): WindowLayout | LayoutRefusal { - const existing = layout.tabs.find( - (tab) => tab.layout.kind === "single" && tab.layout.view.path === path, - ); + const existing = fresh + ? undefined + : layout.tabs.find( + (tab) => tab.layout.kind === "single" && tab.layout.view.path === path, + ); if (existing) { return background || existing.id === layout.activeTabId ? layout @@ -186,7 +190,12 @@ export function reduceLayout( ): WindowLayout | LayoutRefusal { switch (action.kind) { case "open": - return open(layout, action.path, action.background ?? false); + return open( + layout, + action.path, + action.background ?? false, + action.fresh, + ); case "close": return close(layout, action.tabId); case "focusTab": diff --git a/docs/designs/DECISIONS.md b/docs/designs/DECISIONS.md index 878c69d4d..fa9f9c744 100644 --- a/docs/designs/DECISIONS.md +++ b/docs/designs/DECISIONS.md @@ -317,6 +317,7 @@ check enforces the mechanical half. Full rationale: | DL-389 | The one hash router stops choosing what `
` renders: it mirrors the focused view's path into the hash and applies hash changes to the focused view, while a `ViewHost` renders each view from the shared `appRoutes` table; browser history stays one linear stack whose entries carry the owning view id in history state, and a tab switch replaces the current entry rather than pushing | Active (Matt, 2026-10-04) | [in-window tabs §A2](ui/compass-in-window-tabs/design.md#a2--the-router-stays-the-url-bridge-not-the-pane-renderer) | | DL-390 | Window layout is a list of view tabs, each a single view or a bounded two-pane split; links navigate the focused view in place and `Mod`+click or middle-click opens a tab; an already-open path is deduped to its tab; at most 10 tabs; inactive tabs stay mounted and hidden; the layout persists per window in `sessionStorage`, and a deep-link hash wins over the restored layout | Active (Matt, 2026-10-04) | [in-window tabs §A3](ui/compass-in-window-tabs/design.md#a3--window-layout-tabs-each-a-single-view-or-a-two-pane-split) | | DL-391 | Tab and split commands bind to a second leader, `W`, under the leader-chord rules (not `Mod+T`/`W`/`Tab`, which the browser host cannot intercept), and each is also a palette command | Active (Matt, 2026-10-04) | [in-window tabs §A6](ui/compass-in-window-tabs/design.md#a6--keyboard) | +| DL-409 | New tab (`W N`, palette "New tab") always opens a new tab on `/`, skipping the open-path dedupe, so it never just focuses an open Bridge tab; duplicate Bridge tabs are allowed and the 10-tab cap still applies. Amends DL-390's "an already-open path is deduped to its tab" clause for this command only | Active (Matt, 2026-10-06) | [in-window tabs §A6](ui/compass-in-window-tabs/design.md#a6--keyboard) | ## Threading & rendering