authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 13:04:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 19:47:09-08:00
log1b409c39468298a2a0c01d988fc2d90340877f13
treea72fb92ead04015ce31fdee38c395451d76ee30d
parent60ed67e5e1e5b983c0d6f0315baeb98b6372daea

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

7 files changed, 99 insertions(+), 60 deletions(-)

apps/web/package.json+1
...@@ -13,6 +13,7 @@...@@ -13,6 +13,7 @@
13 "dependencies": {13 "dependencies": {
14 "@base-ui/react": "^1.2.0",14 "@base-ui/react": "^1.2.0",
15 "@t3tools/contracts": "workspace:*",15 "@t3tools/contracts": "workspace:*",
16 "@tanstack/react-pacer": "^0.19.4",
16 "@tanstack/react-query": "^5.90.0",17 "@tanstack/react-query": "^5.90.0",
17 "@xterm/addon-fit": "^0.11.0",18 "@xterm/addon-fit": "^0.11.0",
18 "@xterm/xterm": "^6.0.0",19 "@xterm/xterm": "^6.0.0",
apps/web/src/components/BranchToolbar.tsx+13-12
...@@ -68,19 +68,13 @@ export default function BranchToolbar({...@@ -68,19 +68,13 @@ export default function BranchToolbar({
68 : branchNames;68 : branchNames;
69 // ── Mutations ─────────────────────────────────────────────────────────69 // ── Mutations ─────────────────────────────────────────────────────────
7070
71 const checkoutMutation = useMutation({71 const checkoutMutation = useMutation(
72 ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),72 gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
73 onError: (error) => {73 );
74 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
75 setIsBranchMenuOpen(true);
76 },
77 });
7874
79 const createBranchMutation = useMutation({75 const createBranchMutation = useMutation(
80 ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),76 gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
81 onError: (error) =>77 );
82 setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
83 });
8478
85 // ── Effects ───────────────────────────────────────────────────────────79 // ── Effects ───────────────────────────────────────────────────────────
8680
...@@ -159,6 +153,10 @@ export default function BranchToolbar({...@@ -159,6 +153,10 @@ export default function BranchToolbar({
159 setIsBranchMenuOpen(false);153 setIsBranchMenuOpen(false);
160 onComposerFocusRequest?.();154 onComposerFocusRequest?.();
161 },155 },
156 onError: (error) => {
157 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
158 setIsBranchMenuOpen(true);
159 },
162 });160 });
163 };161 };
164162
...@@ -173,6 +171,9 @@ export default function BranchToolbar({...@@ -173,6 +171,9 @@ export default function BranchToolbar({
173 setIsBranchMenuOpen(false);171 setIsBranchMenuOpen(false);
174 onComposerFocusRequest?.();172 onComposerFocusRequest?.();
175 },173 },
174 onError: (error) => {
175 setThreadError(error instanceof Error ? error.message : "Failed to create branch.");
176 },
176 });177 });
177 };178 };
178179
apps/web/src/components/ChatView.tsx+23-43
...@@ -27,8 +27,10 @@ import {...@@ -27,8 +27,10 @@ import {
27 useState,27 useState,
28} from "react";28} from "react";
29import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";29import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
30import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
30import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";31import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
31import { serverConfigQueryOptions } from "~/lib/serverReactQuery";32import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
33import { serverKeybindingsQueryOptions } from "~/lib/serverReactQuery";
3234
33import { isElectron } from "../env";35import { isElectron } from "../env";
34import { buildBootstrapInput } from "../historyBootstrap";36import { buildBootstrapInput } from "../historyBootstrap";
...@@ -340,7 +342,6 @@ export default function ChatView() {...@@ -340,7 +342,6 @@ export default function ChatView() {
340 const [nowTick, setNowTick] = useState(() => Date.now());342 const [nowTick, setNowTick] = useState(() => Date.now());
341 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);343 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
342 const [composerCursor, setComposerCursor] = useState(0);344 const [composerCursor, setComposerCursor] = useState(0);
343 const [debouncedPathQuery, setDebouncedPathQuery] = useState("");
344 const [composerMenuIndex, setComposerMenuIndex] = useState(0);345 const [composerMenuIndex, setComposerMenuIndex] = useState(0);
345 const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false);346 const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false);
346 const messagesScrollRef = useRef<HTMLDivElement>(null);347 const messagesScrollRef = useRef<HTMLDivElement>(null);
...@@ -462,47 +463,23 @@ export default function ChatView() {...@@ -462,47 +463,23 @@ export default function ChatView() {
462 const composerTriggerKind = composerTrigger?.kind ?? null;463 const composerTriggerKind = composerTrigger?.kind ?? null;
463 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";464 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
464 const isPathTrigger = composerTriggerKind === "path";465 const isPathTrigger = composerTriggerKind === "path";
466 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
467 pathTriggerQuery,
468 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
469 (debouncerState) => ({ isPending: debouncerState.isPending }),
470 );
471 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
465 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));472 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
466 const keybindingsQuery = useQuery({473 const keybindingsQuery = useQuery(serverKeybindingsQueryOptions(api));
467 ...serverConfigQueryOptions(api),474 const workspaceEntriesQuery = useQuery(
468 select: (config) => config.keybindings,475 projectSearchEntriesQueryOptions({
469 });476 api,
470 useEffect(() => {477 cwd: gitCwd,
471 if (!isPathTrigger) {478 query: effectivePathQuery,
472 setDebouncedPathQuery("");479 enabled: isPathTrigger,
473 return;480 limit: 80,
474 }481 }),
475 if (pathTriggerQuery.length === 0) {482 );
476 setDebouncedPathQuery("");
477 return;
478 }
479 const timer = window.setTimeout(() => {
480 setDebouncedPathQuery(pathTriggerQuery);
481 }, COMPOSER_PATH_QUERY_DEBOUNCE_MS);
482 return () => {
483 window.clearTimeout(timer);
484 };
485 }, [isPathTrigger, pathTriggerQuery]);
486 const workspaceEntriesQuery = useQuery({
487 queryKey: [
488 "composer-workspace-entries",
489 gitCwd,
490 debouncedPathQuery,
491 ],
492 enabled: Boolean(api && gitCwd && isPathTrigger),
493 staleTime: 15_000,
494 placeholderData: (previous) => previous,
495 queryFn: async () => {
496 if (!api || !gitCwd || !isPathTrigger) {
497 return { entries: [], truncated: false };
498 }
499 return api.projects.searchEntries({
500 cwd: gitCwd,
501 query: debouncedPathQuery,
502 limit: 80,
503 });
504 },
505 });
506 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;483 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
507 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {484 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
508 if (!composerTrigger) return [];485 if (!composerTrigger) return [];
...@@ -1301,7 +1278,10 @@ export default function ChatView() {...@@ -1301,7 +1278,10 @@ export default function ChatView() {
1301 setComposerMenuIndex(index);1278 setComposerMenuIndex(index);
1302 }, []);1279 }, []);
1303 const isComposerMenuLoading =1280 const isComposerMenuLoading =
1304 composerTriggerKind === "path" && (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching);1281 composerTriggerKind === "path" &&
1282 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
1283 workspaceEntriesQuery.isLoading ||
1284 workspaceEntriesQuery.isFetching);
13051285
1306 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {1286 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1307 setPrompt(nextPrompt);1287 setPrompt(nextPrompt);
apps/web/src/components/Sidebar.tsx+2-5
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
16} from "../types";16} from "../types";
17import { useNativeApi } from "../hooks/useNativeApi";17import { useNativeApi } from "../hooks/useNativeApi";
18import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";18import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
19import { serverConfigQueryOptions } from "../lib/serverReactQuery";19import { serverKeybindingsQueryOptions } from "../lib/serverReactQuery";
20import { toastManager } from "./ui/toast";20import { toastManager } from "./ui/toast";
21import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";21import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
2222
...@@ -118,10 +118,7 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null...@@ -118,10 +118,7 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
118export default function Sidebar() {118export default function Sidebar() {
119 const { state, dispatch } = useStore();119 const { state, dispatch } = useStore();
120 const api = useNativeApi();120 const api = useNativeApi();
121 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({121 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery(serverKeybindingsQueryOptions(api));
122 ...serverConfigQueryOptions(api),
123 select: (config) => config.keybindings,
124 });
125 const queryClient = useQueryClient();122 const queryClient = useQueryClient();
126 const removeWorktreeMutation = useMutation(123 const removeWorktreeMutation = useMutation(
127 gitRemoveWorktreeMutationOptions({ api, queryClient }),124 gitRemoveWorktreeMutationOptions({ api, queryClient }),
apps/web/src/lib/projectReactQuery.ts created+42
...@@ -0,0 +1,42 @@
1import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3
4export const projectQueryKeys = {
5 all: ["projects"] as const,
6 searchEntries: (cwd: string | null, query: string, limit: number) =>
7 ["projects", "search-entries", cwd, query, limit] as const,
8};
9
10const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
11const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000;
12const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
13 entries: [],
14 truncated: false,
15};
16
17export function projectSearchEntriesQueryOptions(input: {
18 api: NativeApi | undefined;
19 cwd: string | null;
20 query: string;
21 enabled?: boolean;
22 limit?: number;
23 staleTime?: number;
24}) {
25 const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;
26 return queryOptions({
27 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),
28 queryFn: async () => {
29 if (!input.api || !input.cwd) {
30 throw new Error("Workspace entry search is unavailable.");
31 }
32 return input.api.projects.searchEntries({
33 cwd: input.cwd,
34 query: input.query,
35 limit,
36 });
37 },
38 enabled: input.enabled ?? Boolean(input.api && input.cwd),
39 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
40 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
41 });
42}
apps/web/src/lib/serverReactQuery.ts+7
...@@ -19,3 +19,10 @@ export function serverConfigQueryOptions(api: NativeApi | undefined) {...@@ -19,3 +19,10 @@ export function serverConfigQueryOptions(api: NativeApi | undefined) {
19 staleTime: Infinity,19 staleTime: Infinity,
20 });20 });
21}21}
22
23export function serverKeybindingsQueryOptions(api: NativeApi | undefined) {
24 return queryOptions({
25 ...serverConfigQueryOptions(api),
26 select: (config) => config.keybindings,
27 });
28}
bun.lock+11
...@@ -53,6 +53,7 @@...@@ -53,6 +53,7 @@
53 "dependencies": {53 "dependencies": {
54 "@base-ui/react": "^1.2.0",54 "@base-ui/react": "^1.2.0",
55 "@t3tools/contracts": "workspace:*",55 "@t3tools/contracts": "workspace:*",
56 "@tanstack/react-pacer": "^0.19.4",
56 "@tanstack/react-query": "^5.90.0",57 "@tanstack/react-query": "^5.90.0",
57 "@xterm/addon-fit": "^0.11.0",58 "@xterm/addon-fit": "^0.11.0",
58 "@xterm/xterm": "^6.0.0",59 "@xterm/xterm": "^6.0.0",
...@@ -425,10 +426,20 @@...@@ -425,10 +426,20 @@
425426
426 "@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=="],427 "@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=="],
427428
429 "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
430
431 "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],
432
428 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],433 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
429434
435 "@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=="],
436
430 "@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=="],437 "@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=="],
431438
439 "@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=="],
440
441 "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
442
432 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],443 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
433444
434 "@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=="],445 "@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=="],