From 604b74a99bb92eaf3f7b2d7622fbe58a1841555e Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:43:42 +0530 Subject: [PATCH 1/2] feat(tab): searchable new tab launcher palette on Cmd+T and add tab --- e2e/helpers.ts | 27 +- e2e/specs/tabs-layout.e2e.ts | 55 ++- .../dialogs/CommandPalette.coverage.test.ts | 1 + src/components/dialogs/Dialogs.tsx | 2 + src/components/dialogs/NewTabPalette.test.ts | 124 +++++ src/components/dialogs/NewTabPalette.tsx | 438 ++++++++++++++++++ src/components/task/PaneHeader.tsx | 94 +--- src/components/task/TabBar.tsx | 91 +--- src/components/task/TerminalPane.tsx | 16 +- src/hooks/useShortcuts.ts | 7 +- src/lib/scratchTabs.ts | 20 +- src/lib/types.ts | 4 + src/locales/en/task.ts | 17 + src/locales/zh-CN/task.ts | 17 + src/store/app.ts | 28 +- src/store/ui.ts | 8 + 16 files changed, 747 insertions(+), 202 deletions(-) create mode 100644 src/components/dialogs/NewTabPalette.test.ts create mode 100644 src/components/dialogs/NewTabPalette.tsx diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 3c888ed0d..e8b501504 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -702,6 +702,7 @@ export async function dismissOverlays(): Promise { // 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(() => { @@ -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("[role='menuitem']")]); + return scope.flatMap(sc => [ + ...sc.querySelectorAll("[role='menuitem']"), + ...sc.querySelectorAll("[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; @@ -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("[role='menuitem']")]); + return scope.flatMap(sc => [ + ...sc.querySelectorAll("[role='menuitem']"), + ...sc.querySelectorAll("[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(); diff --git a/e2e/specs/tabs-layout.e2e.ts b/e2e/specs/tabs-layout.e2e.ts index 902e049bf..5fce5004c 100644 --- a/e2e/specs/tabs-layout.e2e.ts +++ b/e2e/specs/tabs-layout.e2e.ts @@ -31,13 +31,13 @@ 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; @@ -45,11 +45,11 @@ describe("tab management", () => { 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 () => { @@ -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" }, @@ -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(); }); @@ -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("[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", @@ -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; @@ -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); + } }); }); diff --git a/src/components/dialogs/CommandPalette.coverage.test.ts b/src/components/dialogs/CommandPalette.coverage.test.ts index e07dc70f9..e6b296b08 100644 --- a/src/components/dialogs/CommandPalette.coverage.test.ts +++ b/src/components/dialogs/CommandPalette.coverage.test.ts @@ -46,6 +46,7 @@ const EXCLUDED: Record = { 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. */ diff --git a/src/components/dialogs/Dialogs.tsx b/src/components/dialogs/Dialogs.tsx index a91b61e54..a1bcb8600 100644 --- a/src/components/dialogs/Dialogs.tsx +++ b/src/components/dialogs/Dialogs.tsx @@ -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"; @@ -93,6 +94,7 @@ export function Dialogs() { + diff --git a/src/components/dialogs/NewTabPalette.test.ts b/src/components/dialogs/NewTabPalette.test.ts new file mode 100644 index 000000000..2de2eff98 --- /dev/null +++ b/src/components/dialogs/NewTabPalette.test.ts @@ -0,0 +1,124 @@ +// @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 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"); + }); +}); diff --git a/src/components/dialogs/NewTabPalette.tsx b/src/components/dialogs/NewTabPalette.tsx new file mode 100644 index 000000000..edf156d35 --- /dev/null +++ b/src/components/dialogs/NewTabPalette.tsx @@ -0,0 +1,438 @@ +// Quick new-tab palette (⌘T / + tab) — fuzzy-filter agents, custom terminals, +// scratchpads, and recent closed tabs. Allows typing any arbitrary command to +// run directly in a new terminal tab. +// Modelled on PromptPalette.tsx: same non-modal Dialog pattern, same fuzzy +// scoring, positional accelerators (1-9) when query is empty. + +import { useEffect, useMemo, useRef, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { Search, NotepadText, History, Settings, CornerDownLeft, Play, Terminal } from "lucide-react"; +import { useUI } from "@/store/ui"; +import { useApp, type ClosedTabEntry } from "@/store/app"; +import { fuzzyMatch, Highlighted } from "@/lib/fuzzy"; +import { useTranslation } from "react-i18next"; +import { CliIcon, CLI_BRAND_COLOR, resolveIconId } from "@/icons/cli"; +import { visibleCliIds, agentDisplayName, isTerminalEntry } from "@/lib/agents"; +import { newScratchTab } from "@/lib/scratchTabs"; +import { focusTerminalTab } from "@/lib/tabFocus"; +import { cn } from "@/lib/utils"; + +const NO_CLOSED_TABS: ClosedTabEntry[] = []; + +function relativeTime(iso: string, now: string): string { + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); + if (mins < 1) return now; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +export type NewTabPaletteItem = { + id: string; + kind: "shell" | "custom_terminal" | "agent" | "scratchpad" | "resume" | "custom_command"; + title: string; + subtitle?: string; + category: "terminals" | "agents" | "scratchpads" | "resume" | "custom_command"; + iconId?: string; + badge?: string; + run: () => void; +}; + +export function NewTabPalette() { + const { t } = useTranslation("task"); + const paletteState = useUI(s => s.newTabPalette); + const close = useUI(s => s.closeNewTabPalette); + const openSettings = useApp(s => s.openSettings); + + const open = !!paletteState; + const taskId = paletteState?.taskId ?? ""; + const paneId = paletteState?.paneId ?? null; + + const registry = useApp(s => s.agents); + const detectedClis = useApp(s => s.detectedClis); + const task = useApp(s => s.tasks.find(w => w.id === taskId)); + const closedTabs = useApp(s => (taskId ? (s.closedTabs[taskId] ?? NO_CLOSED_TABS) : NO_CLOSED_TABS)); + const addTab = useApp(s => s.addTab); + const addPaneTab = useApp(s => s.addPaneTab); + const resumeClosedTab = useApp(s => s.resumeClosedTab); + + const [query, setQuery] = useState(""); + const [activeIdx, setActiveIdx] = useState(0); + const listRef = useRef(null); + const returnFocusRef = useRef(null); + + useEffect(() => { + if (open) { + setQuery(""); + setActiveIdx(0); + } + }, [open]); + + useEffect(() => { + setActiveIdx(0); + }, [query]); + + // Construct all base launchable items + const baseItems = useMemo(() => { + if (!taskId) return []; + const items: NewTabPaletteItem[] = []; + + // 1. Shell terminal + items.push({ + id: "shell", + kind: "shell", + title: t("newTab.terminal"), + category: "terminals", + iconId: "shell", + run: () => { + if (paneId) { + addPaneTab(taskId, paneId, "shell"); + } else { + const id = crypto.randomUUID(); + addTab(taskId, { id, type: "terminal", title: t("newTab.terminal"), cli: "shell" }); + focusTerminalTab(id); + } + }, + }); + + // 2. Custom terminals (kind: "terminal") + const customTerminals = registry.filter(a => isTerminalEntry(a) && !a.disabled); + for (const ct of customTerminals) { + items.push({ + id: `ct-${ct.id}`, + kind: "custom_terminal", + title: ct.display_name || ct.id, + subtitle: ct.command || undefined, + category: "terminals", + iconId: resolveIconId(ct.id, registry), + badge: t("newTabPalette.groupTerminals"), + run: () => { + if (paneId) { + addPaneTab(taskId, paneId, ct.id); + } else { + const id = crypto.randomUUID(); + addTab(taskId, { + id, + type: "terminal", + title: ct.display_name || ct.id, + cli: ct.id, + command: ct.command, + }); + focusTerminalTab(id); + } + }, + }); + } + + // 3. AI Agents + const visibleClis = visibleCliIds(registry.map(a => a.id), registry, detectedClis); + const agentEntries = registry.filter(a => !isTerminalEntry(a) && visibleClis.has(a.id)); + for (const ag of agentEntries) { + items.push({ + id: `ag-${ag.id}`, + kind: "agent", + title: ag.display_name || ag.id, + category: "agents", + iconId: resolveIconId(ag.id, registry), + badge: t("newTabPalette.groupAgents"), + run: () => { + if (paneId) { + addPaneTab(taskId, paneId, ag.id); + } else { + const id = crypto.randomUUID(); + addTab(taskId, { + id, + type: "terminal", + title: ag.display_name || ag.id, + cli: ag.id, + }); + focusTerminalTab(id); + } + }, + }); + } + + // 4. Scratchpads + const scratchScopes: Array<{ scope: "task" | "project" | "profile" | "global"; title: string }> = [ + { scope: "task", title: t("newTab.scratchpad") }, + { scope: "project", title: t("newTab.scratchpadProject") }, + { scope: "profile", title: t("newTab.scratchpadProfile") }, + { scope: "global", title: t("newTab.scratchpadGlobal") }, + ]; + for (const sc of scratchScopes) { + items.push({ + id: `scratch-${sc.scope}`, + kind: "scratchpad", + title: sc.title, + category: "scratchpads", + badge: t("newTabPalette.groupScratchpads"), + run: () => { + void newScratchTab(taskId, { + scope: sc.scope, + projectId: sc.scope === "project" ? task?.project_id : undefined, + paneId, + }); + }, + }); + } + + // 5. Recent closed tabs + for (const entry of closedTabs.slice(0, 5)) { + items.push({ + id: `resume-${entry.id}`, + kind: "resume", + title: entry.title, + subtitle: relativeTime(entry.closedAt, t("newTab.now")), + category: "resume", + iconId: entry.cli, + badge: t("newTabPalette.groupResume"), + run: () => { + resumeClosedTab(taskId, entry.id, { paneId }); + }, + }); + } + + return items; + }, [taskId, paneId, registry, detectedClis, closedTabs, task?.project_id, t, addTab, addPaneTab, resumeClosedTab]); + + // Fuzzy filter + custom command option + const rows = useMemo(() => { + type Scored = { item: NewTabPaletteItem; matches: number[]; score: number }; + const trimmed = query.trim(); + + if (!trimmed) { + return baseItems.map(item => ({ item, matches: [], score: 0 })); + } + + const scored: Scored[] = []; + for (const item of baseItems) { + const mTitle = fuzzyMatch(item.title, trimmed); + const mSub = item.subtitle ? fuzzyMatch(item.subtitle, trimmed) : null; + if (mTitle) { + scored.push({ item, matches: mTitle.matches, score: mTitle.score + 10 }); + } else if (mSub) { + scored.push({ item, matches: [], score: mSub.score }); + } + } + scored.sort((a, b) => b.score - a.score); + + // If query was typed, offer to run it as a custom shell command + const exactMatch = baseItems.some(it => it.title.toLowerCase() === trimmed.toLowerCase()); + if (!exactMatch) { + const customCmdItem: NewTabPaletteItem = { + id: "custom-command-runner", + kind: "custom_command", + title: t("newTabPalette.runCommand", { cmd: trimmed }), + subtitle: t("newTabPalette.runCommandTip"), + category: "custom_command", + iconId: "shell", + run: () => { + if (paneId) { + addPaneTab(taskId, paneId, "shell", { + title: trimmed, + command: trimmed, + initialInput: `${trimmed}\r`, + }); + } else { + const id = crypto.randomUUID(); + addTab(taskId, { + id, + type: "terminal", + title: trimmed, + cli: "shell", + command: trimmed, + initialInput: `${trimmed}\r`, + }); + focusTerminalTab(id); + } + }, + }; + scored.push({ item: customCmdItem, matches: [], score: -1 }); + } + + return scored; + }, [baseItems, query, paneId, taskId, addPaneTab, addTab, t]); + + useEffect(() => { + if (activeIdx > rows.length - 1) setActiveIdx(Math.max(0, rows.length - 1)); + }, [rows.length, activeIdx]); + + useEffect(() => { + listRef.current?.querySelector(`[data-row="${activeIdx}"]`)?.scrollIntoView({ block: "nearest" }); + }, [activeIdx]); + + function executeItem(item: NewTabPaletteItem) { + close(); + requestAnimationFrame(() => item.run()); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (!query && /^[1-9]$/.test(e.key)) { + const row = rows[Number(e.key) - 1]; + if (row) { + e.preventDefault(); + executeItem(row.item); + return; + } + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIdx(i => Math.min(i + 1, rows.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIdx(i => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const row = rows[activeIdx]; + if (row) executeItem(row.item); + } else if (e.key === "Escape") { + e.preventDefault(); + close(); + } + } + + if (!open) return null; + + return ( + (v ? null : close())} modal={false}> + +
+ { + returnFocusRef.current = (document.activeElement as HTMLElement) ?? null; + }} + onCloseAutoFocus={e => { + e.preventDefault(); + const el = returnFocusRef.current; + requestAnimationFrame(() => { + const ae = document.activeElement; + if ((!ae || ae === document.body) && el && document.contains(el)) el.focus(); + }); + }} + data-testid="new-tab-palette" + style={{ background: "color-mix(in srgb, var(--color-bg-1) 88%, transparent)" }} + className="termic-pop fixed left-1/2 top-[14vh] z-50 w-[min(520px,94vw)] -translate-x-1/2 overflow-hidden rounded-xl border border-[var(--color-border)] shadow-2xl outline-none backdrop-blur-lg" + onKeyDown={onKeyDown} + > + {t("newTabPalette.srTitle")} + {t("newTabPalette.srDesc")} + +
+ + setQuery(e.target.value)} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + autoComplete="off" + placeholder={t("newTabPalette.placeholder")} + className="w-full bg-transparent pl-1 text-[14px] text-[var(--color-fg)] placeholder:text-[var(--color-fg-faint)] focus:outline-none" + /> + {query && ( + + )} +
+ +
+ {rows.length === 0 && ( +
+ {t("newTabPalette.noMatching")} +
+ )} + {rows.map(({ item, matches }, i) => { + const isSelected = i === activeIdx; + return ( + + ); + })} +
+ +
+ +
+ ↑↓ {t("newTabPalette.hintNav")} + · + ↵ {t("newTabPalette.hintRun")} + · + esc {t("newTabPalette.hintDismiss")} +
+
+
+ + + ); +} diff --git a/src/components/task/PaneHeader.tsx b/src/components/task/PaneHeader.tsx index faaca04bf..0706c05da 100644 --- a/src/components/task/PaneHeader.tsx +++ b/src/components/task/PaneHeader.tsx @@ -11,17 +11,12 @@ import { useTranslation } from "react-i18next"; import type { Task, Tab } from "@/lib/types"; import type { PaneLeaf } from "@/lib/splitTree"; import { useApp } from "@/store/app"; +import { useUI } from "@/store/ui"; import { useShallow } from "zustand/react/shallow"; import { TabPill } from "./TabBar"; import { Button } from "@/components/ui/Button"; -import { - DropdownRoot, DropdownTrigger, DropdownMenu, - DropdownItem, DropdownLabel, DropdownSeparator, -} from "@/components/ui/Dropdown"; -import { CliIcon, CLI_BRAND_COLOR } from "@/icons/cli"; import { Plus, X } from "lucide-react"; import { cn } from "@/lib/utils"; -import { visibleCliIds, isTerminalEntry } from "@/lib/agents"; import { focusPaneTab } from "@/lib/tabFocus"; import { requestClosePaneTab, requestClosePaneTabs } from "@/lib/closeTab"; import { TabContextMenu } from "./TabContextMenu"; @@ -200,11 +195,13 @@ export function PaneHeader({ leaf, task, onClose }: PaneHeaderProps) { useEffect(() => () => { dragTeardownRef.current?.(); clearDropTarget(); }, []); - // ⌘T from inside this pane opens this pane's "+" dropdown. + // ⌘T from inside this pane opens the quick new-tab palette for this pane. useEffect(() => { function onNewTabMenu(e: Event) { const { taskId: eTaskId, paneId: ePaneId } = (e as CustomEvent).detail ?? {}; - if (eTaskId === task.id && ePaneId === paneId) setOpen(true); + if (eTaskId === task.id && ePaneId === paneId) { + useUI.getState().openNewTabPalette(task.id, paneId); + } } window.addEventListener("termic-pane-new-tab-menu", onNewTabMenu); return () => window.removeEventListener("termic-pane-new-tab-menu", onNewTabMenu); @@ -213,32 +210,9 @@ export function PaneHeader({ leaf, task, onClose }: PaneHeaderProps) { const activeTaskPaneId = useApp(s => s.activePaneId[task.id] ?? ""); const isPaneFocused = activeTaskPaneId === paneId; - const registry = useApp(s => s.agents); - const detectedClis = useApp(s => s.detectedClis); - const visibleClis = useMemo( - () => visibleCliIds(registry.map(a => a.id), registry, detectedClis), - [registry, detectedClis], - ); - const customTerminals = useMemo( - () => registry.filter(a => isTerminalEntry(a) && !a.disabled), - [registry], - ); - const agentEntries = useMemo( - () => registry.filter(a => visibleClis.has(a.id)), - [registry, visibleClis], - ); - - const [open, setOpen] = useState(false); - const suppressDropdownReturn = useRef(false); // Drag-ghost label for a tab with no title yet ("Tab"), translated. const ghostFallback = t("paneHeader.dragGhostFallback"); - function spawnPaneTab(cli: string) { - suppressDropdownReturn.current = true; - addPaneTab(task.id, paneId, cli); - setOpen(false); - } - // Closing the pane's last tab collapses the pane, matching ⌘W // (useShortcuts). Without this the X leaves an empty "New pane" behind while // ⌘W removes it. Capture wasLastTab before the async confirm, and only @@ -324,53 +298,17 @@ export function PaneHeader({ leaf, task, onClose }: PaneHeaderProps) { )}
- {/* + dropdown: same agent/shell menu as the main TabBar. */} - - - - - { - if (suppressDropdownReturn.current) { - suppressDropdownReturn.current = false; - e.preventDefault(); - } - }} - > - {t("newTab.newTerminal")} - spawnPaneTab('shell')}> - - - - {t("newTab.terminal")} - - {customTerminals.map(a => ( - spawnPaneTab(a.id)}> - - - - {a.display_name} - - ))} - {agentEntries.length > 0 && ( - <> - - {t("newTab.newAgent")} - {agentEntries.map(a => ( - spawnPaneTab(a.id)}> - - - - {a.display_name} - - ))} - - )} - - + {/* + button: opens the quick new-tab palette for this pane. */} + {/* Close pane. */} - - { - if (suppressDropdownReturn.current) { - suppressDropdownReturn.current = false; - e.preventDefault(); - } - }} - > - { - setOpen(false); - void newScratchTab(task.id, { - scope: scope === "global" || scope === "profile" || scope === "project" ? scope : "task", - projectId: scope === "project" ? task.project_id : undefined, - }); - }} - onResume={resumeAndFocus} - onMore={() => { setOpen(false); setView("history", { projectId: task.project_id }); }} - /> - - + {/* New tab button — opens the searchable new tab palette (⌘T). */} + diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index 14a5035bc..fecf24b63 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -2709,6 +2709,9 @@ const captureArmedRef = useRef(false); const isShell = tab.cli === "shell"; // The Touch ID install tab: types its line once, never offers. const sudoInstallInput = tab.sudoTouchIdInstall; + // Initial typed command (new-tab quick search or Touch ID install): + // types its line at the first shell prompt, then cleared. + const initialInput = tab.initialInput ?? sudoInstallInput; // Custom-command tasks run a user-supplied launch command in a // login shell — never an agent. They share the shell's "no resume, // no agent_id, no per-agent env" treatment; only the spawned argv @@ -3167,7 +3170,7 @@ const captureArmedRef = useRef(false); ? makeCtrlSniffer((kind, payload) => dbg(`raw-${kind}`, payload)) : null; // Cleared to "" once sent, so a respawn of this tab never retypes it. - let sudoInputPending = !!sudoInstallInput; + let initialInputPending = !!initialInput; const tailDecoder = new TextDecoder(); resumeTailRef.current = ""; resumeTailOpenRef.current = lastSpawnWasResumeRef.current; @@ -3179,10 +3182,13 @@ const captureArmedRef = useRef(false); } // Same wait-for-the-prompt as AuxTerminal's initialInput: bytes // written before zsh's line editor is up are echoed twice. - if (sudoInputPending && !cancelled) { - sudoInputPending = false; - patchTab(task.id, tab.id, { sudoTouchIdInstall: "" }); - ipc.ptyWrite(ptyId, Array.from(new TextEncoder().encode(sudoInstallInput))).catch(() => {}); + if (initialInputPending && !cancelled) { + initialInputPending = false; + patchTab(task.id, tab.id, { + ...(tab.initialInput ? { initialInput: "" } : {}), + ...(tab.sudoTouchIdInstall ? { sudoTouchIdInstall: "" } : {}), + }); + ipc.ptyWrite(ptyId, Array.from(new TextEncoder().encode(initialInput))).catch(() => {}); } oscSniffer?.(u8); ctrlSniffer?.(u8); diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts index ca10c238d..548cedf68 100644 --- a/src/hooks/useShortcuts.ts +++ b/src/hooks/useShortcuts.ts @@ -630,7 +630,7 @@ export function useShortcuts() { return; // ⌘T → new tab, behaviour depends on which pane has focus. NO - // `isTyping` guard (xterm's hidden textarea). + // `isTyping` guard (xterm's hidden textarea). Opens the quick new-tab palette. case "new-tab": { if (!taskId) return; e.preventDefault(); @@ -640,10 +640,9 @@ export function useShortcuts() { const focusedLeaf = (document.activeElement as HTMLElement | null) ?.closest?.("[data-split-leaf]") as HTMLElement | null; const paneId = focusedLeaf?.getAttribute("data-pane-id") ?? null; - if (paneId) window.dispatchEvent(new CustomEvent("termic-pane-new-tab-menu", { detail: { taskId, paneId } })); + useUI.getState().openNewTabPalette(taskId, paneId); } else { - // Main pane: open the "+" tab menu. - window.dispatchEvent(new CustomEvent("termic-new-tab-menu", { detail: { taskId } })); + useUI.getState().openNewTabPalette(taskId); } return; } diff --git a/src/lib/scratchTabs.ts b/src/lib/scratchTabs.ts index 9fae8d743..682713a66 100644 --- a/src/lib/scratchTabs.ts +++ b/src/lib/scratchTabs.ts @@ -15,6 +15,7 @@ import * as ipc from "@/lib/ipc"; import type { ScratchTab } from "@/lib/types"; import { SCRATCH_UNTITLED } from "@/lib/scratchTitle"; import { PROFILE_NS } from "@/lib/profileScope"; +import { addLeafTab } from "@/lib/splitTree"; /** The pads already open in `taskId`'s strip, by scratch id. */ function openScratchIds(taskId: string): Set { @@ -111,7 +112,7 @@ export function openScratchFileTab( * pad rather than a tab pointing at nothing. */ export async function newScratchTab( taskId: string, - opts?: { scope?: "task" | "project" | "profile" | "global"; projectId?: string } + opts?: { scope?: "task" | "project" | "profile" | "global"; projectId?: string; paneId?: string | null } ): Promise { const scratchId = crypto.randomUUID(); const scope = opts?.scope ?? "task"; @@ -124,7 +125,22 @@ export async function newScratchTab( ? `project_${projectId ?? ""}` : taskId; const tab = scratchTab({ id: scratchId, scope, projectId, targetId }); - useApp.getState().addTab(taskId, tab); + if (opts?.paneId) { + const paneTab = { ...tab, paneId: opts.paneId }; + useApp.setState(s => { + const tree = s.splitTree[taskId]; + const newTree = tree ? addLeafTab(tree, opts.paneId!, paneTab.id) : tree; + return { + tabs: { ...s.tabs, [taskId]: [...(s.tabs[taskId] ?? []), paneTab] }, + ...(newTree ? { splitTree: { ...s.splitTree, [taskId]: newTree } } : {}), + activePaneId: { ...s.activePaneId, [taskId]: opts.paneId! }, + }; + }); + useApp.getState().syncDurableTabs(taskId); + useApp.getState().saveSplitLayout(taskId); + } else { + useApp.getState().addTab(taskId, tab); + } try { await ipc.scratchWrite(targetId, scratchId, ""); } catch (e) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 087cb1efd..9625e27ca 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1745,6 +1745,10 @@ export interface TerminalTab extends BaseTab { * shows the offer, since it is where the user is answering it. Runtime * only, never persisted. */ sudoTouchIdInstall?: string; + /** Text typed at the shell's first prompt (e.g. a command run from the + * new-tab palette), then "" once sent so a respawn never retypes it. + * Ending with `\r` runs it. Runtime only, never persisted. */ + initialInput?: string; } /** One entry in a terminal tab's message queue. `repeat` is the total diff --git a/src/locales/en/task.ts b/src/locales/en/task.ts index 547f32c90..dab510c18 100644 --- a/src/locales/en/task.ts +++ b/src/locales/en/task.ts @@ -20,6 +20,23 @@ export default { now: "now", }, + newTabPalette: { + srTitle: "New tab launcher", + srDesc: "Search and launch agents, terminals, and scratchpads", + placeholder: "Search agents, terminals, scratchpads...", + noMatching: "No matching agents or tools", + runCommand: "Run command: {{cmd}}", + runCommandTip: "Run in a new terminal", + groupAgents: "Agents", + groupTerminals: "Terminals", + groupScratchpads: "Scratchpads", + groupResume: "Resume closed", + manage: "Configure tools in Settings", + hintNav: "to navigate", + hintRun: "to launch", + hintDismiss: "to close", + }, + tabBar: { broadcastTip: "Broadcast a message to all agents from this task ({{combo}})", splitRightTip: "Split right ({{combo}})", diff --git a/src/locales/zh-CN/task.ts b/src/locales/zh-CN/task.ts index 7c6bd3394..bbdd0142a 100644 --- a/src/locales/zh-CN/task.ts +++ b/src/locales/zh-CN/task.ts @@ -18,6 +18,23 @@ export default { now: "刚刚", }, + newTabPalette: { + srTitle: "新建标签页启动器", + srDesc: "搜索并启动智能体、终端和便签", + placeholder: "搜索智能体、终端、便签…", + noMatching: "未找到匹配的智能体或工具", + runCommand: "运行命令:{{cmd}}", + runCommandTip: "在新建终端中运行", + groupAgents: "智能体", + groupTerminals: "终端", + groupScratchpads: "便签", + groupResume: "恢复已关闭标签", + manage: "在设置中配置工具", + hintNav: "导航", + hintRun: "启动", + hintDismiss: "关闭", + }, + tabBar: { broadcastTip: "向此任务的所有智能体广播消息({{combo}})", splitRightTip: "向右分屏({{combo}})", diff --git a/src/store/app.ts b/src/store/app.ts index 10dd34e80..c22941651 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -320,7 +320,7 @@ export interface AppState { setSplitRatio: (taskId: string, splitId: string, ratio: number) => void; /** Once the SplitLauncher in an empty leaf has chosen a cli, create the tab * and wire it into the leaf. Returns the new tab id. */ - addPaneTab: (taskId: string, paneId: string, cli: string) => string; + addPaneTab: (taskId: string, paneId: string, cli: string, opts?: { title?: string; command?: string; initialInput?: string }) => string; /** Switch which tab is visible in a split pane's mini tab strip. */ setPaneActiveTab: (taskId: string, paneId: string, tabId: string) => void; /** Close a specific tab within a split pane (kills PTY if terminal). */ @@ -403,7 +403,7 @@ export interface AppState { * `sessionId` so the agent resumes via `--resume ` (see * `decideResume` in TerminalPane) instead of starting a new * conversation. Removes the entry from `closedTabs` once reopened. */ - resumeClosedTab: (taskId: string, entryId: string, opts?: { afterTabId?: string; beforeTabId?: string }) => void; + resumeClosedTab: (taskId: string, entryId: string, opts?: { afterTabId?: string; beforeTabId?: string; paneId?: string | null }) => void; setActiveTabId: (taskId: string, tabId: string) => void; /** Show a place WITHOUT treating it as a visit — see the implementation. */ previewPlace: (taskId: string, tabId: string) => void; @@ -1655,14 +1655,16 @@ export const useApp = create((set, get) => ({ return { splitTree: { ...s.splitTree, [taskId]: updateSplitRatio(tree, splitId, ratio) } }; }), - addPaneTab: (taskId, paneId, cli) => { + addPaneTab: (taskId, paneId, cli, opts) => { const s = get(); const tabId = crypto.randomUUID(); const tab: TerminalTab = { id: tabId, type: 'terminal', - title: cli === 'shell' ? 'Terminal' : agentDisplayName(cli, s.agents), + title: opts?.title ?? (cli === 'shell' ? 'Terminal' : agentDisplayName(cli, s.agents)), cli, paneId, + ...(opts?.command ? { command: opts.command } : {}), + ...(opts?.initialInput ? { initialInput: opts.initialInput } : {}), }; set(s2 => { const tree = s2.splitTree[taskId]; @@ -2475,8 +2477,24 @@ export const useApp = create((set, get) => ({ command: entry.command ?? undefined, sessionId: entry.sessionId ?? undefined, agentArgs: entry.agentArgs, + ...(opts?.paneId ? { paneId: opts.paneId } : {}), }; - get().addTab(taskId, tab, { afterTabId: opts?.afterTabId, beforeTabId: opts?.beforeTabId }); + if (opts?.paneId) { + set(s2 => { + const tree = s2.splitTree[taskId]; + const newTree = tree ? addLeafTab(tree, opts.paneId!, tab.id) : tree; + return { + tabs: { ...s2.tabs, [taskId]: [...(s2.tabs[taskId] ?? []), tab] }, + ...(newTree ? { splitTree: { ...s2.splitTree, [taskId]: newTree } } : {}), + activePaneId: { ...s2.activePaneId, [taskId]: opts.paneId! }, + }; + }); + focusTerminalTab(tab.id); + get().syncDurableTabs(taskId); + get().saveSplitLayout(taskId); + } else { + get().addTab(taskId, tab, { afterTabId: opts?.afterTabId, beforeTabId: opts?.beforeTabId }); + } }, setActiveTabId: (taskId, tabId) => { diff --git a/src/store/ui.ts b/src/store/ui.ts index 4adcfa94b..5b7f14865 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -232,6 +232,9 @@ interface UIState { commandPaletteView: "root" | "theme"; /** ⌥⌘P prompt palette — searchable list of library prompts (title only). */ promptPaletteOpen: boolean; + /** Quick new-tab palette (⌘T / + tab) with searchable agents, terminals, and scratchpads. + * `null` when closed; when open, holds the targeted task and optional split pane id. */ + newTabPalette: { taskId: string; paneId?: string | null } | null; /** "Set syntax" picker — which editor tab it will re-highlight; null = * closed. Keyed by tab (not just task) because the pick applies to one * buffer, and the palette can outlive a tab switch underneath it. */ @@ -403,6 +406,8 @@ interface UIState { closeCommandPalette: () => void; openPromptPalette: () => void; closePromptPalette: () => void; + openNewTabPalette: (taskId: string, paneId?: string | null) => void; + closeNewTabPalette: () => void; openSyntaxPalette: (taskId: string, tabId: string) => void; closeSyntaxPalette: () => void; /** Open the destination picker for `prompt`, seeding the editable body. */ @@ -568,6 +573,7 @@ export const useUI = create((set, get) => ({ commandPaletteOpen: false, commandPaletteView: "root", promptPaletteOpen: false, + newTabPalette: null, syntaxPaletteFor: null, promptFire: null, renameRequest: null, @@ -669,6 +675,8 @@ export const useUI = create((set, get) => ({ closeCommandPalette:() => set({ commandPaletteOpen: false }), openPromptPalette: () => set({ promptPaletteOpen: true }), closePromptPalette:() => set({ promptPaletteOpen: false }), + openNewTabPalette: (taskId: string, paneId?: string | null) => set({ newTabPalette: { taskId, paneId } }), + closeNewTabPalette:() => set({ newTabPalette: null }), openSyntaxPalette: (taskId: string, tabId: string) => set({ syntaxPaletteFor: { taskId, tabId } }), closeSyntaxPalette:() => set({ syntaxPaletteFor: null }), openPromptFire: (prompt) => set({ promptFire: { prompt, body: prompt.body } }), From ea9b07f96c3de5e42c332fc845f5baafa9fcc8e4 Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:18:45 +0530 Subject: [PATCH 2/2] fix(tab): guard project scratchpad in new-tab palette when task has no project Omit the project scratchpad launcher entry when task has no project_id, preventing project-less tasks from creating orphaned project_ scratchpads. Refs #414 --- src/components/dialogs/NewTabPalette.test.ts | 11 +++++++++++ src/components/dialogs/NewTabPalette.tsx | 20 ++++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/components/dialogs/NewTabPalette.test.ts b/src/components/dialogs/NewTabPalette.test.ts index 2de2eff98..b7648cb8b 100644 --- a/src/components/dialogs/NewTabPalette.test.ts +++ b/src/components/dialogs/NewTabPalette.test.ts @@ -19,6 +19,7 @@ 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", () => { @@ -121,4 +122,14 @@ describe("NewTabPalette fuzzy search logic", () => { 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); + }); }); diff --git a/src/components/dialogs/NewTabPalette.tsx b/src/components/dialogs/NewTabPalette.tsx index edf156d35..56d2231fd 100644 --- a/src/components/dialogs/NewTabPalette.tsx +++ b/src/components/dialogs/NewTabPalette.tsx @@ -39,6 +39,18 @@ export type NewTabPaletteItem = { run: () => void; }; +export function getScratchScopes( + t: (key: string) => string, + hasProject: boolean, +): Array<{ scope: "task" | "project" | "profile" | "global"; title: string }> { + return [ + { scope: "task", title: t("newTab.scratchpad") }, + ...(hasProject ? [{ scope: "project" as const, title: t("newTab.scratchpadProject") }] : []), + { scope: "profile", title: t("newTab.scratchpadProfile") }, + { scope: "global", title: t("newTab.scratchpadGlobal") }, + ]; +} + export function NewTabPalette() { const { t } = useTranslation("task"); const paletteState = useUI(s => s.newTabPalette); @@ -154,12 +166,8 @@ export function NewTabPalette() { } // 4. Scratchpads - const scratchScopes: Array<{ scope: "task" | "project" | "profile" | "global"; title: string }> = [ - { scope: "task", title: t("newTab.scratchpad") }, - { scope: "project", title: t("newTab.scratchpadProject") }, - { scope: "profile", title: t("newTab.scratchpadProfile") }, - { scope: "global", title: t("newTab.scratchpadGlobal") }, - ]; + const hasProject = !!task?.project_id; + const scratchScopes = getScratchScopes(t, hasProject); for (const sc of scratchScopes) { items.push({ id: `scratch-${sc.scope}`,