diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e7..b57c13032c 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, @@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 176e0300ad..6450484869 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -73,6 +73,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts new file mode 100644 index 0000000000..4f291d5a48 --- /dev/null +++ b/apps/web/src/commandPaletteStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +interface CommandPaletteStore { + open: boolean; + setOpen: (open: boolean) => void; + toggleOpen: () => void; +} + +export const useCommandPaletteStore = create((set) => ({ + open: false, + setOpen: (open) => set({ open }), + toggleOpen: () => set((state) => ({ open: !state.open })), +})); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a2b27bb1e9..7cd3d7c54e 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,7 @@ import { useEffect, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; +import { CommandPalette } from "./CommandPalette"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; @@ -28,22 +29,24 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - - - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - }} - > - - - - {children} - + + + + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + }} + > + + + + {children} + + ); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20816d6935..4dc4c23761 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -25,10 +25,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, - removeInlineTerminalContextPlaceholder, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; @@ -37,8 +37,10 @@ import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; const THREAD_ID = "thread-browser-test" as ThreadId; +const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const SECOND_PROJECT_ID = "project-2" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -425,6 +427,18 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function createProjectlessSnapshot(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-projectless-target" as MessageId, + targetText: "projectless", + }); + return { + ...snapshot, + projects: [], + threads: [], + }; +} + function withProjectScripts( snapshot: OrchestrationReadModel, scripts: OrchestrationReadModel["projects"][number]["scripts"], @@ -513,6 +527,100 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithSecondaryProject(options?: { + includeSecondaryThread?: boolean; + includeArchivedSecondaryThread?: boolean; +}): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-secondary-project-target" as MessageId, + targetText: "secondary project", + }); + const includeSecondaryThread = options?.includeSecondaryThread ?? true; + const includeArchivedSecondaryThread = options?.includeArchivedSecondaryThread ?? true; + const secondaryThreads: OrchestrationReadModel["threads"] = includeSecondaryThread + ? [ + { + id: "thread-secondary-project" as ThreadId, + projectId: SECOND_PROJECT_ID, + title: "Release checklist", + modelSelection: { provider: "codex", model: "gpt-5" }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "release/docs-portal", + worktreePath: null, + latestTurn: null, + createdAt: isoAt(30), + updatedAt: isoAt(31), + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: "thread-secondary-project" as ThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(31), + }, + archivedAt: null, + }, + ] + : []; + const archivedSecondaryThreads: OrchestrationReadModel["threads"] = includeArchivedSecondaryThread + ? [ + { + id: ARCHIVED_SECONDARY_THREAD_ID, + projectId: SECOND_PROJECT_ID, + title: "Archived Docs Notes", + modelSelection: { provider: "codex", model: "gpt-5" }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "release/docs-archive", + worktreePath: null, + latestTurn: null, + createdAt: isoAt(24), + updatedAt: isoAt(25), + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: ARCHIVED_SECONDARY_THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(25), + }, + archivedAt: isoAt(26), + }, + ] + : []; + + return { + ...snapshot, + projects: [ + ...snapshot.projects, + { + id: SECOND_PROJECT_ID, + title: "Docs Portal", + workspaceRoot: "/repo/clients/docs-portal", + defaultModelSelection: { provider: "codex", model: "gpt-5" }, + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [...snapshot.threads, ...secondaryThreads, ...archivedSecondaryThreads], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { @@ -755,6 +863,16 @@ async function triggerChatNewShortcutUntilPath( throw new Error(`${errorMessage} Last path: ${pathname}`); } +async function openCommandPaletteFromTrigger(): Promise { + const trigger = page.getByTestId("command-palette-trigger"); + await expect.element(trigger).toBeInTheDocument(); + await trigger.click(); + await waitForElement( + () => document.querySelector('[data-testid="command-palette"]'), + "Command palette should have opened from the sidebar trigger.", + ); +} + async function waitForNewThreadShortcutLabel(): Promise { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); @@ -765,6 +883,26 @@ async function waitForNewThreadShortcutLabel(): Promise { await expect.element(page.getByText(shortcutLabel)).toBeInTheDocument(); } +async function waitForCommandPaletteShortcutLabel(): Promise { + await waitForElement( + () => document.querySelector('[data-testid="command-palette-trigger"] kbd'), + "Command palette shortcut label did not render.", + ); +} + +function placeCaretAtEnd(element: HTMLElement): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); if (images.length === 0) { @@ -887,6 +1025,7 @@ async function mountChatView(options: { customWsRpcResolver = null; await screen.unmount(); host.remove(); + await waitForLayout(); }; return { @@ -952,6 +1091,9 @@ describe("ChatView timeline estimator parity (full app)", () => { stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); + useCommandPaletteStore.setState({ + open: false, + }); useStore.setState({ projects: [], threads: [], @@ -1675,7 +1817,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps removed terminal context pills removed when a new one is added", async () => { + it("keeps backspaced terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; useComposerDraftStore.getState().addTerminalContext( @@ -1705,11 +1847,16 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; - const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_ID, nextPrompt.prompt); - store.removeTerminalContext(THREAD_ID, "ctx-removed"); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + placeCaretAtEnd(composerEditor); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Backspace", + bubbles: true, + cancelable: true, + }), + ); await vi.waitFor( () => { @@ -2247,6 +2394,359 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("does not consume chat.new when there is no project context", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createProjectlessSnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + dispatchChatNewShortcut(); + await waitForLayout(); + + expect(mounted.router.state.location.pathname).toBe(`/${THREAD_ID}`); + expect(Object.keys(useComposerDraftStore.getState().draftThreadsByThreadId)).toHaveLength(0); + } finally { + await mounted.cleanup(); + } + }); + + it("renders the configurable shortcut and runs a command from the sidebar trigger", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, + targetText: "command palette shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("New thread in Project", { exact: true }).click(); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the command palette.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("filters command palette results as the user types", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-search-test" as MessageId, + targetText: "command palette search test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); + await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps project-context thread matches available when searching by project name", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("Release checklist", { exact: true })) + .toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("searches projects by path and opens the latest thread for that project", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("Docs Portal", { exact: true }).click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => path === "/thread-secondary-project", + "Route should have changed to the latest thread for the selected project.", + ); + expect(nextPath).toBe("/thread-secondary-project"); + expect( + useComposerDraftStore.getState().draftThreadsByThreadId[ + "thread-secondary-project" as ThreadId + ], + ).toBeUndefined(); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a new thread from project search when no active project thread exists", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject({ includeSecondaryThread: false }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("Docs Portal", { exact: true }).click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== `/${THREAD_ID}`, + "Route should have changed to a new draft thread UUID from the project search result.", + ); + const nextThreadId = nextPath.slice(1) as ThreadId; + const draftThread = useComposerDraftStore.getState().draftThreadsByThreadId[nextThreadId]; + expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); + expect(draftThread?.envMode).toBe("worktree"); + } finally { + await mounted.cleanup(); + } + }); + + it("filters archived threads out of command palette search results", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs-archive"); + await expect + .element(palette.getByText("Archived Docs Notes", { exact: true })) + .not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7562f845e2..cc15bb09ad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -90,6 +90,7 @@ import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -2330,7 +2331,9 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId || event.defaultPrevented) return; + if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { + return; + } const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts new file mode 100644 index 0000000000..3869b0e6c3 --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from "vitest"; +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import type { Thread } from "../types"; +import { + buildThreadActionItems, + filterCommandPaletteGroups, + type CommandPaletteGroup, +} from "./CommandPalette.logic"; + +describe("buildThreadActionItems", () => { + it("orders threads by most recent activity and formats timestamps from updatedAt", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); + + try { + const projectId = ProjectId.makeUnsafe("project-1"); + const threads = [ + { + id: ThreadId.makeUnsafe("thread-older"), + codexThreadId: null, + projectId, + title: "Older thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-24T12:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + { + id: ThreadId.makeUnsafe("thread-newer"), + codexThreadId: null, + projectId, + title: "Newer thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-20T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-20T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + ] satisfies Thread[]; + + const items = buildThreadActionItems({ + threads, + projectTitleById: new Map([[projectId, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_threadId) => undefined, + }); + + expect(items.map((item) => item.value)).toEqual([ + "thread:thread-older", + "thread:thread-newer", + ]); + expect(items[0]?.timestamp).toBe("1d ago"); + expect(items[1]?.timestamp).toBe("5d ago"); + } finally { + vi.useRealTimers(); + } + }); + + it("ranks thread title matches ahead of contextual project-name matches", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const threadItems = buildThreadActionItems({ + threads: [ + { + id: ThreadId.makeUnsafe("thread-context-match"), + codexThreadId: null, + projectId, + title: "Fix navbar spacing", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-20T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + { + id: ThreadId.makeUnsafe("thread-title-match"), + codexThreadId: null, + projectId, + title: "Project kickoff notes", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-19T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + ] satisfies Thread[], + projectTitleById: new Map([[projectId, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_threadId) => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "project", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: threadItems, + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("threads-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:thread-title-match", + "thread:thread-context-match", + ]); + }); + + it("preserves thread project-name matches when there is no stronger title match", () => { + const group: CommandPaletteGroup = { + value: "threads-search", + label: "Threads", + items: [ + { + kind: "action", + value: "thread:project-context-only", + searchTerms: ["Fix navbar spacing", "Project"], + title: "Fix navbar spacing", + description: "Project", + icon: null, + run: async () => undefined, + }, + ], + }; + + const groups = filterCommandPaletteGroups({ + activeGroups: [group], + query: "project", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); + }); + + it("filters archived threads out of thread search items", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const items = buildThreadActionItems({ + threads: [ + { + id: ThreadId.makeUnsafe("thread-active"), + codexThreadId: null, + projectId, + title: "Active thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-19T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + { + id: ThreadId.makeUnsafe("thread-archived"), + codexThreadId: null, + projectId, + title: "Archived thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + ] satisfies Thread[], + projectTitleById: new Map([[projectId, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_threadId) => undefined, + }); + + expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts new file mode 100644 index 0000000000..26476f7fc8 --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -0,0 +1,264 @@ +import { type KeybindingCommand } from "@t3tools/contracts"; +import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { type ReactNode } from "react"; +import { sortThreads } from "../lib/threadSort"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { type Project, type Thread } from "../types"; + +export const RECENT_THREAD_LIMIT = 12; +export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ADDON_ICON_CLASS = "size-4"; + +export interface CommandPaletteItem { + readonly kind: "action" | "submenu"; + readonly value: string; + readonly searchTerms: ReadonlyArray; + readonly title: ReactNode; + readonly description?: string; + readonly timestamp?: string; + readonly icon: ReactNode; + readonly shortcutCommand?: KeybindingCommand; +} + +export interface CommandPaletteActionItem extends CommandPaletteItem { + readonly kind: "action"; + readonly keepOpen?: boolean; + readonly run: () => Promise; +} + +export interface CommandPaletteSubmenuItem extends CommandPaletteItem { + readonly kind: "submenu"; + readonly addonIcon: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; +} + +export interface CommandPaletteGroup { + readonly value: string; + readonly label: string; + readonly items: ReadonlyArray; +} + +export interface CommandPaletteView { + readonly addonIcon: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; +} + +export type CommandPaletteMode = "root" | "submenu"; + +export function normalizeSearchText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +export function buildProjectActionItems(input: { + projects: ReadonlyArray; + valuePrefix: string; + icon: ReactNode; + runProject: (projectId: Project["id"]) => Promise; +}): CommandPaletteActionItem[] { + return input.projects.map((project) => ({ + kind: "action", + value: `${input.valuePrefix}:${project.id}`, + searchTerms: [project.name, project.cwd], + title: project.name, + description: project.cwd, + icon: input.icon, + run: async () => { + await input.runProject(project.id); + }, + })); +} + +export function buildThreadActionItems(input: { + threads: ReadonlyArray; + activeThreadId?: Thread["id"]; + projectTitleById: ReadonlyMap; + sortOrder: SidebarThreadSortOrder; + icon: ReactNode; + runThread: (threadId: Thread["id"]) => Promise; + limit?: number; +}): CommandPaletteActionItem[] { + const sortedThreads = sortThreads( + input.threads.filter((thread) => thread.archivedAt === null), + input.sortOrder, + ); + const visibleThreads = + input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit); + + return visibleThreads.map((thread) => { + const projectTitle = input.projectTitleById.get(thread.projectId); + const descriptionParts: string[] = []; + + if (projectTitle) { + descriptionParts.push(projectTitle); + } + if (thread.branch) { + descriptionParts.push(`#${thread.branch}`); + } + if (thread.id === input.activeThreadId) { + descriptionParts.push("Current thread"); + } + + return { + kind: "action", + value: `thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], + title: thread.title, + description: descriptionParts.join(" · "), + timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt), + icon: input.icon, + run: async () => { + await input.runThread(thread.id); + }, + }; + }); +} + +function rankSearchFieldMatch(field: string, normalizedQuery: string): number { + const normalizedField = normalizeSearchText(field); + if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { + return Number.NEGATIVE_INFINITY; + } + if (normalizedField === normalizedQuery) { + return 3; + } + if (normalizedField.startsWith(normalizedQuery)) { + return 2; + } + return 1; +} + +function rankCommandPaletteItemMatch( + item: CommandPaletteActionItem | CommandPaletteSubmenuItem, + normalizedQuery: string, +): number { + const terms = item.searchTerms.filter((term) => term.length > 0); + if (terms.length === 0) { + return 0; + } + + for (const [index, field] of terms.entries()) { + const fieldRank = rankSearchFieldMatch(field, normalizedQuery); + if (fieldRank !== Number.NEGATIVE_INFINITY) { + return 1_000 - index * 100 + fieldRank; + } + } + + return 0; +} + +export function filterCommandPaletteGroups(input: { + activeGroups: ReadonlyArray; + query: string; + isInSubmenu: boolean; + projectSearchItems: ReadonlyArray; + threadSearchItems: ReadonlyArray; +}): CommandPaletteGroup[] { + const isActionsFilter = input.query.startsWith(">"); + const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; + const normalizedQuery = normalizeSearchText(searchQuery); + + if (normalizedQuery.length === 0) { + if (isActionsFilter) { + return input.activeGroups.filter((group) => group.value === "actions"); + } + return [...input.activeGroups]; + } + + let baseGroups = [...input.activeGroups]; + if (isActionsFilter) { + baseGroups = baseGroups.filter((group) => group.value === "actions"); + } else if (!input.isInSubmenu) { + baseGroups = baseGroups.filter((group) => group.value !== "recent-threads"); + } + + const searchableGroups = [...baseGroups]; + if (!input.isInSubmenu && !isActionsFilter) { + if (input.projectSearchItems.length > 0) { + searchableGroups.push({ + value: "projects-search", + label: "Projects", + items: input.projectSearchItems, + }); + } + if (input.threadSearchItems.length > 0) { + searchableGroups.push({ + value: "threads-search", + label: "Threads", + items: input.threadSearchItems, + }); + } + } + + return searchableGroups.flatMap((group) => { + const items = group.items + .map((item, index) => { + const haystack = normalizeSearchText(item.searchTerms.join(" ")); + if (!haystack.includes(normalizedQuery)) { + return null; + } + + return { + item, + index, + rank: rankCommandPaletteItemMatch(item, normalizedQuery), + }; + }) + .filter( + (entry): entry is { item: (typeof group.items)[number]; index: number; rank: number } => + entry !== null, + ) + .toSorted((left, right) => right.rank - left.rank || left.index - right.index) + .map((entry) => entry.item); + + if (items.length === 0) { + return []; + } + + return [{ value: group.value, label: group.label, items }]; + }); +} + +export function getCommandPaletteMode(input: { + currentView: CommandPaletteView | null; +}): CommandPaletteMode { + return input.currentView ? "submenu" : "root"; +} + +export function buildRootGroups(input: { + actionItems: ReadonlyArray; + recentThreadItems: ReadonlyArray; +}): CommandPaletteGroup[] { + const groups: CommandPaletteGroup[] = []; + if (input.actionItems.length > 0) { + groups.push({ value: "actions", label: "Actions", items: input.actionItems }); + } + if (input.recentThreadItems.length > 0) { + groups.push({ + value: "recent-threads", + label: "Recent Threads", + items: input.recentThreadItems, + }); + } + return groups; +} + +export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { + switch (mode) { + case "root": + return "Search commands, projects, and threads..."; + case "submenu": + return "Search..."; + } +} + +export function getCommandPaletteInputStartAddon(input: { + mode: CommandPaletteMode; + currentViewAddonIcon: ReactNode | null; +}): ReactNode | undefined { + if (input.mode === "submenu") { + return input.currentViewAddonIcon ?? undefined; + } + return undefined; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000000..d48d343879 --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,418 @@ +"use client"; + +import type { ProjectId } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { + ArrowDownIcon, + ArrowUpIcon, + FolderIcon, + MessageSquareIcon, + SettingsIcon, + SquarePenIcon, +} from "lucide-react"; +import { + useDeferredValue, + useEffect, + useMemo, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useSettings } from "../hooks/useSettings"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { getLatestThreadForProject } from "../lib/threadSort"; +import { cn } from "../lib/utils"; +import { useStore } from "../store"; +import { + ADDON_ICON_CLASS, + buildProjectActionItems, + buildRootGroups, + buildThreadActionItems, + type CommandPaletteActionItem, + type CommandPaletteSubmenuItem, + type CommandPaletteView, + filterCommandPaletteGroups, + getCommandPaletteInputPlaceholder, + getCommandPaletteInputStartAddon, + getCommandPaletteMode, + ITEM_ICON_CLASS, + RECENT_THREAD_LIMIT, +} from "./CommandPalette.logic"; +import { CommandPaletteResults } from "./CommandPaletteResults"; +import { + Command, + CommandDialog, + CommandDialogPopup, + CommandFooter, + CommandInput, + CommandPanel, +} from "./ui/command"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { toastManager } from "./ui/toast"; + +export function CommandPalette({ children }: { children: ReactNode }) { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + + return ( + + {children} + + + ); +} + +function CommandPaletteDialog() { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + + useEffect(() => { + return () => { + setOpen(false); + }; + }, [setOpen]); + + if (!open) { + return null; + } + + return ; +} + +function OpenCommandPaletteDialog() { + const navigate = useNavigate(); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const isActionsOnly = deferredQuery.startsWith(">"); + const settings = useSettings(); + const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); + const projects = useStore((store) => store.projects); + const threads = useStore((store) => store.threads); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? []; + const [viewStack, setViewStack] = useState([]); + const currentView = viewStack.at(-1) ?? null; + const paletteMode = getCommandPaletteMode({ currentView }); + + const projectTitleById = useMemo( + () => new Map(projects.map((project) => [project.id, project.name])), + [projects], + ); + + const activeThreadId = activeThread?.id; + const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + + const openProjectFromSearch = useMemo( + () => async (projectId: (typeof projects)[number]["id"]) => { + const latestThread = getLatestThreadForProject( + threads, + projectId, + settings.sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$threadId", + params: { threadId: latestThread.id }, + }); + return; + } + + await handleNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }); + }, + [ + handleNewThread, + navigate, + settings.defaultThreadEnvMode, + settings.sidebarThreadSortOrder, + threads, + ], + ); + + const projectSearchItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "project", + icon: , + runProject: openProjectFromSearch, + }), + [openProjectFromSearch, projects], + ); + + const projectThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: , + runProject: async (projectId) => { + await handleNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }); + }, + }), + [handleNewThread, projects, settings.defaultThreadEnvMode], + ); + + const projectFreshThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-fresh-thread-in", + icon: , + runProject: async (projectId) => { + await handleNewThread(projectId, { + envMode: "local", + }); + }, + }), + [handleNewThread, projects], + ); + + const allThreadItems = useMemo( + () => + buildThreadActionItems({ + threads, + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: settings.sidebarThreadSortOrder, + icon: , + runThread: async (threadId) => { + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + }), + [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads], + ); + const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + + function pushView(item: CommandPaletteSubmenuItem): void { + setViewStack((previousViews) => [ + ...previousViews, + { + addonIcon: item.addonIcon, + groups: item.groups, + ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + }, + ]); + setQuery(item.initialQuery ?? ""); + } + + function popView(): void { + setViewStack((previousViews) => previousViews.slice(0, -1)); + setQuery(""); + } + + function handleQueryChange(nextQuery: string): void { + setQuery(nextQuery); + if (nextQuery === "" && currentView?.initialQuery) { + popView(); + } + } + + const actionItems: Array = []; + + if (projects.length > 0) { + const activeProjectTitle = currentProjectId + ? (projectTitleById.get(currentProjectId) ?? null) + : null; + + if (activeProjectTitle) { + actionItems.push({ + kind: "action", + value: "action:new-thread", + searchTerms: ["new thread", "chat", "create", "draft"], + title: ( + <> + New thread in {activeProjectTitle} + + ), + icon: , + shortcutCommand: "chat.new", + run: async () => { + await startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + projects, + }); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:new-fresh-thread", + searchTerms: ["new fresh thread", "chat", "create", "default environment"], + title: ( + <> + New fresh thread in {activeProjectTitle} + + ), + icon: , + shortcutCommand: "chat.newLocal", + run: async () => { + await startNewLocalThreadFromContext({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + projects, + }); + }, + }); + } + + actionItems.push({ + kind: "submenu", + value: "action:new-thread-in", + searchTerms: ["new thread", "project", "pick", "choose", "select"], + title: "New thread in...", + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + }); + + actionItems.push({ + kind: "submenu", + value: "action:new-fresh-thread-in", + searchTerms: [ + "new fresh thread", + "project", + "pick", + "choose", + "select", + "default environment", + ], + title: "New fresh thread in...", + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: projectFreshThreadItems }], + }); + } + + actionItems.push({ + kind: "action", + value: "action:settings", + searchTerms: ["settings", "preferences", "configuration", "keybindings"], + title: "Open settings", + icon: , + run: async () => { + await navigate({ to: "/settings" }); + }, + }); + + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const activeGroups = currentView ? currentView.groups : rootGroups; + + const displayedGroups = filterCommandPaletteGroups({ + activeGroups, + query: deferredQuery, + isInSubmenu: currentView !== null, + projectSearchItems: projectSearchItems, + threadSearchItems: allThreadItems, + }); + + const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); + const inputStartAddon = getCommandPaletteInputStartAddon({ + mode: paletteMode, + currentViewAddonIcon: currentView?.addonIcon ?? null, + }); + const isSubmenu = paletteMode === "submenu"; + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Backspace" && query === "" && isSubmenu) { + event.preventDefault(); + popView(); + } + } + + function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void { + if (item.kind === "submenu") { + pushView(item); + return; + } + + if (!item.keepOpen) { + setOpen(false); + } + + void item.run().catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Unable to run command", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + + return ( + + + + + + + +
+ + + + + + + + Navigate + + + Enter + Select + + {isSubmenu ? ( + + Backspace + Back + + ) : null} + + Esc + Close + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx new file mode 100644 index 0000000000..72700471ba --- /dev/null +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -0,0 +1,103 @@ +import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { ChevronRightIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../keybindings"; +import { + type CommandPaletteActionItem, + type CommandPaletteGroup, + type CommandPaletteSubmenuItem, +} from "./CommandPalette.logic"; +import { + CommandCollection, + CommandGroup, + CommandGroupLabel, + CommandItem, + CommandList, + CommandShortcut, +} from "./ui/command"; + +interface CommandPaletteResultsProps { + emptyStateMessage?: string; + groups: ReadonlyArray; + isActionsOnly: boolean; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +} + +export function CommandPaletteResults(props: CommandPaletteResultsProps) { + if (props.groups.length === 0) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); + } + + return ( + + {props.groups.map((group) => ( + + {group.label} + + {(item) => ( + + )} + + + ))} + + ); +} + +function CommandPaletteResultRow(props: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +}) { + const shortcutLabel = props.item.shortcutCommand + ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand) + : null; + + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onExecuteItem(props.item); + }} + > + {props.item.icon} + {props.item.description ? ( + + {props.item.title} + + {props.item.description} + + + ) : ( + + {props.item.title} + + )} + {props.item.timestamp ? ( + + {props.item.timestamp} + + ) : null} + {shortcutLabel ? {shortcutLabel} : null} + {props.item.kind === "submenu" ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d4cd25db4c..c270211f42 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -16,7 +16,6 @@ import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; @@ -633,133 +632,6 @@ function makeThread(overrides: Partial = {}): Thread { }; } -describe("sortThreadsForSidebar", () => { - it("sorts threads by the latest user message in recency mode", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older", - createdAt: "2026-03-09T10:01:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer", - createdAt: "2026-03-09T10:06:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", - }, - ], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("falls back to thread timestamps when there is no user message", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "assistant", - text: "assistant only", - createdAt: "2026-03-09T10:02:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("can sort threads by createdAt when configured", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - }), - ], - "created_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-1"), - ThreadId.makeUnsafe("thread-2"), - ]); - }); -}); - describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ @@ -829,7 +701,6 @@ describe("getFallbackThreadIdAfterDelete", () => { expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-next")); }); }); - describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 16c7752f74..5f3d10e9d4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,11 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, + type ThreadSortInput, +} from "../lib/threadSort"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { @@ -17,10 +23,6 @@ type SidebarProject = { createdAt?: string | undefined; updatedAt?: string | undefined; }; -type SidebarThreadSortInput = Pick & { - latestUserMessageAt?: string | null; - messages?: Pick[]; -}; export type ThreadTraversalDirection = "previous" | "next"; @@ -408,61 +410,8 @@ export function getVisibleThreadsForProject>(input: }; } -function toSortableTimestamp(iso: string | undefined): number | null { - if (!iso) return null; - const ms = Date.parse(iso); - return Number.isFinite(ms) ? ms : null; -} - -function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { - if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; - } - - let latestUserMessageTimestamp: number | null = null; - - for (const message of thread.messages ?? []) { - if (message.role !== "user") continue; - const messageTimestamp = toSortableTimestamp(message.createdAt); - if (messageTimestamp === null) continue; - latestUserMessageTimestamp = - latestUserMessageTimestamp === null - ? messageTimestamp - : Math.max(latestUserMessageTimestamp, messageTimestamp); - } - - if (latestUserMessageTimestamp !== null) { - return latestUserMessageTimestamp; - } - - return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; -} - -function getThreadSortTimestamp( - thread: SidebarThreadSortInput, - sortOrder: SidebarThreadSortOrder | Exclude, -): number { - if (sortOrder === "created_at") { - return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY; - } - return getLatestUserMessageTimestamp(thread); -} - -export function sortThreadsForSidebar< - T extends Pick & SidebarThreadSortInput, ->(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { - return threads.toSorted((left, right) => { - const rightTimestamp = getThreadSortTimestamp(right, sortOrder); - const leftTimestamp = getThreadSortTimestamp(left, sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - if (byTimestamp !== 0) return byTimestamp; - return right.id.localeCompare(left.id); - }); -} - export function getFallbackThreadIdAfterDelete< - T extends Pick & SidebarThreadSortInput, + T extends Pick & ThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; @@ -476,7 +425,7 @@ export function getFallbackThreadIdAfterDelete< } return ( - sortThreadsForSidebar( + sortThreads( threads.filter( (thread) => thread.projectId === deletedThread.projectId && @@ -487,10 +436,9 @@ export function getFallbackThreadIdAfterDelete< )[0]?.id ?? null ); } - export function getProjectSortTimestamp( project: SidebarProject, - projectThreads: readonly SidebarThreadSortInput[], + projectThreads: readonly ThreadSortInput[], sortOrder: Exclude, ): number { if (projectThreads.length > 0) { @@ -508,7 +456,7 @@ export function getProjectSortTimestamp( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & SidebarThreadSortInput, + TThread extends Pick & ThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index efa5124288..d95e513988 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { FolderIcon, GitPullRequestIcon, PlusIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -77,6 +78,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -119,11 +121,12 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { sortThreads } from "../lib/threadSort"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { CommandDialogTrigger } from "./ui/command"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import type { Project, Thread } from "../types"; @@ -629,7 +632,7 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { - const latestThread = sortThreadsForSidebar( + const latestThread = sortThreads( threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), appSettings.sidebarThreadSortOrder, )[0]; @@ -643,7 +646,7 @@ export default function Sidebar() { [appSettings.sidebarThreadSortOrder, navigate, threads], ); - const addProjectFromPath = useCallback( + const addProjectFromInput = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; @@ -666,7 +669,6 @@ export default function Sidebar() { } const projectId = newProjectId(); - const createdAt = new Date().toISOString(); const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; try { await api.orchestration.dispatchCommand({ @@ -679,7 +681,7 @@ export default function Sidebar() { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, - createdAt, + createdAt: new Date().toISOString(), }); await handleNewThread(projectId, { envMode: appSettings.defaultThreadEnvMode, @@ -712,7 +714,7 @@ export default function Sidebar() { ); const handleAddProject = () => { - void addProjectFromPath(newCwd); + void addProjectFromInput(newCwd); }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; @@ -728,7 +730,7 @@ export default function Sidebar() { // Ignore picker failures and leave the current thread selection unchanged. } if (pickedPath) { - await addProjectFromPath(pickedPath); + await addProjectFromInput(pickedPath); } else if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } @@ -1169,7 +1171,7 @@ export default function Sidebar() { const renderedProjects = useMemo( () => sortedProjects.map((project) => { - const projectThreads = sortThreadsForSidebar( + const projectThreads = sortThreads( visibleThreads.filter((thread) => thread.projectId === project.id), appSettings.sidebarThreadSortOrder, ); @@ -1857,6 +1859,11 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + sidebarShortcutLabelOptions, + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1979,6 +1986,29 @@ export default function Sidebar() { ) : ( <> + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index a2bc59c092..759518ddfa 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -30,7 +30,7 @@ function CommandDialogBackdrop({ className, ...props }: CommandDialogPrimitive.B return ( { it("returns effective labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), + "⌘K", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -382,6 +391,23 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches commandPalette.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "commandPalette.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "commandPalette.toggle", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts new file mode 100644 index 0000000000..69fde0f3a1 --- /dev/null +++ b/apps/web/src/lib/chatThreadActions.ts @@ -0,0 +1,73 @@ +import type { ProjectId } from "@t3tools/contracts"; +import type { DraftThreadEnvMode } from "../composerDraftStore"; + +interface ThreadContextLike { + projectId: ProjectId; + branch: string | null; + worktreePath: string | null; +} + +interface DraftThreadContextLike extends ThreadContextLike { + envMode: DraftThreadEnvMode; +} + +interface NewThreadHandler { + ( + projectId: ProjectId, + options?: { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + }, + ): Promise; +} + +export interface ChatThreadActionContext { + readonly activeDraftThread: DraftThreadContextLike | null; + readonly activeThread: ThreadContextLike | undefined; + readonly defaultThreadEnvMode: DraftThreadEnvMode; + readonly handleNewThread: NewThreadHandler; + readonly projects: ReadonlyArray<{ readonly id: ProjectId }>; +} + +export function resolveThreadActionProjectId(context: ChatThreadActionContext): ProjectId | null { + return ( + context.activeThread?.projectId ?? + context.activeDraftThread?.projectId ?? + context.projects[0]?.id ?? + null + ); +} + +export async function startNewThreadFromContext( + context: ChatThreadActionContext, +): Promise { + const projectId = resolveThreadActionProjectId(context); + if (!projectId) { + return false; + } + + await context.handleNewThread(projectId, { + branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null, + worktreePath: + context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null, + envMode: + context.activeDraftThread?.envMode ?? + (context.activeThread?.worktreePath ? "worktree" : "local"), + }); + return true; +} + +export async function startNewLocalThreadFromContext( + context: ChatThreadActionContext, +): Promise { + const projectId = resolveThreadActionProjectId(context); + if (!projectId) { + return false; + } + + await context.handleNewThread(projectId, { + envMode: context.defaultThreadEnvMode, + }); + return true; +} diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts new file mode 100644 index 0000000000..96dc88fe7d --- /dev/null +++ b/apps/web/src/lib/threadSort.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_RUNTIME_MODE, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { Thread } from "../types"; +import { getLatestThreadForProject, sortThreads } from "./threadSort"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-09T10:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-09T10:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +describe("sortThreads", () => { + it("sorts threads by the latest user message in recency mode", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + messages: [ + { + id: "message-1" as never, + role: "user", + text: "older", + createdAt: "2026-03-09T10:01:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:01:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + messages: [ + { + id: "message-2" as never, + role: "user", + text: "newer", + createdAt: "2026-03-09T10:06:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:06:00.000Z", + }, + ], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ]); + }); + + it("falls back to thread timestamps when there is no user message", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + messages: [ + { + id: "message-1" as never, + role: "assistant", + text: "assistant only", + createdAt: "2026-03-09T10:02:00.000Z", + streaming: false, + completedAt: "2026-03-09T10:02:00.000Z", + }, + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + messages: [], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ]); + }); + + it("falls back to id ordering when threads have no sortable timestamps", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "" as never, + updatedAt: undefined, + messages: [], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "" as never, + updatedAt: undefined, + messages: [], + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ]); + }); + + it("can sort threads by createdAt when configured", () => { + const sorted = sortThreads( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + }), + ], + "created_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ]); + }); + + it("returns the latest active thread for a project", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + + const latestThread = getLatestThreadForProject( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + projectId, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + archivedAt: null, + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + projectId, + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-3"), + projectId, + createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", + archivedAt: null, + }), + ], + projectId, + "updated_at", + ); + + expect(latestThread?.id).toBe(ThreadId.makeUnsafe("thread-3")); + }); +}); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts new file mode 100644 index 0000000000..837a7788d3 --- /dev/null +++ b/apps/web/src/lib/threadSort.ts @@ -0,0 +1,73 @@ +import type { ProjectId } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { Thread } from "../types"; + +export type ThreadSortInput = Pick & { + latestUserMessageAt?: string | null; + messages?: Pick[]; +}; + +export function toSortableTimestamp(iso: string | undefined): number | null { + if (!iso) return null; + const ms = Date.parse(iso); + return Number.isFinite(ms) ? ms : null; +} + +function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { + if (thread.latestUserMessageAt) { + return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + } + + let latestUserMessageTimestamp: number | null = null; + + for (const message of thread.messages ?? []) { + if (message.role !== "user") continue; + const messageTimestamp = toSortableTimestamp(message.createdAt); + if (messageTimestamp === null) continue; + latestUserMessageTimestamp = + latestUserMessageTimestamp === null + ? messageTimestamp + : Math.max(latestUserMessageTimestamp, messageTimestamp); + } + + if (latestUserMessageTimestamp !== null) { + return latestUserMessageTimestamp; + } + + return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; +} + +export function getThreadSortTimestamp( + thread: ThreadSortInput, + sortOrder: SidebarThreadSortOrder | Exclude, +): number { + if (sortOrder === "created_at") { + return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY; + } + return getLatestUserMessageTimestamp(thread); +} + +export function sortThreads & ThreadSortInput>( + threads: readonly T[], + sortOrder: SidebarThreadSortOrder, +): T[] { + return threads.toSorted((left, right) => { + const rightTimestamp = getThreadSortTimestamp(right, sortOrder); + const leftTimestamp = getThreadSortTimestamp(left, sortOrder); + const byTimestamp = + rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; + if (byTimestamp !== 0) return byTimestamp; + return right.id.localeCompare(left.id); + }); +} + +export function getLatestThreadForProject< + T extends Pick & ThreadSortInput, +>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { + return ( + sortThreads( + threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + sortOrder, + )[0] ?? null + ); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 245ed9c576..b47f1a577d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,13 +3,18 @@ import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + resolveThreadActionProjectId, + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; import { isTerminalFocused } from "../lib/terminalFocus"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useSettings } from "~/hooks/useSettings"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -19,6 +24,8 @@ function ChatRouteGlobalShortcuts() { const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = useHandleNewThread(); + const commandPaletteOpen = useCommandPaletteStore((s) => s.open); + const toggleOpen = useCommandPaletteStore((s) => s.toggleOpen); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const terminalOpen = useTerminalStateStore((state) => @@ -32,15 +39,6 @@ function ChatRouteGlobalShortcuts() { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (event.key === "Escape" && selectedThreadIdsSize > 0) { - event.preventDefault(); - clearSelection(); - return; - } - - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId; - if (!projectId) return; - const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), @@ -48,28 +46,49 @@ function ChatRouteGlobalShortcuts() { }, }); - if (command === "chat.newLocal") { + if (command === "commandPalette.toggle") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { - envMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), - }); + toggleOpen(); + return; + } + + if (commandPaletteOpen) { + return; + } + + if (event.key === "Escape" && selectedThreadIdsSize > 0) { + event.preventDefault(); + clearSelection(); + return; + } + + if (command !== "chat.new" && command !== "chat.newLocal") { return; } - if (command === "chat.new") { + const threadActionContext = { + activeDraftThread, + activeThread, + defaultThreadEnvMode: appSettings.defaultThreadEnvMode, + handleNewThread, + projects: defaultProjectId ? [{ id: defaultProjectId }] : [], + }; + + if (!resolveThreadActionProjectId(threadActionContext)) { + return; + } + + if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: - activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), - }); + void startNewLocalThreadFromContext(threadActionContext); return; } + + event.preventDefault(); + event.stopPropagation(); + void startNewThreadFromContext(threadActionContext); }; window.addEventListener("keydown", onWindowKeyDown); @@ -80,11 +99,13 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread, clearSelection, + commandPaletteOpen, handleNewThread, keybindings, defaultProjectId, selectedThreadIdsSize, terminalOpen, + toggleOpen, appSettings.defaultThreadEnvMode, ]); diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index c3a7d9f00e..092d5344f2 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -41,6 +41,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); + const parsedCommandPalette = yield* decode(KeybindingRule, { + key: "mod+k", + command: "commandPalette.toggle", + }); + assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 067cba8804..2d0660e7ed 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -33,6 +33,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "commandPalette.toggle", "chat.new", "chat.newLocal", "editor.openFavorite",