1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { getCloneDestinationPath } 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 {
14 type NewThreadLink,
15 type PromptLinkRepository,
16 promptLinkPullRequestUrl,
17} from "@t3tools/shared/promptLinks";
18import { useAtomValue } from "@effect/atom-react";
19import { useLocation, useNavigate } from "@tanstack/react-router";
20import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
21
22import { useComposerDraftStore } from "~/composerDraftStore";
23import { isElectron } from "~/env";
24import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
25import { useScratchProject } from "~/hooks/useScratchProject";
26import { newProjectId } from "~/lib/utils";
27import { useProjects, useThreadShells, waitForProject } from "~/state/entities";
28import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
29import { allReachableShellsLiveAtom } from "~/state/shell";
30import { sourceControlEnvironment } from "~/state/sourceControl";
31import { useAtomCommand } from "~/state/use-atom-command";
32
33import { usePullRequestHandoffs } from "../pullRequest/usePullRequestActions";
34import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
35import { Button } from "../ui/button";
36import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
37import { SidebarInset } from "../ui/sidebar";
38import { stackedThreadToast, toastManager } from "../ui/toast";
39import { WorkspacePageHeader } from "../WorkspacePageHeader";
40import { placeLinkedPrompt, warnAboutHiddenCharacters } from "./linkedPrompt";
41
42type LinkState =
43 | { readonly status: "opening" }
44 | { readonly status: "opened" }
45 | { readonly status: "failed"; readonly message: string; readonly title?: string }
46 | { readonly status: "missing-repository"; readonly repository: PromptLinkRepository }
47 | {
48 readonly status: "pull-request-checkout";
49 readonly project: EnvironmentProject;
50 };
51
52// A link opens once per history entry. Going back to it lands here again, and opening a
53// second draft for the same click would surprise more than it helps.
54const openedLinkKeys = new Set<string>();
55
56/**
57 * The `/new` route: turns a prompt link into a draft holding its prompt, in the project the
58 * link names (or "No project"), or into the thread behind a linked pull request. The user
59 * still reads the prompt and presses Send.
60 */
61export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) {
62 const locationKey = useLocation({ select: (location) => location.state.__TSR_key ?? "" });
63 const navigate = useNavigate();
64 // The link needs every reachable machine's projects and threads to find its repository.
65 const ready = useAtomValue(allReachableShellsLiveAtom);
66 const projects = useProjects();
67 const threads = useThreadShells();
68 const { environments } = useEnvironments();
69 const primaryEnvironmentId = usePrimaryEnvironmentId();
70 const handleNewThread = useNewThreadHandler();
71 const { scratchEnvironmentId, openScratchProject } = useScratchProject();
72 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
73 reportFailure: false,
74 });
75 const [state, setState] = useState<LinkState>(() =>
76 openedLinkKeys.has(locationKey) ? { status: "opened" } : { status: "opening" },
77 );
78 const startedRef = useRef(false);
79
80 const recentProjects = () =>
81 sortScopedProjectsForSidebar(
82 projects.filter((project) => project.isScratch !== true),
83 threads,
84 "updated_at",
85 );
86
87 /** The machine the user worked on last, scratch threads included. */
88 const lastActiveEnvironmentId = () => {
89 let latest: (typeof threads)[number] | undefined;
90 for (const thread of threads) {
91 if (latest === undefined || thread.updatedAt > latest.updatedAt) latest = thread;
92 }
93 return latest?.environmentId ?? primaryEnvironmentId;
94 };
95
96 /** Opens a draft in the project with the link's prompt and, when it is available, model. */
97 const openDraft = async (project: EnvironmentProject, extraPrompt?: string) => {
98 const opened = await handleNewThread(scopeProjectRef(project.environmentId, project.id), {
99 replace: true,
100 }).catch(() => null);
101 if (opened === null) {
102 setState({ status: "failed", message: "The Knife could not open a new thread." });
103 return;
104 }
105 setState({ status: "opened" });
106 const notes: Array<string> = [];
107 if (link.kind === "new" && link.provider !== null) {
108 const providers =
109 environments.find((environment) => environment.environmentId === project.environmentId)
110 ?.serverConfig?.providers ?? [];
111 const resolved = resolvePromptLinkModelSelection(providers, {
112 provider: link.provider,
113 model: link.model,
114 });
115 if (resolved !== null) {
116 useComposerDraftStore.getState().setModelSelection(opened.draftId, resolved.selection, {
117 explicit: true,
118 replaceOptions: true,
119 });
120 }
121 if (resolved === null || !resolved.exact) {
122 notes.push(
123 resolved === null
124 ? `The link asked for ${link.provider}, which is not set up here, so your default model is used.`
125 : `The link asked for ${link.model}, which ${link.provider} does not offer here, so its default model is used.`,
126 );
127 }
128 }
129 const text = [link.prompt?.text, extraPrompt].filter(Boolean).join("\n\n");
130 if (text.length === 0) return;
131 placeLinkedPrompt(
132 opened.draftId,
133 { text, hiddenCharacters: link.prompt?.hiddenCharacters ?? 0 },
134 notes,
135 );
136 };
137
138 /** "No project" on the machine the user last worked on. */
139 const openWithoutProject = async () => {
140 setState({ status: "opening" });
141 // The machine the user last worked on, or the only one that offers it.
142 const environmentId =
143 scratchEnvironmentId(lastActiveEnvironmentId()) ?? scratchEnvironmentId(null);
144 // Reports its own failure.
145 const project = environmentId === null ? null : await openScratchProject(environmentId);
146 if (project === null) {
147 setState({
148 status: "failed",
149 message: "None of your machines can start a thread without a project right now.",
150 });
151 return;
152 }
153 // Without its repository, a pull request link still says which one it meant.
154 await openDraft(
155 project,
156 link.kind === "pull-request" ? promptLinkPullRequestUrl(link.pullRequest) : undefined,
157 );
158 };
159
160 const cloneRepository = async (repository: PromptLinkRepository) => {
161 const environmentId = lastActiveEnvironmentId();
162 const environment = environments.find((entry) => entry.environmentId === environmentId);
163 const newProjectsRoot = environment?.serverConfig?.newProjectsRoot ?? null;
164 if (
165 environmentId === null ||
166 newProjectsRoot === null ||
167 environment?.serverConfig?.environment.capabilities.projectCloneTracking !== true
168 ) {
169 setState({
170 status: "failed",
171 message:
172 "This machine cannot clone from a link. Update The Knife on it, or clone the repository from the command palette.",
173 });
174 return;
175 }
176 setState({ status: "opening" });
177 const projectId = newProjectId();
178 const result = await startProjectClone({
179 environmentId,
180 input: {
181 projectId,
182 title: repository.name,
183 createdAt: new Date().toISOString(),
184 remoteUrl: `https://github.com/${repository.owner}/${repository.name}.git`,
185 destinationPath: getCloneDestinationPath(newProjectsRoot, repository.name),
186 },
187 });
188 if (result._tag === "Failure") {
189 if (!isAtomCommandInterrupted(result)) {
190 const error = squashAtomCommandFailure(result);
191 toastManager.add(
192 stackedThreadToast({
193 type: "error",
194 title: "Clone failed",
195 description: error instanceof Error ? error.message : "An error occurred.",
196 }),
197 );
198 }
199 setState({ status: "missing-repository", repository });
200 return;
201 }
202 // The server adds the project at once and clones behind it. The draft shows the clone's
203 // progress and holds Send until the code is there.
204 const project = await waitForProject(scopeProjectRef(environmentId, projectId), 5_000).catch(
205 () => null,
206 );
207 if (project === null) {
208 setState({ status: "failed", message: "The clone started, but its project did not appear." });
209 return;
210 }
211 // A pull request checks out from the cloned repository, which is not there yet.
212 if (link.kind === "pull-request") {
213 setState({
214 status: "failed",
215 title: `Cloning ${repository.owner}/${repository.name}`,
216 message: "Open this link again when the clone finishes.",
217 });
218 return;
219 }
220 await openDraft(project);
221 };
222
223 const start = useEffectEvent(() => {
224 if (startedRef.current) return;
225 startedRef.current = true;
226 openedLinkKeys.add(locationKey);
227 if (link.kind === "pull-request") {
228 const thread = findPromptLinkPullRequestThread(threads, link.pullRequest);
229 if (thread !== undefined) {
230 setState({ status: "opened" });
231 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
232 if (link.prompt !== null) placeLinkedPrompt(threadRef, link.prompt);
233 void navigate({
234 to: "/$environmentId/$threadId",
235 params: { environmentId: thread.environmentId, threadId: thread.id },
236 replace: true,
237 });
238 return;
239 }
240 }
241 const repository = link.kind === "new" ? link.repository : link.pullRequest;
242 if (repository === null) {
243 void openWithoutProject();
244 return;
245 }
246 const project = findPromptLinkProject(recentProjects(), repository);
247 if (project === undefined) {
248 setState({ status: "missing-repository", repository });
249 return;
250 }
251 if (link.kind === "pull-request") {
252 setState({ status: "pull-request-checkout", project });
253 return;
254 }
255 void openDraft(project);
256 });
257
258 // Runs once per link, when the shells are in. Later shell updates must not open it again.
259 useEffect(() => {
260 if (ready && state.status === "opening") start();
261 }, [ready, state.status]);
262
263 switch (state.status) {
264 case "opening":
265 return <LinkPage title="Opening the link..." />;
266 case "opened":
267 return (
268 <LinkPage title="This link is open">
269 <Button
270 size="sm"
271 variant="outline"
272 onClick={() => {
273 openedLinkKeys.delete(locationKey);
274 startedRef.current = false;
275 setState({ status: "opening" });
276 }}
277 >
278 Open it again
279 </Button>
280 </LinkPage>
281 );
282 case "failed":
283 return (
284 <LinkPage title={state.title ?? "Could not open the link"} description={state.message} />
285 );
286 case "missing-repository": {
287 const name = `${state.repository.owner}/${state.repository.name}`;
288 return (
289 <LinkPage
290 title={`You don't have ${name}`}
291 description="Clone it to work on it here, or start without a project."
292 >
293 <Button size="sm" onClick={() => void cloneRepository(state.repository)}>
294 Clone {name}
295 </Button>
296 <Button size="sm" variant="outline" onClick={() => void openWithoutProject()}>
297 Use without project
298 </Button>
299 </LinkPage>
300 );
301 }
302 case "pull-request-checkout":
303 return link.kind === "pull-request" ? (
304 <PullRequestLinkCheckout link={link} project={state.project} />
305 ) : null;
306 }
307}
308
309/**
310 * Asks, then checks the linked pull request out into its own worktree and opens a thread there.
311 * The checkout runs the project's setup script on code from the pull request, so a link alone
312 * never starts it.
313 */
314function PullRequestLinkCheckout({
315 link,
316 project,
317}: {
318 readonly link: Extract<NewThreadLink, { kind: "pull-request" }>;
319 readonly project: EnvironmentProject;
320}) {
321 const { handoff, startHandoff } = usePullRequestHandoffs({
322 environmentId: project.environmentId,
323 detail: {
324 projectId: project.id,
325 workspaceRoot: project.workspaceRoot,
326 url: promptLinkPullRequestUrl(link.pullRequest),
327 },
328 });
329 const warnedRef = useRef(false);
330 useEffect(() => {
331 if (warnedRef.current || link.prompt === null) return;
332 warnedRef.current = true;
333 warnAboutHiddenCharacters(link.prompt);
334 }, [link.prompt]);
335 const { owner, name, number } = link.pullRequest;
336 return (
337 <LinkPage
338 title={`Check out ${owner}/${name}#${number}?`}
339 description="This puts the pull request's code in a new worktree and runs your project's setup script on it."
340 >
341 <Button
342 size="sm"
343 disabled={handoff !== null}
344 // The checkout reports its own progress and says the task is in the composer.
345 onClick={() =>
346 void startHandoff(
347 "prompt-link",
348 link.prompt === null ? null : { prompt: link.prompt.text },
349 )
350 }
351 >
352 {handoff !== null ? "Checking out..." : "Check out"}
353 </Button>
354 </LinkPage>
355 );
356}
357
358function LinkPage({
359 title,
360 description,
361 children,
362}: {
363 readonly title: string;
364 readonly description?: string;
365 readonly children?: ReactNode;
366}) {
367 return (
368 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
369 {isElectron ? <WorkspacePageHeader electron /> : null}
370 <Empty className="flex-1">
371 <EmptyHeader className="max-w-md">
372 <EmptyTitle>{title}</EmptyTitle>
373 {description ? <EmptyDescription>{description}</EmptyDescription> : null}
374 {children ? <div className="mt-5 flex justify-center gap-2">{children}</div> : null}
375 </EmptyHeader>
376 </Empty>
377 </SidebarInset>
378 );
379}