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
27 changes: 19 additions & 8 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ export async function dismissOverlays(): Promise<void> {
// the rest: editor.e2e left one up, and files.e2e's "top result" assertion
// read the FIRST dialog on screen, which by then was not its own.
ui.closeSyntaxPalette();
ui.closeNewTabPalette();
});
await browser.keys(["Escape"]);
await browser.execute(() => {
Expand Down Expand Up @@ -1889,12 +1890,17 @@ export async function clickMenuItemUntilReady(
// before spending another poll on an empty document.
const present = await browser.execute((t) =>
(() => {
const open = [...document.querySelectorAll('[role="menu"]')]
.filter(m => m.getAttribute("data-state") !== "closed");
const open = [
...document.querySelectorAll('[role="menu"]'),
...document.querySelectorAll('[data-testid="new-tab-palette"]'),
].filter(m => m.getAttribute("data-state") !== "closed");
const scope: (Document | Element)[] = open.length ? [open[open.length - 1]] : [document];
return scope.flatMap(sc => [...sc.querySelectorAll<HTMLElement>("[role='menuitem']")]);
return scope.flatMap(sc => [
...sc.querySelectorAll<HTMLElement>("[role='menuitem']"),
...sc.querySelectorAll<HTMLElement>("[data-kind]"),
]);
})().some(
(e) => e.textContent?.trim() === t && e.getBoundingClientRect().width > 0,
(e) => (e.textContent?.trim() === t || e.querySelector(".truncate")?.textContent?.trim() === t) && e.getBoundingClientRect().width > 0,
), text);
if (reopen && !present) {
reopens += 1;
Expand All @@ -1906,12 +1912,17 @@ export async function clickMenuItemUntilReady(
}
const clicked = await browser.execute((t) => {
const el = (() => {
const open = [...document.querySelectorAll('[role="menu"]')]
.filter(m => m.getAttribute("data-state") !== "closed");
const open = [
...document.querySelectorAll('[role="menu"]'),
...document.querySelectorAll('[data-testid="new-tab-palette"]'),
].filter(m => m.getAttribute("data-state") !== "closed");
const scope: (Document | Element)[] = open.length ? [open[open.length - 1]] : [document];
return scope.flatMap(sc => [...sc.querySelectorAll<HTMLElement>("[role='menuitem']")]);
return scope.flatMap(sc => [
...sc.querySelectorAll<HTMLElement>("[role='menuitem']"),
...sc.querySelectorAll<HTMLElement>("[data-kind]"),
]);
})().find(
(e) => e.textContent?.trim() === t && e.getBoundingClientRect().width > 0,
(e) => (e.textContent?.trim() === t || e.querySelector(".truncate")?.textContent?.trim() === t) && e.getBoundingClientRect().width > 0,
);
if (!el) return false;
el.click();
Expand Down
55 changes: 31 additions & 24 deletions e2e/specs/tabs-layout.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,25 @@ describe("tab management", () => {
const openPlusMenu = async () => {
await browser.waitUntil(
() => browser.execute((id) => {
if (document.querySelector("[role='menu']")) return true;
if (document.querySelector("[data-testid='new-tab-palette']") || document.querySelector("[role='menu']")) return true;
// this task's strip: every visited task stays mounted, hidden, and
// an unscoped lookup finds the first one, which added the tab to a
// leftover task whenever an earlier spec left one behind
const strip = document.querySelector(`[data-task-id="${id}"] [data-main-strip]`);
const plus = [...(strip?.querySelectorAll("button") ?? [])].find((b) =>
b.querySelector("svg.lucide-plus"),
b.querySelector("svg.lucide-plus") || b.getAttribute("data-testid") === "new-tab-button",
);
if (!plus) return false;
const el = plus as HTMLElement;
const opts = { bubbles: true, pointerType: "mouse", button: 0 } as any;
el.dispatchEvent(new PointerEvent("pointerdown", opts));
el.dispatchEvent(new PointerEvent("pointerup", opts));
el.click();
return !!document.querySelector("[role='menu']");
return !!(document.querySelector("[data-testid='new-tab-palette']") || document.querySelector("[role='menu']"));
}, taskId),
{ timeout: 15_000, interval: 250, timeoutMsg: "the tab strip's + menu never opened" },
);
await waitVisible("[role='menu']");
await waitVisible("[data-testid='new-tab-palette'], [role='menu']");
};

it("adds a terminal tab via the + menu and switches between tabs", async () => {
Expand Down Expand Up @@ -81,12 +81,12 @@ describe("tab management", () => {
// Open the tab bar's "+" menu through the retrying helper: dispatched
// once, this is the click the Linux runner drops.
await openPlusMenu();
// Wait for the Radix menu to render, then add a Terminal.
// Wait for the launcher palette or Radix menu to render, then add a Terminal.
await browser.waitUntil(
() =>
browser.execute(() =>
[...document.querySelectorAll("[role='menuitem']")].some(
(e) => e.textContent?.trim() === "Terminal",
[...document.querySelectorAll("[data-testid='new-tab-palette'] [data-kind], [role='menuitem']")].some(
(e) => (e.textContent?.trim() === "Terminal" || e.querySelector(".truncate")?.textContent?.trim() === "Terminal"),
),
),
{ timeout: 5_000, timeoutMsg: "the + menu (Terminal item) never opened" },
Expand Down Expand Up @@ -141,27 +141,26 @@ describe("tab management", () => {

await openPlusMenu();
const text = await browser.execute(() =>
(document.querySelector("[data-testid='new-tab-palette']") as HTMLElement | null)?.innerText ??
(document.querySelector("[role='menu']") as HTMLElement | null)?.innerText ?? "");
expect(text).toContain("Resume");
// The whole point: the sessions are NOT on the top level, so the rows the
// user came for stay near the cursor however long the history gets.
expect(text).not.toContain("now");

// Hovering the trigger opens the submenu and reveals them.
// In the palette, closed tabs are listed with the Resume badge; in a dropdown, under a submenu.
await browser.execute(() => {
const t = [...document.querySelectorAll('[aria-haspopup="menu"]')]
.find(e => e.textContent?.includes("Resume")) as HTMLElement | undefined;
if (!t) throw new Error("no Resume submenu trigger");
const opts = { bubbles: true, pointerType: "mouse" } as any;
t.dispatchEvent(new PointerEvent("pointerover", opts));
t.dispatchEvent(new PointerEvent("pointermove", opts));
t.click();
if (t) {
const opts = { bubbles: true, pointerType: "mouse" } as any;
t.dispatchEvent(new PointerEvent("pointerover", opts));
t.dispatchEvent(new PointerEvent("pointermove", opts));
t.click();
}
});
await browser.waitUntil(
() => browser.execute(() =>
[...document.querySelectorAll("[role='menuitem']")]
[...document.querySelectorAll("[data-testid='new-tab-palette'] [data-kind='resume'], [role='menuitem']")]
.some(e => (e as HTMLElement).innerText.includes("fakeagent"))),
{ timeout: 8_000, timeoutMsg: "the Resume submenu never listed the closed tab" },
{ timeout: 8_000, timeoutMsg: "the Resume list never listed the closed tab" },
);
await dismissOverlays();
});
Expand Down Expand Up @@ -193,6 +192,12 @@ describe("sidebar task menu: New submenu", () => {
* `[role='menuitem']` sweep would mix "Rename" / "Archive task" in. */
const entriesOfTerminalMenu = () =>
browser.execute(() => {
const pal = document.querySelector("[data-testid='new-tab-palette']");
if (pal) {
return [...pal.querySelectorAll<HTMLElement>("[data-kind]")].map(
(e) => e.querySelector(".truncate")?.textContent?.trim() ?? e.textContent?.trim() ?? "",
);
}
const menu = [...document.querySelectorAll("[role='menu']")].find((m) =>
[...m.querySelectorAll("[role='menuitem']")].some(
(i) => i.textContent?.trim() === "Terminal",
Expand Down Expand Up @@ -313,12 +318,11 @@ describe("sidebar task menu: New submenu", () => {
const fromSidebar = await openNewSubmenu(taskId!);
await dismissOverlays();

// Same list from the "+" button on the active task's strip. Radix opens on
// pointerdown, so a bare .click() is not enough.
// Same list from the "+" button on the active task's strip.
await browser.execute((id) => {
const strip = document.querySelector(`[data-task-id="${id}"] [data-main-strip]`);
const plus = [...(strip?.querySelectorAll("button") ?? [])].find((b) =>
b.querySelector("svg.lucide-plus"),
b.querySelector("svg.lucide-plus") || b.getAttribute("data-testid") === "new-tab-button",
);
if (!plus) throw new Error("tab '+' button not found");
const opts = { bubbles: true, pointerType: "mouse", button: 0 } as any;
Expand All @@ -329,17 +333,20 @@ describe("sidebar task menu: New submenu", () => {
await browser.waitUntil(
() =>
browser.execute(() =>
!!document.querySelector("[data-testid='new-tab-palette']") ||
[...document.querySelectorAll("[role='menuitem']")].some(
(e) => e.textContent?.trim() === "Terminal",
(e) => (e.textContent?.trim() === "Terminal" || e.querySelector(".truncate")?.textContent?.trim() === "Terminal"),
),
),
{ timeout: 5_000, timeoutMsg: "the + menu never opened" },
{ timeout: 5_000, timeoutMsg: "the + launcher never opened" },
);
const fromTabStrip = await entriesOfTerminalMenu();
await dismissOverlays();

expect(fromSidebar.length).toBeGreaterThan(1);
expect(fromSidebar).toEqual(fromTabStrip);
for (const item of fromSidebar) {
expect(fromTabStrip).toContain(item);
}
});
});

Expand Down
1 change: 1 addition & 0 deletions src/components/dialogs/CommandPalette.coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const EXCLUDED: Record<string, string> = {
openRunCommands: "surfaced",
openResumeOverride: "surfaced",
openScratchExport: "needs a specific scratchpad file and scope; reached from scratchpad editor breadcrumb or tree context menu",
openNewTabPalette: "needs an active taskId and target pane; reached via Cmd+T or the tab strip + button",
};

/** `open*` action names declared in the UI store's interface. */
Expand Down
2 changes: 2 additions & 0 deletions src/components/dialogs/Dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { CommandPalette } from "./CommandPalette";
import { PromptDestinationDialog } from "./PromptDestinationDialog";
import { PromptPalette } from "./PromptPalette";
import { SyntaxPalette } from "./SyntaxPalette";
import { NewTabPalette } from "./NewTabPalette";
import { SearchEverywhereDialog } from "./SearchEverywhereDialog";
import { ScratchCloseDialog } from "./ScratchCloseDialog";
import { ScratchSaveDialog } from "./ScratchSaveDialog";
Expand Down Expand Up @@ -93,6 +94,7 @@ export function Dialogs() {
<PromptDestinationDialog />
<PromptPalette />
<SyntaxPalette />
<NewTabPalette />
<SearchEverywhereDialog />
<ScratchCloseDialog />
<ScratchSaveDialog />
Expand Down
135 changes: 135 additions & 0 deletions src/components/dialogs/NewTabPalette.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockResolvedValue(() => {}) }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/ipc", () => ({
projectsList: vi.fn().mockResolvedValue([]),
tasksList: vi.fn().mockResolvedValue([]),
settingsLoad: vi.fn().mockResolvedValue({ agents: [] }),
detectClis: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/tabFocus", () => ({
focusTerminalTab: vi.fn(),
focusMainTab: vi.fn(),
focusPaneTab: vi.fn(),
}));

import { useUI } from "@/store/ui";
import { useApp } from "@/store/app";
import { DEFAULT_BINDINGS, SHORTCUT_DEFS } from "@/lib/shortcuts";
import { fuzzyMatch } from "@/lib/fuzzy";
import { getScratchScopes } from "@/components/dialogs/NewTabPalette";
import type { Agent, Task } from "@/lib/types";

describe("NewTabPalette state and shortcut", () => {
beforeEach(() => {
useUI.setState({ newTabPalette: null });
});

it("has a new-tab shortcut registered with Cmd+T / Ctrl+T", () => {
const def = SHORTCUT_DEFS.find(d => d.id === "new-tab");
expect(def).toBeDefined();
expect(def?.group).toBe("Tabs");
expect(DEFAULT_BINDINGS["new-tab"]).toEqual({
key: "t",
cmd: true,
shift: false,
alt: false,
});
});

it("toggles newTabPalette in UI store with taskId and paneId", () => {
expect(useUI.getState().newTabPalette).toBeNull();
useUI.getState().openNewTabPalette("task-123");
expect(useUI.getState().newTabPalette).toEqual({ taskId: "task-123", paneId: undefined });

useUI.getState().openNewTabPalette("task-123", "pane-abc");
expect(useUI.getState().newTabPalette).toEqual({ taskId: "task-123", paneId: "pane-abc" });

useUI.getState().closeNewTabPalette();
expect(useUI.getState().newTabPalette).toBeNull();
});
});

describe("NewTabPalette fuzzy search logic", () => {
const agents: Agent[] = [
{ id: "claude", display_name: "Claude Code", command: "claude", args: [], builtin: true, icon_id: "claude", color: "#d97706" } as Agent,
{ id: "codex", display_name: "Codex CLI", command: "codex", args: [], builtin: true, icon_id: "codex", color: "#10b981" } as Agent,
{ id: "harlequin", display_name: "Harlequin DB", command: "harlequin", args: [], builtin: false, kind: "terminal", icon_id: "lucide:terminal", color: "#9aa0a6" } as Agent,
{ id: "lazysql", display_name: "LazySQL", command: "lazysql", args: [], builtin: false, kind: "terminal", icon_id: "lucide:terminal", color: "#9aa0a6" } as Agent,
];

it("matches custom terminals by title and command", () => {
const custom = agents.filter(a => a.kind === "terminal");
const m1 = fuzzyMatch(custom[0].display_name, "harle");
expect(m1).not.toBeNull();

const m2 = fuzzyMatch(custom[1].command || "", "lazy");
expect(m2).not.toBeNull();
});

it("matches AI agents by display name", () => {
const aiAgents = agents.filter(a => !a.kind);
const m1 = fuzzyMatch(aiAgents[0].display_name, "claude");
expect(m1).not.toBeNull();

const m2 = fuzzyMatch(aiAgents[1].display_name, "codex");
expect(m2).not.toBeNull();
});

it("identifies custom command runner when query is not an exact match", () => {
const query = "psql -U postgres -d mydb";
const exactMatch = agents.some(a => a.display_name.toLowerCase() === query.toLowerCase());
expect(exactMatch).toBe(false);
});

it("creates a tab with initialInput ending in CR to execute in terminal", () => {
const query = "echo some text";
const tabOpts = {
title: query,
command: query,
initialInput: `${query}\r`,
};
expect(tabOpts.initialInput).toBe("echo some text\r");

const tabId = useApp.getState().addPaneTab("task-1", "pane-1", "shell", tabOpts);
const tabs = useApp.getState().tabs["task-1"] || [];
const created = tabs.find(t => t.id === tabId);
expect(created).toBeDefined();
expect(created?.type).toBe("terminal");
expect(created?.title).toBe("echo some text");
expect((created as any)?.initialInput).toBe("echo some text\r");
expect((created as any)?.command).toBe("echo some text");
});

it("resumes a closed tab into a split pane when paneId is provided", () => {
useApp.setState({
closedTabs: {
"task-1": [{
id: "closed-1",
tabId: "old-tab-1",
title: "Closed Tab",
cli: "shell",
closedAt: new Date().toISOString(),
isDefault: false,
}],
},
});
useApp.getState().resumeClosedTab("task-1", "closed-1", { paneId: "pane-1" });
const tabs = useApp.getState().tabs["task-1"] || [];
const resumed = tabs.find(t => t.id === "old-tab-1");
expect(resumed).toBeDefined();
expect((resumed as any)?.paneId).toBe("pane-1");
});

it("omits project scratchpad scope when task has no project", () => {
const scopesNoProject = getScratchScopes(k => k, false);
expect(scopesNoProject.map(s => s.scope)).toEqual(["task", "profile", "global"]);
expect(scopesNoProject.some(s => s.scope === "project")).toBe(false);

const scopesWithProject = getScratchScopes(k => k, true);
expect(scopesWithProject.map(s => s.scope)).toEqual(["task", "project", "profile", "global"]);
expect(scopesWithProject.some(s => s.scope === "project")).toBe(true);
});
});
Loading
Loading