From ce9bffd93be58b8be529da79697b7c321f5aea30 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 01/12] feat(contracts): add filesystem.browse WS method and request schema --- packages/contracts/src/ws.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b..2bb58b518 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,11 @@ 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 b27c4317853ebb7d43b1a66129860018e020e473 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 02/12] feat(contracts): add project.addByPath to keybinding commands --- packages/contracts/src/keybindings.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b182..dd4fbd60a 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -16,6 +16,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + "project.addByPath", ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ From 64a1b3194986a1dcaf6370af0d68d02fb2f436c5 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 03/12] feat(contracts): add browseFilesystem to NativeApi interface --- packages/contracts/src/ipc.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..0c2c337ff 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -128,6 +128,10 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + browseFilesystem: (input: { partialPath: string }) => Promise<{ + parentPath: string; + entries: Array<{ name: string; fullPath: string }>; + }>; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; From ad59c92fdca3c588b6d578c0c3c550eee5ae52f1 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 04/12] feat(server): add filesystem browse endpoint with directory listing --- apps/server/src/wsServer.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..a4280d356 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -866,6 +866,38 @@ 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 0c9feeab65349499bc15bf44cd3b9290916a0677 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 05/12] feat(server): add default mod+shift+k keybinding for project.addByPath --- apps/server/src/keybindings.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf5846782..df2addf71 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -74,6 +74,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+shift+k", command: "project.addByPath", when: "!terminalFocus" }, ]; function normalizeKeyToken(token: string): string { From ac2cc2141b207b9a0a294549da897d2aad822c6d Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 06/12] feat(web): wire browseFilesystem to WS transport --- apps/web/src/wsNativeApi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..a04922fad 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -114,6 +114,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + browseFilesystem: (input) => transport.request(WS_METHODS.filesystemBrowse, input), }, shell: { openInEditor: (cwd, editor) => From a88c9263105de132b93b673bb69d83dcceb6e86d Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 07/12] feat(web): add AddProjectDialog component with path autocomplete --- apps/web/src/components/AddProjectDialog.tsx | 184 +++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 apps/web/src/components/AddProjectDialog.tsx diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx new file mode 100644 index 000000000..51f746cb9 --- /dev/null +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { FolderIcon } from "lucide-react"; +import { + Command, + CommandDialog, + CommandDialogPopup, + CommandFooter, + CommandInput, + CommandItem, + CommandList, + CommandPanel, +} from "~/components/ui/command"; +import { readNativeApi } from "../nativeApi"; + +interface BrowseEntry { + name: string; + fullPath: string; +} + +interface AddProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAddProject: (path: string) => void; +} + +export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjectDialogProps) { + const [inputValue, setInputValue] = useState(""); + const [entries, setEntries] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const debounceRef = useRef | null>(null); + const inputRef = useRef(null); + + const browse = useCallback(async (partialPath: string) => { + if (!partialPath) { + setEntries([]); + return; + } + const api = readNativeApi(); + if (!api) return; + try { + const result = await api.projects.browseFilesystem({ partialPath }); + setEntries(result.entries); + setHighlightedIndex(0); + } catch { + setEntries([]); + } + }, []); + + useEffect(() => { + if (!open) { + setInputValue(""); + setEntries([]); + setHighlightedIndex(0); + return; + } + }, [open]); + + useEffect(() => { + if (!open) return; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void browse(inputValue); + }, 200); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [inputValue, open, browse]); + + const selectEntry = useCallback( + (entry: BrowseEntry) => { + const nextValue = entry.fullPath + "/"; + setInputValue(nextValue); + void browse(nextValue); + inputRef.current?.focus(); + }, + [browse], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Tab") { + event.preventDefault(); + if (entries.length > 0) { + const idx = Math.min(highlightedIndex, entries.length - 1); + const entry = entries[idx]; + if (entry) { + selectEntry(entry); + } + } + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (entries.length > 0) { + const idx = Math.min(highlightedIndex, entries.length - 1); + const entry = entries[idx]; + if (entry) { + selectEntry(entry); + } + } else if (inputValue.trim()) { + onAddProject(inputValue.trim()); + onOpenChange(false); + } + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlightedIndex((prev) => Math.min(prev + 1, entries.length - 1)); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlightedIndex((prev) => Math.max(prev - 1, 0)); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + onOpenChange(false); + return; + } + }, + [entries, highlightedIndex, inputValue, onAddProject, onOpenChange, selectEntry], + ); + + const handleSubmitDirect = useCallback(() => { + if (inputValue.trim()) { + onAddProject(inputValue.trim()); + onOpenChange(false); + } + }, [inputValue, onAddProject, onOpenChange]); + + return ( + + + + + } + value={inputValue} + onChange={(e) => setInputValue(e.currentTarget.value)} + onKeyDown={handleKeyDown} + /> + + {entries.map((entry, index) => ( + setHighlightedIndex(index)} + onClick={() => selectEntry(entry)} + > + + {entry.name} + + {entry.fullPath} + + + ))} + + {inputValue.trim() && ( +
+ +
+ )} +
+ + Tab to autocomplete · Enter to drill down · Enter on empty to add project + +
+
+
+ ); +} From b1832f3a7088880b05c6aaaa8629a2ab9dfd4897 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:29:52 +0000 Subject: [PATCH 08/12] feat(web): integrate AddProjectDialog with keybinding in chat route --- apps/web/src/routes/_chat.tsx | 64 ++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 193cb0e7a..55f0153eb 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,11 +1,14 @@ -import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { AddProjectDialog } from "../components/AddProjectDialog"; import ThreadSidebar from "../components/Sidebar"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { newCommandId, newProjectId } from "../lib/utils"; +import { readNativeApi } from "../nativeApi"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -13,6 +16,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useAppSettings } from "~/appSettings"; +import { useStore } from "../store"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -40,9 +44,6 @@ function ChatRouteGlobalShortcuts() { return; } - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), @@ -50,6 +51,16 @@ function ChatRouteGlobalShortcuts() { }, }); + if (command === "project.addByPath") { + event.preventDefault(); + event.stopPropagation(); + window.dispatchEvent(new CustomEvent("t3:openAddProjectDialog")); + return; + } + + const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + if (!projectId) return; + if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); @@ -92,6 +103,16 @@ function ChatRouteGlobalShortcuts() { function ChatRouteLayout() { const navigate = useNavigate(); + const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false); + const { handleNewThread } = useHandleNewThread(); + const projects = useStore((s) => s.projects); + const { settings: appSettings } = useAppSettings(); + + useEffect(() => { + const handler = () => setAddProjectDialogOpen(true); + window.addEventListener("t3:openAddProjectDialog", handler); + return () => window.removeEventListener("t3:openAddProjectDialog", handler); + }, []); useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -109,6 +130,34 @@ function ChatRouteLayout() { }; }, [navigate]); + const handleAddProject = useCallback( + async (cwd: string) => { + const api = readNativeApi(); + if (!api) return; + + const existing = projects.find((p) => p.cwd === cwd); + if (existing) return; + + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + const segments = cwd.split(/[/\\]/).filter(Boolean); + const title = segments[segments.length - 1] ?? cwd; + 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); + }, + [handleNewThread, projects, appSettings.defaultThreadEnvMode], + ); + return ( @@ -120,6 +169,11 @@ function ChatRouteLayout() { + void handleAddProject(path)} + /> ); } From 9af229d34e3c5d222c4f875fdca4f3225e749dbe Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:33:54 +0000 Subject: [PATCH 09/12] style: format files with oxfmt --- apps/server/src/wsServer.ts | 10 ++++++---- apps/web/src/components/AddProjectDialog.tsx | 5 ++++- packages/contracts/src/ws.ts | 9 ++++++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a4280d356..a724a5780 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -873,9 +873,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< 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.catch(() => Effect.succeed([] as string[]))); const showHidden = prefix.startsWith("."); const filtered = names @@ -886,7 +886,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< filtered, (name) => fileSystem.stat(path.join(parentDir, name)).pipe( - Effect.map((s) => (s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null)), + Effect.map((s) => + s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null, + ), Effect.catch(() => Effect.succeed(null)), ), { concurrency: 16 }, diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx index 51f746cb9..4d7c6702b 100644 --- a/apps/web/src/components/AddProjectDialog.tsx +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -175,7 +175,10 @@ export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjec )} - Tab to autocomplete · Enter to drill down · Enter on empty to add project + + Tab to autocomplete · Enter to drill down · Enter on empty to add + project + diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 2bb58b518..c30b6f6e5 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -140,9 +140,12 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), // Filesystem - tagRequestBody(WS_METHODS.filesystemBrowse, Schema.Struct({ - partialPath: Schema.String, - })), + tagRequestBody( + WS_METHODS.filesystemBrowse, + Schema.Struct({ + partialPath: Schema.String, + }), + ), // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), From 1d6d6ab7c3f35eb505b3d1e429f58d397e10a301 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 13:46:26 +0000 Subject: [PATCH 10/12] fix(web): Enter adds highlighted project, Tab drills into directory --- apps/web/src/components/AddProjectDialog.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx index 4d7c6702b..a896fc886 100644 --- a/apps/web/src/components/AddProjectDialog.tsx +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -96,7 +96,8 @@ export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjec const idx = Math.min(highlightedIndex, entries.length - 1); const entry = entries[idx]; if (entry) { - selectEntry(entry); + onAddProject(entry.fullPath); + onOpenChange(false); } } else if (inputValue.trim()) { onAddProject(inputValue.trim()); @@ -175,10 +176,7 @@ export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjec )} - - Tab to autocomplete · Enter to drill down · Enter on empty to add - project - + Tab to autocomplete · Enter to add project From 2970d7cb28c5f3305827b6fc2af52a008fc9d6f9 Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 14:17:56 +0000 Subject: [PATCH 11/12] refactor(web): use useDebouncedValue and useQuery in AddProjectDialog --- apps/web/src/components/AddProjectDialog.tsx | 160 ++++++++----------- 1 file changed, 65 insertions(+), 95 deletions(-) diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx index a896fc886..9a9c9c97f 100644 --- a/apps/web/src/components/AddProjectDialog.tsx +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { useQuery } from "@tanstack/react-query"; import { FolderIcon } from "lucide-react"; import { Command, @@ -25,117 +27,85 @@ interface AddProjectDialogProps { export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjectDialogProps) { const [inputValue, setInputValue] = useState(""); - const [entries, setEntries] = useState([]); const [highlightedIndex, setHighlightedIndex] = useState(0); - const debounceRef = useRef | null>(null); const inputRef = useRef(null); - const browse = useCallback(async (partialPath: string) => { - if (!partialPath) { - setEntries([]); - return; - } - const api = readNativeApi(); - if (!api) return; - try { - const result = await api.projects.browseFilesystem({ partialPath }); - setEntries(result.entries); - setHighlightedIndex(0); - } catch { - setEntries([]); - } - }, []); + const [debouncedPath] = useDebouncedValue(inputValue, { wait: 200 }); - useEffect(() => { - if (!open) { - setInputValue(""); - setEntries([]); - setHighlightedIndex(0); - return; - } - }, [open]); + const { data: entries = [] } = useQuery({ + queryKey: ["filesystemBrowse", debouncedPath], + queryFn: async () => { + const api = readNativeApi(); + if (!api) return []; + const result = await api.projects.browseFilesystem({ partialPath: debouncedPath }); + return result.entries; + }, + enabled: open && debouncedPath.length > 0, + }); useEffect(() => { - if (!open) return; - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - void browse(inputValue); - }, 200); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [inputValue, open, browse]); + setHighlightedIndex(0); + }, [entries]); + + const close = useCallback(() => onOpenChange(false), [onOpenChange]); - const selectEntry = useCallback( - (entry: BrowseEntry) => { - const nextValue = entry.fullPath + "/"; - setInputValue(nextValue); - void browse(nextValue); - inputRef.current?.focus(); + const handleOpenChange = useCallback( + (next: boolean) => { + if (!next) { + setInputValue(""); + setHighlightedIndex(0); + } + onOpenChange(next); }, - [browse], + [onOpenChange], ); - const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Tab") { - event.preventDefault(); - if (entries.length > 0) { - const idx = Math.min(highlightedIndex, entries.length - 1); - const entry = entries[idx]; - if (entry) { - selectEntry(entry); - } - } - return; - } + const addProject = useCallback( + (path: string) => { + onAddProject(path); + close(); + }, + [onAddProject, close], + ); - if (event.key === "Enter") { - event.preventDefault(); - if (entries.length > 0) { - const idx = Math.min(highlightedIndex, entries.length - 1); - const entry = entries[idx]; - if (entry) { - onAddProject(entry.fullPath); - onOpenChange(false); - } - } else if (inputValue.trim()) { - onAddProject(inputValue.trim()); - onOpenChange(false); - } - return; - } + const drillInto = useCallback((entry: BrowseEntry) => { + setInputValue(entry.fullPath + "/"); + inputRef.current?.focus(); + }, []); - if (event.key === "ArrowDown") { - event.preventDefault(); - setHighlightedIndex((prev) => Math.min(prev + 1, entries.length - 1)); - return; - } + const getHighlightedEntry = () => + entries.length > 0 ? entries[Math.min(highlightedIndex, entries.length - 1)] : null; - if (event.key === "ArrowUp") { - event.preventDefault(); - setHighlightedIndex((prev) => Math.max(prev - 1, 0)); - return; - } + const handleKeyDown = (event: React.KeyboardEvent) => { + const highlighted = getHighlightedEntry(); - if (event.key === "Escape") { + switch (event.key) { + case "Tab": event.preventDefault(); - onOpenChange(false); - return; - } - }, - [entries, highlightedIndex, inputValue, onAddProject, onOpenChange, selectEntry], - ); - - const handleSubmitDirect = useCallback(() => { - if (inputValue.trim()) { - onAddProject(inputValue.trim()); - onOpenChange(false); + if (highlighted) drillInto(highlighted); + break; + case "Enter": + event.preventDefault(); + if (highlighted) addProject(highlighted.fullPath); + else if (inputValue.trim()) addProject(inputValue.trim()); + break; + case "ArrowDown": + event.preventDefault(); + setHighlightedIndex((i) => Math.min(i + 1, entries.length - 1)); + break; + case "ArrowUp": + event.preventDefault(); + setHighlightedIndex((i) => Math.max(i - 1, 0)); + break; + case "Escape": + event.preventDefault(); + close(); + break; } - }, [inputValue, onAddProject, onOpenChange]); + }; return ( - + @@ -153,7 +123,7 @@ export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjec key={entry.fullPath} data-highlighted={index === highlightedIndex ? "" : undefined} onPointerMove={() => setHighlightedIndex(index)} - onClick={() => selectEntry(entry)} + onClick={() => drillInto(entry)} > {entry.name} @@ -168,7 +138,7 @@ export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjec From dea99ea69c65189403ebb1e58818648cbd80bbab Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Sat, 14 Mar 2026 14:28:37 +0000 Subject: [PATCH 12/12] refactor(web): replace CustomEvent bridge with prop, drop useCallback (React Compiler) --- apps/web/src/components/AddProjectDialog.tsx | 36 +++++------ apps/web/src/routes/_chat.tsx | 66 +++++++++----------- 2 files changed, 45 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx index 9a9c9c97f..23fa47728 100644 --- a/apps/web/src/components/AddProjectDialog.tsx +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useQuery } from "@tanstack/react-query"; import { FolderIcon } from "lucide-react"; @@ -47,31 +47,25 @@ export function AddProjectDialog({ open, onOpenChange, onAddProject }: AddProjec setHighlightedIndex(0); }, [entries]); - const close = useCallback(() => onOpenChange(false), [onOpenChange]); + const close = () => onOpenChange(false); - const handleOpenChange = useCallback( - (next: boolean) => { - if (!next) { - setInputValue(""); - setHighlightedIndex(0); - } - onOpenChange(next); - }, - [onOpenChange], - ); + const handleOpenChange = (next: boolean) => { + if (!next) { + setInputValue(""); + setHighlightedIndex(0); + } + onOpenChange(next); + }; - const addProject = useCallback( - (path: string) => { - onAddProject(path); - close(); - }, - [onAddProject, close], - ); + const addProject = (path: string) => { + onAddProject(path); + close(); + }; - const drillInto = useCallback((entry: BrowseEntry) => { + const drillInto = (entry: BrowseEntry) => { setInputValue(entry.fullPath + "/"); inputRef.current?.focus(); - }, []); + }; const getHighlightedEntry = () => entries.length > 0 ? entries[Math.min(highlightedIndex, entries.length - 1)] : null; diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 55f0153eb..50bd622cc 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,7 +1,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { AddProjectDialog } from "../components/AddProjectDialog"; import ThreadSidebar from "../components/Sidebar"; @@ -20,7 +20,7 @@ import { useStore } from "../store"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -function ChatRouteGlobalShortcuts() { +function ChatRouteGlobalShortcuts({ onOpenAddProject }: { onOpenAddProject: () => void }) { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = @@ -54,7 +54,7 @@ function ChatRouteGlobalShortcuts() { if (command === "project.addByPath") { event.preventDefault(); event.stopPropagation(); - window.dispatchEvent(new CustomEvent("t3:openAddProjectDialog")); + onOpenAddProject(); return; } @@ -92,6 +92,7 @@ function ChatRouteGlobalShortcuts() { clearSelection, handleNewThread, keybindings, + onOpenAddProject, projects, selectedThreadIdsSize, terminalOpen, @@ -108,11 +109,7 @@ function ChatRouteLayout() { const projects = useStore((s) => s.projects); const { settings: appSettings } = useAppSettings(); - useEffect(() => { - const handler = () => setAddProjectDialogOpen(true); - window.addEventListener("t3:openAddProjectDialog", handler); - return () => window.removeEventListener("t3:openAddProjectDialog", handler); - }, []); + const openAddProjectDialog = () => setAddProjectDialogOpen(true); useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -130,37 +127,34 @@ function ChatRouteLayout() { }; }, [navigate]); - const handleAddProject = useCallback( - async (cwd: string) => { - const api = readNativeApi(); - if (!api) return; - - const existing = projects.find((p) => p.cwd === cwd); - if (existing) return; - - const projectId = newProjectId(); - const createdAt = new Date().toISOString(); - const segments = cwd.split(/[/\\]/).filter(Boolean); - const title = segments[segments.length - 1] ?? cwd; - 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); - }, - [handleNewThread, projects, appSettings.defaultThreadEnvMode], - ); + const handleAddProject = async (cwd: string) => { + const api = readNativeApi(); + if (!api) return; + + const existing = projects.find((p) => p.cwd === cwd); + if (existing) return; + + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + const segments = cwd.split(/[/\\]/).filter(Boolean); + const title = segments[segments.length - 1] ?? cwd; + 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); + }; return ( - +