From 1b409c39468298a2a0c01d988fc2d90340877f13 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Feb 2026 13:04:49 -0800 Subject: [PATCH] Refactor web query hooks and debounce composer path search - add `@tanstack/react-pacer` and switch composer path debounce to `useDebouncedValue` - extract reusable `projectSearchEntriesQueryOptions` for workspace entry lookup - add `serverKeybindingsQueryOptions` and update consumers in chat/sidebar - keep branch toolbar mutation error handling at call sites --- apps/web/package.json | 1 + apps/web/src/components/BranchToolbar.tsx | 25 ++++----- apps/web/src/components/ChatView.tsx | 66 ++++++++--------------- apps/web/src/components/Sidebar.tsx | 7 +-- apps/web/src/lib/projectReactQuery.ts | 42 +++++++++++++++ apps/web/src/lib/serverReactQuery.ts | 7 +++ bun.lock | 11 ++++ 7 files changed, 99 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/lib/projectReactQuery.ts diff --git a/apps/web/package.json b/apps/web/package.json index 8f610f069f1d2c0a4a234516873e0647f2130df4..8cccaddfeb31962ad93a398d81fab175255d6d8f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@base-ui/react": "^1.2.0", "@t3tools/contracts": "workspace:*", + "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d02c94db95dc9b6d4b852d47be16d3dd9cdde3f9..f13490a3f702d6abd2a897d37a7c8ad535980bfe 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -68,19 +68,13 @@ export default function BranchToolbar({ : branchNames; // ── Mutations ───────────────────────────────────────────────────────── - const checkoutMutation = useMutation({ - ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), - onError: (error) => { - setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); - setIsBranchMenuOpen(true); - }, - }); + const checkoutMutation = useMutation( + gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), + ); - const createBranchMutation = useMutation({ - ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), - onError: (error) => - setThreadError(error instanceof Error ? error.message : "Failed to create branch."), - }); + const createBranchMutation = useMutation( + gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), + ); // ── Effects ─────────────────────────────────────────────────────────── @@ -159,6 +153,10 @@ export default function BranchToolbar({ setIsBranchMenuOpen(false); onComposerFocusRequest?.(); }, + onError: (error) => { + setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); + setIsBranchMenuOpen(true); + }, }); }; @@ -173,6 +171,9 @@ export default function BranchToolbar({ setIsBranchMenuOpen(false); onComposerFocusRequest?.(); }, + onError: (error) => { + setThreadError(error instanceof Error ? error.message : "Failed to create branch."); + }, }); }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 991e27cd10577625b7f8bcec984a680d40819dce..ab042183a5f30244d9cb385e0879096e3fbcc2f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,8 +27,10 @@ import { useState, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; -import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; +import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { serverKeybindingsQueryOptions } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; @@ -340,7 +342,6 @@ export default function ChatView() { const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerCursor, setComposerCursor] = useState(0); - const [debouncedPathQuery, setDebouncedPathQuery] = useState(""); const [composerMenuIndex, setComposerMenuIndex] = useState(0); const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); const messagesScrollRef = useRef(null); @@ -462,47 +463,23 @@ export default function ChatView() { const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; + const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( + pathTriggerQuery, + { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, + (debouncerState) => ({ isPending: debouncerState.isPending }), + ); + const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); - const keybindingsQuery = useQuery({ - ...serverConfigQueryOptions(api), - select: (config) => config.keybindings, - }); - useEffect(() => { - if (!isPathTrigger) { - setDebouncedPathQuery(""); - return; - } - if (pathTriggerQuery.length === 0) { - setDebouncedPathQuery(""); - return; - } - const timer = window.setTimeout(() => { - setDebouncedPathQuery(pathTriggerQuery); - }, COMPOSER_PATH_QUERY_DEBOUNCE_MS); - return () => { - window.clearTimeout(timer); - }; - }, [isPathTrigger, pathTriggerQuery]); - const workspaceEntriesQuery = useQuery({ - queryKey: [ - "composer-workspace-entries", - gitCwd, - debouncedPathQuery, - ], - enabled: Boolean(api && gitCwd && isPathTrigger), - staleTime: 15_000, - placeholderData: (previous) => previous, - queryFn: async () => { - if (!api || !gitCwd || !isPathTrigger) { - return { entries: [], truncated: false }; - } - return api.projects.searchEntries({ - cwd: gitCwd, - query: debouncedPathQuery, - limit: 80, - }); - }, - }); + const keybindingsQuery = useQuery(serverKeybindingsQueryOptions(api)); + const workspaceEntriesQuery = useQuery( + projectSearchEntriesQueryOptions({ + api, + cwd: gitCwd, + query: effectivePathQuery, + enabled: isPathTrigger, + limit: 80, + }), + ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -1301,7 +1278,10 @@ export default function ChatView() { setComposerMenuIndex(index); }, []); const isComposerMenuLoading = - composerTriggerKind === "path" && (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); + composerTriggerKind === "path" && + ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + workspaceEntriesQuery.isLoading || + workspaceEntriesQuery.isFetching); const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { setPrompt(nextPrompt); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2c29a6da4fef1e01c1bc0e2696def26ef5e29a8a..4beeff38c1fe65b6ee6ccc8a5ec6a286645c4ffd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -16,7 +16,7 @@ import { } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { serverKeybindingsQueryOptions } from "../lib/serverReactQuery"; import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -118,10 +118,7 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); - const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ - ...serverConfigQueryOptions(api), - select: (config) => config.keybindings, - }); + const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery(serverKeybindingsQueryOptions(api)); const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation( gitRemoveWorktreeMutationOptions({ api, queryClient }), diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fa917e96c0b3e6f4c48438d064f99be7c0f8bd3 --- /dev/null +++ b/apps/web/src/lib/projectReactQuery.ts @@ -0,0 +1,42 @@ +import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts"; +import { queryOptions } from "@tanstack/react-query"; + +export const projectQueryKeys = { + all: ["projects"] as const, + searchEntries: (cwd: string | null, query: string, limit: number) => + ["projects", "search-entries", cwd, query, limit] as const, +}; + +const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; +const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000; +const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { + entries: [], + truncated: false, +}; + +export function projectSearchEntriesQueryOptions(input: { + api: NativeApi | undefined; + cwd: string | null; + query: string; + enabled?: boolean; + limit?: number; + staleTime?: number; +}) { + const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; + return queryOptions({ + queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), + queryFn: async () => { + if (!input.api || !input.cwd) { + throw new Error("Workspace entry search is unavailable."); + } + return input.api.projects.searchEntries({ + cwd: input.cwd, + query: input.query, + limit, + }); + }, + enabled: input.enabled ?? Boolean(input.api && input.cwd), + staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, + placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, + }); +} diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 8324cd33ef6bfdf0993a3eb9f078b0cc42d9a317..835687a6cd6e9179c99bc9f396b1aabb0b41889f 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -19,3 +19,10 @@ export function serverConfigQueryOptions(api: NativeApi | undefined) { staleTime: Infinity, }); } + +export function serverKeybindingsQueryOptions(api: NativeApi | undefined) { + return queryOptions({ + ...serverConfigQueryOptions(api), + select: (config) => config.keybindings, + }); +} diff --git a/bun.lock b/bun.lock index 070abdca67845430f7fe783de465385a043ba9e5..62e324ce832a591f60eba75e4cb3746c45f01836 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "dependencies": { "@base-ui/react": "^1.2.0", "@t3tools/contracts": "workspace:*", + "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", @@ -425,10 +426,20 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + + "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], + + "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], -- 2.54.0