1import { StackActions, useNavigation } from "@react-navigation/native";
2import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
3import {
4 findPromptLinkProject,
5 findPromptLinkPullRequestThread,
6 resolvePromptLinkModelSelection,
7} from "@t3tools/client-runtime/prompt-links";
8import {
9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
13import type { EnvironmentId } from "@t3tools/contracts";
14import {
15 parseNewThreadLink,
16 type LinkedPrompt,
17 type PromptLinkPullRequest,
18 type PromptLinkRepository,
19} from "@t3tools/shared/promptLinks";
20import { useEffect, useEffectEvent, useRef } from "react";
21import { Alert } from "react-native";
22
23import { scopedThreadKey } from "../../lib/scopedEntities";
24import { useServerConfigs, useThreadShells } from "../../state/entities";
25import { projectEnvironment } from "../../state/projects";
26import { composerPullRequests } from "../../state/pull-requests";
27import { useAtomCommand } from "../../state/use-atom-command";
28import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
29import {
30 createNewTaskDraft,
31 setComposerDraftText,
32 updateComposerDraftSettings,
33 waitForComposerDraftsLoaded,
34} from "../../state/use-composer-drafts";
35import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
36import { useWorkspaceState } from "../../state/workspace";
37import { useNewTaskFlow } from "../threads/new-task-flow-provider";
38import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions";
39import {
40 clearedPromptLinkParams,
41 promptLinkNotice,
42 promptLinkRepositoryName,
43 latestThreadEnvironmentIds,
44 promptLinkScratchEnvironmentId,
45 promptLinkSearchFromParams,
46} from "./prompt-link-model";
47
48/** What a `new` link asks for, in one shape for every destination. */
49type LinkedDraftRequest = {
50 readonly prompt: LinkedPrompt | null;
51 readonly provider: string | null;
52 readonly model: string | null;
53 readonly pullRequest: PromptLinkPullRequest | null;
54};
55
56/**
57 * Opens a `t3code://new?…` link. The link lands on the New Task picker with its query as route
58 * params; this resolves it to a project, a thread, or a choice, then fills a draft. Nothing is
59 * ever sent: the user reads the prompt and presses Send. Used by `NewTaskRouteScreen`.
60 */
61export function usePromptLinkResolver(params: Readonly<Record<string, unknown>> | undefined) {
62 const navigation = useNavigation();
63 const flow = useNewTaskFlow();
64 const { state: catalogState } = useWorkspaceState();
65 const threads = useThreadShells();
66 const serverConfigs = useServerConfigs();
67 const { connectedEnvironments } = useRemoteConnectionStatus();
68 const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
69 const loadPullRequest = useAtomQueryRunner(composerPullRequests.detail, {
70 reportFailure: false,
71 });
72 const search = promptLinkSearchFromParams(params);
73 // A cold launch from a link has no projects or threads yet. Matching then would offer to
74 // clone a repository the user already has.
75 const waitingForShells =
76 catalogState.isLoadingConnections ||
77 catalogState.hasPendingShellSnapshot ||
78 // A machine still connecting may hold the repository or thread. One that keeps failing to
79 // reconnect has settled offline, and must not hold every link.
80 catalogState.connectingEnvironments.some(
81 (environment) =>
82 environment.connectionState === "connecting" || environment.connectionError === null,
83 );
84
85 // Writes the prompt and the requested model into a draft. Returns true when the link named a
86 // model the user cannot use, so their default stands in.
87 const writeDraft = (
88 draftKey: string,
89 environmentId: EnvironmentId,
90 request: LinkedDraftRequest,
91 ): boolean => {
92 if (request.prompt) setComposerDraftText(draftKey, request.prompt.text);
93 if (request.provider === null) return false;
94 const resolved = resolvePromptLinkModelSelection(
95 serverConfigs.get(environmentId)?.providers ?? [],
96 { provider: request.provider, model: request.model },
97 );
98 if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection });
99 return resolved?.exact !== true;
100 };
101
102 const openProjectDraft = async (project: EnvironmentProject, request: LinkedDraftRequest) => {
103 try {
104 await waitForComposerDraftsLoaded();
105 } catch {
106 Alert.alert(
107 "Could not open link",
108 "Drafts could not be loaded, so the prompt was not added.",
109 );
110 return;
111 }
112 const draftKey = createNewTaskDraft({
113 environmentId: project.environmentId,
114 projectId: project.id,
115 });
116 let pullRequestUnavailable: number | null = null;
117 if (request.pullRequest) {
118 // The same draft the PR screen's Ask makes: Send checks out the PR head in a worktree.
119 const detail = await loadPullRequest({
120 environmentId: project.environmentId,
121 input: {
122 projectId: project.id,
123 repository: promptLinkRepositoryName(request.pullRequest),
124 number: request.pullRequest.number,
125 },
126 });
127 if (detail._tag === "Success") {
128 updateComposerDraftSettings(draftKey, {
129 workspaceSelection: {
130 mode: "worktree",
131 branch: detail.value.headBranch,
132 worktreePath: null,
133 startFromOrigin: false,
134 pullRequestReference: detail.value.url,
135 },
136 });
137 } else {
138 pullRequestUnavailable = request.pullRequest.number;
139 }
140 }
141 const modelUnavailable = writeDraft(draftKey, project.environmentId, request);
142 navigation.dispatch(
143 StackActions.push("NewTaskDraft", {
144 draftId: draftKey,
145 title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title,
146 }),
147 );
148 if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) {
149 showPromptLinkNotice(
150 promptLinkNotice({
151 hiddenCharacters: request.prompt?.hiddenCharacters ?? 0,
152 modelUnavailable,
153 pullRequestUnavailable,
154 }),
155 );
156 }
157 };
158
159 const openScratchDraft = async (request: LinkedDraftRequest) => {
160 const environmentId = promptLinkScratchEnvironmentId(
161 // Machines by their latest thread, scratch threads included.
162 latestThreadEnvironmentIds(threads),
163 connectedEnvironments
164 .filter(
165 (environment) =>
166 availableScratchWorkspaceRoot(
167 environment.connectionState,
168 serverConfigs.get(environment.environmentId),
169 ) !== null,
170 )
171 .map((environment) => environment.environmentId),
172 );
173 if (environmentId === null) {
174 Alert.alert(
175 "Could not start without a project",
176 "No connected environment offers threads without a project.",
177 );
178 return;
179 }
180 const result = await openScratch({ environmentId, input: {} });
181 if (result._tag === "Failure") {
182 if (!isAtomCommandInterrupted(result)) {
183 const error = squashAtomCommandFailure(result);
184 Alert.alert(
185 "Could not start without a project",
186 error instanceof Error
187 ? error.message
188 : "The folder for threads without a project could not be created.",
189 );
190 }
191 return;
192 }
193 await openProjectDraft(result.value, { ...request, pullRequest: null });
194 };
195
196 // Clone needs a tap on the destination screen, which then opens a draft in the cloned project
197 // holding the prompt. A pull request's prompt belongs on its branch, which only exists once the
198 // clone is done, so that link, like any on an older server, has to be opened again.
199 const cloneRepository = (repository: PromptLinkRepository, request: LinkedDraftRequest) => {
200 const environmentId =
201 flow.selectedEnvironmentId ?? connectedEnvironments[0]?.environmentId ?? null;
202 if (environmentId === null) {
203 Alert.alert("Could not clone", "Connect an environment first.");
204 return;
205 }
206 // Only a tracked clone opens a draft the prompt can ride into; older servers do not.
207 const prompt =
208 request.pullRequest === null &&
209 serverConfigs.get(environmentId)?.environment.capabilities.projectCloneTracking === true
210 ? request.prompt
211 : null;
212 const name = promptLinkRepositoryName(repository);
213 navigation.dispatch(
214 StackActions.push("AddProjectDestination", {
215 environmentId,
216 remoteUrl: `https://github.com/${name}.git`,
217 repositoryTitle: name,
218 repositoryName: repository.name,
219 ...(prompt ? { prompt: prompt.text } : {}),
220 }),
221 );
222 if (!request.prompt) return;
223 showPromptLinkNotice(
224 [
225 promptLinkNotice({
226 hiddenCharacters: request.prompt.hiddenCharacters,
227 modelUnavailable: prompt !== null && request.provider !== null,
228 }),
229 prompt
230 ? "The prompt opens in a new task once the clone starts."
231 : "Open the link again after the clone to get the prompt.",
232 ].join("\n\n"),
233 );
234 };
235
236 const resolveLink = useEffectEvent((linkSearch: string) => {
237 const parsed = parseNewThreadLink(linkSearch);
238 if (!parsed.ok) {
239 Alert.alert("Could not open link", parsed.error);
240 return;
241 }
242 const link = parsed.value;
243 const request: LinkedDraftRequest =
244 link.kind === "new"
245 ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null }
246 : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest };
247
248 if (link.kind === "pull-request") {
249 const thread = findPromptLinkPullRequestThread(threads, link.pullRequest);
250 if (thread) {
251 (navigation.getParent() ?? navigation).dispatch(
252 StackActions.replace("Thread", {
253 environmentId: String(thread.environmentId),
254 threadId: String(thread.id),
255 }),
256 );
257 if (link.prompt) {
258 void applyLinkedPromptToThreadDraft(
259 scopedThreadKey(thread.environmentId, thread.id),
260 link.prompt,
261 promptLinkNotice({ hiddenCharacters: link.prompt.hiddenCharacters }),
262 );
263 }
264 return;
265 }
266 }
267
268 const repository = link.kind === "new" ? link.repository : link.pullRequest;
269 if (repository === null) {
270 void openScratchDraft(request);
271 return;
272 }
273 // Most recently active first, so the user lands where they last worked. Scopes are ordered
274 // that way, but the checkouts inside one are not.
275 const lastActivity = new Map<string, string>();
276 for (const thread of threads) {
277 const key = `${thread.environmentId}:${thread.projectId}`;
278 if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt);
279 }
280 const activityOf = (project: EnvironmentProject) =>
281 lastActivity.get(`${project.environmentId}:${project.id}`) ?? "";
282 const project = findPromptLinkProject(
283 flow.projectScopes
284 .flatMap((scope) => scope.projects)
285 .sort((left, right) => activityOf(right).localeCompare(activityOf(left))),
286 repository,
287 );
288 if (project) {
289 void openProjectDraft(project, request);
290 return;
291 }
292 Alert.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [
293 { text: "Cancel", style: "cancel" },
294 ...(request.prompt
295 ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }]
296 : []),
297 { text: "Clone", onPress: () => cloneRepository(repository, request) },
298 ]);
299 });
300
301 // Handle each link once. Clearing the params lets the same link open again later.
302 const handledSearchRef = useRef<string | null>(null);
303 useEffect(() => {
304 if (search === null) {
305 handledSearchRef.current = null;
306 return;
307 }
308 if (waitingForShells || handledSearchRef.current === search) return;
309 handledSearchRef.current = search;
310 navigation.setParams(clearedPromptLinkParams());
311 resolveLink(search);
312 }, [navigation, search, waitingForShells]);
313}