import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; import { createThreadSearchResultsAtomFamily, makeThreadSearchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { useAtomValue } from "@effect/atom-react"; import * as Option from "effect/Option"; import { Atom } from "effect/unstable/reactivity"; import { useEffect, useMemo, useState } from "react"; import { orchestrationEnvironment } from "./orchestration"; import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery, type CheckpointDiffTarget, } from "./queryTargets"; const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; const COMPOSER_PATH_SEARCH_LIMIT = 20; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, isLoading: false, }).pipe(Atom.withLabel("mobile:thread-search:empty")); const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ getSearchAtom: (environmentId, query) => orchestrationEnvironment.threadSearch({ environmentId, input: { query }, }), labelPrefix: "mobile:thread-search", }); export interface ThreadDetailView { readonly data: OrchestrationThread | null; readonly error: string | null; readonly isPending: boolean; readonly isDeleted: boolean; } export interface ComposerPathSearchTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; readonly query: string | null; } function useDebouncedValue(value: A, delayMs: number): A { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebounced(value); }, delayMs); return () => { clearTimeout(timer); }; }, [delayMs, value]); return debounced; } export function useThreadSearch( environmentIds: ReadonlyArray, query: string, ): { readonly matches: ReadonlyArray; readonly isPending: boolean; } { const normalizedQuery = query.trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS); const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2; const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null; const searchKey = useMemo( () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)), [environmentIds, settledQuery], ); const result = useAtomValue( searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey), ); const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; return { matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, isPending: canSearch && (isDebouncing || result.isLoading), }; } export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, ): ThreadDetailView { const state = useEnvironmentThread(environmentId, threadId); return { data: Option.getOrNull(state.data), error: Option.getOrNull(state.error), isPending: state.status === "synchronizing", isDeleted: state.status === "deleted", }; } export function useBranches(input: { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; readonly query?: string | null; }) { const query = input.query?.trim() ?? ""; return useEnvironmentQuery( input.environmentId !== null && input.cwd !== null ? vcsEnvironment.listRefs({ environmentId: input.environmentId, input: { cwd: input.cwd, ...(query.length > 0 ? { query } : {}), limit: VCS_REF_LIST_LIMIT, }, }) : null, ); } export function useComposerPathSearch(target: ComposerPathSearchTarget) { const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, query: normalizeComposerPathSearchQuery(target.query), }), [target.cwd, target.environmentId, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && debouncedTarget.query.length > 0 ? projectEnvironment.searchEntries({ environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, limit: COMPOSER_PATH_SEARCH_LIMIT, }, }) : null, ); return { entries: result.data?.entries ?? [], error: result.error, isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, refresh: result.refresh, }; } export function useCheckpointDiff(target: CheckpointDiffTarget) { const targets = useMemo( () => buildCheckpointDiffTargets(target), [ target.environmentId, target.fromTurnCount, target.ignoreWhitespace, target.threadId, target.toTurnCount, ], ); const fullThread = useEnvironmentQuery( targets.fullThread === null ? null : orchestrationEnvironment.fullThreadDiff(targets.fullThread), ); const turn = useEnvironmentQuery( targets.turn === null ? null : orchestrationEnvironment.turnDiff(targets.turn), ); return targets.fullThread === null ? turn : fullThread; }