| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 2 | import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; |
| 3 | import { |
| 4 | findPromptLinkProject, |
| 5 | findPromptLinkPullRequestThread, |
| 6 | resolvePromptLinkModelSelection, |
| 7 | } from "@t3tools/client-runtime/prompt-links"; |
| 8 | import { |
| 9 | isAtomCommandInterrupted, |
| 10 | squashAtomCommandFailure, |
| 11 | } from "@t3tools/client-runtime/state/runtime"; |
| 12 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 14 | import { |
| 15 | parseNewThreadLink, |
| 16 | type LinkedPrompt, |
| 17 | type PromptLinkPullRequest, |
| 18 | type PromptLinkRepository, |
| 19 | } from "@t3tools/shared/promptLinks"; |
| 20 | import { useEffect, useEffectEvent, useRef } from "react"; |
| 21 | import { Alert } from "react-native"; |
| 22 | |
| 23 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 24 | import { useServerConfigs, useThreadShells } from "../../state/entities"; |
| 25 | import { projectEnvironment } from "../../state/projects"; |
| 26 | import { composerPullRequests } from "../../state/pull-requests"; |
| 27 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 28 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 29 | import { |
| 30 | createNewTaskDraft, |
| 31 | setComposerDraftText, |
| 32 | updateComposerDraftSettings, |
| 33 | waitForComposerDraftsLoaded, |
| 34 | } from "../../state/use-composer-drafts"; |
| 35 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 36 | import { useWorkspaceState } from "../../state/workspace"; |
| 37 | import { useNewTaskFlow } from "../threads/new-task-flow-provider"; |
| 38 | import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; |
| 39 | import { |
| 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. */ |
| 49 | type 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 | */ |
| 61 | export 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 | } |