1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
8import type { EnvironmentId } from "@t3tools/contracts";
9import { useCallback } from "react";
10
11import { stackedThreadToast, toastManager } from "~/components/ui/toast";
12import { useEnvironments } from "~/state/environments";
13import { projectEnvironment } from "~/state/projects";
14import { useAtomCommand } from "~/state/use-atom-command";
15import { useNewThreadHandler } from "./useHandleNewThread";
16
17function reportScratchFailure(title: string, error: unknown) {
18 toastManager.add(
19 stackedThreadToast({
20 type: "error",
21 title,
22 description: error instanceof Error ? error.message : "An error occurred.",
23 }),
24 );
25}
26
27/**
28 * Threads without a project live in the environment's scratch project, a
29 * plain folder the server owns (users see it as "No project"). The server
30 * creates it on first use; after that it is an ordinary project on the
31 * non-git path, and each thread gets its own subfolder.
32 */
33export function useScratchProject() {
34 const { environments } = useEnvironments();
35 const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
36 const handleNewThread = useNewThreadHandler();
37
38 /** The scratch folder of a connected environment, or null when it offers none. */
39 const scratchWorkspaceRootFor = useCallback(
40 (environmentId: EnvironmentId | null): string | null => {
41 const environment = environments.find((entry) => entry.environmentId === environmentId);
42 return availableScratchWorkspaceRoot(
43 environment?.connection.phase,
44 environment?.serverConfig,
45 );
46 },
47 [environments],
48 );
49
50 // A thread without a project starts on the machine the user is working on,
51 // and only there. With no current machine (the hosted app with nothing
52 // open), it starts on the one machine that offers it, never a silent pick.
53 const scratchEnvironmentId = useCallback(
54 (current: EnvironmentId | null): EnvironmentId | null => {
55 if (current !== null) return scratchWorkspaceRootFor(current) !== null ? current : null;
56 const offering = environments.filter(
57 (entry) => scratchWorkspaceRootFor(entry.environmentId) !== null,
58 );
59 return offering.length === 1 ? (offering[0]?.environmentId ?? null) : null;
60 },
61 [environments, scratchWorkspaceRootFor],
62 );
63
64 /** Resolves to the scratch project once it is in this client's store. */
65 const openScratchProject = useCallback(
66 async (
67 environmentId: EnvironmentId,
68 failureTitle = "Could not start without a project",
69 ): Promise<EnvironmentProject | null> => {
70 const result = await openScratch({ environmentId, input: {} });
71 if (result._tag === "Success") return result.value;
72 if (!isAtomCommandInterrupted(result)) {
73 reportScratchFailure(failureTitle, squashAtomCommandFailure(result));
74 }
75 return null;
76 },
77 [openScratch],
78 );
79
80 const startScratchThread = useCallback(
81 async (environmentId: EnvironmentId) => {
82 const project = await openScratchProject(environmentId);
83 if (project) {
84 await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch(
85 (error: unknown) => reportScratchFailure("Could not start without a project", error),
86 );
87 }
88 },
89 [handleNewThread, openScratchProject],
90 );
91
92 return { scratchWorkspaceRootFor, scratchEnvironmentId, openScratchProject, startScratchThread };
93}