diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e7..b57c13032c 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, @@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf58467825..fcb3db0d8d 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -70,6 +70,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..76c2d82520 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1573,6 +1573,138 @@ 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("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 }); + 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("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 2e6ac51b7f..ee5cd74210 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -110,6 +110,40 @@ 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("./") || + 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` + @@ -866,6 +900,66 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } + 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, + 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.mapError( + (cause) => + new RouteRequestError({ + message: `Unable to browse '${parentDir}': ${Cause.pretty(Cause.fail(cause)).trim()}`, + }), + ), + ); + + const showHidden = prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + const filtered = names + .filter( + (name) => + name.toLowerCase().startsWith(lowerPrefix) && (showHidden || !name.startsWith(".")), + ) + .toSorted((left, right) => left.localeCompare(right)); + + const entries = yield* Effect.forEach( + filtered, + (name) => + 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 }, + ); + + return { + parentPath: parentDir, + entries: entries.filter(Boolean), + }; + } + case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; return { diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts new file mode 100644 index 0000000000..4f291d5a48 --- /dev/null +++ b/apps/web/src/commandPaletteStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +interface CommandPaletteStore { + open: boolean; + setOpen: (open: boolean) => void; + toggleOpen: () => void; +} + +export const useCommandPaletteStore = create((set) => ({ + open: false, + setOpen: (open) => set({ open }), + toggleOpen: () => set((state) => ({ open: !state.open })), +})); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6cbef09bd6..33f9bf8aff 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -33,6 +33,7 @@ import { estimateTimelineMessageHeight } from "./timelineHeight"; const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const SECOND_PROJECT_ID = "project-2" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -381,6 +382,30 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithSecondaryProject(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-secondary-project-target" as MessageId, + targetText: "secondary project", + }); + + return { + ...snapshot, + projects: [ + ...snapshot.projects, + { + id: SECOND_PROJECT_ID, + title: "Docs Portal", + workspaceRoot: "/repo/clients/docs-portal", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -1333,6 +1358,252 @@ 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); + const palette = page.getByTestId("command-palette"); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(palette).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("New thread in Project", { exact: true }).click(); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the command palette.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("filters command palette results as the user types", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-search-test" as MessageId, + targetText: "command palette search test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + const palette = page.getByTestId("command-palette"); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("settings"); + await expect.element(palette.getByText("Open settings", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("New thread in Project", { exact: true })) + .not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("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); + const palette = page.getByTestId("command-palette"); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("project"); + 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(); + } + }); + + it("searches projects by path and opens a new thread using the default env mode", async () => { + localStorage.setItem( + "t3code:app-settings:v1", + JSON.stringify({ + codexBinaryPath: "", + codexHomePath: "", + defaultThreadEnvMode: "worktree", + confirmThreadDelete: true, + enableAssistantStreaming: false, + timestampFormat: "locale", + customCodexModels: [], + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithSecondaryProject(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + const palette = page.getByTestId("command-palette"); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await expect.element(palette).toBeInTheDocument(); + await page.getByPlaceholder("Search commands, projects, and threads...").fill("clients/docs"); + await expect.element(palette.getByText("Docs Portal", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("/repo/clients/docs-portal", { exact: true })) + .toBeInTheDocument(); + await palette.getByText("Docs Portal", { exact: true }).click(); + + const nextPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== `/${THREAD_ID}`, + "Route should have changed to a new draft thread UUID from the project search result.", + ); + const nextThreadId = nextPath.slice(1) as ThreadId; + const draftThread = useComposerDraftStore.getState().draftThreadsByThreadId[nextThreadId]; + expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); + expect(draftThread?.envMode).toBe("worktree"); + } finally { + await mounted.cleanup(); + } + }); + it("creates a fresh draft after the previous draft thread is promoted", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eaead424fb..31ea85182f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -94,6 +94,7 @@ import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -2157,7 +2158,9 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId || event.defaultPrevented) return; + if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { + return; + } const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts new file mode 100644 index 0000000000..a1134b5969 --- /dev/null +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -0,0 +1,262 @@ +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; + browseQuery: string; + 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: `${input.browseQuery} ..`, + searchText: `${input.browseQuery} ..`, + 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: `${input.browseQuery} ${entry.fullPath} ${entry.name}`, + searchText: `${input.browseQuery} ${entry.fullPath} ${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 new file mode 100644 index 0000000000..d9e5a06fdd --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,615 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { + ArrowDownIcon, + ArrowUpIcon, + CornerLeftUpIcon, + FolderIcon, + FolderPlusIcon, + MessageSquareIcon, + SettingsIcon, + SquarePenIcon, +} from "lucide-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"; +import { + startNewLocalThreadFromContext, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; +import { + appendBrowsePathSegment, + getBrowseParentPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, +} from "../lib/projectPaths"; +import { addProjectFromPath } from "../lib/projectAdd"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { cn } from "../lib/utils"; +import { readNativeApi } from "../nativeApi"; +import { useStore } from "../store"; +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, + CommandDialog, + CommandDialogPopup, + CommandFooter, + CommandInput, + CommandPanel, +} from "./ui/command"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { toastManager } from "./ui/toast"; + +export function CommandPalette({ children }: { children: ReactNode }) { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + + return ( + + {children} + + + ); +} + +function CommandPaletteDialog() { + const open = useCommandPaletteStore((store) => store.open); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + + useEffect(() => { + return () => { + setOpen(false); + }; + }, [setOpen]); + + if (!open) { + return null; + } + + return ; +} + +function OpenCommandPaletteDialog() { + const navigate = useNavigate(); + const setOpen = useCommandPaletteStore((store) => store.setOpen); + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const isActionsOnly = query.startsWith(">"); + const 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); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + 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 relativePathNeedsActiveProject = + isExplicitRelativeProjectPath(query.trim()) && currentProjectCwd === null; + const debouncedRelativePathNeedsActiveProject = + isExplicitRelativeProjectPath(debouncedBrowsePath.trim()) && currentProjectCwd === 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 } : {}), + }); + return result.entries; + }, + enabled: + isBrowsing && debouncedBrowsePath.length > 0 && !debouncedRelativePathNeedsActiveProject, + }); + + const projectThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-thread-in", + icon: , + runProject: async (projectId) => { + await handleNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }); + }, + }), + [handleNewThread, projects, settings.defaultThreadEnvMode], + ); + + const projectLocalThreadItems = useMemo( + () => + buildProjectActionItems({ + projects, + valuePrefix: "new-local-thread-in", + icon: , + runProject: async (projectId) => { + await handleNewThread(projectId, { + envMode: "local", + }); + }, + }), + [handleNewThread, projects], + ); + + const allThreadItems = useMemo( + () => + buildThreadActionItems({ + threads, + ...(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((previousViews) => [ + ...previousViews, + { + addonIcon: item.addonIcon, + groups: item.groups, + ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + }, + ]); + setHighlightedItemValue(null); + setQuery(item.initialQuery ?? ""); + }, []); + + const popView = useCallback(() => { + setViewStack((previousViews) => previousViews.slice(0, -1)); + setHighlightedItemValue(null); + setQuery(""); + }, []); + + const handleQueryChange = useCallback( + (nextQuery: string) => { + setHighlightedItemValue(null); + setQuery(nextQuery); + if (nextQuery === "" && currentView?.initialQuery) { + popView(); + } + }, + [currentView, popView], + ); + + const rootGroups = useMemo(() => { + const actionItems: Array = []; + + if (projects.length > 0) { + const activeProjectTitle = currentProjectId + ? (projectTitleById.get(currentProjectId) ?? null) + : null; + + if (activeProjectTitle) { + actionItems.push({ + kind: "action", + value: "action:new-thread", + 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 fresh thread chat create ${activeProjectTitle}`.trim(), + title: ( + <> + New fresh 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", + icon: , + addonIcon: , + groups: [], + initialQuery: "~/", + }); + + actionItems.push({ + kind: "action", + value: "action:settings", + label: "settings preferences configuration keybindings", + title: "Open settings", + icon: , + run: async () => { + await navigate({ to: "/settings" }); + }, + }); + + const groups: CommandPaletteGroup[] = []; + if (actionItems.length > 0) { + groups.push({ + value: "actions", + label: "Actions", + items: actionItems, + }); + } + if (recentThreadItems.length > 0) { + groups.push({ + value: "recent-threads", + label: "Recent Threads", + items: recentThreadItems, + }); + } + return groups; + }, [ + activeDraftThread, + activeThread, + currentProjectId, + handleNewThread, + navigate, + projectLocalThreadItems, + projectThreadItems, + projectTitleById, + projects, + recentThreadItems, + settings.defaultThreadEnvMode, + ]); + + const activeGroups = currentView ? currentView.groups : rootGroups; + + const filteredGroups = useMemo( + () => + filterCommandPaletteGroups({ + activeGroups, + query: deferredQuery, + isInSubmenu: currentView !== null, + projectSearchItems: projectThreadItems, + threadSearchItems: allThreadItems, + }), + [activeGroups, allThreadItems, currentView, deferredQuery, projectThreadItems], + ); + + const handleAddProject = useCallback( + async (rawCwd: string) => { + const api = readNativeApi(); + if (!api) return; + + try { + await addProjectFromPath( + { + api, + currentProjectCwd, + defaultThreadEnvMode: settings.defaultThreadEnvMode, + handleNewThread, + navigateToThread: async (threadId) => { + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + platform: navigator.platform, + projects, + threads, + }, + rawCwd, + ); + setOpen(false); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, + [ + currentProjectCwd, + handleNewThread, + navigate, + projects, + setOpen, + settings.defaultThreadEnvMode, + threads, + ], + ); + + const browseTo = useCallback( + (name: string) => { + setHighlightedItemValue(null); + setQuery(appendBrowsePathSegment(query, name)); + setBrowseGeneration((generation) => generation + 1); + }, + [query], + ); + + const browseUp = useCallback(() => { + const parentPath = getBrowseParentPath(query); + if (parentPath === null) { + return; + } + + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); + }, [query]); + + const canBrowseUp = + isBrowsing && !relativePathNeedsActiveProject && getBrowseParentPath(query) !== null; + + const browseGroups = useMemo( + () => + buildBrowseGroups({ + browseEntries, + browseQuery: query, + canBrowseUp, + upIcon: , + directoryIcon: , + browseUp, + browseTo, + }), + [browseEntries, browseTo, browseUp, canBrowseUp, query], + ); + + const displayedGroups = useMemo( + () => + isBrowsing && relativePathNeedsActiveProject + ? [] + : isBrowsing + ? browseGroups + : filteredGroups, + [browseGroups, filteredGroups, isBrowsing, relativePathNeedsActiveProject], + ); + 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: KeyboardEvent) => { + if ( + isBrowsing && + event.key === "Enter" && + highlightedItemValue === null && + !relativePathNeedsActiveProject + ) { + event.preventDefault(); + void handleAddProject(query.trim()); + } + + if (event.key === "Backspace" && query === "" && isSubmenu) { + event.preventDefault(); + popView(); + } + }, + [ + handleAddProject, + highlightedItemValue, + isBrowsing, + isSubmenu, + popView, + query, + relativePathNeedsActiveProject, + ], + ); + + const executeItem = useCallback( + (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => { + if (item.kind === "submenu") { + pushView(item); + return; + } + + if (!item.keepOpen) { + setOpen(false); + } + + void item.run().catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Unable to run command", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + }, + [pushView, setOpen], + ); + + return ( + + { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} + onValueChange={handleQueryChange} + value={query} + > +
+ + {isBrowsing ? ( + + ) : null} +
+ + + + +
+ + + + + + + + Navigate + + + Enter + Select + + {isSubmenu ? ( + + Backspace + Back + + ) : null} + + Esc + Close + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx new file mode 100644 index 0000000000..00f778ad74 --- /dev/null +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -0,0 +1,104 @@ +import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { ChevronRightIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../keybindings"; +import { + type CommandPaletteActionItem, + type CommandPaletteGroup, + type CommandPaletteSubmenuItem, +} from "./CommandPalette.logic"; +import { + CommandCollection, + CommandGroup, + CommandGroupLabel, + CommandItem, + CommandList, + CommandShortcut, +} from "./ui/command"; + +interface CommandPaletteResultsProps { + emptyStateMessage?: string; + groups: ReadonlyArray; + isActionsOnly: boolean; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +} + +export function CommandPaletteResults(props: CommandPaletteResultsProps) { + if (props.groups.length === 0) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); + } + + return ( + + {props.groups.map((group) => ( + + {group.label} + + {(item) => ( + + )} + + + ))} + + ); +} + +function CommandPaletteResultRow(props: { + item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + keybindings: ResolvedKeybindingsConfig; + onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; +}) { + const shortcutLabel = props.item.shortcutCommand + ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand) + : null; + + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onExecuteItem(props.item); + }} + > + {props.item.searchText ? {props.item.searchText} : null} + {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 bcef110c1b..fb6cd257c6 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, @@ -27,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, @@ -39,7 +39,8 @@ 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"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; @@ -48,8 +49,10 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { addProjectFromPath as runAddProjectFromPath } from "../lib/projectAdd"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; +import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -82,7 +85,6 @@ import { } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; import { resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -90,20 +92,11 @@ import { shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +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; @@ -272,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), @@ -382,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 = rawCwd.trim(); - 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 = () => { @@ -416,29 +392,26 @@ export default function Sidebar() { setAddingProject(false); }; - const existing = projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject(existing.id); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const createdAt = new Date().toISOString(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? 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 }, + }); + }, + platform: navigator.platform, + projects, + threads, + }, + rawCwd, + ); + finishAddingProject(); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; @@ -452,22 +425,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; @@ -483,7 +458,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(); } @@ -1068,6 +1043,10 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new"), [keybindings], ); + const commandPaletteShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "commandPalette.toggle"), + [keybindings], + ); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1203,6 +1182,29 @@ export default function Sidebar() { )} + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -1590,7 +1592,11 @@ export default function Sidebar() { : "text-muted-foreground/40" }`} > - {formatRelativeTime(thread.createdAt)} + {formatRelativeTime( + thread.createdAt, + Date.now(), + "short", + )} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index a2bc59c092..759518ddfa 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -30,7 +30,7 @@ function CommandDialogBackdrop({ className, ...props }: CommandDialogPrimitive.B return ( { it("returns labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), + "⌘K", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -284,6 +294,21 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches commandPalette.toggle shortcut outside terminal focus", () => { + assert.isTrue( + isCommandPaletteToggleShortcut(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + assert.isFalse( + isCommandPaletteToggleShortcut(event({ key: "k", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aad..2bd4f19add 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -206,6 +206,14 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isCommandPaletteToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "commandPalette.toggle", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts new file mode 100644 index 0000000000..69fde0f3a1 --- /dev/null +++ b/apps/web/src/lib/chatThreadActions.ts @@ -0,0 +1,73 @@ +import type { ProjectId } from "@t3tools/contracts"; +import type { DraftThreadEnvMode } from "../composerDraftStore"; + +interface ThreadContextLike { + projectId: ProjectId; + branch: string | null; + worktreePath: string | null; +} + +interface DraftThreadContextLike extends ThreadContextLike { + envMode: DraftThreadEnvMode; +} + +interface NewThreadHandler { + ( + projectId: ProjectId, + options?: { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + }, + ): Promise; +} + +export interface ChatThreadActionContext { + readonly activeDraftThread: DraftThreadContextLike | null; + readonly activeThread: ThreadContextLike | undefined; + readonly defaultThreadEnvMode: DraftThreadEnvMode; + readonly handleNewThread: NewThreadHandler; + readonly projects: ReadonlyArray<{ readonly id: ProjectId }>; +} + +export function resolveThreadActionProjectId(context: ChatThreadActionContext): ProjectId | null { + return ( + context.activeThread?.projectId ?? + context.activeDraftThread?.projectId ?? + context.projects[0]?.id ?? + null + ); +} + +export async function startNewThreadFromContext( + context: ChatThreadActionContext, +): Promise { + const projectId = resolveThreadActionProjectId(context); + if (!projectId) { + return false; + } + + await context.handleNewThread(projectId, { + branch: context.activeThread?.branch ?? context.activeDraftThread?.branch ?? null, + worktreePath: + context.activeThread?.worktreePath ?? context.activeDraftThread?.worktreePath ?? null, + envMode: + context.activeDraftThread?.envMode ?? + (context.activeThread?.worktreePath ? "worktree" : "local"), + }); + return true; +} + +export async function startNewLocalThreadFromContext( + context: ChatThreadActionContext, +): Promise { + const projectId = resolveThreadActionProjectId(context); + if (!projectId) { + return false; + } + + await context.handleNewThread(projectId, { + envMode: context.defaultThreadEnvMode, + }); + return true; +} diff --git a/apps/web/src/lib/projectAdd.ts b/apps/web/src/lib/projectAdd.ts new file mode 100644 index 0000000000..6cc0c364d6 --- /dev/null +++ b/apps/web/src/lib/projectAdd.ts @@ -0,0 +1,97 @@ +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, + isExplicitRelativeProjectPath, + isUnsupportedWindowsProjectPath, + 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 platform: string; + 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); +} + +export async function addProjectFromPath( + context: AddProjectFromPathContext, + rawCwd: string, +): Promise { + if (isUnsupportedWindowsProjectPath(rawCwd.trim(), context.platform)) { + throw new Error("Windows-style paths are only supported on Windows."); + } + + if (isExplicitRelativeProjectPath(rawCwd.trim()) && !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 new file mode 100644 index 0000000000..e9e1b46203 --- /dev/null +++ b/apps/web/src/lib/projectPaths.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { + appendBrowsePathSegment, + findProjectByPath, + getBrowseParentPath, + 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("~/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("./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("./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("C:\\")).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts new file mode 100644 index 0000000000..8ce3e3a1e7 --- /dev/null +++ b/apps/web/src/lib/projectPaths.ts @@ -0,0 +1,183 @@ +import { isWindowsPlatform } from "./utils"; + +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("\\") ? "\\" : "/"; +} + +function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +export function isExplicitRelativeProjectPath(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, + 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 && (value.startsWith("\\\\") || isWindowsDrivePath(value))) + ); +} + +export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { + return (isWindowsDrivePath(value) || isUncPath(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 (!isExplicitRelativeProjectPath(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); + 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/relativeTime.test.ts b/apps/web/src/relativeTime.test.ts new file mode 100644 index 0000000000..dd076ab196 --- /dev/null +++ b/apps/web/src/relativeTime.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { formatRelativeTime } from "./relativeTime"; + +describe("formatRelativeTime", () => { + const nowMs = Date.parse("2026-03-15T12:00:00.000Z"); + + it("returns just now for times under a minute old", () => { + expect(formatRelativeTime("2026-03-15T11:59:45.000Z", nowMs)).toBe("just now"); + }); + + it("formats minutes, hours, and days ago", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs)).toBe("5 minutes ago"); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs)).toBe("3 hours ago"); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs)).toBe("3 days ago"); + }); + + it("supports compact m/h/d formatting", () => { + expect(formatRelativeTime("2026-03-15T11:55:00.000Z", nowMs, "short")).toBe("5m ago"); + expect(formatRelativeTime("2026-03-15T09:00:00.000Z", nowMs, "short")).toBe("3h ago"); + expect(formatRelativeTime("2026-03-12T12:00:00.000Z", nowMs, "short")).toBe("3d ago"); + }); +}); diff --git a/apps/web/src/relativeTime.ts b/apps/web/src/relativeTime.ts new file mode 100644 index 0000000000..c0c6ae2dbd --- /dev/null +++ b/apps/web/src/relativeTime.ts @@ -0,0 +1,56 @@ +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; +let relativeTimeFormatter: Intl.RelativeTimeFormat | null = null; +export type RelativeTimeStyle = "long" | "short"; + +function formatRelativeUnit(value: number, unit: Intl.RelativeTimeFormatUnit): string { + if (relativeTimeFormatter === null) { + relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + } + return relativeTimeFormatter.format(-value, unit); +} + +function formatShortRelativeUnit(value: number, suffix: string): string { + return `${value}${suffix} ago`; +} + +export function formatRelativeTime( + isoDate: string, + nowMs = Date.now(), + style: RelativeTimeStyle = "long", +): string { + const targetMs = Date.parse(isoDate); + if (Number.isNaN(targetMs)) { + return ""; + } + + const diffMs = Math.max(0, nowMs - targetMs); + const formatUnit = (value: number, unit: Intl.RelativeTimeFormatUnit, shortSuffix: string) => + style === "short" + ? formatShortRelativeUnit(value, shortSuffix) + : formatRelativeUnit(value, unit); + + if (diffMs < MINUTE_MS) { + return "just now"; + } + if (diffMs < HOUR_MS) { + return formatUnit(Math.floor(diffMs / MINUTE_MS), "minute", "m"); + } + if (diffMs < DAY_MS) { + return formatUnit(Math.floor(diffMs / HOUR_MS), "hour", "h"); + } + if (diffMs < WEEK_MS) { + return formatUnit(Math.floor(diffMs / DAY_MS), "day", "d"); + } + if (diffMs < MONTH_MS) { + return formatUnit(Math.floor(diffMs / WEEK_MS), "week", "w"); + } + if (diffMs < YEAR_MS) { + return formatUnit(Math.floor(diffMs / MONTH_MS), "month", "mo"); + } + return formatUnit(Math.floor(diffMs / YEAR_MS), "year", "y"); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 193cb0e7a9..fd3c5b98fd 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,15 +3,20 @@ import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; +import { CommandPalette } from "../components/CommandPalette"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import ThreadSidebar from "../components/Sidebar"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + 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 +26,8 @@ function ChatRouteGlobalShortcuts() { const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = useHandleNewThread(); + const commandPaletteOpen = useCommandPaletteStore((s) => s.open); + const toggleOpen = useCommandPaletteStore((s) => s.toggleOpen); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const terminalOpen = useTerminalStateStore((state) => @@ -34,15 +41,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 +48,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 +81,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 +98,13 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread, clearSelection, + commandPaletteOpen, handleNewThread, keybindings, projects, selectedThreadIdsSize, terminalOpen, + toggleOpen, appSettings.defaultThreadEnvMode, ]); @@ -110,17 +131,19 @@ function ChatRouteLayout() { }, [navigate]); return ( - - - - - - - + + + + + + + + + ); } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da0..2090cf60ea 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 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("forwards full-thread diff requests to the orchestration websocket method", async () => { requestMock.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..8a0a4d2cd5 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -115,6 +115,9 @@ 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 new file mode 100644 index 0000000000..0675066daf --- /dev/null +++ b/packages/contracts/src/filesystem.ts @@ -0,0 +1,22 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const FILESYSTEM_PATH_MAX_LENGTH = 512; + +export const FilesystemBrowseInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), +}); +export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; + +export const FilesystemBrowseEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + fullPath: TrimmedNonEmptyString, +}); +export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; + +export const FilesystemBrowseResult = Schema.Struct({ + parentPath: TrimmedNonEmptyString, + entries: Schema.Array(FilesystemBrowseEntry), +}); +export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..c9f708ce4a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,4 +10,5 @@ export * from "./server"; export * from "./git"; export * from "./orchestration"; export * from "./editor"; +export * from "./filesystem"; export * from "./project"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..ebe88e7f90 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,6 +24,7 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -129,6 +130,9 @@ 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/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c53..afab73cca7 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -41,6 +41,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); + const parsedCommandPalette = yield* decode(KeybindingRule, { + key: "mod+k", + command: "commandPalette.toggle", + }); + assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b1824..9b61df6a23 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -13,6 +13,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "commandPalette.toggle", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index d732242ecd..7c2135978d 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -73,6 +73,25 @@ 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 ebb76138b8..5f5f6b0bfe 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -35,6 +35,7 @@ import { } from "./terminal"; import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; +import { FilesystemBrowseInput } from "./filesystem"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; @@ -72,6 +73,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 +140,9 @@ 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),