diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7e90b0743374..12f296a15e69 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1598,7 +1598,7 @@ export function NewTaskDraftScreen(props: { /> ) : null} - {workspaceControls} + {flow.canChooseWorkspace ? {workspaceControls} : null} {modelUnavailable ? ( + canCreateProjectInEnvironment(environment.connectionState) && + serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + ); + const scratchEnvironment = + scratchEnvironments.find( + (environment) => environment.environmentId === selectedEnvironmentId, + ) ?? + scratchEnvironments[0] ?? + null; + const scratchWorkspaceRoot = scratchEnvironment + ? (serverConfigs.get(scratchEnvironment.environmentId)?.scratchWorkspaceRoot ?? null) + : null; + // Once the Scratch project exists it is an ordinary row in the list. + const scratchProjectExists = projects.some( + (project) => + project.environmentId === scratchEnvironment?.environmentId && + isScratchProject(project, scratchWorkspaceRoot), + ); + const canStartScratch = scratchWorkspaceRoot !== null && reservedDestinationProject === null; + const scratchMachineLabel = + connectedEnvironments.length > 1 ? (scratchEnvironment?.environmentLabel ?? null) : null; + const startScratchLabel = scratchMachineLabel + ? `Start without a project on ${scratchMachineLabel}` + : "Start without a project"; + const scratchRowSubtitle = scratchMachineLabel + ? `On ${scratchMachineLabel}` + : "Start a task without a project"; + const scratchStartInFlightRef = useRef(false); async function selectProject(project: EnvironmentProject): Promise { if (incomingShare?.destination && !reservedDestinationProject) { @@ -189,6 +234,36 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + if (!scratchEnvironment || scratchStartInFlightRef.current) return; + 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.", + ); + return; + } + await selectProject(project); + } finally { + scratchStartInFlightRef.current = false; + } + } + useEffect(() => { const destination = incomingShare?.destination; if (!destination) { @@ -264,15 +339,24 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {Platform.OS === "android" ? ( - - catalogState.hasReadyEnvironment - ? navigation.dispatch(StackActions.push("AddProject")) - : navigation.navigate("ConnectionsNew") - } - /> + <> + + catalogState.hasReadyEnvironment + ? navigation.dispatch(StackActions.push("AddProject")) + : navigation.navigate("ConnectionsNew") + } + /> + {canStartScratch ? ( + void startScratch()} + /> + ) : null} + ) : !catalogState.hasReadyEnvironment ? ( ) : ( - navigation.dispatch(StackActions.push("AddProject"))} - > - - Add new project - - + <> + navigation.dispatch(StackActions.push("AddProject"))} + > + + Add new project + + + {canStartScratch ? ( + void startScratch()} + > + + {startScratchLabel} + + + ) : null} + )} ) : visibleScopes.length === 0 ? ( @@ -390,6 +486,55 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps )} + {canStartScratch && !scratchProjectExists && projectScopes.length > 0 ? ( + Platform.OS === "android" ? ( + + void startScratch()} + leading={ + + } + /> + + ) : ( + + void startScratch()} + className="flex-row items-center gap-3 bg-card px-4 py-3.5" + > + + + + + No project + + {scratchRowSubtitle} + + + + + + ) + ) : null} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 61b77afffba1..a723c2fa29f6 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -79,6 +79,7 @@ import { setPendingConnectionError, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; +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"; import { @@ -146,6 +147,8 @@ type NewTaskFlowContextValue = { readonly selectedProjectKey: string | null; readonly selectedModelKey: string | null; readonly workspaceMode: WorkspaceMode; + /** False for threads without a project: their folder has no branch or worktree. */ + readonly canChooseWorkspace: boolean; readonly selectedBranchName: string | null; readonly selectedWorktreePath: string | null; readonly startFromOrigin: boolean; @@ -452,7 +455,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ), [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // A thread without a project runs in a plain folder, so worktree mode + // would leave it unsendable: it is always local and offers no choice. + const canChooseWorkspace = !( + selectedProject !== null && + isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot) + ); + const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace + ? projectSettings.settings.defaultThreadEnvMode + : "local"; // While the file read is pending and nothing above it decided, the // resolved default is provisional. Nothing may write it into the draft // during that window (the auto-branch effect does), or the frozen interim @@ -461,7 +472,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectDraft.workspaceSelection?.mode !== undefined || projectSettings.sources.defaultThreadEnvMode !== "environment" || !t3ProjectFileQuery.isPending; - const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; + const workspaceMode = canChooseWorkspace + ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode) + : "local"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: @@ -971,7 +984,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (text.length === 0 || !draftModelSelection) { return null; } - const workspaceSelection = draft.workspaceSelection; + // A saved choice from before the project went no-project must not + // survive: those threads always run locally in their own folder. + const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined; // Fall back to the resolved mode (server default) so queued tasks drain // with the same mode the composer displayed. const mode = workspaceSelection?.mode ?? workspaceMode; @@ -1031,6 +1046,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }; }, [ + canChooseWorkspace, defaultRuntimeMode, editingPendingProject, editingPendingTask, @@ -1154,6 +1170,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectKey, selectedModelKey, workspaceMode, + canChooseWorkspace, selectedBranchName, selectedWorktreePath, startFromOrigin, @@ -1259,6 +1276,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { startFromOrigin, submitting, workspaceMode, + canChooseWorkspace, appendAttachments, clearAttachments, removeAttachment, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 73aaba5190c1..61f70aac2836 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -115,6 +115,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cb1ed672312e..930e7e7bb369 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -123,7 +123,10 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; -import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts"; +import { + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, +} from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; @@ -5334,6 +5337,225 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates the Scratch project once and restores its folder on reuse", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const created: Array<{ readonly projectId: ProjectId; readonly workspaceRoot: string }> = []; + const iconUpdates: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "project.create") { + created.push({ + projectId: command.projectId, + workspaceRoot: command.workspaceRoot, + }); + } + if (command.type === "project.meta.update") iconUpdates.push(command.projectIcon); + return { sequence: created.length + iconUpdates.length }; + }), + }, + projectionSnapshotQuery: { + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + Option.fromNullishOr( + created.find((project) => project.workspaceRoot === workspaceRoot), + ).pipe( + Option.map((project) => ({ + ...makeDefaultOrchestrationReadModel().projects[0]!, + id: project.projectId, + workspaceRoot, + })), + ), + ), + }, + }, + }); + + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + const config = yield* client[WS_METHODS.serverGetConfig]({}); + const scratchRoot = config.scratchWorkspaceRoot ?? ""; + const first = yield* client[WS_METHODS.projectsEnsureScratch]({}); + // A user may delete the folder; reuse must bring it back. + yield* fileSystem.remove(scratchRoot, { recursive: true }); + const second = yield* client[WS_METHODS.projectsEnsureScratch]({}); + + assert.isTrue(scratchRoot.endsWith("scratch")); + assert.equal(created.length, 1); + assert.equal(created[0]?.workspaceRoot, scratchRoot); + assert.equal(first.projectId, created[0]?.projectId); + assert.equal(second.projectId, first.projectId); + // The icon is set once, at create. + assert.deepEqual(iconUpdates, [ + { kind: "lucide", name: "message-square-dashed", color: "gray" }, + ]); + assert.isTrue(yield* fileSystem.exists(scratchRoot)); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("resolves a lost Scratch create race to the winning project", () => + Effect.gen(function* () { + const winnerId = ProjectId.make("project-scratch-winner"); + let lookups = 0; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Active project already exists for workspace root.", + }), + ), + }, + projectionSnapshotQuery: { + // Empty before the create, then the other client's project. + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.sync(() => + lookups++ === 0 + ? Option.none() + : Option.some({ + ...makeDefaultOrchestrationReadModel().projects[0]!, + id: winnerId, + workspaceRoot, + }), + ), + }, + }, + }); + + const result = yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + client[WS_METHODS.projectsEnsureScratch]({}), + ), + ); + assert.equal(result.projectId, winnerId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("gives each new Scratch thread its own folder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scratchProjectId = ProjectId.make("project-scratch"); + let scratchRoot = ""; + const created: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "thread.create") created.push(command.worktreePath); + return { sequence: created.length }; + }), + readEvents: () => Stream.empty, + }, + projectionSnapshotQuery: { + getProjectShellById: (projectId) => + Effect.succeed( + projectId === scratchProjectId + ? Option.some({ + id: scratchProjectId, + title: "Scratch", + workspaceRoot: scratchRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-25T00:00:00.000Z", + updatedAt: "2026-09-25T00:00:00.000Z", + }) + : Option.none(), + ), + }, + }, + }); + + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + scratchRoot = + (yield* client[WS_METHODS.serverGetConfig]({})).scratchWorkspaceRoot ?? ""; + const createdAt = "2026-09-25T10:00:00.000Z"; + // The second id shares the first's short prefix, the third tries to + // climb out of the scratch root, and the fourth pastes a long token. + const text = "Convert these PNGs to WebP, please!"; + const starts = [ + { id: "a1b2c3d4-scratch-thread", text }, + { id: "a1b2c3d4-other", text }, + { id: "../../escape", text }, + { id: "f00dcafe-long", text: "x".repeat(300) }, + ]; + for (const [index, { id, text: messageText }] of starts.entries()) { + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-scratch-turn-start-${index}`), + threadId: ThreadId.make(id), + message: { + messageId: MessageId.make(`msg-scratch-${index}`), + role: "user", + text: messageText, + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: scratchProjectId, + title: "New thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + }, + createdAt, + }); + } + }), + ), + ); + + // The date is the server's receipt time, not the client's createdAt. + const names = created.map((folder) => path.basename(folder ?? "")); + assert.match(names[0] ?? "", /^\d{4}-\d{2}-\d{2}-convert-these-pngs-to-webp-a1b2c3d4$/); + assert.match(names[1] ?? "", /-convert-these-pngs-to-webp-a1b2c3d4other$/); + assert.match(names[2] ?? "", /-convert-these-pngs-to-webp-escape$/); + assert.match(names[3] ?? "", /^\d{4}-\d{2}-\d{2}-x{48}-f00dcafe$/); + for (const folder of created) { + assert.equal(path.dirname(folder ?? ""), scratchRoot); + assert.isTrue(yield* fileSystem.exists(folder ?? "")); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("withholds Scratch when the data dir sits inside a work tree", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { vcsDriver: { isInsideWorkTree: () => Effect.succeed(true) } }, + }); + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + const config = yield* client[WS_METHODS.serverGetConfig]({}); + const ensure = yield* Effect.flip(client[WS_METHODS.projectsEnsureScratch]({})); + + assert.isUndefined(config.scratchWorkspaceRoot); + assert.include(String(ensure.message), "not available"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("advertises the usable file manager and its reveal label", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 47974366a671..65923773907c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -180,6 +180,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -1775,7 +1776,143 @@ const makeWsRpcLayer = ( return yield* runBootstrap; }); - const dispatchNormalizedCommand = ( + const path = yield* Path.Path; + // Scratch threads run in a plain folder under the data dir. Inside a + // checkout (a dev worktree's .t3, a dotfiles home) that folder would + // inherit the repo's git status and checkpoints, so it is only offered + // when the data dir is outside any work tree. Detection failures and + // defects fail closed and hide the folder, never the config. + // Probed once per connection: a negative VCS detection is not cached. + // An interrupt stays an interrupt, so a config load cancelled mid-probe + // invalidates the cache and the next load probes again. + const [cachedScratchWorkspaceRoot, invalidateScratchWorkspaceRoot] = + yield* Effect.cachedInvalidateWithTTL( + gitWorkflow.isRepository(config.baseDir).pipe( + Effect.map((isRepository) => + isRepository ? undefined : path.resolve(config.baseDir, "scratch"), + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined), + ), + ), + Duration.infinity, + ); + const resolveScratchWorkspaceRoot = cachedScratchWorkspaceRoot.pipe( + Effect.onInterrupt(() => invalidateScratchWorkspaceRoot), + ); + + const fileSystem = yield* FileSystem.FileSystem; + // Each Scratch thread gets its own folder under the Scratch root, named + // from its date, first words, and id. It rides in worktreePath like any + // thread that runs outside its project root, so the provider, terminal, + // and file tree all use it. Threads that already name a folder keep it. + const scratchThreadFolder = (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly createdAt: string; + readonly text: string; + }): Effect.Effect => + Effect.gen(function* () { + if (input.worktreePath !== null) return null; + const scratchRoot = yield* resolveScratchWorkspaceRoot; + if (scratchRoot === undefined) return null; + const project = yield* projectionSnapshotQuery.getProjectShellById(input.projectId).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to look up the thread's project.", + cause, + }), + ), + ); + if ( + Option.isNone(project) || + normalizeProjectPathForComparison(project.value.workspaceRoot) !== + normalizeProjectPathForComparison(scratchRoot) + ) { + return null; + } + // Only [a-z0-9] reaches the name, so it stays one path segment inside + // the scratch root, and the words are capped so pasted data cannot + // outgrow a file name. Each leaf is created without `recursive`, so + // the create itself claims it: a taken short name falls back to the + // full id, which only the same thread can already hold. + const words = input.text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + .slice(0, 5) + .join("-") + .slice(0, 48) + .replace(/-+$/, ""); + const id = input.threadId.toLowerCase().replace(/[^a-z0-9]/g, ""); + const folderFor = (idPart: string) => + path.join( + scratchRoot, + [input.createdAt.slice(0, 10), words, idPart].filter(Boolean).join("-"), + ); + yield* fileSystem.makeDirectory(scratchRoot, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the folder for threads without a project.", + cause, + }), + ), + ); + const claim = (folder: string) => + fileSystem.makeDirectory(folder).pipe( + Effect.as(true), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the thread's folder.", + cause, + }), + ), + ); + const shortFolder = folderFor(id.slice(0, 8)); + if (yield* claim(shortFolder)) return shortFolder; + const fullFolder = folderFor(id); + yield* claim(fullFolder); + return fullFolder; + }); + const withScratchThreadFolder = ( + command: OrchestrationCommand, + ): Effect.Effect => { + if (command.type === "thread.create") { + return scratchThreadFolder({ ...command, text: command.title }).pipe( + Effect.map((worktreePath) => + worktreePath === null ? command : { ...command, worktreePath }, + ), + ); + } + if (command.type !== "thread.turn.start") return Effect.succeed(command); + const bootstrap = command.bootstrap; + const createThread = bootstrap?.createThread; + if (bootstrap === undefined || createThread === undefined) return Effect.succeed(command); + return scratchThreadFolder({ + ...createThread, + threadId: command.threadId, + text: command.message.text, + }).pipe( + Effect.map((worktreePath) => + worktreePath === null + ? command + : { + ...command, + bootstrap: { ...bootstrap, createThread: { ...createThread, worktreePath } }, + }, + ), + ); + }; + + const dispatchPreparedCommand = ( normalizedCommand: OrchestrationCommand, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { const dispatchEffect = @@ -1804,6 +1941,83 @@ const makeWsRpcLayer = ( ); }; + const dispatchNormalizedCommand = ( + command: OrchestrationCommand, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => + withScratchThreadFolder(command).pipe(Effect.flatMap(dispatchPreparedCommand)); + + // One Scratch project per environment, created the first time a client + // asks. Two clients racing the create both reach dispatch; the loser's + // duplicate-root rejection resolves to the project the winner made. + // The folder is (re)made on every call so a deleted Scratch still runs. + const ensureScratchProject = Effect.gen(function* () { + const workspaceRoot = yield* resolveScratchWorkspaceRoot; + if (workspaceRoot === undefined) { + return yield* new OrchestrationDispatchCommandError({ + message: "Threads without a project are not available on this environment.", + }); + } + yield* fileSystem.makeDirectory(workspaceRoot, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the folder for threads without a project.", + cause, + }), + ), + ); + const findScratchProjectId = projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(workspaceRoot) + .pipe( + Effect.map(Option.map((project) => project.id)), + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to look up the home for threads without a project.", + cause, + }), + ), + ); + const existingProjectId = yield* findScratchProjectId; + if (Option.isSome(existingProjectId)) { + return { projectId: existingProjectId.value }; + } + const projectId = ProjectId.make(yield* randomUUID); + return yield* Effect.gen(function* () { + const command = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: yield* serverCommandId("scratch-project-create"), + projectId, + title: "No project", + workspaceRoot, + createdAt: yield* nowIso, + }); + yield* dispatchNormalizedCommand(command); + // A dashed chat bubble in neutral gray marks Scratch. Set once at + // create, so a user's own icon choice is never overwritten. + yield* dispatchNormalizedCommand( + yield* normalizeDispatchCommand({ + type: "project.meta.update", + commandId: yield* serverCommandId("scratch-project-icon"), + projectId, + projectIcon: { kind: "lucide", name: "message-square-dashed", color: "gray" }, + }), + ); + return { projectId }; + }).pipe( + Effect.catch((error) => + findScratchProjectId.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(error), + onSome: (racedProjectId) => Effect.succeed({ projectId: racedProjectId }), + }), + ), + ), + ), + ); + }); + // Only clients that answer /usage-limits themselves see it in the catalogs; // an older client would send the injected command to the provider. const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => @@ -1818,6 +2032,7 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const scratchWorkspaceRoot = yield* resolveScratchWorkspaceRoot; const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( externalLauncher.resolveAvailableEditors(), ); @@ -1866,6 +2081,7 @@ const makeWsRpcLayer = ( threadResumeCompletionMarker: true, threadSnapshotPagination: true, reasoningMessages: true, + ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), }; }); @@ -3031,6 +3247,10 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "source-control" }, ), + [WS_METHODS.projectsEnsureScratch]: () => + observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { + "rpc.aggregate": "orchestration", + }), [WS_METHODS.projectCloneCancel]: (input) => observeRpcEffect( WS_METHODS.projectCloneCancel, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1c3fe81ac63a..b08ecdeb3999 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -47,6 +47,7 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + MessageSquareDashedIcon, LinkIcon, MessageSquareIcon, MonitorIcon, @@ -95,6 +96,8 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useScratchProject } from "../hooks/useScratchProject"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { useThreadSearch } from "../state/queries"; @@ -708,6 +711,7 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); + const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -1100,6 +1104,11 @@ function OpenCommandPaletteDialog(props: { const currentProjectEnvironmentId = activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + // Where "without a project" threads start: the current environment when it + // offers them, otherwise the first connected one that does. + const scratchTargetEnvironmentId = scratchEnvironmentId( + currentProjectEnvironmentId ?? primaryEnvironmentId, + ); const currentProjectCwd = currentProjectId ? (projectCwdById.get(currentProjectId) ?? null) : null; @@ -1270,9 +1279,12 @@ function OpenCommandPaletteDialog(props: { const projectThreadItems = useMemo( () => - enumerateCommandPaletteItems( - buildProjectActionItems({ - projects: pickerProjects, + enumerateCommandPaletteItems([ + ...buildProjectActionItems({ + // The no-project home shows once, as the "No project" item below. + projects: pickerProjects.filter( + (project) => !isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)), + ), valuePrefix: "new-thread-in", searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); @@ -1323,13 +1335,29 @@ function OpenCommandPaletteDialog(props: { ); }, }), - ), + ...(scratchTargetEnvironmentId === null + ? [] + : [ + { + kind: "action" as const, + value: "new-thread-in:no-project", + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + }, + ]), + ]), [ contextualProjectRef, handleNewThread, pickerProjects, projectEnvironmentLocationById, projectGroupByTargetKey, + scratchTargetEnvironmentId, + scratchWorkspaceRootFor, + startScratchThread, ], ); @@ -1787,6 +1815,18 @@ function OpenCommandPaletteDialog(props: { }); } + if (scratchTargetEnvironmentId !== null) { + actionItems.push({ + kind: "action", + value: "action:new-thread-without-project", + searchTerms: ["new thread", "no project", "without project", "none", "chat"], + title: "New thread without a project", + icon: , + shortcutCommand: "chat.newWithoutProject", + run: () => startScratchThread(scratchTargetEnvironmentId), + }); + } + if (activeThreadReferenceCopyTarget !== null) { actionItems.push({ kind: "action", diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index 2da1c0aadb8c..00e044990393 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -1,8 +1,10 @@ -import { PlusIcon } from "lucide-react"; +import { MessageSquareDashedIcon, PlusIcon } from "lucide-react"; import { useCallback } from "react"; import { openCommandPalette } from "../commandPaletteBus"; import { isElectron } from "../env"; +import { useScratchProject } from "../hooks/useScratchProject"; +import { usePrimaryEnvironmentId } from "../state/environments"; import { Button } from "./ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; @@ -10,6 +12,9 @@ import { WorkspacePageHeader } from "./WorkspacePageHeader"; export function NoProjectsHero() { const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { scratchEnvironmentId, startScratchThread } = useScratchProject(); + const scratchTargetEnvironmentId = scratchEnvironmentId(primaryEnvironmentId); return ( @@ -20,12 +25,26 @@ export function NoProjectsHero() {
What should we work on? - Add a project to start your first thread. -
+ + {scratchTargetEnvironmentId === null + ? "Add a project to start your first thread." + : "Add a project, or start without one."} + +
+ {scratchTargetEnvironmentId === null ? null : ( + + )}
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index ea4741b80664..3d5b7bfd311d 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -2,13 +2,22 @@ import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { FolderPlusIcon } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; +import { shortcutLabelForCommand } from "~/keybindings"; +import { projectIconColorClassName } from "~/projectIconColors"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; -import { selectProjectGroupingSettings } from "~/logicalProject"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "~/logicalProject"; import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, @@ -32,6 +41,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +// Menu value for "No project"; real entries are keyed by logical project key. +const NO_PROJECT_VALUE = "no-project"; + interface DraftHeroHeadlineProps { readonly draftId: DraftId | null; readonly activeProjectRef: ScopedProjectRef | null; @@ -56,6 +68,8 @@ export function DraftHeroHeadline({ const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const environmentLabelById = useMemo( () => @@ -131,6 +145,85 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; + // The project that hosts threads without a project appears once, as the + // "No project" item, not as a project row. + const menuEntries = projectPickerEntries.filter( + ({ targetProject }) => + !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), + ); + const activeProject = + activeProjectRef === null + ? null + : (projects.find( + (project) => + project.environmentId === activeProjectRef.environmentId && + project.id === activeProjectRef.projectId, + ) ?? null); + const scratchTargetEnvironmentId = scratchEnvironmentId( + activeProjectRef?.environmentId ?? primaryEnvironmentId, + ); + const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId); + const isScratchDraft = + activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot); + + // The picker can change the draft's target while the no-project home is + // still being opened; a stale continuation must not retarget it again. + const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId }); + useEffect(() => { + latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; + }, [activeProjectKey, scratchTargetEnvironmentId, draftId]); + // Project selection changes the target of the open draft in place. The + // prompt stays in the same composer session, so the sidebar only gets a + // draft row if the user later navigates away. + const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => { + if (!draftId) { + return; + } + latestTargetRef.current = { + draftId, + activeProjectKey: logicalProjectKey, + scratchTargetEnvironmentId: project.environmentId, + }; + const currentDraft = getComposerDraft(draftId); + setLogicalProjectDraftThreadId( + logicalProjectKey, + scopeProjectRef(project.environmentId, project.id), + draftId, + ); + if (!hasExplicitComposerModelSelection(currentDraft)) { + applyStickyState(draftId); + const environmentSettings = environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings; + const defaultModelSelection = environmentSettings + ? resolveProjectSettings(environmentSettings, project.id, project).settings + .defaultModelSelection + : project.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { + replaceOptions: true, + }); + } + } + }; + const startScratch = async (): Promise => { + if (scratchTargetEnvironmentId === null || isScratchDraft) { + return false; + } + const requested = { draftId, activeProjectKey, scratchTargetEnvironmentId }; + const project = await openScratchProject(scratchTargetEnvironmentId); + const latest = latestTargetRef.current; + if ( + !project || + latest.draftId !== requested.draftId || + latest.activeProjectKey !== requested.activeProjectKey || + latest.scratchTargetEnvironmentId !== requested.scratchTargetEnvironmentId + ) { + return false; + } + selectProject(project, deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)); + return true; + }; const projectSelector = shouldShowProjectMenu ? ( @@ -143,55 +236,49 @@ export function DraftHeroHeadline({ // mid-sentence and baffle screen-reader users. } + data-draft-project-trigger="" className="pointer-events-auto max-w-64 align-baseline" /> } > - {activeProjectDisplayName ?? "Choose a project"} + + {isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")} + - {activeProjectDisplayName ? ( + {activeProjectDisplayName && !isScratchDraft ? ( {activeProjectDisplayName} ) : null} { - const entry = projectEntryByKey.get(value as string); - if (!entry || value === activeProjectKey) { + if (value === NO_PROJECT_VALUE) { + void startScratch(); return; } - const project = entry.targetProject; - if (!draftId) { + const entry = projectEntryByKey.get(value as string); + if (!entry || value === activeProjectKey) { return; } - // Project selection changes the target of the open draft in - // place. The prompt stays in the same composer session, so the - // sidebar only gets a draft row if the user later navigates away. - const currentDraft = getComposerDraft(draftId); - setLogicalProjectDraftThreadId( - entry.group.projectKey, - scopeProjectRef(project.environmentId, project.id), - draftId, - ); - if (!hasExplicitComposerModelSelection(currentDraft)) { - applyStickyState(draftId); - const environmentSettings = environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings - ? resolveProjectSettings(environmentSettings, project.id, project).settings - .defaultModelSelection - : project.defaultModelSelection; - if (defaultModelSelection) { - setModelSelection(draftId, defaultModelSelection, { - replaceOptions: true, - }); - } - } + selectProject(entry.targetProject, entry.group.projectKey); }} > - {projectPickerEntries.map(({ group }) => { + {scratchWorkspaceRoot === null ? null : ( + + + {/* Boxed like ProjectFavicon so the label lines up with project rows. */} + + No project + + + )} + {menuEntries.map(({ group }) => { return ( @@ -214,7 +301,7 @@ export function DraftHeroHeadline({ ); })} - + {projectPickerEntries.length > 0 ? : null} New project @@ -235,24 +322,64 @@ export function DraftHeroHeadline({ // a complete sentence too. The project picker is a control rendered inline // in the h1; without an explicit label its widget state bleeds into the // announced phrase. - const headingLabel = hasResolvedProject - ? `What should we build in ${activeProjectDisplayName}?` - : canChooseProject - ? `${activeProjectDisplayName ?? "Choose a project"} to start` - : "Add a project to start"; + const headingLabel = isScratchDraft + ? "What should we work on?" + : hasResolvedProject + ? `What should we build in ${activeProjectDisplayName}?` + : canChooseProject + ? `${activeProjectDisplayName ?? "Choose a project"} to start` + : "Add a project to start"; + + // One click out of the project, phrased as the alternative to the question + // above it. Focus moves to the project picker once this line has gone. + const noProjectShortcut = shortcutLabelForCommand(keybindings, "chat.newWithoutProject"); + const orStartWithoutProject = + scratchWorkspaceRoot !== null && !isScratchDraft && (hasResolvedProject || canChooseProject) ? ( + + + void startScratch().then((started) => { + if (started) { + document.querySelector("[data-draft-project-trigger]")?.focus(); + } + }) + } + /> + } + > + or start without a project + + {noProjectShortcut ? {noProjectShortcut} : null} + + ) : null; return ( -

- {hasResolvedProject ? ( - <>What should we build in {projectSelector}? - ) : canChooseProject ? ( - <>{projectSelector} to start - ) : ( - <>Add a project to start +
+

+ {isScratchDraft ? ( + <>What should we work on? + ) : hasResolvedProject ? ( + <>What should we build in {projectSelector}? + ) : canChooseProject ? ( + <>{projectSelector} to start + ) : ( + <>Add a project to start + )} +

+ {/* Reserved whenever threads can skip a project, so the heading does not + move. Without a project, the picker moves here to choose one. */} + {scratchWorkspaceRoot === null ? null : ( +

+ {isScratchDraft ? projectSelector : orStartWithoutProject} +

)} -

+
); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 7729ce183754..791ebccfa932 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -49,13 +49,13 @@ describe("KeybindingsSettings.logic", () => { it("orders Usage bindings and command choices like the page", () => { const expected = [ "usage.cost", - "usage.open", "usage.tokens", "usage.limits", "usage.period.day", "usage.period.week", "usage.period.month", "usage.period.quarter", + "usage.open", ]; const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed(); expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a06628e5d505..a1f445b5db82 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -19,10 +19,24 @@ const usageCommandOrder = new Map( [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), ); -function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null { - const leftIndex = usageCommandOrder.get(left); - const rightIndex = usageCommandOrder.get(right); - return leftIndex !== undefined && rightIndex !== undefined ? leftIndex - rightIndex : null; +const firstUsageCommand = METRIC_OPTIONS[0].command; + +/** + * Orders commands by `key`, except Usage page commands, which sort as one + * block in page order where the first of them would sort. A total order, so + * adding a binding elsewhere cannot reshuffle the Usage rows. + */ +function compareCommands( + left: KeybindingCommand, + right: KeybindingCommand, + key: (command: KeybindingCommand) => string, +): number { + const leftRank = usageCommandOrder.get(left); + const rightRank = usageCommandOrder.get(right); + if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank; + return key(leftRank === undefined ? left : firstUsageCommand).localeCompare( + key(rightRank === undefined ? right : firstUsageCommand), + ); } export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -215,9 +229,7 @@ export function buildKeybindingRows( }); rowsWithConflicts.sort((left, right) => { - const commandCompare = - compareUsageCommands(left.command, right.command) ?? - left.command.localeCompare(right.command); + const commandCompare = compareCommands(left.command, right.command, (command) => command); if (commandCompare !== 0) return commandCompare; return left.key.localeCompare(right.key); }); @@ -290,10 +302,7 @@ export function buildKeybindingCommandOptions( for (const binding of keybindings) { commands.add(binding.command); } - return [...commands].toSorted( - (left, right) => - compareUsageCommands(left, right) ?? commandLabel(left).localeCompare(commandLabel(right)), - ); + return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel)); } export function commandLabel(command: KeybindingCommand): string { diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts new file mode 100644 index 000000000000..0d25f3385855 --- /dev/null +++ b/apps/web/src/hooks/useScratchProject.ts @@ -0,0 +1,98 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +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) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start without a project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** + * Threads without a project live in the environment's scratch project, a + * plain folder the server owns (users see it as "No project"). The server + * creates it on first use; after that it is an ordinary project on the + * non-git path, and each thread gets its own subfolder. + */ +export function useScratchProject() { + const { environments } = useEnvironments(); + const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { 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; + }, + [environments], + ); + + // A thread without a project starts on the machine the user is working on, + // and only there. With no current machine (the hosted app with nothing + // open), it starts on the one machine that offers it, never a silent pick. + const scratchEnvironmentId = useCallback( + (current: EnvironmentId | null): EnvironmentId | null => { + if (current !== null) return scratchWorkspaceRootFor(current) !== null ? current : null; + const offering = environments.filter( + (entry) => scratchWorkspaceRootFor(entry.environmentId) !== null, + ); + return offering.length === 1 ? (offering[0]?.environmentId ?? null) : null; + }, + [environments, scratchWorkspaceRootFor], + ); + + /** 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; + } + // 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; + }, + ); + }, + [ensureScratch], + ); + + const startScratchThread = useCallback( + async (environmentId: EnvironmentId) => { + const project = await openScratchProject(environmentId); + if (project) { + await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( + reportScratchFailure, + ); + } + }, + [handleNewThread, openScratchProject], + ); + + return { scratchWorkspaceRootFor, scratchEnvironmentId, openScratchProject, startScratchThread }; +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6a82b920ab31..2dae445041bb 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -19,6 +19,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -399,12 +400,18 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; + const environmentConfig = appAtomRegistry + .get(environmentServerConfigsAtom) + .get(threadRef.environmentId); + // A Scratch thread's folder is not a git worktree, and deleting the + // thread keeps its files. + const canDeleteWorktree = + orphanedWorktreePath !== null && + threadProject !== null && + !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot); const localApi = readLocalApi(); let shouldDeleteWorktree = false; - const environmentSettings = appAtomRegistry - .get(environmentServerConfigsAtom) - .get(threadRef.environmentId)?.settings; + const environmentSettings = environmentConfig?.settings; const automaticWorktreeCleanup = environmentSettings ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete : false; diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 5b5b4888269b..0a0b1adee06b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -13,6 +13,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useScratchProject } from "../hooks/useScratchProject"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -37,6 +38,7 @@ function ChatRouteGlobalShortcuts() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { scratchEnvironmentId, startScratchThread } = useScratchProject(); const projectGroupCount = useMemo( () => buildSidebarProjectSnapshots({ @@ -105,6 +107,17 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newWithoutProject") { + const environmentId = scratchEnvironmentId( + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, + ); + if (environmentId === null) return; + event.preventDefault(); + event.stopPropagation(); + void startScratchThread(environmentId); + return; + } + if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); @@ -180,9 +193,12 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + primaryEnvironmentId, projectGroupCount, routeThreadRef, + scratchEnvironmentId, selectedThreadKeysSize, + startScratchThread, legacySidebarEnabled, terminalOpen, ]); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 2525fed64d94..1844eb62c211 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -122,7 +122,8 @@ through the pages you have visited, like a browser's back and forward buttons. `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your -[new-thread defaults](./thread-sidebar.md#start-a-thread). +[new-thread defaults](./thread-sidebar.md#start-a-thread). `chat.newWithoutProject` +(`mod+alt+n`) starts a thread [without a project](./thread-sidebar.md#start-without-a-project). ## Reserved shortcuts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4a19bac401b9..0335856145d3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -13,6 +13,22 @@ an existing worktree, use **New thread in this worktree** from the branch toolba When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it. +### Start without a project + +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. + +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 +of its first message, and a short id, like +`2026-09-25-convert-these-pngs-to-webp-a1b2c3d4`. Deleting a thread keeps its +folder, so the files the agent wrote stay until you delete them. Branch, worktree, and diff controls stay hidden because +these folders are not Git repositories. This is unavailable when the data +directory itself sits inside a Git checkout. + ### Start in the background In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..d969a8c0d379 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -92,6 +92,13 @@ 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, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts index 10ec5025fb9d..36b1f033ac36 100644 --- a/packages/client-runtime/src/state/projects.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -135,6 +135,16 @@ export function findProjectByPath()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6e436a46db5d..215f3777a175 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -159,6 +159,7 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, ProjectListEntriesResult, @@ -293,6 +294,7 @@ export const WS_METHODS = { projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsEnsureScratch: "projects.ensureScratch", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -991,6 +993,13 @@ const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); +// Finds or creates the Scratch project rooted at ServerConfig.scratchWorkspaceRoot. +const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { + payload: Schema.Struct({}), + success: ProjectEnsureScratchResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1508,6 +1517,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, + WsProjectsEnsureScratchRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0c2498ef6471..49faaaf44712 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -611,6 +611,12 @@ export const ServerConfig = Schema.Struct({ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), /** Whether thread reads accept the reasoningMessages opt-in. */ reasoningMessages: Schema.optionalKey(Schema.Boolean), + /** + * Folder behind this environment's Scratch project, for threads that need + * no repository. Present only on servers that answer projects.ensureScratch + * and whose data dir is outside a Git checkout. + */ + scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8029be96f939..a93409bb3cb4 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -49,6 +49,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" },