Open History to see the last commit
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import {
3 getAddProjectInitialQuery,
4 getCloneDestinationPath,
5 getCloneDirectoryPath,
6} from "@t3tools/client-runtime/operations/projects";
7import {
8 findPromptLinkProjects,
9 findPromptLinkPullRequestThread,
10 pickPromptLinkPullRequestThread,
11 resolvePromptLinkModelSelection,
12} from "@t3tools/client-runtime/prompt-links";
13import {
14 isAtomCommandInterrupted,
15 squashAtomCommandFailure,
16} from "@t3tools/client-runtime/state/runtime";
17import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
18import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
19import {
20 type NewThreadLink,
21 type PromptLinkRepository,
22 promptLinkPullRequestUrl,
23} from "@t3tools/shared/promptLinks";
24import { useAtomValue } from "@effect/atom-react";
25import { useLocation, useNavigate } from "@tanstack/react-router";
26import { Atom } from "effect/reactivity";
27import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
28
29import { useComposerDraftStore } from "~/composerDraftStore";
30import { isElectron } from "~/env";
31import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
32import { useScratchProject } from "~/hooks/useScratchProject";
33import { newProjectId } from "~/lib/utils";
34import { useProjects, useThreadShells, waitForProject } from "~/state/entities";
35import { appAtomRegistry } from "~/rpc/atomRegistry";
36import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
37import { environmentPresentations } from "~/state/presentation";
38import { allReachableShellsLiveAtom, environmentShell } from "~/state/shell";
39import { sourceControlEnvironment } from "~/state/sourceControl";
40import { pullRequestEnvironment } from "~/state/pullRequests";
41import { useAtomCommand } from "~/state/use-atom-command";
42
43import { usePullRequestHandoffs } from "../pullRequest/usePullRequestActions";
44import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
45import { Button } from "../ui/button";
46import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
47import { SidebarInset } from "../ui/sidebar";
48import { stackedThreadToast, toastManager } from "../ui/toast";
49import { suggestAppAccessForRemote } from "../sourceControlAppAccessToast";
50import { WorkspacePageHeader } from "../WorkspacePageHeader";
51import { placeLinkedPrompt, warnAboutHiddenCharacters } from "./linkedPrompt";
52
53type LinkState =
54 | { readonly status: "opening" }
55 | { readonly status: "opened" }
56 | { readonly status: "failed"; readonly message: string; readonly title?: string }
57 | { readonly status: "missing-repository"; readonly repository: PromptLinkRepository }
58 | {
59 readonly status: "pull-request-checkout";
60 readonly project: EnvironmentProject;
61 };
62
63type LinkLaunch =
64 | {
65 readonly kind: "thread";
66 readonly thread: Pick<ReturnType<typeof useThreadShells>[number], "environmentId" | "id">;
67 }
68 | { readonly kind: "draft"; readonly project: EnvironmentProject }
69 | { readonly kind: "scratch" }
70 | { readonly kind: "settled" };
71
72const openedLinkKeys = new Set<string>();
73
74type PullRequestThreadLookup = {
75 readonly targets: ReadonlyArray<readonly [environmentId: EnvironmentId, projectId: ProjectId]>;
76 readonly repository: string;
77 readonly number: number;
78};
79
80// A pull request's thread can be an older settled thread no shell holds, so ask every
81// environment with the repository.
82const pullRequestThreadLookupAtom = Atom.family((key: string) => {
83 const { targets, repository, number } = JSON.parse(key) as PullRequestThreadLookup;
84 return Atom.make((get) =>
85 pickPromptLinkPullRequestThread(
86 targets
87 // A shell that is not live cannot answer, and its lookup would wait forever.
88 .filter(([environmentId]) => get(environmentShell.statusAtom(environmentId)) === "live")
89 .map(([environmentId, projectId]) => ({
90 environmentId,
91 result: get(
92 pullRequestEnvironment.linkedThreadsUncached({
93 environmentId,
94 input: { projectId, repository, number },
95 }),
96 ),
97 })),
98 ),
99 ).pipe(Atom.withLabel(`web-prompt-link-pull-request-thread:${key}`));
100});
101
102const NO_PULL_REQUEST_THREAD_LOOKUP = Atom.make(null);
103
104export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) {
105 const locationKey = useLocation({ select: (location) => location.state.__TSR_key ?? "" });
106 const navigate = useNavigate();
107 const ready = useAtomValue(allReachableShellsLiveAtom);
108 const projects = useProjects();
109 const threads = useThreadShells();
110 const { environments } = useEnvironments();
111 const primaryEnvironmentId = usePrimaryEnvironmentId();
112 const handleNewThread = useNewThreadHandler();
113 const { scratchEnvironmentId, openScratchProject } = useScratchProject();
114 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
115 reportFailure: false,
116 });
117 const [state, setState] = useState<LinkState>(() =>
118 openedLinkKeys.has(locationKey) ? { status: "opened" } : { status: "opening" },
119 );
120 const [started, setStarted] = useState(false);
121 const [launch, setLaunch] = useState<LinkLaunch | null>(null);
122 const launchedRef = useRef<LinkLaunch | null>(null);
123
124 const recentProjects = () =>
125 sortScopedProjectsForSidebar(
126 projects.filter((project) => project.isScratch !== true),
127 threads,
128 "updated_at",
129 );
130
131 const lastActiveEnvironmentId = () => {
132 let latest: (typeof threads)[number] | undefined;
133 for (const thread of threads) {
134 if (latest === undefined || thread.updatedAt > latest.updatedAt) latest = thread;
135 }
136 return latest?.environmentId ?? primaryEnvironmentId;
137 };
138
139 const openDraft = async (project: EnvironmentProject, extraPrompt?: string) => {
140 const opened = await handleNewThread(scopeProjectRef(project.environmentId, project.id), {
141 replace: true,
142 }).catch(() => null);
143 if (opened === null) {
144 setState({ status: "failed", message: "T3 Code could not open a new thread." });
145 return;
146 }
147 setState({ status: "opened" });
148 const notes: Array<string> = [];
149 if (link.kind === "new" && link.provider !== null) {
150 const providers =
151 appAtomRegistry
152 .get(environmentPresentations.providersByEnvironmentAtom)
153 .get(project.environmentId) ?? [];
154 const resolved = resolvePromptLinkModelSelection(providers, {
155 provider: link.provider,
156 model: link.model,
157 });
158 if (resolved !== null) {
159 useComposerDraftStore.getState().setModelSelection(opened.draftId, resolved.selection, {
160 explicit: true,
161 replaceOptions: true,
162 });
163 }
164 if (resolved === null || !resolved.exact) {
165 notes.push(
166 resolved === null
167 ? `The link asked for ${link.provider}, which is not set up here, so your default model is used.`
168 : `The link asked for ${link.model}, which ${link.provider} does not offer here, so its default model is used.`,
169 );
170 }
171 }
172 const text = [link.prompt?.text, extraPrompt].filter(Boolean).join("\n\n");
173 if (text.length === 0) return;
174 placeLinkedPrompt(
175 opened.draftId,
176 { text, hiddenCharacters: link.prompt?.hiddenCharacters ?? 0 },
177 notes,
178 );
179 };
180
181 const openScratchDraft = async () => {
182 const environmentId = scratchEnvironmentId(lastActiveEnvironmentId());
183 const project = environmentId === null ? null : await openScratchProject(environmentId);
184 if (project === null) {
185 setState({
186 status: "failed",
187 message: "None of your machines can start a thread without a project right now.",
188 });
189 return;
190 }
191 await openDraft(
192 project,
193 link.kind === "pull-request" ? promptLinkPullRequestUrl(link.pullRequest) : undefined,
194 );
195 };
196
197 const openWithoutProject = () => {
198 setState({ status: "opening" });
199 void openScratchDraft();
200 };
201
202 const cloneRepository = async (repository: PromptLinkRepository) => {
203 const environmentId = lastActiveEnvironmentId();
204 const environment = environments.find((entry) => entry.environmentId === environmentId);
205 const serverConfig = environment?.serverConfig ?? null;
206 const newProjectsRoot = serverConfig?.newProjectsRoot ?? null;
207 if (environmentId === null || serverConfig === null || newProjectsRoot === null) {
208 setState({
209 status: "failed",
210 message:
211 "This machine cannot clone from a link. Update T3 Code on it, or clone the repository from the command palette.",
212 });
213 return;
214 }
215 setState({ status: "opening" });
216 const remoteUrl = `https://github.com/${repository.owner}/${repository.name}.git`;
217 const settings = serverConfig.settings;
218 const result = await startProjectClone({
219 environmentId,
220 input: {
221 projectId: newProjectId(),
222 title: repository.name,
223 createdAt: new Date().toISOString(),
224 remoteUrl,
225 destinationPath: getCloneDestinationPath(
226 settings.ghqCloneLayout
227 ? getAddProjectInitialQuery(settings.addProjectBaseDirectory)
228 : newProjectsRoot,
229 getCloneDirectoryPath(remoteUrl, settings.ghqCloneLayout === true),
230 ),
231 },
232 });
233 if (result._tag === "Failure") {
234 if (!isAtomCommandInterrupted(result)) {
235 const error = squashAtomCommandFailure(result);
236 toastManager.add(
237 stackedThreadToast({
238 type: "error",
239 title: "Clone failed",
240 description: error instanceof Error ? error.message : "An error occurred.",
241 }),
242 );
243 }
244 setState({ status: "missing-repository", repository });
245 return;
246 }
247 void suggestAppAccessForRemote(
248 environmentId,
249 `https://github.com/${repository.owner}/${repository.name}.git`,
250 );
251 const project = await waitForProject(
252 scopeProjectRef(environmentId, result.value.projectId),
253 5_000,
254 ).catch(() => null);
255 if (project === null) {
256 setState({ status: "failed", message: "The clone started, but its project did not appear." });
257 return;
258 }
259 if (link.kind === "pull-request") {
260 setState({
261 status: "failed",
262 title: `Cloning ${repository.owner}/${repository.name}`,
263 message: "Open this link again when the clone finishes.",
264 });
265 return;
266 }
267 await openDraft(project);
268 };
269
270 const localPullRequestThread =
271 ready && link.kind === "pull-request"
272 ? findPromptLinkPullRequestThread(threads, link.pullRequest)
273 : undefined;
274 // Mounted only while deciding, so "Open it again" asks the server again.
275 const lookupProjects =
276 ready &&
277 state.status === "opening" &&
278 !started &&
279 link.kind === "pull-request" &&
280 localPullRequestThread === undefined
281 ? findPromptLinkProjects(recentProjects(), link.pullRequest)
282 : [];
283 const serverPullRequestThread = useAtomValue(
284 link.kind === "pull-request" && lookupProjects.length > 0
285 ? pullRequestThreadLookupAtom(
286 JSON.stringify({
287 targets: lookupProjects.map((project) => [project.environmentId, project.id]),
288 repository: `${link.pullRequest.owner}/${link.pullRequest.name}`,
289 number: link.pullRequest.number,
290 } satisfies PullRequestThreadLookup),
291 )
292 : NO_PULL_REQUEST_THREAD_LOOKUP,
293 );
294
295 if (ready && serverPullRequestThread !== undefined && state.status === "opening" && !started) {
296 setStarted(true);
297 const pullRequestThread = localPullRequestThread ?? serverPullRequestThread ?? undefined;
298 const repository = link.kind === "new" ? link.repository : link.pullRequest;
299 const project =
300 repository === null ? undefined : findPromptLinkProjects(recentProjects(), repository)[0];
301 if (pullRequestThread !== undefined) {
302 setState({ status: "opened" });
303 setLaunch({ kind: "thread", thread: pullRequestThread });
304 } else if (repository === null) {
305 setLaunch({ kind: "scratch" });
306 } else if (project === undefined) {
307 setState({ status: "missing-repository", repository });
308 setLaunch({ kind: "settled" });
309 } else if (link.kind === "pull-request") {
310 setState({ status: "pull-request-checkout", project });
311 setLaunch({ kind: "settled" });
312 } else {
313 setLaunch({ kind: "draft", project });
314 }
315 }
316
317 const runLaunch = useEffectEvent((next: LinkLaunch) => {
318 openedLinkKeys.add(locationKey);
319 switch (next.kind) {
320 case "thread": {
321 const threadRef = scopeThreadRef(next.thread.environmentId, next.thread.id);
322 if (link.prompt !== null) placeLinkedPrompt(threadRef, link.prompt);
323 void navigate({
324 to: "/$environmentId/$threadId",
325 params: { environmentId: next.thread.environmentId, threadId: next.thread.id },
326 replace: true,
327 });
328 return;
329 }
330 case "scratch":
331 void openScratchDraft();
332 return;
333 case "draft":
334 void openDraft(next.project);
335 return;
336 case "settled":
337 }
338 });
339
340 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- runs each launch decision once: navigates to the thread or opens a draft
341 useEffect(() => {
342 if (launch === null || launchedRef.current === launch) return;
343 launchedRef.current = launch;
344 runLaunch(launch);
345 }, [launch]);
346
347 switch (state.status) {
348 case "opening":
349 return <LinkPage title="Opening the link..." />;
350 case "opened":
351 return (
352 <LinkPage title="This link is open">
353 <Button
354 size="sm"
355 variant="outline"
356 onClick={() => {
357 openedLinkKeys.delete(locationKey);
358 setStarted(false);
359 setState({ status: "opening" });
360 }}
361 >
362 Open it again
363 </Button>
364 </LinkPage>
365 );
366 case "failed":
367 return (
368 <LinkPage title={state.title ?? "Could not open the link"} description={state.message} />
369 );
370 case "missing-repository": {
371 const name = `${state.repository.owner}/${state.repository.name}`;
372 return (
373 <LinkPage
374 title={`You don't have ${name}`}
375 description="Clone it to work on it here, or start without a project."
376 >
377 <Button size="sm" onClick={() => void cloneRepository(state.repository)}>
378 Clone {name}
379 </Button>
380 <Button size="sm" variant="outline" onClick={openWithoutProject}>
381 Use without project
382 </Button>
383 </LinkPage>
384 );
385 }
386 case "pull-request-checkout":
387 return link.kind === "pull-request" ? (
388 <PullRequestLinkCheckout link={link} project={state.project} />
389 ) : null;
390 }
391}
392
393/** Checkout runs setup from PR code, so links alone must not start it. */
394function PullRequestLinkCheckout({
395 link,
396 project,
397}: {
398 readonly link: Extract<NewThreadLink, { kind: "pull-request" }>;
399 readonly project: EnvironmentProject;
400}) {
401 const { handoff, startHandoff } = usePullRequestHandoffs({
402 environmentId: project.environmentId,
403 detail: {
404 projectId: project.id,
405 workspaceRoot: project.workspaceRoot,
406 url: promptLinkPullRequestUrl(link.pullRequest),
407 },
408 });
409 const warnedRef = useRef(false);
410 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- shows the hidden-characters warning toast once per linked prompt
411 useEffect(() => {
412 if (warnedRef.current || link.prompt === null) return;
413 warnedRef.current = true;
414 warnAboutHiddenCharacters(link.prompt);
415 }, [link.prompt]);
416 const { owner, name, number } = link.pullRequest;
417 return (
418 <LinkPage
419 title={`Check out ${owner}/${name}#${number}?`}
420 description="This puts the pull request's code in a new worktree and runs your project's setup script on it."
421 >
422 <Button
423 size="sm"
424 disabled={handoff !== null}
425 onClick={() =>
426 void startHandoff(
427 "prompt-link",
428 link.prompt === null ? null : { prompt: link.prompt.text },
429 )
430 }
431 >
432 {handoff !== null ? "Checking out..." : "Check out"}
433 </Button>
434 </LinkPage>
435 );
436}
437
438function LinkPage({
439 title,
440 description,
441 children,
442}: {
443 readonly title: string;
444 readonly description?: string;
445 readonly children?: ReactNode;
446}) {
447 return (
448 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
449 {isElectron ? <WorkspacePageHeader electron /> : null}
450 <Empty className="flex-1">
451 <EmptyHeader className="max-w-md">
452 <EmptyTitle>{title}</EmptyTitle>
453 {description ? <EmptyDescription>{description}</EmptyDescription> : null}
454 {children ? <div className="mt-5 flex justify-center gap-2">{children}</div> : null}
455 </EmptyHeader>
456 </Empty>
457 </SidebarInset>
458 );
459}