authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-30 16:40:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-30 16:40:16-07:00
log6b286ae8a20ef2e18fa70b09e92f66edd859128b
tree68b10b63493d578beb85b3a5c2ed49f17e48b3c0
parent7c67876983ade7ef886c825eefe569b3a96c81e8
signature Signed by PGP key B5690EEEBB952194

feat: start threads without a project (#13612)

Co-authored-by: maria-rcks <maria@kuuro.net> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

23 files changed, 1081 insertions(+), 100 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1-1
......@@ -1598,7 +1598,7 @@ export function NewTaskDraftScreen(props: {
15981598 />
15991599 </View>
16001600 ) : null}
1601 <View className="pb-1">{workspaceControls}</View>
1601 {flow.canChooseWorkspace ? <View className="pb-1">{workspaceControls}</View> : null}
16021602
16031603 {modelUnavailable ? (
16041604 <Pressable
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+163-18
......@@ -7,7 +7,11 @@ import {
77 type StaticScreenProps,
88} from "@react-navigation/native";
99import { SymbolView } from "../../components/AppSymbol";
10import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
11import { isScratchProject } from "@t3tools/client-runtime/state/projects";
1012import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/unstable/reactivity";
1115import { useEffect, useRef, useState } from "react";
1216import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
1317import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -17,7 +21,10 @@ import { MaterialButton } from "../../components/MaterialButton";
1721import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
1822import { AppText as Text } from "../../components/AppText";
1923import { ProjectFavicon } from "../../components/ProjectFavicon";
20import { useProjects } from "../../state/entities";
24import { useProjects, useServerConfigs, waitForProject } from "../../state/entities";
25import { projectEnvironment } from "../../state/projects";
26import { useAtomCommand } from "../../state/use-atom-command";
27import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
2128import type { WorkspaceState } from "../../state/workspaceModel";
2229import { useWorkspaceState } from "../../state/workspace";
2330import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
......@@ -156,6 +163,44 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
156163 project.id === incomingShare.destination?.projectId,
157164 ) ?? null)
158165 : null;
166 const serverConfigs = useServerConfigs();
167 const { connectedEnvironments } = useRemoteConnectionStatus();
168 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, {
169 reportFailure: false,
170 });
171 // Threads without a project need a connected environment that offers them.
172 // The selected environment wins when it has one; otherwise the first that
173 // does, and the entry names that machine whenever there is a choice.
174 const scratchEnvironments = connectedEnvironments.filter(
175 (environment) =>
176 canCreateProjectInEnvironment(environment.connectionState) &&
177 serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined,
178 );
179 const scratchEnvironment =
180 scratchEnvironments.find(
181 (environment) => environment.environmentId === selectedEnvironmentId,
182 ) ??
183 scratchEnvironments[0] ??
184 null;
185 const scratchWorkspaceRoot = scratchEnvironment
186 ? (serverConfigs.get(scratchEnvironment.environmentId)?.scratchWorkspaceRoot ?? null)
187 : null;
188 // Once the Scratch project exists it is an ordinary row in the list.
189 const scratchProjectExists = projects.some(
190 (project) =>
191 project.environmentId === scratchEnvironment?.environmentId &&
192 isScratchProject(project, scratchWorkspaceRoot),
193 );
194 const canStartScratch = scratchWorkspaceRoot !== null && reservedDestinationProject === null;
195 const scratchMachineLabel =
196 connectedEnvironments.length > 1 ? (scratchEnvironment?.environmentLabel ?? null) : null;
197 const startScratchLabel = scratchMachineLabel
198 ? `Start without a project on ${scratchMachineLabel}`
199 : "Start without a project";
200 const scratchRowSubtitle = scratchMachineLabel
201 ? `On ${scratchMachineLabel}`
202 : "Start a task without a project";
203 const scratchStartInFlightRef = useRef(false);
159204
160205 async function selectProject(project: EnvironmentProject): Promise<void> {
161206 if (incomingShare?.destination && !reservedDestinationProject) {
......@@ -189,6 +234,36 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
189234 );
190235 }
191236
237 async function startScratch(): Promise<void> {
238 if (!scratchEnvironment || scratchStartInFlightRef.current) return;
239 const environmentId = scratchEnvironment.environmentId;
240 scratchStartInFlightRef.current = true;
241 try {
242 const result = await ensureScratch({ environmentId, input: {} });
243 if (AsyncResult.isFailure(result)) {
244 const error = Cause.squash(result.cause);
245 Alert.alert(
246 "Could not start without a project",
247 error instanceof Error
248 ? error.message
249 : "The folder for threads without a project could not be created.",
250 );
251 return;
252 }
253 const project = await waitForProject({ environmentId, projectId: result.value.projectId });
254 if (project === null) {
255 Alert.alert(
256 "Could not start without a project",
257 "It has not reached this device yet. Pick No project from the list once it appears.",
258 );
259 return;
260 }
261 await selectProject(project);
262 } finally {
263 scratchStartInFlightRef.current = false;
264 }
265 }
266
192267 useEffect(() => {
193268 const destination = incomingShare?.destination;
194269 if (!destination) {
......@@ -264,15 +339,24 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
264339 {projectEmptyState.detail}
265340 </Text>
266341 {Platform.OS === "android" ? (
267 <MaterialButton
268 label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"}
269 tone="primary"
270 onPress={() =>
271 catalogState.hasReadyEnvironment
272 ? navigation.dispatch(StackActions.push("AddProject"))
273 : navigation.navigate("ConnectionsNew")
274 }
275 />
342 <>
343 <MaterialButton
344 label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"}
345 tone="primary"
346 onPress={() =>
347 catalogState.hasReadyEnvironment
348 ? navigation.dispatch(StackActions.push("AddProject"))
349 : navigation.navigate("ConnectionsNew")
350 }
351 />
352 {canStartScratch ? (
353 <MaterialButton
354 label={startScratchLabel}
355 tone="secondary"
356 onPress={() => void startScratch()}
357 />
358 ) : null}
359 </>
276360 ) : !catalogState.hasReadyEnvironment ? (
277361 <Pressable
278362 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
......@@ -283,14 +367,26 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
283367 </Text>
284368 </Pressable>
285369 ) : (
286 <Pressable
287 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
288 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
289 >
290 <Text className="text-sm font-t3-bold text-primary-foreground">
291 Add new project
292 </Text>
293 </Pressable>
370 <>
371 <Pressable
372 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
373 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
374 >
375 <Text className="text-sm font-t3-bold text-primary-foreground">
376 Add new project
377 </Text>
378 </Pressable>
379 {canStartScratch ? (
380 <Pressable
381 className="rounded-full bg-subtle px-4 py-2.5 active:opacity-70"
382 onPress={() => void startScratch()}
383 >
384 <Text className="text-sm font-t3-bold text-foreground">
385 {startScratchLabel}
386 </Text>
387 </Pressable>
388 ) : null}
389 </>
294390 )}
295391 </View>
296392 ) : visibleScopes.length === 0 ? (
......@@ -390,6 +486,55 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
390486 })}
391487 </View>
392488 )}
489 {canStartScratch && !scratchProjectExists && projectScopes.length > 0 ? (
490 Platform.OS === "android" ? (
491 <View collapsable={false} className="overflow-hidden rounded-[28px] bg-card">
492 <MaterialListRow
493 title="No project"
494 subtitle={scratchRowSubtitle}
495 onPress={() => void startScratch()}
496 leading={
497 <SymbolView
498 name="text.bubble"
499 size={22}
500 tintColorClassName="accent-icon-muted"
501 type="monochrome"
502 />
503 }
504 />
505 </View>
506 ) : (
507 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
508 <Pressable
509 accessibilityRole="button"
510 accessibilityLabel="No project"
511 onPress={() => void startScratch()}
512 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
513 >
514 <View className="h-7 w-7 items-center justify-center">
515 <SymbolView
516 name="text.bubble"
517 size={18}
518 tintColorClassName="accent-icon-muted"
519 type="monochrome"
520 />
521 </View>
522 <View className="min-w-0 flex-1">
523 <Text className="text-base font-t3-bold leading-snug">No project</Text>
524 <Text className="text-xs leading-snug text-foreground-muted" numberOfLines={1}>
525 {scratchRowSubtitle}
526 </Text>
527 </View>
528 <SymbolView
529 name="chevron.right"
530 size={14}
531 tintColorClassName="accent-chevron"
532 type="monochrome"
533 />
534 </Pressable>
535 </View>
536 )
537 ) : null}
393538 </ScrollView>
394539 </MaterialScreenContent>
395540 </View>
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-3
......@@ -79,6 +79,7 @@ import {
7979 setPendingConnectionError,
8080 useSavedRemoteConnections,
8181} from "../../state/use-remote-environment-registry";
82import { isScratchProject } from "@t3tools/client-runtime/state/projects";
8283import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
8384import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
8485import {
......@@ -146,6 +147,8 @@ type NewTaskFlowContextValue = {
146147 readonly selectedProjectKey: string | null;
147148 readonly selectedModelKey: string | null;
148149 readonly workspaceMode: WorkspaceMode;
150 /** False for threads without a project: their folder has no branch or worktree. */
151 readonly canChooseWorkspace: boolean;
149152 readonly selectedBranchName: string | null;
150153 readonly selectedWorktreePath: string | null;
151154 readonly startFromOrigin: boolean;
......@@ -452,7 +455,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
452455 ),
453456 [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile],
454457 );
455 const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode;
458 // A thread without a project runs in a plain folder, so worktree mode
459 // would leave it unsendable: it is always local and offers no choice.
460 const canChooseWorkspace = !(
461 selectedProject !== null &&
462 isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot)
463 );
464 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace
465 ? projectSettings.settings.defaultThreadEnvMode
466 : "local";
456467 // While the file read is pending and nothing above it decided, the
457468 // resolved default is provisional. Nothing may write it into the draft
458469 // during that window (the auto-branch effect does), or the frozen interim
......@@ -461,7 +472,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
461472 selectedProjectDraft.workspaceSelection?.mode !== undefined ||
462473 projectSettings.sources.defaultThreadEnvMode !== "environment" ||
463474 !t3ProjectFileQuery.isPending;
464 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
475 const workspaceMode = canChooseWorkspace
476 ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode)
477 : "local";
465478 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
466479 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
467480 // Keep the user's explicit choice separate from the resolved display value:
......@@ -971,7 +984,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
971984 if (text.length === 0 || !draftModelSelection) {
972985 return null;
973986 }
974 const workspaceSelection = draft.workspaceSelection;
987 // A saved choice from before the project went no-project must not
988 // survive: those threads always run locally in their own folder.
989 const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined;
975990 // Fall back to the resolved mode (server default) so queued tasks drain
976991 // with the same mode the composer displayed.
977992 const mode = workspaceSelection?.mode ?? workspaceMode;
......@@ -1031,6 +1046,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
10311046 };
10321047 },
10331048 [
1049 canChooseWorkspace,
10341050 defaultRuntimeMode,
10351051 editingPendingProject,
10361052 editingPendingTask,
......@@ -1154,6 +1170,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
11541170 selectedProjectKey,
11551171 selectedModelKey,
11561172 workspaceMode,
1173 canChooseWorkspace,
11571174 selectedBranchName,
11581175 selectedWorktreePath,
11591176 startFromOrigin,
......@@ -1259,6 +1276,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
12591276 startFromOrigin,
12601277 submitting,
12611278 workspaceMode,
1279 canChooseWorkspace,
12621280 appendAttachments,
12631281 clearAttachments,
12641282 removeAttachment,
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -115,6 +115,7 @@ export const RPC_REQUIRED_SCOPES = {
115115 [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
116116 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
117117 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
118 [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope,
118119 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
119120 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
120121 [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
apps/server/src/server.test.ts+223-1
......@@ -123,7 +123,10 @@ import * as Keybindings from "./keybindings.ts";
123123import * as ExternalLauncher from "./process/externalLauncher.ts";
124124import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
125125import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
126import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts";
126import {
127 OrchestrationCommandInvariantError,
128 OrchestrationThreadSettleBlockedError,
129} from "./orchestration/Errors.ts";
127130import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
128131import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
129132import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
......@@ -5334,6 +5337,225 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
53345337 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
53355338 );
53365339
5340 it.effect("creates the Scratch project once and restores its folder on reuse", () =>
5341 Effect.gen(function* () {
5342 const fileSystem = yield* FileSystem.FileSystem;
5343 const created: Array<{ readonly projectId: ProjectId; readonly workspaceRoot: string }> = [];
5344 const iconUpdates: Array<unknown> = [];
5345 yield* buildAppUnderTest({
5346 layers: {
5347 orchestrationEngine: {
5348 dispatch: (command) =>
5349 Effect.sync(() => {
5350 if (command.type === "project.create") {
5351 created.push({
5352 projectId: command.projectId,
5353 workspaceRoot: command.workspaceRoot,
5354 });
5355 }
5356 if (command.type === "project.meta.update") iconUpdates.push(command.projectIcon);
5357 return { sequence: created.length + iconUpdates.length };
5358 }),
5359 },
5360 projectionSnapshotQuery: {
5361 getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
5362 Effect.succeed(
5363 Option.fromNullishOr(
5364 created.find((project) => project.workspaceRoot === workspaceRoot),
5365 ).pipe(
5366 Option.map((project) => ({
5367 ...makeDefaultOrchestrationReadModel().projects[0]!,
5368 id: project.projectId,
5369 workspaceRoot,
5370 })),
5371 ),
5372 ),
5373 },
5374 },
5375 });
5376
5377 yield* Effect.scoped(
5378 withWsRpcClient(yield* getWsServerUrl("/ws"), (client) =>
5379 Effect.gen(function* () {
5380 const config = yield* client[WS_METHODS.serverGetConfig]({});
5381 const scratchRoot = config.scratchWorkspaceRoot ?? "";
5382 const first = yield* client[WS_METHODS.projectsEnsureScratch]({});
5383 // A user may delete the folder; reuse must bring it back.
5384 yield* fileSystem.remove(scratchRoot, { recursive: true });
5385 const second = yield* client[WS_METHODS.projectsEnsureScratch]({});
5386
5387 assert.isTrue(scratchRoot.endsWith("scratch"));
5388 assert.equal(created.length, 1);
5389 assert.equal(created[0]?.workspaceRoot, scratchRoot);
5390 assert.equal(first.projectId, created[0]?.projectId);
5391 assert.equal(second.projectId, first.projectId);
5392 // The icon is set once, at create.
5393 assert.deepEqual(iconUpdates, [
5394 { kind: "lucide", name: "message-square-dashed", color: "gray" },
5395 ]);
5396 assert.isTrue(yield* fileSystem.exists(scratchRoot));
5397 }),
5398 ),
5399 );
5400 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5401 );
5402
5403 it.effect("resolves a lost Scratch create race to the winning project", () =>
5404 Effect.gen(function* () {
5405 const winnerId = ProjectId.make("project-scratch-winner");
5406 let lookups = 0;
5407 yield* buildAppUnderTest({
5408 layers: {
5409 orchestrationEngine: {
5410 dispatch: (command) =>
5411 Effect.fail(
5412 new OrchestrationCommandInvariantError({
5413 commandType: command.type,
5414 detail: "Active project already exists for workspace root.",
5415 }),
5416 ),
5417 },
5418 projectionSnapshotQuery: {
5419 // Empty before the create, then the other client's project.
5420 getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
5421 Effect.sync(() =>
5422 lookups++ === 0
5423 ? Option.none()
5424 : Option.some({
5425 ...makeDefaultOrchestrationReadModel().projects[0]!,
5426 id: winnerId,
5427 workspaceRoot,
5428 }),
5429 ),
5430 },
5431 },
5432 });
5433
5434 const result = yield* Effect.scoped(
5435 withWsRpcClient(yield* getWsServerUrl("/ws"), (client) =>
5436 client[WS_METHODS.projectsEnsureScratch]({}),
5437 ),
5438 );
5439 assert.equal(result.projectId, winnerId);
5440 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5441 );
5442
5443 it.effect("gives each new Scratch thread its own folder", () =>
5444 Effect.gen(function* () {
5445 const fileSystem = yield* FileSystem.FileSystem;
5446 const path = yield* Path.Path;
5447 const scratchProjectId = ProjectId.make("project-scratch");
5448 let scratchRoot = "";
5449 const created: Array<string | null> = [];
5450 yield* buildAppUnderTest({
5451 layers: {
5452 orchestrationEngine: {
5453 dispatch: (command) =>
5454 Effect.sync(() => {
5455 if (command.type === "thread.create") created.push(command.worktreePath);
5456 return { sequence: created.length };
5457 }),
5458 readEvents: () => Stream.empty,
5459 },
5460 projectionSnapshotQuery: {
5461 getProjectShellById: (projectId) =>
5462 Effect.succeed(
5463 projectId === scratchProjectId
5464 ? Option.some({
5465 id: scratchProjectId,
5466 title: "Scratch",
5467 workspaceRoot: scratchRoot,
5468 defaultModelSelection: null,
5469 scripts: [],
5470 createdAt: "2026-09-25T00:00:00.000Z",
5471 updatedAt: "2026-09-25T00:00:00.000Z",
5472 })
5473 : Option.none(),
5474 ),
5475 },
5476 },
5477 });
5478
5479 yield* Effect.scoped(
5480 withWsRpcClient(yield* getWsServerUrl("/ws"), (client) =>
5481 Effect.gen(function* () {
5482 scratchRoot =
5483 (yield* client[WS_METHODS.serverGetConfig]({})).scratchWorkspaceRoot ?? "";
5484 const createdAt = "2026-09-25T10:00:00.000Z";
5485 // The second id shares the first's short prefix, the third tries to
5486 // climb out of the scratch root, and the fourth pastes a long token.
5487 const text = "Convert these PNGs to WebP, please!";
5488 const starts = [
5489 { id: "a1b2c3d4-scratch-thread", text },
5490 { id: "a1b2c3d4-other", text },
5491 { id: "../../escape", text },
5492 { id: "f00dcafe-long", text: "x".repeat(300) },
5493 ];
5494 for (const [index, { id, text: messageText }] of starts.entries()) {
5495 yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
5496 type: "thread.turn.start",
5497 commandId: CommandId.make(`cmd-scratch-turn-start-${index}`),
5498 threadId: ThreadId.make(id),
5499 message: {
5500 messageId: MessageId.make(`msg-scratch-${index}`),
5501 role: "user",
5502 text: messageText,
5503 attachments: [],
5504 },
5505 modelSelection: defaultModelSelection,
5506 runtimeMode: "full-access",
5507 interactionMode: "default",
5508 bootstrap: {
5509 createThread: {
5510 projectId: scratchProjectId,
5511 title: "New thread",
5512 modelSelection: defaultModelSelection,
5513 runtimeMode: "full-access",
5514 interactionMode: "default",
5515 branch: null,
5516 worktreePath: null,
5517 createdAt,
5518 },
5519 },
5520 createdAt,
5521 });
5522 }
5523 }),
5524 ),
5525 );
5526
5527 // The date is the server's receipt time, not the client's createdAt.
5528 const names = created.map((folder) => path.basename(folder ?? ""));
5529 assert.match(names[0] ?? "", /^\d{4}-\d{2}-\d{2}-convert-these-pngs-to-webp-a1b2c3d4$/);
5530 assert.match(names[1] ?? "", /-convert-these-pngs-to-webp-a1b2c3d4other$/);
5531 assert.match(names[2] ?? "", /-convert-these-pngs-to-webp-escape$/);
5532 assert.match(names[3] ?? "", /^\d{4}-\d{2}-\d{2}-x{48}-f00dcafe$/);
5533 for (const folder of created) {
5534 assert.equal(path.dirname(folder ?? ""), scratchRoot);
5535 assert.isTrue(yield* fileSystem.exists(folder ?? ""));
5536 }
5537 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5538 );
5539
5540 it.effect("withholds Scratch when the data dir sits inside a work tree", () =>
5541 Effect.gen(function* () {
5542 yield* buildAppUnderTest({
5543 layers: { vcsDriver: { isInsideWorkTree: () => Effect.succeed(true) } },
5544 });
5545 yield* Effect.scoped(
5546 withWsRpcClient(yield* getWsServerUrl("/ws"), (client) =>
5547 Effect.gen(function* () {
5548 const config = yield* client[WS_METHODS.serverGetConfig]({});
5549 const ensure = yield* Effect.flip(client[WS_METHODS.projectsEnsureScratch]({}));
5550
5551 assert.isUndefined(config.scratchWorkspaceRoot);
5552 assert.include(String(ensure.message), "not available");
5553 }),
5554 ),
5555 );
5556 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5557 );
5558
53375559 it.effect("advertises the usable file manager and its reveal label", () =>
53385560 Effect.gen(function* () {
53395561 yield* buildAppUnderTest({
apps/server/src/ws.ts+221-1
......@@ -180,6 +180,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
180180import * as SessionStore from "./auth/SessionStore.ts";
181181import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
182182import * as RelayClient from "@t3tools/shared/relayClient";
183import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
183184const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
184185
185186const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
......@@ -1775,7 +1776,143 @@ const makeWsRpcLayer = (
17751776 return yield* runBootstrap;
17761777 });
17771778
1778 const dispatchNormalizedCommand = (
1779 const path = yield* Path.Path;
1780 // Scratch threads run in a plain folder under the data dir. Inside a
1781 // checkout (a dev worktree's .t3, a dotfiles home) that folder would
1782 // inherit the repo's git status and checkpoints, so it is only offered
1783 // when the data dir is outside any work tree. Detection failures and
1784 // defects fail closed and hide the folder, never the config.
1785 // Probed once per connection: a negative VCS detection is not cached.
1786 // An interrupt stays an interrupt, so a config load cancelled mid-probe
1787 // invalidates the cache and the next load probes again.
1788 const [cachedScratchWorkspaceRoot, invalidateScratchWorkspaceRoot] =
1789 yield* Effect.cachedInvalidateWithTTL(
1790 gitWorkflow.isRepository(config.baseDir).pipe(
1791 Effect.map((isRepository) =>
1792 isRepository ? undefined : path.resolve(config.baseDir, "scratch"),
1793 ),
1794 Effect.catchCause((cause) =>
1795 Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined),
1796 ),
1797 ),
1798 Duration.infinity,
1799 );
1800 const resolveScratchWorkspaceRoot = cachedScratchWorkspaceRoot.pipe(
1801 Effect.onInterrupt(() => invalidateScratchWorkspaceRoot),
1802 );
1803
1804 const fileSystem = yield* FileSystem.FileSystem;
1805 // Each Scratch thread gets its own folder under the Scratch root, named
1806 // from its date, first words, and id. It rides in worktreePath like any
1807 // thread that runs outside its project root, so the provider, terminal,
1808 // and file tree all use it. Threads that already name a folder keep it.
1809 const scratchThreadFolder = (input: {
1810 readonly threadId: ThreadId;
1811 readonly projectId: ProjectId;
1812 readonly worktreePath: string | null;
1813 readonly createdAt: string;
1814 readonly text: string;
1815 }): Effect.Effect<string | null, OrchestrationDispatchCommandError> =>
1816 Effect.gen(function* () {
1817 if (input.worktreePath !== null) return null;
1818 const scratchRoot = yield* resolveScratchWorkspaceRoot;
1819 if (scratchRoot === undefined) return null;
1820 const project = yield* projectionSnapshotQuery.getProjectShellById(input.projectId).pipe(
1821 Effect.mapError(
1822 (cause) =>
1823 new OrchestrationDispatchCommandError({
1824 message: "Failed to look up the thread's project.",
1825 cause,
1826 }),
1827 ),
1828 );
1829 if (
1830 Option.isNone(project) ||
1831 normalizeProjectPathForComparison(project.value.workspaceRoot) !==
1832 normalizeProjectPathForComparison(scratchRoot)
1833 ) {
1834 return null;
1835 }
1836 // Only [a-z0-9] reaches the name, so it stays one path segment inside
1837 // the scratch root, and the words are capped so pasted data cannot
1838 // outgrow a file name. Each leaf is created without `recursive`, so
1839 // the create itself claims it: a taken short name falls back to the
1840 // full id, which only the same thread can already hold.
1841 const words = input.text
1842 .toLowerCase()
1843 .split(/[^a-z0-9]+/)
1844 .filter(Boolean)
1845 .slice(0, 5)
1846 .join("-")
1847 .slice(0, 48)
1848 .replace(/-+$/, "");
1849 const id = input.threadId.toLowerCase().replace(/[^a-z0-9]/g, "");
1850 const folderFor = (idPart: string) =>
1851 path.join(
1852 scratchRoot,
1853 [input.createdAt.slice(0, 10), words, idPart].filter(Boolean).join("-"),
1854 );
1855 yield* fileSystem.makeDirectory(scratchRoot, { recursive: true }).pipe(
1856 Effect.mapError(
1857 (cause) =>
1858 new OrchestrationDispatchCommandError({
1859 message: "Failed to create the folder for threads without a project.",
1860 cause,
1861 }),
1862 ),
1863 );
1864 const claim = (folder: string) =>
1865 fileSystem.makeDirectory(folder).pipe(
1866 Effect.as(true),
1867 Effect.catchIf(
1868 (error) => error.reason._tag === "AlreadyExists",
1869 () => Effect.succeed(false),
1870 ),
1871 Effect.mapError(
1872 (cause) =>
1873 new OrchestrationDispatchCommandError({
1874 message: "Failed to create the thread's folder.",
1875 cause,
1876 }),
1877 ),
1878 );
1879 const shortFolder = folderFor(id.slice(0, 8));
1880 if (yield* claim(shortFolder)) return shortFolder;
1881 const fullFolder = folderFor(id);
1882 yield* claim(fullFolder);
1883 return fullFolder;
1884 });
1885 const withScratchThreadFolder = (
1886 command: OrchestrationCommand,
1887 ): Effect.Effect<OrchestrationCommand, OrchestrationDispatchCommandError> => {
1888 if (command.type === "thread.create") {
1889 return scratchThreadFolder({ ...command, text: command.title }).pipe(
1890 Effect.map((worktreePath) =>
1891 worktreePath === null ? command : { ...command, worktreePath },
1892 ),
1893 );
1894 }
1895 if (command.type !== "thread.turn.start") return Effect.succeed(command);
1896 const bootstrap = command.bootstrap;
1897 const createThread = bootstrap?.createThread;
1898 if (bootstrap === undefined || createThread === undefined) return Effect.succeed(command);
1899 return scratchThreadFolder({
1900 ...createThread,
1901 threadId: command.threadId,
1902 text: command.message.text,
1903 }).pipe(
1904 Effect.map((worktreePath) =>
1905 worktreePath === null
1906 ? command
1907 : {
1908 ...command,
1909 bootstrap: { ...bootstrap, createThread: { ...createThread, worktreePath } },
1910 },
1911 ),
1912 );
1913 };
1914
1915 const dispatchPreparedCommand = (
17791916 normalizedCommand: OrchestrationCommand,
17801917 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => {
17811918 const dispatchEffect =
......@@ -1804,6 +1941,83 @@ const makeWsRpcLayer = (
18041941 );
18051942 };
18061943
1944 const dispatchNormalizedCommand = (
1945 command: OrchestrationCommand,
1946 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
1947 withScratchThreadFolder(command).pipe(Effect.flatMap(dispatchPreparedCommand));
1948
1949 // One Scratch project per environment, created the first time a client
1950 // asks. Two clients racing the create both reach dispatch; the loser's
1951 // duplicate-root rejection resolves to the project the winner made.
1952 // The folder is (re)made on every call so a deleted Scratch still runs.
1953 const ensureScratchProject = Effect.gen(function* () {
1954 const workspaceRoot = yield* resolveScratchWorkspaceRoot;
1955 if (workspaceRoot === undefined) {
1956 return yield* new OrchestrationDispatchCommandError({
1957 message: "Threads without a project are not available on this environment.",
1958 });
1959 }
1960 yield* fileSystem.makeDirectory(workspaceRoot, { recursive: true }).pipe(
1961 Effect.mapError(
1962 (cause) =>
1963 new OrchestrationDispatchCommandError({
1964 message: "Failed to create the folder for threads without a project.",
1965 cause,
1966 }),
1967 ),
1968 );
1969 const findScratchProjectId = projectionSnapshotQuery
1970 .getActiveProjectByWorkspaceRoot(workspaceRoot)
1971 .pipe(
1972 Effect.map(Option.map((project) => project.id)),
1973 Effect.mapError(
1974 (cause) =>
1975 new OrchestrationDispatchCommandError({
1976 message: "Failed to look up the home for threads without a project.",
1977 cause,
1978 }),
1979 ),
1980 );
1981 const existingProjectId = yield* findScratchProjectId;
1982 if (Option.isSome(existingProjectId)) {
1983 return { projectId: existingProjectId.value };
1984 }
1985 const projectId = ProjectId.make(yield* randomUUID);
1986 return yield* Effect.gen(function* () {
1987 const command = yield* normalizeDispatchCommand({
1988 type: "project.create",
1989 commandId: yield* serverCommandId("scratch-project-create"),
1990 projectId,
1991 title: "No project",
1992 workspaceRoot,
1993 createdAt: yield* nowIso,
1994 });
1995 yield* dispatchNormalizedCommand(command);
1996 // A dashed chat bubble in neutral gray marks Scratch. Set once at
1997 // create, so a user's own icon choice is never overwritten.
1998 yield* dispatchNormalizedCommand(
1999 yield* normalizeDispatchCommand({
2000 type: "project.meta.update",
2001 commandId: yield* serverCommandId("scratch-project-icon"),
2002 projectId,
2003 projectIcon: { kind: "lucide", name: "message-square-dashed", color: "gray" },
2004 }),
2005 );
2006 return { projectId };
2007 }).pipe(
2008 Effect.catch((error) =>
2009 findScratchProjectId.pipe(
2010 Effect.flatMap(
2011 Option.match({
2012 onNone: () => Effect.fail(error),
2013 onSome: (racedProjectId) => Effect.succeed({ projectId: racedProjectId }),
2014 }),
2015 ),
2016 ),
2017 ),
2018 );
2019 });
2020
18072021 // Only clients that answer /usage-limits themselves see it in the catalogs;
18082022 // an older client would send the injected command to the provider.
18092023 const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) =>
......@@ -1818,6 +2032,7 @@ const makeWsRpcLayer = (
18182032 );
18192033 const environment = yield* serverEnvironment.getDescriptor;
18202034 const auth = yield* serverAuth.getDescriptor();
2035 const scratchWorkspaceRoot = yield* resolveScratchWorkspaceRoot;
18212036 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(
18222037 externalLauncher.resolveAvailableEditors(),
18232038 );
......@@ -1866,6 +2081,7 @@ const makeWsRpcLayer = (
18662081 threadResumeCompletionMarker: true,
18672082 threadSnapshotPagination: true,
18682083 reasoningMessages: true,
2084 ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }),
18692085 };
18702086 });
18712087
......@@ -3031,6 +3247,10 @@ const makeWsRpcLayer = (
30313247 }),
30323248 { "rpc.aggregate": "source-control" },
30333249 ),
3250 [WS_METHODS.projectsEnsureScratch]: () =>
3251 observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, {
3252 "rpc.aggregate": "orchestration",
3253 }),
30343254 [WS_METHODS.projectCloneCancel]: (input) =>
30353255 observeRpcEffect(
30363256 WS_METHODS.projectCloneCancel,
apps/web/src/components/CommandPalette.tsx+44-4
......@@ -47,6 +47,7 @@ import {
4747 FileSearchIcon,
4848 FolderIcon,
4949 FolderPlusIcon,
50 MessageSquareDashedIcon,
5051 LinkIcon,
5152 MessageSquareIcon,
5253 MonitorIcon,
......@@ -95,6 +96,8 @@ import { useEnvironmentQuery } from "../state/query";
9596import { sourceControlEnvironment } from "../state/sourceControl";
9697import { useAtomCommand } from "../state/use-atom-command";
9798import { useAtomQueryRunner } from "../state/use-atom-query-runner";
99import { useScratchProject } from "../hooks/useScratchProject";
100import { isScratchProject } from "@t3tools/client-runtime/state/projects";
98101import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
99102import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
100103import { useThreadSearch } from "../state/queries";
......@@ -708,6 +711,7 @@ function OpenCommandPaletteDialog(props: {
708711 const createProject = useAtomCommand(projectEnvironment.create, {
709712 reportFailure: false,
710713 });
714 const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject();
711715 const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, {
712716 reportFailure: false,
713717 });
......@@ -1100,6 +1104,11 @@ function OpenCommandPaletteDialog(props: {
11001104 const currentProjectEnvironmentId =
11011105 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;
11021106 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
1107 // Where "without a project" threads start: the current environment when it
1108 // offers them, otherwise the first connected one that does.
1109 const scratchTargetEnvironmentId = scratchEnvironmentId(
1110 currentProjectEnvironmentId ?? primaryEnvironmentId,
1111 );
11031112 const currentProjectCwd = currentProjectId
11041113 ? (projectCwdById.get(currentProjectId) ?? null)
11051114 : null;
......@@ -1270,9 +1279,12 @@ function OpenCommandPaletteDialog(props: {
12701279
12711280 const projectThreadItems = useMemo(
12721281 () =>
1273 enumerateCommandPaletteItems(
1274 buildProjectActionItems({
1275 projects: pickerProjects,
1282 enumerateCommandPaletteItems([
1283 ...buildProjectActionItems({
1284 // The no-project home shows once, as the "No project" item below.
1285 projects: pickerProjects.filter(
1286 (project) => !isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)),
1287 ),
12761288 valuePrefix: "new-thread-in",
12771289 searchTerms: (project) => {
12781290 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
......@@ -1323,13 +1335,29 @@ function OpenCommandPaletteDialog(props: {
13231335 );
13241336 },
13251337 }),
1326 ),
1338 ...(scratchTargetEnvironmentId === null
1339 ? []
1340 : [
1341 {
1342 kind: "action" as const,
1343 value: "new-thread-in:no-project",
1344 searchTerms: ["no project", "without project", "none"],
1345 title: "No project",
1346 icon: <MessageSquareDashedIcon className={ITEM_ICON_CLASS} />,
1347 shortcutCommand: "chat.newWithoutProject" as const,
1348 run: () => startScratchThread(scratchTargetEnvironmentId),
1349 },
1350 ]),
1351 ]),
13271352 [
13281353 contextualProjectRef,
13291354 handleNewThread,
13301355 pickerProjects,
13311356 projectEnvironmentLocationById,
13321357 projectGroupByTargetKey,
1358 scratchTargetEnvironmentId,
1359 scratchWorkspaceRootFor,
1360 startScratchThread,
13331361 ],
13341362 );
13351363
......@@ -1787,6 +1815,18 @@ function OpenCommandPaletteDialog(props: {
17871815 });
17881816 }
17891817
1818 if (scratchTargetEnvironmentId !== null) {
1819 actionItems.push({
1820 kind: "action",
1821 value: "action:new-thread-without-project",
1822 searchTerms: ["new thread", "no project", "without project", "none", "chat"],
1823 title: "New thread without a project",
1824 icon: <MessageSquareDashedIcon className={ITEM_ICON_CLASS} />,
1825 shortcutCommand: "chat.newWithoutProject",
1826 run: () => startScratchThread(scratchTargetEnvironmentId),
1827 });
1828 }
1829
17901830 if (activeThreadReferenceCopyTarget !== null) {
17911831 actionItems.push({
17921832 kind: "action",
apps/web/src/components/NoProjectsHero.tsx+22-3
......@@ -1,8 +1,10 @@
1import { PlusIcon } from "lucide-react";
1import { MessageSquareDashedIcon, PlusIcon } from "lucide-react";
22import { useCallback } from "react";
33
44import { openCommandPalette } from "../commandPaletteBus";
55import { isElectron } from "../env";
6import { useScratchProject } from "../hooks/useScratchProject";
7import { usePrimaryEnvironmentId } from "../state/environments";
68import { Button } from "./ui/button";
79import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
810import { SidebarInset } from "./ui/sidebar";
......@@ -10,6 +12,9 @@ import { WorkspacePageHeader } from "./WorkspacePageHeader";
1012
1113export function NoProjectsHero() {
1214 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
15 const primaryEnvironmentId = usePrimaryEnvironmentId();
16 const { scratchEnvironmentId, startScratchThread } = useScratchProject();
17 const scratchTargetEnvironmentId = scratchEnvironmentId(primaryEnvironmentId);
1318
1419 return (
1520 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
......@@ -20,12 +25,26 @@ export function NoProjectsHero() {
2025 <div className="w-full max-w-lg px-8 py-12">
2126 <EmptyHeader className="max-w-none">
2227 <EmptyTitle>What should we work on?</EmptyTitle>
23 <EmptyDescription>Add a project to start your first thread.</EmptyDescription>
24 <div className="mt-6 flex justify-center">
28 <EmptyDescription>
29 {scratchTargetEnvironmentId === null
30 ? "Add a project to start your first thread."
31 : "Add a project, or start without one."}
32 </EmptyDescription>
33 <div className="mt-6 flex justify-center gap-2">
2534 <Button size="sm" onClick={openAddProject}>
2635 <PlusIcon className="size-4" />
2736 Add project
2837 </Button>
38 {scratchTargetEnvironmentId === null ? null : (
39 <Button
40 size="sm"
41 variant="outline"
42 onClick={() => void startScratchThread(scratchTargetEnvironmentId)}
43 >
44 <MessageSquareDashedIcon className="size-4" />
45 Start without a project
46 </Button>
47 )}
2948 </div>
3049 </EmptyHeader>
3150 </div>
apps/web/src/components/chat/DraftHeroHeadline.tsx+179-52
......@@ -2,13 +2,22 @@ import type { DraftId } from "~/composerDraftStore";
22import { useComposerDraftStore } from "~/composerDraftStore";
33import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
44import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
5import { FolderPlusIcon } from "lucide-react";
6import { useCallback, useMemo } from "react";
5import { isScratchProject } from "@t3tools/client-runtime/state/projects";
6import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react";
7import { useAtomValue } from "@effect/atom-react";
8import { useCallback, useEffect, useMemo, useRef } from "react";
79
810import { openCommandPalette } from "~/commandPaletteBus";
11import { shortcutLabelForCommand } from "~/keybindings";
12import { projectIconColorClassName } from "~/projectIconColors";
13import { primaryServerKeybindingsAtom } from "~/state/server";
14import { useScratchProject } from "~/hooks/useScratchProject";
915import { useClientSettings } from "~/hooks/useSettings";
1016import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";
11import { selectProjectGroupingSettings } from "~/logicalProject";
17import {
18 deriveLogicalProjectKeyFromSettings,
19 selectProjectGroupingSettings,
20} from "~/logicalProject";
1221import {
1322 buildSidebarProjectPickerEntries,
1423 buildSidebarProjectSnapshots,
......@@ -32,6 +41,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3241import { InlineButton } from "../ui/button";
3342import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
3443
44// Menu value for "No project"; real entries are keyed by logical project key.
45const NO_PROJECT_VALUE = "no-project";
46
3547interface DraftHeroHeadlineProps {
3648 readonly draftId: DraftId | null;
3749 readonly activeProjectRef: ScopedProjectRef | null;
......@@ -56,6 +68,8 @@ export function DraftHeroHeadline({
5668 const applyStickyState = useComposerDraftStore((store) => store.applyStickyState);
5769 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);
5870 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
71 const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
72 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
5973
6074 const environmentLabelById = useMemo(
6175 () =>
......@@ -131,6 +145,85 @@ export function DraftHeroHeadline({
131145 const hasResolvedProject = activeProjectTitle !== null;
132146 const canChooseProject = projectPickerEntries.length > 0;
133147 const shouldShowProjectMenu = canChooseProject;
148 // The project that hosts threads without a project appears once, as the
149 // "No project" item, not as a project row.
150 const menuEntries = projectPickerEntries.filter(
151 ({ targetProject }) =>
152 !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)),
153 );
154 const activeProject =
155 activeProjectRef === null
156 ? null
157 : (projects.find(
158 (project) =>
159 project.environmentId === activeProjectRef.environmentId &&
160 project.id === activeProjectRef.projectId,
161 ) ?? null);
162 const scratchTargetEnvironmentId = scratchEnvironmentId(
163 activeProjectRef?.environmentId ?? primaryEnvironmentId,
164 );
165 const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId);
166 const isScratchDraft =
167 activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot);
168
169 // The picker can change the draft's target while the no-project home is
170 // still being opened; a stale continuation must not retarget it again.
171 const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId });
172 useEffect(() => {
173 latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId };
174 }, [activeProjectKey, scratchTargetEnvironmentId, draftId]);
175 // Project selection changes the target of the open draft in place. The
176 // prompt stays in the same composer session, so the sidebar only gets a
177 // draft row if the user later navigates away.
178 const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => {
179 if (!draftId) {
180 return;
181 }
182 latestTargetRef.current = {
183 draftId,
184 activeProjectKey: logicalProjectKey,
185 scratchTargetEnvironmentId: project.environmentId,
186 };
187 const currentDraft = getComposerDraft(draftId);
188 setLogicalProjectDraftThreadId(
189 logicalProjectKey,
190 scopeProjectRef(project.environmentId, project.id),
191 draftId,
192 );
193 if (!hasExplicitComposerModelSelection(currentDraft)) {
194 applyStickyState(draftId);
195 const environmentSettings = environments.find(
196 (environment) => environment.environmentId === project.environmentId,
197 )?.serverConfig?.settings;
198 const defaultModelSelection = environmentSettings
199 ? resolveProjectSettings(environmentSettings, project.id, project).settings
200 .defaultModelSelection
201 : project.defaultModelSelection;
202 if (defaultModelSelection) {
203 setModelSelection(draftId, defaultModelSelection, {
204 replaceOptions: true,
205 });
206 }
207 }
208 };
209 const startScratch = async (): Promise<boolean> => {
210 if (scratchTargetEnvironmentId === null || isScratchDraft) {
211 return false;
212 }
213 const requested = { draftId, activeProjectKey, scratchTargetEnvironmentId };
214 const project = await openScratchProject(scratchTargetEnvironmentId);
215 const latest = latestTargetRef.current;
216 if (
217 !project ||
218 latest.draftId !== requested.draftId ||
219 latest.activeProjectKey !== requested.activeProjectKey ||
220 latest.scratchTargetEnvironmentId !== requested.scratchTargetEnvironmentId
221 ) {
222 return false;
223 }
224 selectProject(project, deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings));
225 return true;
226 };
134227
135228 const projectSelector = shouldShowProjectMenu ? (
136229 <Menu>
......@@ -143,55 +236,49 @@ export function DraftHeroHeadline({
143236 // mid-sentence and baffle screen-reader users.
144237 <MenuTrigger
145238 render={<InlineButton tone="picker" />}
239 data-draft-project-trigger=""
146240 className="pointer-events-auto max-w-64 align-baseline"
147241 />
148242 }
149243 >
150 <span className="min-w-0 truncate">{activeProjectDisplayName ?? "Choose a project"}</span>
244 <span className="min-w-0 truncate">
245 {isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")}
246 </span>
151247 </TooltipTrigger>
152 {activeProjectDisplayName ? (
248 {activeProjectDisplayName && !isScratchDraft ? (
153249 <TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>
154250 ) : null}
155251 </Tooltip>
156252 <MenuPopup align="center" className="max-h-80 overflow-y-auto">
157253 <MenuRadioGroup
158 value={activeProjectKey}
254 value={isScratchDraft ? NO_PROJECT_VALUE : activeProjectKey}
159255 onValueChange={(value) => {
160 const entry = projectEntryByKey.get(value as string);
161 if (!entry || value === activeProjectKey) {
256 if (value === NO_PROJECT_VALUE) {
257 void startScratch();
162258 return;
163259 }
164 const project = entry.targetProject;
165 if (!draftId) {
260 const entry = projectEntryByKey.get(value as string);
261 if (!entry || value === activeProjectKey) {
166262 return;
167263 }
168 // Project selection changes the target of the open draft in
169 // place. The prompt stays in the same composer session, so the
170 // sidebar only gets a draft row if the user later navigates away.
171 const currentDraft = getComposerDraft(draftId);
172 setLogicalProjectDraftThreadId(
173 entry.group.projectKey,
174 scopeProjectRef(project.environmentId, project.id),
175 draftId,
176 );
177 if (!hasExplicitComposerModelSelection(currentDraft)) {
178 applyStickyState(draftId);
179 const environmentSettings = environments.find(
180 (environment) => environment.environmentId === project.environmentId,
181 )?.serverConfig?.settings;
182 const defaultModelSelection = environmentSettings
183 ? resolveProjectSettings(environmentSettings, project.id, project).settings
184 .defaultModelSelection
185 : project.defaultModelSelection;
186 if (defaultModelSelection) {
187 setModelSelection(draftId, defaultModelSelection, {
188 replaceOptions: true,
189 });
190 }
191 }
264 selectProject(entry.targetProject, entry.group.projectKey);
192265 }}
193266 >
194 {projectPickerEntries.map(({ group }) => {
267 {scratchWorkspaceRoot === null ? null : (
268 <MenuRadioItem value={NO_PROJECT_VALUE} closeOnClick>
269 <span className="flex min-w-0 items-center gap-2">
270 {/* Boxed like ProjectFavicon so the label lines up with project rows. */}
271 <span
272 aria-hidden="true"
273 className={`inline-flex size-4 shrink-0 ${projectIconColorClassName("gray")}`}
274 >
275 <MessageSquareDashedIcon className="size-full" />
276 </span>
277 No project
278 </span>
279 </MenuRadioItem>
280 )}
281 {menuEntries.map(({ group }) => {
195282 return (
196283 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
197284 <span className="flex min-w-0 items-center gap-2">
......@@ -214,7 +301,7 @@ export function DraftHeroHeadline({
214301 );
215302 })}
216303 </MenuRadioGroup>
217 <MenuSeparator />
304 {projectPickerEntries.length > 0 ? <MenuSeparator /> : null}
218305 <MenuItem onClick={openAddProject}>
219306 <FolderPlusIcon />
220307 New project
......@@ -235,24 +322,64 @@ export function DraftHeroHeadline({
235322 // a complete sentence too. The project picker is a control rendered inline
236323 // in the h1; without an explicit label its widget state bleeds into the
237324 // announced phrase.
238 const headingLabel = hasResolvedProject
239 ? `What should we build in ${activeProjectDisplayName}?`
240 : canChooseProject
241 ? `${activeProjectDisplayName ?? "Choose a project"} to start`
242 : "Add a project to start";
325 const headingLabel = isScratchDraft
326 ? "What should we work on?"
327 : hasResolvedProject
328 ? `What should we build in ${activeProjectDisplayName}?`
329 : canChooseProject
330 ? `${activeProjectDisplayName ?? "Choose a project"} to start`
331 : "Add a project to start";
332
333 // One click out of the project, phrased as the alternative to the question
334 // above it. Focus moves to the project picker once this line has gone.
335 const noProjectShortcut = shortcutLabelForCommand(keybindings, "chat.newWithoutProject");
336 const orStartWithoutProject =
337 scratchWorkspaceRoot !== null && !isScratchDraft && (hasResolvedProject || canChooseProject) ? (
338 <Tooltip>
339 <TooltipTrigger
340 render={
341 <InlineButton
342 tone="muted"
343 className="pointer-events-auto"
344 onClick={() =>
345 void startScratch().then((started) => {
346 if (started) {
347 document.querySelector<HTMLElement>("[data-draft-project-trigger]")?.focus();
348 }
349 })
350 }
351 />
352 }
353 >
354 or start without a project
355 </TooltipTrigger>
356 {noProjectShortcut ? <TooltipPopup side="bottom">{noProjectShortcut}</TooltipPopup> : null}
357 </Tooltip>
358 ) : null;
243359
244360 return (
245 <h1
246 aria-label={headingLabel}
247 className="mx-auto w-full max-w-5xl text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl"
248 >
249 {hasResolvedProject ? (
250 <>What should we build in {projectSelector}?</>
251 ) : canChooseProject ? (
252 <>{projectSelector} to start</>
253 ) : (
254 <>Add a project to start</>
361 <div className="mx-auto flex w-full max-w-5xl flex-col items-center">
362 <h1
363 aria-label={headingLabel}
364 className="w-full text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl"
365 >
366 {isScratchDraft ? (
367 <>What should we work on?</>
368 ) : hasResolvedProject ? (
369 <>What should we build in {projectSelector}?</>
370 ) : canChooseProject ? (
371 <>{projectSelector} to start</>
372 ) : (
373 <>Add a project to start</>
374 )}
375 </h1>
376 {/* Reserved whenever threads can skip a project, so the heading does not
377 move. Without a project, the picker moves here to choose one. */}
378 {scratchWorkspaceRoot === null ? null : (
379 <p className="mt-2 flex h-6 items-center text-sm">
380 {isScratchDraft ? projectSelector : orStartWithoutProject}
381 </p>
255382 )}
256 </h1>
383 </div>
257384 );
258385}
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+1-1
......@@ -49,13 +49,13 @@ describe("KeybindingsSettings.logic", () => {
4949 it("orders Usage bindings and command choices like the page", () => {
5050 const expected = [
5151 "usage.cost",
52 "usage.open",
5352 "usage.tokens",
5453 "usage.limits",
5554 "usage.period.day",
5655 "usage.period.week",
5756 "usage.period.month",
5857 "usage.period.quarter",
58 "usage.open",
5959 ];
6060 const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed();
6161 expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected);
apps/web/src/components/settings/KeybindingsSettings.logic.ts+20-11
......@@ -19,10 +19,24 @@ const usageCommandOrder = new Map<KeybindingCommand, number>(
1919 [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]),
2020);
2121
22function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null {
23 const leftIndex = usageCommandOrder.get(left);
24 const rightIndex = usageCommandOrder.get(right);
25 return leftIndex !== undefined && rightIndex !== undefined ? leftIndex - rightIndex : null;
22const firstUsageCommand = METRIC_OPTIONS[0].command;
23
24/**
25 * Orders commands by `key`, except Usage page commands, which sort as one
26 * block in page order where the first of them would sort. A total order, so
27 * adding a binding elsewhere cannot reshuffle the Usage rows.
28 */
29function compareCommands(
30 left: KeybindingCommand,
31 right: KeybindingCommand,
32 key: (command: KeybindingCommand) => string,
33): number {
34 const leftRank = usageCommandOrder.get(left);
35 const rightRank = usageCommandOrder.get(right);
36 if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank;
37 return key(leftRank === undefined ? left : firstUsageCommand).localeCompare(
38 key(rightRank === undefined ? right : firstUsageCommand),
39 );
2640}
2741
2842export type KeybindingSource = "Default" | "Custom" | "Project";
......@@ -215,9 +229,7 @@ export function buildKeybindingRows(
215229 });
216230
217231 rowsWithConflicts.sort((left, right) => {
218 const commandCompare =
219 compareUsageCommands(left.command, right.command) ??
220 left.command.localeCompare(right.command);
232 const commandCompare = compareCommands(left.command, right.command, (command) => command);
221233 if (commandCompare !== 0) return commandCompare;
222234 return left.key.localeCompare(right.key);
223235 });
......@@ -290,10 +302,7 @@ export function buildKeybindingCommandOptions(
290302 for (const binding of keybindings) {
291303 commands.add(binding.command);
292304 }
293 return [...commands].toSorted(
294 (left, right) =>
295 compareUsageCommands(left, right) ?? commandLabel(left).localeCompare(commandLabel(right)),
296 );
305 return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel));
297306}
298307
299308export function commandLabel(command: KeybindingCommand): string {
apps/web/src/hooks/useScratchProject.ts created+98
......@@ -0,0 +1,98 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
7import type { EnvironmentId } from "@t3tools/contracts";
8import { useCallback } from "react";
9
10import { stackedThreadToast, toastManager } from "~/components/ui/toast";
11import { waitForProject } from "~/state/entities";
12import { useEnvironments } from "~/state/environments";
13import { projectEnvironment } from "~/state/projects";
14import { useAtomCommand } from "~/state/use-atom-command";
15import { useNewThreadHandler } from "./useHandleNewThread";
16
17function reportScratchFailure(error: unknown) {
18 toastManager.add(
19 stackedThreadToast({
20 type: "error",
21 title: "Could not start without a project",
22 description: error instanceof Error ? error.message : "An error occurred.",
23 }),
24 );
25}
26
27/**
28 * Threads without a project live in the environment's scratch project, a
29 * plain folder the server owns (users see it as "No project"). The server
30 * creates it on first use; after that it is an ordinary project on the
31 * non-git path, and each thread gets its own subfolder.
32 */
33export function useScratchProject() {
34 const { environments } = useEnvironments();
35 const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false });
36 const handleNewThread = useNewThreadHandler();
37
38 /** The scratch folder of a connected environment, or null when it offers none. */
39 const scratchWorkspaceRootFor = useCallback(
40 (environmentId: EnvironmentId | null): string | null => {
41 const environment = environments.find((entry) => entry.environmentId === environmentId);
42 return environment?.connection.phase === "connected"
43 ? (environment.serverConfig?.scratchWorkspaceRoot ?? null)
44 : null;
45 },
46 [environments],
47 );
48
49 // A thread without a project starts on the machine the user is working on,
50 // and only there. With no current machine (the hosted app with nothing
51 // open), it starts on the one machine that offers it, never a silent pick.
52 const scratchEnvironmentId = useCallback(
53 (current: EnvironmentId | null): EnvironmentId | null => {
54 if (current !== null) return scratchWorkspaceRootFor(current) !== null ? current : null;
55 const offering = environments.filter(
56 (entry) => scratchWorkspaceRootFor(entry.environmentId) !== null,
57 );
58 return offering.length === 1 ? (offering[0]?.environmentId ?? null) : null;
59 },
60 [environments, scratchWorkspaceRootFor],
61 );
62
63 /** Resolves to the scratch project once it is in this client's store. */
64 const openScratchProject = useCallback(
65 async (environmentId: EnvironmentId): Promise<EnvironmentProject | null> => {
66 const result = await ensureScratch({ environmentId, input: {} });
67 if (result._tag === "Failure") {
68 if (!isAtomCommandInterrupted(result)) {
69 reportScratchFailure(squashAtomCommandFailure(result));
70 }
71 return null;
72 }
73 // Drafts key off the project's stored path and settings, so wait for
74 // the create event to reach the store before targeting one.
75 return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch(
76 (error: unknown) => {
77 reportScratchFailure(error);
78 return null;
79 },
80 );
81 },
82 [ensureScratch],
83 );
84
85 const startScratchThread = useCallback(
86 async (environmentId: EnvironmentId) => {
87 const project = await openScratchProject(environmentId);
88 if (project) {
89 await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch(
90 reportScratchFailure,
91 );
92 }
93 },
94 [handleNewThread, openScratchProject],
95 );
96
97 return { scratchWorkspaceRootFor, scratchEnvironmentId, openScratchProject, startScratchThread };
98}
apps/web/src/hooks/useThreadActions.ts+11-4
......@@ -19,6 +19,7 @@ import { useComposerDraftStore } from "../composerDraftStore";
1919import { terminalEnvironment } from "../state/terminal";
2020import { appAtomRegistry } from "../rpc/atomRegistry";
2121import { environmentServerConfigsAtom } from "../state/server";
22import { isScratchProject } from "@t3tools/client-runtime/state/projects";
2223import { threadEnvironment } from "../state/threads";
2324import { vcsEnvironment } from "../state/vcs";
2425import { useNewThreadHandler } from "./useHandleNewThread";
......@@ -399,12 +400,18 @@ export function useThreadActions() {
399400 const displayWorktreePath = orphanedWorktreePath
400401 ? formatWorktreePathForDisplay(orphanedWorktreePath)
401402 : null;
402 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null;
403 const environmentConfig = appAtomRegistry
404 .get(environmentServerConfigsAtom)
405 .get(threadRef.environmentId);
406 // A Scratch thread's folder is not a git worktree, and deleting the
407 // thread keeps its files.
408 const canDeleteWorktree =
409 orphanedWorktreePath !== null &&
410 threadProject !== null &&
411 !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot);
403412 const localApi = readLocalApi();
404413 let shouldDeleteWorktree = false;
405 const environmentSettings = appAtomRegistry
406 .get(environmentServerConfigsAtom)
407 .get(threadRef.environmentId)?.settings;
414 const environmentSettings = environmentConfig?.settings;
408415 const automaticWorktreeCleanup = environmentSettings
409416 ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete
410417 : false;
apps/web/src/routes/_chat.tsx+16
......@@ -13,6 +13,7 @@ import { selectProjectGroupingSettings } from "../logicalProject";
1313import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
1414import { dispatchPreviewAction } from "../components/preview/previewActionBus";
1515import { useHandleNewThread } from "../hooks/useHandleNewThread";
16import { useScratchProject } from "../hooks/useScratchProject";
1617import { startNewThreadFromContext } from "../lib/chatThreadActions";
1718import { isPreviewFocused } from "../lib/previewFocus";
1819import { isTerminalFocused } from "../lib/terminalFocus";
......@@ -37,6 +38,7 @@ function ChatRouteGlobalShortcuts() {
3738 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
3839 const projects = useProjects();
3940 const primaryEnvironmentId = usePrimaryEnvironmentId();
41 const { scratchEnvironmentId, startScratchThread } = useScratchProject();
4042 const projectGroupCount = useMemo(
4143 () =>
4244 buildSidebarProjectSnapshots({
......@@ -105,6 +107,17 @@ function ChatRouteGlobalShortcuts() {
105107 return;
106108 }
107109
110 if (command === "chat.newWithoutProject") {
111 const environmentId = scratchEnvironmentId(
112 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId,
113 );
114 if (environmentId === null) return;
115 event.preventDefault();
116 event.stopPropagation();
117 void startScratchThread(environmentId);
118 return;
119 }
120
108121 if (command === "chat.new") {
109122 event.preventDefault();
110123 event.stopPropagation();
......@@ -180,9 +193,12 @@ function ChatRouteGlobalShortcuts() {
180193 keybindings,
181194 defaultProjectRef,
182195 previewOpen,
196 primaryEnvironmentId,
183197 projectGroupCount,
184198 routeThreadRef,
199 scratchEnvironmentId,
185200 selectedThreadKeysSize,
201 startScratchThread,
186202 legacySidebarEnabled,
187203 terminalOpen,
188204 ]);
docs/user/keybindings.md+2-1
......@@ -122,7 +122,8 @@ through the pages you have visited, like a browser's back and forward buttons.
122122
123123`chat.new` may ask you to choose a project when there is more than one.
124124`chat.newLocal` skips that chooser. Both use your
125[new-thread defaults](./thread-sidebar.md#start-a-thread).
125[new-thread defaults](./thread-sidebar.md#start-a-thread). `chat.newWithoutProject`
126(`mod+alt+n`) starts a thread [without a project](./thread-sidebar.md#start-without-a-project).
126127
127128## Reserved shortcuts
128129
docs/user/thread-sidebar.md+16
......@@ -13,6 +13,22 @@ an existing worktree, use **New thread in this worktree** from the branch toolba
1313When you change a new thread's project, T3 Code stays in the current environment
1414if that project exists there. Otherwise it selects an environment that has it.
1515
16### Start without a project
17
18A thread does not need a project. To start one without a project, click **or
19start without a project** under a new thread's heading, pick **No project** from
20the project menu in that heading or from **New thread in...** in the command
21palette, or press `mod+alt+n`. On mobile, pick **No project** from the project
22list. To move a draft into a project, pick the project in the heading.
23
24Each thread without a project works in its own folder under `~/.t3/scratch` (the
25`scratch` folder of your T3 data directory), named after its date, the first words
26of its first message, and a short id, like
27`2026-09-25-convert-these-pngs-to-webp-a1b2c3d4`. Deleting a thread keeps its
28folder, so the files the agent wrote stay until you delete them. Branch, worktree, and diff controls stay hidden because
29these folders are not Git repositories. This is unavailable when the data
30directory itself sits inside a Git checkout.
31
1632### Start in the background
1733
1834In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter`
packages/client-runtime/src/state/projectCommands.ts+7
......@@ -92,6 +92,13 @@ export function createProjectEnvironmentAtoms<R, E>(
9292 scheduler: projectScheduler,
9393 concurrency: projectConcurrency,
9494 }),
95 // Finds or creates the environment's Scratch project and returns its id.
96 ensureScratch: createEnvironmentRpcCommand(runtime, {
97 label: "environment-data:projects:ensure-scratch",
98 tag: WS_METHODS.projectsEnsureScratch,
99 scheduler: projectScheduler,
100 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },
101 }),
95102 writeFile: createEnvironmentRpcCommand(runtime, {
96103 label: "environment-data:projects:write-file",
97104 tag: WS_METHODS.projectsWriteFile,
packages/client-runtime/src/state/projects.ts+10
......@@ -135,6 +135,16 @@ export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: stri
135135 });
136136}
137137
138/** Whether a project is its environment's Scratch project (`ServerConfig.scratchWorkspaceRoot`). */
139export function isScratchProject(
140 project: { readonly workspaceRoot: string },
141 scratchWorkspaceRoot: string | null | undefined,
142): boolean {
143 return (
144 scratchWorkspaceRoot != null && findProjectByPath([project], scratchWorkspaceRoot) !== undefined
145 );
146}
147
138148export function inferProjectTitleFromPath(value: string): string {
139149 const normalized = normalizeProjectPathForDispatch(value);
140150 const absolutePath = splitAbsolutePath(normalized);
packages/contracts/src/keybindings.ts+1
......@@ -91,6 +91,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
9191 "composer.branch",
9292 "chat.new",
9393 "chat.newLocal",
94 "chat.newWithoutProject",
9495 "editor.openFavorite",
9596 "usage.cost",
9697 "usage.tokens",
packages/contracts/src/project.ts+7
......@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
22import {
33 NonNegativeInt,
44 PositiveInt,
5 ProjectId,
56 TrimmedNonEmptyString,
67 TrimmedString,
78} from "./baseSchemas.ts";
......@@ -281,6 +282,12 @@ export const ProjectWriteFileResult = Schema.Struct({
281282});
282283export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;
283284
285/** The environment's Scratch project, created on first request. */
286export const ProjectEnsureScratchResult = Schema.Struct({
287 projectId: ProjectId,
288});
289export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type;
290
284291export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()(
285292 "ProjectWriteFileError",
286293 {
packages/contracts/src/rpc.ts+10
......@@ -159,6 +159,7 @@ import {
159159 RelayClientStatusSchema,
160160} from "./relayClient.ts";
161161import {
162 ProjectEnsureScratchResult,
162163 ProjectListEntriesError,
163164 ProjectListEntriesInput,
164165 ProjectListEntriesResult,
......@@ -293,6 +294,7 @@ export const WS_METHODS = {
293294 projectsSearchContents: "projects.searchContents",
294295 projectsSearchEntries: "projects.searchEntries",
295296 projectsWriteFile: "projects.writeFile",
297 projectsEnsureScratch: "projects.ensureScratch",
296298
297299 // Shell methods
298300 shellOpenInEditor: "shell.openInEditor",
......@@ -991,6 +993,13 @@ const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
991993 error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]),
992994});
993995
996// Finds or creates the Scratch project rooted at ServerConfig.scratchWorkspaceRoot.
997const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, {
998 payload: Schema.Struct({}),
999 success: ProjectEnsureScratchResult,
1000 error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]),
1001});
1002
9941003const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
9951004 payload: LaunchEditorInput,
9961005 error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]),
......@@ -1508,6 +1517,7 @@ export const WsRpcGroup = RpcGroup.make(
15081517 WsProjectsReadFileRpc,
15091518 WsProjectsSearchContentsRpc,
15101519 WsProjectsSearchEntriesRpc,
1520 WsProjectsEnsureScratchRpc,
15111521 WsProjectsWriteFileRpc,
15121522 WsShellOpenInEditorRpc,
15131523 WsFilesystemBrowseRpc,
packages/contracts/src/server.ts+6
......@@ -611,6 +611,12 @@ export const ServerConfig = Schema.Struct({
611611 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
612612 /** Whether thread reads accept the reasoningMessages opt-in. */
613613 reasoningMessages: Schema.optionalKey(Schema.Boolean),
614 /**
615 * Folder behind this environment's Scratch project, for threads that need
616 * no repository. Present only on servers that answer projects.ensureScratch
617 * and whose data dir is outside a Git checkout.
618 */
619 scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString),
614620 /**
615621 * Palettes published by this environment's machine. Never sent in a config
616622 * snapshot: the theme stream emits the current set before any change, so a
packages/shared/src/keybindings.ts+1
......@@ -49,6 +49,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
4949 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
5050 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
5151 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
52 { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" },
5253 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
5354 { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" },
5455 { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" },