From 7bef645ff12f397a25dbb8660851b65261c6fbdb Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sun, 15 Mar 2026 15:08:37 +1100 Subject: [PATCH 01/10] feat(web): add extensible command palette --- KEYBINDINGS.md | 2 + apps/server/src/keybindings.ts | 1 + apps/web/src/components/ChatView.browser.tsx | 57 ++++ apps/web/src/components/ChatView.tsx | 5 +- apps/web/src/components/CommandPalette.tsx | 326 +++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 30 ++ apps/web/src/keybindings.test.ts | 25 ++ apps/web/src/keybindings.ts | 8 + apps/web/src/lib/chatThreadActions.ts | 73 +++++ apps/web/src/routes/_chat.tsx | 79 +++-- packages/contracts/src/keybindings.test.ts | 6 + packages/contracts/src/keybindings.ts | 1 + 12 files changed, 583 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/CommandPalette.tsx create mode 100644 apps/web/src/lib/chatThreadActions.ts diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e..b57c13032 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 bf5846782..fcb3db0d8 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/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..1e8e97037 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1141,6 +1141,63 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens the command palette from the configurable shortcut and runs a command", 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 { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await expect.element(page.getByText("New thread")).toBeInTheDocument(); + await page.getByText("New thread").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("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 52637695e..b1da8258b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,6 +86,7 @@ import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; +import { useCommandPalette } from "./CommandPalette"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -197,6 +198,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const { open: commandPaletteOpen } = useCommandPalette(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -1966,7 +1968,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId || event.defaultPrevented) return; + if (!activeThreadId || commandPaletteOpen || event.defaultPrevented) return; const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), @@ -2042,6 +2044,7 @@ export default function ChatView({ threadId }: ChatViewProps) { keybindings, onToggleDiff, toggleTerminalVisibility, + commandPaletteOpen, ]); const addComposerImages = (files: File[]) => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 000000000..53c12faf4 --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { type KeybindingCommand } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { useAppSettings } from "../appSettings"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { cn } from "../lib/utils"; +import { shortcutLabelForCommand } from "../keybindings"; +import { useStore } from "../store"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { + Command, + CommandCollection, + CommandDialog, + CommandDialogPopup, + CommandEmpty, + CommandFooter, + CommandGroup, + CommandGroupLabel, + CommandInput, + CommandItem, + CommandList, + CommandPanel, + CommandShortcut, +} from "./ui/command"; +import { toastManager } from "./ui/toast"; + +const RECENT_THREAD_LIMIT = 12; + +interface CommandPaletteState { + readonly open: boolean; + readonly setOpen: (open: boolean) => void; + readonly toggleOpen: () => void; +} + +interface CommandPaletteItem { + readonly value: string; + readonly label: string; + readonly title: string; + readonly description?: string; + readonly icon: ReactNode; + readonly shortcutCommand?: KeybindingCommand; + readonly run: () => Promise; +} + +interface CommandPaletteGroup { + readonly value: string; + readonly label: string; + readonly items: ReadonlyArray; +} + +const CommandPaletteContext = createContext(null); + +function iconClassName() { + return "size-4 text-muted-foreground/80"; +} + +export function useCommandPalette() { + const context = useContext(CommandPaletteContext); + if (!context) { + throw new Error("useCommandPalette must be used within CommandPaletteProvider."); + } + return context; +} + +export function CommandPaletteProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const toggleOpen = useCallback(() => { + setOpen((current) => !current); + }, []); + + const value = useMemo( + () => ({ + open, + setOpen, + toggleOpen, + }), + [open, toggleOpen], + ); + + return ( + + + {children} + + + + ); +} + +function CommandPaletteDialog() { + const navigate = useNavigate(); + const { open, setOpen } = useCommandPalette(); + 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 projectTitleById = useMemo( + () => new Map(projects.map((project) => [project.id, project.name] as const)), + [projects], + ); + + const groups = useMemo(() => { + const actionItems: CommandPaletteItem[] = []; + if (projects.length > 0) { + const activeProjectTitle = + projectTitleById.get( + activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]!.id, + ) ?? null; + + actionItems.push({ + value: "action:new-thread", + label: `new thread chat create ${activeProjectTitle ?? ""}`.trim(), + title: "New thread", + description: activeProjectTitle + ? `Create a draft thread in ${activeProjectTitle}` + : "Create a new draft thread", + icon: , + shortcutCommand: "chat.new", + run: async () => { + await startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + projects, + }); + }, + }); + actionItems.push({ + value: "action:new-local-thread", + label: `new local thread chat create ${activeProjectTitle ?? ""}`.trim(), + title: "New local thread", + description: activeProjectTitle + ? `Create a fresh ${settings.defaultThreadEnvMode} thread in ${activeProjectTitle}` + : "Create a fresh thread using the default environment", + icon: , + shortcutCommand: "chat.newLocal", + run: async () => { + await startNewLocalThreadFromContext({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + projects, + }); + }, + }); + } + + actionItems.push({ + value: "action:settings", + label: "settings preferences configuration keybindings", + title: "Open settings", + description: "Open app settings and keybinding configuration", + icon: , + run: async () => { + await navigate({ to: "/settings" }); + }, + }); + + const recentThreadItems = threads + .toSorted((left, right) => { + const rightTimestamp = Date.parse( + right.latestTurn?.completedAt ?? + right.latestTurn?.startedAt ?? + right.latestTurn?.requestedAt ?? + right.createdAt, + ); + const leftTimestamp = Date.parse( + left.latestTurn?.completedAt ?? + left.latestTurn?.startedAt ?? + left.latestTurn?.requestedAt ?? + left.createdAt, + ); + const byTimestamp = rightTimestamp - leftTimestamp; + if (byTimestamp !== 0) { + return byTimestamp; + } + return right.id.localeCompare(left.id); + }) + .slice(0, RECENT_THREAD_LIMIT) + .map((thread) => { + const projectTitle = projectTitleById.get(thread.projectId); + const descriptionParts = [ + projectTitle, + thread.branch ? `#${thread.branch}` : null, + thread.id === activeThread?.id ? "Current thread" : null, + ].filter(Boolean); + + return { + value: `thread:${thread.id}`, + label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), + title: thread.title, + description: descriptionParts.join(" · "), + icon: , + run: async () => { + await navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }, + }; + }); + + const nextGroups: CommandPaletteGroup[] = []; + if (actionItems.length > 0) { + nextGroups.push({ + value: "actions", + label: "Actions", + items: actionItems, + }); + } + if (recentThreadItems.length > 0) { + nextGroups.push({ + value: "recent-threads", + label: "Recent Threads", + items: recentThreadItems, + }); + } + return nextGroups; + }, [ + activeDraftThread, + activeThread, + handleNewThread, + navigate, + projectTitleById, + projects, + settings.defaultThreadEnvMode, + threads, + ]); + + const executeItem = useCallback( + (item: CommandPaletteItem) => { + 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.", + }); + }); + }, + [setOpen], + ); + + if (!open) { + return null; + } + + return ( + + + + + + {groups.map((group) => ( + + {group.label} + + {(item) => { + const shortcutLabel = item.shortcutCommand + ? shortcutLabelForCommand(keybindings, item.shortcutCommand) + : null; + return ( + { + event.preventDefault(); + }} + onClick={() => { + executeItem(item); + }} + > + + {item.icon} + + + {item.title} + {item.description ? ( + + {item.description} + + ) : null} + + {shortcutLabel ? {shortcutLabel} : null} + + ); + }} + + + ))} + + No matching commands or threads. + + + Search actions and jump back into recent threads. +
+ + Enter + Open + + + Esc + Close + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b43eb4c1..57c35a31b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { GitPullRequestIcon, PlusIcon, RocketIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -50,6 +51,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; +import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -90,6 +92,7 @@ import { shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { CommandDialogTrigger } from "./ui/command"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -1029,6 +1032,10 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new"), [keybindings], ); + const commandPaletteShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "commandPalette.toggle"), + [keybindings], + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1164,6 +1171,29 @@ export default function Sidebar() { )} + + + + + } + > + + Search commands + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f..b94f9c17e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -10,6 +10,7 @@ import { formatShortcutLabel, isChatNewShortcut, isChatNewLocalShortcut, + isCommandPaletteToggleShortcut, isDiffToggleShortcut, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, @@ -97,6 +98,11 @@ const DEFAULT_BINDINGS = compile([ command: "diff.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("k"), + command: "commandPalette.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, @@ -237,6 +243,10 @@ describe("shortcutLabelForCommand", () => { 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 +294,21 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches commandPalette.toggle shortcut outside terminal focus", () => { + assert.isTrue( + isCommandPaletteToggleShortcut(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + assert.isFalse( + isCommandPaletteToggleShortcut(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aa..2bd4f19ad 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -206,6 +206,14 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isCommandPaletteToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "commandPalette.toggle", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts new file mode 100644 index 000000000..69fde0f3a --- /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/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 193cb0e7a..03f0dccc3 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,15 +3,19 @@ import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; +import { CommandPaletteProvider, useCommandPalette } from "../components/CommandPalette"; import ThreadSidebar from "../components/Sidebar"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + 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 { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; -import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useAppSettings } from "~/appSettings"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -21,6 +25,7 @@ function ChatRouteGlobalShortcuts() { const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = useHandleNewThread(); + const { open: commandPaletteOpen, toggleOpen } = useCommandPalette(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const terminalOpen = useTerminalStateStore((state) => @@ -34,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 ?? projects[0]?.id; - if (!projectId) return; - const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), @@ -50,13 +46,32 @@ function ChatRouteGlobalShortcuts() { }, }); + if (command === "commandPalette.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleOpen(); + return; + } + + if (commandPaletteOpen) { + return; + } + + if (event.key === "Escape" && selectedThreadIdsSize > 0) { + event.preventDefault(); + clearSelection(); + return; + } + if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { - envMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), + void startNewLocalThreadFromContext({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: appSettings.defaultThreadEnvMode, + handleNewThread, + projects, }); return; } @@ -64,10 +79,12 @@ function ChatRouteGlobalShortcuts() { 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({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: appSettings.defaultThreadEnvMode, + handleNewThread, + projects, }); }; @@ -79,11 +96,13 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread, clearSelection, + commandPaletteOpen, handleNewThread, keybindings, projects, selectedThreadIdsSize, terminalOpen, + toggleOpen, appSettings.defaultThreadEnvMode, ]); @@ -110,17 +129,19 @@ function ChatRouteLayout() { }, [navigate]); return ( - - - - - - - + + + + + + + + + ); } diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c5..afab73cca 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 48821b182..9b61df6a2 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", From 17073d59cc592c845b5232b395da01f4b83b7a3d Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sun, 15 Mar 2026 15:12:49 +1100 Subject: [PATCH 02/10] perf(web): avoid closed-state command palette work --- apps/web/src/components/CommandPalette.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 53c12faf4..537587fba 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -97,8 +97,17 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) { } function CommandPaletteDialog() { + const { open } = useCommandPalette(); + if (!open) { + return null; + } + + return ; +} + +function OpenCommandPaletteDialog() { const navigate = useNavigate(); - const { open, setOpen } = useCommandPalette(); + const { setOpen } = useCommandPalette(); const { settings } = useAppSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); const threads = useStore((store) => store.threads); @@ -253,10 +262,6 @@ function CommandPaletteDialog() { [setOpen], ); - if (!open) { - return null; - } - return ( Date: Sun, 15 Mar 2026 16:41:15 +1100 Subject: [PATCH 03/10] fix(web): align command palette search and shortcut --- apps/web/src/components/ChatView.browser.tsx | 52 ++++++++++++++++++++ apps/web/src/components/CommandPalette.tsx | 41 +++++++++++++-- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1e8e97037..515833b5b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1198,6 +1198,58 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + 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 { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands and threads...").fill("settings"); + await expect.element(page.getByText("Open settings")).toBeInTheDocument(); + await expect.element(page.getByText("New thread")).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/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 537587fba..d4a9efb10 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -4,7 +4,15 @@ import { type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useDeferredValue, + useMemo, + useState, + type ReactNode, +} from "react"; import { useAppSettings } from "../appSettings"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -63,6 +71,10 @@ function iconClassName() { return "size-4 text-muted-foreground/80"; } +function normalizeSearchText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + export function useCommandPalette() { const context = useContext(CommandPaletteContext); if (!context) { @@ -108,6 +120,8 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); const { setOpen } = useCommandPalette(); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); const { settings } = useAppSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); const threads = useStore((store) => store.threads); @@ -118,7 +132,7 @@ function OpenCommandPaletteDialog() { [projects], ); - const groups = useMemo(() => { + const allGroups = useMemo(() => { const actionItems: CommandPaletteItem[] = []; if (projects.length > 0) { const activeProjectTitle = @@ -248,6 +262,25 @@ function OpenCommandPaletteDialog() { threads, ]); + const filteredGroups = useMemo(() => { + const normalizedQuery = normalizeSearchText(deferredQuery); + if (normalizedQuery.length === 0) { + return allGroups; + } + + return allGroups + .map((group) => ({ + ...group, + items: group.items.filter((item) => { + const haystack = normalizeSearchText( + [item.label, item.title, item.description ?? ""].join(" "), + ); + return haystack.includes(normalizedQuery); + }), + })) + .filter((group) => group.items.length > 0); + }, [allGroups, deferredQuery]); + const executeItem = useCallback( (item: CommandPaletteItem) => { setOpen(false); @@ -268,11 +301,11 @@ function OpenCommandPaletteDialog() { className="overflow-hidden p-0" data-testid="command-palette" > - + - {groups.map((group) => ( + {filteredGroups.map((group) => ( {group.label} From 897b9ed18bf6a0908f738a0f5412ee73e72fc6a9 Mon Sep 17 00:00:00 2001 From: Hugo Blom <6117705+huxcrux@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:13:24 +0100 Subject: [PATCH 04/10] command palette add thread timestamps and projects (#1) --- apps/web/src/components/ChatView.browser.tsx | 144 ++++++++++++++++++- apps/web/src/components/CommandPalette.tsx | 80 +++++++---- apps/web/src/components/Sidebar.tsx | 17 +-- apps/web/src/relativeTime.test.ts | 22 +++ apps/web/src/relativeTime.ts | 56 ++++++++ 5 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/relativeTime.test.ts create mode 100644 apps/web/src/relativeTime.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 515833b5b..a984499a6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -29,6 +29,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 = ""; @@ -356,6 +357,30 @@ 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", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -1242,7 +1267,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); - await page.getByPlaceholder("Search commands and threads...").fill("settings"); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); await expect.element(page.getByText("Open settings")).toBeInTheDocument(); await expect.element(page.getByText("New thread")).not.toBeInTheDocument(); } finally { @@ -1250,6 +1275,123 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("does not match thread actions from contextual project names", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-project-query-test" as MessageId, + targetText: "command palette project query 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 { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("project"); + await expect.element(page.getByText("Project")).toBeInTheDocument(); + await expect.element(page.getByText("New thread")).not.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({ defaultThreadEnvMode: "worktree" }), + ); + + 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 { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(page.getByText("Docs Portal")).toBeInTheDocument(); + await expect.element(page.getByText("/repo/clients/docs-portal")).toBeInTheDocument(); + await page.getByText("Docs Portal").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/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d4a9efb10..ae0980a80 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -3,7 +3,7 @@ import { type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; +import { FolderIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; import { createContext, useCallback, @@ -22,6 +22,7 @@ import { import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; +import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { Kbd, KbdGroup } from "./ui/kbd"; import { @@ -54,6 +55,8 @@ interface CommandPaletteItem { readonly label: string; readonly title: string; readonly description?: string; + readonly searchText?: string; + readonly timestamp?: string; readonly icon: ReactNode; readonly shortcutCommand?: KeybindingCommand; readonly run: () => Promise; @@ -71,6 +74,17 @@ function iconClassName() { return "size-4 text-muted-foreground/80"; } +function compareThreadsByCreatedAtDesc( + left: { id: string; createdAt: string }, + right: { id: string; createdAt: string }, +): number { + const byTimestamp = Date.parse(right.createdAt) - Date.parse(left.createdAt); + if (!Number.isNaN(byTimestamp) && byTimestamp !== 0) { + return byTimestamp; + } + return right.id.localeCompare(left.id); +} + function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } @@ -147,6 +161,7 @@ function OpenCommandPaletteDialog() { description: activeProjectTitle ? `Create a draft thread in ${activeProjectTitle}` : "Create a new draft thread", + searchText: "new thread chat create draft", icon: , shortcutCommand: "chat.new", run: async () => { @@ -166,6 +181,7 @@ function OpenCommandPaletteDialog() { description: activeProjectTitle ? `Create a fresh ${settings.defaultThreadEnvMode} thread in ${activeProjectTitle}` : "Create a fresh thread using the default environment", + searchText: "new local thread chat create fresh default environment", icon: , shortcutCommand: "chat.newLocal", run: async () => { @@ -191,26 +207,21 @@ function OpenCommandPaletteDialog() { }, }); + const projectItems = projects.map((project) => ({ + value: `project:${project.id}`, + label: `${project.name} ${project.cwd}`.trim(), + title: project.name, + description: project.cwd, + icon: , + run: async () => { + await handleNewThread(project.id, { + envMode: settings.defaultThreadEnvMode, + }); + }, + })); + const recentThreadItems = threads - .toSorted((left, right) => { - const rightTimestamp = Date.parse( - right.latestTurn?.completedAt ?? - right.latestTurn?.startedAt ?? - right.latestTurn?.requestedAt ?? - right.createdAt, - ); - const leftTimestamp = Date.parse( - left.latestTurn?.completedAt ?? - left.latestTurn?.startedAt ?? - left.latestTurn?.requestedAt ?? - left.createdAt, - ); - const byTimestamp = rightTimestamp - leftTimestamp; - if (byTimestamp !== 0) { - return byTimestamp; - } - return right.id.localeCompare(left.id); - }) + .toSorted(compareThreadsByCreatedAtDesc) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => { const projectTitle = projectTitleById.get(thread.projectId); @@ -225,6 +236,7 @@ function OpenCommandPaletteDialog() { label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), title: thread.title, description: descriptionParts.join(" · "), + timestamp: formatRelativeTime(thread.createdAt), icon: , run: async () => { await navigate({ @@ -243,6 +255,13 @@ function OpenCommandPaletteDialog() { items: actionItems, }); } + if (projectItems.length > 0) { + nextGroups.push({ + value: "projects", + label: "Projects", + items: projectItems, + }); + } if (recentThreadItems.length > 0) { nextGroups.push({ value: "recent-threads", @@ -273,7 +292,11 @@ function OpenCommandPaletteDialog() { ...group, items: group.items.filter((item) => { const haystack = normalizeSearchText( - [item.label, item.title, item.description ?? ""].join(" "), + [ + item.title, + item.searchText ?? item.label, + item.searchText ? "" : (item.description ?? ""), + ].join(" "), ); return haystack.includes(normalizedQuery); }), @@ -302,7 +325,7 @@ function OpenCommandPaletteDialog() { data-testid="command-palette" > - + {filteredGroups.map((group) => ( @@ -335,6 +358,11 @@ function OpenCommandPaletteDialog() { ) : null} + {item.timestamp ? ( + + {item.timestamp} + + ) : null} {shortcutLabel ? {shortcutLabel} : null} ); @@ -343,10 +371,14 @@ function OpenCommandPaletteDialog() { ))} - No matching commands or threads. + + No matching commands, projects, or threads. + - Search actions and jump back into recent threads. + + Search actions, start a thread in any project, or jump back into recent threads. +
Enter diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 57c35a31b..5cc62efc5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -97,16 +98,6 @@ import { CommandDialogTrigger } from "./ui/command"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; -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; @@ -1581,7 +1572,11 @@ export default function Sidebar() { : "text-muted-foreground/40" }`} > - {formatRelativeTime(thread.createdAt)} + {formatRelativeTime( + thread.createdAt, + Date.now(), + "short", + )}
diff --git a/apps/web/src/relativeTime.test.ts b/apps/web/src/relativeTime.test.ts new file mode 100644 index 000000000..dd076ab19 --- /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 000000000..c0c6ae2db --- /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"); +} From e7ffca48043bde822797e20f2592e06dddd79af5 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 05/10] feat(contracts): add filesystem.browse WS method and request schema --- packages/contracts/src/ws.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b..c30b6f6e5 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -72,6 +72,9 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Filesystem + filesystemBrowse: "filesystem.browse", + // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", @@ -136,6 +139,14 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), + // Filesystem + tagRequestBody( + WS_METHODS.filesystemBrowse, + Schema.Struct({ + partialPath: Schema.String, + }), + ), + // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), From cefe9261948df7325de48723b9876905fcec0b0c Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 06/10] feat(contracts): add browseFilesystem to NativeApi interface --- packages/contracts/src/ipc.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..0c2c337ff 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -128,6 +128,10 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + browseFilesystem: (input: { partialPath: string }) => Promise<{ + parentPath: string; + entries: Array<{ name: string; fullPath: string }>; + }>; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; From ea52bb3050b7a3472cca29ff1bac77055d06cac2 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 07/10] feat(server): add filesystem browse endpoint with directory listing --- apps/server/src/wsServer.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..a724a5780 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -866,6 +866,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } + case WS_METHODS.filesystemBrowse: { + const body = stripRequestTag(request.body); + const expanded = path.resolve(yield* expandHomePath(body.partialPath)); + const endsWithSep = body.partialPath.endsWith("/") || body.partialPath === "~"; + const parentDir = endsWithSep ? expanded : path.dirname(expanded); + const prefix = endsWithSep ? "" : path.basename(expanded); + + const names = yield* fileSystem + .readDirectory(parentDir) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))); + + const showHidden = prefix.startsWith("."); + const filtered = names + .filter((n) => n.startsWith(prefix) && (showHidden || !n.startsWith("."))) + .slice(0, 100); + + const entries = yield* Effect.forEach( + filtered, + (name) => + fileSystem.stat(path.join(parentDir, name)).pipe( + Effect.map((s) => + s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null, + ), + Effect.catch(() => Effect.succeed(null)), + ), + { concurrency: 16 }, + ); + + return { + parentPath: parentDir, + entries: entries.filter(Boolean).slice(0, 50), + }; + } + case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; return { From c6a9033e223b4a17e9d9db02500a507197235017 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 08/10] feat(web): wire browseFilesystem to WS transport --- apps/web/src/wsNativeApi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..a04922fad 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -114,6 +114,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + browseFilesystem: (input) => transport.request(WS_METHODS.filesystemBrowse, input), }, shell: { openInEditor: (cwd, editor) => From 7bc39e699c88d762502106deedbe9b862706dc9c Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 12:01:47 +0000 Subject: [PATCH 09/10] feat(web): add project browser with filesystem browsing to command palette --- apps/web/src/components/CommandPalette.tsx | 174 ++++++++++++++++++--- 1 file changed, 154 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ae0980a80..0ba0fca41 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,9 +1,16 @@ "use client"; -import { type KeybindingCommand } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { FolderIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { + FolderIcon, + FolderPlusIcon, + MessageSquareIcon, + SettingsIcon, + SquarePenIcon, +} from "lucide-react"; import { createContext, useCallback, @@ -20,8 +27,9 @@ import { startNewThreadFromContext, } from "../lib/chatThreadActions"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; -import { cn } from "../lib/utils"; +import { cn, newCommandId, newProjectId } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; +import { readNativeApi } from "../nativeApi"; import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -59,6 +67,7 @@ interface CommandPaletteItem { readonly timestamp?: string; readonly icon: ReactNode; readonly shortcutCommand?: KeybindingCommand; + readonly keepOpen?: boolean; readonly run: () => Promise; } @@ -89,6 +98,15 @@ function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } +function getHighlightedEntryPath(): string | null { + const item = document.querySelector( + "[data-testid='command-palette'] [data-slot='autocomplete-item'][data-highlighted]", + ); + if (!item) return null; + const description = item.querySelector("[class*='text-xs']"); + return description?.textContent ?? null; +} + export function useCommandPalette() { const context = useContext(CommandPaletteContext); if (!context) { @@ -136,11 +154,25 @@ function OpenCommandPaletteDialog() { const { setOpen } = useCommandPalette(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); + const isBrowsing = query.startsWith("/") || query.startsWith("~/") || query.startsWith("./"); + const [debouncedBrowsePath] = useDebouncedValue(query, { wait: 200 }); 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 { data: browseEntries = [] } = useQuery({ + queryKey: ["filesystemBrowse", debouncedBrowsePath], + queryFn: async () => { + const api = readNativeApi(); + if (!api) return []; + const result = await api.projects.browseFilesystem({ partialPath: debouncedBrowsePath }); + return result.entries; + }, + enabled: isBrowsing && debouncedBrowsePath.length > 0, + }); + const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.name] as const)), [projects], @@ -196,6 +228,18 @@ function OpenCommandPaletteDialog() { }); } + actionItems.push({ + value: "action:add-project", + label: "add project folder directory browse", + title: "Add project", + description: "Browse filesystem and add a project directory", + icon: , + keepOpen: true, + run: async () => { + setQuery("~/"); + }, + }); + actionItems.push({ value: "action:settings", label: "settings preferences configuration keybindings", @@ -304,9 +348,77 @@ function OpenCommandPaletteDialog() { .filter((group) => group.items.length > 0); }, [allGroups, deferredQuery]); + const handleAddProject = useCallback( + async (cwd: string) => { + const api = readNativeApi(); + if (!api) return; + const existing = projects.find((p) => p.cwd === cwd); + if (existing) { + setOpen(false); + return; + } + const projectId = newProjectId(); + const segments = cwd.split(/[/\\]/); + const title = segments.findLast(Boolean) ?? cwd; + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title, + workspaceRoot: cwd, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + createdAt: new Date().toISOString(), + }); + await handleNewThread(projectId, { envMode: settings.defaultThreadEnvMode }).catch(() => {}); + setOpen(false); + }, + [handleNewThread, projects, setOpen, settings.defaultThreadEnvMode], + ); + + const browseGroups = useMemo(() => { + if (browseEntries.length === 0) return []; + return [ + { + value: "directories", + label: "Directories", + items: browseEntries.map((entry) => ({ + value: `dir:${entry.fullPath}`, + label: entry.name, + title: entry.name, + description: entry.fullPath, + icon: , + run: async () => { + await handleAddProject(entry.fullPath); + }, + })), + }, + ]; + }, [browseEntries, handleAddProject]); + + const displayedGroups = !isBrowsing ? filteredGroups : browseGroups; + + const handleBrowseKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!isBrowsing) return; + if (event.key === "Tab") { + event.preventDefault(); + const fullPath = getHighlightedEntryPath(); + if (fullPath) { + setQuery(fullPath.endsWith("/") ? fullPath : fullPath + "/"); + } + } else if (event.key === "Enter" && browseEntries.length === 0) { + event.preventDefault(); + void handleAddProject(query.trim()); + } + }, + [isBrowsing, query, browseEntries.length, handleAddProject], + ); + const executeItem = useCallback( (item: CommandPaletteItem) => { - setOpen(false); + if (!item.keepOpen) { + setOpen(false); + } void item.run().catch((error: unknown) => { toastManager.add({ type: "error", @@ -325,10 +437,18 @@ function OpenCommandPaletteDialog() { data-testid="command-palette" > - + : undefined} + onKeyDown={handleBrowseKeyDown} + /> - {filteredGroups.map((group) => ( + {displayedGroups.map((group) => ( {group.label} @@ -372,23 +492,37 @@ function OpenCommandPaletteDialog() { ))} - No matching commands, projects, or threads. + {!isBrowsing + ? "No matching commands, projects, or threads." + : "No directories found. Press Enter to add the typed path."} - - Search actions, start a thread in any project, or jump back into recent threads. - -
- - Enter - Open - - - Esc - Close - -
+ {!isBrowsing ? ( + <> + + Search actions, start a thread in any project, or jump back into recent threads. + +
+ + Enter + Open + + + Esc + Close + +
+ + ) : ( + <> + Type a path to browse · Tab to autocomplete + + Enter + Add project + + + )}
From 1b13b76910e975f02f8eb88b28f8a53ce108b31f Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 17:17:43 +0000 Subject: [PATCH 10/10] fix(web): add windows path support and prevent stale debounce actions in browse mode --- apps/web/src/components/CommandPalette.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0ba0fca41..d86ace905 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -154,7 +154,11 @@ function OpenCommandPaletteDialog() { const { setOpen } = useCommandPalette(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); - const isBrowsing = query.startsWith("/") || query.startsWith("~/") || query.startsWith("./"); + const isBrowsing = + query.startsWith("/") || + query.startsWith("~/") || + query.startsWith("./") || + /^[a-zA-Z]:[/\\]/.test(query); const [debouncedBrowsePath] = useDebouncedValue(query, { wait: 200 }); const { settings } = useAppSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); @@ -397,6 +401,8 @@ function OpenCommandPaletteDialog() { const displayedGroups = !isBrowsing ? filteredGroups : browseGroups; + const isDebounceStale = isBrowsing && query !== debouncedBrowsePath; + const handleBrowseKeyDown = useCallback( (event: React.KeyboardEvent) => { if (!isBrowsing) return; @@ -406,12 +412,14 @@ function OpenCommandPaletteDialog() { if (fullPath) { setQuery(fullPath.endsWith("/") ? fullPath : fullPath + "/"); } + } else if (event.key === "Enter" && isDebounceStale) { + event.preventDefault(); } else if (event.key === "Enter" && browseEntries.length === 0) { event.preventDefault(); void handleAddProject(query.trim()); } }, - [isBrowsing, query, browseEntries.length, handleAddProject], + [isBrowsing, query, browseEntries.length, isDebounceStale, handleAddProject], ); const executeItem = useCallback(