1import {
2 EnvironmentId,
3 OrchestrationSearchThreadsInput,
4 type OrchestrationSearchThreadsResult,
5 type OrchestrationThreadSearchMatch,
6} from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import { AsyncResult, Atom } from "effect/reactivity";
10
11export interface EnvironmentThreadSearchMatch extends OrchestrationThreadSearchMatch {
12 readonly environmentId: EnvironmentId;
13}
14
15export interface ThreadSearchResultsState {
16 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
17 readonly isLoading: boolean;
18}
19
20const ThreadSearchKey = Schema.fromJsonString(
21 Schema.Tuple([Schema.Array(EnvironmentId), OrchestrationSearchThreadsInput.fields.query]),
22);
23const decodeThreadSearchKey = Schema.decodeUnknownOption(ThreadSearchKey);
24
25export function makeThreadSearchKey(
26 environmentIds: ReadonlyArray<EnvironmentId>,
27 query: string,
28): string {
29 return JSON.stringify([
30 [...environmentIds].sort((left, right) => left.localeCompare(right)),
31 query,
32 ]);
33}
34
35function parseThreadSearchKey(key: string) {
36 return decodeThreadSearchKey(key);
37}
38
39export function threadSearchMatchKey(
40 match: Pick<EnvironmentThreadSearchMatch, "environmentId" | "threadId">,
41): string {
42 return JSON.stringify([match.environmentId, match.threadId]);
43}
44
45/**
46 * Combines one search query atom per environment. Invalid search keys, failed
47 * requests, and disconnected environments contribute no content matches,
48 * preserving local title search as the compatibility fallback.
49 */
50export function createThreadSearchResultsAtomFamily<E>(options: {
51 readonly getSearchAtom: (
52 environmentId: EnvironmentId,
53 query: string,
54 ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationSearchThreadsResult, E>>;
55 readonly labelPrefix: string;
56}) {
57 return Atom.family((key: string) =>
58 Atom.make((get): ThreadSearchResultsState => {
59 const parsedKey = parseThreadSearchKey(key);
60 if (Option.isNone(parsedKey)) {
61 return { matches: [], isLoading: false };
62 }
63
64 const [environmentIds, query] = parsedKey.value;
65 const matches: EnvironmentThreadSearchMatch[] = [];
66 let isLoading = false;
67
68 for (const environmentId of environmentIds) {
69 const result = get(options.getSearchAtom(environmentId, query));
70 isLoading ||= result.waiting;
71 const value = Option.getOrNull(AsyncResult.value(result));
72 if (value !== null) {
73 matches.push(
74 ...value.matches.map((match) => ({
75 ...match,
76 environmentId,
77 })),
78 );
79 }
80 }
81
82 return { matches, isLoading };
83 }).pipe(Atom.withLabel(`${options.labelPrefix}:${key}`)),
84 );
85}