Skip to content
Source
Sign inSign up

queries.ts

484 lines17.7 KB

1import { useAtomValue } from "@effect/atom-react";
2import {
3 type CheckpointDiffTarget,
4 type ComposerPathSearchTarget,
5} from "@t3tools/client-runtime/state/threads";
6import {
7 createThreadSearchResultsAtomFamily,
8 makeThreadSearchKey,
9 visibleThreadSearch,
10 type EnvironmentThreadSearchMatch,
11 type SettledThreadSearch,
12 type ThreadSearchResultsState,
13 type ThreadSearchScope,
14} from "@t3tools/client-runtime/state/thread-search";
15import {
16 PROJECT_SEARCH_CONTENTS_QUERY_MAX_LENGTH,
17 THREAD_SEARCH_QUERY_MAX_LENGTH,
18} from "@t3tools/contracts";
19import { messageSearchTokens } from "@t3tools/shared/paletteSearch";
20import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
21import type {
22 EnvironmentId,
23 OrchestrationV2GetTurnItemResult,
24 ProjectContentMatch,
25 ProjectEntry,
26 ProjectEntryKind,
27 ThreadId,
28 TurnItemId,
29 VcsListRefsResult,
30 VcsRef,
31} from "@t3tools/contracts";
32import * as Cause from "effect/Cause";
33import { AsyncResult, Atom } from "effect/reactivity";
34import { useEffect, useState } from "react";
35​
36import { appAtomRegistry } from "../rpc/atomRegistry";
37import { useFilesystemReadAccess } from "./filesystem";
38import { orchestrationEnvironment } from "./orchestration";
39import { olderSettledThreads, threadFamilies } from "./threads";
40import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";
41import { projectContentSearch, projectEnvironment } from "./projects";
42import { useEnvironmentQuery } from "./query";
43import { vcsEnvironment } from "./vcs";
44​
45const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120;
46const COMPOSER_PATH_SEARCH_LIMIT = 80;
47const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120;
48const PROJECT_CONTENT_SEARCH_LIMIT = 500;
49const THREAD_SEARCH_DEBOUNCE_MS = 120;
50const VCS_REF_LIST_LIMIT = 100;
51const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
52const EMPTY_PROJECT_ENTRIES: ReadonlyArray<ProjectEntry> = [];
53const EMPTY_CONTENT_MATCHES: ReadonlyArray<ProjectContentMatch> = [];
54const INITIAL_BRANCH_CURSORS = [undefined] as const;
55const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
56const EMPTY_THREAD_SEARCH_ATOM = Atom.make<ThreadSearchResultsState>({
57 matches: EMPTY_THREAD_SEARCH_MATCHES,
58 isLoading: false,
59 rows: [],
60}).pipe(Atom.withLabel("web:thread-search:empty"));
61​
62const olderSettledBeforeAtom = Atom.family((environmentId: EnvironmentId) =>
63 Atom.make((get) => get(olderSettledThreads.olderSettledAtom(environmentId))?.before ?? null).pipe(
64 Atom.withLabel(`web:older-settled-before:${environmentId}`),
65 ),
66);
67// Whether any of these environments leaves older settled threads out, so short queries matter.
68const anyOlderSettledAtom = Atom.family((environmentIdsKey: string) =>
69 Atom.make((get) =>
70 (JSON.parse(environmentIdsKey) as ReadonlyArray<EnvironmentId>).some(
71 (environmentId) => get(olderSettledBeforeAtom(environmentId)) !== null,
72 ),
73 ).pipe(Atom.withLabel(`web:any-older-settled:${environmentIdsKey}`)),
74);
75const threadSearch = createThreadSearchResultsAtomFamily({
76 getSearchAtom: (environmentId, input) =>
77 input.olderSettledBefore === undefined
78 ? orchestrationEnvironment.threadSearch({ environmentId, input })
79 : olderSettledThreads.searchAtom(environmentId, input),
80 olderSettledBefore: olderSettledBeforeAtom,
81 labelPrefix: "web:thread-search",
82});
83​
84export function useDebouncedValue<A>(value: A, delayMs: number): A {
85 const [debounced, setDebounced] = useState(value);
86​
87 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- debounce timer that publishes the value after delayMs
88 useEffect(() => {
89 const timer = window.setTimeout(() => {
90 setDebounced(value);
91 }, delayMs);
92 return () => {
93 window.clearTimeout(timer);
94 };
95 }, [delayMs, value]);
96​
97 return debounced;
99​
100/**
101 * Server matches for a thread search: message hits, and on environments that leave older settled
102 * threads out of the shell, the rows of the best older matches, mounted into the thread list while
103 * they show so the caller's own ranking finds them.
104 */
105export function useThreadSearch(
106 environmentIds: ReadonlyArray<EnvironmentId>,
107 query: string,
108 scope: ThreadSearchScope = {},
109): {
110 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
111 readonly query: string;
112 readonly isPending: boolean;
113} {
114 // A pasted wall of text searches by its start rather than failing the request.
115 const normalizedQuery = query.trim().slice(0, THREAD_SEARCH_QUERY_MAX_LENGTH).trim();
116 const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS);
117 const anyOlderSettled = useAtomValue(anyOlderSettledAtom(JSON.stringify(environmentIds)));
118 // Messages need a word of three or more letters; older settled titles match from two.
119 const canSearch =
120 environmentIds.length > 0 &&
121 ((scope.titlesOnly !== true && messageSearchTokens(normalizedQuery).long.length > 0) ||
122 (anyOlderSettled && normalizedQuery.length >= 2));
123 const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null;
124 const searchKeyOf = (searchQuery: string) =>
125 makeThreadSearchKey(environmentIds, searchQuery, scope);
126 const searchKey = settledQuery === null ? null : searchKeyOf(settledQuery);
127 const result = useAtomValue(
128 searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearch.results(searchKey),
129 );
130 const isPending = canSearch && (normalizedQuery !== debouncedQuery || result.isLoading);
131 // What fast environments returned shows without waiting for a slow one;
132 // callers keep shown rows in place with `nextShownOrder`.
133 const ready =
134 settledQuery !== null &&
135 (!result.isLoading || result.matches.length > 0 || result.rows.length > 0)
136 ? { query: settledQuery, matches: result.matches }
137 : null;
138 // Kept from earlier renders: React's pattern for information from previous renders.
139 const [previous, setPrevious] = useState<SettledThreadSearch | null>(null);
140 if (ready !== null && previous?.matches !== ready.matches) setPrevious(ready);
141 const visible = canSearch
142 ? visibleThreadSearch(normalizedQuery, ready, previous)
143 : { query: "", matches: EMPTY_THREAD_SEARCH_MATCHES };
144 // The shown results' rows stay mounted until newer results replace them.
145 const shownKey = visible.query === "" ? null : searchKeyOf(visible.query);
146 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the shown search results' rows into the thread family registry until newer results replace them
147 useEffect(() => {
148 if (shownKey === null) return;
149 const environments = (JSON.parse(shownKey) as [ReadonlyArray<EnvironmentId>])[0];
150 const releases = environments.map((environmentId) =>
151 threadFamilies.mountRows(
152 appAtomRegistry,
153 environmentId,
154 `search:${shownKey}`,
155 threadSearch.rows(shownKey, environmentId),
156 ),
157 );
158 return () => {
159 for (const release of releases) release();
160 };
161 }, [shownKey]);
162 return {
163 matches: visible.matches,
164 query: visible.query,
165 isPending,
166 };
168​
169function vcsRefPageAtom(targetKey: string, cursor: number | undefined) {
170 const [environmentId, cwd, query] = JSON.parse(targetKey) as [EnvironmentId, string, string];
171 return vcsEnvironment.listRefs({
172 environmentId,
173 input: {
174 cwd,
175 ...(query.length > 0 ? { query } : {}),
176 ...(cursor === undefined ? {} : { cursor }),
177 limit: VCS_REF_LIST_LIMIT,
178 },
179 });
181​
182const EMPTY_VCS_REF_PAGES_ATOM = Atom.make<
183 ReadonlyArray<Atom.Type<ReturnType<typeof vcsRefPageAtom>>>
184>([]).pipe(Atom.withLabel("web:vcs-ref-pages:empty"));
185​
186const vcsRefPagesAtom = Atom.family((pagesKey: string) => {
187 const [targetKey, cursors] = JSON.parse(pagesKey) as [string, Array<number | null>];
188 const pageAtoms = cursors.map((cursor) => vcsRefPageAtom(targetKey, cursor ?? undefined));
189 return Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe(
190 Atom.withLabel(`web:vcs-ref-pages:${pagesKey}`),
191 );
192});
193​
194export function usePaginatedBranches(target: VcsRefTarget) {
195 // Leave unkeyed; mismatches already reset cursors, and a seeded key would break caching across the hook.
196 const [pagination, setPagination] = useState<{
197 readonly targetKey: string | null;
198 readonly cursors: ReadonlyArray<number | undefined>;
199 }>({
200 targetKey: null,
201 cursors: INITIAL_BRANCH_CURSORS,
202 });
203 const { environmentId, cwd } = target;
204 const query = target.query?.trim() ?? "";
205 const targetKey =
206 environmentId !== null && cwd !== null ? JSON.stringify([environmentId, cwd, query]) : null;
207 const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS;
208 const pagesKey = targetKey === null ? null : JSON.stringify([targetKey, cursors]);
209 const results = useAtomValue(
210 pagesKey === null ? EMPTY_VCS_REF_PAGES_ATOM : vcsRefPagesAtom(pagesKey),
211 );
212 const values = results.flatMap((result) => {
213 const value = AsyncResult.getOrElse(result, () => null);
214 return value === null ? [] : [value];
215 });
216 const refs = new Map<string, VcsRef>();
217 for (const value of values) {
218 for (const ref of value.refs) {
219 refs.set(ref.name, ref);
220 }
221 }
222 const first = values[0] ?? null;
223 const last = values.at(-1) ?? null;
224 const data: VcsListRefsResult | null =
225 first === null || last === null
226 ? null
227 : {
228 refs: [...refs.values()],
229 isRepo: first.isRepo,
230 hasPrimaryRemote: first.hasPrimaryRemote,
231 nextCursor: last.nextCursor,
232 totalCount: Math.max(...values.map((value) => value.totalCount)),
233 };
234 const failed = results.find((result) => result._tag === "Failure");
235 const isFetchingNextPage = isPaginatedBranchesNextPagePending(results);
236 const error =
237 failed?._tag === "Failure"
238 ? (() => {
239 const cause = Cause.squash(failed.cause);
240 return cause instanceof Error && cause.message.trim().length > 0
241 ? cause.message
242 : "Failed to load refs.";
243 })()
244 : null;
245 const refresh = () => {
246 setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS });
247 if (targetKey !== null) {
248 appAtomRegistry.refresh(vcsRefPageAtom(targetKey, undefined));
249 }
250 };
251 const nextCursor = data?.nextCursor;
252 const loadNext = () => {
253 if (targetKey === null || nextCursor === null || nextCursor === undefined) {
254 return;
255 }
256 setPagination((current) => {
257 const currentCursors =
258 current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS;
259 return currentCursors.includes(nextCursor)
260 ? { targetKey, cursors: currentCursors }
261 : { targetKey, cursors: [...currentCursors, nextCursor] };
262 });
263 };
264​
265 return {
266 data,
267 refs: data?.refs ?? EMPTY_REFS,
268 error,
269 isPending: results.some((result) => result.waiting),
270 isFetchingNextPage,
271 refresh,
272 loadNext,
273 };
275​
276type ProjectPathSearchTarget = ComposerPathSearchTarget & {
277 readonly kind?: ProjectEntryKind | undefined;
278 readonly imageOnly?: boolean | undefined;
279};
280​
281function areProjectPathSearchTargetsEqual(
282 left: ProjectPathSearchTarget,
283 right: ProjectPathSearchTarget,
284): boolean {
285 return (
286 left.environmentId === right.environmentId &&
287 left.cwd === right.cwd &&
288 left.query === right.query &&
289 left.kind === right.kind &&
290 left.imageOnly === right.imageOnly
291 );
293​
294export function useProjectPathSearch(
295 target: ProjectPathSearchTarget,
296 limit: number,
297 options?: { readonly allowEmptyQuery?: boolean },
298) {
299 const allowEmptyQuery = options?.allowEmptyQuery === true;
300 const normalizedTarget = {
301 environmentId: target.environmentId,
302 cwd: target.cwd,
303 query: target.query == null ? null : target.query.trim(),
304 kind: target.kind,
305 imageOnly: target.imageOnly,
306 };
307 const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);
308 const fileAccess = useFilesystemReadAccess(debouncedTarget.environmentId);
309 const { canReadFiles } = fileAccess;
310 const searchTarget =
311 debouncedTarget.environmentId !== null &&
312 debouncedTarget.cwd !== null &&
313 debouncedTarget.query !== null &&
314 (allowEmptyQuery || debouncedTarget.query.length > 0)
315 ? {
316 environmentId: debouncedTarget.environmentId,
317 input: {
318 cwd: debouncedTarget.cwd,
319 query: debouncedTarget.query.slice(0, 256).replace(/[\uD800-\uDBFF]$/, ""),
320 limit,
321 ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
322 ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}),
323 },
324 }
325 : null;
326 const result = useEnvironmentQuery(
327 canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null,
328 );
329 const hasTarget = searchTarget !== null;
330​
331 return {
332 entries: result.data?.entries ?? EMPTY_PROJECT_ENTRIES,
333 error:
334 !hasTarget || fileAccess.isPending
335 ? null
336 : canReadFiles
337 ? result.error
338 : (fileAccess.error ?? "This connection cannot search host files."),
339 isPending:
340 !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) ||
341 (hasTarget && (fileAccess.isPending || result.isPending)),
342 searchedTarget: debouncedTarget,
343 searchedQuery: debouncedTarget.query ?? "",
344 truncated: result.data?.truncated ?? false,
345 refresh: result.refresh,
346 };
348​
349export function useComposerPathSearch(target: ComposerPathSearchTarget) {
350 return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT);
352​
353interface ProjectContentSearchTarget {
354 readonly environmentId: EnvironmentId | null;
355 readonly cwd: string | null;
356 readonly query: string;
357 readonly caseSensitive: boolean;
358 readonly wholeWord: boolean;
359 readonly useRegex: boolean;
361​
362export function useProjectContentSearch(target: ProjectContentSearchTarget) {
363 const hasTarget = target.environmentId !== null && target.cwd !== null;
364 const fileAccess = useFilesystemReadAccess(target.environmentId);
365 const canReadFiles = hasTarget && fileAccess.canReadFiles;
366 const isCheckingAccess = hasTarget && fileAccess.isPending;
367 // Preserve query whitespace; trim only to detect blank input.
368 const query = target.query;
369 const hasQuery = query.trim().length > 0;
370 const queryTooLong = query.length > PROJECT_SEARCH_CONTENTS_QUERY_MAX_LENGTH;
371 const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS);
372 const result = useEnvironmentQuery(
373 canReadFiles &&
374 target.environmentId !== null &&
375 target.cwd !== null &&
376 hasQuery &&
377 !queryTooLong &&
378 debouncedQuery.trim().length > 0 &&
379 debouncedQuery.length <= PROJECT_SEARCH_CONTENTS_QUERY_MAX_LENGTH
380 ? projectContentSearch({
381 environmentId: target.environmentId,
382 input: {
383 cwd: target.cwd,
384 query: debouncedQuery,
385 limit: PROJECT_CONTENT_SEARCH_LIMIT,
386 caseSensitive: target.caseSensitive,
387 wholeWord: target.wholeWord,
388 useRegex: target.useRegex,
389 },
390 })
391 : null,
392 );
393​
394 return {
395 canReadFiles,
396 isCheckingAccess,
397 matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES,
398 error:
399 !hasTarget || isCheckingAccess
400 ? null
401 : !canReadFiles
402 ? (fileAccess.error ?? "This connection cannot search host files.")
403 : queryTooLong
404 ? `Search is limited to ${PROJECT_SEARCH_CONTENTS_QUERY_MAX_LENGTH} characters.`
405 : result.error,
406 isPending:
407 isCheckingAccess ||
408 (canReadFiles && hasQuery && !queryTooLong && (query !== debouncedQuery || result.isPending)),
409 hasQuery,
410 truncated: result.data?.truncated ?? false,
411 invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined,
412 };
414​
415export function useCheckpointDiff(
416 target: CheckpointDiffTarget,
417 options?: { readonly enabled?: boolean },
418) {
419 const enabled =
420 options?.enabled !== false &&
421 target.environmentId !== null &&
422 target.threadId !== null &&
423 target.fromTurnCount !== null &&
424 target.toTurnCount !== null;
425 const fullThreadTarget =
426 enabled && target.fromTurnCount === 0
427 ? {
428 environmentId: target.environmentId!,
429 input: {
430 threadId: target.threadId!,
431 toTurnCount: target.toTurnCount!,
432 ignoreWhitespace: target.ignoreWhitespace,
433 },
434 }
435 : null;
436 const turnTarget =
437 enabled && target.fromTurnCount !== 0
438 ? {
439 environmentId: target.environmentId!,
440 input: {
441 threadId: target.threadId!,
442 fromTurnCount: target.fromTurnCount!,
443 toTurnCount: target.toTurnCount!,
444 ignoreWhitespace: target.ignoreWhitespace,
445 },
446 }
447 : null;
448 const fullThread = useEnvironmentQuery(
449 fullThreadTarget === null ? null : orchestrationEnvironment.fullThreadDiff(fullThreadTarget),
450 );
451 const turn = useEnvironmentQuery(
452 turnTarget === null ? null : orchestrationEnvironment.turnDiff(turnTarget),
453 );
454 return fullThreadTarget === null ? turn : fullThread;
456​
457/** Reads one item's detail from somewhere other than the environment, as the guest page does. */
458export type TurnItemDetailSource = (input: {
459 readonly threadId: ThreadId;
460 readonly itemId: TurnItemId;
461 readonly revision: string;
462}) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationV2GetTurnItemResult, unknown>>;
463​
464export function useTurnItemDetail(
465 target: {
466 readonly environmentId: EnvironmentId;
467 readonly threadId: ThreadId;
468 readonly itemId: TurnItemId;
469 readonly revision: string;
470 } | null,
471 source?: TurnItemDetailSource,
472) {
473 const input =
474 target === null
475 ? null
476 : { threadId: target.threadId, itemId: target.itemId, revision: target.revision };
477 return useEnvironmentQuery<OrchestrationV2GetTurnItemResult, unknown>(
478 target === null || input === null
479 ? null
480 : source
481 ? source(input)
482 : orchestrationEnvironment.turnItem({ environmentId: target.environmentId, input }),
483 );