From 955952bb9d63a86150b303cbc715af24972cac39 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sun, 15 Mar 2026 15:08:37 +1100 Subject: [PATCH 01/15] 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 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/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6cbef09bd6..d3cd099e84 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1333,6 +1333,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 9ebb4ec9e8..efc7fe40fe 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 { useCommandPalette } from "./CommandPalette"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -225,6 +226,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({ @@ -2091,7 +2093,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), @@ -2167,6 +2169,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 0000000000..53c12faf42 --- /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 bcef110c1b..5057711d1c 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; @@ -1068,6 +1071,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; @@ -1203,6 +1210,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 0ecccf43f8..b94f9c17e4 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 09d9308aad..2bd4f19add 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 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/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 193cb0e7a9..03f0dccc3a 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 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", From f4f3d48a456e1b5c869a3a08fa7a68aa141cc5f1 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sun, 15 Mar 2026 15:12:49 +1100 Subject: [PATCH 02/15] 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 53c12faf42..537587fbab 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/15] 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 d3cd099e84..145a464e4f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1390,6 +1390,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 537587fbab..d4a9efb10b 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 93488944b43491f02cf786fc57aa035e17185f9d 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/15] 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 145a464e4f..6895f94cd1 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -33,6 +33,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 = ""; @@ -381,6 +382,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) { @@ -1434,7 +1459,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 { @@ -1442,6 +1467,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 d4a9efb10b..ae0980a80e 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 5057711d1c..f565e565f7 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 { 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"; @@ -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; @@ -1620,7 +1611,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 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"); +} From b2795039e42c49a5ac45eb3e59838fb5a1d3fd11 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 05/15] 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 ebb76138b8..c30b6f6e50 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 7eb56386954b00874dcd329d9262564c521392e0 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 06/15] 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 b9127fb176..0c2c337ff3 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 2ef924f64639a487246db1dd7c426f346060b711 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 07/15] 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 2e6ac51b7f..a724a5780d 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 1ddcea16a3519802a7ec700e4864c3b8c3a029bd Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 08/15] 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 ddfffbde69..a04922fad7 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 24efd78129b4ff3838a724ad18c45a32edc8f197 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 12:01:47 +0000 Subject: [PATCH 09/15] 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 ae0980a80e..0ba0fca41f 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 c2f7dfd5c0914a48de232785fc86addaed33639f Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 17:17:43 +0000 Subject: [PATCH 10/15] 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 0ba0fca41f..d86ace9058 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( From b14a1aca634c3270250557c9c0a5a25bf292e501 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 17 Mar 2026 10:27:56 +1300 Subject: [PATCH 11/15] feat: update command palette styles and state management --- apps/server/src/wsServer.ts | 3 +- apps/web/src/commandPaletteStore.ts | 13 + apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/CommandPalette.tsx | 737 +++++++++++++-------- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/ui/command.tsx | 2 +- apps/web/src/routes/_chat.tsx | 10 +- 7 files changed, 499 insertions(+), 272 deletions(-) create mode 100644 apps/web/src/commandPaletteStore.ts diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a724a5780d..8d8965cd9f 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -878,8 +878,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< .pipe(Effect.catch(() => Effect.succeed([] as string[]))); const showHidden = prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); const filtered = names - .filter((n) => n.startsWith(prefix) && (showHidden || !n.startsWith("."))) + .filter((n) => n.toLowerCase().startsWith(lowerPrefix) && (showHidden || !n.startsWith("."))) .slice(0, 100); const entries = yield* Effect.forEach( 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.tsx b/apps/web/src/components/ChatView.tsx index efc7fe40fe..17ca24e761 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,7 +87,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 { useCommandPaletteStore } from "../commandPaletteStore"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -226,7 +226,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 commandPaletteOpen = useCommandPaletteStore((s) => s.open); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d86ace9058..772c25943f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -5,22 +5,19 @@ import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { + ArrowDownIcon, + ArrowUpIcon, + ChevronRightIcon, + CornerLeftUpIcon, FolderIcon, FolderPlusIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; -import { - createContext, - useCallback, - useContext, - useDeferredValue, - useMemo, - useState, - type ReactNode, -} from "react"; +import { useCallback, useDeferredValue, useMemo, useState, type ReactNode } from "react"; import { useAppSettings } from "../appSettings"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewLocalThreadFromContext, @@ -38,7 +35,6 @@ import { CommandCollection, CommandDialog, CommandDialogPopup, - CommandEmpty, CommandFooter, CommandGroup, CommandGroupLabel, @@ -48,41 +44,52 @@ import { CommandPanel, CommandShortcut, } from "./ui/command"; +import { Button } from "./ui/button"; 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 kind: "action" | "submenu"; readonly value: string; readonly label: string; - readonly title: string; + readonly title: ReactNode; readonly description?: string; readonly searchText?: string; readonly timestamp?: string; readonly icon: ReactNode; readonly shortcutCommand?: KeybindingCommand; +} + +interface CommandPaletteActionItem extends CommandPaletteItem { + readonly kind: "action"; readonly keepOpen?: boolean; readonly run: () => Promise; } +interface CommandPaletteSubmenuItem extends CommandPaletteItem { + readonly kind: "submenu"; + readonly addonIcon: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; +} + interface CommandPaletteGroup { readonly value: string; readonly label: string; - readonly items: ReadonlyArray; + readonly items: ReadonlyArray; } -const CommandPaletteContext = createContext(null); - -function iconClassName() { - return "size-4 text-muted-foreground/80"; +interface CommandPaletteView { + readonly addonIcon: ReactNode; + readonly title: ReactNode; + readonly groups: ReadonlyArray; + readonly initialQuery?: string; } +const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +const ADDON_ICON_CLASS = "size-4"; + function compareThreadsByCreatedAtDesc( left: { id: string; createdAt: string }, right: { id: string; createdAt: string }, @@ -98,50 +105,20 @@ 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) { - 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], - ); +export function CommandPalette({ children }: { children: ReactNode }) { + const open = useCommandPaletteStore((s) => s.open); + const setOpen = useCommandPaletteStore((s) => s.setOpen); return ( - - - {children} - - - + + {children} + + ); } function CommandPaletteDialog() { - const { open } = useCommandPalette(); + const open = useCommandPaletteStore((s) => s.open); if (!open) { return null; } @@ -151,13 +128,15 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); - const { setOpen } = useCommandPalette(); + const setOpen = useCommandPaletteStore((s) => s.setOpen); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); + const isActionsOnly = query.startsWith(">"); const isBrowsing = query.startsWith("/") || query.startsWith("~/") || query.startsWith("./") || + query.startsWith("../") || /^[a-zA-Z]:[/\\]/.test(query); const [debouncedBrowsePath] = useDebouncedValue(query, { wait: 200 }); const { settings } = useAppSettings(); @@ -165,7 +144,9 @@ function OpenCommandPaletteDialog() { const threads = useStore((store) => store.threads); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? []; - + const [viewStack, setViewStack] = useState([]); + const currentView = viewStack.length > 0 ? viewStack[viewStack.length - 1]! : null; + const [browseGeneration, setBrowseGeneration] = useState(0); const { data: browseEntries = [] } = useQuery({ queryKey: ["filesystemBrowse", debouncedBrowsePath], queryFn: async () => { @@ -182,96 +163,180 @@ function OpenCommandPaletteDialog() { [projects], ); - const allGroups = 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", - searchText: "new thread chat create draft", - icon: , - shortcutCommand: "chat.new", + const projectThreadItems = useMemo( + () => + projects.map((project) => ({ + kind: "action", + value: `new-thread-in:${project.id}`, + label: `${project.name} ${project.cwd}`.trim(), + title: project.name, + description: project.cwd, + icon: , run: async () => { - await startNewThreadFromContext({ - activeDraftThread, - activeThread, - defaultThreadEnvMode: settings.defaultThreadEnvMode, - handleNewThread, - projects, + await handleNewThread(project.id, { + envMode: settings.defaultThreadEnvMode, }); }, - }); - 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", - searchText: "new local thread chat create fresh default environment", - icon: , - shortcutCommand: "chat.newLocal", + })), + [handleNewThread, projects, settings.defaultThreadEnvMode], + ); + + const projectLocalThreadItems = useMemo( + () => + projects.map((project) => ({ + kind: "action", + value: `new-local-thread-in:${project.id}`, + label: `${project.name} ${project.cwd}`.trim(), + title: project.name, + description: project.cwd, + icon: , run: async () => { - await startNewLocalThreadFromContext({ - activeDraftThread, - activeThread, - defaultThreadEnvMode: settings.defaultThreadEnvMode, - handleNewThread, - projects, + await handleNewThread(project.id, { + envMode: "local", }); }, + })), + [handleNewThread, projects], + ); + + const pushView = useCallback((item: CommandPaletteSubmenuItem) => { + setViewStack((prev) => [ + ...prev, + { + addonIcon: item.addonIcon, + title: item.title, + groups: item.groups, + ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + }, + ]); + setQuery(item.initialQuery ?? ""); + }, []); + + const popView = useCallback(() => { + setViewStack((prev) => prev.slice(0, -1)); + setQuery(""); + }, []); + + const handleQueryChange = useCallback( + (nextQuery: string) => { + setQuery(nextQuery); + // Auto-exit views that were entered with an initial query (e.g. browse mode) + // when the input is fully cleared. This unifies the exit behavior for + // typing ~/... at root and entering via the "Add project" submenu. + if (nextQuery === "" && currentView?.initialQuery) { + popView(); + } + }, + [currentView, popView], + ); + + const rootGroups = useMemo(() => { + const actionItems: Array = []; + + if (projects.length > 0) { + const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId; + const activeProjectTitle = activeProjectId + ? (projectTitleById.get(activeProjectId) ?? null) + : null; + + // Quick actions: only show when there's an active thread/draft to derive the project from + if (activeProjectTitle) { + actionItems.push({ + kind: "action", + value: "action:new-thread", + label: `new thread chat create ${activeProjectTitle}`.trim(), + title: ( + <> + New thread in {activeProjectTitle} + + ), + searchText: "new thread chat create draft", + icon: , + shortcutCommand: "chat.new", + run: async () => { + await startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + projects, + }); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:new-local-thread", + label: `new local thread chat create ${activeProjectTitle}`.trim(), + title: ( + <> + New local thread in {activeProjectTitle} + + ), + searchText: "new local thread chat create fresh default environment", + icon: , + shortcutCommand: "chat.newLocal", + run: async () => { + await startNewLocalThreadFromContext({ + activeDraftThread, + activeThread, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + projects, + }); + }, + }); + } + + actionItems.push({ + kind: "submenu", + value: "action:new-thread-in", + label: "new thread in project", + title: "New thread in...", + searchText: "new thread project pick choose select", + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + }); + + actionItems.push({ + kind: "submenu", + value: "action:new-local-thread-in", + label: "new local thread in project", + title: "New local thread in...", + searchText: "new local thread project pick choose select fresh default environment", + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items: projectLocalThreadItems }], }); } actionItems.push({ + kind: "submenu", 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("~/"); - }, + icon: , + addonIcon: , + groups: [], + initialQuery: "~/", }); actionItems.push({ + kind: "action", value: "action:settings", label: "settings preferences configuration keybindings", title: "Open settings", - description: "Open app settings and keybinding configuration", - icon: , + icon: , run: async () => { await navigate({ to: "/settings" }); }, }); - 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(compareThreadsByCreatedAtDesc) .slice(0, RECENT_THREAD_LIMIT) - .map((thread) => { + .map((thread) => { const projectTitle = projectTitleById.get(thread.projectId); const descriptionParts = [ projectTitle, @@ -280,12 +345,13 @@ function OpenCommandPaletteDialog() { ].filter(Boolean); return { + kind: "action", value: `thread:${thread.id}`, label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), title: thread.title, description: descriptionParts.join(" · "), timestamp: formatRelativeTime(thread.createdAt), - icon: , + icon: , run: async () => { await navigate({ to: "/$threadId", @@ -303,13 +369,6 @@ 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", @@ -325,32 +384,101 @@ function OpenCommandPaletteDialog() { navigate, projectTitleById, projects, + projectLocalThreadItems, + projectThreadItems, settings.defaultThreadEnvMode, threads, ]); + const activeGroups = currentView ? currentView.groups : rootGroups; + + // All threads as searchable items (used when there's a query to search beyond the 12 recent) + const allThreadItems = useMemo( + () => + threads.toSorted(compareThreadsByCreatedAtDesc).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 { + kind: "action", + value: `thread:${thread.id}`, + label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), + title: thread.title, + description: descriptionParts.join(" · "), + timestamp: formatRelativeTime(thread.createdAt), + icon: , + run: async () => { + await navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }, + }; + }), + [activeThread, navigate, projectTitleById, threads], + ); + const filteredGroups = useMemo(() => { - const normalizedQuery = normalizeSearchText(deferredQuery); + const isActionsFilter = deferredQuery.startsWith(">"); + const searchQuery = isActionsFilter ? deferredQuery.slice(1) : deferredQuery; + const normalizedQuery = normalizeSearchText(searchQuery); + if (normalizedQuery.length === 0) { - return allGroups; + const sourceGroups = isActionsFilter + ? activeGroups.filter((group) => group.value === "actions") + : activeGroups; + return sourceGroups; + } + + // When searching at root level, replace the recent-threads group with all threads + // and add all projects so the full dataset is searchable + const baseGroups = isActionsFilter + ? activeGroups.filter((group) => group.value === "actions") + : currentView === null + ? activeGroups.filter((group) => group.value !== "recent-threads") + : activeGroups; + + const extraGroups: CommandPaletteGroup[] = []; + if (currentView === null && !isActionsFilter) { + if (projectThreadItems.length > 0) { + extraGroups.push({ + value: "projects-search", + label: "Projects", + items: projectThreadItems, + }); + } + if (allThreadItems.length > 0) { + extraGroups.push({ + value: "threads-search", + label: "Threads", + items: allThreadItems, + }); + } } - return allGroups - .map((group) => ({ - ...group, - items: group.items.filter((item) => { - const haystack = normalizeSearchText( - [ - item.title, - item.searchText ?? item.label, - item.searchText ? "" : (item.description ?? ""), - ].join(" "), - ); - return haystack.includes(normalizedQuery); - }), - })) - .filter((group) => group.items.length > 0); - }, [allGroups, deferredQuery]); + const searchableGroups = [...baseGroups, ...extraGroups]; + + return searchableGroups.flatMap((group) => { + const items = group.items.filter((item) => { + const haystack = normalizeSearchText( + [item.searchText ?? item.label, item.searchText ? "" : (item.description ?? "")].join( + " ", + ), + ); + return haystack.includes(normalizedQuery); + }); + + if (items.length === 0) { + return []; + } + + return [{ value: group.value, label: group.label, items }]; + }); + }, [activeGroups, allThreadItems, currentView, deferredQuery, projectThreadItems]); const handleAddProject = useCallback( async (cwd: string) => { @@ -379,51 +507,95 @@ function OpenCommandPaletteDialog() { [handleNewThread, projects, setOpen, settings.defaultThreadEnvMode], ); + // Navigate into a subdirectory in browse mode + const browseTo = useCallback( + (name: string) => { + const queryDir = query.replace(/[^/]*$/, ""); // e.g. "~/" or "~/projects/" + setQuery(queryDir + name + "/"); + setBrowseGeneration((g) => g + 1); + }, + [query], + ); + + // Navigate up one directory level in browse mode + const browseUp = useCallback(() => { + const trimmed = query.replace(/\/$/, ""); + const lastSlash = trimmed.lastIndexOf("/"); + if (lastSlash >= 0) { + setQuery(trimmed.slice(0, lastSlash + 1)); + setBrowseGeneration((g) => g + 1); + } + }, [query]); + + // Whether to show a ".." entry (can go up if path has more than one segment) + const canBrowseUp = isBrowsing && /\/.+\//.test(query); + + // Browse mode items rendered through the autocomplete primitive 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 items: CommandPaletteActionItem[] = []; + + // ".." to go up + if (canBrowseUp) { + items.push({ + kind: "action", + value: "browse:up", + label: "..", + title: "..", + icon: , + keepOpen: true, + run: async () => { + browseUp(); + }, + }); + } - const displayedGroups = !isBrowsing ? filteredGroups : browseGroups; + // Directory entries + for (const entry of browseEntries) { + items.push({ + kind: "action", + value: `browse:${entry.fullPath}`, + label: entry.name, + title: entry.name, + icon: , + keepOpen: true, + run: async () => { + browseTo(entry.name); + }, + }); + } + + return [{ value: "directories", label: "Directories", items }]; + }, [canBrowseUp, browseEntries, browseUp, browseTo]); - const isDebounceStale = isBrowsing && query !== debouncedBrowsePath; + const displayedGroups = isBrowsing ? browseGroups : filteredGroups; - const handleBrowseKeyDown = useCallback( + const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { - if (!isBrowsing) return; - if (event.key === "Tab") { - event.preventDefault(); - const fullPath = getHighlightedEntryPath(); - if (fullPath) { - setQuery(fullPath.endsWith("/") ? fullPath : fullPath + "/"); + // In browse mode, Enter with nothing highlighted submits the typed path + if (isBrowsing && event.key === "Enter") { + const hasHighlight = document.querySelector( + "[data-testid='command-palette'] [data-highlighted]", + ); + if (!hasHighlight) { + event.preventDefault(); + void handleAddProject(query.trim()); } - } else if (event.key === "Enter" && isDebounceStale) { - event.preventDefault(); - } else if (event.key === "Enter" && browseEntries.length === 0) { + } + + if (event.key === "Backspace" && query === "" && viewStack.length > 0) { event.preventDefault(); - void handleAddProject(query.trim()); + popView(); } }, - [isBrowsing, query, browseEntries.length, isDebounceStale, handleAddProject], + [isBrowsing, query, handleAddProject, viewStack, popView], ); const executeItem = useCallback( - (item: CommandPaletteItem) => { + (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => { + if (item.kind === "submenu") { + pushView(item); + return; + } if (!item.keepOpen) { setOpen(false); } @@ -435,102 +607,141 @@ function OpenCommandPaletteDialog() { }); }); }, - [setOpen], + [pushView, setOpen], ); + const inputPlaceholder = isBrowsing + ? "Enter project path (e.g. ~/projects/my-app)" + : currentView !== null + ? "Search..." + : "Search commands, projects, and threads..."; + return ( - - : undefined} - onKeyDown={handleBrowseKeyDown} - /> + +
+ : undefined)} + onKeyDown={handleKeyDown} + /> + {isBrowsing ? ( + + ) : null} +
- - {displayedGroups.map((group) => ( - - {group.label} - - {(item) => { - const shortcutLabel = item.shortcutCommand - ? shortcutLabelForCommand(keybindings, item.shortcutCommand) - : null; - return ( - { - event.preventDefault(); - }} - onClick={() => { - executeItem(item); - }} - > - + {displayedGroups.length === 0 ? ( +
+ {isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads."} +
+ ) : ( + + {displayedGroups.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} + + {item.title} + + {item.description} + + + ) : ( + + {item.title} + )} + {item.timestamp ? ( + + {item.timestamp} + + ) : null} + {shortcutLabel ? ( + {shortcutLabel} ) : null} - - {item.timestamp ? ( - - {item.timestamp} - - ) : null} - {shortcutLabel ? {shortcutLabel} : null} -
- ); - }} -
-
- ))} -
- - {!isBrowsing - ? "No matching commands, projects, or threads." - : "No directories found. Press Enter to add the typed path."} - + {item.kind === "submenu" ? ( + + ) : null} + + ); + }} + + + ))} + + )}
- {!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 +
+ + + + + + + + Navigate + + + Enter + Select + + {currentView !== null ? ( - Enter - Add project + Backspace + Back - - )} + ) : null} + + Esc + Close + +
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f565e565f7..9b197f9fdc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1208,7 +1208,7 @@ export default function Sidebar() { render={ } diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index a2bc59c092..5c0074d90f 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 ( state.selectedThreadIds.size); const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = useHandleNewThread(); - const { open: commandPaletteOpen, toggleOpen } = useCommandPalette(); + 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) => @@ -129,7 +131,7 @@ function ChatRouteLayout() { }, [navigate]); return ( - + - + ); } From 9826515e31d310c2f83345d34ca9a90f83911b2e Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 20 Mar 2026 10:45:29 +1300 Subject: [PATCH 12/15] Fix command palette path handling --- apps/server/src/wsServer.test.ts | 33 ++++++++ apps/server/src/wsServer.ts | 9 ++- apps/web/src/components/ChatView.tsx | 6 +- apps/web/src/components/CommandPalette.tsx | 74 ++++++++++++------ apps/web/src/components/Sidebar.tsx | 12 ++- apps/web/src/components/ui/command.tsx | 2 +- apps/web/src/lib/projectPaths.test.ts | 54 +++++++++++++ apps/web/src/lib/projectPaths.ts | 89 ++++++++++++++++++++++ apps/web/src/wsNativeApi.test.ts | 14 ++++ packages/contracts/src/ipc.ts | 7 +- packages/contracts/src/project.ts | 17 +++++ packages/contracts/src/ws.test.ts | 17 +++++ packages/contracts/src/ws.ts | 9 +-- 13 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/lib/projectPaths.test.ts create mode 100644 apps/web/src/lib/projectPaths.ts diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..6eaa683a29 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1573,6 +1573,39 @@ describe("WebSocket Server", () => { }); }); + it("supports filesystem.browse with directory-only results", async () => { + const workspace = makeTempDir("t3code-ws-filesystem-browse-"); + fs.mkdirSync(path.join(workspace, "components"), { recursive: true }); + fs.mkdirSync(path.join(workspace, "composables"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "composer.ts"), "export {};\n", "utf8"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: path.join(workspace, "comp"), + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + parentPath: workspace, + entries: [ + { + name: "components", + fullPath: path.join(workspace, "components"), + }, + { + name: "composables", + fullPath: path.join(workspace, "composables"), + }, + ], + }); + }); + it("supports projects.writeFile within the workspace root", async () => { const workspace = makeTempDir("t3code-ws-write-file-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8d8965cd9f..bd40ac848c 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -869,7 +869,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< 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 endsWithSep = /[\\/]$/.test(body.partialPath) || body.partialPath === "~"; const parentDir = endsWithSep ? expanded : path.dirname(expanded); const prefix = endsWithSep ? "" : path.basename(expanded); @@ -880,8 +880,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const showHidden = prefix.startsWith("."); const lowerPrefix = prefix.toLowerCase(); const filtered = names - .filter((n) => n.toLowerCase().startsWith(lowerPrefix) && (showHidden || !n.startsWith("."))) - .slice(0, 100); + .filter( + (name) => + name.toLowerCase().startsWith(lowerPrefix) && (showHidden || !name.startsWith(".")), + ) + .toSorted((left, right) => left.localeCompare(right)); const entries = yield* Effect.forEach( filtered, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 17ca24e761..17479a8d80 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -226,7 +226,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); - const commandPaletteOpen = useCommandPaletteStore((s) => s.open); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -2093,7 +2092,9 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId || commandPaletteOpen || event.defaultPrevented) return; + if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { + return; + } const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), @@ -2169,7 +2170,6 @@ 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 index 772c25943f..2357a10860 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -23,6 +23,14 @@ import { startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { + appendBrowsePathSegment, + findProjectByPath, + getBrowseParentPath, + inferProjectTitleFromPath, + isFilesystemBrowseQuery, + normalizeProjectPathForDispatch, +} from "../lib/projectPaths"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn, newCommandId, newProjectId } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; @@ -132,12 +140,7 @@ function OpenCommandPaletteDialog() { const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = query.startsWith(">"); - const isBrowsing = - query.startsWith("/") || - query.startsWith("~/") || - query.startsWith("./") || - query.startsWith("../") || - /^[a-zA-Z]:[/\\]/.test(query); + const isBrowsing = isFilesystemBrowseQuery(query); const [debouncedBrowsePath] = useDebouncedValue(query, { wait: 200 }); const { settings } = useAppSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); @@ -267,10 +270,10 @@ function OpenCommandPaletteDialog() { actionItems.push({ kind: "action", value: "action:new-local-thread", - label: `new local thread chat create ${activeProjectTitle}`.trim(), + label: `new fresh thread chat create ${activeProjectTitle}`.trim(), title: ( <> - New local thread in {activeProjectTitle} + New fresh thread in {activeProjectTitle} ), searchText: "new local thread chat create fresh default environment", @@ -481,17 +484,31 @@ function OpenCommandPaletteDialog() { }, [activeGroups, allThreadItems, currentView, deferredQuery, projectThreadItems]); const handleAddProject = useCallback( - async (cwd: string) => { + async (rawCwd: string) => { const api = readNativeApi(); if (!api) return; - const existing = projects.find((p) => p.cwd === cwd); + const cwd = normalizeProjectPathForDispatch(rawCwd); + if (cwd.length === 0) { + return; + } + + const existing = findProjectByPath(projects, cwd); if (existing) { + const latestThread = threads + .filter((thread) => thread.projectId === existing.id) + .toSorted(compareThreadsByCreatedAtDesc)[0]; + if (latestThread) { + await navigate({ + to: "/$threadId", + params: { threadId: latestThread.id }, + }); + } setOpen(false); return; } + const projectId = newProjectId(); - const segments = cwd.split(/[/\\]/); - const title = segments.findLast(Boolean) ?? cwd; + const title = inferProjectTitleFromPath(cwd); await api.orchestration.dispatchCommand({ type: "project.create", commandId: newCommandId(), @@ -504,14 +521,13 @@ function OpenCommandPaletteDialog() { await handleNewThread(projectId, { envMode: settings.defaultThreadEnvMode }).catch(() => {}); setOpen(false); }, - [handleNewThread, projects, setOpen, settings.defaultThreadEnvMode], + [handleNewThread, navigate, projects, setOpen, settings.defaultThreadEnvMode, threads], ); // Navigate into a subdirectory in browse mode const browseTo = useCallback( (name: string) => { - const queryDir = query.replace(/[^/]*$/, ""); // e.g. "~/" or "~/projects/" - setQuery(queryDir + name + "/"); + setQuery(appendBrowsePathSegment(query, name)); setBrowseGeneration((g) => g + 1); }, [query], @@ -519,16 +535,14 @@ function OpenCommandPaletteDialog() { // Navigate up one directory level in browse mode const browseUp = useCallback(() => { - const trimmed = query.replace(/\/$/, ""); - const lastSlash = trimmed.lastIndexOf("/"); - if (lastSlash >= 0) { - setQuery(trimmed.slice(0, lastSlash + 1)); + const parentPath = getBrowseParentPath(query); + if (parentPath !== null) { + setQuery(parentPath); setBrowseGeneration((g) => g + 1); } }, [query]); - // Whether to show a ".." entry (can go up if path has more than one segment) - const canBrowseUp = isBrowsing && /\/.+\//.test(query); + const canBrowseUp = isBrowsing && getBrowseParentPath(query) !== null; // Browse mode items rendered through the autocomplete primitive const browseGroups = useMemo(() => { @@ -633,10 +647,20 @@ function OpenCommandPaletteDialog() {
: undefined)} + placeholder={ + currentView !== null + ? isBrowsing + ? "Enter path (e.g. ~/projects/my-app)" + : "Search..." + : inputPlaceholder + } + startAddon={ + currentView !== null ? ( + currentView.addonIcon + ) : isBrowsing ? ( + + ) : undefined + } onKeyDown={handleKeyDown} /> {isBrowsing ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9b197f9fdc..087ca75322 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -50,6 +50,11 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + findProjectByPath, + inferProjectTitleFromPath, + normalizeProjectPathForDispatch, +} from "../lib/projectPaths"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { Kbd } from "./ui/kbd"; @@ -85,7 +90,6 @@ import { } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; import { resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -397,7 +401,7 @@ export default function Sidebar() { const addProjectFromPath = useCallback( async (rawCwd: string) => { - const cwd = rawCwd.trim(); + const cwd = normalizeProjectPathForDispatch(rawCwd); if (!cwd || isAddingProject) return; const api = readNativeApi(); if (!api) return; @@ -410,7 +414,7 @@ export default function Sidebar() { setAddingProject(false); }; - const existing = projects.find((project) => project.cwd === cwd); + const existing = findProjectByPath(projects, cwd); if (existing) { focusMostRecentThreadForProject(existing.id); finishAddingProject(); @@ -419,7 +423,7 @@ export default function Sidebar() { const projectId = newProjectId(); const createdAt = new Date().toISOString(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; + const title = inferProjectTitleFromPath(cwd); try { await api.orchestration.dispatchCommand({ type: "project.create", diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 5c0074d90f..a2bc59c092 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("normalizes trailing separators for dispatch and comparison", () => { + expect(normalizeProjectPathForDispatch(" /repo/app/ ")).toBe("/repo/app"); + expect(normalizeProjectPathForComparison("/repo/app/")).toBe("/repo/app"); + }); + + it("normalizes windows-style paths for comparison", () => { + expect(normalizeProjectPathForComparison("C:/Work/Repo/")).toBe("c:\\work\\repo"); + expect(normalizeProjectPathForComparison("C:\\Work\\Repo\\")).toBe("c:\\work\\repo"); + }); + + it("finds existing projects even when the input formatting differs", () => { + const existing = findProjectByPath( + [ + { id: "project-1", cwd: "/repo/app" }, + { id: "project-2", cwd: "C:\\Work\\Repo" }, + ], + "C:/Work/Repo/", + ); + + expect(existing?.id).toBe("project-2"); + }); + + it("infers project titles from normalized paths", () => { + expect(inferProjectTitleFromPath("/repo/app/")).toBe("app"); + expect(inferProjectTitleFromPath("C:\\Work\\Repo\\")).toBe("Repo"); + }); + + it("detects browse queries across supported path styles", () => { + expect(isFilesystemBrowseQuery("~/projects")).toBe(true); + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\")).toBe(true); + expect(isFilesystemBrowseQuery("notes")).toBe(false); + }); + + it("navigates browse paths with matching separators", () => { + expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/"); + expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\"); + expect(getBrowseParentPath("/repo/src/")).toBe("/repo/"); + expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\"); + expect(getBrowseParentPath("C:\\")).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts new file mode 100644 index 0000000000..7b6132ae24 --- /dev/null +++ b/apps/web/src/lib/projectPaths.ts @@ -0,0 +1,89 @@ +function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +function isRootPath(value: string): boolean { + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); +} + +function trimTrailingPathSeparators(value: string): string { + if (value.length === 0 || isRootPath(value)) { + return value; + } + + const trimmed = value.replace(/[\\/]+$/g, ""); + if (trimmed.length === 0) { + return value; + } + + return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; +} + +function preferredPathSeparator(value: string): "/" | "\\" { + return value.includes("\\") ? "\\" : "/"; +} + +export function isFilesystemBrowseQuery(value: string): boolean { + return ( + value.startsWith("/") || + value.startsWith("~/") || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith("\\\\") || + isWindowsDrivePath(value) + ); +} + +export function normalizeProjectPathForDispatch(value: string): string { + return trimTrailingPathSeparators(value.trim()); +} + +export function normalizeProjectPathForComparison(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { + return normalized.replaceAll("/", "\\").toLowerCase(); + } + return normalized; +} + +export function findProjectByPath( + projects: ReadonlyArray, + candidatePath: string, +): T | undefined { + const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); + if (normalizedCandidate.length === 0) { + return undefined; + } + + return projects.find( + (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate, + ); +} + +export function inferProjectTitleFromPath(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + const segments = normalized.split(/[/\\]/); + return segments.findLast(Boolean) ?? normalized; +} + +export function appendBrowsePathSegment(currentPath: string, segment: string): string { + const separator = preferredPathSeparator(currentPath); + const parentPath = currentPath.replace(/[^/\\]*$/, ""); + return `${parentPath}${segment}${separator}`; +} + +export function getBrowseParentPath(currentPath: string): string | null { + const separator = preferredPathSeparator(currentPath); + const trimmed = currentPath.replace(/[\\/]+$/, ""); + const lastSeparatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + + if (lastSeparatorIndex < 0) { + return null; + } + + if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { + return `${trimmed.slice(0, 2)}${separator}`; + } + + return trimmed.slice(0, lastSeparatorIndex + 1); +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da0..6d98d08cc8 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -320,6 +320,20 @@ describe("wsNativeApi", () => { }); }); + it("forwards filesystem browse requests to the websocket project method", async () => { + requestMock.mockResolvedValue({ parentPath: "/tmp", entries: [] }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.projects.browseFilesystem({ + partialPath: "/tmp/project", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.filesystemBrowse, { + partialPath: "/tmp/project", + }); + }); + it("forwards full-thread diff requests to the orchestration websocket method", async () => { requestMock.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 0c2c337ff3..574e8d4e60 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,6 +19,8 @@ import type { GitStatusResult, } from "./git"; import type { + FilesystemBrowseInput, + FilesystemBrowseResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -128,10 +130,7 @@ 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 }>; - }>; + browseFilesystem: (input: FilesystemBrowseInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0903253301..a290812812 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -26,6 +26,23 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export const FilesystemBrowseInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), +}); +export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; + +export const FilesystemBrowseEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + fullPath: TrimmedNonEmptyString, +}); +export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; + +export const FilesystemBrowseResult = Schema.Struct({ + parentPath: TrimmedNonEmptyString, + entries: Schema.Array(FilesystemBrowseEntry), +}); +export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; + export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index d732242ecd..a6ab353270 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -73,6 +73,23 @@ it.effect("accepts git.preparePullRequestThread requests", () => }), ); +it.effect("accepts filesystem browse requests and trims the partial path", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-filesystem-1", + body: { + _tag: WS_METHODS.filesystemBrowse, + partialPath: " ~/projects ", + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.filesystemBrowse); + if (parsed.body._tag === WS_METHODS.filesystemBrowse) { + assert.strictEqual(parsed.body.partialPath, "~/projects"); + } + }), +); + it.effect("accepts typed websocket push envelopes with sequence", () => Effect.gen(function* () { const parsed = yield* decodeWsResponse({ diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index c30b6f6e50..1e1baa609d 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -34,7 +34,7 @@ import { TerminalWriteInput, } from "./terminal"; import { KeybindingRule } from "./keybindings"; -import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; +import { FilesystemBrowseInput, ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; @@ -140,12 +140,7 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), // Filesystem - tagRequestBody( - WS_METHODS.filesystemBrowse, - Schema.Struct({ - partialPath: Schema.String, - }), - ), + tagRequestBody(WS_METHODS.filesystemBrowse, FilesystemBrowseInput), // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), From 019e830eaff3370ae4016653105e04742b40824e Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 20 Mar 2026 11:13:27 +1300 Subject: [PATCH 13/15] Add command palette project browsing --- apps/server/src/wsServer.test.ts | 47 ++++++ apps/server/src/wsServer.ts | 63 +++++++-- apps/web/src/components/CommandPalette.tsx | 157 +++++++++++---------- apps/web/src/components/Sidebar.tsx | 92 +++++------- apps/web/src/lib/projectAdd.ts | 100 +++++++++++++ apps/web/src/lib/projectPaths.test.ts | 8 ++ apps/web/src/lib/projectPaths.ts | 85 +++++++++++ apps/web/src/wsNativeApi.test.ts | 4 +- apps/web/src/wsNativeApi.ts | 4 +- packages/contracts/src/filesystem.ts | 22 +++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 7 +- packages/contracts/src/project.ts | 17 --- packages/contracts/src/ws.test.ts | 2 + packages/contracts/src/ws.ts | 3 +- 15 files changed, 449 insertions(+), 163 deletions(-) create mode 100644 apps/web/src/lib/projectAdd.ts create mode 100644 packages/contracts/src/filesystem.ts diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 6eaa683a29..afeb66b14b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1606,6 +1606,53 @@ describe("WebSocket Server", () => { }); }); + it("resolves relative filesystem.browse paths against the provided cwd", async () => { + const workspace = makeTempDir("t3code-ws-filesystem-browse-relative-"); + fs.mkdirSync(path.join(workspace, "apps"), { recursive: true }); + fs.mkdirSync(path.join(workspace, "docs"), { recursive: true }); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: "../d", + cwd: path.join(workspace, "apps"), + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + parentPath: workspace, + entries: [ + { + name: "docs", + fullPath: path.join(workspace, "docs"), + }, + ], + }); + }); + + it("rejects relative filesystem.browse paths without a cwd", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: "./docs", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain( + "Relative filesystem browse paths require a current project.", + ); + }); + it("supports projects.writeFile within the workspace root", async () => { const workspace = makeTempDir("t3code-ws-write-file-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bd40ac848c..06d6381fd4 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -110,6 +110,32 @@ const isServerNotRunningError = (error: Error): boolean => { ); }; +function isExplicitRelativePath(value: string): boolean { + return ( + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +} + +function resolveFilesystemBrowseInputPath(input: { + cwd: string | undefined; + path: Path.Path; + partialPath: string; +}): Effect.Effect { + return Effect.gen(function* () { + if (!isExplicitRelativePath(input.partialPath)) { + return input.path.resolve(yield* expandHomePath(input.partialPath)); + } + if (!input.cwd) { + return null; + } + const expandedCwd = yield* expandHomePath(input.cwd); + return input.path.resolve(expandedCwd, input.partialPath); + }); +} + function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { socket.end( `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + @@ -868,14 +894,30 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.filesystemBrowse: { const body = stripRequestTag(request.body); - const expanded = path.resolve(yield* expandHomePath(body.partialPath)); + const resolvedInputPath = yield* resolveFilesystemBrowseInputPath({ + cwd: body.cwd, + path, + partialPath: body.partialPath, + }); + if (resolvedInputPath === null) { + return yield* new RouteRequestError({ + message: "Relative filesystem browse paths require a current project.", + }); + } + + const expanded = resolvedInputPath; const endsWithSep = /[\\/]$/.test(body.partialPath) || 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 names = yield* fileSystem.readDirectory(parentDir).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Unable to browse '${parentDir}': ${Cause.pretty(Cause.fail(cause)).trim()}`, + }), + ), + ); const showHidden = prefix.startsWith("."); const lowerPrefix = prefix.toLowerCase(); @@ -889,18 +931,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< 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, + 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), + entries: entries.filter(Boolean), }; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2357a10860..fc61e26102 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ "use client"; -import { DEFAULT_MODEL_BY_PROVIDER, type KeybindingCommand } from "@t3tools/contracts"; +import { type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -25,14 +25,12 @@ import { } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, - findProjectByPath, getBrowseParentPath, - inferProjectTitleFromPath, isFilesystemBrowseQuery, - normalizeProjectPathForDispatch, } from "../lib/projectPaths"; +import { addProjectFromPath } from "../lib/projectAdd"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; -import { cn, newCommandId, newProjectId } from "../lib/utils"; +import { cn } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; import { readNativeApi } from "../nativeApi"; import { formatRelativeTime } from "../relativeTime"; @@ -148,17 +146,31 @@ function OpenCommandPaletteDialog() { const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? []; const [viewStack, setViewStack] = useState([]); - const currentView = viewStack.length > 0 ? viewStack[viewStack.length - 1]! : null; + const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); + const projectCwdById = useMemo( + () => new Map(projects.map((project) => [project.id, project.cwd] as const)), + [projects], + ); + const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + const currentProjectCwd = currentProjectId + ? (projectCwdById.get(currentProjectId) ?? null) + : null; const { data: browseEntries = [] } = useQuery({ - queryKey: ["filesystemBrowse", debouncedBrowsePath], + queryKey: ["filesystemBrowse", debouncedBrowsePath, currentProjectCwd], queryFn: async () => { const api = readNativeApi(); if (!api) return []; - const result = await api.projects.browseFilesystem({ partialPath: debouncedBrowsePath }); + const result = await api.filesystem.browse({ + partialPath: debouncedBrowsePath, + ...(currentProjectCwd ? { cwd: currentProjectCwd } : {}), + }); return result.entries; }, - enabled: isBrowsing && debouncedBrowsePath.length > 0, + enabled: + isBrowsing && + debouncedBrowsePath.length > 0 && + (!debouncedBrowsePath.startsWith(".") || currentProjectCwd !== null), }); const projectTitleById = useMemo( @@ -237,9 +249,8 @@ function OpenCommandPaletteDialog() { const actionItems: Array = []; if (projects.length > 0) { - const activeProjectId = activeThread?.projectId ?? activeDraftThread?.projectId; - const activeProjectTitle = activeProjectId - ? (projectTitleById.get(activeProjectId) ?? null) + const activeProjectTitle = currentProjectId + ? (projectTitleById.get(currentProjectId) ?? null) : null; // Quick actions: only show when there's an active thread/draft to derive the project from @@ -383,6 +394,7 @@ function OpenCommandPaletteDialog() { }, [ activeDraftThread, activeThread, + currentProjectId, handleNewThread, navigate, projectTitleById, @@ -431,19 +443,20 @@ function OpenCommandPaletteDialog() { const normalizedQuery = normalizeSearchText(searchQuery); if (normalizedQuery.length === 0) { - const sourceGroups = isActionsFilter - ? activeGroups.filter((group) => group.value === "actions") - : activeGroups; - return sourceGroups; + if (isActionsFilter) { + return activeGroups.filter((group) => group.value === "actions"); + } + return activeGroups; } // When searching at root level, replace the recent-threads group with all threads // and add all projects so the full dataset is searchable - const baseGroups = isActionsFilter - ? activeGroups.filter((group) => group.value === "actions") - : currentView === null - ? activeGroups.filter((group) => group.value !== "recent-threads") - : activeGroups; + let baseGroups = activeGroups; + if (isActionsFilter) { + baseGroups = activeGroups.filter((group) => group.value === "actions"); + } else if (currentView === null) { + baseGroups = activeGroups.filter((group) => group.value !== "recent-threads"); + } const extraGroups: CommandPaletteGroup[] = []; if (currentView === null && !isActionsFilter) { @@ -487,41 +500,43 @@ function OpenCommandPaletteDialog() { async (rawCwd: string) => { const api = readNativeApi(); if (!api) return; - const cwd = normalizeProjectPathForDispatch(rawCwd); - if (cwd.length === 0) { - return; - } - const existing = findProjectByPath(projects, cwd); - if (existing) { - const latestThread = threads - .filter((thread) => thread.projectId === existing.id) - .toSorted(compareThreadsByCreatedAtDesc)[0]; - if (latestThread) { - await navigate({ - to: "/$threadId", - params: { threadId: latestThread.id }, - }); - } + try { + await addProjectFromPath( + { + api, + currentProjectCwd, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + navigateToThread: async (threadId) => { + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + projects, + threads, + }, + rawCwd, + ); setOpen(false); - return; + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }); } - - const projectId = newProjectId(); - const title = inferProjectTitleFromPath(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, navigate, projects, setOpen, settings.defaultThreadEnvMode, threads], + [ + currentProjectCwd, + handleNewThread, + navigate, + projects, + setOpen, + settings.defaultThreadEnvMode, + threads, + ], ); // Navigate into a subdirectory in browse mode @@ -624,11 +639,23 @@ function OpenCommandPaletteDialog() { [pushView, setOpen], ); - const inputPlaceholder = isBrowsing - ? "Enter project path (e.g. ~/projects/my-app)" - : currentView !== null - ? "Search..." - : "Search commands, projects, and threads..."; + let inputPlaceholder = "Search commands, projects, and threads..."; + if (currentView !== null) { + inputPlaceholder = "Search..."; + } + if (isBrowsing) { + inputPlaceholder = "Enter project path (e.g. ~/projects/my-app)"; + if (currentView !== null) { + inputPlaceholder = "Enter path (e.g. ~/projects/my-app)"; + } + } + + let inputStartAddon: ReactNode = undefined; + if (currentView !== null) { + inputStartAddon = currentView.addonIcon; + } else if (isBrowsing) { + inputStartAddon = ; + } return ( - ) : undefined - } + placeholder={inputPlaceholder} + startAddon={inputStartAddon} onKeyDown={handleKeyDown} /> {isBrowsing ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 087ca75322..5b6173861c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,7 +28,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, ThreadId, @@ -40,7 +39,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { isLinuxPlatform, isMacPlatform, newCommandId } from "../lib/utils"; import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; @@ -50,11 +49,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { - findProjectByPath, - inferProjectTitleFromPath, - normalizeProjectPathForDispatch, -} from "../lib/projectPaths"; +import { addProjectFromPath as runAddProjectFromPath } from "../lib/projectAdd"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { Kbd } from "./ui/kbd"; @@ -270,7 +265,7 @@ export default function Sidebar() { const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const { settings: appSettings } = useAppSettings(); - const { handleNewThread } = useHandleNewThread(); + const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), @@ -380,31 +375,14 @@ export default function Sidebar() { }); }, []); - const focusMostRecentThreadForProject = useCallback( - (projectId: ProjectId) => { - const latestThread = threads - .filter((thread) => thread.projectId === projectId) - .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - })[0]; - if (!latestThread) return; - - void navigate({ - to: "/$threadId", - params: { threadId: latestThread.id }, - }); - }, - [navigate, threads], - ); - - const addProjectFromPath = useCallback( + const addProjectFromInput = useCallback( async (rawCwd: string) => { - const cwd = normalizeProjectPathForDispatch(rawCwd); - if (!cwd || isAddingProject) return; const api = readNativeApi(); - if (!api) return; + if (!api || isAddingProject) return; + const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + const currentProjectCwd = currentProjectId + ? (projectCwdById.get(currentProjectId) ?? null) + : null; setIsAddingProject(true); const finishAddingProject = () => { @@ -414,29 +392,25 @@ export default function Sidebar() { setAddingProject(false); }; - const existing = findProjectByPath(projects, cwd); - if (existing) { - focusMostRecentThreadForProject(existing.id); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const createdAt = new Date().toISOString(); - const title = inferProjectTitleFromPath(cwd); try { - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title, - workspaceRoot: cwd, - defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, - createdAt, - }); - await handleNewThread(projectId, { - envMode: appSettings.defaultThreadEnvMode, - }).catch(() => undefined); + await runAddProjectFromPath( + { + api, + currentProjectCwd, + defaultThreadEnvMode: appSettings.defaultThreadEnvMode, + handleNewThread, + navigateToThread: async (threadId) => { + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + projects, + threads, + }, + rawCwd, + ); + finishAddingProject(); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; @@ -450,22 +424,24 @@ export default function Sidebar() { } else { setAddProjectError(description); } - return; } - finishAddingProject(); }, [ - focusMostRecentThreadForProject, + activeDraftThread, + activeThread, handleNewThread, isAddingProject, + navigate, + projectCwdById, projects, shouldBrowseForProjectImmediately, + threads, appSettings.defaultThreadEnvMode, ], ); const handleAddProject = () => { - void addProjectFromPath(newCwd); + void addProjectFromInput(newCwd); }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; @@ -481,7 +457,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(); } diff --git a/apps/web/src/lib/projectAdd.ts b/apps/web/src/lib/projectAdd.ts new file mode 100644 index 0000000000..9475c6ca3a --- /dev/null +++ b/apps/web/src/lib/projectAdd.ts @@ -0,0 +1,100 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + type NativeApi, + type ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import type { DraftThreadEnvMode } from "../composerDraftStore"; +import { newCommandId, newProjectId } from "./utils"; +import { + findProjectByPath, + inferProjectTitleFromPath, + resolveProjectPathForDispatch, +} from "./projectPaths"; + +interface ProjectLike { + readonly id: ProjectId; + readonly cwd: string; +} + +interface ThreadLike { + readonly id: ThreadId; + readonly projectId: ProjectId; + readonly createdAt: string; +} + +interface AddProjectFromPathContext { + readonly api: NativeApi; + readonly currentProjectCwd?: string | null; + readonly defaultThreadEnvMode: DraftThreadEnvMode; + readonly handleNewThread: ( + projectId: ProjectId, + options?: { envMode?: DraftThreadEnvMode }, + ) => Promise; + readonly navigateToThread: (threadId: ThreadId) => Promise; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +} + +export type AddProjectFromPathResult = "created" | "existing" | "noop"; + +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 hasExplicitRelativePrefix(value: string): boolean { + const trimmedValue = value.trim(); + return ( + trimmedValue.startsWith("./") || + trimmedValue.startsWith("../") || + trimmedValue.startsWith(".\\") || + trimmedValue.startsWith("..\\") + ); +} + +export async function addProjectFromPath( + context: AddProjectFromPathContext, + rawCwd: string, +): Promise { + if (hasExplicitRelativePrefix(rawCwd) && !context.currentProjectCwd) { + throw new Error("Relative paths require an active project."); + } + + const cwd = resolveProjectPathForDispatch(rawCwd, context.currentProjectCwd); + if (cwd.length === 0) { + return "noop"; + } + + const existing = findProjectByPath(context.projects, cwd); + if (existing) { + const latestThread = context.threads + .filter((thread) => thread.projectId === existing.id) + .toSorted(compareThreadsByCreatedAtDesc)[0]; + if (latestThread) { + await context.navigateToThread(latestThread.id); + } + return "existing"; + } + + const projectId = newProjectId(); + await context.api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: inferProjectTitleFromPath(cwd), + workspaceRoot: cwd, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + createdAt: new Date().toISOString(), + }); + await context.handleNewThread(projectId, { + envMode: context.defaultThreadEnvMode, + }); + return "created"; +} diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts index b216f01883..d8b28187fd 100644 --- a/apps/web/src/lib/projectPaths.test.ts +++ b/apps/web/src/lib/projectPaths.test.ts @@ -8,6 +8,7 @@ import { isFilesystemBrowseQuery, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, + resolveProjectPathForDispatch, } from "./projectPaths"; describe("projectPaths", () => { @@ -41,9 +42,16 @@ describe("projectPaths", () => { it("detects browse queries across supported path styles", () => { expect(isFilesystemBrowseQuery("~/projects")).toBe(true); expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\")).toBe(true); + expect(isFilesystemBrowseQuery("..\\docs")).toBe(true); expect(isFilesystemBrowseQuery("notes")).toBe(false); }); + it("resolves explicit relative paths against the current project", () => { + expect(resolveProjectPathForDispatch("./docs", "/repo/app")).toBe("/repo/app/docs"); + expect(resolveProjectPathForDispatch("../docs", "/repo/app")).toBe("/repo/docs"); + expect(resolveProjectPathForDispatch("./Repo", "C:\\Work")).toBe("C:\\Work\\Repo"); + }); + it("navigates browse paths with matching separators", () => { expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/"); expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\"); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts index 7b6132ae24..de2660cc9b 100644 --- a/apps/web/src/lib/projectPaths.ts +++ b/apps/web/src/lib/projectPaths.ts @@ -23,12 +23,66 @@ function preferredPathSeparator(value: string): "/" | "\\" { return value.includes("\\") ? "\\" : "/"; } +function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +function isExplicitRelativePath(value: string): boolean { + return ( + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +} + +function splitAbsolutePath(value: string): { + root: string; + separator: "/" | "\\"; + segments: string[]; +} | null { + const separator = preferredPathSeparator(value); + if (isWindowsDrivePath(value)) { + const root = `${value.slice(0, 2)}\\`; + const segments = value + .slice(root.length) + .split(/[\\/]+/) + .filter(Boolean); + return { root, separator: "\\", segments }; + } + if (isUncPath(value)) { + const segments = value.split(/[\\/]+/).filter(Boolean); + const [server, share, ...rest] = segments; + if (!server || !share) { + return null; + } + return { + root: `\\\\${server}\\${share}\\`, + separator: "\\", + segments: rest, + }; + } + if (value.startsWith("/")) { + return { + root: "/", + separator, + segments: value + .slice(1) + .split(/[\\/]+/) + .filter(Boolean), + }; + } + return null; +} + export function isFilesystemBrowseQuery(value: string): boolean { return ( value.startsWith("/") || value.startsWith("~/") || value.startsWith("./") || value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") || value.startsWith("\\\\") || isWindowsDrivePath(value) ); @@ -38,6 +92,37 @@ export function normalizeProjectPathForDispatch(value: string): string { return trimTrailingPathSeparators(value.trim()); } +export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { + const trimmedValue = value.trim(); + if (!isExplicitRelativePath(trimmedValue) || !cwd) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); + if (!absoluteBase) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const nextSegments = [...absoluteBase.segments]; + for (const segment of trimmedValue.split(/[\\/]+/)) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + nextSegments.pop(); + continue; + } + nextSegments.push(segment); + } + + const joinedPath = nextSegments.join(absoluteBase.separator); + if (joinedPath.length === 0) { + return normalizeProjectPathForDispatch(absoluteBase.root); + } + + return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`); +} + export function normalizeProjectPathForComparison(value: string): string { const normalized = normalizeProjectPathForDispatch(value); if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 6d98d08cc8..2090cf60ea 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -320,12 +320,12 @@ describe("wsNativeApi", () => { }); }); - it("forwards filesystem browse requests to the websocket project method", async () => { + it("forwards filesystem browse requests to the websocket filesystem method", async () => { requestMock.mockResolvedValue({ parentPath: "/tmp", entries: [] }); const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); - await api.projects.browseFilesystem({ + await api.filesystem.browse({ partialPath: "/tmp/project", }); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index a04922fad7..8a0a4d2cd5 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -114,7 +114,9 @@ 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), + }, + filesystem: { + browse: (input) => transport.request(WS_METHODS.filesystemBrowse, input), }, shell: { openInEditor: (cwd, editor) => diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts new file mode 100644 index 0000000000..0675066daf --- /dev/null +++ b/packages/contracts/src/filesystem.ts @@ -0,0 +1,22 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const FILESYSTEM_PATH_MAX_LENGTH = 512; + +export const FilesystemBrowseInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), +}); +export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; + +export const FilesystemBrowseEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + fullPath: TrimmedNonEmptyString, +}); +export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; + +export const FilesystemBrowseResult = Schema.Struct({ + parentPath: TrimmedNonEmptyString, + entries: Schema.Array(FilesystemBrowseEntry), +}); +export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..c9f708ce4a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,4 +10,5 @@ export * from "./server"; export * from "./git"; export * from "./orchestration"; export * from "./editor"; +export * from "./filesystem"; export * from "./project"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 574e8d4e60..ebe88e7f90 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,13 +19,12 @@ import type { GitStatusResult, } from "./git"; import type { - FilesystemBrowseInput, - FilesystemBrowseResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -130,7 +129,9 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; - browseFilesystem: (input: FilesystemBrowseInput) => Promise; + }; + filesystem: { + browse: (input: FilesystemBrowseInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index a290812812..0903253301 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -26,23 +26,6 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; -export const FilesystemBrowseInput = Schema.Struct({ - partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), -}); -export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; - -export const FilesystemBrowseEntry = Schema.Struct({ - name: TrimmedNonEmptyString, - fullPath: TrimmedNonEmptyString, -}); -export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; - -export const FilesystemBrowseResult = Schema.Struct({ - parentPath: TrimmedNonEmptyString, - entries: Schema.Array(FilesystemBrowseEntry), -}); -export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; - export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index a6ab353270..7c2135978d 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -80,12 +80,14 @@ it.effect("accepts filesystem browse requests and trims the partial path", () => body: { _tag: WS_METHODS.filesystemBrowse, partialPath: " ~/projects ", + cwd: " /repo/app ", }, }); assert.strictEqual(parsed.body._tag, WS_METHODS.filesystemBrowse); if (parsed.body._tag === WS_METHODS.filesystemBrowse) { assert.strictEqual(parsed.body.partialPath, "~/projects"); + assert.strictEqual(parsed.body.cwd, "/repo/app"); } }), ); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 1e1baa609d..5f5f6b0bfe 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -34,7 +34,8 @@ import { TerminalWriteInput, } from "./terminal"; import { KeybindingRule } from "./keybindings"; -import { FilesystemBrowseInput, ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; +import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; +import { FilesystemBrowseInput } from "./filesystem"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; From cc3201871370e14701fa79b1262b3848c620b25d Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 20 Mar 2026 12:44:12 +1300 Subject: [PATCH 14/15] Refactor command palette and fix browser tests --- apps/web/src/components/ChatView.browser.tsx | 48 +- .../src/components/CommandPalette.logic.ts | 259 +++++++++ apps/web/src/components/CommandPalette.tsx | 512 ++++++------------ .../src/components/CommandPaletteResults.tsx | 101 ++++ apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/components/ui/command.tsx | 2 +- 6 files changed, 548 insertions(+), 376 deletions(-) create mode 100644 apps/web/src/components/CommandPalette.logic.ts create mode 100644 apps/web/src/components/CommandPaletteResults.tsx diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6895f94cd1..33f9bf8aff 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1391,6 +1391,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const useMetaForMod = isMacPlatform(navigator.platform); + const palette = page.getByTestId("command-palette"); window.dispatchEvent( new KeyboardEvent("keydown", { key: "k", @@ -1401,9 +1402,11 @@ describe("ChatView timeline estimator parity (full app)", () => { }), ); - await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); - await expect.element(page.getByText("New thread")).toBeInTheDocument(); - await page.getByText("New thread").click(); + 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, @@ -1448,6 +1451,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const useMetaForMod = isMacPlatform(navigator.platform); + const palette = page.getByTestId("command-palette"); window.dispatchEvent( new KeyboardEvent("keydown", { key: "k", @@ -1458,10 +1462,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }), ); - await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await expect.element(palette).toBeInTheDocument(); 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(); + 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(); } @@ -1500,6 +1506,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const useMetaForMod = isMacPlatform(navigator.platform); + const palette = page.getByTestId("command-palette"); window.dispatchEvent( new KeyboardEvent("keydown", { key: "k", @@ -1510,10 +1517,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }), ); - await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await expect.element(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(); + await expect.element(palette.getByText("Project", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .not.toBeInTheDocument(); } finally { await mounted.cleanup(); } @@ -1522,7 +1531,15 @@ describe("ChatView timeline estimator parity (full app)", () => { 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" }), + JSON.stringify({ + codexBinaryPath: "", + codexHomePath: "", + defaultThreadEnvMode: "worktree", + confirmThreadDelete: true, + enableAssistantStreaming: false, + timestampFormat: "locale", + customCodexModels: [], + }), ); const mounted = await mountChatView({ @@ -1554,6 +1571,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const useMetaForMod = isMacPlatform(navigator.platform); + const palette = page.getByTestId("command-palette"); window.dispatchEvent( new KeyboardEvent("keydown", { key: "k", @@ -1564,11 +1582,13 @@ describe("ChatView timeline estimator parity (full app)", () => { }), ); - await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await expect.element(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(); + 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, diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts new file mode 100644 index 0000000000..f0dc320c82 --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -0,0 +1,259 @@ +import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; +import { type ReactNode } from "react"; +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 label: string; + readonly title: ReactNode; + readonly description?: string; + readonly searchText?: 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" | "root-browse" | "submenu" | "submenu-browse"; + +export 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); +} + +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}`, + label: `${project.name} ${project.cwd}`.trim(), + 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; + icon: ReactNode; + runThread: (threadId: Thread["id"]) => Promise; + limit?: number; +}): CommandPaletteActionItem[] { + const sortedThreads = input.threads.toSorted(compareThreadsByCreatedAtDesc); + 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}`, + label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), + title: thread.title, + description: descriptionParts.join(" · "), + timestamp: formatRelativeTime(thread.createdAt), + icon: input.icon, + run: async () => { + await input.runThread(thread.id); + }, + }; + }); +} + +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.filter((item) => { + const haystack = normalizeSearchText( + [item.searchText ?? item.label, item.searchText ? "" : (item.description ?? "")].join(" "), + ); + return haystack.includes(normalizedQuery); + }); + + if (items.length === 0) { + return []; + } + + return [{ value: group.value, label: group.label, items }]; + }); +} + +export function buildBrowseGroups(input: { + browseEntries: ReadonlyArray; + canBrowseUp: boolean; + upIcon: ReactNode; + directoryIcon: ReactNode; + browseUp: () => void; + browseTo: (name: string) => void; +}): CommandPaletteGroup[] { + const items: CommandPaletteActionItem[] = []; + + if (input.canBrowseUp) { + items.push({ + kind: "action", + value: "browse:up", + label: "..", + title: "..", + icon: input.upIcon, + keepOpen: true, + run: async () => { + input.browseUp(); + }, + }); + } + + for (const entry of input.browseEntries) { + items.push({ + kind: "action", + value: `browse:${entry.fullPath}`, + label: entry.name, + title: entry.name, + icon: input.directoryIcon, + keepOpen: true, + run: async () => { + input.browseTo(entry.name); + }, + }); + } + + return [{ value: "directories", label: "Directories", items }]; +} + +export function getCommandPaletteMode(input: { + currentView: CommandPaletteView | null; + isBrowsing: boolean; +}): CommandPaletteMode { + if (input.currentView) { + return input.isBrowsing ? "submenu-browse" : "submenu"; + } + return input.isBrowsing ? "root-browse" : "root"; +} + +export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { + switch (mode) { + case "root": + return "Search commands, projects, and threads..."; + case "root-browse": + return "Enter project path (e.g. ~/projects/my-app)"; + case "submenu": + return "Search..."; + case "submenu-browse": + return "Enter path (e.g. ~/projects/my-app)"; + } +} + +export function getCommandPaletteInputStartAddon(input: { + mode: CommandPaletteMode; + currentViewAddonIcon: ReactNode | null; + browseIcon: ReactNode; +}): ReactNode | undefined { + if (input.mode === "submenu" || input.mode === "submenu-browse") { + return input.currentViewAddonIcon ?? undefined; + } + if (input.mode === "root-browse") { + return input.browseIcon; + } + return undefined; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fc61e26102..cae2cc6f37 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,13 +1,11 @@ "use client"; -import { type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { ArrowDownIcon, ArrowUpIcon, - ChevronRightIcon, CornerLeftUpIcon, FolderIcon, FolderPlusIcon, @@ -15,7 +13,15 @@ import { SettingsIcon, SquarePenIcon, } from "lucide-react"; -import { useCallback, useDeferredValue, useMemo, useState, type ReactNode } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; import { useAppSettings } from "../appSettings"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -31,89 +37,40 @@ import { import { addProjectFromPath } from "../lib/projectAdd"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn } 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"; +import { + ADDON_ICON_CLASS, + buildBrowseGroups, + buildProjectActionItems, + buildThreadActionItems, + type CommandPaletteActionItem, + type CommandPaletteGroup, + type CommandPaletteSubmenuItem, + type CommandPaletteView, + filterCommandPaletteGroups, + getCommandPaletteInputPlaceholder, + getCommandPaletteInputStartAddon, + getCommandPaletteMode, + ITEM_ICON_CLASS, + RECENT_THREAD_LIMIT, +} from "./CommandPalette.logic"; +import { CommandPaletteResults } from "./CommandPaletteResults"; +import { Button } from "./ui/button"; import { Command, - CommandCollection, CommandDialog, CommandDialogPopup, CommandFooter, - CommandGroup, - CommandGroupLabel, CommandInput, - CommandItem, - CommandList, CommandPanel, - CommandShortcut, } from "./ui/command"; -import { Button } from "./ui/button"; +import { Kbd, KbdGroup } from "./ui/kbd"; import { toastManager } from "./ui/toast"; -const RECENT_THREAD_LIMIT = 12; - -interface CommandPaletteItem { - readonly kind: "action" | "submenu"; - readonly value: string; - readonly label: string; - readonly title: ReactNode; - readonly description?: string; - readonly searchText?: string; - readonly timestamp?: string; - readonly icon: ReactNode; - readonly shortcutCommand?: KeybindingCommand; -} - -interface CommandPaletteActionItem extends CommandPaletteItem { - readonly kind: "action"; - readonly keepOpen?: boolean; - readonly run: () => Promise; -} - -interface CommandPaletteSubmenuItem extends CommandPaletteItem { - readonly kind: "submenu"; - readonly addonIcon: ReactNode; - readonly groups: ReadonlyArray; - readonly initialQuery?: string; -} - -interface CommandPaletteGroup { - readonly value: string; - readonly label: string; - readonly items: ReadonlyArray; -} - -interface CommandPaletteView { - readonly addonIcon: ReactNode; - readonly title: ReactNode; - readonly groups: ReadonlyArray; - readonly initialQuery?: string; -} - -const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; -const ADDON_ICON_CLASS = "size-4"; - -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, " "); -} - export function CommandPalette({ children }: { children: ReactNode }) { - const open = useCommandPaletteStore((s) => s.open); - const setOpen = useCommandPaletteStore((s) => s.setOpen); + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); return ( @@ -124,7 +81,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { } function CommandPaletteDialog() { - const open = useCommandPaletteStore((s) => s.open); + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + + useEffect(() => { + return () => { + setOpen(false); + }; + }, [setOpen]); + if (!open) { return null; } @@ -134,12 +99,13 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); - const setOpen = useCommandPaletteStore((s) => s.setOpen); + const setOpen = useCommandPaletteStore((store) => store.setOpen); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = query.startsWith(">"); const isBrowsing = isFilesystemBrowseQuery(query); const [debouncedBrowsePath] = useDebouncedValue(query, { wait: 200 }); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); const { settings } = useAppSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); const threads = useStore((store) => store.threads); @@ -147,20 +113,29 @@ function OpenCommandPaletteDialog() { const keybindings = serverConfigQuery.data?.keybindings ?? []; const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; + const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const [browseGeneration, setBrowseGeneration] = useState(0); + const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); + const projectTitleById = useMemo( + () => new Map(projects.map((project) => [project.id, project.name] as const)), + [projects], + ); + const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; const currentProjectCwd = currentProjectId ? (projectCwdById.get(currentProjectId) ?? null) : null; + const { data: browseEntries = [] } = useQuery({ queryKey: ["filesystemBrowse", debouncedBrowsePath, currentProjectCwd], queryFn: async () => { const api = readNativeApi(); if (!api) return []; + const result = await api.filesystem.browse({ partialPath: debouncedBrowsePath, ...(currentProjectCwd ? { cwd: currentProjectCwd } : {}), @@ -173,71 +148,81 @@ function OpenCommandPaletteDialog() { (!debouncedBrowsePath.startsWith(".") || currentProjectCwd !== null), }); - const projectTitleById = useMemo( - () => new Map(projects.map((project) => [project.id, project.name] as const)), - [projects], - ); - - const projectThreadItems = useMemo( + const projectThreadItems = useMemo( () => - projects.map((project) => ({ - kind: "action", - value: `new-thread-in:${project.id}`, - label: `${project.name} ${project.cwd}`.trim(), - title: project.name, - description: project.cwd, + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", icon: , - run: async () => { - await handleNewThread(project.id, { + runProject: async (projectId) => { + await handleNewThread(projectId, { envMode: settings.defaultThreadEnvMode, }); }, - })), + }), [handleNewThread, projects, settings.defaultThreadEnvMode], ); - const projectLocalThreadItems = useMemo( + const projectLocalThreadItems = useMemo( () => - projects.map((project) => ({ - kind: "action", - value: `new-local-thread-in:${project.id}`, - label: `${project.name} ${project.cwd}`.trim(), - title: project.name, - description: project.cwd, + buildProjectActionItems({ + projects, + valuePrefix: "new-local-thread-in", icon: , - run: async () => { - await handleNewThread(project.id, { + runProject: async (projectId) => { + await handleNewThread(projectId, { envMode: "local", }); }, - })), + }), [handleNewThread, projects], ); + const allThreadItems = useMemo( + () => + buildThreadActionItems({ + threads, + ...(activeThread?.id ? { activeThreadId: activeThread.id } : {}), + projectTitleById, + icon: , + runThread: async (threadId) => { + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + }), + [activeThread?.id, navigate, projectTitleById, threads], + ); + + const recentThreadItems = useMemo( + () => allThreadItems.slice(0, RECENT_THREAD_LIMIT), + [allThreadItems], + ); + const pushView = useCallback((item: CommandPaletteSubmenuItem) => { - setViewStack((prev) => [ - ...prev, + setViewStack((previousViews) => [ + ...previousViews, { addonIcon: item.addonIcon, - title: item.title, groups: item.groups, ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), }, ]); + setHighlightedItemValue(null); setQuery(item.initialQuery ?? ""); }, []); const popView = useCallback(() => { - setViewStack((prev) => prev.slice(0, -1)); + setViewStack((previousViews) => previousViews.slice(0, -1)); + setHighlightedItemValue(null); setQuery(""); }, []); const handleQueryChange = useCallback( (nextQuery: string) => { + setHighlightedItemValue(null); setQuery(nextQuery); - // Auto-exit views that were entered with an initial query (e.g. browse mode) - // when the input is fully cleared. This unifies the exit behavior for - // typing ~/... at root and entering via the "Add project" submenu. if (nextQuery === "" && currentView?.initialQuery) { popView(); } @@ -253,7 +238,6 @@ function OpenCommandPaletteDialog() { ? (projectTitleById.get(currentProjectId) ?? null) : null; - // Quick actions: only show when there's an active thread/draft to derive the project from if (activeProjectTitle) { actionItems.push({ kind: "action", @@ -347,155 +331,50 @@ function OpenCommandPaletteDialog() { }, }); - const recentThreadItems = threads - .toSorted(compareThreadsByCreatedAtDesc) - .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 { - kind: "action", - value: `thread:${thread.id}`, - label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), - title: thread.title, - description: descriptionParts.join(" · "), - timestamp: formatRelativeTime(thread.createdAt), - icon: , - run: async () => { - await navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }, - }; - }); - - const nextGroups: CommandPaletteGroup[] = []; + const groups: CommandPaletteGroup[] = []; if (actionItems.length > 0) { - nextGroups.push({ + groups.push({ value: "actions", label: "Actions", items: actionItems, }); } if (recentThreadItems.length > 0) { - nextGroups.push({ + groups.push({ value: "recent-threads", label: "Recent Threads", items: recentThreadItems, }); } - return nextGroups; + return groups; }, [ activeDraftThread, activeThread, currentProjectId, handleNewThread, navigate, - projectTitleById, - projects, projectLocalThreadItems, projectThreadItems, + projectTitleById, + projects, + recentThreadItems, settings.defaultThreadEnvMode, - threads, ]); const activeGroups = currentView ? currentView.groups : rootGroups; - // All threads as searchable items (used when there's a query to search beyond the 12 recent) - const allThreadItems = useMemo( + const filteredGroups = useMemo( () => - threads.toSorted(compareThreadsByCreatedAtDesc).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 { - kind: "action", - value: `thread:${thread.id}`, - label: `${thread.title} ${projectTitle ?? ""} ${thread.branch ?? ""}`.trim(), - title: thread.title, - description: descriptionParts.join(" · "), - timestamp: formatRelativeTime(thread.createdAt), - icon: , - run: async () => { - await navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }, - }; + filterCommandPaletteGroups({ + activeGroups, + query: deferredQuery, + isInSubmenu: currentView !== null, + projectSearchItems: projectThreadItems, + threadSearchItems: allThreadItems, }), - [activeThread, navigate, projectTitleById, threads], + [activeGroups, allThreadItems, currentView, deferredQuery, projectThreadItems], ); - const filteredGroups = useMemo(() => { - const isActionsFilter = deferredQuery.startsWith(">"); - const searchQuery = isActionsFilter ? deferredQuery.slice(1) : deferredQuery; - const normalizedQuery = normalizeSearchText(searchQuery); - - if (normalizedQuery.length === 0) { - if (isActionsFilter) { - return activeGroups.filter((group) => group.value === "actions"); - } - return activeGroups; - } - - // When searching at root level, replace the recent-threads group with all threads - // and add all projects so the full dataset is searchable - let baseGroups = activeGroups; - if (isActionsFilter) { - baseGroups = activeGroups.filter((group) => group.value === "actions"); - } else if (currentView === null) { - baseGroups = activeGroups.filter((group) => group.value !== "recent-threads"); - } - - const extraGroups: CommandPaletteGroup[] = []; - if (currentView === null && !isActionsFilter) { - if (projectThreadItems.length > 0) { - extraGroups.push({ - value: "projects-search", - label: "Projects", - items: projectThreadItems, - }); - } - if (allThreadItems.length > 0) { - extraGroups.push({ - value: "threads-search", - label: "Threads", - items: allThreadItems, - }); - } - } - - const searchableGroups = [...baseGroups, ...extraGroups]; - - return searchableGroups.flatMap((group) => { - const items = group.items.filter((item) => { - const haystack = normalizeSearchText( - [item.searchText ?? item.label, item.searchText ? "" : (item.description ?? "")].join( - " ", - ), - ); - return haystack.includes(normalizedQuery); - }); - - if (items.length === 0) { - return []; - } - - return [{ value: group.value, label: group.label, items }]; - }); - }, [activeGroups, allThreadItems, currentView, deferredQuery, projectThreadItems]); - const handleAddProject = useCallback( async (rawCwd: string) => { const api = readNativeApi(); @@ -539,84 +418,63 @@ function OpenCommandPaletteDialog() { ], ); - // Navigate into a subdirectory in browse mode const browseTo = useCallback( (name: string) => { + setHighlightedItemValue(null); setQuery(appendBrowsePathSegment(query, name)); - setBrowseGeneration((g) => g + 1); + setBrowseGeneration((generation) => generation + 1); }, [query], ); - // Navigate up one directory level in browse mode const browseUp = useCallback(() => { const parentPath = getBrowseParentPath(query); - if (parentPath !== null) { - setQuery(parentPath); - setBrowseGeneration((g) => g + 1); + if (parentPath === null) { + return; } + + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); }, [query]); const canBrowseUp = isBrowsing && getBrowseParentPath(query) !== null; - // Browse mode items rendered through the autocomplete primitive - const browseGroups = useMemo(() => { - const items: CommandPaletteActionItem[] = []; - - // ".." to go up - if (canBrowseUp) { - items.push({ - kind: "action", - value: "browse:up", - label: "..", - title: "..", - icon: , - keepOpen: true, - run: async () => { - browseUp(); - }, - }); - } - - // Directory entries - for (const entry of browseEntries) { - items.push({ - kind: "action", - value: `browse:${entry.fullPath}`, - label: entry.name, - title: entry.name, - icon: , - keepOpen: true, - run: async () => { - browseTo(entry.name); - }, - }); - } - - return [{ value: "directories", label: "Directories", items }]; - }, [canBrowseUp, browseEntries, browseUp, browseTo]); + const browseGroups = useMemo( + () => + buildBrowseGroups({ + browseEntries, + canBrowseUp, + upIcon: , + directoryIcon: , + browseUp, + browseTo, + }), + [browseEntries, browseTo, browseUp, canBrowseUp], + ); const displayedGroups = isBrowsing ? browseGroups : filteredGroups; + const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); + const inputStartAddon = getCommandPaletteInputStartAddon({ + mode: paletteMode, + currentViewAddonIcon: currentView?.addonIcon ?? null, + browseIcon: , + }); + const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - // In browse mode, Enter with nothing highlighted submits the typed path - if (isBrowsing && event.key === "Enter") { - const hasHighlight = document.querySelector( - "[data-testid='command-palette'] [data-highlighted]", - ); - if (!hasHighlight) { - event.preventDefault(); - void handleAddProject(query.trim()); - } + (event: KeyboardEvent) => { + if (isBrowsing && event.key === "Enter" && highlightedItemValue === null) { + event.preventDefault(); + void handleAddProject(query.trim()); } - if (event.key === "Backspace" && query === "" && viewStack.length > 0) { + if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); popView(); } }, - [isBrowsing, query, handleAddProject, viewStack, popView], + [handleAddProject, highlightedItemValue, isBrowsing, isSubmenu, popView, query], ); const executeItem = useCallback( @@ -625,9 +483,11 @@ function OpenCommandPaletteDialog() { pushView(item); return; } + if (!item.keepOpen) { setOpen(false); } + void item.run().catch((error: unknown) => { toastManager.add({ type: "error", @@ -639,24 +499,6 @@ function OpenCommandPaletteDialog() { [pushView, setOpen], ); - let inputPlaceholder = "Search commands, projects, and threads..."; - if (currentView !== null) { - inputPlaceholder = "Search..."; - } - if (isBrowsing) { - inputPlaceholder = "Enter project path (e.g. ~/projects/my-app)"; - if (currentView !== null) { - inputPlaceholder = "Enter path (e.g. ~/projects/my-app)"; - } - } - - let inputStartAddon: ReactNode = undefined; - if (currentView !== null) { - inputStartAddon = currentView.addonIcon; - } else if (isBrowsing) { - inputStartAddon = ; - } - return ( { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} onValueChange={handleQueryChange} value={query} > @@ -695,65 +540,12 @@ function OpenCommandPaletteDialog() { ) : null}
- {displayedGroups.length === 0 ? ( -
- {isActionsOnly - ? "No matching actions." - : "No matching commands, projects, or threads."} -
- ) : ( - - {displayedGroups.map((group) => ( - - {group.label} - - {(item) => { - const shortcutLabel = item.shortcutCommand - ? shortcutLabelForCommand(keybindings, item.shortcutCommand) - : null; - return ( - { - event.preventDefault(); - }} - onClick={() => { - executeItem(item); - }} - > - {item.icon} - {item.description ? ( - - {item.title} - - {item.description} - - - ) : ( - - {item.title} - - )} - {item.timestamp ? ( - - {item.timestamp} - - ) : null} - {shortcutLabel ? ( - {shortcutLabel} - ) : null} - {item.kind === "submenu" ? ( - - ) : null} - - ); - }} - - - ))} - - )} +
@@ -770,7 +562,7 @@ function OpenCommandPaletteDialog() { Enter Select - {currentView !== null ? ( + {isSubmenu ? ( Backspace Back diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx new file mode 100644 index 0000000000..938d86ff37 --- /dev/null +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -0,0 +1,101 @@ +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 { + groups: ReadonlyArray; + isActionsOnly: boolean; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +} + +export function CommandPaletteResults(props: CommandPaletteResultsProps) { + if (props.groups.length === 0) { + return ( +
+ {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.tsx b/apps/web/src/components/Sidebar.tsx index 5b6173861c..b68b030192 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1194,7 +1194,7 @@ export default function Sidebar() { } > - Search commands + Search {commandPaletteShortcutLabel ? ( {commandPaletteShortcutLabel} 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 ( Date: Fri, 20 Mar 2026 15:31:22 +1300 Subject: [PATCH 15/15] Fix command palette path browsing --- apps/server/src/wsServer.test.ts | 52 +++++++++++++++++++ apps/server/src/wsServer.ts | 25 ++++++--- .../src/components/CommandPalette.logic.ts | 7 ++- apps/web/src/components/CommandPalette.tsx | 51 +++++++++++++++--- .../src/components/CommandPaletteResults.tsx | 9 ++-- apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/lib/projectAdd.ts | 19 +++---- apps/web/src/lib/projectPaths.test.ts | 16 +++++- apps/web/src/lib/projectPaths.ts | 19 +++++-- 9 files changed, 163 insertions(+), 36 deletions(-) diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index afeb66b14b..76c2d82520 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1606,6 +1606,38 @@ describe("WebSocket Server", () => { }); }); + it("skips unreadable or broken browse entries instead of failing the request", async () => { + if (process.platform === "win32") { + return; + } + + const workspace = makeTempDir("t3code-ws-filesystem-browse-broken-entry-"); + fs.mkdirSync(path.join(workspace, "docs"), { recursive: true }); + fs.symlinkSync(path.join(workspace, "missing-target"), path.join(workspace, "broken-link")); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: `${workspace}/`, + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + parentPath: workspace, + entries: [ + { + name: "docs", + fullPath: path.join(workspace, "docs"), + }, + ], + }); + }); + it("resolves relative filesystem.browse paths against the provided cwd", async () => { const workspace = makeTempDir("t3code-ws-filesystem-browse-relative-"); fs.mkdirSync(path.join(workspace, "apps"), { recursive: true }); @@ -1653,6 +1685,26 @@ describe("WebSocket Server", () => { ); }); + it("rejects windows-style filesystem.browse paths on non-windows hosts", async () => { + if (process.platform === "win32") { + return; + } + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: "C:\\Work\\Repo", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Windows-style paths are only supported on Windows."); + }); + it("supports projects.writeFile within the workspace root", async () => { const workspace = makeTempDir("t3code-ws-write-file-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 06d6381fd4..ee5cd74210 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -110,6 +110,14 @@ const isServerNotRunningError = (error: Error): boolean => { ); }; +function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +function isWindowsAbsolutePath(value: string): boolean { + return value.startsWith("\\\\") || isWindowsDrivePath(value); +} + function isExplicitRelativePath(value: string): boolean { return ( value.startsWith("./") || @@ -894,6 +902,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.filesystemBrowse: { const body = stripRequestTag(request.body); + if (process.platform !== "win32" && isWindowsAbsolutePath(body.partialPath)) { + return yield* new RouteRequestError({ + message: "Windows-style paths are only supported on Windows.", + }); + } const resolvedInputPath = yield* resolveFilesystemBrowseInputPath({ cwd: body.cwd, path, @@ -931,13 +944,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const entries = yield* Effect.forEach( filtered, (name) => - fileSystem - .stat(path.join(parentDir, name)) - .pipe( - Effect.map((s) => + fileSystem.stat(path.join(parentDir, name)).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (s) => s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null, - ), - ), + }), + ), { concurrency: 16 }, ); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f0dc320c82..a1134b5969 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -182,6 +182,7 @@ export function filterCommandPaletteGroups(input: { export function buildBrowseGroups(input: { browseEntries: ReadonlyArray; + browseQuery: string; canBrowseUp: boolean; upIcon: ReactNode; directoryIcon: ReactNode; @@ -194,7 +195,8 @@ export function buildBrowseGroups(input: { items.push({ kind: "action", value: "browse:up", - label: "..", + label: `${input.browseQuery} ..`, + searchText: `${input.browseQuery} ..`, title: "..", icon: input.upIcon, keepOpen: true, @@ -208,7 +210,8 @@ export function buildBrowseGroups(input: { items.push({ kind: "action", value: `browse:${entry.fullPath}`, - label: entry.name, + label: `${input.browseQuery} ${entry.fullPath} ${entry.name}`, + searchText: `${input.browseQuery} ${entry.fullPath} ${entry.name}`, title: entry.name, icon: input.directoryIcon, keepOpen: true, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index cae2cc6f37..d9e5a06fdd 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -32,6 +32,7 @@ import { import { appendBrowsePathSegment, getBrowseParentPath, + isExplicitRelativeProjectPath, isFilesystemBrowseQuery, } from "../lib/projectPaths"; import { addProjectFromPath } from "../lib/projectAdd"; @@ -129,6 +130,10 @@ function OpenCommandPaletteDialog() { const currentProjectCwd = currentProjectId ? (projectCwdById.get(currentProjectId) ?? null) : null; + const relativePathNeedsActiveProject = + isExplicitRelativeProjectPath(query.trim()) && currentProjectCwd === null; + const debouncedRelativePathNeedsActiveProject = + isExplicitRelativeProjectPath(debouncedBrowsePath.trim()) && currentProjectCwd === null; const { data: browseEntries = [] } = useQuery({ queryKey: ["filesystemBrowse", debouncedBrowsePath, currentProjectCwd], @@ -143,9 +148,7 @@ function OpenCommandPaletteDialog() { return result.entries; }, enabled: - isBrowsing && - debouncedBrowsePath.length > 0 && - (!debouncedBrowsePath.startsWith(".") || currentProjectCwd !== null), + isBrowsing && debouncedBrowsePath.length > 0 && !debouncedRelativePathNeedsActiveProject, }); const projectThreadItems = useMemo( @@ -393,6 +396,7 @@ function OpenCommandPaletteDialog() { params: { threadId }, }); }, + platform: navigator.platform, projects, threads, }, @@ -438,22 +442,32 @@ function OpenCommandPaletteDialog() { setBrowseGeneration((generation) => generation + 1); }, [query]); - const canBrowseUp = isBrowsing && getBrowseParentPath(query) !== null; + const canBrowseUp = + isBrowsing && !relativePathNeedsActiveProject && getBrowseParentPath(query) !== null; const browseGroups = useMemo( () => buildBrowseGroups({ browseEntries, + browseQuery: query, canBrowseUp, upIcon: , directoryIcon: , browseUp, browseTo, }), - [browseEntries, browseTo, browseUp, canBrowseUp], + [browseEntries, browseTo, browseUp, canBrowseUp, query], ); - const displayedGroups = isBrowsing ? browseGroups : filteredGroups; + const displayedGroups = useMemo( + () => + isBrowsing && relativePathNeedsActiveProject + ? [] + : isBrowsing + ? browseGroups + : filteredGroups, + [browseGroups, filteredGroups, isBrowsing, relativePathNeedsActiveProject], + ); const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); const inputStartAddon = getCommandPaletteInputStartAddon({ mode: paletteMode, @@ -464,7 +478,12 @@ function OpenCommandPaletteDialog() { const handleKeyDown = useCallback( (event: KeyboardEvent) => { - if (isBrowsing && event.key === "Enter" && highlightedItemValue === null) { + if ( + isBrowsing && + event.key === "Enter" && + highlightedItemValue === null && + !relativePathNeedsActiveProject + ) { event.preventDefault(); void handleAddProject(query.trim()); } @@ -474,7 +493,15 @@ function OpenCommandPaletteDialog() { popView(); } }, - [handleAddProject, highlightedItemValue, isBrowsing, isSubmenu, popView, query], + [ + handleAddProject, + highlightedItemValue, + isBrowsing, + isSubmenu, + popView, + query, + relativePathNeedsActiveProject, + ], ); const executeItem = useCallback( @@ -527,11 +554,16 @@ function OpenCommandPaletteDialog() {