1import * as Dialog from "../../components/ui/Dialog";
2import type { NavigationState, PartialState } from "@react-navigation/native";
3import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
4import { parseThreadLinkPrompt, type LinkedPrompt } from "@t3tools/shared/promptLinks";
5import * as Option from "effect/Option";
6import { AsyncResult } from "effect/reactivity";
7
8import { findStackRoute } from "../../lib/appLinking";
9import { rootNavigationRef } from "../../lib/rootNavigation";
10import { scopedThreadKey } from "../../lib/scopedEntities";
11import { appAtomRegistry } from "../../state/atom-registry";
12import { environmentThreadShells, environmentThreads } from "../../state/threads";
13import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions";
14import {
15 promptLinkNotice,
16 promptLinkSearchFromParams,
17 stepPendingPromptLink,
18} from "./prompt-link-model";
19
20const THREAD_LINK_KEYS = ["prompt"];
21
22type PendingThreadLink = { readonly ref: ScopedThreadRef; readonly prompt: LinkedPrompt };
23
24function firstString(value: unknown): string | null {
25 const first = Array.isArray(value) ? value[0] : value;
26 return typeof first === "string" && first.length > 0 ? first : null;
27}
28
29let pendingLink: PendingThreadLink | null = null;
30let stopWaitingForShell: (() => void) | null = null;
31let stopWaitingForDetail: (() => void) | null = null;
32
33/**
34 * `threads/<environment>/<thread>?prompt=…` fills that thread's composer draft. The param is
35 * dropped as the link arrives, so the thread screen never sees it and a later visit cannot apply
36 * it again. The draft is written only once the thread's screen shows a thread that exists: a
37 * screen for an unknown thread replaces itself with a new-thread draft, which drops the link.
38 */
39export function takeThreadPromptLink(
40 state: PartialState<NavigationState> | undefined,
41): PartialState<NavigationState> | undefined {
42 if (!state) return state;
43 const index = state.routes.findIndex((route) => route.name === "Thread");
44 const route = state.routes[index];
45 const params = route?.params as Readonly<Record<string, unknown>> | undefined;
46 const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS);
47 if (!route || !params || search === null) return state;
48 // A newer link replaces a waiting one even when it turns out to be invalid.
49 dropPendingLink();
50 const { prompt: _prompt, ...rest } = params;
51 const routes = state.routes.slice();
52 routes[index] = { ...route, params: rest };
53 const nextState = { ...state, routes };
54
55 const environmentId = firstString(params.environmentId);
56 const threadId = firstString(params.threadId);
57 if (environmentId === null || threadId === null) return nextState;
58 const parsed = parseThreadLinkPrompt(search);
59 if (!parsed.ok) {
60 Dialog.alert("Could not open link", parsed.error);
61 return nextState;
62 }
63 if (parsed.value !== null) {
64 pendingLink = {
65 ref: { environmentId: EnvironmentId.make(environmentId), threadId: ThreadId.make(threadId) },
66 prompt: parsed.value,
67 };
68 }
69 return nextState;
70}
71
72/** Wired to the navigation container's state changes. */
73export function resolvePendingThreadPromptLink(state: NavigationState | undefined): void {
74 if (pendingLink === null) return;
75 const { ref } = pendingLink;
76 // A subagent thread the environment's list leaves out gets a shell once the screen mounts its
77 // family. An archived thread never does; the screen shows it from its detail.
78 const shellAtom = environmentThreadShells.threadShellAtom(ref);
79 const detailAtom = environmentThreads.stateAtom(ref.environmentId, ref.threadId);
80 // Read detail only while the screen holds it: opening it here for an unknown thread would keep
81 // retrying with no screen to close it.
82 const screenHoldsDetail = appAtomRegistry.getNodes().has(detailAtom);
83 const step = stepPendingPromptLink(pendingLink, {
84 screenOnStack:
85 findStackRoute(
86 state,
87 "Thread",
88 (params) =>
89 firstString(params?.environmentId) === ref.environmentId &&
90 firstString(params?.threadId) === ref.threadId,
91 ) !== undefined,
92 ready:
93 appAtomRegistry.get(shellAtom) !== null ||
94 (screenHoldsDetail && detailLoaded(appAtomRegistry.get(detailAtom))),
95 });
96 if (step.pending === null) dropPendingLink();
97 if (step.resolve !== null) {
98 const { prompt } = step.resolve;
99 void applyLinkedPromptToThreadDraft(
100 scopedThreadKey(ref.environmentId, ref.threadId),
101 prompt,
102 promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }),
103 );
104 } else if (step.pending !== null) {
105 stopWaitingForShell ??= appAtomRegistry.subscribe(shellAtom, (shell) => {
106 if (shell !== null) resolvePendingThreadPromptLink(rootNavigationRef.getRootState());
107 });
108 if (screenHoldsDetail) {
109 stopWaitingForDetail ??= appAtomRegistry.subscribe(detailAtom, (detail) => {
110 if (detailLoaded(detail)) resolvePendingThreadPromptLink(rootNavigationRef.getRootState());
111 });
112 }
113 }
114}
115
116function detailLoaded(
117 result: AsyncResult.AsyncResult<{ readonly data: Option.Option<unknown> }, unknown>,
118): boolean {
119 return Option.isSome(Option.flatMap(AsyncResult.value(result), (state) => state.data));
120}
121
122function dropPendingLink(): void {
123 pendingLink = null;
124 stopWaitingForShell?.();
125 stopWaitingForShell = null;
126 stopWaitingForDetail?.();
127 stopWaitingForDetail = null;
128}