diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index a5c0d71325a78297fe40540de4f44297f13fca0b..a1a96d9dbf3dbdbef0958a3b5bf624ffeb3e0249 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -2,8 +2,6 @@ import { MaterialListRow } from "../../components/MaterialListRow"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; import { LegendList } from "@legendapp/list/react-native"; import { isAtomCommandInterrupted, @@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useServerConfigs, waitForProject } from "../../state/entities"; +import { useServerConfigs } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; @@ -208,7 +206,7 @@ export function NewTaskEnvironmentPickerRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const serverConfigs = useServerConfigs(); - const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false, }); const [movingToEnvironmentId, setMovingToEnvironmentId] = useState(null); @@ -218,23 +216,20 @@ export function NewTaskEnvironmentPickerRouteScreen() { async function moveScratchDraft(environmentId: EnvironmentId): Promise { setMovingToEnvironmentId(environmentId); try { - const result = await ensureScratch({ environmentId, input: {} }); - if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not switch machine", - error instanceof Error - ? error.message - : "The folder for threads without a project could not be created.", - ); + const result = await openScratch({ environmentId, input: {} }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch machine", + error instanceof Error + ? error.message + : "The folder for threads without a project could not be created.", + ); + } return; } - const project = await waitForProject({ environmentId, projectId: result.value.projectId }); - if (project === null) { - Alert.alert("Could not switch machine", "It has not reached this device yet. Try again."); - return; - } - flow.setProject(project); + flow.setProject(result.value); navigation.goBack(); } finally { setMovingToEnvironmentId(null); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 1ba1409d7976f26c4d9d1a0ef2f03886916d2682..1a5323281c47ee2ffc70100f926f86a3d3a5bcf0 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -7,11 +7,13 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -21,7 +23,7 @@ import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; +import { useProjects, useServerConfigs } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; @@ -173,7 +175,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, ); const scratchEnvironment = scratchEnvironments.find( @@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps connectedEnvironments.filter( (environment) => - canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, ), [connectedEnvironments, serverConfigs], ); diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts index 7a87998832814ec3d44566390a341e9749eb54fc..aaad677ef9cdf39b6bf75e768ddf7a61b1a7f88e 100644 --- a/apps/mobile/src/state/projects.ts +++ b/apps/mobile/src/state/projects.ts @@ -5,8 +5,10 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; -export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, }); +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { + projectAtom: environmentProjects.projectAtom, +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40defdf551e185828a351de5ae1ff1f6109333a9..55d5492ae74f529cd6197bc5adfe82bad995b9b3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4396,7 +4396,7 @@ export default function ChatView(props: ChatViewProps) { } // Keep send disabled until the destination Scratch project is ready. setIsEnvironmentChanging(true); - void openScratchProject(target.environmentId) + void openScratchProject(target.environmentId, "Could not switch machine") .then((project) => { if (project) retarget(project); }) diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index 0d25f33858554ddb0394111abe7b94534e45ef18..52fdf39cc522f6746986c44e200ba550eb8e284b 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -1,4 +1,5 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -8,17 +9,16 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback } from "react"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { waitForProject } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { projectEnvironment } from "~/state/projects"; import { useAtomCommand } from "~/state/use-atom-command"; import { useNewThreadHandler } from "./useHandleNewThread"; -function reportScratchFailure(error: unknown) { +function reportScratchFailure(title: string, error: unknown) { toastManager.add( stackedThreadToast({ type: "error", - title: "Could not start without a project", + title, description: error instanceof Error ? error.message : "An error occurred.", }), ); @@ -32,16 +32,17 @@ function reportScratchFailure(error: unknown) { */ export function useScratchProject() { const { environments } = useEnvironments(); - const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false }); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); const handleNewThread = useNewThreadHandler(); /** The scratch folder of a connected environment, or null when it offers none. */ const scratchWorkspaceRootFor = useCallback( (environmentId: EnvironmentId | null): string | null => { const environment = environments.find((entry) => entry.environmentId === environmentId); - return environment?.connection.phase === "connected" - ? (environment.serverConfig?.scratchWorkspaceRoot ?? null) - : null; + return availableScratchWorkspaceRoot( + environment?.connection.phase, + environment?.serverConfig, + ); }, [environments], ); @@ -62,24 +63,18 @@ export function useScratchProject() { /** Resolves to the scratch project once it is in this client's store. */ const openScratchProject = useCallback( - async (environmentId: EnvironmentId): Promise => { - const result = await ensureScratch({ environmentId, input: {} }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - reportScratchFailure(squashAtomCommandFailure(result)); - } - return null; + async ( + environmentId: EnvironmentId, + failureTitle = "Could not start without a project", + ): Promise => { + const result = await openScratch({ environmentId, input: {} }); + if (result._tag === "Success") return result.value; + if (!isAtomCommandInterrupted(result)) { + reportScratchFailure(failureTitle, squashAtomCommandFailure(result)); } - // Drafts key off the project's stored path and settings, so wait for - // the create event to reach the store before targeting one. - return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch( - (error: unknown) => { - reportScratchFailure(error); - return null; - }, - ); + return null; }, - [ensureScratch], + [openScratch], ); const startScratchThread = useCallback( @@ -87,7 +82,7 @@ export function useScratchProject() { const project = await openScratchProject(environmentId); if (project) { await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( - reportScratchFailure, + (error: unknown) => reportScratchFailure("Could not start without a project", error), ); } }, diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index d4e1098a364e5db85bd88ca521627cef8b8bb07a..d5a07058a80b00da0a261e09f46753c55267948c 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -7,7 +7,13 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; -export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { + projectAtom: environmentProjects.projectAtom, +}); /** * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile * surface, so the atom family lives here instead of the shared client-runtime @@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti staleTimeMs: 5_000, idleTtlMs: 60_000, }); -export const environmentProjects = createEnvironmentProjectAtoms({ - catalogValueAtom: environmentCatalog.catalogValueAtom, - snapshotAtom: environmentSnapshotAtom, -}); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 285d713a83e8f95e6cc0908e72f3d42d64b794bb..e294e9fdc0a6c0fdc1a322339c5d8615ec779a0b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -19,7 +19,9 @@ A thread does not need a project. To start one without a project, click **or start without a project** under a new thread's heading, pick **No project** from the project menu in that heading or from **New thread in...** in the command palette, or press `mod+alt+n`. On mobile, pick **No project** from the project -list. To move a draft into a project, pick the project in the heading. +list. It starts on your current machine; before sending, pick another machine +from the machine menu to move it there. To move a draft into a project, pick the +project in the heading. Each thread without a project works in its own folder under `~/.t3/scratch` (the `scratch` folder of your T3 data directory), named after its date, the first words diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 505f99ad8e25a35106d391a4d8c7f78e44b5c6e6..8dd937d3a68dd4de3878b25557ac2cea61cc6dca 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -4,6 +4,7 @@ import type { EnvironmentId, ProjectMutation, ProjectId, + ServerConfig, SourceControlDiscoveryResult, SourceControlProviderKind, SourceControlRepositoryInfo, @@ -36,6 +37,19 @@ export function canCreateProjectInEnvironment( return connectionPhase === "connected"; } +/** + * The Scratch folder an environment offers threads without a project right + * now, or null while it is not connected or has none. + */ +export function availableScratchWorkspaceRoot( + connectionPhase: EnvironmentConnectionPhase | null | undefined, + serverConfig: Pick | null | undefined, +): string | null { + return canCreateProjectInEnvironment(connectionPhase) + ? (serverConfig?.scratchWorkspaceRoot ?? null) + : null; +} + export type AddProjectRemoteSourceReadiness = Record< AddProjectRemoteSource, { readonly ready: boolean; readonly hint: string | null } diff --git a/packages/client-runtime/src/state/projectCommands.test.ts b/packages/client-runtime/src/state/projectCommands.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e1bc85ca6dfe4ffea9438a20ac1317cda1bfbcb --- /dev/null +++ b/packages/client-runtime/src/state/projectCommands.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { EnvironmentProject } from "./models.ts"; +import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("scratch"); +const PROJECT = { + id: PROJECT_ID, + environmentId: ENVIRONMENT_ID, + workspaceRoot: "/scratch", +} as EnvironmentProject; + +const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: { environmentId: ENVIRONMENT_ID }, + session: yield* SubscriptionRef.make( + Option.some({ + client: { + [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), + }, + } as unknown as RpcSession), + ), + } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + ), + ); + const storedProject = Atom.make(null); + const commands = createProjectEnvironmentAtoms(runtime, { + projectAtom: (ref: ScopedProjectRef) => + ref.environmentId === ENVIRONMENT_ID && ref.projectId === PROJECT_ID + ? storedProject + : Atom.make(null), + }); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const openScratch = Effect.promise(() => + commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }), + ); + return { registry, storedProject, openScratch }; +}); + +describe("openScratch", () => { + it.effect("resolves once the created project reaches the client store", () => + Effect.gen(function* () { + const { registry, storedProject, openScratch } = yield* makeHarness(); + const opening = yield* Effect.forkChild(openScratch); + yield* Effect.yieldNow; + registry.set(storedProject, PROJECT); + const result = yield* Fiber.join(opening); + expect(result).toMatchObject({ _tag: "Success", value: PROJECT }); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 5c39113501fb10d7af2b363b7aeee1e7b6ea86e5..261fee9db09ecc4875b6e352305b767463de5443 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -1,7 +1,20 @@ -import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ProjectReadFileResult, + type ScopedProjectRef, + WS_METHODS, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; -import { Atom } from "effect/unstable/reactivity"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { type EnvironmentRpcInput, request } from "../rpc/client.ts"; +import type { EnvironmentProject } from "./models.ts"; import { createAtomCommandScheduler, createEnvironmentCommand, @@ -39,8 +52,22 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); } +/** The Scratch project was created, but its event never reached this client. */ +export class ScratchProjectNotLoadedError extends Schema.TaggedError()( + "ScratchProjectNotLoadedError", + { projectId: ProjectId }, +) { + override get message(): string { + return "The folder for threads without a project has not reached this device yet. Try again."; + } +} + export function createProjectEnvironmentAtoms( runtime: Atom.AtomRuntime, + options: { + /** The client store's project; openScratch waits here for the created project. */ + readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom; + }, ) { const projectScheduler = createAtomCommandScheduler(); const fileScheduler = createAtomCommandScheduler(); @@ -92,10 +119,31 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), - // Finds or creates the environment's Scratch project and returns its id. - ensureScratch: createEnvironmentRpcCommand(runtime, { - label: "environment-data:projects:ensure-scratch", - tag: WS_METHODS.projectsEnsureScratch, + // Finds or creates the environment's Scratch project and resolves once the + // project is in the client store, since drafts key off its stored path. + openScratch: createEnvironmentCommand(runtime, { + label: "environment-data:projects:open-scratch", + execute: ( + input: EnvironmentRpcInput, + registry, + environmentId, + ) => + request(WS_METHODS.projectsEnsureScratch, input).pipe( + Effect.flatMap(({ projectId }) => + AtomRegistry.toStream(registry, options.projectAtom({ environmentId, projectId })).pipe( + Stream.filter(Predicate.isNotNull), + Stream.runHead, + Effect.timeoutOption("10 seconds"), + Effect.map(Option.flatten), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => Effect.fail(new ScratchProjectNotLoadedError({ projectId })), + }), + ), + ), + ), + ), scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }),