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 bf58467825..fcb3db0d8d 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -70,6 +70,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/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4e18092463..b6ae23b103 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -21,10 +21,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"; @@ -34,6 +34,7 @@ import { estimateTimelineMessageHeight } from "./timelineHeight"; const THREAD_ID = "thread-browser-test" 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 = ""; @@ -335,6 +336,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"], @@ -404,6 +417,60 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithSecondaryProject(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-secondary-project-target" as MessageId, + targetText: "secondary project", + }); + + 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, + { + 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), + }, + }, + ], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -640,6 +707,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(); @@ -650,6 +727,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) { @@ -769,6 +866,7 @@ async function mountChatView(options: { const cleanup = async () => { await screen.unmount(); host.remove(); + await waitForLayout(); }; return { @@ -833,6 +931,9 @@ describe("ChatView timeline estimator parity (full app)", () => { stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); + useCommandPaletteStore.setState({ + open: false, + }); useStore.setState({ projects: [], threads: [], @@ -1262,7 +1363,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( @@ -1292,11 +1393,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( () => { @@ -1758,6 +1864,262 @@ 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 a new thread using the default env mode", async () => { + localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify({ + codexBinaryPath: "", + codexHomePath: "", + defaultThreadEnvMode: "worktree", + confirmThreadDelete: true, + enableAssistantStreaming: false, + timestampFormat: "locale", + customCodexModels: [], + }), + ); + + 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("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("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 fbc887bf62..55a2730a69 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,6 +87,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"; @@ -2135,7 +2136,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..dbe7845f5e --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -0,0 +1,174 @@ +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", + 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", + 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("yesterday"); + expect(items[1]?.timestamp).toBe("5 days 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", + 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", + 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"]); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts new file mode 100644 index 0000000000..2cb66c742a --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -0,0 +1,261 @@ +import { type KeybindingCommand } from "@t3tools/contracts"; +import { type ReactNode } from "react"; +import type { SidebarThreadSortOrder } from "../appSettings"; +import { sortThreads } from "../lib/threadSort"; +import { formatRelativeTime } from "../relativeTime"; +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, 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: formatRelativeTime(thread.updatedAt ?? thread.createdAt, Date.now(), "long"), + 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..0ff58b22ca --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,377 @@ +"use client"; + +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 { useAppSettings } from "../appSettings"; +import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +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 = query.startsWith(">"); + const { settings } = useAppSettings(); + const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); + 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] as const)), + [projects], + ); + + const activeThreadId = activeThread?.id; + const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + + const projectThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: , + runProject: async (projectId) => { + await handleNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }); + }, + }), + [handleNewThread, projects, settings.defaultThreadEnvMode], + ); + + const projectLocalThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-local-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-local-thread", + searchTerms: ["new local thread", "chat", "create", "fresh", "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-local-thread-in", + searchTerms: [ + "new local thread", + "project", + "pick", + "choose", + "select", + "fresh", + "default environment", + ], + title: "New local thread in...", + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: projectLocalThreadItems }], + }); + } + + 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: projectThreadItems, + 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 dcfe3f6b69..78c5fc6b93 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -586,7 +586,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 de5859af92..097995eb71 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { @@ -236,47 +237,10 @@ function toSortableTimestamp(iso: string | undefined): number | null { return Number.isFinite(ms) ? ms : null; } -function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { - 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, >(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); - }); + return sortThreads(threads, sortOrder); } export function getFallbackThreadIdAfterDelete< @@ -305,7 +269,6 @@ export function getFallbackThreadIdAfterDelete< )[0]?.id ?? null ); } - export function getProjectSortTimestamp( project: SidebarProject, projectThreads: readonly SidebarThreadSortInput[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 923c30b2f9..a484928973 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { GitPullRequestIcon, PlusIcon, RocketIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -46,6 +47,7 @@ import { import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -54,8 +56,10 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { isNonEmpty as isNonEmptyString } from "effect/String"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; +import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -89,7 +93,6 @@ import { } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, @@ -99,9 +102,10 @@ import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, } from "./Sidebar.logic"; +import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { CommandDialogTrigger } from "./ui/command"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -120,16 +124,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { } as const; const loadedProjectFaviconSrcs = new Set(); -function formatRelativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -494,10 +488,11 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { - const latestThread = sortThreadsForSidebar( - threads.filter((thread) => thread.projectId === projectId), + const latestThread = getLatestThreadForProject( + threads, + projectId, appSettings.sidebarThreadSortOrder, - )[0]; + ); if (!latestThread) return; void navigate({ @@ -508,7 +503,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; @@ -531,7 +526,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({ @@ -544,7 +538,7 @@ export default function Sidebar() { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, - createdAt, + createdAt: new Date().toISOString(), }); await handleNewThread(projectId, { envMode: appSettings.defaultThreadEnvMode, @@ -577,7 +571,7 @@ export default function Sidebar() { ); const handleAddProject = () => { - void addProjectFromPath(newCwd); + void addProjectFromInput(newCwd); }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; @@ -593,7 +587,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(); } @@ -1107,7 +1101,7 @@ export default function Sidebar() { project: (typeof sortedProjects)[number], dragHandleProps: SortableProjectHandleProps | null, ) { - const projectThreads = sortThreadsForSidebar( + const projectThreads = sortThreads( threads.filter((thread) => thread.projectId === project.id), appSettings.sidebarThreadSortOrder, ); @@ -1279,7 +1273,7 @@ export default function Sidebar() { : "text-muted-foreground/40" }`} > - {formatRelativeTime(thread.updatedAt ?? thread.createdAt)} + {formatRelativeTime(thread.updatedAt ?? thread.createdAt, Date.now(), "short")} @@ -1523,6 +1517,7 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"); + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1658,6 +1653,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 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", @@ -284,6 +293,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..e2dd26eb90 --- /dev/null +++ b/apps/web/src/lib/threadSort.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_RUNTIME_MODE, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { Thread } from "../types"; +import { 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", + 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"), + ]); + }); +}); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts new file mode 100644 index 0000000000..21bf62ff0b --- /dev/null +++ b/apps/web/src/lib/threadSort.ts @@ -0,0 +1,66 @@ +import type { ProjectId } from "@t3tools/contracts"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import type { Thread } from "../types"; + +type ThreadSortInput = Pick; + +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 { + 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>( + 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, +>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { + return ( + sortThreads( + threads.filter((thread) => thread.projectId === projectId), + sortOrder, + )[0] ?? null + ); +} diff --git a/apps/web/src/relativeTime.test.ts b/apps/web/src/relativeTime.test.ts new file mode 100644 index 0000000000..dd076ab196 --- /dev/null +++ b/apps/web/src/relativeTime.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { formatRelativeTime } from "./relativeTime"; + +describe("formatRelativeTime", () => { + const nowMs = Date.parse("2026-03-15T12:00:00.000Z"); + + it("returns just now for times under a minute old", () => { + expect(formatRelativeTime("2026-03-15T11:59:45.000Z", nowMs)).toBe("just now"); + }); + + it("formats minutes, hours, and days ago", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs)).toBe("5 minutes ago"); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs)).toBe("3 hours ago"); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs)).toBe("3 days ago"); + }); + + it("supports compact m/h/d formatting", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs, "short")).toBe("5m ago"); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs, "short")).toBe("3h ago"); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs, "short")).toBe("3d ago"); + }); +}); diff --git a/apps/web/src/relativeTime.ts b/apps/web/src/relativeTime.ts new file mode 100644 index 0000000000..c0c6ae2dbd --- /dev/null +++ b/apps/web/src/relativeTime.ts @@ -0,0 +1,56 @@ +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; +let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; +export type RelativeTimeStyle = "long" | "short"; + +function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { + if (relativeTimeFormatter === null) { + relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + } + return relativeTimeFormatter.format(-value, unit); +} + +function formatShortRelativeUnit(value: number, suffix: string): string { + return `${value}${suffix} ago`; +} + +export function formatRelativeTime( + isoDate: string, + nowMs = Date.now(), + style: RelativeTimeStyle = "long", +): string { + const targetMs = Date.parse(isoDate); + if (Number.isNaN(targetMs)) { + return ""; + } + + const diffMs = Math.max(0, nowMs - targetMs); + const formatUnit = (value: number, unit: Intl.RelativeTimeFormatUnit, shortSuffix: string) => + style === "short" + ? formatShortRelativeUnit(value, shortSuffix) + : formatRelativeUnit(value, unit); + + if (diffMs < MINUTE_MS) { + return "just now"; + } + if (diffMs < HOUR_MS) { + return formatUnit(Math.floor(diffMs / MINUTE_MS), "minute", "m"); + } + if (diffMs < DAY_MS) { + return formatUnit(Math.floor(diffMs / HOUR_MS), "hour", "h"); + } + if (diffMs < WEEK_MS) { + return formatUnit(Math.floor(diffMs / DAY_MS), "day", "d"); + } + if (diffMs < MONTH_MS) { + return formatUnit(Math.floor(diffMs / WEEK_MS), "week", "w"); + } + if (diffMs < YEAR_MS) { + return formatUnit(Math.floor(diffMs / MONTH_MS), "month", "mo"); + } + return formatUnit(Math.floor(diffMs / YEAR_MS), "year", "y"); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 7cb377056b..72834d6b49 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,14 +3,20 @@ import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; +import { CommandPalette } from "../components/CommandPalette"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import ThreadSidebar from "../components/Sidebar"; 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 { useAppSettings } from "~/appSettings"; import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; @@ -24,6 +30,8 @@ function ChatRouteGlobalShortcuts() { const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); const { activeDraftThread, activeThread, handleNewThread, projects, 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) => @@ -37,41 +45,56 @@ function ChatRouteGlobalShortcuts() { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + + if (command === "commandPalette.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleOpen(); + return; + } + + if (commandPaletteOpen) { + return; + } + if (event.key === "Escape" && selectedThreadIdsSize > 0) { event.preventDefault(); clearSelection(); return; } - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; + if (command !== "chat.new" && command !== "chat.newLocal") { + return; + } - const command = resolveShortcutCommand(event, keybindings, { - context: { - terminalFocus: isTerminalFocused(), - terminalOpen, - }, - }); + const threadActionContext = { + activeDraftThread, + activeThread, + defaultThreadEnvMode: appSettings.defaultThreadEnvMode, + handleNewThread, + projects, + } as const; + + if (!resolveThreadActionProjectId(threadActionContext)) { + return; + } if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { - envMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), - }); + void startNewLocalThreadFromContext(threadActionContext); return; } - if (command !== "chat.new") return; 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 startNewThreadFromContext(threadActionContext); }; window.addEventListener("keydown", onWindowKeyDown); @@ -82,11 +105,13 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread, clearSelection, + commandPaletteOpen, handleNewThread, keybindings, projects, selectedThreadIdsSize, terminalOpen, + toggleOpen, appSettings.defaultThreadEnvMode, ]); @@ -113,24 +138,26 @@ function ChatRouteLayout() { }, [navigate]); return ( - - - - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - }} - > - - - - - + + + + + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + }} + > + + + + + + ); } diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c53..afab73cca7 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 48821b1824..9b61df6a23 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -13,6 +13,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "commandPalette.toggle", "chat.new", "chat.newLocal", "editor.openFavorite",