| author | |
| committer | |
| log | 6b286ae8a20ef2e18fa70b09e92f66edd859128b |
| tree | 68b10b63493d578beb85b3a5c2ed49f17e48b3c0 |
| parent | 7c67876983ade7ef886c825eefe569b3a96c81e8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1598 | /> |
| 1599 | 1599 | </View> |
| 1600 | 1600 | ) : null} |
| 1601 | <View className="pb-1">{workspaceControls}</View> | |
| 1601 | {flow.canChooseWorkspace ? <View className="pb-1">{workspaceControls}</View> : null} | |
| 1602 | 1602 | |
| 1603 | 1603 | {modelUnavailable ? ( |
| 1604 | 1604 | <Pressable |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+163-18| ... | ... | @@ -7,7 +7,11 @@ import { |
| 7 | 7 | type StaticScreenProps, |
| 8 | 8 | } from "@react-navigation/native"; |
| 9 | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 10 | import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; | |
| 11 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | |
| 10 | 12 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 13 | import * as Cause from "effect/Cause"; | |
| 14 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 11 | 15 | import { useEffect, useRef, useState } from "react"; |
| 12 | 16 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; |
| 13 | 17 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | ... | @@ -17,7 +21,10 @@ import { MaterialButton } from "../../components/MaterialButton"; |
| 17 | 21 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 18 | 22 | import { AppText as Text } from "../../components/AppText"; |
| 19 | 23 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 20 | import { useProjects } from "../../state/entities"; | |
| 24 | import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; | |
| 25 | import { projectEnvironment } from "../../state/projects"; | |
| 26 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 27 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | |
| 21 | 28 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 22 | 29 | import { useWorkspaceState } from "../../state/workspace"; |
| 23 | 30 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| ... | ... | @@ -156,6 +163,44 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 156 | 163 | project.id === incomingShare.destination?.projectId, |
| 157 | 164 | ) ?? null) |
| 158 | 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); | |
| 159 | 204 | |
| 160 | 205 | async function selectProject(project: EnvironmentProject): Promise<void> { |
| 161 | 206 | if (incomingShare?.destination && !reservedDestinationProject) { |
| ... | ... | @@ -189,6 +234,36 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 189 | 234 | ); |
| 190 | 235 | } |
| 191 | 236 | |
| 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 | 267 | useEffect(() => { |
| 193 | 268 | const destination = incomingShare?.destination; |
| 194 | 269 | if (!destination) { |
| ... | ... | @@ -264,15 +339,24 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 264 | 339 | {projectEmptyState.detail} |
| 265 | 340 | </Text> |
| 266 | 341 | {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 | </> | |
| 276 | 360 | ) : !catalogState.hasReadyEnvironment ? ( |
| 277 | 361 | <Pressable |
| 278 | 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 | 367 | </Text> |
| 284 | 368 | </Pressable> |
| 285 | 369 | ) : ( |
| 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 | </> | |
| 294 | 390 | )} |
| 295 | 391 | </View> |
| 296 | 392 | ) : visibleScopes.length === 0 ? ( |
| ... | ... | @@ -390,6 +486,55 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 390 | 486 | })} |
| 391 | 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 | 538 | </ScrollView> |
| 394 | 539 | </MaterialScreenContent> |
| 395 | 540 | </View> |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-3| ... | ... | @@ -79,6 +79,7 @@ import { |
| 79 | 79 | setPendingConnectionError, |
| 80 | 80 | useSavedRemoteConnections, |
| 81 | 81 | } from "../../state/use-remote-environment-registry"; |
| 82 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | |
| 82 | 83 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 83 | 84 | import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 84 | 85 | import { |
| ... | ... | @@ -146,6 +147,8 @@ type NewTaskFlowContextValue = { |
| 146 | 147 | readonly selectedProjectKey: string | null; |
| 147 | 148 | readonly selectedModelKey: string | null; |
| 148 | 149 | readonly workspaceMode: WorkspaceMode; |
| 150 | /** False for threads without a project: their folder has no branch or worktree. */ | |
| 151 | readonly canChooseWorkspace: boolean; | |
| 149 | 152 | readonly selectedBranchName: string | null; |
| 150 | 153 | readonly selectedWorktreePath: string | null; |
| 151 | 154 | readonly startFromOrigin: boolean; |
| ... | ... | @@ -452,7 +455,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 452 | 455 | ), |
| 453 | 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 | 467 | // While the file read is pending and nothing above it decided, the |
| 457 | 468 | // resolved default is provisional. Nothing may write it into the draft |
| 458 | 469 | // during that window (the auto-branch effect does), or the frozen interim |
| ... | ... | @@ -461,7 +472,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 461 | 472 | selectedProjectDraft.workspaceSelection?.mode !== undefined || |
| 462 | 473 | projectSettings.sources.defaultThreadEnvMode !== "environment" || |
| 463 | 474 | !t3ProjectFileQuery.isPending; |
| 464 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; | |
| 475 | const workspaceMode = canChooseWorkspace | |
| 476 | ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode) | |
| 477 | : "local"; | |
| 465 | 478 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; |
| 466 | 479 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; |
| 467 | 480 | // Keep the user's explicit choice separate from the resolved display value: |
| ... | ... | @@ -971,7 +984,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 971 | 984 | if (text.length === 0 || !draftModelSelection) { |
| 972 | 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 | 990 | // Fall back to the resolved mode (server default) so queued tasks drain |
| 976 | 991 | // with the same mode the composer displayed. |
| 977 | 992 | const mode = workspaceSelection?.mode ?? workspaceMode; |
| ... | ... | @@ -1031,6 +1046,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1031 | 1046 | }; |
| 1032 | 1047 | }, |
| 1033 | 1048 | [ |
| 1049 | canChooseWorkspace, | |
| 1034 | 1050 | defaultRuntimeMode, |
| 1035 | 1051 | editingPendingProject, |
| 1036 | 1052 | editingPendingTask, |
| ... | ... | @@ -1154,6 +1170,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1154 | 1170 | selectedProjectKey, |
| 1155 | 1171 | selectedModelKey, |
| 1156 | 1172 | workspaceMode, |
| 1173 | canChooseWorkspace, | |
| 1157 | 1174 | selectedBranchName, |
| 1158 | 1175 | selectedWorktreePath, |
| 1159 | 1176 | startFromOrigin, |
| ... | ... | @@ -1259,6 +1276,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1259 | 1276 | startFromOrigin, |
| 1260 | 1277 | submitting, |
| 1261 | 1278 | workspaceMode, |
| 1279 | canChooseWorkspace, | |
| 1262 | 1280 | appendAttachments, |
| 1263 | 1281 | clearAttachments, |
| 1264 | 1282 | removeAttachment, |
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -115,6 +115,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 115 | 115 | [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, |
| 116 | 116 | [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, |
| 117 | 117 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, |
| 118 | [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, | |
| 118 | 119 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 119 | 120 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, |
| 120 | 121 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, |
apps/server/src/server.test.ts+223-1| ... | ... | @@ -123,7 +123,10 @@ import * as Keybindings from "./keybindings.ts"; |
| 123 | 123 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 124 | 124 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| 125 | 125 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 126 | import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts"; | |
| 126 | import { | |
| 127 | OrchestrationCommandInvariantError, | |
| 128 | OrchestrationThreadSettleBlockedError, | |
| 129 | } from "./orchestration/Errors.ts"; | |
| 127 | 130 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 128 | 131 | import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; |
| 129 | 132 | import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; |
| ... | ... | @@ -5334,6 +5337,225 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5334 | 5337 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5335 | 5338 | ); |
| 5336 | 5339 | |
| 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 | 5559 | it.effect("advertises the usable file manager and its reveal label", () => |
| 5338 | 5560 | Effect.gen(function* () { |
| 5339 | 5561 | yield* buildAppUnderTest({ |
apps/server/src/ws.ts+221-1| ... | ... | @@ -180,6 +180,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; |
| 180 | 180 | import * as SessionStore from "./auth/SessionStore.ts"; |
| 181 | 181 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; |
| 182 | 182 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 183 | import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; | |
| 183 | 184 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 184 | 185 | |
| 185 | 186 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| ... | ... | @@ -1775,7 +1776,143 @@ const makeWsRpcLayer = ( |
| 1775 | 1776 | return yield* runBootstrap; |
| 1776 | 1777 | }); |
| 1777 | 1778 | |
| 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 | 1916 | normalizedCommand: OrchestrationCommand, |
| 1780 | 1917 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { |
| 1781 | 1918 | const dispatchEffect = |
| ... | ... | @@ -1804,6 +1941,83 @@ const makeWsRpcLayer = ( |
| 1804 | 1941 | ); |
| 1805 | 1942 | }; |
| 1806 | 1943 | |
| 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 | 2021 | // Only clients that answer /usage-limits themselves see it in the catalogs; |
| 1808 | 2022 | // an older client would send the injected command to the provider. |
| 1809 | 2023 | const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => |
| ... | ... | @@ -1818,6 +2032,7 @@ const makeWsRpcLayer = ( |
| 1818 | 2032 | ); |
| 1819 | 2033 | const environment = yield* serverEnvironment.getDescriptor; |
| 1820 | 2034 | const auth = yield* serverAuth.getDescriptor(); |
| 2035 | const scratchWorkspaceRoot = yield* resolveScratchWorkspaceRoot; | |
| 1821 | 2036 | const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig( |
| 1822 | 2037 | externalLauncher.resolveAvailableEditors(), |
| 1823 | 2038 | ); |
| ... | ... | @@ -1866,6 +2081,7 @@ const makeWsRpcLayer = ( |
| 1866 | 2081 | threadResumeCompletionMarker: true, |
| 1867 | 2082 | threadSnapshotPagination: true, |
| 1868 | 2083 | reasoningMessages: true, |
| 2084 | ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), | |
| 1869 | 2085 | }; |
| 1870 | 2086 | }); |
| 1871 | 2087 | |
| ... | ... | @@ -3031,6 +3247,10 @@ const makeWsRpcLayer = ( |
| 3031 | 3247 | }), |
| 3032 | 3248 | { "rpc.aggregate": "source-control" }, |
| 3033 | 3249 | ), |
| 3250 | [WS_METHODS.projectsEnsureScratch]: () => | |
| 3251 | observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { | |
| 3252 | "rpc.aggregate": "orchestration", | |
| 3253 | }), | |
| 3034 | 3254 | [WS_METHODS.projectCloneCancel]: (input) => |
| 3035 | 3255 | observeRpcEffect( |
| 3036 | 3256 | WS_METHODS.projectCloneCancel, |
apps/web/src/components/CommandPalette.tsx+44-4| ... | ... | @@ -47,6 +47,7 @@ import { |
| 47 | 47 | FileSearchIcon, |
| 48 | 48 | FolderIcon, |
| 49 | 49 | FolderPlusIcon, |
| 50 | MessageSquareDashedIcon, | |
| 50 | 51 | LinkIcon, |
| 51 | 52 | MessageSquareIcon, |
| 52 | 53 | MonitorIcon, |
| ... | ... | @@ -95,6 +96,8 @@ import { useEnvironmentQuery } from "../state/query"; |
| 95 | 96 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 96 | 97 | import { useAtomCommand } from "../state/use-atom-command"; |
| 97 | 98 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 99 | import { useScratchProject } from "../hooks/useScratchProject"; | |
| 100 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | |
| 98 | 101 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 99 | 102 | import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; |
| 100 | 103 | import { useThreadSearch } from "../state/queries"; |
| ... | ... | @@ -708,6 +711,7 @@ function OpenCommandPaletteDialog(props: { |
| 708 | 711 | const createProject = useAtomCommand(projectEnvironment.create, { |
| 709 | 712 | reportFailure: false, |
| 710 | 713 | }); |
| 714 | const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); | |
| 711 | 715 | const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { |
| 712 | 716 | reportFailure: false, |
| 713 | 717 | }); |
| ... | ... | @@ -1100,6 +1104,11 @@ function OpenCommandPaletteDialog(props: { |
| 1100 | 1104 | const currentProjectEnvironmentId = |
| 1101 | 1105 | activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; |
| 1102 | 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 | 1112 | const currentProjectCwd = currentProjectId |
| 1104 | 1113 | ? (projectCwdById.get(currentProjectId) ?? null) |
| 1105 | 1114 | : null; |
| ... | ... | @@ -1270,9 +1279,12 @@ function OpenCommandPaletteDialog(props: { |
| 1270 | 1279 | |
| 1271 | 1280 | const projectThreadItems = useMemo( |
| 1272 | 1281 | () => |
| 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 | ), | |
| 1276 | 1288 | valuePrefix: "new-thread-in", |
| 1277 | 1289 | searchTerms: (project) => { |
| 1278 | 1290 | const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); |
| ... | ... | @@ -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 | 1353 | contextualProjectRef, |
| 1329 | 1354 | handleNewThread, |
| 1330 | 1355 | pickerProjects, |
| 1331 | 1356 | projectEnvironmentLocationById, |
| 1332 | 1357 | projectGroupByTargetKey, |
| 1358 | scratchTargetEnvironmentId, | |
| 1359 | scratchWorkspaceRootFor, | |
| 1360 | startScratchThread, | |
| 1333 | 1361 | ], |
| 1334 | 1362 | ); |
| 1335 | 1363 | |
| ... | ... | @@ -1787,6 +1815,18 @@ function OpenCommandPaletteDialog(props: { |
| 1787 | 1815 | }); |
| 1788 | 1816 | } |
| 1789 | 1817 | |
| 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 | 1830 | if (activeThreadReferenceCopyTarget !== null) { |
| 1791 | 1831 | actionItems.push({ |
| 1792 | 1832 | kind: "action", |
apps/web/src/components/NoProjectsHero.tsx+22-3| ... | ... | @@ -1,8 +1,10 @@ |
| 1 | import { PlusIcon } from "lucide-react"; | |
| 1 | import { MessageSquareDashedIcon, PlusIcon } from "lucide-react"; | |
| 2 | 2 | import { useCallback } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { openCommandPalette } from "../commandPaletteBus"; |
| 5 | 5 | import { isElectron } from "../env"; |
| 6 | import { useScratchProject } from "../hooks/useScratchProject"; | |
| 7 | import { usePrimaryEnvironmentId } from "../state/environments"; | |
| 6 | 8 | import { Button } from "./ui/button"; |
| 7 | 9 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; |
| 8 | 10 | import { SidebarInset } from "./ui/sidebar"; |
| ... | ... | @@ -10,6 +12,9 @@ import { WorkspacePageHeader } from "./WorkspacePageHeader"; |
| 10 | 12 | |
| 11 | 13 | export function NoProjectsHero() { |
| 12 | 14 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); |
| 15 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 16 | const { scratchEnvironmentId, startScratchThread } = useScratchProject(); | |
| 17 | const scratchTargetEnvironmentId = scratchEnvironmentId(primaryEnvironmentId); | |
| 13 | 18 | |
| 14 | 19 | return ( |
| 15 | 20 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none"> |
| ... | ... | @@ -20,12 +25,26 @@ export function NoProjectsHero() { |
| 20 | 25 | <div className="w-full max-w-lg px-8 py-12"> |
| 21 | 26 | <EmptyHeader className="max-w-none"> |
| 22 | 27 | <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"> | |
| 25 | 34 | <Button size="sm" onClick={openAddProject}> |
| 26 | 35 | <PlusIcon className="size-4" /> |
| 27 | 36 | Add project |
| 28 | 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 | 48 | </div> |
| 30 | 49 | </EmptyHeader> |
| 31 | 50 | </div> |
apps/web/src/components/chat/DraftHeroHeadline.tsx+179-52| ... | ... | @@ -2,13 +2,22 @@ import type { DraftId } from "~/composerDraftStore"; |
| 2 | 2 | import { useComposerDraftStore } from "~/composerDraftStore"; |
| 3 | 3 | import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; |
| 4 | 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 5 | import { FolderPlusIcon } from "lucide-react"; | |
| 6 | import { useCallback, useMemo } from "react"; | |
| 5 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | |
| 6 | import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; | |
| 7 | import { useAtomValue } from "@effect/atom-react"; | |
| 8 | import { useCallback, useEffect, useMemo, useRef } from "react"; | |
| 7 | 9 | |
| 8 | 10 | import { openCommandPalette } from "~/commandPaletteBus"; |
| 11 | import { shortcutLabelForCommand } from "~/keybindings"; | |
| 12 | import { projectIconColorClassName } from "~/projectIconColors"; | |
| 13 | import { primaryServerKeybindingsAtom } from "~/state/server"; | |
| 14 | import { useScratchProject } from "~/hooks/useScratchProject"; | |
| 9 | 15 | import { useClientSettings } from "~/hooks/useSettings"; |
| 10 | 16 | import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; |
| 11 | import { selectProjectGroupingSettings } from "~/logicalProject"; | |
| 17 | import { | |
| 18 | deriveLogicalProjectKeyFromSettings, | |
| 19 | selectProjectGroupingSettings, | |
| 20 | } from "~/logicalProject"; | |
| 12 | 21 | import { |
| 13 | 22 | buildSidebarProjectPickerEntries, |
| 14 | 23 | buildSidebarProjectSnapshots, |
| ... | ... | @@ -32,6 +41,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 32 | 41 | import { InlineButton } from "../ui/button"; |
| 33 | 42 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 34 | 43 | |
| 44 | // Menu value for "No project"; real entries are keyed by logical project key. | |
| 45 | const NO_PROJECT_VALUE = "no-project"; | |
| 46 | ||
| 35 | 47 | interface DraftHeroHeadlineProps { |
| 36 | 48 | readonly draftId: DraftId | null; |
| 37 | 49 | readonly activeProjectRef: ScopedProjectRef | null; |
| ... | ... | @@ -56,6 +68,8 @@ export function DraftHeroHeadline({ |
| 56 | 68 | const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); |
| 57 | 69 | const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); |
| 58 | 70 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); |
| 71 | const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); | |
| 72 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | |
| 59 | 73 | |
| 60 | 74 | const environmentLabelById = useMemo( |
| 61 | 75 | () => |
| ... | ... | @@ -131,6 +145,85 @@ export function DraftHeroHeadline({ |
| 131 | 145 | const hasResolvedProject = activeProjectTitle !== null; |
| 132 | 146 | const canChooseProject = projectPickerEntries.length > 0; |
| 133 | 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 | }; | |
| 134 | 227 | |
| 135 | 228 | const projectSelector = shouldShowProjectMenu ? ( |
| 136 | 229 | <Menu> |
| ... | ... | @@ -143,55 +236,49 @@ export function DraftHeroHeadline({ |
| 143 | 236 | // mid-sentence and baffle screen-reader users. |
| 144 | 237 | <MenuTrigger |
| 145 | 238 | render={<InlineButton tone="picker" />} |
| 239 | data-draft-project-trigger="" | |
| 146 | 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 | 247 | </TooltipTrigger> |
| 152 | {activeProjectDisplayName ? ( | |
| 248 | {activeProjectDisplayName && !isScratchDraft ? ( | |
| 153 | 249 | <TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup> |
| 154 | 250 | ) : null} |
| 155 | 251 | </Tooltip> |
| 156 | 252 | <MenuPopup align="center" className="max-h-80 overflow-y-auto"> |
| 157 | 253 | <MenuRadioGroup |
| 158 | value={activeProjectKey} | |
| 254 | value={isScratchDraft ? NO_PROJECT_VALUE : activeProjectKey} | |
| 159 | 255 | onValueChange={(value) => { |
| 160 | const entry = projectEntryByKey.get(value as string); | |
| 161 | if (!entry || value === activeProjectKey) { | |
| 256 | if (value === NO_PROJECT_VALUE) { | |
| 257 | void startScratch(); | |
| 162 | 258 | return; |
| 163 | 259 | } |
| 164 | const project = entry.targetProject; | |
| 165 | if (!draftId) { | |
| 260 | const entry = projectEntryByKey.get(value as string); | |
| 261 | if (!entry || value === activeProjectKey) { | |
| 166 | 262 | return; |
| 167 | 263 | } |
| 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); | |
| 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 | 282 | return ( |
| 196 | 283 | <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick> |
| 197 | 284 | <span className="flex min-w-0 items-center gap-2"> |
| ... | ... | @@ -214,7 +301,7 @@ export function DraftHeroHeadline({ |
| 214 | 301 | ); |
| 215 | 302 | })} |
| 216 | 303 | </MenuRadioGroup> |
| 217 | <MenuSeparator /> | |
| 304 | {projectPickerEntries.length > 0 ? <MenuSeparator /> : null} | |
| 218 | 305 | <MenuItem onClick={openAddProject}> |
| 219 | 306 | <FolderPlusIcon /> |
| 220 | 307 | New project |
| ... | ... | @@ -235,24 +322,64 @@ export function DraftHeroHeadline({ |
| 235 | 322 | // a complete sentence too. The project picker is a control rendered inline |
| 236 | 323 | // in the h1; without an explicit label its widget state bleeds into the |
| 237 | 324 | // 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; | |
| 243 | 359 | |
| 244 | 360 | 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> | |
| 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 | 49 | it("orders Usage bindings and command choices like the page", () => { |
| 50 | 50 | const expected = [ |
| 51 | 51 | "usage.cost", |
| 52 | "usage.open", | |
| 53 | 52 | "usage.tokens", |
| 54 | 53 | "usage.limits", |
| 55 | 54 | "usage.period.day", |
| 56 | 55 | "usage.period.week", |
| 57 | 56 | "usage.period.month", |
| 58 | 57 | "usage.period.quarter", |
| 58 | "usage.open", | |
| 59 | 59 | ]; |
| 60 | 60 | const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed(); |
| 61 | 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 | 19 | [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), |
| 20 | 20 | ); |
| 21 | 21 | |
| 22 | function 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; | |
| 22 | const 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 | */ | |
| 29 | function 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 | } |
| 27 | 41 | |
| 28 | 42 | export type KeybindingSource = "Default" | "Custom" | "Project"; |
| ... | ... | @@ -215,9 +229,7 @@ export function buildKeybindingRows( |
| 215 | 229 | }); |
| 216 | 230 | |
| 217 | 231 | 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); | |
| 221 | 233 | if (commandCompare !== 0) return commandCompare; |
| 222 | 234 | return left.key.localeCompare(right.key); |
| 223 | 235 | }); |
| ... | ... | @@ -290,10 +302,7 @@ export function buildKeybindingCommandOptions( |
| 290 | 302 | for (const binding of keybindings) { |
| 291 | 303 | commands.add(binding.command); |
| 292 | 304 | } |
| 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)); | |
| 297 | 306 | } |
| 298 | 307 | |
| 299 | 308 | export function commandLabel(command: KeybindingCommand): string { |
apps/web/src/hooks/useScratchProject.ts created+98| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { | |
| 3 | isAtomCommandInterrupted, | |
| 4 | squashAtomCommandFailure, | |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 8 | import { useCallback } from "react"; | |
| 9 | ||
| 10 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | |
| 11 | import { waitForProject } from "~/state/entities"; | |
| 12 | import { useEnvironments } from "~/state/environments"; | |
| 13 | import { projectEnvironment } from "~/state/projects"; | |
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 15 | import { useNewThreadHandler } from "./useHandleNewThread"; | |
| 16 | ||
| 17 | function 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 | */ | |
| 33 | export 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 | 19 | import { terminalEnvironment } from "../state/terminal"; |
| 20 | 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 21 | 21 | import { environmentServerConfigsAtom } from "../state/server"; |
| 22 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | |
| 22 | 23 | import { threadEnvironment } from "../state/threads"; |
| 23 | 24 | import { vcsEnvironment } from "../state/vcs"; |
| 24 | 25 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| ... | ... | @@ -399,12 +400,18 @@ export function useThreadActions() { |
| 399 | 400 | const displayWorktreePath = orphanedWorktreePath |
| 400 | 401 | ? formatWorktreePathForDisplay(orphanedWorktreePath) |
| 401 | 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 | 412 | const localApi = readLocalApi(); |
| 404 | 413 | let shouldDeleteWorktree = false; |
| 405 | const environmentSettings = appAtomRegistry | |
| 406 | .get(environmentServerConfigsAtom) | |
| 407 | .get(threadRef.environmentId)?.settings; | |
| 414 | const environmentSettings = environmentConfig?.settings; | |
| 408 | 415 | const automaticWorktreeCleanup = environmentSettings |
| 409 | 416 | ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete |
| 410 | 417 | : false; |
apps/web/src/routes/_chat.tsx+16| ... | ... | @@ -13,6 +13,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; |
| 13 | 13 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; |
| 14 | 14 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; |
| 15 | 15 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 16 | import { useScratchProject } from "../hooks/useScratchProject"; | |
| 16 | 17 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 17 | 18 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 18 | 19 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| ... | ... | @@ -37,6 +38,7 @@ function ChatRouteGlobalShortcuts() { |
| 37 | 38 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 38 | 39 | const projects = useProjects(); |
| 39 | 40 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 41 | const { scratchEnvironmentId, startScratchThread } = useScratchProject(); | |
| 40 | 42 | const projectGroupCount = useMemo( |
| 41 | 43 | () => |
| 42 | 44 | buildSidebarProjectSnapshots({ |
| ... | ... | @@ -105,6 +107,17 @@ function ChatRouteGlobalShortcuts() { |
| 105 | 107 | return; |
| 106 | 108 | } |
| 107 | 109 | |
| 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 | 121 | if (command === "chat.new") { |
| 109 | 122 | event.preventDefault(); |
| 110 | 123 | event.stopPropagation(); |
| ... | ... | @@ -180,9 +193,12 @@ function ChatRouteGlobalShortcuts() { |
| 180 | 193 | keybindings, |
| 181 | 194 | defaultProjectRef, |
| 182 | 195 | previewOpen, |
| 196 | primaryEnvironmentId, | |
| 183 | 197 | projectGroupCount, |
| 184 | 198 | routeThreadRef, |
| 199 | scratchEnvironmentId, | |
| 185 | 200 | selectedThreadKeysSize, |
| 201 | startScratchThread, | |
| 186 | 202 | legacySidebarEnabled, |
| 187 | 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 | 122 | |
| 123 | 123 | `chat.new` may ask you to choose a project when there is more than one. |
| 124 | 124 | `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). | |
| 126 | 127 | |
| 127 | 128 | ## Reserved shortcuts |
| 128 | 129 |
docs/user/thread-sidebar.md+16| ... | ... | @@ -13,6 +13,22 @@ an existing worktree, use **New thread in this worktree** from the branch toolba |
| 13 | 13 | When you change a new thread's project, T3 Code stays in the current environment |
| 14 | 14 | if that project exists there. Otherwise it selects an environment that has it. |
| 15 | 15 | |
| 16 | ### Start without a project | |
| 17 | ||
| 18 | A thread does not need a project. To start one without a project, click **or | |
| 19 | start without a project** under a new thread's heading, pick **No project** from | |
| 20 | the project menu in that heading or from **New thread in...** in the command | |
| 21 | palette, or press `mod+alt+n`. On mobile, pick **No project** from the project | |
| 22 | list. To move a draft into a project, pick the project in the heading. | |
| 23 | ||
| 24 | Each 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 | |
| 26 | of its first message, and a short id, like | |
| 27 | `2026-09-25-convert-these-pngs-to-webp-a1b2c3d4`. Deleting a thread keeps its | |
| 28 | folder, so the files the agent wrote stay until you delete them. Branch, worktree, and diff controls stay hidden because | |
| 29 | these folders are not Git repositories. This is unavailable when the data | |
| 30 | directory itself sits inside a Git checkout. | |
| 31 | ||
| 16 | 32 | ### Start in the background |
| 17 | 33 | |
| 18 | 34 | In 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 | 92 | scheduler: projectScheduler, |
| 93 | 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 | 102 | writeFile: createEnvironmentRpcCommand(runtime, { |
| 96 | 103 | label: "environment-data:projects:write-file", |
| 97 | 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 | 135 | }); |
| 136 | 136 | } |
| 137 | 137 | |
| 138 | /** Whether a project is its environment's Scratch project (`ServerConfig.scratchWorkspaceRoot`). */ | |
| 139 | export 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 | ||
| 138 | 148 | export function inferProjectTitleFromPath(value: string): string { |
| 139 | 149 | const normalized = normalizeProjectPathForDispatch(value); |
| 140 | 150 | const absolutePath = splitAbsolutePath(normalized); |
packages/contracts/src/keybindings.ts+1| ... | ... | @@ -91,6 +91,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ |
| 91 | 91 | "composer.branch", |
| 92 | 92 | "chat.new", |
| 93 | 93 | "chat.newLocal", |
| 94 | "chat.newWithoutProject", | |
| 94 | 95 | "editor.openFavorite", |
| 95 | 96 | "usage.cost", |
| 96 | 97 | "usage.tokens", |
packages/contracts/src/project.ts+7| ... | ... | @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; |
| 2 | 2 | import { |
| 3 | 3 | NonNegativeInt, |
| 4 | 4 | PositiveInt, |
| 5 | ProjectId, | |
| 5 | 6 | TrimmedNonEmptyString, |
| 6 | 7 | TrimmedString, |
| 7 | 8 | } from "./baseSchemas.ts"; |
| ... | ... | @@ -281,6 +282,12 @@ export const ProjectWriteFileResult = Schema.Struct({ |
| 281 | 282 | }); |
| 282 | 283 | export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; |
| 283 | 284 | |
| 285 | /** The environment's Scratch project, created on first request. */ | |
| 286 | export const ProjectEnsureScratchResult = Schema.Struct({ | |
| 287 | projectId: ProjectId, | |
| 288 | }); | |
| 289 | export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; | |
| 290 | ||
| 284 | 291 | export class ProjectWriteFileError extends Schema.TaggedError<ProjectWriteFileError>()( |
| 285 | 292 | "ProjectWriteFileError", |
| 286 | 293 | { |
packages/contracts/src/rpc.ts+10| ... | ... | @@ -159,6 +159,7 @@ import { |
| 159 | 159 | RelayClientStatusSchema, |
| 160 | 160 | } from "./relayClient.ts"; |
| 161 | 161 | import { |
| 162 | ProjectEnsureScratchResult, | |
| 162 | 163 | ProjectListEntriesError, |
| 163 | 164 | ProjectListEntriesInput, |
| 164 | 165 | ProjectListEntriesResult, |
| ... | ... | @@ -293,6 +294,7 @@ export const WS_METHODS = { |
| 293 | 294 | projectsSearchContents: "projects.searchContents", |
| 294 | 295 | projectsSearchEntries: "projects.searchEntries", |
| 295 | 296 | projectsWriteFile: "projects.writeFile", |
| 297 | projectsEnsureScratch: "projects.ensureScratch", | |
| 296 | 298 | |
| 297 | 299 | // Shell methods |
| 298 | 300 | shellOpenInEditor: "shell.openInEditor", |
| ... | ... | @@ -991,6 +993,13 @@ const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { |
| 991 | 993 | error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), |
| 992 | 994 | }); |
| 993 | 995 | |
| 996 | // Finds or creates the Scratch project rooted at ServerConfig.scratchWorkspaceRoot. | |
| 997 | const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { | |
| 998 | payload: Schema.Struct({}), | |
| 999 | success: ProjectEnsureScratchResult, | |
| 1000 | error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), | |
| 1001 | }); | |
| 1002 | ||
| 994 | 1003 | const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { |
| 995 | 1004 | payload: LaunchEditorInput, |
| 996 | 1005 | error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), |
| ... | ... | @@ -1508,6 +1517,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1508 | 1517 | WsProjectsReadFileRpc, |
| 1509 | 1518 | WsProjectsSearchContentsRpc, |
| 1510 | 1519 | WsProjectsSearchEntriesRpc, |
| 1520 | WsProjectsEnsureScratchRpc, | |
| 1511 | 1521 | WsProjectsWriteFileRpc, |
| 1512 | 1522 | WsShellOpenInEditorRpc, |
| 1513 | 1523 | WsFilesystemBrowseRpc, |
packages/contracts/src/server.ts+6| ... | ... | @@ -611,6 +611,12 @@ export const ServerConfig = Schema.Struct({ |
| 611 | 611 | threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), |
| 612 | 612 | /** Whether thread reads accept the reasoningMessages opt-in. */ |
| 613 | 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 | 621 | * Palettes published by this environment's machine. Never sent in a config |
| 616 | 622 | * 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 | 49 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 50 | 50 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 51 | 51 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| 52 | { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" }, | |
| 52 | 53 | { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, |
| 53 | 54 | { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, |
| 54 | 55 | { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, |