From 7bef645ff12f397a25dbb8660851b65261c6fbdb Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sun, 15 Mar 2026 15:08:37 +1100 Subject: [PATCH 01/50] 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 0c00fed4e70..b57c13032ce 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 bf58467825a..fcb3db0d8d7 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 faecc7f51b1..1e8e97037ff 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1141,6 +1141,63 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens the command palette from the configurable shortcut and runs a command", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-shortcut-test" as MessageId, + targetText: "command palette shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await expect.element(page.getByText("New thread")).toBeInTheDocument(); + await page.getByText("New thread").click(); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the command palette.", + ); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e64..b1da8258be3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,6 +86,7 @@ import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; +import { useCommandPalette } from "./CommandPalette"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -197,6 +198,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const { open: commandPaletteOpen } = useCommandPalette(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -1966,7 +1968,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId || event.defaultPrevented) return; + if (!activeThreadId || commandPaletteOpen || event.defaultPrevented) return; const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), @@ -2042,6 +2044,7 @@ export default function ChatView({ threadId }: ChatViewProps) { keybindings, onToggleDiff, toggleTerminalVisibility, + commandPaletteOpen, ]); const addComposerImages = (files: File[]) => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 00000000000..53c12faf42c --- /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 1b43eb4c18e..57c35a31ba7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { GitPullRequestIcon, PlusIcon, RocketIcon, + SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -50,6 +51,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; +import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -90,6 +92,7 @@ import { shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { CommandDialogTrigger } from "./ui/command"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -1029,6 +1032,10 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new"), [keybindings], ); + const commandPaletteShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "commandPalette.toggle"), + [keybindings], + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1164,6 +1171,29 @@ export default function Sidebar() { )} + + + + + } + > + + Search commands + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f85..b94f9c17e47 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 09d9308aade..2bd4f19add0 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 00000000000..69fde0f3a1c --- /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 193cb0e7a92..03f0dccc3a4 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 1b99362c537..afab73cca7d 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 48821b18241..9b61df6a235 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -13,6 +13,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "commandPalette.toggle", "chat.new", "chat.newLocal", "editor.openFavorite", From 17073d59cc592c845b5232b395da01f4b83b7a3d Mon Sep 17 00:00:00 2001 From: BinBandit Date: Sun, 15 Mar 2026 15:12:49 +1100 Subject: [PATCH 02/50] 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 53c12faf42c..537587fbabe 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/50] 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 1e8e97037ff..515833b5b40 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1198,6 +1198,58 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("filters command palette results as the user types", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-search-test" as MessageId, + targetText: "command palette search test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands and threads...").fill("settings"); + await expect.element(page.getByText("Open settings")).toBeInTheDocument(); + await expect.element(page.getByText("New thread")).not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 537587fbabe..d4a9efb10b3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -4,7 +4,15 @@ import { type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useDeferredValue, + useMemo, + useState, + type ReactNode, +} from "react"; import { useAppSettings } from "../appSettings"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -63,6 +71,10 @@ function iconClassName() { return "size-4 text-muted-foreground/80"; } +function normalizeSearchText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + export function useCommandPalette() { const context = useContext(CommandPaletteContext); if (!context) { @@ -108,6 +120,8 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); const { setOpen } = useCommandPalette(); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); const { settings } = useAppSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); const threads = useStore((store) => store.threads); @@ -118,7 +132,7 @@ function OpenCommandPaletteDialog() { [projects], ); - const groups = useMemo(() => { + const allGroups = useMemo(() => { const actionItems: CommandPaletteItem[] = []; if (projects.length > 0) { const activeProjectTitle = @@ -248,6 +262,25 @@ function OpenCommandPaletteDialog() { threads, ]); + const filteredGroups = useMemo(() => { + const normalizedQuery = normalizeSearchText(deferredQuery); + if (normalizedQuery.length === 0) { + return allGroups; + } + + return allGroups + .map((group) => ({ + ...group, + items: group.items.filter((item) => { + const haystack = normalizeSearchText( + [item.label, item.title, item.description ?? ""].join(" "), + ); + return haystack.includes(normalizedQuery); + }), + })) + .filter((group) => group.items.length > 0); + }, [allGroups, deferredQuery]); + const executeItem = useCallback( (item: CommandPaletteItem) => { setOpen(false); @@ -268,11 +301,11 @@ function OpenCommandPaletteDialog() { className="overflow-hidden p-0" data-testid="command-palette" > - + - {groups.map((group) => ( + {filteredGroups.map((group) => ( {group.label} From 897b9ed18bf6a0908f738a0f5412ee73e72fc6a9 Mon Sep 17 00:00:00 2001 From: Hugo Blom <6117705+huxcrux@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:13:24 +0100 Subject: [PATCH 04/50] 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 515833b5b40..a984499a6b6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -29,6 +29,7 @@ import { estimateTimelineMessageHeight } from "./timelineHeight"; const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const SECOND_PROJECT_ID = "project-2" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -356,6 +357,30 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithSecondaryProject(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-secondary-project-target" as MessageId, + targetText: "secondary project", + }); + + return { + ...snapshot, + projects: [ + ...snapshot.projects, + { + id: SECOND_PROJECT_ID, + title: "Docs Portal", + workspaceRoot: "/repo/clients/docs-portal", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -1242,7 +1267,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); - await page.getByPlaceholder("Search commands and threads...").fill("settings"); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); await expect.element(page.getByText("Open settings")).toBeInTheDocument(); await expect.element(page.getByText("New thread")).not.toBeInTheDocument(); } finally { @@ -1250,6 +1275,123 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("does not match thread actions from contextual project names", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-project-query-test" as MessageId, + targetText: "command palette project query test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("project"); + await expect.element(page.getByText("Project")).toBeInTheDocument(); + await expect.element(page.getByText("New thread")).not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("searches projects by path and opens a new thread using the default env mode", async () => { + localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify({ defaultThreadEnvMode: "worktree" }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(page.getByTestId("command-palette")).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(page.getByText("Docs Portal")).toBeInTheDocument(); + await expect.element(page.getByText("/repo/clients/docs-portal")).toBeInTheDocument(); + await page.getByText("Docs Portal").click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== `/${THREAD_ID}`, + "Route should have changed to a new draft thread UUID from the project search result.", + ); + const nextThreadId = nextPath.slice(1) as ThreadId; + const draftThread = useComposerDraftStore.getState().draftThreadsByThreadId[nextThreadId]; + expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); + expect(draftThread?.envMode).toBe("worktree"); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d4a9efb10b3..ae0980a80ed 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 57c35a31ba7..5cc62efc5a4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -97,16 +98,6 @@ import { CommandDialogTrigger } from "./ui/command"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; -function formatRelativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -1581,7 +1572,11 @@ export default function Sidebar() { : "text-muted-foreground/40" }`} > - {formatRelativeTime(thread.createdAt)} + {formatRelativeTime( + thread.createdAt, + Date.now(), + "short", + )}
diff --git a/apps/web/src/relativeTime.test.ts b/apps/web/src/relativeTime.test.ts new file mode 100644 index 00000000000..dd076ab1968 --- /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 00000000000..c0c6ae2dbdc --- /dev/null +++ b/apps/web/src/relativeTime.ts @@ -0,0 +1,56 @@ +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; +let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; +export type RelativeTimeStyle = "long" | "short"; + +function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { + if (relativeTimeFormatter === null) { + relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + } + return relativeTimeFormatter.format(-value, unit); +} + +function formatShortRelativeUnit(value: number, suffix: string): string { + return `${value}${suffix} ago`; +} + +export function formatRelativeTime( + isoDate: string, + nowMs = Date.now(), + style: RelativeTimeStyle = "long", +): string { + const targetMs = Date.parse(isoDate); + if (Number.isNaN(targetMs)) { + return ""; + } + + const diffMs = Math.max(0, nowMs - targetMs); + const formatUnit = (value: number, unit: Intl.RelativeTimeFormatUnit, shortSuffix: string) => + style === "short" + ? formatShortRelativeUnit(value, shortSuffix) + : formatRelativeUnit(value, unit); + + if (diffMs < MINUTE_MS) { + return "just now"; + } + if (diffMs < HOUR_MS) { + return formatUnit(Math.floor(diffMs / MINUTE_MS), "minute", "m"); + } + if (diffMs < DAY_MS) { + return formatUnit(Math.floor(diffMs / HOUR_MS), "hour", "h"); + } + if (diffMs < WEEK_MS) { + return formatUnit(Math.floor(diffMs / DAY_MS), "day", "d"); + } + if (diffMs < MONTH_MS) { + return formatUnit(Math.floor(diffMs / WEEK_MS), "week", "w"); + } + if (diffMs < YEAR_MS) { + return formatUnit(Math.floor(diffMs / MONTH_MS), "month", "mo"); + } + return formatUnit(Math.floor(diffMs / YEAR_MS), "year", "y"); +} From e7ffca48043bde822797e20f2592e06dddd79af5 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 05/50] 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 ebb76138b85..c30b6f6e506 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -72,6 +72,9 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Filesystem + filesystemBrowse: "filesystem.browse", + // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", @@ -136,6 +139,14 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), + // Filesystem + tagRequestBody( + WS_METHODS.filesystemBrowse, + Schema.Struct({ + partialPath: Schema.String, + }), + ), + // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), From cefe9261948df7325de48723b9876905fcec0b0c Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 06/50] 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 b9127fb1766..0c2c337ff37 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -128,6 +128,10 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + browseFilesystem: (input: { partialPath: string }) => Promise<{ + parentPath: string; + entries: Array<{ name: string; fullPath: string }>; + }>; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; From ea52bb3050b7a3472cca29ff1bac77055d06cac2 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 07/50] 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 2e6ac51b7fe..a724a5780dc 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -866,6 +866,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } + case WS_METHODS.filesystemBrowse: { + const body = stripRequestTag(request.body); + const expanded = path.resolve(yield* expandHomePath(body.partialPath)); + const endsWithSep = body.partialPath.endsWith("/") || body.partialPath === "~"; + const parentDir = endsWithSep ? expanded : path.dirname(expanded); + const prefix = endsWithSep ? "" : path.basename(expanded); + + const names = yield* fileSystem + .readDirectory(parentDir) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))); + + const showHidden = prefix.startsWith("."); + const filtered = names + .filter((n) => n.startsWith(prefix) && (showHidden || !n.startsWith("."))) + .slice(0, 100); + + const entries = yield* Effect.forEach( + filtered, + (name) => + fileSystem.stat(path.join(parentDir, name)).pipe( + Effect.map((s) => + s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null, + ), + Effect.catch(() => Effect.succeed(null)), + ), + { concurrency: 16 }, + ); + + return { + parentPath: parentDir, + entries: entries.filter(Boolean).slice(0, 50), + }; + } + case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; return { From c6a9033e223b4a17e9d9db02500a507197235017 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 11:16:39 +0000 Subject: [PATCH 08/50] 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 ddfffbde69f..a04922fad77 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -114,6 +114,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + browseFilesystem: (input) => transport.request(WS_METHODS.filesystemBrowse, input), }, shell: { openInEditor: (cwd, editor) => From 7bc39e699c88d762502106deedbe9b862706dc9c Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 12:01:47 +0000 Subject: [PATCH 09/50] 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 ae0980a80ed..0ba0fca41f2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,9 +1,16 @@ "use client"; -import { type KeybindingCommand } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type KeybindingCommand } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { FolderIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { + FolderIcon, + FolderPlusIcon, + MessageSquareIcon, + SettingsIcon, + SquarePenIcon, +} from "lucide-react"; import { createContext, useCallback, @@ -20,8 +27,9 @@ import { startNewThreadFromContext, } from "../lib/chatThreadActions"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; -import { cn } from "../lib/utils"; +import { cn, newCommandId, newProjectId } from "../lib/utils"; import { shortcutLabelForCommand } from "../keybindings"; +import { readNativeApi } from "../nativeApi"; import { formatRelativeTime } from "../relativeTime"; import { useStore } from "../store"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -59,6 +67,7 @@ interface CommandPaletteItem { readonly timestamp?: string; readonly icon: ReactNode; readonly shortcutCommand?: KeybindingCommand; + readonly keepOpen?: boolean; readonly run: () => Promise; } @@ -89,6 +98,15 @@ function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } +function getHighlightedEntryPath(): string | null { + const item = document.querySelector( + "[data-testid='command-palette'] [data-slot='autocomplete-item'][data-highlighted]", + ); + if (!item) return null; + const description = item.querySelector("[class*='text-xs']"); + return description?.textContent ?? null; +} + export function useCommandPalette() { const context = useContext(CommandPaletteContext); if (!context) { @@ -136,11 +154,25 @@ function OpenCommandPaletteDialog() { const { setOpen } = useCommandPalette(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); + const isBrowsing = query.startsWith("/") || query.startsWith("~/") || query.startsWith("./"); + const [debouncedBrowsePath] = useDebouncedValue(query, { wait: 200 }); const { settings } = useAppSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); const threads = useStore((store) => store.threads); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? []; + + const { data: browseEntries = [] } = useQuery({ + queryKey: ["filesystemBrowse", debouncedBrowsePath], + queryFn: async () => { + const api = readNativeApi(); + if (!api) return []; + const result = await api.projects.browseFilesystem({ partialPath: debouncedBrowsePath }); + return result.entries; + }, + enabled: isBrowsing && debouncedBrowsePath.length > 0, + }); + const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.name] as const)), [projects], @@ -196,6 +228,18 @@ function OpenCommandPaletteDialog() { }); } + actionItems.push({ + value: "action:add-project", + label: "add project folder directory browse", + title: "Add project", + description: "Browse filesystem and add a project directory", + icon: , + keepOpen: true, + run: async () => { + setQuery("~/"); + }, + }); + actionItems.push({ value: "action:settings", label: "settings preferences configuration keybindings", @@ -304,9 +348,77 @@ function OpenCommandPaletteDialog() { .filter((group) => group.items.length > 0); }, [allGroups, deferredQuery]); + const handleAddProject = useCallback( + async (cwd: string) => { + const api = readNativeApi(); + if (!api) return; + const existing = projects.find((p) => p.cwd === cwd); + if (existing) { + setOpen(false); + return; + } + const projectId = newProjectId(); + const segments = cwd.split(/[/\\]/); + const title = segments.findLast(Boolean) ?? cwd; + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title, + workspaceRoot: cwd, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + createdAt: new Date().toISOString(), + }); + await handleNewThread(projectId, { envMode: settings.defaultThreadEnvMode }).catch(() => {}); + setOpen(false); + }, + [handleNewThread, projects, setOpen, settings.defaultThreadEnvMode], + ); + + const browseGroups = useMemo(() => { + if (browseEntries.length === 0) return []; + return [ + { + value: "directories", + label: "Directories", + items: browseEntries.map((entry) => ({ + value: `dir:${entry.fullPath}`, + label: entry.name, + title: entry.name, + description: entry.fullPath, + icon: , + run: async () => { + await handleAddProject(entry.fullPath); + }, + })), + }, + ]; + }, [browseEntries, handleAddProject]); + + const displayedGroups = !isBrowsing ? filteredGroups : browseGroups; + + const handleBrowseKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!isBrowsing) return; + if (event.key === "Tab") { + event.preventDefault(); + const fullPath = getHighlightedEntryPath(); + if (fullPath) { + setQuery(fullPath.endsWith("/") ? fullPath : fullPath + "/"); + } + } else if (event.key === "Enter" && browseEntries.length === 0) { + event.preventDefault(); + void handleAddProject(query.trim()); + } + }, + [isBrowsing, query, browseEntries.length, handleAddProject], + ); + const executeItem = useCallback( (item: CommandPaletteItem) => { - setOpen(false); + if (!item.keepOpen) { + setOpen(false); + } void item.run().catch((error: unknown) => { toastManager.add({ type: "error", @@ -325,10 +437,18 @@ function OpenCommandPaletteDialog() { data-testid="command-palette" > - + : undefined} + onKeyDown={handleBrowseKeyDown} + /> - {filteredGroups.map((group) => ( + {displayedGroups.map((group) => ( {group.label} @@ -372,23 +492,37 @@ function OpenCommandPaletteDialog() { ))} - No matching commands, projects, or threads. + {!isBrowsing + ? "No matching commands, projects, or threads." + : "No directories found. Press Enter to add the typed path."} - - Search actions, start a thread in any project, or jump back into recent threads. - -
- - Enter - Open - - - Esc - Close - -
+ {!isBrowsing ? ( + <> + + Search actions, start a thread in any project, or jump back into recent threads. + +
+ + Enter + Open + + + Esc + Close + +
+ + ) : ( + <> + Type a path to browse · Tab to autocomplete + + Enter + Add project + + + )}
From 1b13b76910e975f02f8eb88b28f8a53ce108b31f Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sun, 15 Mar 2026 17:17:43 +0000 Subject: [PATCH 10/50] 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 0ba0fca41f2..d86ace9058c 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 a1b5299e7e1f6ced89ad819fc00a9704c83b4073 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 17 Mar 2026 10:27:56 +1300 Subject: [PATCH 11/50] 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 a724a5780dc..8d8965cd9fb 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 00000000000..4f291d5a480 --- /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 a170eb79c4d..f58ab0d441c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -94,7 +94,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"; @@ -245,7 +245,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 d86ace9058c..772c25943f4 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 f565e565f7a..9b197f9fdcf 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 a2bc59c092f..5c0074d90f9 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 bd703a06a02480fd45e53a549d74e18b433938f4 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 20 Mar 2026 10:45:29 +1300 Subject: [PATCH 12/50] 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 f12792a3186..6eaa683a29a 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 8d8965cd9fb..bd40ac848cf 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 f58ab0d441c..31ea85182ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -245,7 +245,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({ @@ -2159,7 +2158,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), @@ -2235,7 +2236,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 772c25943f4..2357a108605 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 9b197f9fdcf..087ca753228 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 5c0074d90f9..a2bc59c092f 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 00000000000..7b6132ae243 --- /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 2323380da0f..6d98d08cc85 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 0c2c337ff37..574e8d4e60a 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 0903253301e..a2908128124 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 d732242ecda..a6ab3532709 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 c30b6f6e506..1e1baa609d1 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 75258e1707170ba53aba12311524f07336870790 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 20 Mar 2026 11:13:27 +1300 Subject: [PATCH 13/50] 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 6eaa683a29a..afeb66b14b8 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 bd40ac848cf..06d6381fd48 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 2357a108605..fc61e261026 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 087ca753228..5b6173861c5 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 00000000000..9475c6ca3a9 --- /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 b216f018830..d8b28187fdf 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 7b6132ae243..de2660cc9b4 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 6d98d08cc85..2090cf60ea6 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 a04922fad77..8a0a4d2cd5f 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 00000000000..0675066daf5 --- /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 0f37a935157..c9f708ce4ad 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 574e8d4e60a..ebe88e7f904 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 a2908128124..0903253301e 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 a6ab3532709..7c2135978d8 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 1e1baa609d1..5f5f6b0bfef 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 36a8834b86248a23d9a85a68bf9b7fdcaa1f2f3b Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 20 Mar 2026 12:44:12 +1300 Subject: [PATCH 14/50] 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 6895f94cd18..33f9bf8affa 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 00000000000..f0dc320c820 --- /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 fc61e261026..cae2cc6f376 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 00000000000..938d86ff372 --- /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 5b6173861c5..b68b0301926 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 a2bc59c092f..759518ddfad 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/50] 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 afeb66b14b8..76c2d825205 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 06d6381fd48..ee5cd742102 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 f0dc320c820..a1134b59692 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 cae2cc6f376..d9e5a06fddc 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() { - ) : null} -
+ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c88a639c8f4..9802c3964cc 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,4 @@ -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import type { SidebarProjectSortOrder } from "../appSettings"; import { getThreadSortTimestamp } from "../lib/threadSort"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 405169ce68d..b9548117e23 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -375,7 +375,7 @@ export default function Sidebar() { const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const { settings: appSettings, updateSettings } = useAppSettings(); - const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); + const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts deleted file mode 100644 index 81fb47fa117..00000000000 --- a/apps/web/src/lib/projectPaths.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - appendBrowsePathSegment, - canNavigateUp, - getBrowseDirectoryPath, - findProjectByPath, - getBrowseLeafPathSegment, - getBrowseParentPath, - hasTrailingPathSeparator, - inferProjectTitleFromPath, - isExplicitRelativeProjectPath, - isFilesystemBrowseQuery, - normalizeProjectPathForComparison, - normalizeProjectPathForDispatch, - isUnsupportedWindowsProjectPath, - resolveProjectPathForDispatch, -} from "./projectPaths"; - -describe("projectPaths", () => { - 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(".")).toBe(false); - expect(isFilesystemBrowseQuery("..")).toBe(false); - expect(isFilesystemBrowseQuery("./")).toBe(true); - expect(isFilesystemBrowseQuery("../")).toBe(true); - expect(isFilesystemBrowseQuery("~/projects")).toBe(true); - expect(isFilesystemBrowseQuery("..\\docs")).toBe(true); - expect(isFilesystemBrowseQuery("notes")).toBe(false); - }); - - it("only treats windows-style paths as browse queries on windows", () => { - expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "MacIntel")).toBe(false); - expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "Win32")).toBe(true); - expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "MacIntel")).toBe(true); - expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "Win32")).toBe(false); - }); - - it("detects explicit relative project paths", () => { - expect(isExplicitRelativeProjectPath(".")).toBe(true); - expect(isExplicitRelativeProjectPath("..")).toBe(true); - expect(isExplicitRelativeProjectPath("./docs")).toBe(true); - expect(isExplicitRelativeProjectPath("..\\docs")).toBe(true); - expect(isExplicitRelativeProjectPath("/repo/docs")).toBe(false); - }); - - it("resolves explicit relative paths against the current project", () => { - expect(resolveProjectPathForDispatch(".", "/repo/app")).toBe("/repo/app"); - expect(resolveProjectPathForDispatch("..", "/repo/app")).toBe("/repo"); - 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\\"); - expect(getBrowseParentPath("/repo/src/")).toBe("/repo/"); - expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\"); - expect(getBrowseParentPath("\\\\server\\share\\")).toBeNull(); - expect(getBrowseParentPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\"); - expect(getBrowseParentPath("C:\\")).toBeNull(); - }); - - it("detects browse path boundaries", () => { - expect(hasTrailingPathSeparator("/repo/src/")).toBe(true); - expect(hasTrailingPathSeparator("/repo/src")).toBe(false); - expect(getBrowseDirectoryPath("/repo/src")).toBe("/repo/"); - expect(getBrowseDirectoryPath("/repo/src/")).toBe("/repo/src/"); - expect(getBrowseLeafPathSegment("/repo/src")).toBe("src"); - expect(getBrowseLeafPathSegment("C:\\Work\\Repo\\Docs")).toBe("Docs"); - }); - - it("only allows browse-up after entering a directory", () => { - expect(canNavigateUp("~/repo")).toBe(false); - expect(canNavigateUp("~/a")).toBe(false); - expect(canNavigateUp("~/repo/")).toBe(true); - expect(canNavigateUp("\\\\server\\share\\")).toBe(false); - expect(canNavigateUp("\\\\server\\share\\repo\\")).toBe(true); - }); -}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts deleted file mode 100644 index 281f67baa17..00000000000 --- a/apps/web/src/lib/projectPaths.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - isExplicitRelativePath, - isUncPath, - isWindowsAbsolutePath, - isWindowsDrivePath, -} from "@t3tools/shared/path"; -import { isWindowsPlatform } from "./utils"; - -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 hasTrailingPathSeparator(value: string): boolean { - return /[\\/]$/.test(value); -} - -export { isExplicitRelativePath as isExplicitRelativeProjectPath }; - -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, - platform = typeof navigator === "undefined" ? "" : navigator.platform, -): boolean { - const allowWindowsPaths = isWindowsPlatform(platform); - return ( - value.startsWith("./") || - value.startsWith("../") || - value.startsWith(".\\") || - value.startsWith("..\\") || - value.startsWith("/") || - value.startsWith("~/") || - (allowWindowsPaths && isWindowsAbsolutePath(value)) - ); -} - -export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { - return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); -} - -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("\\\\")) { - 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); - return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; -} - -export function getBrowseLeafPathSegment(currentPath: string): string { - const lastSeparatorIndex = Math.max(currentPath.lastIndexOf("/"), currentPath.lastIndexOf("\\")); - return currentPath.slice(lastSeparatorIndex + 1); -} - -export function getBrowseDirectoryPath(currentPath: string): string { - if (hasTrailingPathSeparator(currentPath)) { - return currentPath; - } - - const lastSeparatorIndex = Math.max(currentPath.lastIndexOf("/"), currentPath.lastIndexOf("\\")); - if (lastSeparatorIndex < 0) { - return currentPath; - } - - return currentPath.slice(0, lastSeparatorIndex + 1); -} - -export function getBrowseParentPath(currentPath: string): string | null { - const trimmed = trimTrailingPathSeparators(currentPath); - const absolutePath = splitAbsolutePath(trimmed); - if (absolutePath) { - if (absolutePath.segments.length === 0) { - return null; - } - - if (absolutePath.segments.length === 1) { - return absolutePath.root; - } - - const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); - return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; - } - - const separator = preferredPathSeparator(currentPath); - 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); -} - -export function canNavigateUp(currentPath: string): boolean { - return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; -} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ec0ad1d472e..e500b577911 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -342,20 +342,6 @@ describe("wsNativeApi", () => { }); }); - 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.filesystem.browse({ - partialPath: "/tmp/project", - }); - - expect(requestMock).toHaveBeenCalledWith(WS_METHODS.filesystemBrowse, { - partialPath: "/tmp/project", - }); - }); - it("uses no client timeout for git.runStackedAction", async () => { requestMock.mockResolvedValue({ action: "commit", diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index fa7a2a8be49..042875f6f76 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -127,9 +127,6 @@ export function createWsNativeApi(): NativeApi { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), }, - filesystem: { - browse: (input) => transport.request(WS_METHODS.filesystemBrowse, input), - }, shell: { openInEditor: (cwd, editor) => transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts deleted file mode 100644 index 0675066daf5..00000000000 --- a/packages/contracts/src/filesystem.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 c9f708ce4ad..0f37a935157 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,5 +10,4 @@ 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 8760aa66fab..ea73024de30 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -25,7 +25,6 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -131,9 +130,6 @@ export interface NativeApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; - filesystem: { - browse: (input: FilesystemBrowseInput) => Promise; - }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 8b563fbdb66..2030dad4e5b 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -73,25 +73,6 @@ 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 ", - 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"); - } - }), -); - 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 84408f91bae..45ef0512daa 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -36,7 +36,6 @@ import { } from "./terminal"; import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; -import { FilesystemBrowseInput } from "./filesystem"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; @@ -74,9 +73,6 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", - // Filesystem - filesystemBrowse: "filesystem.browse", - // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", @@ -142,9 +138,6 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), - // Filesystem - tagRequestBody(WS_METHODS.filesystemBrowse, FilesystemBrowseInput), - // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), diff --git a/packages/shared/package.json b/packages/shared/package.json index eb7d7fa400a..02ae794d645 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -31,10 +31,6 @@ "./schemaJson": { "types": "./src/schemaJson.ts", "import": "./src/schemaJson.ts" - }, - "./path": { - "types": "./src/path.ts", - "import": "./src/path.ts" } }, "scripts": { diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts deleted file mode 100644 index 912e1e13d75..00000000000 --- a/packages/shared/src/path.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isExplicitRelativePath, - isUncPath, - isWindowsAbsolutePath, - isWindowsDrivePath, -} from "./path"; - -describe("path helpers", () => { - it("detects windows drive paths", () => { - expect(isWindowsDrivePath("C:\\repo")).toBe(true); - expect(isWindowsDrivePath("D:/repo")).toBe(true); - expect(isWindowsDrivePath("/repo")).toBe(false); - }); - - it("detects UNC paths", () => { - expect(isUncPath("\\\\server\\share\\repo")).toBe(true); - expect(isUncPath("C:\\repo")).toBe(false); - }); - - it("detects windows absolute paths", () => { - expect(isWindowsAbsolutePath("C:\\repo")).toBe(true); - expect(isWindowsAbsolutePath("\\\\server\\share\\repo")).toBe(true); - expect(isWindowsAbsolutePath("./repo")).toBe(false); - }); - - it("detects explicit relative paths", () => { - expect(isExplicitRelativePath(".")).toBe(true); - expect(isExplicitRelativePath("..")).toBe(true); - expect(isExplicitRelativePath("./repo")).toBe(true); - expect(isExplicitRelativePath("..\\repo")).toBe(true); - expect(isExplicitRelativePath("~/repo")).toBe(false); - }); -}); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts deleted file mode 100644 index 2bb2ca0238d..00000000000 --- a/packages/shared/src/path.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function isWindowsDrivePath(value: string): boolean { - return /^[a-zA-Z]:([/\\]|$)/.test(value); -} - -export function isUncPath(value: string): boolean { - return value.startsWith("\\\\"); -} - -export function isWindowsAbsolutePath(value: string): boolean { - return isUncPath(value) || isWindowsDrivePath(value); -} - -export function isExplicitRelativePath(value: string): boolean { - return ( - value === "." || - value === ".." || - value.startsWith("./") || - value.startsWith("../") || - value.startsWith(".\\") || - value.startsWith("..\\") - ); -} From 10d436ef725883b42facecea29ce89791dca1c3f Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Thu, 26 Mar 2026 21:30:25 +1300 Subject: [PATCH 28/50] test(web): use merged settings fixture in browser test --- apps/web/src/components/ChatView.browser.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 16814a7607f..6bfd8dcebb8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2061,25 +2061,16 @@ 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({ - codexBinaryPath: "", - codexHomePath: "", - defaultThreadEnvMode: "worktree", - confirmThreadDelete: true, - enableAssistantStreaming: false, - timestampFormat: "locale", - customCodexModels: [], - }), - ); - const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithSecondaryProject(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, keybindings: [ { command: "commandPalette.toggle", From 0cb4b2b53d3950ced84c8d09c1e176f916bc6c79 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sun, 29 Mar 2026 22:35:20 +1300 Subject: [PATCH 29/50] feat(web): open latest thread from project search --- apps/web/src/components/ChatView.browser.tsx | 133 ++++++++++++++----- apps/web/src/components/CommandPalette.tsx | 42 +++++- apps/web/src/components/Sidebar.tsx | 8 +- apps/web/src/lib/threadSort.test.ts | 36 ++++- apps/web/src/lib/threadSort.ts | 7 +- 5 files changed, 186 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 029fd0cc204..ffb6df674bf 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -448,11 +448,46 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function createSnapshotWithSecondaryProject(): OrchestrationReadModel { +function createSnapshotWithSecondaryProject(options?: { + includeSecondaryThread?: boolean; +}): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-secondary-project-target" as MessageId, targetText: "secondary project", }); + const includeSecondaryThread = options?.includeSecondaryThread ?? true; + const secondaryThreads: OrchestrationReadModel["threads"] = includeSecondaryThread + ? [ + { + id: "thread-secondary-project" as ThreadId, + projectId: SECOND_PROJECT_ID, + title: "Release checklist", + modelSelection: { provider: "codex", model: "gpt-5" }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "release/docs-portal", + worktreePath: null, + latestTurn: null, + createdAt: isoAt(30), + updatedAt: isoAt(31), + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: "thread-secondary-project" as ThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(31), + }, + archivedAt: null, + }, + ] + : []; return { ...snapshot, @@ -469,37 +504,7 @@ function createSnapshotWithSecondaryProject(): OrchestrationReadModel { deletedAt: null, }, ], - threads: [ - ...snapshot.threads, - { - id: "thread-secondary-project" as ThreadId, - projectId: SECOND_PROJECT_ID, - title: "Release checklist", - modelSelection: { provider: "codex", model: "gpt-5" }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "release/docs-portal", - worktreePath: null, - latestTurn: null, - createdAt: isoAt(30), - updatedAt: isoAt(31), - deletedAt: null, - messages: [], - activities: [], - proposedPlans: [], - checkpoints: [], - session: { - threadId: "thread-secondary-project" as ThreadId, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: isoAt(31), - }, - archivedAt: null, - }, - ], + threads: [...snapshot.threads, ...secondaryThreads], }; } @@ -2372,7 +2377,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("searches projects by path and opens a new thread using the default env mode", async () => { + it("searches projects by path and opens the latest thread for that project", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithSecondaryProject(), @@ -2404,6 +2409,68 @@ describe("ChatView timeline estimator parity (full app)", () => { }, }); + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("Docs Portal", { exact: true }).click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => path === "/thread-secondary-project", + "Route should have changed to the latest thread for the selected project.", + ); + expect(nextPath).toBe("/thread-secondary-project"); + expect( + useComposerDraftStore.getState().draftThreadsByThreadId[ + "thread-secondary-project" as ThreadId + ], + ).toBeUndefined(); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a new thread from project search when no active project thread exists", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject({ includeSecondaryThread: false }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + try { await waitForServerConfigToApply(); await waitForCommandPaletteShortcutLabel(); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7f6f8c96a82..10ffdec3fcb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -26,6 +26,7 @@ import { startNewThreadFromContext, } from "../lib/chatThreadActions"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { getLatestThreadForProject } from "../lib/threadSort"; import { cn } from "../lib/utils"; import { useStore } from "../store"; import { @@ -107,6 +108,45 @@ function OpenCommandPaletteDialog() { const activeThreadId = activeThread?.id; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + const openProjectFromSearch = useMemo( + () => async (projectId: (typeof projects)[number]["id"]) => { + const latestThread = getLatestThreadForProject( + threads, + projectId, + settings.sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$threadId", + params: { threadId: latestThread.id }, + }); + return; + } + + await handleNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }); + }, + [ + handleNewThread, + navigate, + settings.defaultThreadEnvMode, + settings.sidebarThreadSortOrder, + threads, + ], + ); + + const projectSearchItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "project", + icon: , + runProject: openProjectFromSearch, + }), + [openProjectFromSearch, projects], + ); + const projectThreadItems = useMemo( () => buildProjectActionItems({ @@ -280,7 +320,7 @@ function OpenCommandPaletteDialog() { activeGroups, query: deferredQuery, isInSubmenu: currentView !== null, - projectSearchItems: projectThreadItems, + projectSearchItems: projectSearchItems, threadSearchItems: allThreadItems, }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ca5f00b11cc..b4001db4492 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -120,6 +120,7 @@ import { sortThreadsForSidebar, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { getLatestThreadForProject } from "../lib/threadSort"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -485,10 +486,11 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { - const latestThread = sortThreadsForSidebar( - threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + const latestThread = getLatestThreadForProject( + threads, + projectId, appSettings.sidebarThreadSortOrder, - )[0]; + ); if (!latestThread) return; void navigate({ diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index 0d49a448cb2..96dc88fe7d8 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_RUNTIME_MODE, ProjectId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; -import { sortThreads } from "./threadSort"; +import { getLatestThreadForProject, sortThreads } from "./threadSort"; function makeThread(overrides: Partial = {}): Thread { return { @@ -153,4 +153,38 @@ describe("sortThreads", () => { ThreadId.makeUnsafe("thread-2"), ]); }); + + it("returns the latest active thread for a project", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + + const latestThread = getLatestThreadForProject( + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + projectId, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + archivedAt: null, + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + projectId, + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-3"), + projectId, + createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", + archivedAt: null, + }), + ], + projectId, + "updated_at", + ); + + expect(latestThread?.id).toBe(ThreadId.makeUnsafe("thread-3")); + }); }); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 2fffbc8d6f6..928da3556ac 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -55,11 +55,14 @@ export function sortThreads, + T extends Pick< + Thread, + "id" | "projectId" | "createdAt" | "updatedAt" | "messages" | "archivedAt" + >, >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { return ( sortThreads( - threads.filter((thread) => thread.projectId === projectId), + threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), sortOrder, )[0] ?? null ); From 87ee5aa5e8af3481382a5226043fb016e1a68b5f Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sun, 29 Mar 2026 22:41:23 +1300 Subject: [PATCH 30/50] feat(web): support archived threads in command palette --- apps/web/src/components/ChatView.browser.tsx | 109 +++++++++++++++++- .../components/CommandPalette.logic.test.ts | 49 ++++++++ .../src/components/CommandPalette.logic.ts | 52 ++++++++- apps/web/src/components/CommandPalette.tsx | 23 ++++ 4 files changed, 231 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ffb6df674bf..a1120623c03 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -34,6 +34,7 @@ import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; const THREAD_ID = "thread-browser-test" as ThreadId; +const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; const SECOND_PROJECT_ID = "project-2" as ProjectId; @@ -450,12 +451,14 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { function createSnapshotWithSecondaryProject(options?: { includeSecondaryThread?: boolean; + includeArchivedSecondaryThread?: boolean; }): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-secondary-project-target" as MessageId, targetText: "secondary project", }); const includeSecondaryThread = options?.includeSecondaryThread ?? true; + const includeArchivedSecondaryThread = options?.includeArchivedSecondaryThread ?? true; const secondaryThreads: OrchestrationReadModel["threads"] = includeSecondaryThread ? [ { @@ -488,6 +491,38 @@ function createSnapshotWithSecondaryProject(options?: { }, ] : []; + const archivedSecondaryThreads: OrchestrationReadModel["threads"] = includeArchivedSecondaryThread + ? [ + { + id: ARCHIVED_SECONDARY_THREAD_ID, + projectId: SECOND_PROJECT_ID, + title: "Archived Docs Notes", + modelSelection: { provider: "codex", model: "gpt-5" }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "release/docs-archive", + worktreePath: null, + latestTurn: null, + createdAt: isoAt(24), + updatedAt: isoAt(25), + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: ARCHIVED_SECONDARY_THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: isoAt(25), + }, + archivedAt: isoAt(26), + }, + ] + : []; return { ...snapshot, @@ -504,7 +539,7 @@ function createSnapshotWithSecondaryProject(options?: { deletedAt: null, }, ], - threads: [...snapshot.threads, ...secondaryThreads], + threads: [...snapshot.threads, ...secondaryThreads, ...archivedSecondaryThreads], }; } @@ -2499,6 +2534,78 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("searches archived threads and unarchives them before opening", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await page + .getByPlaceholder("Search commands, projects, and threads...") + .fill("archived docs"); + await expect + .element(palette.getByText("Archived Threads", { exact: true })) + .toBeInTheDocument(); + await expect + .element(palette.getByText("Archived Docs Notes", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("Archived Docs Notes", { exact: true }).click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => path === `/${ARCHIVED_SECONDARY_THREAD_ID}`, + "Route should have changed to the archived thread after selecting it from search.", + ); + expect(nextPath).toBe(`/${ARCHIVED_SECONDARY_THREAD_ID}`); + + await vi.waitFor(() => { + expect( + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.command && + typeof request.command === "object" && + "type" in request.command && + request.command.type === "thread.unarchive" && + "threadId" in request.command && + request.command.threadId === ARCHIVED_SECONDARY_THREAD_ID, + ), + ).toBe(true); + }); + } 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.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 25a9a24863c..80ef94ed870 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { ProjectId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildArchivedThreadActionItems, buildThreadActionItems, filterCommandPaletteGroups, type CommandPaletteGroup, @@ -137,6 +138,7 @@ describe("buildThreadActionItems", () => { isInSubmenu: false, projectSearchItems: [], threadSearchItems: threadItems, + archivedThreadSearchItems: [], }); expect(groups).toHaveLength(1); @@ -170,9 +172,56 @@ describe("buildThreadActionItems", () => { isInSubmenu: false, projectSearchItems: [], threadSearchItems: [], + archivedThreadSearchItems: [], }); expect(groups).toHaveLength(1); expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); + + it("builds archived thread items separately with archived metadata", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); + + try { + const projectId = ProjectId.makeUnsafe("project-1"); + const items = buildArchivedThreadActionItems({ + threads: [ + { + id: ThreadId.makeUnsafe("thread-archived"), + codexThreadId: null, + projectId, + title: "Archived notes", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: "2026-03-24T12:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + latestTurn: null, + branch: "feature/archive", + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + ] satisfies Thread[], + projectTitleById: new Map([[projectId, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_threadId) => undefined, + }); + + expect(items).toHaveLength(1); + expect(items[0]?.value).toBe("archived-thread:thread-archived"); + expect(items[0]?.description).toBe("Project · #feature/archive · Archived"); + expect(items[0]?.timestamp).toBe("Archived yesterday"); + expect(items[0]?.searchTerms).toContain("archived"); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 38ccce02391..013609fa281 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -79,7 +79,10 @@ export function buildThreadActionItems(input: { runThread: (threadId: Thread["id"]) => Promise; limit?: number; }): CommandPaletteActionItem[] { - const sortedThreads = sortThreads(input.threads, input.sortOrder); + const sortedThreads = sortThreads( + input.threads.filter((thread) => thread.archivedAt === null), + input.sortOrder, + ); const visibleThreads = input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit); @@ -112,6 +115,45 @@ export function buildThreadActionItems(input: { }); } +export function buildArchivedThreadActionItems(input: { + threads: ReadonlyArray; + projectTitleById: ReadonlyMap; + sortOrder: SidebarThreadSortOrder; + icon: ReactNode; + runThread: (threadId: Thread["id"]) => Promise; +}): CommandPaletteActionItem[] { + const archivedThreads = sortThreads( + input.threads.filter((thread) => thread.archivedAt !== null), + input.sortOrder, + ); + + return archivedThreads.map((thread) => { + const projectTitle = input.projectTitleById.get(thread.projectId); + const descriptionParts: string[] = []; + + if (projectTitle) { + descriptionParts.push(projectTitle); + } + if (thread.branch) { + descriptionParts.push(`#${thread.branch}`); + } + descriptionParts.push("Archived"); + + return { + kind: "action", + value: `archived-thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? "", "archived"], + title: thread.title, + description: descriptionParts.join(" · "), + timestamp: `Archived ${formatRelativeTime(thread.archivedAt ?? thread.createdAt, Date.now(), "long")}`, + icon: input.icon, + run: async () => { + await input.runThread(thread.id); + }, + }; + }); +} + function rankSearchFieldMatch(field: string, normalizedQuery: string): number { const normalizedField = normalizeSearchText(field); if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { @@ -151,6 +193,7 @@ export function filterCommandPaletteGroups(input: { isInSubmenu: boolean; projectSearchItems: ReadonlyArray; threadSearchItems: ReadonlyArray; + archivedThreadSearchItems: ReadonlyArray; }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; @@ -186,6 +229,13 @@ export function filterCommandPaletteGroups(input: { items: input.threadSearchItems, }); } + if (input.archivedThreadSearchItems.length > 0) { + searchableGroups.push({ + value: "archived-threads-search", + label: "Archived Threads", + items: input.archivedThreadSearchItems, + }); + } } return searchableGroups.flatMap((group) => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 10ffdec3fcb..45451905233 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -5,6 +5,7 @@ import { useNavigate } from "@tanstack/react-router"; import { ArrowDownIcon, ArrowUpIcon, + ArchiveIcon, FolderIcon, MessageSquareIcon, SettingsIcon, @@ -21,6 +22,7 @@ import { import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; +import { useThreadActions } from "../hooks/useThreadActions"; import { startNewLocalThreadFromContext, startNewThreadFromContext, @@ -31,6 +33,7 @@ import { cn } from "../lib/utils"; import { useStore } from "../store"; import { ADDON_ICON_CLASS, + buildArchivedThreadActionItems, buildProjectActionItems, buildRootGroups, buildThreadActionItems, @@ -93,6 +96,7 @@ function OpenCommandPaletteDialog() { const isActionsOnly = query.startsWith(">"); const settings = useSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); + const { unarchiveThread } = useThreadActions(); const threads = useStore((store) => store.threads); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? []; @@ -177,6 +181,24 @@ function OpenCommandPaletteDialog() { [handleNewThread, projects], ); + const archivedThreadItems = useMemo( + () => + buildArchivedThreadActionItems({ + threads, + projectTitleById, + sortOrder: settings.sidebarThreadSortOrder, + icon: , + runThread: async (threadId) => { + await unarchiveThread(threadId); + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + }), + [navigate, projectTitleById, settings.sidebarThreadSortOrder, threads, unarchiveThread], + ); + const allThreadItems = useMemo( () => buildThreadActionItems({ @@ -322,6 +344,7 @@ function OpenCommandPaletteDialog() { isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, threadSearchItems: allThreadItems, + archivedThreadSearchItems: archivedThreadItems, }); const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); From 8e22d9b285855beab86433af24254abd30ef4e09 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sun, 29 Mar 2026 22:53:09 +1300 Subject: [PATCH 31/50] fix(web): filter archived threads from command palette search --- apps/web/src/components/ChatView.browser.tsx | 34 +------ .../components/CommandPalette.logic.test.ts | 99 ++++++++++--------- .../src/components/CommandPalette.logic.ts | 47 --------- apps/web/src/components/CommandPalette.tsx | 23 ----- 4 files changed, 56 insertions(+), 147 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a1120623c03..51340627db6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2534,7 +2534,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("searches archived threads and unarchives them before opening", async () => { + it("filters archived threads out of command palette search results", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithSecondaryProject(), @@ -2569,38 +2569,10 @@ describe("ChatView timeline estimator parity (full app)", () => { await openCommandPaletteFromTrigger(); await expect.element(palette).toBeInTheDocument(); - await page - .getByPlaceholder("Search commands, projects, and threads...") - .fill("archived docs"); - await expect - .element(palette.getByText("Archived Threads", { exact: true })) - .toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("docs-archive"); await expect .element(palette.getByText("Archived Docs Notes", { exact: true })) - .toBeInTheDocument(); - await palette.getByText("Archived Docs Notes", { exact: true }).click(); - - const nextPath = await waitForURL( - mounted.router, - (path) => path === `/${ARCHIVED_SECONDARY_THREAD_ID}`, - "Route should have changed to the archived thread after selecting it from search.", - ); - expect(nextPath).toBe(`/${ARCHIVED_SECONDARY_THREAD_ID}`); - - await vi.waitFor(() => { - expect( - wsRequests.some( - (request) => - request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && - request.command && - typeof request.command === "object" && - "type" in request.command && - request.command.type === "thread.unarchive" && - "threadId" in request.command && - request.command.threadId === ARCHIVED_SECONDARY_THREAD_ID, - ), - ).toBe(true); - }); + .not.toBeInTheDocument(); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 80ef94ed870..2d9c2b70b30 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { ProjectId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { - buildArchivedThreadActionItems, buildThreadActionItems, filterCommandPaletteGroups, type CommandPaletteGroup, @@ -138,7 +137,6 @@ describe("buildThreadActionItems", () => { isInSubmenu: false, projectSearchItems: [], threadSearchItems: threadItems, - archivedThreadSearchItems: [], }); expect(groups).toHaveLength(1); @@ -172,56 +170,65 @@ describe("buildThreadActionItems", () => { isInSubmenu: false, projectSearchItems: [], threadSearchItems: [], - archivedThreadSearchItems: [], }); expect(groups).toHaveLength(1); expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); - it("builds archived thread items separately with archived metadata", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); - - try { - const projectId = ProjectId.makeUnsafe("project-1"); - const items = buildArchivedThreadActionItems({ - threads: [ - { - id: ThreadId.makeUnsafe("thread-archived"), - codexThreadId: null, - projectId, - title: "Archived notes", - modelSelection: { provider: "codex", model: "gpt-5" }, - runtimeMode: "full-access", - interactionMode: "default", - session: null, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-01T00:00:00.000Z", - archivedAt: "2026-03-24T12:00:00.000Z", - updatedAt: "2026-03-20T00:00:00.000Z", - latestTurn: null, - branch: "feature/archive", - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }, - ] satisfies Thread[], - projectTitleById: new Map([[projectId, "Project"]]), - sortOrder: "updated_at", - icon: null, - runThread: async (_threadId) => undefined, - }); + it("filters archived threads out of thread search items", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const items = buildThreadActionItems({ + threads: [ + { + id: ThreadId.makeUnsafe("thread-active"), + codexThreadId: null, + projectId, + title: "Active thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-02T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-19T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + { + id: ThreadId.makeUnsafe("thread-archived"), + codexThreadId: null, + projectId, + title: "Archived thread", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }, + ] satisfies Thread[], + projectTitleById: new Map([[projectId, "Project"]]), + sortOrder: "updated_at", + icon: null, + runThread: async (_threadId) => undefined, + }); - expect(items).toHaveLength(1); - expect(items[0]?.value).toBe("archived-thread:thread-archived"); - expect(items[0]?.description).toBe("Project · #feature/archive · Archived"); - expect(items[0]?.timestamp).toBe("Archived yesterday"); - expect(items[0]?.searchTerms).toContain("archived"); - } finally { - vi.useRealTimers(); - } + expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 013609fa281..4515b7014d5 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -115,45 +115,6 @@ export function buildThreadActionItems(input: { }); } -export function buildArchivedThreadActionItems(input: { - threads: ReadonlyArray; - projectTitleById: ReadonlyMap; - sortOrder: SidebarThreadSortOrder; - icon: ReactNode; - runThread: (threadId: Thread["id"]) => Promise; -}): CommandPaletteActionItem[] { - const archivedThreads = sortThreads( - input.threads.filter((thread) => thread.archivedAt !== null), - input.sortOrder, - ); - - return archivedThreads.map((thread) => { - const projectTitle = input.projectTitleById.get(thread.projectId); - const descriptionParts: string[] = []; - - if (projectTitle) { - descriptionParts.push(projectTitle); - } - if (thread.branch) { - descriptionParts.push(`#${thread.branch}`); - } - descriptionParts.push("Archived"); - - return { - kind: "action", - value: `archived-thread:${thread.id}`, - searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? "", "archived"], - title: thread.title, - description: descriptionParts.join(" · "), - timestamp: `Archived ${formatRelativeTime(thread.archivedAt ?? thread.createdAt, Date.now(), "long")}`, - icon: input.icon, - run: async () => { - await input.runThread(thread.id); - }, - }; - }); -} - function rankSearchFieldMatch(field: string, normalizedQuery: string): number { const normalizedField = normalizeSearchText(field); if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { @@ -193,7 +154,6 @@ export function filterCommandPaletteGroups(input: { isInSubmenu: boolean; projectSearchItems: ReadonlyArray; threadSearchItems: ReadonlyArray; - archivedThreadSearchItems: ReadonlyArray; }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; @@ -229,13 +189,6 @@ export function filterCommandPaletteGroups(input: { items: input.threadSearchItems, }); } - if (input.archivedThreadSearchItems.length > 0) { - searchableGroups.push({ - value: "archived-threads-search", - label: "Archived Threads", - items: input.archivedThreadSearchItems, - }); - } } return searchableGroups.flatMap((group) => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 45451905233..10ffdec3fcb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -5,7 +5,6 @@ import { useNavigate } from "@tanstack/react-router"; import { ArrowDownIcon, ArrowUpIcon, - ArchiveIcon, FolderIcon, MessageSquareIcon, SettingsIcon, @@ -22,7 +21,6 @@ import { import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; -import { useThreadActions } from "../hooks/useThreadActions"; import { startNewLocalThreadFromContext, startNewThreadFromContext, @@ -33,7 +31,6 @@ import { cn } from "../lib/utils"; import { useStore } from "../store"; import { ADDON_ICON_CLASS, - buildArchivedThreadActionItems, buildProjectActionItems, buildRootGroups, buildThreadActionItems, @@ -96,7 +93,6 @@ function OpenCommandPaletteDialog() { const isActionsOnly = query.startsWith(">"); const settings = useSettings(); const { activeDraftThread, activeThread, handleNewThread, projects } = useHandleNewThread(); - const { unarchiveThread } = useThreadActions(); const threads = useStore((store) => store.threads); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? []; @@ -181,24 +177,6 @@ function OpenCommandPaletteDialog() { [handleNewThread, projects], ); - const archivedThreadItems = useMemo( - () => - buildArchivedThreadActionItems({ - threads, - projectTitleById, - sortOrder: settings.sidebarThreadSortOrder, - icon: , - runThread: async (threadId) => { - await unarchiveThread(threadId); - await navigate({ - to: "/$threadId", - params: { threadId }, - }); - }, - }), - [navigate, projectTitleById, settings.sidebarThreadSortOrder, threads, unarchiveThread], - ); - const allThreadItems = useMemo( () => buildThreadActionItems({ @@ -344,7 +322,6 @@ function OpenCommandPaletteDialog() { isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, threadSearchItems: allThreadItems, - archivedThreadSearchItems: archivedThreadItems, }); const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); From f49a6d084049febcff1dcd282d2c8c1255911c39 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 14:33:42 +1300 Subject: [PATCH 32/50] fix: deduplicate toSortableTimestamp, rename formatRelativeTime, fix DrainableWorker drain race - Export toSortableTimestamp from threadSort.ts, remove duplicate in Sidebar.logic.ts - Rename formatRelativeTime in timestampFormat.ts to formatCompactRelativeTime to avoid name collision with relativeTime.ts - Fix DrainableWorker drain race where non-atomic Ref.get + Deferred.await could resolve prematurely when enqueue replaces the idle deferred between the two ops --- apps/web/src/components/Sidebar.logic.ts | 7 +------ .../components/settings/SettingsPanels.tsx | 4 ++-- apps/web/src/lib/threadSort.ts | 2 +- apps/web/src/timestampFormat.ts | 7 +++++-- packages/shared/src/DrainableWorker.ts | 21 +++++++++++++++++-- 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 65ec01831fb..b79b4db8a4a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,6 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { toSortableTimestamp } from "../lib/threadSort"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { @@ -408,12 +409,6 @@ export function getVisibleThreadsForProject>(input: }; } -function toSortableTimestamp(iso: string | undefined): number | null { - if (!iso) return null; - const ms = Date.parse(iso); - return Number.isFinite(ms) ? ms : null; -} - function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { if (thread.latestUserMessageAt) { return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f68..50c8cc53c2b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -48,7 +48,7 @@ import { } from "../../modelSelection"; import { ensureNativeApi, readNativeApi } from "../../nativeApi"; import { useStore } from "../../store"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatCompactRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -194,7 +194,7 @@ function useRelativeTimeTick(intervalMs = 1_000) { function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { useRelativeTimeTick(); - const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; + const lastCheckedRelative = lastCheckedAt ? formatCompactRelativeTime(lastCheckedAt) : null; if (!lastCheckedRelative) { return null; diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 928da3556ac..b73efeadb7c 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -4,7 +4,7 @@ import type { Thread } from "../types"; type ThreadSortInput = Pick; -function toSortableTimestamp(iso: string | undefined): number | null { +export function toSortableTimestamp(iso: string | undefined): number | null { if (!iso) return null; const ms = Date.parse(iso); return Number.isFinite(ms) ? ms : null; diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 453c0701669..95e15fec0d7 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -53,7 +53,10 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` * so callers can style the numeric portion independently. */ -export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { +export function formatCompactRelativeTime(isoDate: string): { + value: string; + suffix: string | null; +} { const diffMs = Date.now() - new Date(isoDate).getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); @@ -68,6 +71,6 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st } export function formatRelativeTimeLabel(isoDate: string) { - const relative = formatRelativeTime(isoDate); + const relative = formatCompactRelativeTime(isoDate); return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 6b9ede9f453..b8e3eec1813 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -93,8 +93,25 @@ export const makeDrainableWorker = ( } }); - const drain: DrainableWorker["drain"] = Ref.get(state).pipe( - Effect.flatMap(({ idle }) => Deferred.await(idle)), + const drain: DrainableWorker["drain"] = Effect.suspend(() => + Ref.get(state).pipe( + Effect.flatMap(({ idle }) => + Deferred.isDone(idle).pipe( + Effect.flatMap((done) => + done + ? // Re-read state to ensure no new work was enqueued between + // Ref.get and now. If the current idle deferred is still the + // same one, the worker is truly idle; otherwise retry. + Ref.get(state).pipe( + Effect.flatMap((current) => + current.idle === idle ? Effect.void : Deferred.await(current.idle), + ), + ) + : Deferred.await(idle), + ), + ), + ), + ), ); return { enqueue, drain } satisfies DrainableWorker; From 4909e9bb9cbcd7f40963454179ae12f9110c81a9 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 14:43:17 +1300 Subject: [PATCH 33/50] revert DrainableWorker drain change (pre-existing issue, not introduced by this PR) --- packages/shared/src/DrainableWorker.ts | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index b8e3eec1813..6b9ede9f453 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -93,25 +93,8 @@ export const makeDrainableWorker = ( } }); - const drain: DrainableWorker["drain"] = Effect.suspend(() => - Ref.get(state).pipe( - Effect.flatMap(({ idle }) => - Deferred.isDone(idle).pipe( - Effect.flatMap((done) => - done - ? // Re-read state to ensure no new work was enqueued between - // Ref.get and now. If the current idle deferred is still the - // same one, the worker is truly idle; otherwise retry. - Ref.get(state).pipe( - Effect.flatMap((current) => - current.idle === idle ? Effect.void : Deferred.await(current.idle), - ), - ) - : Deferred.await(idle), - ), - ), - ), - ), + const drain: DrainableWorker["drain"] = Ref.get(state).pipe( + Effect.flatMap(({ idle }) => Deferred.await(idle)), ); return { enqueue, drain } satisfies DrainableWorker; From 938771bab6f760f4793ba3f78aefdf0b5184959f Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 15:03:33 +1300 Subject: [PATCH 34/50] refactor(web): consolidate relative time formatting into timestampFormat Remove relativeTime.ts and merge its logic into timestampFormat.ts with a single shared bucketing implementation. formatRelativeTime now returns structured { value, suffix } and supports short/long styles. formatRelativeTimeLabel wraps it as a convenience string helper. --- .../src/components/CommandPalette.logic.ts | 4 +- .../components/settings/SettingsPanels.tsx | 4 +- apps/web/src/relativeTime.test.ts | 22 ---- apps/web/src/relativeTime.ts | 56 ---------- apps/web/src/timestampFormat.test.ts | 59 +++++++++- apps/web/src/timestampFormat.ts | 104 ++++++++++++++---- 6 files changed, 146 insertions(+), 103 deletions(-) delete mode 100644 apps/web/src/relativeTime.test.ts delete mode 100644 apps/web/src/relativeTime.ts diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 4515b7014d5..5bc4d166aaf 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -2,7 +2,7 @@ import { type KeybindingCommand } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; -import { formatRelativeTime } from "../relativeTime"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; @@ -106,7 +106,7 @@ export function buildThreadActionItems(input: { searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], title: thread.title, description: descriptionParts.join(" · "), - timestamp: formatRelativeTime(thread.updatedAt ?? thread.createdAt, Date.now(), "long"), + timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt, "long"), icon: input.icon, run: async () => { await input.runThread(thread.id); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 50c8cc53c2b..b7fde0c5f68 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -48,7 +48,7 @@ import { } from "../../modelSelection"; import { ensureNativeApi, readNativeApi } from "../../nativeApi"; import { useStore } from "../../store"; -import { formatCompactRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -194,7 +194,7 @@ function useRelativeTimeTick(intervalMs = 1_000) { function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { useRelativeTimeTick(); - const lastCheckedRelative = lastCheckedAt ? formatCompactRelativeTime(lastCheckedAt) : null; + const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; if (!lastCheckedRelative) { return null; diff --git a/apps/web/src/relativeTime.test.ts b/apps/web/src/relativeTime.test.ts deleted file mode 100644 index dd076ab1968..00000000000 --- a/apps/web/src/relativeTime.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index c0c6ae2dbdc..00000000000 --- a/apps/web/src/relativeTime.ts +++ /dev/null @@ -1,56 +0,0 @@ -const MINUTE_MS = 60_000; -const HOUR_MS = 60 * MINUTE_MS; -const DAY_MS = 24 * HOUR_MS; -const WEEK_MS = 7 * DAY_MS; -const MONTH_MS = 30 * DAY_MS; -const YEAR_MS = 365 * DAY_MS; -let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; -export type RelativeTimeStyle = "long" | "short"; - -function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { - if (relativeTimeFormatter === null) { - relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); - } - return relativeTimeFormatter.format(-value, unit); -} - -function formatShortRelativeUnit(value: number, suffix: string): string { - return `${value}${suffix} ago`; -} - -export function formatRelativeTime( - isoDate: string, - nowMs = Date.now(), - style: RelativeTimeStyle = "long", -): string { - const targetMs = Date.parse(isoDate); - if (Number.isNaN(targetMs)) { - return ""; - } - - const diffMs = Math.max(0, nowMs - targetMs); - const formatUnit = (value: number, unit: Intl.RelativeTimeFormatUnit, shortSuffix: string) => - style === "short" - ? formatShortRelativeUnit(value, shortSuffix) - : formatRelativeUnit(value, unit); - - if (diffMs < MINUTE_MS) { - return "just now"; - } - if (diffMs < HOUR_MS) { - return formatUnit(Math.floor(diffMs / MINUTE_MS), "minute", "m"); - } - if (diffMs < DAY_MS) { - return formatUnit(Math.floor(diffMs / HOUR_MS), "hour", "h"); - } - if (diffMs < WEEK_MS) { - return formatUnit(Math.floor(diffMs / DAY_MS), "day", "d"); - } - if (diffMs < MONTH_MS) { - return formatUnit(Math.floor(diffMs / WEEK_MS), "week", "w"); - } - if (diffMs < YEAR_MS) { - return formatUnit(Math.floor(diffMs / MONTH_MS), "month", "mo"); - } - return formatUnit(Math.floor(diffMs / YEAR_MS), "year", "y"); -} diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index f45ada7341c..bcc71ac7a93 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { getTimestampFormatOptions } from "./timestampFormat"; +import { + formatRelativeTime, + formatRelativeTimeLabel, + getTimestampFormatOptions, +} from "./timestampFormat"; describe("getTimestampFormatOptions", () => { it("omits hour12 when locale formatting is requested", () => { @@ -28,3 +32,56 @@ describe("getTimestampFormatOptions", () => { }); }); }); + +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)).toEqual({ + value: "just now", + suffix: null, + }); + }); + + it("returns structured short values by default", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs)).toEqual({ + value: "5m", + suffix: "ago", + }); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs)).toEqual({ + value: "3h", + suffix: "ago", + }); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs)).toEqual({ + value: "3d", + suffix: "ago", + }); + }); + + it("returns structured long values", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs, "long")).toEqual({ + value: "5 minutes", + suffix: "ago", + }); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs, "long")).toEqual({ + value: "3 hours", + suffix: "ago", + }); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs, "long")).toEqual({ + value: "3 days", + suffix: "ago", + }); + }); +}); + +describe("formatRelativeTimeLabel", () => { + it("formats short labels", () => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); + expect(formatRelativeTimeLabel(fiveMinutesAgo, "short")).toBe("5m ago"); + }); + + it("formats long labels", () => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); + expect(formatRelativeTimeLabel(fiveMinutesAgo, "long")).toBe("5 minutes ago"); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 95e15fec0d7..8466359f5ed 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -48,29 +48,93 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); } +// --------------------------------------------------------------------------- +// Relative time formatting +// --------------------------------------------------------------------------- + +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; + +export type RelativeTimeStyle = "long" | "short"; + +let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; + +function formatLongUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { + if (relativeTimeFormatter === null) { + relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + } + return relativeTimeFormatter.format(-value, unit); +} + +interface RelativeTimeBucket { + threshold: number; + divisor: number; + unit: Intl.RelativeTimeFormatUnit; + shortSuffix: string; +} + +const BUCKETS: RelativeTimeBucket[] = [ + { threshold: MINUTE_MS, divisor: MINUTE_MS, unit: "minute", shortSuffix: "m" }, + { threshold: HOUR_MS, divisor: HOUR_MS, unit: "hour", shortSuffix: "h" }, + { threshold: DAY_MS, divisor: DAY_MS, unit: "day", shortSuffix: "d" }, + { threshold: WEEK_MS, divisor: WEEK_MS, unit: "week", shortSuffix: "w" }, + { threshold: MONTH_MS, divisor: MONTH_MS, unit: "month", shortSuffix: "mo" }, + { threshold: YEAR_MS, divisor: YEAR_MS, unit: "year", shortSuffix: "y" }, +]; + +function resolveRelativeBucket( + diffMs: number, +): { value: number; bucket: RelativeTimeBucket } | null { + if (diffMs < MINUTE_MS) return null; + + let matched = BUCKETS[0]!; + for (const bucket of BUCKETS) { + if (diffMs >= bucket.threshold) { + matched = bucket; + } + } + return { value: Math.floor(diffMs / matched.divisor), bucket: matched }; +} + /** - * Format a relative time string from an ISO date. - * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` - * so callers can style the numeric portion independently. + * Structured relative time for callers that style value and suffix independently. + * + * - `"short"` (default): `{ value: "5m", suffix: "ago" }` + * - `"long"`: `{ value: "5 minutes", suffix: "ago" }` + * - Returns `{ value: "just now", suffix: null }` for times under a minute. */ -export function formatCompactRelativeTime(isoDate: string): { - value: string; - suffix: string | null; -} { - const diffMs = Date.now() - new Date(isoDate).getTime(); - if (diffMs < 0) return { value: "just now", suffix: null }; - const seconds = Math.floor(diffMs / 1000); - if (seconds < 5) return { value: "just now", suffix: null }; - if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" }; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; - const hours = Math.floor(minutes / 60); - if (hours < 24) return { value: `${hours}h`, suffix: "ago" }; - const days = Math.floor(hours / 24); - return { value: `${days}d`, suffix: "ago" }; +export function formatRelativeTime( + isoDate: string, + nowMs = Date.now(), + style: RelativeTimeStyle = "short", +): { value: string; suffix: string | null } { + const targetMs = Date.parse(isoDate); + if (Number.isNaN(targetMs)) return { value: "", suffix: null }; + + const diffMs = Math.max(0, nowMs - targetMs); + const result = resolveRelativeBucket(diffMs); + if (!result) return { value: "just now", suffix: null }; + + if (style === "short") { + return { value: `${result.value}${result.bucket.shortSuffix}`, suffix: "ago" }; + } + + const longText = formatLongUnit(result.value, result.bucket.unit); + const agoMatch = longText.match(/^(.+)\s+ago$/); + if (agoMatch) { + return { value: agoMatch[1]!, suffix: "ago" }; + } + return { value: longText, suffix: null }; } -export function formatRelativeTimeLabel(isoDate: string) { - const relative = formatCompactRelativeTime(isoDate); +export function formatRelativeTimeLabel( + isoDate: string, + style: RelativeTimeStyle = "short", +): string { + const relative = formatRelativeTime(isoDate, Date.now(), style); return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } From 2914502a10dadf9cbf79eb47d24d4e693914e783 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 15:19:19 +1300 Subject: [PATCH 35/50] =?UTF-8?q?refactor(web):=20simplify=20relative=20ti?= =?UTF-8?q?me=20=E2=80=94=20use=20main's=20formatRelativeTimeLabel=20every?= =?UTF-8?q?where?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert timestampFormat.ts to main's version (no long style, no bucketing abstraction). Use formatRelativeTimeLabel in command palette instead of the removed formatRelativeTime from relativeTime.ts. Delete relativeTime.ts. --- .../components/CommandPalette.logic.test.ts | 4 +- .../src/components/CommandPalette.logic.ts | 2 +- apps/web/src/timestampFormat.test.ts | 59 +--------- apps/web/src/timestampFormat.ts | 101 +++--------------- 4 files changed, 21 insertions(+), 145 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2d9c2b70b30..3869b0e6c30 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -71,8 +71,8 @@ describe("buildThreadActionItems", () => { "thread:thread-older", "thread:thread-newer", ]); - expect(items[0]?.timestamp).toBe("yesterday"); - expect(items[1]?.timestamp).toBe("5 days ago"); + expect(items[0]?.timestamp).toBe("1d ago"); + expect(items[1]?.timestamp).toBe("5d ago"); } finally { vi.useRealTimers(); } diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 5bc4d166aaf..26476f7fc86 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -106,7 +106,7 @@ export function buildThreadActionItems(input: { searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], title: thread.title, description: descriptionParts.join(" · "), - timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt, "long"), + timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt), icon: input.icon, run: async () => { await input.runThread(thread.id); diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index bcc71ac7a93..f45ada7341c 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - formatRelativeTime, - formatRelativeTimeLabel, - getTimestampFormatOptions, -} from "./timestampFormat"; +import { getTimestampFormatOptions } from "./timestampFormat"; describe("getTimestampFormatOptions", () => { it("omits hour12 when locale formatting is requested", () => { @@ -32,56 +28,3 @@ describe("getTimestampFormatOptions", () => { }); }); }); - -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)).toEqual({ - value: "just now", - suffix: null, - }); - }); - - it("returns structured short values by default", () => { - expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs)).toEqual({ - value: "5m", - suffix: "ago", - }); - expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs)).toEqual({ - value: "3h", - suffix: "ago", - }); - expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs)).toEqual({ - value: "3d", - suffix: "ago", - }); - }); - - it("returns structured long values", () => { - expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs, "long")).toEqual({ - value: "5 minutes", - suffix: "ago", - }); - expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs, "long")).toEqual({ - value: "3 hours", - suffix: "ago", - }); - expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs, "long")).toEqual({ - value: "3 days", - suffix: "ago", - }); - }); -}); - -describe("formatRelativeTimeLabel", () => { - it("formats short labels", () => { - const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); - expect(formatRelativeTimeLabel(fiveMinutesAgo, "short")).toBe("5m ago"); - }); - - it("formats long labels", () => { - const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); - expect(formatRelativeTimeLabel(fiveMinutesAgo, "long")).toBe("5 minutes ago"); - }); -}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 8466359f5ed..453c0701669 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -48,93 +48,26 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); } -// --------------------------------------------------------------------------- -// Relative time formatting -// --------------------------------------------------------------------------- - -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; - -export type RelativeTimeStyle = "long" | "short"; - -let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; - -function formatLongUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { - if (relativeTimeFormatter === null) { - relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); - } - return relativeTimeFormatter.format(-value, unit); -} - -interface RelativeTimeBucket { - threshold: number; - divisor: number; - unit: Intl.RelativeTimeFormatUnit; - shortSuffix: string; -} - -const BUCKETS: RelativeTimeBucket[] = [ - { threshold: MINUTE_MS, divisor: MINUTE_MS, unit: "minute", shortSuffix: "m" }, - { threshold: HOUR_MS, divisor: HOUR_MS, unit: "hour", shortSuffix: "h" }, - { threshold: DAY_MS, divisor: DAY_MS, unit: "day", shortSuffix: "d" }, - { threshold: WEEK_MS, divisor: WEEK_MS, unit: "week", shortSuffix: "w" }, - { threshold: MONTH_MS, divisor: MONTH_MS, unit: "month", shortSuffix: "mo" }, - { threshold: YEAR_MS, divisor: YEAR_MS, unit: "year", shortSuffix: "y" }, -]; - -function resolveRelativeBucket( - diffMs: number, -): { value: number; bucket: RelativeTimeBucket } | null { - if (diffMs < MINUTE_MS) return null; - - let matched = BUCKETS[0]!; - for (const bucket of BUCKETS) { - if (diffMs >= bucket.threshold) { - matched = bucket; - } - } - return { value: Math.floor(diffMs / matched.divisor), bucket: matched }; -} - /** - * Structured relative time for callers that style value and suffix independently. - * - * - `"short"` (default): `{ value: "5m", suffix: "ago" }` - * - `"long"`: `{ value: "5 minutes", suffix: "ago" }` - * - Returns `{ value: "just now", suffix: null }` for times under a minute. + * Format a relative time string from an ISO date. + * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` + * so callers can style the numeric portion independently. */ -export function formatRelativeTime( - isoDate: string, - nowMs = Date.now(), - style: RelativeTimeStyle = "short", -): { value: string; suffix: string | null } { - const targetMs = Date.parse(isoDate); - if (Number.isNaN(targetMs)) return { value: "", suffix: null }; - - const diffMs = Math.max(0, nowMs - targetMs); - const result = resolveRelativeBucket(diffMs); - if (!result) return { value: "just now", suffix: null }; - - if (style === "short") { - return { value: `${result.value}${result.bucket.shortSuffix}`, suffix: "ago" }; - } - - const longText = formatLongUnit(result.value, result.bucket.unit); - const agoMatch = longText.match(/^(.+)\s+ago$/); - if (agoMatch) { - return { value: agoMatch[1]!, suffix: "ago" }; - } - return { value: longText, suffix: null }; +export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { + const diffMs = Date.now() - new Date(isoDate).getTime(); + if (diffMs < 0) return { value: "just now", suffix: null }; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 5) return { value: "just now", suffix: null }; + if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" }; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { value: `${hours}h`, suffix: "ago" }; + const days = Math.floor(hours / 24); + return { value: `${days}d`, suffix: "ago" }; } -export function formatRelativeTimeLabel( - isoDate: string, - style: RelativeTimeStyle = "short", -): string { - const relative = formatRelativeTime(isoDate, Date.now(), style); +export function formatRelativeTimeLabel(isoDate: string) { + const relative = formatRelativeTime(isoDate); return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } From e6366643dec3c2768184b861478504a6ff2a402b Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 15:21:38 +1300 Subject: [PATCH 36/50] revert DrainableWorker to main's STM-based implementation --- packages/shared/src/DrainableWorker.ts | 71 +++++++------------------- 1 file changed, 19 insertions(+), 52 deletions(-) diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 6b9ede9f453..55483f33e8c 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -8,8 +8,8 @@ * * @module DrainableWorker */ -import { Deferred, Effect, Queue, Ref } from "effect"; import type { Scope } from "effect"; +import { Effect, TxQueue, TxRef } from "effect"; export interface DrainableWorker { /** @@ -39,63 +39,30 @@ export const makeDrainableWorker = ( process: (item: A) => Effect.Effect, ): Effect.Effect, never, Scope.Scope | R> => Effect.gen(function* () { - const queue = yield* Queue.unbounded(); - const initialIdle = yield* Deferred.make(); - yield* Deferred.succeed(initialIdle, undefined).pipe(Effect.orDie); - const state = yield* Ref.make({ - outstanding: 0, - idle: initialIdle, - }); + const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), TxQueue.shutdown); + const outstanding = yield* TxRef.make(0); - yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); - - const finishOne = Ref.modify(state, (current) => { - const remaining = Math.max(0, current.outstanding - 1); - return [ - remaining === 0 ? current.idle : null, - { - outstanding: remaining, - idle: current.idle, - }, - ] as const; - }).pipe( - Effect.flatMap((idle) => - idle === null ? Effect.void : Deferred.succeed(idle, undefined).pipe(Effect.orDie), - ), - ); - - yield* Effect.forkScoped( - Effect.forever( - Queue.take(queue).pipe( - Effect.flatMap((item) => process(item).pipe(Effect.ensuring(finishOne))), + yield* TxQueue.take(queue).pipe( + Effect.tap((a) => + Effect.ensuring( + process(a), + TxRef.update(outstanding, (n) => n - 1), ), ), + Effect.forever, + Effect.forkScoped, ); - const enqueue: DrainableWorker["enqueue"] = (item) => - Effect.gen(function* () { - const nextIdle = yield* Deferred.make(); - yield* Ref.update(state, (current) => - current.outstanding === 0 - ? { - outstanding: 1, - idle: nextIdle, - } - : { - outstanding: current.outstanding + 1, - idle: current.idle, - }, - ); - - const accepted = yield* Queue.offer(queue, item); - if (!accepted) { - yield* finishOne; - } - }); - - const drain: DrainableWorker["drain"] = Ref.get(state).pipe( - Effect.flatMap(({ idle }) => Deferred.await(idle)), + const drain: DrainableWorker["drain"] = TxRef.get(outstanding).pipe( + Effect.tap((n) => (n > 0 ? Effect.txRetry : Effect.void)), + Effect.tx, ); + const enqueue = (element: A): Effect.Effect => + TxQueue.offer(queue, element).pipe( + Effect.tap(() => TxRef.update(outstanding, (n) => n + 1)), + Effect.tx, + ); + return { enqueue, drain } satisfies DrainableWorker; }); From 9d569bd889253b4076f7550bfe41e80c16bee4f5 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 15:27:35 +1300 Subject: [PATCH 37/50] cleanup: remove unnecessary as const assertions --- apps/web/src/components/CommandPalette.tsx | 3 ++- apps/web/src/routes/_chat.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 177f28f97bc..8bd551136fd 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ "use client"; +import type { ProjectId } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { @@ -102,7 +103,7 @@ function OpenCommandPaletteDialog() { const paletteMode = getCommandPaletteMode({ currentView }); const projectTitleById = useMemo( - () => new Map(projects.map((project) => [project.id, project.name] as const)), + () => new Map(projects.map((project) => [project.id, project.name])), [projects], ); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 4b54bf5fc7a..b47f1a577d9 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -72,8 +72,8 @@ function ChatRouteGlobalShortcuts() { activeThread, defaultThreadEnvMode: appSettings.defaultThreadEnvMode, handleNewThread, - projects: defaultProjectId ? [{ id: defaultProjectId } as const] : [], - } as const; + projects: defaultProjectId ? [{ id: defaultProjectId }] : [], + }; if (!resolveThreadActionProjectId(threadActionContext)) { return; From 0e097957bcb30d7dd88fe0ec2e7c52d851a06035 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 15:45:43 +1300 Subject: [PATCH 38/50] refactor(web): consolidate thread sorting into threadSort.ts Widen threadSort.ts types to support optional messages and latestUserMessageAt. Remove duplicate sorting logic from Sidebar.logic.ts and replace sortThreadsForSidebar with sortThreads everywhere. --- apps/web/src/components/Sidebar.logic.test.ts | 128 ------------------ apps/web/src/components/Sidebar.logic.ts | 66 ++------- apps/web/src/components/Sidebar.tsx | 6 +- apps/web/src/lib/threadSort.ts | 18 ++- 4 files changed, 24 insertions(+), 194 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bcd4d677d32..c270211f42f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -16,7 +16,6 @@ import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; @@ -633,133 +632,6 @@ function makeThread(overrides: Partial = {}): Thread { }; } -describe("sortThreadsForSidebar", () => { - it("sorts threads by the latest user message in recency mode", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older", - createdAt: "2026-03-09T10:01:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer", - createdAt: "2026-03-09T10:06:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", - }, - ], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("falls back to thread timestamps when there is no user message", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "assistant", - text: "assistant only", - createdAt: "2026-03-09T10:02:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-2"), - ThreadId.makeUnsafe("thread-1"), - ]); - }); - - it("can sort threads by createdAt when configured", () => { - const sorted = sortThreadsForSidebar( - [ - makeThread({ - id: ThreadId.makeUnsafe("thread-1"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - }), - makeThread({ - id: ThreadId.makeUnsafe("thread-2"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - }), - ], - "created_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.makeUnsafe("thread-1"), - ThreadId.makeUnsafe("thread-2"), - ]); - }); -}); - describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b79b4db8a4a..5f3d10e9d4c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,11 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import { toSortableTimestamp } from "../lib/threadSort"; +import { + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, + type ThreadSortInput, +} from "../lib/threadSort"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { @@ -18,10 +23,6 @@ type SidebarProject = { createdAt?: string | undefined; updatedAt?: string | undefined; }; -type SidebarThreadSortInput = Pick & { - latestUserMessageAt?: string | null; - messages?: Pick[]; -}; export type ThreadTraversalDirection = "previous" | "next"; @@ -409,55 +410,8 @@ export function getVisibleThreadsForProject>(input: }; } -function getLatestUserMessageTimestamp(thread: SidebarThreadSortInput): number { - if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; - } - - let latestUserMessageTimestamp: number | null = null; - - for (const message of thread.messages ?? []) { - if (message.role !== "user") continue; - const messageTimestamp = toSortableTimestamp(message.createdAt); - if (messageTimestamp === null) continue; - latestUserMessageTimestamp = - latestUserMessageTimestamp === null - ? messageTimestamp - : Math.max(latestUserMessageTimestamp, messageTimestamp); - } - - if (latestUserMessageTimestamp !== null) { - return latestUserMessageTimestamp; - } - - return toSortableTimestamp(thread.updatedAt ?? thread.createdAt) ?? Number.NEGATIVE_INFINITY; -} - -function getThreadSortTimestamp( - thread: SidebarThreadSortInput, - sortOrder: SidebarThreadSortOrder | Exclude, -): number { - if (sortOrder === "created_at") { - return toSortableTimestamp(thread.createdAt) ?? Number.NEGATIVE_INFINITY; - } - return getLatestUserMessageTimestamp(thread); -} - -export function sortThreadsForSidebar< - T extends Pick & SidebarThreadSortInput, ->(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { - return threads.toSorted((left, right) => { - const rightTimestamp = getThreadSortTimestamp(right, sortOrder); - const leftTimestamp = getThreadSortTimestamp(left, sortOrder); - const byTimestamp = - rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; - if (byTimestamp !== 0) return byTimestamp; - return right.id.localeCompare(left.id); - }); -} - export function getFallbackThreadIdAfterDelete< - T extends Pick & SidebarThreadSortInput, + T extends Pick & ThreadSortInput, >(input: { threads: readonly T[]; deletedThreadId: T["id"]; @@ -471,7 +425,7 @@ export function getFallbackThreadIdAfterDelete< } return ( - sortThreadsForSidebar( + sortThreads( threads.filter( (thread) => thread.projectId === deletedThread.projectId && @@ -484,7 +438,7 @@ export function getFallbackThreadIdAfterDelete< } export function getProjectSortTimestamp( project: SidebarProject, - projectThreads: readonly SidebarThreadSortInput[], + projectThreads: readonly ThreadSortInput[], sortOrder: Exclude, ): number { if (projectThreads.length > 0) { @@ -502,7 +456,7 @@ export function getProjectSortTimestamp( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & SidebarThreadSortInput, + TThread extends Pick & ThreadSortInput, >( projects: readonly TProject[], threads: readonly TThread[], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 084b31bd1b9..d95e5139887 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -121,10 +121,10 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { sortThreads } from "../lib/threadSort"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -632,7 +632,7 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { - const latestThread = sortThreadsForSidebar( + const latestThread = sortThreads( threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), appSettings.sidebarThreadSortOrder, )[0]; @@ -1171,7 +1171,7 @@ export default function Sidebar() { const renderedProjects = useMemo( () => sortedProjects.map((project) => { - const projectThreads = sortThreadsForSidebar( + const projectThreads = sortThreads( visibleThreads.filter((thread) => thread.projectId === project.id), appSettings.sidebarThreadSortOrder, ); diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index b73efeadb7c..837a7788d38 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -2,7 +2,10 @@ import type { ProjectId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { Thread } from "../types"; -type ThreadSortInput = Pick; +export type ThreadSortInput = Pick & { + latestUserMessageAt?: string | null; + messages?: Pick[]; +}; export function toSortableTimestamp(iso: string | undefined): number | null { if (!iso) return null; @@ -11,9 +14,13 @@ export function toSortableTimestamp(iso: string | undefined): number | null { } function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { + if (thread.latestUserMessageAt) { + return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + } + let latestUserMessageTimestamp: number | null = null; - for (const message of thread.messages) { + for (const message of thread.messages ?? []) { if (message.role !== "user") continue; const messageTimestamp = toSortableTimestamp(message.createdAt); if (messageTimestamp === null) continue; @@ -40,7 +47,7 @@ export function getThreadSortTimestamp( return getLatestUserMessageTimestamp(thread); } -export function sortThreads>( +export function sortThreads & ThreadSortInput>( threads: readonly T[], sortOrder: SidebarThreadSortOrder, ): T[] { @@ -55,10 +62,7 @@ export function sortThreads, + T extends Pick & ThreadSortInput, >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { return ( sortThreads( From b889cb26e5b5c2fde38358f805950a95186630c1 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 16:00:28 +1300 Subject: [PATCH 39/50] fix(web): derive isActionsOnly from deferredQuery to match displayed groups --- apps/web/src/components/CommandPalette.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8bd551136fd..c7edc0d6a16 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -91,7 +91,7 @@ function OpenCommandPaletteDialog() { const setOpen = useCommandPaletteStore((store) => store.setOpen); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); - const isActionsOnly = query.startsWith(">"); + const isActionsOnly = deferredQuery.startsWith(">"); const settings = useSettings(); const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); const projects = useStore((store) => store.projects); From 0fffcb91b956313a707cf8c0824fc5bfcae493bf Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 31 Mar 2026 16:24:57 +1300 Subject: [PATCH 40/50] refactor(web): rename "local thread" to "fresh thread" in command palette labels --- apps/web/src/components/CommandPalette.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c7edc0d6a16..d48d3438790 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -164,11 +164,11 @@ function OpenCommandPaletteDialog() { [handleNewThread, projects, settings.defaultThreadEnvMode], ); - const projectLocalThreadItems = useMemo( + const projectFreshThreadItems = useMemo( () => buildProjectActionItems({ projects, - valuePrefix: "new-local-thread-in", + valuePrefix: "new-fresh-thread-in", icon: , runProject: async (projectId) => { await handleNewThread(projectId, { @@ -254,8 +254,8 @@ function OpenCommandPaletteDialog() { actionItems.push({ kind: "action", - value: "action:new-local-thread", - searchTerms: ["new local thread", "chat", "create", "fresh", "default environment"], + value: "action:new-fresh-thread", + searchTerms: ["new fresh thread", "chat", "create", "default environment"], title: ( <> New fresh thread in {activeProjectTitle} @@ -287,20 +287,19 @@ function OpenCommandPaletteDialog() { actionItems.push({ kind: "submenu", - value: "action:new-local-thread-in", + value: "action:new-fresh-thread-in", searchTerms: [ - "new local thread", + "new fresh thread", "project", "pick", "choose", "select", - "fresh", "default environment", ], - title: "New local thread in...", + title: "New fresh thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectLocalThreadItems }], + groups: [{ value: "projects", label: "Projects", items: projectFreshThreadItems }], }); } From f753e042e867f1708bf1d6216a111171aee73b5e Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 10 Apr 2026 22:25:30 +1200 Subject: [PATCH 41/50] fix: fix render loop --- apps/web/src/components/CommandPalette.logic.ts | 14 +++++++++++--- apps/web/src/components/CommandPalette.tsx | 7 ++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 57db49a2041..fab98bf861c 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -3,7 +3,7 @@ import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; import { formatRelativeTimeLabel } from "../timestampFormat"; -import { type Project, type Thread } from "../types"; +import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; @@ -71,12 +71,20 @@ export function buildProjectActionItems(input: { } export function buildThreadActionItems(input: { - threads: ReadonlyArray; + threads: ReadonlyArray< + Pick< + SidebarThreadSummary, + "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" + > & { + updatedAt?: string | undefined; + latestUserMessageAt?: string | null; + } + >; activeThreadId?: Thread["id"]; projectTitleById: ReadonlyMap; sortOrder: SidebarThreadSortOrder; icon: ReactNode; - runThread: (thread: Thread) => Promise; + runThread: (thread: Pick) => Promise; limit?: number; }): CommandPaletteActionItem[] { const sortedThreads = sortThreads( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index cef9a17386d..9895878f142 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -19,6 +19,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; +import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; @@ -30,7 +31,7 @@ import { sortThreads } from "../lib/threadSort"; import { cn } from "../lib/utils"; import { selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, + selectSidebarThreadsAcrossEnvironments, useStore, } from "../store"; import { buildThreadRouteParams } from "../threadRoutes"; @@ -99,8 +100,8 @@ function OpenCommandPaletteDialog() { const isActionsOnly = deferredQuery.startsWith(">"); const settings = useSettings(); const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); - const projects = useStore(selectProjectsAcrossEnvironments); - const threads = useStore(selectThreadsAcrossEnvironments); + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const keybindings = useServerKeybindings(); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; From be4305b516019083ea600c658d4ea741bec3160a Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 10 Apr 2026 22:40:15 +1200 Subject: [PATCH 42/50] Make command palette globally toggleable - Move palette hotkey handling into the root shell - Add a back button for submenu navigation - Use the configured default thread environment in palette actions --- apps/web/src/components/AppSidebarLayout.tsx | 37 +++++------ apps/web/src/components/CommandPalette.tsx | 65 ++++++++++++++++---- apps/web/src/components/ui/command.tsx | 7 ++- apps/web/src/routes/__root.tsx | 9 ++- apps/web/src/routes/_chat.tsx | 9 --- 5 files changed, 80 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 7cd3d7c54e5..a2b27bb1e96 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,7 +1,6 @@ import { useEffect, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; -import { CommandPalette } from "./CommandPalette"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; @@ -29,24 +28,22 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - - - - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - }} - > - - - - {children} - - + + + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + }} + > + + + + {children} + ); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9895878f142..9f74978b964 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -5,6 +5,7 @@ import type { ProjectId } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import { ArrowDownIcon, + ArrowLeftIcon, ArrowUpIcon, FolderIcon, MessageSquareIcon, @@ -27,6 +28,7 @@ import { startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { sortThreads } from "../lib/threadSort"; import { cn } from "../lib/utils"; import { @@ -45,13 +47,13 @@ import { type CommandPaletteView, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, - getCommandPaletteInputStartAddon, getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { useServerKeybindings } from "../rpc/serverState"; +import { resolveShortcutCommand } from "../keybindings"; import { Command, CommandDialog, @@ -66,6 +68,28 @@ import { toastManager } from "./ui/toast"; export function CommandPalette({ children }: { children: ReactNode }) { const open = useCommandPaletteStore((store) => store.open); const setOpen = useCommandPaletteStore((store) => store.setOpen); + const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); + const keybindings = useServerKeybindings(); + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: false, + }, + }); + if (command !== "commandPalette.toggle") { + return; + } + event.preventDefault(); + event.stopPropagation(); + toggleOpen(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, toggleOpen]); return ( @@ -182,11 +206,11 @@ function OpenCommandPaletteDialog() { icon: , runProject: async (project) => { await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - envMode: "local", + envMode: settings.defaultThreadEnvMode, }); }, }), - [handleNewThread, projects], + [handleNewThread, projects, settings.defaultThreadEnvMode], ); const allThreadItems = useMemo( @@ -265,10 +289,11 @@ function OpenCommandPaletteDialog() { actionItems.push({ kind: "action", value: "action:new-fresh-thread", - searchTerms: ["new fresh thread", "chat", "create", "default environment"], + searchTerms: ["new thread", "chat", "create", settings.defaultThreadEnvMode], title: ( <> - New fresh thread in {activeProjectTitle} + New {settings.defaultThreadEnvMode} thread in{" "} + {activeProjectTitle} ), icon: , @@ -299,14 +324,14 @@ function OpenCommandPaletteDialog() { kind: "submenu", value: "action:new-fresh-thread-in", searchTerms: [ - "new fresh thread", + "new thread", "project", "pick", "choose", "select", - "default environment", + settings.defaultThreadEnvMode, ], - title: "New fresh thread in...", + title: `New ${settings.defaultThreadEnvMode} thread in...`, icon: , addonIcon: , groups: [{ value: "projects", label: "Projects", items: projectFreshThreadItems }], @@ -336,10 +361,6 @@ function OpenCommandPaletteDialog() { }); const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); - const inputStartAddon = getCommandPaletteInputStartAddon({ - mode: paletteMode, - currentViewAddonIcon: currentView?.addonIcon ?? null, - }); const isSubmenu = paletteMode === "submenu"; function handleKeyDown(event: KeyboardEvent): void { @@ -384,7 +405,25 @@ function OpenCommandPaletteDialog() { > + + + ), + } + : {})} onKeyDown={handleKeyDown} /> diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 759518ddfad..0bd1c7a5737 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -90,11 +90,14 @@ function Command({ function CommandInput({ className, + wrapperClassName, placeholder = undefined, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + wrapperClassName?: string | undefined; +}) { return ( -
+
- - - + + + + + diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index c4b1348b09e..d15cc7d65b2 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -22,7 +22,6 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const commandPaletteOpen = useCommandPaletteStore((s) => s.open); - const toggleOpen = useCommandPaletteStore((s) => s.toggleOpen); const keybindings = useServerKeybindings(); const terminalOpen = useTerminalStateStore((state) => routeThreadRef @@ -41,13 +40,6 @@ function ChatRouteGlobalShortcuts() { }, }); - if (command === "commandPalette.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleOpen(); - return; - } - if (commandPaletteOpen) { return; } @@ -105,7 +97,6 @@ function ChatRouteGlobalShortcuts() { routeThreadRef, selectedThreadKeysSize, terminalOpen, - toggleOpen, appSettings.defaultThreadEnvMode, ]); From f2b990f00a62319fd8c4e5aa16e10611c672e08b Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 10 Apr 2026 22:56:25 +1200 Subject: [PATCH 43/50] Refine command palette thread creation - Reuse shared thread action helpers for project and local creation - Resolve latest project thread with environment-aware lookup - Remove obsolete command palette addon helper --- .../src/components/CommandPalette.logic.ts | 10 ----- apps/web/src/components/CommandPalette.tsx | 31 ++++++++------- apps/web/src/lib/chatThreadActions.ts | 39 +++++++++++++------ 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index fab98bf861c..778b24cdd75 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -260,13 +260,3 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str return "Search..."; } } - -export function getCommandPaletteInputStartAddon(input: { - mode: CommandPaletteMode; - currentViewAddonIcon: ReactNode | null; -}): ReactNode | undefined { - if (input.mode === "submenu") { - return input.currentViewAddonIcon ?? undefined; - } - return undefined; -} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9f74978b964..3b23ff4b9bc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -26,10 +26,11 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; import { startNewLocalThreadFromContext, + startNewThreadInProjectFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { sortThreads } from "../lib/threadSort"; +import { getLatestThreadForProject } from "../lib/threadSort"; import { cn } from "../lib/utils"; import { selectProjectsAcrossEnvironments, @@ -141,14 +142,11 @@ function OpenCommandPaletteDialog() { const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { - const latestThread = - sortThreads( - threads.filter( - (thread) => - thread.projectId === project.id && thread.environmentId === project.environmentId, - ), - settings.sidebarThreadSortOrder, - )[0] ?? null; + const latestThread = getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === project.environmentId), + project.id, + settings.sidebarThreadSortOrder, + ); if (latestThread) { await navigate({ to: "/$environmentId/$threadId", @@ -190,12 +188,19 @@ function OpenCommandPaletteDialog() { valuePrefix: "new-thread-in", icon: , runProject: async (project) => { - await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - envMode: settings.defaultThreadEnvMode, - }); + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + projects, + }, + scopeProjectRef(project.environmentId, project.id), + ); }, }), - [handleNewThread, projects, settings.defaultThreadEnvMode], + [activeDraftThread, activeThread, handleNewThread, projects, settings.defaultThreadEnvMode], ); const projectFreshThreadItems = useMemo( diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 87e1586389a..140fc628b11 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -24,6 +24,8 @@ interface NewThreadHandler { ): Promise; } +type NewThreadOptions = NonNullable[1]>; + export interface ChatThreadActionContext { readonly activeDraftThread: DraftThreadContextLike | null; readonly activeThread: ThreadContextLike | undefined; @@ -53,6 +55,30 @@ export function resolveThreadActionProjectRef( : null; } +function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions { + return { + 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"), + }; +} + +function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions { + return { + envMode: context.defaultThreadEnvMode, + }; +} + +export async function startNewThreadInProjectFromContext( + context: ChatThreadActionContext, + projectRef: ScopedProjectRef, +): Promise { + await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); +} + export async function startNewThreadFromContext( context: ChatThreadActionContext, ): Promise { @@ -61,14 +87,7 @@ export async function startNewThreadFromContext( return false; } - await context.handleNewThread(projectRef, { - 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"), - }); + await startNewThreadInProjectFromContext(context, projectRef); return true; } @@ -80,8 +99,6 @@ export async function startNewLocalThreadFromContext( return false; } - await context.handleNewThread(projectRef, { - envMode: context.defaultThreadEnvMode, - }); + await context.handleNewThread(projectRef, buildDefaultThreadOptions(context)); return true; } From 646d05b863fc0cff5ac26d1158db94c108f5bcbd Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 10 Apr 2026 23:22:03 +1200 Subject: [PATCH 44/50] Refine timeline height estimates for composer layout - Update user bubble character width constant for more accurate wrapping - Adjust timeline parity test to focus the composer before measuring layout --- apps/web/src/components/ChatView.browser.tsx | 4 +++- apps/web/src/components/timelineHeight.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 2920bb079b6..3bf8f83fc4b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3877,7 +3877,6 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const newThreadButton = page.getByTestId("new-thread-button"); await expect.element(newThreadButton).toBeInTheDocument(); - await waitForNewThreadShortcutLabel(); await waitForServerConfigToApply(); await newThreadButton.click(); @@ -3901,6 +3900,9 @@ describe("ChatView timeline estimator parity (full app)", () => { }, { timeout: 8_000, interval: 16 }, ); + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + await waitForLayout(); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 776fe9ad889..2b57686b757 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -17,7 +17,7 @@ const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; +const USER_AVG_CHAR_WIDTH_PX = 7.3; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; @@ -59,7 +59,7 @@ function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); + return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_AVG_CHAR_WIDTH_PX)); } function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { From 32bf2fdd2ec3b4682e6fdc6f22f43233ade44db6 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 10 Apr 2026 23:48:20 +1200 Subject: [PATCH 45/50] Read command palette state at shortcut time - Avoid stale closure in global chat shortcuts - Keep command-palette handling aligned with current store state --- apps/web/src/routes/_chat.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d15cc7d65b2..5e16dd0ff66 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -21,7 +21,6 @@ function ChatRouteGlobalShortcuts() { const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); - const commandPaletteOpen = useCommandPaletteStore((s) => s.open); const keybindings = useServerKeybindings(); const terminalOpen = useTerminalStateStore((state) => routeThreadRef @@ -40,7 +39,7 @@ function ChatRouteGlobalShortcuts() { }, }); - if (commandPaletteOpen) { + if (useCommandPaletteStore.getState().open) { return; } @@ -90,7 +89,6 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread, clearSelection, - commandPaletteOpen, handleNewThread, keybindings, defaultProjectRef, From 8ac8b37cf1747e0221cd39fa4a97ec4dcbbda5fd Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sat, 11 Apr 2026 00:06:32 +1200 Subject: [PATCH 46/50] chore: revert change from conflict --- apps/web/src/components/timelineHeight.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 2b57686b757..776fe9ad889 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -17,7 +17,7 @@ const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_AVG_CHAR_WIDTH_PX = 7.3; +const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; @@ -59,7 +59,7 @@ function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_AVG_CHAR_WIDTH_PX)); + return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); } function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { From 239c55273f1868f3888e12b2490716b54368ec40 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sat, 11 Apr 2026 00:16:04 +1200 Subject: [PATCH 47/50] Remove redundant sortThreadsForSidebar wrapper Inline the trivial pass-through to sortThreads from threadSort directly, aligning with getFallbackThreadIdAfterDelete which already called it. --- apps/web/src/components/Sidebar.logic.test.ts | 12 ++++++------ apps/web/src/components/Sidebar.logic.ts | 6 ------ apps/web/src/components/Sidebar.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c6324cb052b..1eebe3ff757 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -17,9 +17,9 @@ import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; +import { sortThreads } from "../lib/threadSort"; import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, @@ -667,9 +667,9 @@ function makeThread(overrides: Partial = {}): Thread { }; } -describe("sortThreadsForSidebar", () => { +describe("sortThreads", () => { it("sorts threads by the latest user message in recency mode", () => { - const sorted = sortThreadsForSidebar( + const sorted = sortThreads( [ makeThread({ id: ThreadId.make("thread-1"), @@ -712,7 +712,7 @@ describe("sortThreadsForSidebar", () => { }); it("falls back to thread timestamps when there is no user message", () => { - const sorted = sortThreadsForSidebar( + const sorted = sortThreads( [ makeThread({ id: ThreadId.make("thread-1"), @@ -746,7 +746,7 @@ describe("sortThreadsForSidebar", () => { }); it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreadsForSidebar( + const sorted = sortThreads( [ makeThread({ id: ThreadId.make("thread-1"), @@ -771,7 +771,7 @@ describe("sortThreadsForSidebar", () => { }); it("can sort threads by createdAt when configured", () => { - const sorted = sortThreadsForSidebar( + const sorted = sortThreads( [ makeThread({ id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index bd24b7707a4..8c742cbe9b9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -443,12 +443,6 @@ export function getVisibleThreadsForProject>(input: }; } -export function sortThreadsForSidebar< - T extends Pick & ThreadSortInput, ->(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { - return sortThreads(threads, sortOrder); -} - export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 57cee501c0b..aa88f007701 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -132,10 +132,10 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - sortThreadsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; +import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; @@ -1142,7 +1142,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const visibleProjectThreads = sortThreadsForSidebar( + const visibleProjectThreads = sortThreads( projectThreads.filter((thread) => thread.archivedAt === null), threadSortOrder, ); @@ -2563,7 +2563,7 @@ export default function Sidebar() { scopeProjectRef(projectRef.environmentId, projectRef.projectId), ); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const latestThread = sortThreadsForSidebar( + const latestThread = sortThreads( (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), sidebarThreadSortOrder, )[0]; @@ -2796,7 +2796,7 @@ export default function Sidebar() { const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { - const projectThreads = sortThreadsForSidebar( + const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), From 74563851516a9a109b0f113abf7ec5b4d2594e2a Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sat, 11 Apr 2026 00:33:03 +1200 Subject: [PATCH 48/50] Fix command palette terminalOpen context and remove duplicated sort tests Read actual terminal state from the store instead of hardcoding false, so custom when clauses referencing terminalOpen resolve correctly. Remove sortThreads tests from Sidebar.logic.test.ts that duplicate the canonical tests in threadSort.test.ts. --- apps/web/src/components/CommandPalette.tsx | 19 ++- apps/web/src/components/Sidebar.logic.test.ts | 127 ------------------ 2 files changed, 15 insertions(+), 131 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3b23ff4b9bc..9a54e7ad072 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2,7 +2,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { ProjectId } from "@t3tools/contracts"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; import { ArrowDownIcon, ArrowLeftIcon, @@ -37,7 +37,8 @@ import { selectSidebarThreadsAcrossEnvironments, useStore, } from "../store"; -import { buildThreadRouteParams } from "../threadRoutes"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { ADDON_ICON_CLASS, buildProjectActionItems, @@ -71,6 +72,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { const setOpen = useCommandPaletteStore((store) => store.setOpen); const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); const keybindings = useServerKeybindings(); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const terminalOpen = useTerminalStateStore((state) => + routeThreadRef + ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { @@ -78,7 +89,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), - terminalOpen: false, + terminalOpen, }, }); if (command !== "commandPalette.toggle") { @@ -90,7 +101,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, toggleOpen]); + }, [keybindings, terminalOpen, toggleOpen]); return ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1eebe3ff757..35534af9f38 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -19,7 +19,6 @@ import { sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { sortThreads } from "../lib/threadSort"; import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, @@ -667,132 +666,6 @@ function makeThread(overrides: Partial = {}): Thread { }; } -describe("sortThreads", () => { - it("sorts threads by the latest user message in recency mode", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "user", - text: "older", - createdAt: "2026-03-09T10:01:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [ - { - id: "message-2" as never, - role: "user", - text: "newer", - createdAt: "2026-03-09T10:06:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", - }, - ], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - - it("falls back to thread timestamps when there is no user message", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:01:00.000Z", - messages: [ - { - id: "message-1" as never, - role: "assistant", - text: "assistant only", - createdAt: "2026-03-09T10:02:00.000Z", - streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", - }, - ], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - - it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "" as never, - updatedAt: undefined, - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - - it("can sort threads by createdAt when configured", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:05:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:10:00.000Z", - }), - ], - "created_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ]); - }); -}); describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ From 0c7f9653ff6988da29483668e71bdcaf4f614b6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 21:01:32 -0700 Subject: [PATCH 49/50] Add composer focus handoff for command palette - Let the command palette return focus to the chat composer - Use project-specific icons in palette project actions - Share composer handle through context for imperative access --- apps/web/src/components/ChatView.tsx | 4 +- .../src/components/CommandPalette.logic.ts | 4 +- apps/web/src/components/CommandPalette.tsx | 88 +++++++++---------- apps/web/src/composerFocus.ts | 18 ++++ apps/web/src/composerHandleContext.ts | 10 +++ 5 files changed, 73 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/composerFocus.ts create mode 100644 apps/web/src/composerHandleContext.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 05d4259684d..7a5a2875ccc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -161,6 +161,7 @@ import { waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useComposerHandleContext } from "../composerHandleContext"; import { useServerAvailableEditors, useServerConfig, @@ -651,7 +652,8 @@ export default function ChatView(props: ChatViewProps) { const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); - const composerRef = useRef(null); + const localComposerRef = useRef(null); + const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 778b24cdd75..c29f1271087 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -54,7 +54,7 @@ export function normalizeSearchText(value: string): string { export function buildProjectActionItems(input: { projects: ReadonlyArray; valuePrefix: string; - icon: ReactNode; + icon: (project: Project) => ReactNode; runProject: (project: Project) => Promise; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -63,7 +63,7 @@ export function buildProjectActionItems(input: { searchTerms: [project.name, project.cwd], title: project.name, description: project.cwd, - icon: input.icon, + icon: input.icon(project), run: async () => { await input.runProject(project); }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9a54e7ad072..06d454ead54 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,7 +7,6 @@ import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, - FolderIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, @@ -16,6 +15,7 @@ import { useDeferredValue, useEffect, useMemo, + useRef, useState, type KeyboardEvent, type ReactNode, @@ -54,6 +54,7 @@ import { RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; +import { ProjectFavicon } from "./ProjectFavicon"; import { useServerKeybindings } from "../rpc/serverState"; import { resolveShortcutCommand } from "../keybindings"; import { @@ -66,12 +67,15 @@ import { } from "./ui/command"; import { Kbd, KbdGroup } from "./ui/kbd"; import { toastManager } from "./ui/toast"; +import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; +import type { ChatComposerHandle } from "./chat/ChatComposer"; export function CommandPalette({ children }: { children: ReactNode }) { const open = useCommandPaletteStore((store) => store.open); const setOpen = useCommandPaletteStore((store) => store.setOpen); const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); const keybindings = useServerKeybindings(); + const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -104,10 +108,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { }, [keybindings, terminalOpen, toggleOpen]); return ( - - {children} - - + + + {children} + + + ); } @@ -131,6 +140,7 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); const setOpen = useCommandPaletteStore((store) => store.setOpen); + const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); @@ -186,7 +196,13 @@ function OpenCommandPaletteDialog() { buildProjectActionItems({ projects, valuePrefix: "project", - icon: , + icon: (project) => ( + + ), runProject: openProjectFromSearch, }), [openProjectFromSearch, projects], @@ -197,7 +213,13 @@ function OpenCommandPaletteDialog() { buildProjectActionItems({ projects, valuePrefix: "new-thread-in", - icon: , + icon: (project) => ( + + ), runProject: async (project) => { await startNewThreadInProjectFromContext( { @@ -219,7 +241,13 @@ function OpenCommandPaletteDialog() { buildProjectActionItems({ projects, valuePrefix: "new-fresh-thread-in", - icon: , + icon: (project) => ( + + ), runProject: async (project) => { await handleNewThread(scopeProjectRef(project.environmentId, project.id), { envMode: settings.defaultThreadEnvMode, @@ -301,29 +329,6 @@ function OpenCommandPaletteDialog() { }); }, }); - - actionItems.push({ - kind: "action", - value: "action:new-fresh-thread", - searchTerms: ["new thread", "chat", "create", settings.defaultThreadEnvMode], - title: ( - <> - New {settings.defaultThreadEnvMode} thread in{" "} - {activeProjectTitle} - - ), - icon: , - shortcutCommand: "chat.newLocal", - run: async () => { - await startNewLocalThreadFromContext({ - activeDraftThread, - activeThread, - defaultThreadEnvMode: settings.defaultThreadEnvMode, - handleNewThread, - projects, - }); - }, - }); } actionItems.push({ @@ -335,23 +340,6 @@ function OpenCommandPaletteDialog() { addonIcon: , groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); - - actionItems.push({ - kind: "submenu", - value: "action:new-fresh-thread-in", - searchTerms: [ - "new thread", - "project", - "pick", - "choose", - "select", - settings.defaultThreadEnvMode, - ], - title: `New ${settings.defaultThreadEnvMode} thread in...`, - icon: , - addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectFreshThreadItems }], - }); } actionItems.push({ @@ -410,6 +398,10 @@ function OpenCommandPaletteDialog() { aria-label="Command palette" className="overflow-hidden p-0" data-testid="command-palette" + finalFocus={() => { + composerHandleRef?.current?.focusAtEnd(); + return false; + }} > void) | null = null; + +export function registerComposerFocus(fn: () => void): () => void { + focusFn = fn; + return () => { + if (focusFn === fn) focusFn = null; + }; +} + +export function focusComposerInput(): void { + focusFn?.(); +} diff --git a/apps/web/src/composerHandleContext.ts b/apps/web/src/composerHandleContext.ts new file mode 100644 index 00000000000..820720ba7e4 --- /dev/null +++ b/apps/web/src/composerHandleContext.ts @@ -0,0 +1,10 @@ +import { createContext, useContext, type MutableRefObject } from "react"; +import type { ChatComposerHandle } from "./components/chat/ChatComposer"; + +export type ComposerHandleRef = MutableRefObject; + +export const ComposerHandleContext = createContext(null); + +export function useComposerHandleContext(): ComposerHandleRef | null { + return useContext(ComposerHandleContext); +} From 45198911b659e12496f87f1a6794220900d6236c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 21:10:40 -0700 Subject: [PATCH 50/50] Refactor thread palette actions to use shared context - Route new-thread shortcuts and palette actions through shared helpers - Resolve default project context centrally instead of scanning projects - Add tests for thread action resolution and creation paths --- apps/web/src/components/CommandPalette.tsx | 43 +++------ apps/web/src/composerFocus.ts | 18 ---- apps/web/src/lib/chatThreadActions.test.ts | 107 +++++++++++++++++++++ apps/web/src/lib/chatThreadActions.ts | 10 +- apps/web/src/routes/_chat.tsx | 36 +++---- 5 files changed, 140 insertions(+), 74 deletions(-) delete mode 100644 apps/web/src/composerFocus.ts create mode 100644 apps/web/src/lib/chatThreadActions.test.ts diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 06d454ead54..4028043f196 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -25,7 +25,6 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; import { - startNewLocalThreadFromContext, startNewThreadInProjectFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; @@ -109,10 +108,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { return ( - + {children} @@ -145,7 +141,8 @@ function OpenCommandPaletteDialog() { const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const settings = useSettings(); - const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = + useHandleNewThread(); const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const keybindings = useServerKeybindings(); @@ -225,36 +222,22 @@ function OpenCommandPaletteDialog() { { activeDraftThread, activeThread, + defaultProjectRef, defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, - projects, }, scopeProjectRef(project.environmentId, project.id), ); }, }), - [activeDraftThread, activeThread, handleNewThread, projects, settings.defaultThreadEnvMode], - ); - - const projectFreshThreadItems = useMemo( - () => - buildProjectActionItems({ - projects, - valuePrefix: "new-fresh-thread-in", - icon: (project) => ( - - ), - runProject: async (project) => { - await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - envMode: settings.defaultThreadEnvMode, - }); - }, - }), - [handleNewThread, projects, settings.defaultThreadEnvMode], + [ + activeDraftThread, + activeThread, + defaultProjectRef, + handleNewThread, + projects, + settings.defaultThreadEnvMode, + ], ); const allThreadItems = useMemo( @@ -323,9 +306,9 @@ function OpenCommandPaletteDialog() { await startNewThreadFromContext({ activeDraftThread, activeThread, + defaultProjectRef, defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, - projects, }); }, }); diff --git a/apps/web/src/composerFocus.ts b/apps/web/src/composerFocus.ts deleted file mode 100644 index e67bbeab03f..00000000000 --- a/apps/web/src/composerFocus.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Module-level registry for the active chat composer's focus function. - * ChatView registers itself here via useLayoutEffect so that other parts - * of the app (e.g. command palette) can imperatively focus the input. - */ - -let focusFn: (() => void) | null = null; - -export function registerComposerFocus(fn: () => void): () => void { - focusFn = fn; - return () => { - if (focusFn === fn) focusFn = null; - }; -} - -export function focusComposerInput(): void { - focusFn?.(); -} diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts new file mode 100644 index 00000000000..9f51264bab0 --- /dev/null +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -0,0 +1,107 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; +import { + resolveThreadActionProjectRef, + startNewLocalThreadFromContext, + startNewThreadFromContext, + type ChatThreadActionContext, +} from "./chatThreadActions"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const FALLBACK_PROJECT_ID = ProjectId.make("project-2"); + +function createContext(overrides: Partial = {}): ChatThreadActionContext { + return { + activeDraftThread: null, + activeThread: undefined, + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), + defaultThreadEnvMode: "local", + handleNewThread: async () => {}, + ...overrides, + }; +} + +describe("chatThreadActions", () => { + it("prefers the active draft thread project when resolving thread actions", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + }, + }), + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + + it("falls back to the default project ref when there is no active thread context", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + }), + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + + it("starts a contextual new thread from the active draft thread", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadFromContext( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + }, + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + envMode: "worktree", + }); + }); + + it("starts a local thread with the configured default env mode", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewLocalThreadFromContext( + createContext({ + defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + defaultThreadEnvMode: "worktree", + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + envMode: "worktree", + }); + }); + + it("does not start a thread when there is no project context", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadFromContext( + createContext({ + defaultProjectRef: null, + handleNewThread, + }), + ); + + expect(didStart).toBe(false); + expect(handleNewThread).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 140fc628b11..39826e8af3d 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -29,12 +29,9 @@ type NewThreadOptions = NonNullable[1]>; export interface ChatThreadActionContext { readonly activeDraftThread: DraftThreadContextLike | null; readonly activeThread: ThreadContextLike | undefined; + readonly defaultProjectRef: ScopedProjectRef | null; readonly defaultThreadEnvMode: DraftThreadEnvMode; readonly handleNewThread: NewThreadHandler; - readonly projects: ReadonlyArray<{ - readonly environmentId: EnvironmentId; - readonly id: ProjectId; - }>; } export function resolveThreadActionProjectRef( @@ -49,10 +46,7 @@ export function resolveThreadActionProjectRef( context.activeDraftThread.projectId, ); } - const fallbackProject = context.projects[0]; - return fallbackProject - ? scopeProjectRef(fallbackProject.environmentId, fallbackProject.id) - : null; + return context.defaultProjectRef; } function buildContextualThreadOptions(context: ChatThreadActionContext): NewThreadOptions { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 5e16dd0ff66..b72e504fae9 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,4 +1,3 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; @@ -8,6 +7,10 @@ import { resolveInitialServerAuthGateState, } from "../environments/primary"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -49,22 +52,17 @@ function ChatRouteGlobalShortcuts() { return; } - const projectRef = activeThread - ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) - : activeDraftThread && routeThreadRef - ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId) - : defaultProjectRef; - if (!projectRef) { - return; - } - if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectRef, { - envMode: resolveSidebarNewThreadEnvMode({ + void startNewLocalThreadFromContext({ + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), + handleNewThread, }); return; } @@ -72,11 +70,14 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectRef, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: - activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), + void startNewThreadFromContext({ + activeDraftThread, + activeThread, + defaultProjectRef, + defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ + defaultEnvMode: appSettings.defaultThreadEnvMode, + }), + handleNewThread, }); } }; @@ -92,7 +93,6 @@ function ChatRouteGlobalShortcuts() { handleNewThread, keybindings, defaultProjectRef, - routeThreadRef, selectedThreadKeysSize, terminalOpen, appSettings.defaultThreadEnvMode,