Skip to content
Source
Sign inSign up

threadSearch.ts

337 lines12.1 KB

1import {
2 EnvironmentId,
3 OrchestrationSearchThreadsInput,
4 ProjectId,
5 type OrchestrationThreadSearchMatch,
6 type OrchestrationV2SearchThreadsInput,
7 type OrchestrationV2SearchThreadsResult,
8 type OrchestrationV2ThreadFamily,
9} from "@t3tools/contracts";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import { AsyncResult, Atom } from "effect/reactivity";
13import {
14 foldSearchQuery,
15 messageSearchTokens,
16 THREAD_OPEN_DEDUPE_MS,
17 type ThreadSearchSubject,
18} from "@t3tools/shared/paletteSearch";
19import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests";
20​
21import type { EnvironmentThreadShell } from "./models.ts";
22import { getThreadSortTimestamp } from "./threadSort.ts";
23​
24// When this client last sent an open per thread, by its own clock: a server
25// timestamp would be off by any clock skew. The server merges opens closer
26// together than THREAD_OPEN_DEDUPE_MS anyway, so skipping them saves a command
27// and a broadcast while flipping between threads.
28const sentThreadOpens = new Map<string, number>();
29// When this client last opened each thread, sent or not, so recent threads
30// follow quick switches the server merges.
31const localThreadOpens = new Map<string, number>();
32let localThreadOpensVersion = 0;
33/**
34 * Records that this client opened a thread, and says whether to send the open
35 * to the server now.
36 */
37export function claimThreadOpen(environmentId: EnvironmentId, threadId: string, now: number) {
38 const key = `${environmentId}:${threadId}`;
39 localThreadOpens.set(key, now);
40 localThreadOpensVersion += 1;
41 if (now - (sentThreadOpens.get(key) ?? Number.NEGATIVE_INFINITY) < THREAD_OPEN_DEDUPE_MS) {
42 return false;
43 }
44 sentThreadOpens.set(key, now);
45 return true;
47​
48export interface EnvironmentThreadSearchMatch extends OrchestrationThreadSearchMatch {
49 readonly environmentId: EnvironmentId;
51​
52export interface ThreadSearchResultsState {
53 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
54 readonly isLoading: boolean;
55 /** Rows of matching older settled threads, which shells that omit them do not hold. */
56 readonly rows: ReadonlyArray<{
57 readonly environmentId: EnvironmentId;
58 readonly read: OrchestrationV2ThreadFamily;
59 }>;
61​
62/**
63 * How a search reaches environments whose shells omit older settled threads (others ignore it):
64 * the caller's project scope and its names for environments, which its own ranking matches, and
65 * whether to skip message search.
66 */
67export const ThreadSearchScope = Schema.Struct({
68 projectIds: Schema.optionalKey(Schema.Array(ProjectId)),
69 environmentLabels: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
70 titlesOnly: Schema.optionalKey(Schema.Boolean),
71});
72export type ThreadSearchScope = typeof ThreadSearchScope.Type;
73​
74const ThreadSearchKey = Schema.fromJsonString(
75 Schema.Union([
76 Schema.Tuple([Schema.Array(EnvironmentId), OrchestrationSearchThreadsInput.fields.query]),
77 Schema.Tuple([
78 Schema.Array(EnvironmentId),
79 OrchestrationSearchThreadsInput.fields.query,
80 ThreadSearchScope,
81 ]),
82 ]),
83);
84const decodeThreadSearchKey = Schema.decodeUnknownOption(ThreadSearchKey);
85const EMPTY_SEARCH_ROWS: ThreadSearchResultsState["rows"] = [];
86const EMPTY_READS: ReadonlyArray<OrchestrationV2ThreadFamily> = [];
87​
88export function makeThreadSearchKey(
89 environmentIds: ReadonlyArray<EnvironmentId>,
90 query: string,
91 scope: ThreadSearchScope = {},
92): string {
93 const sorted = [...environmentIds].sort((left, right) => left.localeCompare(right));
94 return JSON.stringify(Object.keys(scope).length === 0 ? [sorted, query] : [sorted, query, scope]);
96​
97function parseThreadSearchKey(key: string) {
98 return decodeThreadSearchKey(key);
100​
101export function threadSearchMatchKey(
102 match: Pick<EnvironmentThreadSearchMatch, "environmentId" | "threadId">,
103): string {
104 return JSON.stringify([match.environmentId, match.threadId]);
106​
107/**
108 * Message matches from every environment, plus, from environments whose shells omit older settled
109 * threads (`olderSettledBefore` is set), the rows of the best older matches. Callers mount those
110 * rows (`rows`) so their own ranking sees them, as it sees shell rows.
111 */
112export function createThreadSearchResultsAtomFamily<E>(options: {
113 readonly getSearchAtom: (
114 environmentId: EnvironmentId,
115 input: OrchestrationV2SearchThreadsInput,
116 ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationV2SearchThreadsResult, E>>;
117 readonly olderSettledBefore?: (environmentId: EnvironmentId) => Atom.Atom<string | null>;
118 readonly labelPrefix: string;
119}) {
120 const results = Atom.family((key: string) =>
121 Atom.make((get): ThreadSearchResultsState => {
122 const parsedKey = parseThreadSearchKey(key);
123 if (Option.isNone(parsedKey)) {
124 return { matches: [], isLoading: false, rows: EMPTY_SEARCH_ROWS };
125 }
126​
127 const [environmentIds, query, scope = {}] = parsedKey.value;
128 // The server needs a word of three or more letters to match messages.
129 const searchMessages =
130 scope.titlesOnly !== true && messageSearchTokens(query).long.length > 0;
131 const matches: EnvironmentThreadSearchMatch[] = [];
132 const rows: Array<ThreadSearchResultsState["rows"][number]> = [];
133 let isLoading = false;
134​
135 for (const environmentId of environmentIds) {
136 const before = options.olderSettledBefore
137 ? get(options.olderSettledBefore(environmentId))
138 : null;
139 if (!searchMessages && before === null) continue;
140 const label = scope.environmentLabels?.[environmentId];
141 const result = get(
142 options.getSearchAtom(environmentId, {
143 query,
144 ...(before === null
145 ? {}
146 : {
147 olderSettledBefore: before,
148 ...(scope.projectIds === undefined ? {} : { projectIds: scope.projectIds }),
149 ...(label === undefined ? {} : { environmentLabel: label }),
150 ...(searchMessages ? {} : { titlesOnly: true }),
151 }),
152 }),
153 );
154 isLoading ||= result.waiting;
155 const value = AsyncResult.getOrElse(result, () => null);
156 if (value !== null) {
157 matches.push(...value.matches.map((match) => ({ ...match, environmentId })));
158 if (value.threads !== undefined && value.threads.length > 0) {
159 rows.push({
160 environmentId,
161 read: { snapshotSequence: value.snapshotSequence ?? 0, threads: value.threads },
162 });
163 }
164 }
165 }
166​
167 return { matches, isLoading, rows: rows.length === 0 ? EMPTY_SEARCH_ROWS : rows };
168 }).pipe(Atom.withLabel(`${options.labelPrefix}:${key}`)),
169 );
170 const rowsAtom = Atom.family((rowsKey: string) => {
171 const [key, environmentId] = JSON.parse(rowsKey) as [string, EnvironmentId];
172 let previous = EMPTY_READS;
173 return Atom.make((get) => {
174 const next = get(results(key)).rows.flatMap((row) =>
175 row.environmentId === environmentId ? [row.read] : [],
176 );
177 if (next.length === previous.length && next.every((read, index) => read === previous[index]))
178 return previous;
179 previous = next.length === 0 ? EMPTY_READS : next;
180 return previous;
181 }).pipe(Atom.withLabel(`${options.labelPrefix}-rows:${rowsKey}`));
182 });
183 return {
184 results,
185 /** One environment's older settled rows for a search key, for `mountRows`. */
186 rows: (key: string, environmentId: EnvironmentId) =>
187 rowsAtom(JSON.stringify([key, environmentId])),
188 };
190​
191export type ThreadSearchShell = Pick<
192 EnvironmentThreadShell,
193 | "environmentId"
194 | "id"
195 | "title"
196 | "branch"
197 | "createdAt"
198 | "updatedAt"
199 | "latestUserMessageAt"
200 | "latestUserAuthoredMessageAt"
201 | "opens"
202> &
203 Partial<Pick<EnvironmentThreadShell, "pullRequests" | "linkedPullRequest" | "branchPullRequest">>;
204​
205/**
206 * What the web and mobile palettes rank a thread shell by. Pass the
207 * environment label only when there are two or more environments.
208 */
209export function threadSearchSubject(
210 thread: ThreadSearchShell,
211 projectTitle: string | null | undefined,
212 environmentLabel?: string | null,
213): ThreadSearchSubject {
214 // A keystroke ranks the same shells again, so a subject is built once per shell.
215 const cached = subjectCache.get(thread);
216 if (
217 cached !== undefined &&
218 cached.projectTitle === projectTitle &&
219 cached.environmentLabel === environmentLabel &&
220 cached.opensVersion === localThreadOpensVersion
221 ) {
222 return cached.subject;
223 }
224 const subject = buildThreadSearchSubject(thread, projectTitle, environmentLabel);
225 subjectCache.set(thread, {
226 projectTitle,
227 environmentLabel,
228 opensVersion: localThreadOpensVersion,
229 subject,
230 });
231 return subject;
233​
234const subjectCache = new WeakMap<
235 ThreadSearchShell,
236 {
237 readonly projectTitle: string | null | undefined;
238 readonly environmentLabel: string | null | undefined;
239 readonly opensVersion: number;
240 readonly subject: ThreadSearchSubject;
241 }
242>();
243​
244function buildThreadSearchSubject(
245 thread: ThreadSearchShell,
246 projectTitle: string | null | undefined,
247 environmentLabel: string | null | undefined,
248): ThreadSearchSubject {
249 const activityAt = getThreadSortTimestamp(thread, "updated_at");
250 const localOpenedAt = localThreadOpens.get(`${thread.environmentId}:${thread.id}`) ?? 0;
251 return {
252 id: thread.id,
253 title: thread.title,
254 branch: thread.branch,
255 projectTitle: projectTitle ?? null,
256 environmentLabel: environmentLabel ?? null,
257 pullRequests: threadSearchPullRequests(thread),
258 activityAt: Number.isFinite(activityAt) ? activityAt : null,
259 // This client's own latest open counts too: the server merged it, or has
260 // no record yet (an older server ignores opens).
261 opens:
262 localOpenedAt > (thread.opens?.openedAt ?? 0)
263 ? { score: 1, picks: [], ...thread.opens, openedAt: localOpenedAt }
264 : (thread.opens ?? null),
265 };
267​
268/** Message result keys in the order a search box last showed them for `query`. */
269export interface ShownOrder {
270 readonly query: string;
271 readonly keys: ReadonlyArray<string>;
272 /** How many of `keys` are on screen; only those hold their places. */
273 readonly shown: number;
275​
276// Rows past the limit a slower environment's best matches may take.
277const LATE_ROOM = 3;
278​
279/**
280 * Keep shown rows in place so late results cannot move the pointer or keyboard target.
281 * Allow up to LATE_ROOM extra rows so late best matches remain visible.
282 * Reuse `previous` when unchanged to avoid a React state update.
283 */
284export function nextShownOrder(
285 previous: ShownOrder | null,
286 query: string,
287 ranked: ReadonlyArray<string>,
288 limit: number,
289): ShownOrder {
290 if (previous?.query !== query) {
291 return { query, keys: ranked, shown: Math.min(limit, ranked.length) };
292 }
293 const listed = new Set(ranked);
294 const kept = previous.keys.slice(0, previous.shown).filter((key) => listed.has(key));
295 const held = new Set(kept);
296 const seen = new Set(previous.keys);
297 const rest = ranked.filter((key) => !held.has(key));
298 const arrived = rest.filter((key) => !seen.has(key)).length;
299 const keys = [...kept, ...rest];
300 const shown = Math.min(
301 keys.length,
302 limit + LATE_ROOM,
303 Math.max(limit, kept.length + Math.min(arrived, LATE_ROOM)),
304 );
305 const unchanged =
306 shown === previous.shown &&
307 keys.length === previous.keys.length &&
308 keys.every((key, index) => key === previous.keys[index]);
309 return unchanged ? previous : { query, keys, shown };
311​
312export interface SettledThreadSearch {
313 readonly query: string;
314 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
316​
317/**
318 * What a search box shows for `query`: its own results once they arrive
319 * (`ready`), else the previous results if `query` refines or widens their
320 * query, so result rows do not blink out and back on every keystroke.
321 * Results for an unrelated query clear.
322 */
323export function visibleThreadSearch(
324 query: string,
325 ready: SettledThreadSearch | null,
326 previous: SettledThreadSearch | null,
327): SettledThreadSearch {
328 if (ready !== null) return ready;
329 const current = foldSearchQuery(query);
330 const before = foldSearchQuery(previous?.query ?? "");
331 return previous !== null &&
332 current.length > 0 &&
333 before.length > 0 &&
334 (current.startsWith(before) || before.startsWith(current))
335 ? previous
336 : { query: "", matches: [] };