Skip to content
Source
Sign inSign up

entities.ts

298 lines10.6 KB

1import { useAtomValue } from "@effect/atom-react";
2import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution";
3​
4import { appAtomRegistry } from "./atom-registry";
5import type {
6 EnvironmentProject,
7 EnvironmentThread,
8 EnvironmentThreadShell,
9} from "@t3tools/client-runtime/state/shell";
10import {
11 EnvironmentId,
12 ThreadId,
13 type ComposerContextRecord,
14 type ScopedProjectRef,
15 type ScopedThreadRef,
16 type ServerConfig,
17} from "@t3tools/contracts";
18import { scopeThreadRef } from "@t3tools/client-runtime/environment";
19import {
20 guessThreadLinkRef,
21 hasThreadLinks,
22 parseThreadLinkHref,
23 relabelThreadLinks,
24 threadLinkCandidates,
25 threadLinkRefs,
26 type ThreadLink,
27} from "@t3tools/shared/threadLinks";
28import * as Schema from "effect/Schema";
29import { AsyncResult, Atom } from "effect/reactivity";
30​
31import { threadChipTargets } from "../lib/enrichedLinkPresentation";
32import { resolveProjectChipIcon } from "../lib/projectIcon";
33import { projectGlyphImageAtom, projectGlyphKey } from "../native/ProjectChipIconImages";
34import { projectFaviconUrlAtom } from "./assets";
35import { orchestrationEnvironment } from "./orchestration";
36import { environmentProjects } from "./projects";
37import { environmentServerConfigsAtom, serverEnvironment } from "./server";
38import { environmentThreadDetails, environmentThreadShells } from "./threads";
39​
40const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
41 Atom.withLabel("mobile-project:empty"),
42);
43const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).pipe(
44 Atom.withLabel("mobile-thread-shell:empty"),
45);
46const EMPTY_SERVER_CONFIG_ATOM = Atom.make<ServerConfig | null>(null).pipe(
47 Atom.withLabel("mobile-server-config:empty"),
48);
49const EMPTY_THREAD_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
50 Atom.withLabel("mobile-thread:empty"),
51);
52const EMPTY_THREAD_LINK_TITLES_ATOM = Atom.make<ReadonlyMap<string, string>>(new Map()).pipe(
53 Atom.withLabel("mobile-thread-link-titles:empty"),
54);
55​
56const decodeThreadKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, ThreadId]));
57const threadKey = (ref: ScopedThreadRef) => JSON.stringify([ref.environmentId, ref.threadId]);
58​
59// Omitted and archived targets need only a metadata lookup, not their conversation history.
60const threadTitleAtom = Atom.family((key: string) => {
61 const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key)));
62 return Atom.make((get) => {
63 const shell = get(environmentThreadShells.threadShellAtom(ref));
64 if (shell !== null) return shell.title;
65 const titles = get(
66 orchestrationEnvironment.threadLinkMetadata({
67 environmentId: ref.environmentId,
68 input: { threadIds: [ref.threadId] },
69 }),
70 );
71 return AsyncResult.isSuccess(titles)
72 ? (titles.value[ref.threadId]?.title ?? (titles.waiting ? "Thread" : null))
73 : "Thread";
74 }).pipe(Atom.withLabel(`mobile-thread-title:${key}`));
75});
76​
77const decodeThreadKeys = Schema.decodeUnknownSync(Schema.Array(Schema.String));
78​
79const threadLinkTitlesAtom = Atom.family((key: string) => {
80 const refKeys = decodeThreadKeys(JSON.parse(key));
81 return Atom.make((get) => {
82 const titles = new Map<string, string>();
83 for (const refKey of refKeys) {
84 const title = get(threadTitleAtom(refKey));
85 if (title !== null) titles.set(refKey, title);
86 }
87 return titles;
88 }).pipe(Atom.withLabel(`mobile-thread-link-titles:${key}`));
89});
90​
91const decodeThreadChipTargets = Schema.decodeUnknownSync(
92 Schema.Array(
93 Schema.Struct({
94 href: Schema.String,
95 environmentId: EnvironmentId,
96 threadId: ThreadId,
97 link: Schema.Boolean,
98 }),
99 ),
100);
101​
102const threadProjectChipIconAtom = Atom.family((key: string) => {
103 const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key)));
104 return Atom.make((get): string | null => {
105 const shell = get(environmentThreadShells.threadShellAtom(ref));
106 const metadata =
107 shell ??
108 AsyncResult.getOrElse(
109 get(
110 orchestrationEnvironment.threadLinkMetadata({
111 environmentId: ref.environmentId,
112 input: { threadIds: [ref.threadId] },
113 }),
114 ),
115 () => null,
116 )?.[ref.threadId];
117 const project = metadata?.projectId
118 ? get(
119 environmentProjects.projectAtom({
120 environmentId: ref.environmentId,
121 projectId: metadata.projectId,
122 }),
123 )
124 : null;
125 const icon = resolveProjectChipIcon(
126 project,
127 get(serverEnvironment.configValueAtom(ref.environmentId))?.scratchWorkspaceRoot,
128 () =>
129 project
130 ? get(
131 projectFaviconUrlAtom({
132 environmentId: ref.environmentId,
133 cwd: project.workspaceRoot,
134 faviconPath: project.faviconPath,
135 }),
136 )
137 : null,
138 );
139 if (!icon) return null;
140 if (icon.kind === "favicon") return icon.uri;
141 return AsyncResult.getOrElse(get(projectGlyphImageAtom(projectGlyphKey(icon))), () => null);
142 }).pipe(Atom.withLabel(`mobile-thread-project-chip-icon:${key}`));
143});
144​
145const EMPTY_THREAD_CHIP_ICONS: ReadonlyMap<string, string> = new Map();
146const EMPTY_THREAD_CHIP_ICONS_ATOM = Atom.make(EMPTY_THREAD_CHIP_ICONS).pipe(
147 Atom.withLabel("mobile-thread-chip-icons:empty"),
148);
149​
150const threadChipIconsAtom = Atom.family((key: string) => {
151 const targets = decodeThreadChipTargets(JSON.parse(key));
152 return Atom.make((get) => {
153 const icons = new Map<string, string>();
154 for (const target of targets) {
155 const link = target.link ? parseThreadLinkHref(target.href) : null;
156 const ref =
157 (link ? resolveThreadLink(get, link, target.environmentId) : null) ??
158 scopeThreadRef(target.environmentId, target.threadId);
159 const icon = get(threadProjectChipIconAtom(threadKey(ref)));
160 if (icon) icons.set(target.href, icon);
161 }
162 return icons;
163 }).pipe(Atom.withLabel(`mobile-thread-chip-icons:${key}`));
164});
165​
166export function waitForProject(
167 ref: ScopedProjectRef,
168 timeoutMs = 10_000,
169): Promise<EnvironmentProject | null> {
170 const atom = environmentProjects.projectAtom(ref);
171 const current = appAtomRegistry.get(atom);
172 if (current !== null) return Promise.resolve(current);
173 return new Promise((resolve) => {
174 let unsubscribe: (() => void) | null = null;
175 const timeout = setTimeout(() => {
176 unsubscribe?.();
177 resolve(null);
178 }, timeoutMs);
179 const finish = (project: EnvironmentProject | null) => {
180 if (project === null) return;
181 clearTimeout(timeout);
182 unsubscribe?.();
183 resolve(project);
184 };
185 unsubscribe = appAtomRegistry.subscribe(atom, finish);
186 finish(appAtomRegistry.get(atom));
187 });
189​
190export function useProjects(): ReadonlyArray<EnvironmentProject> {
191 return useAtomValue(environmentProjects.projectsAtom);
193​
194export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
195 return useAtomValue(environmentThreadShells.threadShellsAtom);
197​
198/** Thread shells minus those the tag filter hides, for lists that show threads to the user. */
199export function useVisibleThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
200 return useAtomValue(environmentThreadShells.visibleThreadShellsAtom);
202​
203export function useNavigationThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
204 return useAtomValue(environmentThreadShells.navigationThreadShellsAtom);
206​
207export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null {
208 return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref));
210​
211export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null {
212 return useAtomValue(
213 ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref),
214 );
216​
217/** Streaming timestamps, item counts, and the latest message can be stale during a run. */
218export function useQuietThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null {
219 return useAtomValue(
220 ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.quietThreadShellAtom(ref),
221 );
223​
224export function useChildThreadInputs(ref: ScopedThreadRef) {
225 return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref));
227​
228export function useSideChats(ref: ScopedThreadRef) {
229 return useAtomValue(environmentThreadShells.sideChatsAtom(ref));
231​
232export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
233 const titles = useAtomValue(
234 hasThreadLinks(markdown)
235 ? threadLinkTitlesAtom(JSON.stringify(threadLinkRefs(markdown, environmentId).map(threadKey)))
236 : EMPTY_THREAD_LINK_TITLES_ATOM,
237 );
238 return relabelThreadLinks(markdown, environmentId, (ref) => titles.get(threadKey(ref)));
240​
241/** Keyed reads; every thread is read only to place a v1 link its message's environment lacks. */
242export function resolveThreadLink(
243 read: <A>(atom: Atom.Atom<A>) => A,
244 link: ThreadLink,
245 messageEnvironmentId: EnvironmentId,
246): ScopedThreadRef | null {
247 const known = threadLinkCandidates(link, messageEnvironmentId).find((ref) => {
248 if (read(environmentThreadShells.threadShellAtom(ref)) !== null) return true;
249 const metadata = read(
250 orchestrationEnvironment.threadLinkMetadata({
251 environmentId: ref.environmentId,
252 input: { threadIds: [ref.threadId] },
253 }),
254 );
255 return AsyncResult.getOrElse(metadata, () => null)?.[ref.threadId] !== undefined;
256 });
257 if (known) return known;
258 return link.environmentId === null
259 ? guessThreadLinkRef(link, read(environmentThreadShells.threadRefsAtom))
260 : scopeThreadRef(link.environmentId, link.threadId);
262​
263export function useThreadChipIcons(
264 environmentId: EnvironmentId,
265 markdown: string,
266 records?: ReadonlyArray<ComposerContextRecord>,
267): ReadonlyMap<string, string> {
268 const targets = threadChipTargets(environmentId, markdown, records);
269 return useAtomValue(
270 targets.length === 0
271 ? EMPTY_THREAD_CHIP_ICONS_ATOM
272 : threadChipIconsAtom(JSON.stringify(targets)),
273 );
275​
276export function useEnvironmentServerConfig(
277 environmentId: EnvironmentId | null,
278): ServerConfig | null {
279 return useAtomValue(
280 environmentId === null
281 ? EMPTY_SERVER_CONFIG_ATOM
282 : serverEnvironment.configValueAtom(environmentId),
283 );
285​
286export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
287 return useAtomValue(environmentServerConfigsAtom);
289​
290const selectReportedModelSelection = (thread: EnvironmentThread | null) =>
291 thread === null ? null : deriveReportedModelSelection(thread.projection);
292​
293export function useThreadReportedModelSelection(ref: ScopedThreadRef | null) {
294 return useAtomValue(
295 ref === null ? EMPTY_THREAD_ATOM : environmentThreadDetails.threadAtom(ref),
296 selectReportedModelSelection,
297 );