Skip to content
Source
Sign inSign up

newTaskPromptLink.ts

419 lines14.6 KB

1import * as Dialog from "../../components/ui/Dialog";
2import {
3 StackActions,
4 type NavigationState,
5 type PartialState,
6 type StackActionType,
7} from "@react-navigation/native";
8import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
9import {
10 findPromptLinkProjects,
11 findPromptLinkPullRequestThread,
12 resolvePromptLinkModelSelection,
13} from "@t3tools/client-runtime/prompt-links";
14import {
15 executeAtomQuery,
16 isAtomCommandInterrupted,
17 runAtomCommand,
18 squashAtomCommandFailure,
19} from "@t3tools/client-runtime/state/runtime";
20import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
21import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
22import {
23 parseNewThreadLink,
24 type LinkedPrompt,
25 type PromptLinkPullRequest,
26 type PromptLinkRepository,
27} from "@t3tools/shared/promptLinks";
28import { Atom, type AsyncResult } from "effect/reactivity";
29​
30import { findLinkedRoute, findStackRoute, updateLinkedRoute } from "../../lib/appLinking";
31import { rootNavigationRef } from "../../lib/rootNavigation";
32import { scopedThreadKey } from "../../lib/scopedEntities";
33import { appAtomRegistry } from "../../state/atom-registry";
34import { environmentProjects, projectEnvironment } from "../../state/projects";
35import { composerPullRequests } from "../../state/pull-requests";
36import { environmentServerConfigsAtom } from "../../state/server";
37import { environmentThreadShells } from "../../state/threads";
38import {
39 createNewTaskDraft,
40 setComposerDraftText,
41 updateComposerDraftSettings,
42 waitForComposerDraftsLoaded,
43} from "../../state/use-composer-drafts";
44import { workspaceConnections, workspaceStateAtom } from "../../state/workspace";
45import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions";
46import {
47 latestThreadEnvironmentIds,
48 promptLinkNotice,
49 promptLinkRepositoryName,
50 promptLinkScratchEnvironmentId,
51 promptLinkSearchFromParams,
52 stepPendingPromptLink,
53 withoutPromptLinkParams,
54} from "./prompt-link-model";
55​
56// Found at any depth, both in the parsed link and on the live stack, so the two never disagree on
57// whether the link has landed.
58const NEW_TASK_ROUTE = "NewTaskProject";
59const NEW_TASK_ROUTES = [NEW_TASK_ROUTE];
60​
61type LinkedDraftRequest = {
62 readonly prompt: LinkedPrompt | null;
63 readonly provider: string | null;
64 readonly model: string | null;
65 readonly pullRequest: PromptLinkPullRequest | null;
66};
67​
68// A cold launch from a link has no projects or threads yet. Matching then would offer to clone a
69// repository the user already has.
70const shellsSettledAtom = Atom.make((get) => {
71 const workspace = get(workspaceStateAtom);
72 return !(
73 workspace.isLoadingConnections ||
74 workspace.hasPendingShellSnapshot ||
75 // A machine still connecting may hold the repository or thread. One that keeps failing to
76 // reconnect has settled offline, and must not hold every link.
77 workspace.connectingEnvironments.some(
78 (environment) =>
79 environment.connectionState === "connecting" || environment.connectionError === null,
80 )
81 );
82}).pipe(Atom.withLabel("mobile:prompt-link-shells-settled"));
83​
84let pendingLink: string | null = null;
85let stopWaitingForShells: (() => void) | null = null;
86​
87/**
88 * `new?prompt=…&repo=…` drops its params as it arrives, so the screen never sees them and each
89 * opening resolves once. It resolves when its screen is on the navigation state that follows and
90 * the shells have settled; a newer link replaces one still waiting.
91 */
92export function takeNewTaskPromptLink(
93 state: PartialState<NavigationState> | undefined,
94): PartialState<NavigationState> | undefined {
95 const route = findLinkedRoute(state, NEW_TASK_ROUTES);
96 const search = promptLinkSearchFromParams(
97 route?.params as Readonly<Record<string, unknown>> | undefined,
98 );
99 if (search === null) return state;
100 pendingLink = search;
101 return updateLinkedRoute(state, NEW_TASK_ROUTES, (linked) => ({
102 ...linked,
103 params: withoutPromptLinkParams(linked.params),
104 }));
106​
107/** Wired to the navigation container's state changes. */
108export function resolvePendingNewTaskPromptLink(state: NavigationState | undefined): void {
109 if (pendingLink === null) return;
110 const step = stepPendingPromptLink(pendingLink, {
111 // A screen pushed above New task (such as Add project) drops the link, so it never lands
112 // over the wrong screen.
113 screenOnStack: findStackRoute(state, NEW_TASK_ROUTE)?.focused === true,
114 ready: appAtomRegistry.get(shellsSettledAtom),
115 });
116 if (step.pending === null) dropPendingLink();
117 else pendingLink = step.pending;
118 if (step.resolve !== null) resolveLink(step.resolve);
119 else if (step.pending !== null) {
120 stopWaitingForShells ??= appAtomRegistry.subscribe(shellsSettledAtom, (settled) => {
121 if (settled) resolvePendingNewTaskPromptLink(rootNavigationRef.getRootState());
122 });
123 }
125​
126function dropPendingLink(): void {
127 pendingLink = null;
128 stopWaitingForShells?.();
129 stopWaitingForShells = null;
131​
132/** Dispatches as the new-task screen would, so `replace` swaps that screen out. */
133function dispatchFromNewTask(action: StackActionType): void {
134 const root = rootNavigationRef.getRootState();
135 const screen = findStackRoute(root, NEW_TASK_ROUTE);
136 const target = screen?.navigatorKey ?? root?.key;
137 rootNavigationRef.dispatch({
138 ...action,
139 ...(target ? { target } : {}),
140 ...(screen ? { source: screen.routeKey } : {}),
141 });
143​
144function runQuery<A, E>(atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>) {
145 return executeAtomQuery(appAtomRegistry, atom, {
146 label: atom.label?.[0] ?? "atom query",
147 reportFailure: false,
148 });
150​
151function writeDraft(
152 draftKey: string,
153 environmentId: EnvironmentId,
154 request: LinkedDraftRequest,
155): boolean {
156 if (request.prompt) setComposerDraftText(draftKey, request.prompt.text);
157 if (request.provider === null) return false;
158 const resolved = resolvePromptLinkModelSelection(
159 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.providers ?? [],
160 { provider: request.provider, model: request.model },
161 );
162 if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection });
163 return resolved?.exact !== true;
165​
166function openThread(
167 thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId },
168 prompt: LinkedDraftRequest["prompt"],
169) {
170 dispatchFromNewTask(
171 StackActions.replace("Thread", {
172 environmentId: String(thread.environmentId),
173 threadId: String(thread.id),
174 }),
175 );
176 if (prompt) {
177 void applyLinkedPromptToThreadDraft(
178 scopedThreadKey(thread.environmentId, thread.id),
179 prompt,
180 promptLinkNotice({ hiddenCharacters: prompt.hiddenCharacters }),
181 );
182 }
184​
185// A pull request's thread can be an older settled thread no shell holds, so ask every
186// connected environment with the repository. A lookup on another one would wait forever.
187async function findServerPullRequestThread(
188 projects: ReadonlyArray<EnvironmentProject>,
189 pullRequest: PromptLinkPullRequest,
190) {
191 const connected = new Set(
192 appAtomRegistry
193 .get(workspaceConnections.environmentsAtom)
194 .filter((environment) => environment.connectionState === "connected")
195 .map((environment) => environment.environmentId),
196 );
197 const lookups = projects
198 .filter((project) => connected.has(project.environmentId))
199 .map(async (project) => {
200 const linked = await runQuery(
201 composerPullRequests.linkedThreadsUncached({
202 environmentId: project.environmentId,
203 input: {
204 projectId: project.id,
205 repository: promptLinkRepositoryName(pullRequest),
206 number: pullRequest.number,
207 },
208 }),
209 );
210 const thread =
211 linked._tag === "Success"
212 ? linked.value.threads.find((candidate) => candidate.archivedAt === null)
213 : undefined;
214 return thread && { environmentId: project.environmentId, id: thread.id };
215 });
216 // The most recent project wins without waiting for the lookups after it.
217 for (const lookup of lookups) {
218 const thread = await lookup;
219 if (thread) return thread;
220 }
221 return undefined;
223​
224async function openProjectDraft(project: EnvironmentProject, request: LinkedDraftRequest) {
225 try {
226 await waitForComposerDraftsLoaded();
227 } catch {
228 Dialog.alert("Could not open link", "Drafts could not be loaded, so the prompt was not added.");
229 return;
230 }
231 const draftKey = createNewTaskDraft({
232 environmentId: project.environmentId,
233 projectId: project.id,
234 });
235 let pullRequestUnavailable: number | null = null;
236 if (request.pullRequest) {
237 const detail = await runQuery(
238 composerPullRequests.detail({
239 environmentId: project.environmentId,
240 input: {
241 projectId: project.id,
242 repository: promptLinkRepositoryName(request.pullRequest),
243 number: request.pullRequest.number,
244 },
245 }),
246 );
247 if (detail._tag === "Success") {
248 updateComposerDraftSettings(draftKey, {
249 workspaceSelection: {
250 mode: "worktree",
251 branch: detail.value.headBranch,
252 worktreePath: null,
253 startFromOrigin: false,
254 pullRequestReference: detail.value.url,
255 },
256 });
257 } else {
258 pullRequestUnavailable = request.pullRequest.number;
259 }
260 }
261 const modelUnavailable = writeDraft(draftKey, project.environmentId, request);
262 dispatchFromNewTask(
263 StackActions.push("Thread", {
264 draft: {
265 draftId: draftKey,
266 title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title,
267 },
268 }),
269 );
270 if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) {
271 showPromptLinkNotice(
272 promptLinkNotice({
273 hiddenCharacters: request.prompt?.hiddenCharacters ?? 0,
274 modelUnavailable,
275 pullRequestUnavailable,
276 }),
277 );
278 }
280​
281async function openScratchDraft(request: LinkedDraftRequest) {
282 const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom);
283 const environmentId = promptLinkScratchEnvironmentId(
284 latestThreadEnvironmentIds(appAtomRegistry.get(environmentThreadShells.threadShellsAtom)),
285 appAtomRegistry
286 .get(workspaceConnections.environmentsAtom)
287 .filter(
288 (environment) =>
289 availableScratchWorkspaceRoot(
290 environment.connectionState,
291 serverConfigs.get(environment.environmentId),
292 ) !== null,
293 )
294 .map((environment) => environment.environmentId),
295 );
296 if (environmentId === null) {
297 Dialog.alert(
298 "Could not start without a project",
299 "No connected environment offers threads without a project.",
300 );
301 return;
302 }
303 const result = await runAtomCommand(
304 appAtomRegistry,
305 projectEnvironment.openScratch,
306 { environmentId, input: {} },
307 { reportFailure: false },
308 );
309 if (result._tag === "Failure") {
310 if (!isAtomCommandInterrupted(result)) {
311 const error = squashAtomCommandFailure(result);
312 Dialog.alert(
313 "Could not start without a project",
314 error instanceof Error
315 ? error.message
316 : "The folder for threads without a project could not be created.",
317 );
318 }
319 return;
320 }
321 await openProjectDraft(result.value, { ...request, pullRequest: null });
323​
324function cloneRepository(repository: PromptLinkRepository, request: LinkedDraftRequest) {
325 // The clone screen lets the user pick another environment before cloning.
326 const environmentId =
327 appAtomRegistry.get(environmentProjects.projectsAtom)[0]?.environmentId ??
328 appAtomRegistry.get(workspaceConnections.environmentsAtom)[0]?.environmentId ??
329 null;
330 if (environmentId === null) {
331 Dialog.alert("Could not clone", "Connect an environment first.");
332 return;
333 }
334 const prompt =
335 request.pullRequest === null &&
336 appAtomRegistry.get(environmentServerConfigsAtom).has(environmentId)
337 ? request.prompt
338 : null;
339 const name = promptLinkRepositoryName(repository);
340 dispatchFromNewTask(
341 StackActions.push("AddProjectDestination", {
342 environmentId,
343 remoteUrl: `https://github.com/${name}.git`,
344 repositoryTitle: name,
345 repositoryName: repository.name,
346 ...(prompt ? { prompt: prompt.text } : {}),
347 }),
348 );
349 if (!request.prompt) return;
350 showPromptLinkNotice(
351 [
352 promptLinkNotice({
353 hiddenCharacters: request.prompt.hiddenCharacters,
354 modelUnavailable: prompt !== null && request.provider !== null,
355 }),
356 prompt
357 ? "The prompt opens in a new task once the clone starts."
358 : "Open the link again after the clone to get the prompt.",
359 ].join("\n\n"),
360 );
362​
363function resolveLink(linkSearch: string) {
364 const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
365 const parsed = parseNewThreadLink(linkSearch);
366 if (!parsed.ok) {
367 Dialog.alert("Could not open link", parsed.error);
368 return;
369 }
370 const link = parsed.value;
371 const request: LinkedDraftRequest =
372 link.kind === "new"
373 ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null }
374 : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest };
375​
376 if (link.kind === "pull-request") {
377 const thread = findPromptLinkPullRequestThread(threads, link.pullRequest);
378 if (thread) {
379 openThread(thread, link.prompt);
380 return;
381 }
382 }
383​
384 const repository = link.kind === "new" ? link.repository : link.pullRequest;
385 if (repository === null) {
386 void openScratchDraft(request);
387 return;
388 }
389 const lastActivity = new Map<string, string>();
390 for (const thread of threads) {
391 const key = `${thread.environmentId}:${thread.projectId}`;
392 if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt);
393 }
394 const activityOf = (project: EnvironmentProject) =>
395 lastActivity.get(`${project.environmentId}:${project.id}`) ?? "";
396 const projects = findPromptLinkProjects(
397 [...appAtomRegistry.get(environmentProjects.projectsAtom)].sort((left, right) =>
398 activityOf(right).localeCompare(activityOf(left)),
399 ),
400 repository,
401 );
402 const project = projects[0];
403 if (project) {
404 const { pullRequest } = request;
405 void (async () => {
406 const thread = pullRequest && (await findServerPullRequestThread(projects, pullRequest));
407 if (thread) openThread(thread, request.prompt);
408 else await openProjectDraft(project, request);
409 })();
410 return;
411 }
412 Dialog.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [
413 { text: "Cancel", style: "cancel" },
414 ...(request.prompt
415 ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }]
416 : []),
417 { text: "Clone", onPress: () => cloneRepository(repository, request) },
418 ]);