Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<EnvironmentId | null>(null);
Expand All @@ -218,23 +216,20 @@ export function NewTaskEnvironmentPickerRouteScreen() {
async function moveScratchDraft(environmentId: EnvironmentId): Promise<void> {
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.",
);
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.");
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;
}
flow.setProject(project);
flow.setProject(result.value);
navigation.goBack();
} finally {
setMovingToEnvironmentId(null);
Expand Down
48 changes: 23 additions & 25 deletions apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -173,16 +175,18 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
) ?? null)
: null;
const { connectedEnvironments } = useRemoteConnectionStatus();
const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {
const openScratch = useAtomCommand(projectEnvironment.openScratch, {
reportFailure: false,
});
// Threads without a project need a connected environment that offers them.
// The row starts on the selected environment when it has one, otherwise the
// first that does; the draft page's machine picker moves it from there.
const scratchEnvironments = connectedEnvironments.filter(
(environment) =>
canCreateProjectInEnvironment(environment.connectionState) &&
serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,
availableScratchWorkspaceRoot(
environment.connectionState,
serverConfigs.get(environment.environmentId),
) !== null,
);
const scratchEnvironment =
scratchEnvironments.find(
Expand Down Expand Up @@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
const environmentId = scratchEnvironment.environmentId;
scratchStartInFlightRef.current = true;
try {
const result = await ensureScratch({ environmentId, input: {} });
if (AsyncResult.isFailure(result)) {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not start without a project",
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 start without a project",
"It has not reached this device yet. Pick No project from the list once it appears.",
);
const result = await openScratch({ environmentId, input: {} });
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Alert.alert(
"Could not start without a project",
error instanceof Error
? error.message
: "The folder for threads without a project could not be created.",
);
}
return;
}
await selectProject(project);
await selectProject(result.value);
} finally {
scratchStartInFlightRef.current = false;
}
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import {
useRemoteConnectionStatus,
useSavedRemoteConnections,
} from "../../state/use-remote-environment-registry";
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 { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
Expand Down Expand Up @@ -367,8 +367,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
() =>
connectedEnvironments.filter(
(environment) =>
canCreateProjectInEnvironment(environment.connectionState) &&
serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,
availableScratchWorkspaceRoot(
environment.connectionState,
serverConfigs.get(environment.environmentId),
) !== null,
),
[connectedEnvironments, serverConfigs],
);
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/state/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
Expand Down
43 changes: 19 additions & 24 deletions apps/web/src/hooks/useScratchProject.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
Expand All @@ -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.",
}),
);
Expand All @@ -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],
);
Expand All @@ -62,32 +63,26 @@ export function useScratchProject() {

/** Resolves to the scratch project once it is in this client's store. */
const openScratchProject = useCallback(
async (environmentId: EnvironmentId): Promise<EnvironmentProject | null> => {
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<EnvironmentProject | null> => {
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(
async (environmentId: EnvironmentId) => {
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),
);
}
},
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/state/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti
staleTimeMs: 5_000,
idleTtlMs: 60_000,
});
export const environmentProjects = createEnvironmentProjectAtoms({
catalogValueAtom: environmentCatalog.catalogValueAtom,
snapshotAtom: environmentSnapshotAtom,
});
4 changes: 3 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/client-runtime/src/operations/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
EnvironmentId,
ProjectMutation,
ProjectId,
ServerConfig,
SourceControlDiscoveryResult,
SourceControlProviderKind,
SourceControlRepositoryInfo,
Expand Down Expand Up @@ -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<ServerConfig, "scratchWorkspaceRoot"> | null | undefined,
): string | null {
return canCreateProjectInEnvironment(connectionPhase)
? (serverConfig?.scratchWorkspaceRoot ?? null)
: null;
}

export type AddProjectRemoteSourceReadiness = Record<
AddProjectRemoteSource,
{ readonly ready: boolean; readonly hint: string | null }
Expand Down
Loading
Loading