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: {...@@ -1598,7 +1598,7 @@ export function NewTaskDraftScreen(props: {
1598 />1598 />
1599 </View>1599 </View>
1600 ) : null}1600 ) : null}
1601 <View className="pb-1">{workspaceControls}</View>1601 {flow.canChooseWorkspace ? <View className="pb-1">{workspaceControls}</View> : null}
16021602
1603 {modelUnavailable ? (1603 {modelUnavailable ? (
1604 <Pressable1604 <Pressable
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+163-18
...@@ -7,7 +7,11 @@ import {...@@ -7,7 +7,11 @@ import {
7 type StaticScreenProps,7 type StaticScreenProps,
8} from "@react-navigation/native";8} from "@react-navigation/native";
9import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
10import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
11import { isScratchProject } from "@t3tools/client-runtime/state/projects";
10import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";12import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/unstable/reactivity";
11import { useEffect, useRef, useState } from "react";15import { useEffect, useRef, useState } from "react";
12import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";16import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";17import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -17,7 +21,10 @@ import { MaterialButton } from "../../components/MaterialButton";...@@ -17,7 +21,10 @@ import { MaterialButton } from "../../components/MaterialButton";
17import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";21import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
18import { AppText as Text } from "../../components/AppText";22import { AppText as Text } from "../../components/AppText";
19import { ProjectFavicon } from "../../components/ProjectFavicon";23import { 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";
21import type { WorkspaceState } from "../../state/workspaceModel";28import type { WorkspaceState } from "../../state/workspaceModel";
22import { useWorkspaceState } from "../../state/workspace";29import { useWorkspaceState } from "../../state/workspace";
23import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";30import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
...@@ -156,6 +163,44 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -156,6 +163,44 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
156 project.id === incomingShare.destination?.projectId,163 project.id === incomingShare.destination?.projectId,
157 ) ?? null)164 ) ?? null)
158 : null;165 : 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
160 async function selectProject(project: EnvironmentProject): Promise<void> {205 async function selectProject(project: EnvironmentProject): Promise<void> {
161 if (incomingShare?.destination && !reservedDestinationProject) {206 if (incomingShare?.destination && !reservedDestinationProject) {
...@@ -189,6 +234,36 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -189,6 +234,36 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
189 );234 );
190 }235 }
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
192 useEffect(() => {267 useEffect(() => {
193 const destination = incomingShare?.destination;268 const destination = incomingShare?.destination;
194 if (!destination) {269 if (!destination) {
...@@ -264,15 +339,24 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -264,15 +339,24 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
264 {projectEmptyState.detail}339 {projectEmptyState.detail}
265 </Text>340 </Text>
266 {Platform.OS === "android" ? (341 {Platform.OS === "android" ? (
267 <MaterialButton342 <>
268 label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"}343 <MaterialButton
269 tone="primary"344 label={catalogState.hasReadyEnvironment ? "Add new project" : "Add environment"}
270 onPress={() =>345 tone="primary"
271 catalogState.hasReadyEnvironment346 onPress={() =>
272 ? navigation.dispatch(StackActions.push("AddProject"))347 catalogState.hasReadyEnvironment
273 : navigation.navigate("ConnectionsNew")348 ? navigation.dispatch(StackActions.push("AddProject"))
274 }349 : navigation.navigate("ConnectionsNew")
275 />350 }
351 />
352 {canStartScratch ? (
353 <MaterialButton
354 label={startScratchLabel}
355 tone="secondary"
356 onPress={() => void startScratch()}
357 />
358 ) : null}
359 </>
276 ) : !catalogState.hasReadyEnvironment ? (360 ) : !catalogState.hasReadyEnvironment ? (
277 <Pressable361 <Pressable
278 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"362 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...@@ -283,14 +367,26 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
283 </Text>367 </Text>
284 </Pressable>368 </Pressable>
285 ) : (369 ) : (
286 <Pressable370 <>
287 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"371 <Pressable
288 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}372 className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
289 >373 onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
290 <Text className="text-sm font-t3-bold text-primary-foreground">374 >
291 Add new project375 <Text className="text-sm font-t3-bold text-primary-foreground">
292 </Text>376 Add new project
293 </Pressable>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 </>
294 )}390 )}
295 </View>391 </View>
296 ) : visibleScopes.length === 0 ? (392 ) : visibleScopes.length === 0 ? (
...@@ -390,6 +486,55 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -390,6 +486,55 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
390 })}486 })}
391 </View>487 </View>
392 )}488 )}
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}
393 </ScrollView>538 </ScrollView>
394 </MaterialScreenContent>539 </MaterialScreenContent>
395 </View>540 </View>
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-3
...@@ -79,6 +79,7 @@ import {...@@ -79,6 +79,7 @@ import {
79 setPendingConnectionError,79 setPendingConnectionError,
80 useSavedRemoteConnections,80 useSavedRemoteConnections,
81} from "../../state/use-remote-environment-registry";81} from "../../state/use-remote-environment-registry";
82import { isScratchProject } from "@t3tools/client-runtime/state/projects";
82import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";83import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
83import { type VcsRef } from "@t3tools/client-runtime/state/vcs";84import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
84import {85import {
...@@ -146,6 +147,8 @@ type NewTaskFlowContextValue = {...@@ -146,6 +147,8 @@ type NewTaskFlowContextValue = {
146 readonly selectedProjectKey: string | null;147 readonly selectedProjectKey: string | null;
147 readonly selectedModelKey: string | null;148 readonly selectedModelKey: string | null;
148 readonly workspaceMode: WorkspaceMode;149 readonly workspaceMode: WorkspaceMode;
150 /** False for threads without a project: their folder has no branch or worktree. */
151 readonly canChooseWorkspace: boolean;
149 readonly selectedBranchName: string | null;152 readonly selectedBranchName: string | null;
150 readonly selectedWorktreePath: string | null;153 readonly selectedWorktreePath: string | null;
151 readonly startFromOrigin: boolean;154 readonly startFromOrigin: boolean;
...@@ -452,7 +455,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -452,7 +455,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
452 ),455 ),
453 [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile],456 [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile],
454 );457 );
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";
456 // While the file read is pending and nothing above it decided, the467 // While the file read is pending and nothing above it decided, the
457 // resolved default is provisional. Nothing may write it into the draft468 // resolved default is provisional. Nothing may write it into the draft
458 // during that window (the auto-branch effect does), or the frozen interim469 // during that window (the auto-branch effect does), or the frozen interim
...@@ -461,7 +472,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -461,7 +472,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
461 selectedProjectDraft.workspaceSelection?.mode !== undefined ||472 selectedProjectDraft.workspaceSelection?.mode !== undefined ||
462 projectSettings.sources.defaultThreadEnvMode !== "environment" ||473 projectSettings.sources.defaultThreadEnvMode !== "environment" ||
463 !t3ProjectFileQuery.isPending;474 !t3ProjectFileQuery.isPending;
464 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;475 const workspaceMode = canChooseWorkspace
476 ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode)
477 : "local";
465 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;478 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
466 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;479 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
467 // Keep the user's explicit choice separate from the resolved display value:480 // Keep the user's explicit choice separate from the resolved display value:
...@@ -971,7 +984,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -971,7 +984,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
971 if (text.length === 0 || !draftModelSelection) {984 if (text.length === 0 || !draftModelSelection) {
972 return null;985 return null;
973 }986 }
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;
975 // Fall back to the resolved mode (server default) so queued tasks drain990 // Fall back to the resolved mode (server default) so queued tasks drain
976 // with the same mode the composer displayed.991 // with the same mode the composer displayed.
977 const mode = workspaceSelection?.mode ?? workspaceMode;992 const mode = workspaceSelection?.mode ?? workspaceMode;
...@@ -1031,6 +1046,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1031,6 +1046,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1031 };1046 };
1032 },1047 },
1033 [1048 [
1049 canChooseWorkspace,
1034 defaultRuntimeMode,1050 defaultRuntimeMode,
1035 editingPendingProject,1051 editingPendingProject,
1036 editingPendingTask,1052 editingPendingTask,
...@@ -1154,6 +1170,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1154,6 +1170,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1154 selectedProjectKey,1170 selectedProjectKey,
1155 selectedModelKey,1171 selectedModelKey,
1156 workspaceMode,1172 workspaceMode,
1173 canChooseWorkspace,
1157 selectedBranchName,1174 selectedBranchName,
1158 selectedWorktreePath,1175 selectedWorktreePath,
1159 startFromOrigin,1176 startFromOrigin,
...@@ -1259,6 +1276,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -1259,6 +1276,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
1259 startFromOrigin,1276 startFromOrigin,
1260 submitting,1277 submitting,
1261 workspaceMode,1278 workspaceMode,
1279 canChooseWorkspace,
1262 appendAttachments,1280 appendAttachments,
1263 clearAttachments,1281 clearAttachments,
1264 removeAttachment,1282 removeAttachment,
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -115,6 +115,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -115,6 +115,7 @@ export const RPC_REQUIRED_SCOPES = {
115 [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,115 [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
116 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,116 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
117 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,117 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
118 [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope,
118 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,119 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
119 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,120 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
120 [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,121 [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
apps/server/src/server.test.ts+223-1
...@@ -123,7 +123,10 @@ import * as Keybindings from "./keybindings.ts";...@@ -123,7 +123,10 @@ import * as Keybindings from "./keybindings.ts";
123import * as ExternalLauncher from "./process/externalLauncher.ts";123import * as ExternalLauncher from "./process/externalLauncher.ts";
124import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";124import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
125import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";125import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
126import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts";126import {
127 OrchestrationCommandInvariantError,
128 OrchestrationThreadSettleBlockedError,
129} from "./orchestration/Errors.ts";
127import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";130import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
128import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";131import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
129import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";132import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
...@@ -5334,6 +5337,225 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5334,6 +5337,225 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5334 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5337 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5335 );5338 );
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
5337 it.effect("advertises the usable file manager and its reveal label", () =>5559 it.effect("advertises the usable file manager and its reveal label", () =>
5338 Effect.gen(function* () {5560 Effect.gen(function* () {
5339 yield* buildAppUnderTest({5561 yield* buildAppUnderTest({
apps/server/src/ws.ts+221-1
...@@ -180,6 +180,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts";...@@ -180,6 +180,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
180import * as SessionStore from "./auth/SessionStore.ts";180import * as SessionStore from "./auth/SessionStore.ts";
181import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";181import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
182import * as RelayClient from "@t3tools/shared/relayClient";182import * as RelayClient from "@t3tools/shared/relayClient";
183import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
183const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);184const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
184185
185const nowIso = Effect.map(DateTime.now, DateTime.formatIso);186const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
...@@ -1775,7 +1776,143 @@ const makeWsRpcLayer = (...@@ -1775,7 +1776,143 @@ const makeWsRpcLayer = (
1775 return yield* runBootstrap;1776 return yield* runBootstrap;
1776 });1777 });
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 = (
1779 normalizedCommand: OrchestrationCommand,1916 normalizedCommand: OrchestrationCommand,
1780 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => {1917 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => {
1781 const dispatchEffect =1918 const dispatchEffect =
...@@ -1804,6 +1941,83 @@ const makeWsRpcLayer = (...@@ -1804,6 +1941,83 @@ const makeWsRpcLayer = (
1804 );1941 );
1805 };1942 };
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
1807 // Only clients that answer /usage-limits themselves see it in the catalogs;2021 // Only clients that answer /usage-limits themselves see it in the catalogs;
1808 // an older client would send the injected command to the provider.2022 // an older client would send the injected command to the provider.
1809 const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) =>2023 const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) =>
...@@ -1818,6 +2032,7 @@ const makeWsRpcLayer = (...@@ -1818,6 +2032,7 @@ const makeWsRpcLayer = (
1818 );2032 );
1819 const environment = yield* serverEnvironment.getDescriptor;2033 const environment = yield* serverEnvironment.getDescriptor;
1820 const auth = yield* serverAuth.getDescriptor();2034 const auth = yield* serverAuth.getDescriptor();
2035 const scratchWorkspaceRoot = yield* resolveScratchWorkspaceRoot;
1821 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(2036 const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig(
1822 externalLauncher.resolveAvailableEditors(),2037 externalLauncher.resolveAvailableEditors(),
1823 );2038 );
...@@ -1866,6 +2081,7 @@ const makeWsRpcLayer = (...@@ -1866,6 +2081,7 @@ const makeWsRpcLayer = (
1866 threadResumeCompletionMarker: true,2081 threadResumeCompletionMarker: true,
1867 threadSnapshotPagination: true,2082 threadSnapshotPagination: true,
1868 reasoningMessages: true,2083 reasoningMessages: true,
2084 ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }),
1869 };2085 };
1870 });2086 });
18712087
...@@ -3031,6 +3247,10 @@ const makeWsRpcLayer = (...@@ -3031,6 +3247,10 @@ const makeWsRpcLayer = (
3031 }),3247 }),
3032 { "rpc.aggregate": "source-control" },3248 { "rpc.aggregate": "source-control" },
3033 ),3249 ),
3250 [WS_METHODS.projectsEnsureScratch]: () =>
3251 observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, {
3252 "rpc.aggregate": "orchestration",
3253 }),
3034 [WS_METHODS.projectCloneCancel]: (input) =>3254 [WS_METHODS.projectCloneCancel]: (input) =>
3035 observeRpcEffect(3255 observeRpcEffect(
3036 WS_METHODS.projectCloneCancel,3256 WS_METHODS.projectCloneCancel,
apps/web/src/components/CommandPalette.tsx+44-4
...@@ -47,6 +47,7 @@ import {...@@ -47,6 +47,7 @@ import {
47 FileSearchIcon,47 FileSearchIcon,
48 FolderIcon,48 FolderIcon,
49 FolderPlusIcon,49 FolderPlusIcon,
50 MessageSquareDashedIcon,
50 LinkIcon,51 LinkIcon,
51 MessageSquareIcon,52 MessageSquareIcon,
52 MonitorIcon,53 MonitorIcon,
...@@ -95,6 +96,8 @@ import { useEnvironmentQuery } from "../state/query";...@@ -95,6 +96,8 @@ import { useEnvironmentQuery } from "../state/query";
95import { sourceControlEnvironment } from "../state/sourceControl";96import { sourceControlEnvironment } from "../state/sourceControl";
96import { useAtomCommand } from "../state/use-atom-command";97import { useAtomCommand } from "../state/use-atom-command";
97import { useAtomQueryRunner } from "../state/use-atom-query-runner";98import { useAtomQueryRunner } from "../state/use-atom-query-runner";
99import { useScratchProject } from "../hooks/useScratchProject";
100import { isScratchProject } from "@t3tools/client-runtime/state/projects";
98import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";101import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
99import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";102import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
100import { useThreadSearch } from "../state/queries";103import { useThreadSearch } from "../state/queries";
...@@ -708,6 +711,7 @@ function OpenCommandPaletteDialog(props: {...@@ -708,6 +711,7 @@ function OpenCommandPaletteDialog(props: {
708 const createProject = useAtomCommand(projectEnvironment.create, {711 const createProject = useAtomCommand(projectEnvironment.create, {
709 reportFailure: false,712 reportFailure: false,
710 });713 });
714 const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject();
711 const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, {715 const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, {
712 reportFailure: false,716 reportFailure: false,
713 });717 });
...@@ -1100,6 +1104,11 @@ function OpenCommandPaletteDialog(props: {...@@ -1100,6 +1104,11 @@ function OpenCommandPaletteDialog(props: {
1100 const currentProjectEnvironmentId =1104 const currentProjectEnvironmentId =
1101 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;1105 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;
1102 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;1106 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 );
1103 const currentProjectCwd = currentProjectId1112 const currentProjectCwd = currentProjectId
1104 ? (projectCwdById.get(currentProjectId) ?? null)1113 ? (projectCwdById.get(currentProjectId) ?? null)
1105 : null;1114 : null;
...@@ -1270,9 +1279,12 @@ function OpenCommandPaletteDialog(props: {...@@ -1270,9 +1279,12 @@ function OpenCommandPaletteDialog(props: {
12701279
1271 const projectThreadItems = useMemo(1280 const projectThreadItems = useMemo(
1272 () =>1281 () =>
1273 enumerateCommandPaletteItems(1282 enumerateCommandPaletteItems([
1274 buildProjectActionItems({1283 ...buildProjectActionItems({
1275 projects: pickerProjects,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 ),
1276 valuePrefix: "new-thread-in",1288 valuePrefix: "new-thread-in",
1277 searchTerms: (project) => {1289 searchTerms: (project) => {
1278 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);1290 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
...@@ -1323,13 +1335,29 @@ function OpenCommandPaletteDialog(props: {...@@ -1323,13 +1335,29 @@ function OpenCommandPaletteDialog(props: {
1323 );1335 );
1324 },1336 },
1325 }),1337 }),
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 ]),
1327 [1352 [
1328 contextualProjectRef,1353 contextualProjectRef,
1329 handleNewThread,1354 handleNewThread,
1330 pickerProjects,1355 pickerProjects,
1331 projectEnvironmentLocationById,1356 projectEnvironmentLocationById,
1332 projectGroupByTargetKey,1357 projectGroupByTargetKey,
1358 scratchTargetEnvironmentId,
1359 scratchWorkspaceRootFor,
1360 startScratchThread,
1333 ],1361 ],
1334 );1362 );
13351363
...@@ -1787,6 +1815,18 @@ function OpenCommandPaletteDialog(props: {...@@ -1787,6 +1815,18 @@ function OpenCommandPaletteDialog(props: {
1787 });1815 });
1788 }1816 }
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
1790 if (activeThreadReferenceCopyTarget !== null) {1830 if (activeThreadReferenceCopyTarget !== null) {
1791 actionItems.push({1831 actionItems.push({
1792 kind: "action",1832 kind: "action",
apps/web/src/components/NoProjectsHero.tsx+22-3
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { PlusIcon } from "lucide-react";1import { MessageSquareDashedIcon, PlusIcon } from "lucide-react";
2import { useCallback } from "react";2import { useCallback } from "react";
33
4import { openCommandPalette } from "../commandPaletteBus";4import { openCommandPalette } from "../commandPaletteBus";
5import { isElectron } from "../env";5import { isElectron } from "../env";
6import { useScratchProject } from "../hooks/useScratchProject";
7import { usePrimaryEnvironmentId } from "../state/environments";
6import { Button } from "./ui/button";8import { Button } from "./ui/button";
7import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";9import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
8import { SidebarInset } from "./ui/sidebar";10import { SidebarInset } from "./ui/sidebar";
...@@ -10,6 +12,9 @@ import { WorkspacePageHeader } from "./WorkspacePageHeader";...@@ -10,6 +12,9 @@ import { WorkspacePageHeader } from "./WorkspacePageHeader";
1012
11export function NoProjectsHero() {13export function NoProjectsHero() {
12 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);14 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
15 const primaryEnvironmentId = usePrimaryEnvironmentId();
16 const { scratchEnvironmentId, startScratchThread } = useScratchProject();
17 const scratchTargetEnvironmentId = scratchEnvironmentId(primaryEnvironmentId);
1318
14 return (19 return (
15 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">20 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
...@@ -20,12 +25,26 @@ export function NoProjectsHero() {...@@ -20,12 +25,26 @@ export function NoProjectsHero() {
20 <div className="w-full max-w-lg px-8 py-12">25 <div className="w-full max-w-lg px-8 py-12">
21 <EmptyHeader className="max-w-none">26 <EmptyHeader className="max-w-none">
22 <EmptyTitle>What should we work on?</EmptyTitle>27 <EmptyTitle>What should we work on?</EmptyTitle>
23 <EmptyDescription>Add a project to start your first thread.</EmptyDescription>28 <EmptyDescription>
24 <div className="mt-6 flex justify-center">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">
25 <Button size="sm" onClick={openAddProject}>34 <Button size="sm" onClick={openAddProject}>
26 <PlusIcon className="size-4" />35 <PlusIcon className="size-4" />
27 Add project36 Add project
28 </Button>37 </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 )}
29 </div>48 </div>
30 </EmptyHeader>49 </EmptyHeader>
31 </div>50 </div>
apps/web/src/components/chat/DraftHeroHeadline.tsx+179-52
...@@ -2,13 +2,22 @@ import type { DraftId } from "~/composerDraftStore";...@@ -2,13 +2,22 @@ import type { DraftId } from "~/composerDraftStore";
2import { useComposerDraftStore } from "~/composerDraftStore";2import { useComposerDraftStore } from "~/composerDraftStore";
3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
5import { FolderPlusIcon } from "lucide-react";5import { isScratchProject } from "@t3tools/client-runtime/state/projects";
6import { useCallback, useMemo } from "react";6import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react";
7import { useAtomValue } from "@effect/atom-react";
8import { useCallback, useEffect, useMemo, useRef } from "react";
79
8import { openCommandPalette } from "~/commandPaletteBus";10import { openCommandPalette } from "~/commandPaletteBus";
11import { shortcutLabelForCommand } from "~/keybindings";
12import { projectIconColorClassName } from "~/projectIconColors";
13import { primaryServerKeybindingsAtom } from "~/state/server";
14import { useScratchProject } from "~/hooks/useScratchProject";
9import { useClientSettings } from "~/hooks/useSettings";15import { useClientSettings } from "~/hooks/useSettings";
10import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";16import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";
11import { selectProjectGroupingSettings } from "~/logicalProject";17import {
18 deriveLogicalProjectKeyFromSettings,
19 selectProjectGroupingSettings,
20} from "~/logicalProject";
12import {21import {
13 buildSidebarProjectPickerEntries,22 buildSidebarProjectPickerEntries,
14 buildSidebarProjectSnapshots,23 buildSidebarProjectSnapshots,
...@@ -32,6 +41,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -32,6 +41,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
32import { InlineButton } from "../ui/button";41import { InlineButton } from "../ui/button";
33import { resolveProjectSettings } from "@t3tools/shared/projectSettings";42import { 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
35interface DraftHeroHeadlineProps {47interface DraftHeroHeadlineProps {
36 readonly draftId: DraftId | null;48 readonly draftId: DraftId | null;
37 readonly activeProjectRef: ScopedProjectRef | null;49 readonly activeProjectRef: ScopedProjectRef | null;
...@@ -56,6 +68,8 @@ export function DraftHeroHeadline({...@@ -56,6 +68,8 @@ export function DraftHeroHeadline({
56 const applyStickyState = useComposerDraftStore((store) => store.applyStickyState);68 const applyStickyState = useComposerDraftStore((store) => store.applyStickyState);
57 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);69 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);
58 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);70 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
71 const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
72 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
5973
60 const environmentLabelById = useMemo(74 const environmentLabelById = useMemo(
61 () =>75 () =>
...@@ -131,6 +145,85 @@ export function DraftHeroHeadline({...@@ -131,6 +145,85 @@ export function DraftHeroHeadline({
131 const hasResolvedProject = activeProjectTitle !== null;145 const hasResolvedProject = activeProjectTitle !== null;
132 const canChooseProject = projectPickerEntries.length > 0;146 const canChooseProject = projectPickerEntries.length > 0;
133 const shouldShowProjectMenu = canChooseProject;147 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
135 const projectSelector = shouldShowProjectMenu ? (228 const projectSelector = shouldShowProjectMenu ? (
136 <Menu>229 <Menu>
...@@ -143,55 +236,49 @@ export function DraftHeroHeadline({...@@ -143,55 +236,49 @@ export function DraftHeroHeadline({
143 // mid-sentence and baffle screen-reader users.236 // mid-sentence and baffle screen-reader users.
144 <MenuTrigger237 <MenuTrigger
145 render={<InlineButton tone="picker" />}238 render={<InlineButton tone="picker" />}
239 data-draft-project-trigger=""
146 className="pointer-events-auto max-w-64 align-baseline"240 className="pointer-events-auto max-w-64 align-baseline"
147 />241 />
148 }242 }
149 >243 >
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>
151 </TooltipTrigger>247 </TooltipTrigger>
152 {activeProjectDisplayName ? (248 {activeProjectDisplayName && !isScratchDraft ? (
153 <TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>249 <TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>
154 ) : null}250 ) : null}
155 </Tooltip>251 </Tooltip>
156 <MenuPopup align="center" className="max-h-80 overflow-y-auto">252 <MenuPopup align="center" className="max-h-80 overflow-y-auto">
157 <MenuRadioGroup253 <MenuRadioGroup
158 value={activeProjectKey}254 value={isScratchDraft ? NO_PROJECT_VALUE : activeProjectKey}
159 onValueChange={(value) => {255 onValueChange={(value) => {
160 const entry = projectEntryByKey.get(value as string);256 if (value === NO_PROJECT_VALUE) {
161 if (!entry || value === activeProjectKey) {257 void startScratch();
162 return;258 return;
163 }259 }
164 const project = entry.targetProject;260 const entry = projectEntryByKey.get(value as string);
165 if (!draftId) {261 if (!entry || value === activeProjectKey) {
166 return;262 return;
167 }263 }
168 // Project selection changes the target of the open draft in264 selectProject(entry.targetProject, entry.group.projectKey);
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 }
192 }}265 }}
193 >266 >
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 }) => {
195 return (282 return (
196 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>283 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
197 <span className="flex min-w-0 items-center gap-2">284 <span className="flex min-w-0 items-center gap-2">
...@@ -214,7 +301,7 @@ export function DraftHeroHeadline({...@@ -214,7 +301,7 @@ export function DraftHeroHeadline({
214 );301 );
215 })}302 })}
216 </MenuRadioGroup>303 </MenuRadioGroup>
217 <MenuSeparator />304 {projectPickerEntries.length > 0 ? <MenuSeparator /> : null}
218 <MenuItem onClick={openAddProject}>305 <MenuItem onClick={openAddProject}>
219 <FolderPlusIcon />306 <FolderPlusIcon />
220 New project307 New project
...@@ -235,24 +322,64 @@ export function DraftHeroHeadline({...@@ -235,24 +322,64 @@ export function DraftHeroHeadline({
235 // a complete sentence too. The project picker is a control rendered inline322 // a complete sentence too. The project picker is a control rendered inline
236 // in the h1; without an explicit label its widget state bleeds into the323 // in the h1; without an explicit label its widget state bleeds into the
237 // announced phrase.324 // announced phrase.
238 const headingLabel = hasResolvedProject325 const headingLabel = isScratchDraft
239 ? `What should we build in ${activeProjectDisplayName}?`326 ? "What should we work on?"
240 : canChooseProject327 : hasResolvedProject
241 ? `${activeProjectDisplayName ?? "Choose a project"} to start`328 ? `What should we build in ${activeProjectDisplayName}?`
242 : "Add a project to start";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
244 return (360 return (
245 <h1361 <div className="mx-auto flex w-full max-w-5xl flex-col items-center">
246 aria-label={headingLabel}362 <h1
247 className="mx-auto w-full max-w-5xl text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl"363 aria-label={headingLabel}
248 >364 className="w-full text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl"
249 {hasResolvedProject ? (365 >
250 <>What should we build in {projectSelector}?</>366 {isScratchDraft ? (
251 ) : canChooseProject ? (367 <>What should we work on?</>
252 <>{projectSelector} to start</>368 ) : hasResolvedProject ? (
253 ) : (369 <>What should we build in {projectSelector}?</>
254 <>Add a project to start</>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>
255 )}382 )}
256 </h1>383 </div>
257 );384 );
258}385}
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+1-1
...@@ -49,13 +49,13 @@ describe("KeybindingsSettings.logic", () => {...@@ -49,13 +49,13 @@ describe("KeybindingsSettings.logic", () => {
49 it("orders Usage bindings and command choices like the page", () => {49 it("orders Usage bindings and command choices like the page", () => {
50 const expected = [50 const expected = [
51 "usage.cost",51 "usage.cost",
52 "usage.open",
53 "usage.tokens",52 "usage.tokens",
54 "usage.limits",53 "usage.limits",
55 "usage.period.day",54 "usage.period.day",
56 "usage.period.week",55 "usage.period.week",
57 "usage.period.month",56 "usage.period.month",
58 "usage.period.quarter",57 "usage.period.quarter",
58 "usage.open",
59 ];59 ];
60 const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed();60 const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed();
61 expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected);61 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>(...@@ -19,10 +19,24 @@ const usageCommandOrder = new Map<KeybindingCommand, number>(
19 [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]),19 [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]),
20);20);
2121
22function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null {22const firstUsageCommand = METRIC_OPTIONS[0].command;
23 const leftIndex = usageCommandOrder.get(left);23
24 const rightIndex = usageCommandOrder.get(right);24/**
25 return leftIndex !== undefined && rightIndex !== undefined ? leftIndex - rightIndex : null;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 );
26}40}
2741
28export type KeybindingSource = "Default" | "Custom" | "Project";42export type KeybindingSource = "Default" | "Custom" | "Project";
...@@ -215,9 +229,7 @@ export function buildKeybindingRows(...@@ -215,9 +229,7 @@ export function buildKeybindingRows(
215 });229 });
216230
217 rowsWithConflicts.sort((left, right) => {231 rowsWithConflicts.sort((left, right) => {
218 const commandCompare =232 const commandCompare = compareCommands(left.command, right.command, (command) => command);
219 compareUsageCommands(left.command, right.command) ??
220 left.command.localeCompare(right.command);
221 if (commandCompare !== 0) return commandCompare;233 if (commandCompare !== 0) return commandCompare;
222 return left.key.localeCompare(right.key);234 return left.key.localeCompare(right.key);
223 });235 });
...@@ -290,10 +302,7 @@ export function buildKeybindingCommandOptions(...@@ -290,10 +302,7 @@ export function buildKeybindingCommandOptions(
290 for (const binding of keybindings) {302 for (const binding of keybindings) {
291 commands.add(binding.command);303 commands.add(binding.command);
292 }304 }
293 return [...commands].toSorted(305 return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel));
294 (left, right) =>
295 compareUsageCommands(left, right) ?? commandLabel(left).localeCompare(commandLabel(right)),
296 );
297}306}
298307
299export function commandLabel(command: KeybindingCommand): string {308export 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";...@@ -19,6 +19,7 @@ import { useComposerDraftStore } from "../composerDraftStore";
19import { terminalEnvironment } from "../state/terminal";19import { terminalEnvironment } from "../state/terminal";
20import { appAtomRegistry } from "../rpc/atomRegistry";20import { appAtomRegistry } from "../rpc/atomRegistry";
21import { environmentServerConfigsAtom } from "../state/server";21import { environmentServerConfigsAtom } from "../state/server";
22import { isScratchProject } from "@t3tools/client-runtime/state/projects";
22import { threadEnvironment } from "../state/threads";23import { threadEnvironment } from "../state/threads";
23import { vcsEnvironment } from "../state/vcs";24import { vcsEnvironment } from "../state/vcs";
24import { useNewThreadHandler } from "./useHandleNewThread";25import { useNewThreadHandler } from "./useHandleNewThread";
...@@ -399,12 +400,18 @@ export function useThreadActions() {...@@ -399,12 +400,18 @@ export function useThreadActions() {
399 const displayWorktreePath = orphanedWorktreePath400 const displayWorktreePath = orphanedWorktreePath
400 ? formatWorktreePathForDisplay(orphanedWorktreePath)401 ? formatWorktreePathForDisplay(orphanedWorktreePath)
401 : null;402 : 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);
403 const localApi = readLocalApi();412 const localApi = readLocalApi();
404 let shouldDeleteWorktree = false;413 let shouldDeleteWorktree = false;
405 const environmentSettings = appAtomRegistry414 const environmentSettings = environmentConfig?.settings;
406 .get(environmentServerConfigsAtom)
407 .get(threadRef.environmentId)?.settings;
408 const automaticWorktreeCleanup = environmentSettings415 const automaticWorktreeCleanup = environmentSettings
409 ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete416 ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete
410 : false;417 : false;
apps/web/src/routes/_chat.tsx+16
...@@ -13,6 +13,7 @@ import { selectProjectGroupingSettings } from "../logicalProject";...@@ -13,6 +13,7 @@ import { selectProjectGroupingSettings } from "../logicalProject";
13import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";13import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
14import { dispatchPreviewAction } from "../components/preview/previewActionBus";14import { dispatchPreviewAction } from "../components/preview/previewActionBus";
15import { useHandleNewThread } from "../hooks/useHandleNewThread";15import { useHandleNewThread } from "../hooks/useHandleNewThread";
16import { useScratchProject } from "../hooks/useScratchProject";
16import { startNewThreadFromContext } from "../lib/chatThreadActions";17import { startNewThreadFromContext } from "../lib/chatThreadActions";
17import { isPreviewFocused } from "../lib/previewFocus";18import { isPreviewFocused } from "../lib/previewFocus";
18import { isTerminalFocused } from "../lib/terminalFocus";19import { isTerminalFocused } from "../lib/terminalFocus";
...@@ -37,6 +38,7 @@ function ChatRouteGlobalShortcuts() {...@@ -37,6 +38,7 @@ function ChatRouteGlobalShortcuts() {
37 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);38 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
38 const projects = useProjects();39 const projects = useProjects();
39 const primaryEnvironmentId = usePrimaryEnvironmentId();40 const primaryEnvironmentId = usePrimaryEnvironmentId();
41 const { scratchEnvironmentId, startScratchThread } = useScratchProject();
40 const projectGroupCount = useMemo(42 const projectGroupCount = useMemo(
41 () =>43 () =>
42 buildSidebarProjectSnapshots({44 buildSidebarProjectSnapshots({
...@@ -105,6 +107,17 @@ function ChatRouteGlobalShortcuts() {...@@ -105,6 +107,17 @@ function ChatRouteGlobalShortcuts() {
105 return;107 return;
106 }108 }
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
108 if (command === "chat.new") {121 if (command === "chat.new") {
109 event.preventDefault();122 event.preventDefault();
110 event.stopPropagation();123 event.stopPropagation();
...@@ -180,9 +193,12 @@ function ChatRouteGlobalShortcuts() {...@@ -180,9 +193,12 @@ function ChatRouteGlobalShortcuts() {
180 keybindings,193 keybindings,
181 defaultProjectRef,194 defaultProjectRef,
182 previewOpen,195 previewOpen,
196 primaryEnvironmentId,
183 projectGroupCount,197 projectGroupCount,
184 routeThreadRef,198 routeThreadRef,
199 scratchEnvironmentId,
185 selectedThreadKeysSize,200 selectedThreadKeysSize,
201 startScratchThread,
186 legacySidebarEnabled,202 legacySidebarEnabled,
187 terminalOpen,203 terminalOpen,
188 ]);204 ]);
docs/user/keybindings.md+2-1
...@@ -122,7 +122,8 @@ through the pages you have visited, like a browser's back and forward buttons....@@ -122,7 +122,8 @@ through the pages you have visited, like a browser's back and forward buttons.
122122
123`chat.new` may ask you to choose a project when there is more than one.123`chat.new` may ask you to choose a project when there is more than one.
124`chat.newLocal` skips that chooser. Both use your124`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
127## Reserved shortcuts128## 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...@@ -13,6 +13,22 @@ an existing worktree, use **New thread in this worktree** from the branch toolba
13When you change a new thread's project, T3 Code stays in the current environment13When you change a new thread's project, T3 Code stays in the current environment
14if that project exists there. Otherwise it selects an environment that has it.14if 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
16### Start in the background32### Start in the background
1733
18In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter`34In 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>(...@@ -92,6 +92,13 @@ export function createProjectEnvironmentAtoms<R, E>(
92 scheduler: projectScheduler,92 scheduler: projectScheduler,
93 concurrency: projectConcurrency,93 concurrency: projectConcurrency,
94 }),94 }),
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 }),
95 writeFile: createEnvironmentRpcCommand(runtime, {102 writeFile: createEnvironmentRpcCommand(runtime, {
96 label: "environment-data:projects:write-file",103 label: "environment-data:projects:write-file",
97 tag: WS_METHODS.projectsWriteFile,104 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...@@ -135,6 +135,16 @@ export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: stri
135 });135 });
136}136}
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
138export function inferProjectTitleFromPath(value: string): string {148export function inferProjectTitleFromPath(value: string): string {
139 const normalized = normalizeProjectPathForDispatch(value);149 const normalized = normalizeProjectPathForDispatch(value);
140 const absolutePath = splitAbsolutePath(normalized);150 const absolutePath = splitAbsolutePath(normalized);
packages/contracts/src/keybindings.ts+1
...@@ -91,6 +91,7 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -91,6 +91,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
91 "composer.branch",91 "composer.branch",
92 "chat.new",92 "chat.new",
93 "chat.newLocal",93 "chat.newLocal",
94 "chat.newWithoutProject",
94 "editor.openFavorite",95 "editor.openFavorite",
95 "usage.cost",96 "usage.cost",
96 "usage.tokens",97 "usage.tokens",
packages/contracts/src/project.ts+7
...@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";...@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
2import {2import {
3 NonNegativeInt,3 NonNegativeInt,
4 PositiveInt,4 PositiveInt,
5 ProjectId,
5 TrimmedNonEmptyString,6 TrimmedNonEmptyString,
6 TrimmedString,7 TrimmedString,
7} from "./baseSchemas.ts";8} from "./baseSchemas.ts";
...@@ -281,6 +282,12 @@ export const ProjectWriteFileResult = Schema.Struct({...@@ -281,6 +282,12 @@ export const ProjectWriteFileResult = Schema.Struct({
281});282});
282export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type;283export 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
284export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()(291export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()(
285 "ProjectWriteFileError",292 "ProjectWriteFileError",
286 {293 {
packages/contracts/src/rpc.ts+10
...@@ -159,6 +159,7 @@ import {...@@ -159,6 +159,7 @@ import {
159 RelayClientStatusSchema,159 RelayClientStatusSchema,
160} from "./relayClient.ts";160} from "./relayClient.ts";
161import {161import {
162 ProjectEnsureScratchResult,
162 ProjectListEntriesError,163 ProjectListEntriesError,
163 ProjectListEntriesInput,164 ProjectListEntriesInput,
164 ProjectListEntriesResult,165 ProjectListEntriesResult,
...@@ -293,6 +294,7 @@ export const WS_METHODS = {...@@ -293,6 +294,7 @@ export const WS_METHODS = {
293 projectsSearchContents: "projects.searchContents",294 projectsSearchContents: "projects.searchContents",
294 projectsSearchEntries: "projects.searchEntries",295 projectsSearchEntries: "projects.searchEntries",
295 projectsWriteFile: "projects.writeFile",296 projectsWriteFile: "projects.writeFile",
297 projectsEnsureScratch: "projects.ensureScratch",
296298
297 // Shell methods299 // Shell methods
298 shellOpenInEditor: "shell.openInEditor",300 shellOpenInEditor: "shell.openInEditor",
...@@ -991,6 +993,13 @@ const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {...@@ -991,6 +993,13 @@ const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
991 error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]),993 error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]),
992});994});
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
994const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {1003const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
995 payload: LaunchEditorInput,1004 payload: LaunchEditorInput,
996 error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]),1005 error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]),
...@@ -1508,6 +1517,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1508,6 +1517,7 @@ export const WsRpcGroup = RpcGroup.make(
1508 WsProjectsReadFileRpc,1517 WsProjectsReadFileRpc,
1509 WsProjectsSearchContentsRpc,1518 WsProjectsSearchContentsRpc,
1510 WsProjectsSearchEntriesRpc,1519 WsProjectsSearchEntriesRpc,
1520 WsProjectsEnsureScratchRpc,
1511 WsProjectsWriteFileRpc,1521 WsProjectsWriteFileRpc,
1512 WsShellOpenInEditorRpc,1522 WsShellOpenInEditorRpc,
1513 WsFilesystemBrowseRpc,1523 WsFilesystemBrowseRpc,
packages/contracts/src/server.ts+6
...@@ -611,6 +611,12 @@ export const ServerConfig = Schema.Struct({...@@ -611,6 +611,12 @@ export const ServerConfig = Schema.Struct({
611 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),611 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
612 /** Whether thread reads accept the reasoningMessages opt-in. */612 /** Whether thread reads accept the reasoningMessages opt-in. */
613 reasoningMessages: Schema.optionalKey(Schema.Boolean),613 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),
614 /**620 /**
615 * Palettes published by this environment's machine. Never sent in a config621 * Palettes published by this environment's machine. Never sent in a config
616 * snapshot: the theme stream emits the current set before any change, so a622 * 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> = [...@@ -49,6 +49,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
49 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },49 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
50 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },50 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
51 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },51 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
52 { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" },
52 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },53 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
53 { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" },54 { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" },
54 { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" },55 { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" },