Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/ui/src/keyboard/spine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/src/tab-keep-alive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
19 changes: 19 additions & 0 deletions apps/ui/src/window-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
19 changes: 14 additions & 5 deletions apps/ui/src/window-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
1 change: 1 addition & 0 deletions docs/designs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ check enforces the mechanical half. Full rationale:
| DL-389 | The one hash router stops choosing what `<main>` 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

Expand Down
Loading