From 9685467c2646c237e9a9ba31725e14becd08e5ab Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 25 Sep 2026 01:33:27 -0700 Subject: [PATCH 01/14] feat(web): start a thread without a project Rebased net diff of #12269 onto current main. The server advertises a plain chats folder in ServerConfig, and "just chat" adds an ordinary project rooted there so the thread follows the existing non-git path. --- .../features/threads/NewTaskRouteScreen.tsx | 182 ++++++++++++++-- apps/server/src/server.test.ts | 26 +++ apps/server/src/ws.ts | 24 +++ apps/web/src/components/CommandPalette.tsx | 28 ++- apps/web/src/components/NoProjectsHero.tsx | 36 +++- .../src/components/chat/DraftHeroHeadline.tsx | 198 +++++++++++++----- apps/web/src/components/ui/button.tsx | 2 + apps/web/src/hooks/useChatProject.ts | 137 ++++++++++++ docs/user/thread-sidebar.md | 10 + .../src/operations/projects.test.ts | 55 +++++ .../client-runtime/src/operations/projects.ts | 37 ++++ packages/contracts/src/server.ts | 6 + 12 files changed, 672 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/hooks/useChatProject.ts diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 27a9b3217c7e..f9a1379cad34 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -7,7 +7,15 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; +import { + buildChatProjectCreateCommand, + canCreateProjectInEnvironment, + findChatProject, +} from "@t3tools/client-runtime/operations/projects"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -17,7 +25,11 @@ import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { useProjects } from "../../state/entities"; +import { uuidv4 } from "../../lib/uuid"; +import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; +import { projectEnvironment } from "../../state/projects"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -156,6 +168,25 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps + (selectedEnvironmentId === null || environment.environmentId === selectedEnvironmentId) && + canCreateProjectInEnvironment(environment.connectionState) && + serverConfigs.get(environment.environmentId)?.chatWorkspaceRoot !== undefined, + ) ?? null; + const chatStartInFlightRef = useRef(false); + const chatWorkspaceRoot = chatEnvironment + ? (serverConfigs.get(chatEnvironment.environmentId)?.chatWorkspaceRoot ?? null) + : null; + const canJustChat = + chatEnvironment !== null && chatWorkspaceRoot !== null && reservedDestinationProject === null; async function selectProject(project: EnvironmentProject): Promise { if (incomingShare?.destination && !reservedDestinationProject) { @@ -189,6 +220,53 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + if (!chatEnvironment || chatWorkspaceRoot === null || chatStartInFlightRef.current) return; + const environmentId = chatEnvironment.environmentId; + const existing = findChatProject({ projects, environmentId, chatWorkspaceRoot }); + if (existing) { + await selectProject(existing); + return; + } + chatStartInFlightRef.current = true; + try { + await createChatProject(environmentId); + } finally { + chatStartInFlightRef.current = false; + } + } + + async function createChatProject(environmentId: EnvironmentId): Promise { + if (chatWorkspaceRoot === null) return; + const projectId = ProjectId.make(uuidv4()); + const result = await createProject({ + environmentId, + input: buildChatProjectCreateCommand({ + commandId: CommandId.make(uuidv4()), + projectId, + chatWorkspaceRoot, + createdAt: new Date().toISOString(), + }), + }); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not start chat", + error instanceof Error ? error.message : "The chat folder could not be created.", + ); + return; + } + const project = await waitForProject({ environmentId, projectId }); + if (project === null) { + Alert.alert( + "Could not start chat", + "The chat folder was created but has not reached this device yet. Pick Chats from the project list once it appears.", + ); + return; + } + await selectProject(project); + } + useEffect(() => { const destination = incomingShare?.destination; if (!destination) { @@ -264,15 +342,24 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {Platform.OS === "android" ? ( - - catalogState.hasReadyEnvironment - ? navigation.dispatch(StackActions.push("AddProject")) - : navigation.navigate("ConnectionsNew") - } - /> + <> + + catalogState.hasReadyEnvironment + ? navigation.dispatch(StackActions.push("AddProject")) + : navigation.navigate("ConnectionsNew") + } + /> + {canJustChat ? ( + void startChat()} + /> + ) : null} + ) : !catalogState.hasReadyEnvironment ? ( ) : ( - navigation.dispatch(StackActions.push("AddProject"))} - > - - Add new project - - + <> + navigation.dispatch(StackActions.push("AddProject"))} + > + + Add new project + + + {canJustChat ? ( + void startChat()} + > + Just chat + + ) : null} + )} ) : visibleScopes.length === 0 ? ( @@ -390,6 +487,55 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps )} + {canJustChat && projectScopes.length > 0 ? ( + Platform.OS === "android" ? ( + + void startChat()} + leading={ + + } + /> + + ) : ( + + void startChat()} + className="flex-row items-center gap-3 bg-card px-4 py-3.5" + > + + + + + Just chat + + Start a task without a repository + + + + + + ) + ) : null} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 43595dbafce1..5100f1617a48 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5328,6 +5328,32 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("offers a chats folder only when the data dir is outside a work tree", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const outside = yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + client[WS_METHODS.serverGetConfig]({}), + ), + ); + assert.isTrue(outside.chatWorkspaceRoot?.endsWith("chats")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("withholds the chats folder when the data dir sits inside a work tree", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { vcsDriver: { isInsideWorkTree: () => Effect.succeed(true) } }, + }); + const inside = yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + client[WS_METHODS.serverGetConfig]({}), + ), + ); + assert.isUndefined(inside.chatWorkspaceRoot); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("advertises the usable file manager and its reveal label", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 077087a7d84e..38fe72ef1bfb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1802,6 +1802,28 @@ const makeWsRpcLayer = ( ); }; + const path = yield* Path.Path; + // A chats folder inside a checkout (a dev worktree's .t3, a dotfiles + // home) would inherit that repo's git status and checkpoints, so the + // folder is only offered when the data dir is outside any work tree. + // Detection failures fail closed and hide the folder, never the config. + // Probed once per connection: a negative VCS detection is not cached. + // A cached probe memoizes an interrupt exit too, so a config load that + // is cancelled mid-probe invalidates it and the next load probes again. + const [cachedChatWorkspaceRoot, invalidateChatWorkspaceRoot] = + yield* Effect.cachedInvalidateWithTTL( + gitWorkflow.isRepository(config.baseDir).pipe( + Effect.map((isRepository) => + isRepository ? undefined : path.join(config.baseDir, "chats"), + ), + Effect.catchCause(() => Effect.succeed(undefined)), + ), + Duration.infinity, + ); + const resolveChatWorkspaceRoot = cachedChatWorkspaceRoot.pipe( + Effect.onInterrupt(() => invalidateChatWorkspaceRoot), + ); + // Only clients that answer /usage-limits themselves see it in the catalogs; // an older client would send the injected command to the provider. const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => @@ -1816,6 +1838,7 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const chatWorkspaceRoot = yield* resolveChatWorkspaceRoot; const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( externalLauncher.resolveAvailableEditors(), ); @@ -1864,6 +1887,7 @@ const makeWsRpcLayer = ( threadResumeCompletionMarker: true, threadSnapshotPagination: true, reasoningMessages: true, + ...(chatWorkspaceRoot === undefined ? {} : { chatWorkspaceRoot }), }; }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0825c5cb92cc..f7014a4f2726 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -48,6 +48,7 @@ import { FolderIcon, FolderPlusIcon, LinkIcon, + MessageCircleIcon, MessageSquareIcon, MonitorIcon, MoonIcon, @@ -95,6 +96,7 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useChatProject } from "../hooks/useChatProject"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { useThreadSearch } from "../state/queries"; @@ -708,6 +710,7 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); + const { canStartChatIn, ensureChatProject } = useChatProject(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -1516,6 +1519,22 @@ function OpenCommandPaletteDialog(props: { }, }, ]; + if (canStartChatIn(environmentId)) { + sourceItems.push({ + kind: "action", + value: `action:add-project:${environmentId}:chat`, + searchTerms: ["chat", "no project", "without", "conversation"], + title: "Just chat", + description: "Start a thread without a repository", + icon: , + run: async () => { + const project = await ensureChatProject(environmentId); + if (project) { + await handleNewThread(scopeProjectRef(project.environmentId, project.id)); + } + }, + }); + } const orderedSources: ReadonlyArray = [ "url", @@ -1587,7 +1606,14 @@ function OpenCommandPaletteDialog(props: { return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; }, - [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], + [ + canStartChatIn, + ensureChatProject, + handleNewThread, + openSourceControlSettings, + startAddProjectBrowse, + startAddProjectClone, + ], ); const startAddProjectSourceSelection = useCallback( diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index 2da1c0aadb8c..e973ba91a6ae 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -1,15 +1,41 @@ -import { PlusIcon } from "lucide-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { MessageCircleIcon, PlusIcon } from "lucide-react"; import { useCallback } from "react"; import { openCommandPalette } from "../commandPaletteBus"; import { isElectron } from "../env"; +import { useChatProject } from "../hooks/useChatProject"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { usePrimaryEnvironmentId } from "../state/environments"; import { Button } from "./ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; +import { stackedThreadToast, toastManager } from "./ui/toast"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; export function NoProjectsHero() { const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { chatEnvironmentId, ensureChatProject } = useChatProject(); + const handleNewThread = useNewThreadHandler(); + const chatTargetEnvironmentId = chatEnvironmentId(primaryEnvironmentId); + const canJustChat = chatTargetEnvironmentId !== null; + const startChat = useCallback(async () => { + if (chatTargetEnvironmentId === null) return; + const project = await ensureChatProject(chatTargetEnvironmentId); + if (!project) return; + try { + await handleNewThread(scopeProjectRef(project.environmentId, project.id)); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, [chatTargetEnvironmentId, ensureChatProject, handleNewThread]); return ( @@ -21,11 +47,17 @@ export function NoProjectsHero() { What should we work on? Add a project to start your first thread. -
+
+ {canJustChat ? ( + + ) : null}
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index ea4741b80664..3cfbbcf5bdd4 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -2,13 +2,18 @@ import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { findChatProject } from "@t3tools/client-runtime/operations/projects"; import { FolderPlusIcon } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; +import { useChatProject } from "~/hooks/useChatProject"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; -import { selectProjectGroupingSettings } from "~/logicalProject"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "~/logicalProject"; import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, @@ -29,7 +34,7 @@ import { MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { InlineButton } from "../ui/button"; +import { Button, InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; interface DraftHeroHeadlineProps { @@ -56,6 +61,8 @@ export function DraftHeroHeadline({ const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const { canStartChatIn, chatEnvironmentId, chatWorkspaceRootFor, ensureChatProject } = + useChatProject(); const environmentLabelById = useMemo( () => @@ -131,6 +138,95 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; + const activeProject = + activeProjectRef === null + ? null + : (projects.find( + (project) => + project.environmentId === activeProjectRef.environmentId && + project.id === activeProjectRef.projectId, + ) ?? null); + const chatTargetEnvironmentId = + activeProjectRef?.environmentId ?? chatEnvironmentId(primaryEnvironmentId); + const chatWorkspaceRoot = chatWorkspaceRootFor(chatTargetEnvironmentId); + const chatProject = + chatTargetEnvironmentId !== null && chatWorkspaceRoot !== null + ? findChatProject({ projects, environmentId: chatTargetEnvironmentId, chatWorkspaceRoot }) + : null; + const isChatDraft = + activeProject !== null && + chatProject !== null && + chatProject.environmentId === activeProject.environmentId && + chatProject.id === activeProject.id; + // On a chat draft the heading already says "chat", so its menu lists + // repositories only. Every other state keeps Chats as a way in. + const menuEntries = projectPickerEntries.filter( + ({ targetProject }) => + !isChatDraft || + chatProject === null || + targetProject.environmentId !== chatProject.environmentId || + targetProject.id !== chatProject.id, + ); + const canJustChat = canStartChatIn(chatTargetEnvironmentId) && !isChatDraft; + + // The picker can change the draft's target while "Just chat" is still + // creating its project; a stale continuation must not retarget it again. + const latestTargetRef = useRef({ draftId, activeProjectKey, chatTargetEnvironmentId }); + useEffect(() => { + latestTargetRef.current = { draftId, activeProjectKey, chatTargetEnvironmentId }; + }, [activeProjectKey, chatTargetEnvironmentId, draftId]); + // Project selection changes the target of the open draft in place. The + // prompt stays in the same composer session, so the sidebar only gets a + // draft row if the user later navigates away. + const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => { + if (!draftId) { + return; + } + latestTargetRef.current = { + draftId, + activeProjectKey: logicalProjectKey, + chatTargetEnvironmentId: project.environmentId, + }; + const currentDraft = getComposerDraft(draftId); + setLogicalProjectDraftThreadId( + logicalProjectKey, + scopeProjectRef(project.environmentId, project.id), + draftId, + ); + if (!hasExplicitComposerModelSelection(currentDraft)) { + applyStickyState(draftId); + const environmentSettings = environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings; + const defaultModelSelection = environmentSettings + ? resolveProjectSettings(environmentSettings, project.id, project).settings + .defaultModelSelection + : project.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { + replaceOptions: true, + }); + } + } + }; + const startChat = async (): Promise => { + if (chatTargetEnvironmentId === null || isChatDraft) { + return false; + } + const requested = { draftId, activeProjectKey, chatTargetEnvironmentId }; + const project = await ensureChatProject(chatTargetEnvironmentId); + const latest = latestTargetRef.current; + if ( + !project || + latest.draftId !== requested.draftId || + latest.activeProjectKey !== requested.activeProjectKey || + latest.chatTargetEnvironmentId !== requested.chatTargetEnvironmentId + ) { + return false; + } + selectProject(project, deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)); + return true; + }; const projectSelector = shouldShowProjectMenu ? ( @@ -143,13 +239,16 @@ export function DraftHeroHeadline({ // mid-sentence and baffle screen-reader users. } + data-draft-project-trigger="" className="pointer-events-auto max-w-64 align-baseline" /> } > - {activeProjectDisplayName ?? "Choose a project"} + + {isChatDraft ? "chat" : (activeProjectDisplayName ?? "Choose a project")} + - {activeProjectDisplayName ? ( + {activeProjectDisplayName && !isChatDraft ? ( {activeProjectDisplayName} ) : null} @@ -161,37 +260,10 @@ export function DraftHeroHeadline({ if (!entry || value === activeProjectKey) { return; } - const project = entry.targetProject; - if (!draftId) { - return; - } - // Project selection changes the target of the open draft in - // place. The prompt stays in the same composer session, so the - // sidebar only gets a draft row if the user later navigates away. - const currentDraft = getComposerDraft(draftId); - setLogicalProjectDraftThreadId( - entry.group.projectKey, - scopeProjectRef(project.environmentId, project.id), - draftId, - ); - if (!hasExplicitComposerModelSelection(currentDraft)) { - applyStickyState(draftId); - const environmentSettings = environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings - ? resolveProjectSettings(environmentSettings, project.id, project).settings - .defaultModelSelection - : project.defaultModelSelection; - if (defaultModelSelection) { - setModelSelection(draftId, defaultModelSelection, { - replaceOptions: true, - }); - } - } + selectProject(entry.targetProject, entry.group.projectKey); }} > - {projectPickerEntries.map(({ group }) => { + {menuEntries.map(({ group }) => { return ( @@ -214,7 +286,7 @@ export function DraftHeroHeadline({ ); })} - + {menuEntries.length > 0 ? : null} New project @@ -236,23 +308,53 @@ export function DraftHeroHeadline({ // in the h1; without an explicit label its widget state bleeds into the // announced phrase. const headingLabel = hasResolvedProject - ? `What should we build in ${activeProjectDisplayName}?` + ? isChatDraft + ? "What should we chat about?" + : `What should we build in ${activeProjectDisplayName}?` : canChooseProject ? `${activeProjectDisplayName ?? "Choose a project"} to start` : "Add a project to start"; + // One click into chat, phrased as the alternative to the question above it. + // Focus moves to the mode word once this line has gone. + const orJustChat = + canJustChat && (hasResolvedProject || canChooseProject) ? ( + + ) : null; + return ( -

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

+
+

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

+ {/* Always reserved so the heading does not move when the line goes. */} +
{orJustChat}
+
); } diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 1a101f107114..440c09c33ea3 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -54,6 +54,8 @@ const buttonVariants = cva( glass: "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] rounded-full border-border/60 text-foreground shadow-sm before:rounded-full [:hover,[data-pressed]]:border-border", link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", + "link-muted": + "border-transparent font-normal text-muted-foreground underline-offset-4 [:hover,[data-pressed]]:text-foreground [:hover,[data-pressed]]:underline", "media-close": "[--control-icon-color:currentColor] border-transparent bg-black/65 text-white shadow-sm ring-1 ring-white/20 [:hover,[data-pressed]]:bg-black/80 focus-visible:ring-white", "media-navigation": diff --git a/apps/web/src/hooks/useChatProject.ts b/apps/web/src/hooks/useChatProject.ts new file mode 100644 index 000000000000..873c59a4bc5a --- /dev/null +++ b/apps/web/src/hooks/useChatProject.ts @@ -0,0 +1,137 @@ +import { CHAT_PROJECT_TITLE, findChatProject } from "@t3tools/client-runtime/operations/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { newProjectId } from "~/lib/utils"; +import { readProjects, waitForProject } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; +import { projectEnvironment } from "~/state/projects"; +import { useAtomCommand } from "~/state/use-atom-command"; + +// One create per environment at a time: a second click while the first +// project.create is in flight would be rejected as a duplicate workspace root. +const inFlightByEnvironment = new Map>(); + +async function waitForChatProject( + find: () => EnvironmentProject | null, + attempts = 12, + intervalMs = 250, +): Promise { + for (let attempt = 0; attempt < attempts; attempt++) { + const project = find(); + if (project) return project; + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + return null; +} + +function reportChatStartFailure(error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to start chat", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** + * "Just chat" runs a thread in a plain folder the server offers instead of a + * repository. That folder becomes an ordinary project the first time it is + * used, so everything downstream is the regular non-git project path. + */ +export function useChatProject() { + const { environments } = useEnvironments(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + + const chatWorkspaceRootFor = useCallback( + (environmentId: EnvironmentId | null): string | null => + environments.find((entry) => entry.environmentId === environmentId)?.serverConfig + ?.chatWorkspaceRoot ?? null, + [environments], + ); + + /** Whether "Just chat" can be started in this environment right now. */ + const canStartChatIn = useCallback( + (environmentId: EnvironmentId | null): boolean => + environments.some( + (entry) => + entry.environmentId === environmentId && + entry.connection.phase === "connected" && + entry.serverConfig?.chatWorkspaceRoot !== undefined, + ), + [environments], + ); + + // The hosted web app has no primary environment, so "Just chat" targets the + // first connected environment that offers a chats folder. + const chatEnvironmentId = useCallback( + (preferred: EnvironmentId | null): EnvironmentId | null => + canStartChatIn(preferred) + ? preferred + : (environments.find((entry) => canStartChatIn(entry.environmentId))?.environmentId ?? + null), + [canStartChatIn, environments], + ); + + const ensureChatProject = useCallback( + (environmentId: EnvironmentId): Promise => { + const chatWorkspaceRoot = chatWorkspaceRootFor(environmentId); + if (chatWorkspaceRoot === null || !canStartChatIn(environmentId)) { + return Promise.resolve(null); + } + const findExisting = () => + findChatProject({ projects: readProjects(), environmentId, chatWorkspaceRoot }); + const existing = findExisting(); + if (existing) return Promise.resolve(existing); + const pending = inFlightByEnvironment.get(environmentId); + if (pending) return pending; + + const create = (async () => { + const projectId = newProjectId(); + const result = await createProject({ + environmentId, + input: { + projectId, + title: CHAT_PROJECT_TITLE, + workspaceRoot: chatWorkspaceRoot, + createWorkspaceRootIfMissing: true, + defaultModelSelection: null, + }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return null; + // Another client may have created it first. Its project event can + // land after this rejection, so give the store a moment to catch up. + const raced = await waitForChatProject(findExisting); + if (raced) return raced; + reportChatStartFailure(squashAtomCommandFailure(result)); + return null; + } + // Drafts key off the project's stored path and settings, so wait for + // the create event to reach the client store before targeting one. + try { + return await waitForProject({ environmentId, projectId }); + } catch { + reportChatStartFailure(new Error("The chat project has not reached this client yet.")); + return null; + } + })().finally(() => { + inFlightByEnvironment.delete(environmentId); + }); + inFlightByEnvironment.set(environmentId, create); + return create; + }, + [canStartChatIn, chatWorkspaceRootFor, createProject], + ); + + return { canStartChatIn, chatEnvironmentId, chatWorkspaceRootFor, ensureChatProject }; +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4a19bac401b9..c7cebfc8b820 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -13,6 +13,16 @@ an existing worktree, use **New thread in this worktree** from the branch toolba When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it. +To talk to an agent without picking a repository, click **or just chat** under +a new thread's heading, choose **Just chat** on the empty-state screen or in the +add-project menu, or pick it from the project list on mobile. The heading then +reads "What should we chat about?"; click **chat** to move the thread to a +project. Chat threads share one **Chats** project rooted in a plain `chats` +folder inside the T3 data directory. Branch, worktree, and diff controls stay +hidden while that folder is not a Git repository. Any files the agent writes +land in that folder. The option is unavailable when the data directory itself +sits inside a Git checkout. + ### Start in the background In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 4b29e67a2176..587870f4d6c3 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -9,8 +9,10 @@ import * as Option from "effect/Option"; import { buildAddProjectRemoteSourceReadiness, + buildChatProjectCreateCommand, buildProjectCreateCommand, canCreateProjectInEnvironment, + findChatProject, findExistingAddProject, getAddProjectInitialQuery, getCloneDestinationBrowsePath, @@ -276,3 +278,56 @@ describe("add project shared logic", () => { }); }); }); + +describe("chat project", () => { + it("finds the project rooted at the environment's chat folder", () => { + const env = EnvironmentId.make("env-1"); + const project = ( + id: string, + environmentId: EnvironmentId, + workspaceRoot: string, + ): EnvironmentProject => ({ + environmentId, + id: ProjectId.make(id), + title: id, + workspaceRoot, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + }); + const projects = [ + project("repo", env, "/repo"), + project("chats", env, "/home/me/.t3/chats/"), + project("other-env", EnvironmentId.make("env-2"), "/home/me/.t3/chats"), + ]; + expect( + findChatProject({ projects, environmentId: env, chatWorkspaceRoot: "/home/me/.t3/chats" }) + ?.id, + ).toBe("chats"); + expect( + findChatProject({ + projects, + environmentId: env, + chatWorkspaceRoot: "/home/me/.t3/elsewhere", + }), + ).toBeNull(); + }); + + it("titles the created project Chats instead of the folder name", () => { + expect( + buildChatProjectCreateCommand({ + commandId: CommandId.make("command"), + projectId: ProjectId.make("project"), + chatWorkspaceRoot: "/home/me/.t3/chats", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ).toMatchObject({ + type: "project.create", + title: "Chats", + workspaceRoot: "/home/me/.t3/chats", + createWorkspaceRootIfMissing: true, + }); + }); +}); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 597b0b5d7b4f..1b5a0cc77e13 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -322,3 +322,40 @@ export function buildProjectCreateCommand(input: { createdAt: input.createdAt, }; } + +/** Title of the plain-folder project that hosts threads started without a repository. */ +export const CHAT_PROJECT_TITLE = "Chats"; + +/** + * The project an environment uses for "just chat" threads, if the user has + * started one before. It is an ordinary project rooted at the folder the + * server advertises in `ServerConfig.chatWorkspaceRoot`. + */ +export function findChatProject(input: { + readonly projects: ReadonlyArray; + readonly environmentId: EnvironmentId; + readonly chatWorkspaceRoot: string; +}): EnvironmentProject | null { + return findExistingAddProject({ + projects: input.projects, + environmentId: input.environmentId, + path: input.chatWorkspaceRoot, + }); +} + +export function buildChatProjectCreateCommand(input: { + readonly commandId: CommandId; + readonly projectId: ProjectId; + readonly chatWorkspaceRoot: string; + readonly createdAt: string; +}): Extract { + return { + ...buildProjectCreateCommand({ + commandId: input.commandId, + projectId: input.projectId, + workspaceRoot: input.chatWorkspaceRoot, + createdAt: input.createdAt, + }), + title: CHAT_PROJECT_TITLE, + }; +} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c137cac9ac7a..94156e05b003 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -603,6 +603,12 @@ export const ServerConfig = Schema.Struct({ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), /** Whether thread reads accept the reasoningMessages opt-in. */ reasoningMessages: Schema.optionalKey(Schema.Boolean), + /** + * Folder this environment offers for threads that need no repository. A + * client that wants "just chat" adds an ordinary project rooted here. Absent + * on servers that predate the feature. + */ + chatWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a From fd319d2f1d6075d69f70a2e272ae2a70bbe2bb67 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 01:43:09 -0700 Subject: [PATCH 02/14] feat: Scratch project with a server-side ensure RPC Renames "just chat" to Scratch and moves the find-or-create step to the server. projects.ensureScratch returns the Scratch project id, and a lost create race resolves to the winner's project. Web and mobile drop their in-flight maps and polling and wait for the project event instead. Scratch now lives at /scratch and is reachable from the draft heading, the empty-state hero, a "New thread in Scratch" palette action, the chat.newScratch keybinding (mod+alt+n), and the mobile project list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/NewTaskRouteScreen.tsx | 133 ++++++++--------- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.test.ts | 110 ++++++++++++-- apps/server/src/ws.ts | 78 ++++++++-- apps/web/src/components/CommandPalette.tsx | 46 +++--- apps/web/src/components/NoProjectsHero.tsx | 49 +++---- .../src/components/chat/DraftHeroHeadline.tsx | 103 +++++-------- apps/web/src/components/ui/button.tsx | 2 - apps/web/src/hooks/useChatProject.ts | 137 ------------------ apps/web/src/hooks/useScratchProject.ts | 94 ++++++++++++ apps/web/src/routes/_chat.tsx | 16 ++ docs/user/keybindings.md | 3 +- docs/user/thread-sidebar.md | 22 +-- .../src/operations/projects.test.ts | 55 ------- .../client-runtime/src/operations/projects.ts | 37 ----- .../src/state/projectCommands.ts | 7 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/project.ts | 7 + packages/contracts/src/rpc.ts | 10 ++ packages/contracts/src/server.ts | 8 +- packages/shared/src/keybindings.ts | 1 + 21 files changed, 460 insertions(+), 460 deletions(-) delete mode 100644 apps/web/src/hooks/useChatProject.ts create mode 100644 apps/web/src/hooks/useScratchProject.ts diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f9a1379cad34..c3d82623fab8 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -7,13 +7,9 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import { - buildChatProjectCreateCommand, - canCreateProjectInEnvironment, - findChatProject, -} from "@t3tools/client-runtime/operations/projects"; +import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useRef, useState } from "react"; @@ -25,7 +21,6 @@ import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { uuidv4 } from "../../lib/uuid"; import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -170,23 +165,32 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps (selectedEnvironmentId === null || environment.environmentId === selectedEnvironmentId) && canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.chatWorkspaceRoot !== undefined, + serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, ) ?? null; - const chatStartInFlightRef = useRef(false); - const chatWorkspaceRoot = chatEnvironment - ? (serverConfigs.get(chatEnvironment.environmentId)?.chatWorkspaceRoot ?? null) + const scratchWorkspaceRoot = scratchEnvironment + ? (serverConfigs.get(scratchEnvironment.environmentId)?.scratchWorkspaceRoot ?? null) : null; - const canJustChat = - chatEnvironment !== null && chatWorkspaceRoot !== null && reservedDestinationProject === null; + // Once the Scratch project exists it is an ordinary row in the list. + const scratchProjectExists = + scratchEnvironment !== null && + scratchWorkspaceRoot !== null && + findProjectByPath( + projects.filter((project) => project.environmentId === scratchEnvironment.environmentId), + scratchWorkspaceRoot, + ) !== undefined; + const canStartScratch = scratchWorkspaceRoot !== null && reservedDestinationProject === null; + const scratchStartInFlightRef = useRef(false); async function selectProject(project: EnvironmentProject): Promise { if (incomingShare?.destination && !reservedDestinationProject) { @@ -220,51 +224,32 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { - if (!chatEnvironment || chatWorkspaceRoot === null || chatStartInFlightRef.current) return; - const environmentId = chatEnvironment.environmentId; - const existing = findChatProject({ projects, environmentId, chatWorkspaceRoot }); - if (existing) { - await selectProject(existing); - return; - } - chatStartInFlightRef.current = true; + async function startScratch(): Promise { + if (!scratchEnvironment || scratchStartInFlightRef.current) return; + const environmentId = scratchEnvironment.environmentId; + scratchStartInFlightRef.current = true; try { - await createChatProject(environmentId); + const result = await ensureScratch({ environmentId, input: {} }); + if (AsyncResult.isFailure(result)) { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not open Scratch", + error instanceof Error ? error.message : "The Scratch folder could not be created.", + ); + return; + } + const project = await waitForProject({ environmentId, projectId: result.value.projectId }); + if (project === null) { + Alert.alert( + "Could not open Scratch", + "Scratch has not reached this device yet. Pick it from the project list once it appears.", + ); + return; + } + await selectProject(project); } finally { - chatStartInFlightRef.current = false; - } - } - - async function createChatProject(environmentId: EnvironmentId): Promise { - if (chatWorkspaceRoot === null) return; - const projectId = ProjectId.make(uuidv4()); - const result = await createProject({ - environmentId, - input: buildChatProjectCreateCommand({ - commandId: CommandId.make(uuidv4()), - projectId, - chatWorkspaceRoot, - createdAt: new Date().toISOString(), - }), - }); - if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not start chat", - error instanceof Error ? error.message : "The chat folder could not be created.", - ); - return; - } - const project = await waitForProject({ environmentId, projectId }); - if (project === null) { - Alert.alert( - "Could not start chat", - "The chat folder was created but has not reached this device yet. Pick Chats from the project list once it appears.", - ); - return; + scratchStartInFlightRef.current = false; } - await selectProject(project); } useEffect(() => { @@ -352,11 +337,11 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - {canJustChat ? ( + {canStartScratch ? ( void startChat()} + onPress={() => void startScratch()} /> ) : null} @@ -379,12 +364,12 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - {canJustChat ? ( + {canStartScratch ? ( void startChat()} + onPress={() => void startScratch()} > - Just chat + Start in Scratch ) : null} @@ -487,16 +472,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps )} - {canJustChat && projectScopes.length > 0 ? ( + {canStartScratch && !scratchProjectExists && projectScopes.length > 0 ? ( Platform.OS === "android" ? ( void startChat()} + onPress={() => void startScratch()} leading={ void startChat()} + accessibilityLabel="Scratch" + onPress={() => void startScratch()} className="flex-row items-center gap-3 bg-card px-4 py-3.5" > - Just chat + Scratch Start a task without a repository diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8ab1520a6f34..d02f16d170e7 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -111,6 +111,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5100f1617a48..70b5dfdbcc72 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -123,7 +123,10 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; -import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts"; +import { + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, +} from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; @@ -5328,29 +5331,116 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("offers a chats folder only when the data dir is outside a work tree", () => + it.effect("creates the Scratch project on first request and reuses it after", () => Effect.gen(function* () { - yield* buildAppUnderTest(); - const outside = yield* Effect.scoped( + const fileSystem = yield* FileSystem.FileSystem; + const created: Array<{ readonly projectId: ProjectId; readonly workspaceRoot: string }> = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "project.create") { + created.push({ + projectId: command.projectId, + workspaceRoot: command.workspaceRoot, + }); + } + return { sequence: created.length }; + }), + }, + projectionSnapshotQuery: { + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + Option.fromNullishOr( + created.find((project) => project.workspaceRoot === workspaceRoot), + ).pipe( + Option.map((project) => ({ + ...makeDefaultOrchestrationReadModel().projects[0]!, + id: project.projectId, + workspaceRoot, + })), + ), + ), + }, + }, + }); + + yield* Effect.scoped( withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => - client[WS_METHODS.serverGetConfig]({}), + Effect.gen(function* () { + const config = yield* client[WS_METHODS.serverGetConfig]({}); + const first = yield* client[WS_METHODS.projectsEnsureScratch]({}); + const second = yield* client[WS_METHODS.projectsEnsureScratch]({}); + + assert.isTrue(config.scratchWorkspaceRoot?.endsWith("scratch")); + assert.equal(created.length, 1); + assert.equal(created[0]?.workspaceRoot, config.scratchWorkspaceRoot); + assert.equal(first.projectId, created[0]?.projectId); + assert.equal(second.projectId, first.projectId); + assert.isTrue(yield* fileSystem.exists(config.scratchWorkspaceRoot ?? "")); + }), ), ); - assert.isTrue(outside.chatWorkspaceRoot?.endsWith("chats")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("withholds the chats folder when the data dir sits inside a work tree", () => + it.effect("resolves a lost Scratch create race to the winning project", () => + Effect.gen(function* () { + const winnerId = ProjectId.make("project-scratch-winner"); + let lookups = 0; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Active project already exists for workspace root.", + }), + ), + }, + projectionSnapshotQuery: { + // Empty before the create, then the other client's project. + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.sync(() => + lookups++ === 0 + ? Option.none() + : Option.some({ + ...makeDefaultOrchestrationReadModel().projects[0]!, + id: winnerId, + workspaceRoot, + }), + ), + }, + }, + }); + + const result = yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + client[WS_METHODS.projectsEnsureScratch]({}), + ), + ); + assert.equal(result.projectId, winnerId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("withholds Scratch when the data dir sits inside a work tree", () => Effect.gen(function* () { yield* buildAppUnderTest({ layers: { vcsDriver: { isInsideWorkTree: () => Effect.succeed(true) } }, }); - const inside = yield* Effect.scoped( + yield* Effect.scoped( withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => - client[WS_METHODS.serverGetConfig]({}), + Effect.gen(function* () { + const config = yield* client[WS_METHODS.serverGetConfig]({}); + const ensure = yield* Effect.flip(client[WS_METHODS.projectsEnsureScratch]({})); + + assert.isUndefined(config.scratchWorkspaceRoot); + assert.include(String(ensure.message), "Scratch is not available"); + }), ), ); - assert.isUndefined(inside.chatWorkspaceRoot); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 38fe72ef1bfb..c65ab62f33d2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1803,27 +1803,81 @@ const makeWsRpcLayer = ( }; const path = yield* Path.Path; - // A chats folder inside a checkout (a dev worktree's .t3, a dotfiles - // home) would inherit that repo's git status and checkpoints, so the - // folder is only offered when the data dir is outside any work tree. - // Detection failures fail closed and hide the folder, never the config. + // Scratch threads run in a plain folder under the data dir. Inside a + // checkout (a dev worktree's .t3, a dotfiles home) that folder would + // inherit the repo's git status and checkpoints, so it is only offered + // when the data dir is outside any work tree. Detection failures fail + // closed and hide the folder, never the config. // Probed once per connection: a negative VCS detection is not cached. // A cached probe memoizes an interrupt exit too, so a config load that // is cancelled mid-probe invalidates it and the next load probes again. - const [cachedChatWorkspaceRoot, invalidateChatWorkspaceRoot] = + const [cachedScratchWorkspaceRoot, invalidateScratchWorkspaceRoot] = yield* Effect.cachedInvalidateWithTTL( gitWorkflow.isRepository(config.baseDir).pipe( Effect.map((isRepository) => - isRepository ? undefined : path.join(config.baseDir, "chats"), + isRepository ? undefined : path.resolve(config.baseDir, "scratch"), ), Effect.catchCause(() => Effect.succeed(undefined)), ), Duration.infinity, ); - const resolveChatWorkspaceRoot = cachedChatWorkspaceRoot.pipe( - Effect.onInterrupt(() => invalidateChatWorkspaceRoot), + const resolveScratchWorkspaceRoot = cachedScratchWorkspaceRoot.pipe( + Effect.onInterrupt(() => invalidateScratchWorkspaceRoot), ); + // One Scratch project per environment, created the first time a client + // asks. Two clients racing the create both reach dispatch; the loser's + // duplicate-root rejection resolves to the project the winner made. + const ensureScratchProject = Effect.gen(function* () { + const workspaceRoot = yield* resolveScratchWorkspaceRoot; + if (workspaceRoot === undefined) { + return yield* new OrchestrationDispatchCommandError({ + message: "Scratch is not available on this environment.", + }); + } + const findScratchProjectId = projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(workspaceRoot) + .pipe( + Effect.map(Option.map((project) => project.id)), + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to look up the Scratch project."), + ), + ); + const existingProjectId = yield* findScratchProjectId; + if (Option.isSome(existingProjectId)) { + return { projectId: existingProjectId.value }; + } + const projectId = ProjectId.make(yield* randomUUID); + return yield* Effect.gen(function* () { + const command = yield* normalizeDispatchCommand({ + type: "project.create", + commandId: yield* serverCommandId("scratch-project-create"), + projectId, + title: "Scratch", + workspaceRoot, + createWorkspaceRootIfMissing: true, + createdAt: yield* nowIso, + }); + yield* dispatchNormalizedCommand(command); + yield* recordClientCommandAnalytics(command); + return { projectId }; + }).pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to create the Scratch project."), + ), + Effect.catch((error) => + findScratchProjectId.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(error), + onSome: (racedProjectId) => Effect.succeed({ projectId: racedProjectId }), + }), + ), + ), + ), + ); + }); + // Only clients that answer /usage-limits themselves see it in the catalogs; // an older client would send the injected command to the provider. const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => @@ -1838,7 +1892,7 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); - const chatWorkspaceRoot = yield* resolveChatWorkspaceRoot; + const scratchWorkspaceRoot = yield* resolveScratchWorkspaceRoot; const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( externalLauncher.resolveAvailableEditors(), ); @@ -1887,7 +1941,7 @@ const makeWsRpcLayer = ( threadResumeCompletionMarker: true, threadSnapshotPagination: true, reasoningMessages: true, - ...(chatWorkspaceRoot === undefined ? {} : { chatWorkspaceRoot }), + ...(scratchWorkspaceRoot === undefined ? {} : { scratchWorkspaceRoot }), }; }); @@ -3041,6 +3095,10 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "source-control" }, ), + [WS_METHODS.projectsEnsureScratch]: () => + observeRpcEffect(WS_METHODS.projectsEnsureScratch, ensureScratchProject, { + "rpc.aggregate": "orchestration", + }), [WS_METHODS.projectCloneCancel]: (input) => observeRpcEffect( WS_METHODS.projectCloneCancel, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f7014a4f2726..68486707f692 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -47,8 +47,8 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + NotepadTextDashedIcon, LinkIcon, - MessageCircleIcon, MessageSquareIcon, MonitorIcon, MoonIcon, @@ -96,7 +96,7 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { useChatProject } from "../hooks/useChatProject"; +import { useScratchProject } from "../hooks/useScratchProject"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { useThreadSearch } from "../state/queries"; @@ -710,7 +710,7 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); - const { canStartChatIn, ensureChatProject } = useChatProject(); + const { scratchEnvironmentId, startScratchThread } = useScratchProject(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -1519,22 +1519,6 @@ function OpenCommandPaletteDialog(props: { }, }, ]; - if (canStartChatIn(environmentId)) { - sourceItems.push({ - kind: "action", - value: `action:add-project:${environmentId}:chat`, - searchTerms: ["chat", "no project", "without", "conversation"], - title: "Just chat", - description: "Start a thread without a repository", - icon: , - run: async () => { - const project = await ensureChatProject(environmentId); - if (project) { - await handleNewThread(scopeProjectRef(project.environmentId, project.id)); - } - }, - }); - } const orderedSources: ReadonlyArray = [ "url", @@ -1606,14 +1590,7 @@ function OpenCommandPaletteDialog(props: { return [{ value: `sources:${environmentId}`, label: "Sources", items: sourceItems }]; }, - [ - canStartChatIn, - ensureChatProject, - handleNewThread, - openSourceControlSettings, - startAddProjectBrowse, - startAddProjectClone, - ], + [openSourceControlSettings, startAddProjectBrowse, startAddProjectClone], ); const startAddProjectSourceSelection = useCallback( @@ -1811,6 +1788,21 @@ function OpenCommandPaletteDialog(props: { }); } + const scratchTargetEnvironmentId = scratchEnvironmentId( + currentProjectEnvironmentId ?? primaryEnvironmentId, + ); + if (scratchTargetEnvironmentId !== null) { + actionItems.push({ + kind: "action", + value: "action:new-scratch-thread", + searchTerms: ["new thread", "scratch", "no project", "without project", "chat"], + title: "New thread in Scratch", + icon: , + shortcutCommand: "chat.newScratch", + run: () => startScratchThread(scratchTargetEnvironmentId), + }); + } + if (activeThreadReferenceCopyTarget !== null) { actionItems.push({ kind: "action", diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index e973ba91a6ae..21d94fe87ee1 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -1,41 +1,20 @@ -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { MessageCircleIcon, PlusIcon } from "lucide-react"; +import { NotepadTextDashedIcon, PlusIcon } from "lucide-react"; import { useCallback } from "react"; import { openCommandPalette } from "../commandPaletteBus"; import { isElectron } from "../env"; -import { useChatProject } from "../hooks/useChatProject"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useScratchProject } from "../hooks/useScratchProject"; import { usePrimaryEnvironmentId } from "../state/environments"; import { Button } from "./ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; -import { stackedThreadToast, toastManager } from "./ui/toast"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; export function NoProjectsHero() { const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { chatEnvironmentId, ensureChatProject } = useChatProject(); - const handleNewThread = useNewThreadHandler(); - const chatTargetEnvironmentId = chatEnvironmentId(primaryEnvironmentId); - const canJustChat = chatTargetEnvironmentId !== null; - const startChat = useCallback(async () => { - if (chatTargetEnvironmentId === null) return; - const project = await ensureChatProject(chatTargetEnvironmentId); - if (!project) return; - try { - await handleNewThread(scopeProjectRef(project.environmentId, project.id)); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not create thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, [chatTargetEnvironmentId, ensureChatProject, handleNewThread]); + const { scratchEnvironmentId, startScratchThread } = useScratchProject(); + const scratchTargetEnvironmentId = scratchEnvironmentId(primaryEnvironmentId); return ( @@ -46,18 +25,26 @@ export function NoProjectsHero() {
What should we work on? - Add a project to start your first thread. + + {scratchTargetEnvironmentId === null + ? "Add a project to start your first thread." + : "Add a project, or start in Scratch without one."} +
- {canJustChat ? ( - - ) : null} + )}
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3cfbbcf5bdd4..df0ab2e6baf2 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -2,12 +2,12 @@ import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { findChatProject } from "@t3tools/client-runtime/operations/projects"; +import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; import { FolderPlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; -import { useChatProject } from "~/hooks/useChatProject"; +import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { @@ -34,7 +34,7 @@ import { MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { Button, InlineButton } from "../ui/button"; +import { InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; interface DraftHeroHeadlineProps { @@ -61,8 +61,7 @@ export function DraftHeroHeadline({ const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); - const { canStartChatIn, chatEnvironmentId, chatWorkspaceRootFor, ensureChatProject } = - useChatProject(); + const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); const environmentLabelById = useMemo( () => @@ -146,35 +145,20 @@ export function DraftHeroHeadline({ project.environmentId === activeProjectRef.environmentId && project.id === activeProjectRef.projectId, ) ?? null); - const chatTargetEnvironmentId = - activeProjectRef?.environmentId ?? chatEnvironmentId(primaryEnvironmentId); - const chatWorkspaceRoot = chatWorkspaceRootFor(chatTargetEnvironmentId); - const chatProject = - chatTargetEnvironmentId !== null && chatWorkspaceRoot !== null - ? findChatProject({ projects, environmentId: chatTargetEnvironmentId, chatWorkspaceRoot }) - : null; - const isChatDraft = + const scratchTargetEnvironmentId = + activeProjectRef?.environmentId ?? scratchEnvironmentId(primaryEnvironmentId); + const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId); + const isScratchDraft = activeProject !== null && - chatProject !== null && - chatProject.environmentId === activeProject.environmentId && - chatProject.id === activeProject.id; - // On a chat draft the heading already says "chat", so its menu lists - // repositories only. Every other state keeps Chats as a way in. - const menuEntries = projectPickerEntries.filter( - ({ targetProject }) => - !isChatDraft || - chatProject === null || - targetProject.environmentId !== chatProject.environmentId || - targetProject.id !== chatProject.id, - ); - const canJustChat = canStartChatIn(chatTargetEnvironmentId) && !isChatDraft; + scratchWorkspaceRoot !== null && + findProjectByPath([activeProject], scratchWorkspaceRoot) !== undefined; - // The picker can change the draft's target while "Just chat" is still - // creating its project; a stale continuation must not retarget it again. - const latestTargetRef = useRef({ draftId, activeProjectKey, chatTargetEnvironmentId }); + // The picker can change the draft's target while Scratch is still being + // opened; a stale continuation must not retarget it again. + const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId }); useEffect(() => { - latestTargetRef.current = { draftId, activeProjectKey, chatTargetEnvironmentId }; - }, [activeProjectKey, chatTargetEnvironmentId, draftId]); + latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; + }, [activeProjectKey, scratchTargetEnvironmentId, draftId]); // Project selection changes the target of the open draft in place. The // prompt stays in the same composer session, so the sidebar only gets a // draft row if the user later navigates away. @@ -185,7 +169,7 @@ export function DraftHeroHeadline({ latestTargetRef.current = { draftId, activeProjectKey: logicalProjectKey, - chatTargetEnvironmentId: project.environmentId, + scratchTargetEnvironmentId: project.environmentId, }; const currentDraft = getComposerDraft(draftId); setLogicalProjectDraftThreadId( @@ -209,18 +193,18 @@ export function DraftHeroHeadline({ } } }; - const startChat = async (): Promise => { - if (chatTargetEnvironmentId === null || isChatDraft) { + const startScratch = async (): Promise => { + if (scratchTargetEnvironmentId === null || isScratchDraft) { return false; } - const requested = { draftId, activeProjectKey, chatTargetEnvironmentId }; - const project = await ensureChatProject(chatTargetEnvironmentId); + const requested = { draftId, activeProjectKey, scratchTargetEnvironmentId }; + const project = await openScratchProject(scratchTargetEnvironmentId); const latest = latestTargetRef.current; if ( !project || latest.draftId !== requested.draftId || latest.activeProjectKey !== requested.activeProjectKey || - latest.chatTargetEnvironmentId !== requested.chatTargetEnvironmentId + latest.scratchTargetEnvironmentId !== requested.scratchTargetEnvironmentId ) { return false; } @@ -244,11 +228,9 @@ export function DraftHeroHeadline({ /> } > - - {isChatDraft ? "chat" : (activeProjectDisplayName ?? "Choose a project")} - + {activeProjectDisplayName ?? "Choose a project"} - {activeProjectDisplayName && !isChatDraft ? ( + {activeProjectDisplayName ? ( {activeProjectDisplayName} ) : null} @@ -263,7 +245,7 @@ export function DraftHeroHeadline({ selectProject(entry.targetProject, entry.group.projectKey); }} > - {menuEntries.map(({ group }) => { + {projectPickerEntries.map(({ group }) => { return ( @@ -286,7 +268,7 @@ export function DraftHeroHeadline({ ); })} - {menuEntries.length > 0 ? : null} + {projectPickerEntries.length > 0 ? : null} New project @@ -308,31 +290,28 @@ export function DraftHeroHeadline({ // in the h1; without an explicit label its widget state bleeds into the // announced phrase. const headingLabel = hasResolvedProject - ? isChatDraft - ? "What should we chat about?" - : `What should we build in ${activeProjectDisplayName}?` + ? `What should we build in ${activeProjectDisplayName}?` : canChooseProject ? `${activeProjectDisplayName ?? "Choose a project"} to start` : "Add a project to start"; - // One click into chat, phrased as the alternative to the question above it. - // Focus moves to the mode word once this line has gone. - const orJustChat = - canJustChat && (hasResolvedProject || canChooseProject) ? ( - + or start in Scratch + ) : null; return ( @@ -342,19 +321,17 @@ export function DraftHeroHeadline({ className="w-full text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl" > {hasResolvedProject ? ( - isChatDraft ? ( - <>What should we {projectSelector} about? - ) : ( - <>What should we build in {projectSelector}? - ) + <>What should we build in {projectSelector}? ) : canChooseProject ? ( <>{projectSelector} to start ) : ( <>Add a project to start )} - {/* Always reserved so the heading does not move when the line goes. */} -
{orJustChat}
+ {/* Reserved wherever Scratch exists so the heading does not move when the line goes. */} + {scratchWorkspaceRoot === null ? null : ( +

{orStartInScratch}

+ )} ); } diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 440c09c33ea3..1a101f107114 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -54,8 +54,6 @@ const buttonVariants = cva( glass: "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] rounded-full border-border/60 text-foreground shadow-sm before:rounded-full [:hover,[data-pressed]]:border-border", link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", - "link-muted": - "border-transparent font-normal text-muted-foreground underline-offset-4 [:hover,[data-pressed]]:text-foreground [:hover,[data-pressed]]:underline", "media-close": "[--control-icon-color:currentColor] border-transparent bg-black/65 text-white shadow-sm ring-1 ring-white/20 [:hover,[data-pressed]]:bg-black/80 focus-visible:ring-white", "media-navigation": diff --git a/apps/web/src/hooks/useChatProject.ts b/apps/web/src/hooks/useChatProject.ts deleted file mode 100644 index 873c59a4bc5a..000000000000 --- a/apps/web/src/hooks/useChatProject.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { CHAT_PROJECT_TITLE, findChatProject } from "@t3tools/client-runtime/operations/projects"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback } from "react"; - -import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { newProjectId } from "~/lib/utils"; -import { readProjects, waitForProject } from "~/state/entities"; -import { useEnvironments } from "~/state/environments"; -import { projectEnvironment } from "~/state/projects"; -import { useAtomCommand } from "~/state/use-atom-command"; - -// One create per environment at a time: a second click while the first -// project.create is in flight would be rejected as a duplicate workspace root. -const inFlightByEnvironment = new Map>(); - -async function waitForChatProject( - find: () => EnvironmentProject | null, - attempts = 12, - intervalMs = 250, -): Promise { - for (let attempt = 0; attempt < attempts; attempt++) { - const project = find(); - if (project) return project; - if (attempt + 1 < attempts) { - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - } - return null; -} - -function reportChatStartFailure(error: unknown) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to start chat", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); -} - -/** - * "Just chat" runs a thread in a plain folder the server offers instead of a - * repository. That folder becomes an ordinary project the first time it is - * used, so everything downstream is the regular non-git project path. - */ -export function useChatProject() { - const { environments } = useEnvironments(); - const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); - - const chatWorkspaceRootFor = useCallback( - (environmentId: EnvironmentId | null): string | null => - environments.find((entry) => entry.environmentId === environmentId)?.serverConfig - ?.chatWorkspaceRoot ?? null, - [environments], - ); - - /** Whether "Just chat" can be started in this environment right now. */ - const canStartChatIn = useCallback( - (environmentId: EnvironmentId | null): boolean => - environments.some( - (entry) => - entry.environmentId === environmentId && - entry.connection.phase === "connected" && - entry.serverConfig?.chatWorkspaceRoot !== undefined, - ), - [environments], - ); - - // The hosted web app has no primary environment, so "Just chat" targets the - // first connected environment that offers a chats folder. - const chatEnvironmentId = useCallback( - (preferred: EnvironmentId | null): EnvironmentId | null => - canStartChatIn(preferred) - ? preferred - : (environments.find((entry) => canStartChatIn(entry.environmentId))?.environmentId ?? - null), - [canStartChatIn, environments], - ); - - const ensureChatProject = useCallback( - (environmentId: EnvironmentId): Promise => { - const chatWorkspaceRoot = chatWorkspaceRootFor(environmentId); - if (chatWorkspaceRoot === null || !canStartChatIn(environmentId)) { - return Promise.resolve(null); - } - const findExisting = () => - findChatProject({ projects: readProjects(), environmentId, chatWorkspaceRoot }); - const existing = findExisting(); - if (existing) return Promise.resolve(existing); - const pending = inFlightByEnvironment.get(environmentId); - if (pending) return pending; - - const create = (async () => { - const projectId = newProjectId(); - const result = await createProject({ - environmentId, - input: { - projectId, - title: CHAT_PROJECT_TITLE, - workspaceRoot: chatWorkspaceRoot, - createWorkspaceRootIfMissing: true, - defaultModelSelection: null, - }, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) return null; - // Another client may have created it first. Its project event can - // land after this rejection, so give the store a moment to catch up. - const raced = await waitForChatProject(findExisting); - if (raced) return raced; - reportChatStartFailure(squashAtomCommandFailure(result)); - return null; - } - // Drafts key off the project's stored path and settings, so wait for - // the create event to reach the client store before targeting one. - try { - return await waitForProject({ environmentId, projectId }); - } catch { - reportChatStartFailure(new Error("The chat project has not reached this client yet.")); - return null; - } - })().finally(() => { - inFlightByEnvironment.delete(environmentId); - }); - inFlightByEnvironment.set(environmentId, create); - return create; - }, - [canStartChatIn, chatWorkspaceRootFor, createProject], - ); - - return { canStartChatIn, chatEnvironmentId, chatWorkspaceRootFor, ensureChatProject }; -} diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts new file mode 100644 index 000000000000..c9980f9ea6cf --- /dev/null +++ b/apps/web/src/hooks/useScratchProject.ts @@ -0,0 +1,94 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { waitForProject } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; +import { projectEnvironment } from "~/state/projects"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useNewThreadHandler } from "./useHandleNewThread"; + +function reportScratchFailure(error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open Scratch", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** + * Scratch runs threads in a plain folder the server owns instead of a + * repository. The server creates the Scratch project on first use; after that + * it is an ordinary project on the non-git path. + */ +export function useScratchProject() { + const { environments } = useEnvironments(); + const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false }); + const handleNewThread = useNewThreadHandler(); + + /** The Scratch folder of a connected environment, or null when it offers none. */ + const scratchWorkspaceRootFor = useCallback( + (environmentId: EnvironmentId | null): string | null => { + const environment = environments.find((entry) => entry.environmentId === environmentId); + return environment?.connection.phase === "connected" + ? (environment.serverConfig?.scratchWorkspaceRoot ?? null) + : null; + }, + [environments], + ); + + // The hosted web app has no primary environment, so Scratch falls back to + // the first connected environment that offers it. + const scratchEnvironmentId = useCallback( + (preferred: EnvironmentId | null): EnvironmentId | null => + scratchWorkspaceRootFor(preferred) !== null + ? preferred + : (environments.find((entry) => scratchWorkspaceRootFor(entry.environmentId) !== null) + ?.environmentId ?? null), + [environments, scratchWorkspaceRootFor], + ); + + /** Resolves to the Scratch project once it is in this client's store. */ + const openScratchProject = useCallback( + async (environmentId: EnvironmentId): Promise => { + const result = await ensureScratch({ environmentId, input: {} }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + reportScratchFailure(squashAtomCommandFailure(result)); + } + return null; + } + // Drafts key off the project's stored path and settings, so wait for + // the create event to reach the store before targeting one. + return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch( + (error: unknown) => { + reportScratchFailure(error); + return null; + }, + ); + }, + [ensureScratch], + ); + + const startScratchThread = useCallback( + async (environmentId: EnvironmentId) => { + const project = await openScratchProject(environmentId); + if (project) { + await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( + reportScratchFailure, + ); + } + }, + [handleNewThread, openScratchProject], + ); + + return { scratchWorkspaceRootFor, scratchEnvironmentId, openScratchProject, startScratchThread }; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 5b5b4888269b..ede705b80522 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -13,6 +13,7 @@ import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useScratchProject } from "../hooks/useScratchProject"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -37,6 +38,7 @@ function ChatRouteGlobalShortcuts() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { scratchEnvironmentId, startScratchThread } = useScratchProject(); const projectGroupCount = useMemo( () => buildSidebarProjectSnapshots({ @@ -105,6 +107,17 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newScratch") { + const environmentId = scratchEnvironmentId( + routeThreadRef?.environmentId ?? primaryEnvironmentId, + ); + if (environmentId === null) return; + event.preventDefault(); + event.stopPropagation(); + void startScratchThread(environmentId); + return; + } + if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); @@ -180,9 +193,12 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + primaryEnvironmentId, projectGroupCount, routeThreadRef, + scratchEnvironmentId, selectedThreadKeysSize, + startScratchThread, legacySidebarEnabled, terminalOpen, ]); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 2525fed64d94..eec94ebc11a5 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -122,7 +122,8 @@ through the pages you have visited, like a browser's back and forward buttons. `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your -[new-thread defaults](./thread-sidebar.md#start-a-thread). +[new-thread defaults](./thread-sidebar.md#start-a-thread). `chat.newScratch` +(`mod+alt+n`) starts a thread in [Scratch](./thread-sidebar.md#scratch). ## Reserved shortcuts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c7cebfc8b820..246a31496f17 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -13,15 +13,19 @@ an existing worktree, use **New thread in this worktree** from the branch toolba When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it. -To talk to an agent without picking a repository, click **or just chat** under -a new thread's heading, choose **Just chat** on the empty-state screen or in the -add-project menu, or pick it from the project list on mobile. The heading then -reads "What should we chat about?"; click **chat** to move the thread to a -project. Chat threads share one **Chats** project rooted in a plain `chats` -folder inside the T3 data directory. Branch, worktree, and diff controls stay -hidden while that folder is not a Git repository. Any files the agent writes -land in that folder. The option is unavailable when the data directory itself -sits inside a Git checkout. +### Scratch + +Scratch is for work that does not belong to a repository. To start there, click +**or start in Scratch** under a new thread's heading, choose **New thread in +Scratch** in the command palette, or press `mod+alt+n`. On mobile, pick +**Scratch** from the project list. To move a draft to a repository, pick the +project in the heading. + +Scratch is an ordinary project rooted in the `scratch` folder of your T3 data +directory (`~/.t3/scratch` by default). All Scratch threads share that folder, and +the files the agent writes stay there until you delete them. Branch, worktree, and +diff controls stay hidden while the folder is not a Git repository. Scratch is +unavailable when the data directory itself sits inside a Git checkout. ### Start in the background diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 587870f4d6c3..4b29e67a2176 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -9,10 +9,8 @@ import * as Option from "effect/Option"; import { buildAddProjectRemoteSourceReadiness, - buildChatProjectCreateCommand, buildProjectCreateCommand, canCreateProjectInEnvironment, - findChatProject, findExistingAddProject, getAddProjectInitialQuery, getCloneDestinationBrowsePath, @@ -278,56 +276,3 @@ describe("add project shared logic", () => { }); }); }); - -describe("chat project", () => { - it("finds the project rooted at the environment's chat folder", () => { - const env = EnvironmentId.make("env-1"); - const project = ( - id: string, - environmentId: EnvironmentId, - workspaceRoot: string, - ): EnvironmentProject => ({ - environmentId, - id: ProjectId.make(id), - title: id, - workspaceRoot, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - }); - const projects = [ - project("repo", env, "/repo"), - project("chats", env, "/home/me/.t3/chats/"), - project("other-env", EnvironmentId.make("env-2"), "/home/me/.t3/chats"), - ]; - expect( - findChatProject({ projects, environmentId: env, chatWorkspaceRoot: "/home/me/.t3/chats" }) - ?.id, - ).toBe("chats"); - expect( - findChatProject({ - projects, - environmentId: env, - chatWorkspaceRoot: "/home/me/.t3/elsewhere", - }), - ).toBeNull(); - }); - - it("titles the created project Chats instead of the folder name", () => { - expect( - buildChatProjectCreateCommand({ - commandId: CommandId.make("command"), - projectId: ProjectId.make("project"), - chatWorkspaceRoot: "/home/me/.t3/chats", - createdAt: "2026-01-01T00:00:00.000Z", - }), - ).toMatchObject({ - type: "project.create", - title: "Chats", - workspaceRoot: "/home/me/.t3/chats", - createWorkspaceRootIfMissing: true, - }); - }); -}); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 1b5a0cc77e13..597b0b5d7b4f 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -322,40 +322,3 @@ export function buildProjectCreateCommand(input: { createdAt: input.createdAt, }; } - -/** Title of the plain-folder project that hosts threads started without a repository. */ -export const CHAT_PROJECT_TITLE = "Chats"; - -/** - * The project an environment uses for "just chat" threads, if the user has - * started one before. It is an ordinary project rooted at the folder the - * server advertises in `ServerConfig.chatWorkspaceRoot`. - */ -export function findChatProject(input: { - readonly projects: ReadonlyArray; - readonly environmentId: EnvironmentId; - readonly chatWorkspaceRoot: string; -}): EnvironmentProject | null { - return findExistingAddProject({ - projects: input.projects, - environmentId: input.environmentId, - path: input.chatWorkspaceRoot, - }); -} - -export function buildChatProjectCreateCommand(input: { - readonly commandId: CommandId; - readonly projectId: ProjectId; - readonly chatWorkspaceRoot: string; - readonly createdAt: string; -}): Extract { - return { - ...buildProjectCreateCommand({ - commandId: input.commandId, - projectId: input.projectId, - workspaceRoot: input.chatWorkspaceRoot, - createdAt: input.createdAt, - }), - title: CHAT_PROJECT_TITLE, - }; -} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..d969a8c0d379 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -92,6 +92,13 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), + // Finds or creates the environment's Scratch project and returns its id. + ensureScratch: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:ensure-scratch", + tag: WS_METHODS.projectsEnsureScratch, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 239f9905c534..3d64200d84d6 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -91,6 +91,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.branch", "chat.new", "chat.newLocal", + "chat.newScratch", "editor.openFavorite", "usage.cost", "usage.tokens", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 3858c241c2d6..f97924b084fb 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, + ProjectId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; @@ -281,6 +282,12 @@ export const ProjectWriteFileResult = Schema.Struct({ }); export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; +/** The environment's Scratch project, created on first request. */ +export const ProjectEnsureScratchResult = Schema.Struct({ + projectId: ProjectId, +}); +export type ProjectEnsureScratchResult = typeof ProjectEnsureScratchResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dbc143048a72..3ad176dd977f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -150,6 +150,7 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, ProjectListEntriesResult, @@ -284,6 +285,7 @@ export const WS_METHODS = { projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsEnsureScratch: "projects.ensureScratch", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -955,6 +957,13 @@ const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); +// Finds or creates the Scratch project rooted at ServerConfig.scratchWorkspaceRoot. +const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { + payload: Schema.Struct({}), + success: ProjectEnsureScratchResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1468,6 +1477,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsReadFileRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, + WsProjectsEnsureScratchRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 94156e05b003..116643353ce1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -604,11 +604,11 @@ export const ServerConfig = Schema.Struct({ /** Whether thread reads accept the reasoningMessages opt-in. */ reasoningMessages: Schema.optionalKey(Schema.Boolean), /** - * Folder this environment offers for threads that need no repository. A - * client that wants "just chat" adds an ordinary project rooted here. Absent - * on servers that predate the feature. + * Folder behind this environment's Scratch project, for threads that need + * no repository. Present only on servers that answer projects.ensureScratch + * and whose data dir is outside a Git checkout. */ - chatWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), + scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8029be96f939..e723001748ca 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -49,6 +49,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+alt+n", command: "chat.newScratch", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, From 843fea784f27ad0447a221aa18e399b18def93ab Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 01:55:02 -0700 Subject: [PATCH 03/14] fix(server): keep an interrupted Scratch probe from caching a miss Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/ws.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c65ab62f33d2..7bcd0ee86d51 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1806,18 +1806,20 @@ const makeWsRpcLayer = ( // Scratch threads run in a plain folder under the data dir. Inside a // checkout (a dev worktree's .t3, a dotfiles home) that folder would // inherit the repo's git status and checkpoints, so it is only offered - // when the data dir is outside any work tree. Detection failures fail - // closed and hide the folder, never the config. + // when the data dir is outside any work tree. Detection failures and + // defects fail closed and hide the folder, never the config. // Probed once per connection: a negative VCS detection is not cached. - // A cached probe memoizes an interrupt exit too, so a config load that - // is cancelled mid-probe invalidates it and the next load probes again. + // An interrupt stays an interrupt, so a config load cancelled mid-probe + // invalidates the cache and the next load probes again. const [cachedScratchWorkspaceRoot, invalidateScratchWorkspaceRoot] = yield* Effect.cachedInvalidateWithTTL( gitWorkflow.isRepository(config.baseDir).pipe( Effect.map((isRepository) => isRepository ? undefined : path.resolve(config.baseDir, "scratch"), ), - Effect.catchCause(() => Effect.succeed(undefined)), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined), + ), ), Duration.infinity, ); From 6ab45dd4f9389640ca0c6712a1a7709628ec80ad Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 02:25:40 -0700 Subject: [PATCH 04/14] fix: address Scratch review findings - ensureScratch remakes a deleted Scratch folder and reports fixed error messages instead of storage error text. - mod+alt+n prefers the open thread or draft's environment. - Mobile picks any connected environment that offers Scratch, and Scratch drafts default to the current checkout. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/NewTaskRouteScreen.tsx | 19 ++++++++------- .../threads/new-task-flow-provider.tsx | 11 ++++++++- apps/server/src/server.test.ts | 11 +++++---- apps/server/src/ws.ts | 24 +++++++++++++------ apps/web/src/routes/_chat.tsx | 2 +- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index c3d82623fab8..040b88c62eee 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -169,15 +169,18 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps + canCreateProjectInEnvironment(environment.connectionState) && + serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + ); const scratchEnvironment = - connectedEnvironments.find( - (environment) => - (selectedEnvironmentId === null || environment.environmentId === selectedEnvironmentId) && - canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, - ) ?? null; + scratchEnvironments.find( + (environment) => environment.environmentId === selectedEnvironmentId, + ) ?? + scratchEnvironments[0] ?? + null; const scratchWorkspaceRoot = scratchEnvironment ? (serverConfigs.get(scratchEnvironment.environmentId)?.scratchWorkspaceRoot ?? null) : null; diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 61b77afffba1..3270de92d4ce 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -79,6 +79,7 @@ import { setPendingConnectionError, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; +import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; import { @@ -452,7 +453,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ), [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // Scratch is a plain folder, so a worktree default would leave it unsendable. + const scratchWorkspaceRoot = selectedEnvironmentServerConfig?.scratchWorkspaceRoot; + const isScratchProject = + selectedProject !== null && + scratchWorkspaceRoot !== undefined && + findProjectByPath([selectedProject], scratchWorkspaceRoot) !== undefined; + const defaultWorkspaceMode: WorkspaceMode = isScratchProject + ? "local" + : projectSettings.settings.defaultThreadEnvMode; // While the file read is pending and nothing above it decided, the // resolved default is provisional. Nothing may write it into the draft // during that window (the auto-branch effect does), or the frozen interim diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 70b5dfdbcc72..5db222f7b17e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5331,7 +5331,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("creates the Scratch project on first request and reuses it after", () => + it.effect("creates the Scratch project once and restores its folder on reuse", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const created: Array<{ readonly projectId: ProjectId; readonly workspaceRoot: string }> = []; @@ -5370,15 +5370,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => Effect.gen(function* () { const config = yield* client[WS_METHODS.serverGetConfig]({}); + const scratchRoot = config.scratchWorkspaceRoot ?? ""; const first = yield* client[WS_METHODS.projectsEnsureScratch]({}); + // A user may delete the folder; reuse must bring it back. + yield* fileSystem.remove(scratchRoot, { recursive: true }); const second = yield* client[WS_METHODS.projectsEnsureScratch]({}); - assert.isTrue(config.scratchWorkspaceRoot?.endsWith("scratch")); + assert.isTrue(scratchRoot.endsWith("scratch")); assert.equal(created.length, 1); - assert.equal(created[0]?.workspaceRoot, config.scratchWorkspaceRoot); + assert.equal(created[0]?.workspaceRoot, scratchRoot); assert.equal(first.projectId, created[0]?.projectId); assert.equal(second.projectId, first.projectId); - assert.isTrue(yield* fileSystem.exists(config.scratchWorkspaceRoot ?? "")); + assert.isTrue(yield* fileSystem.exists(scratchRoot)); }), ), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7bcd0ee86d51..bb3431715e0d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1830,6 +1830,8 @@ const makeWsRpcLayer = ( // One Scratch project per environment, created the first time a client // asks. Two clients racing the create both reach dispatch; the loser's // duplicate-root rejection resolves to the project the winner made. + // The folder is (re)made on every call so a deleted Scratch still runs. + const fileSystem = yield* FileSystem.FileSystem; const ensureScratchProject = Effect.gen(function* () { const workspaceRoot = yield* resolveScratchWorkspaceRoot; if (workspaceRoot === undefined) { @@ -1837,12 +1839,25 @@ const makeWsRpcLayer = ( message: "Scratch is not available on this environment.", }); } + yield* fileSystem.makeDirectory(workspaceRoot, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the Scratch folder.", + cause, + }), + ), + ); const findScratchProjectId = projectionSnapshotQuery .getActiveProjectByWorkspaceRoot(workspaceRoot) .pipe( Effect.map(Option.map((project) => project.id)), - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to look up the Scratch project."), + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to look up the Scratch project.", + cause, + }), ), ); const existingProjectId = yield* findScratchProjectId; @@ -1857,16 +1872,11 @@ const makeWsRpcLayer = ( projectId, title: "Scratch", workspaceRoot, - createWorkspaceRootIfMissing: true, createdAt: yield* nowIso, }); yield* dispatchNormalizedCommand(command); - yield* recordClientCommandAnalytics(command); return { projectId }; }).pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to create the Scratch project."), - ), Effect.catch((error) => findScratchProjectId.pipe( Effect.flatMap( diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index ede705b80522..6b392b433e93 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -109,7 +109,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.newScratch") { const environmentId = scratchEnvironmentId( - routeThreadRef?.environmentId ?? primaryEnvironmentId, + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, ); if (environmentId === null) return; event.preventDefault(); From 248f27e6c9dc97e7aad5342816a6f08997afab29 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 03:23:04 -0700 Subject: [PATCH 05/14] feat: give each Scratch thread its own folder Scratch threads shared one folder, so their files collided and each agent saw the others' leftovers. Now the server assigns every new Scratch thread /-- and carries it in worktreePath, which the provider, terminal, and file tree already use as the thread's folder. This matches the Codex app's projectless chats. Deleting a Scratch thread keeps its folder and no longer offers to remove it as a git worktree. The legacy sidebar only shows the worktree badge for branch threads, like the default sidebar. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/NewTaskRouteScreen.tsx | 14 +- .../threads/new-task-flow-provider.tsx | 13 +- apps/server/src/server.test.ts | 85 +++++++++++ apps/server/src/ws.ts | 139 ++++++++++++++---- apps/web/src/components/LegacySidebar.tsx | 3 +- .../src/components/chat/DraftHeroHeadline.tsx | 6 +- apps/web/src/hooks/useThreadActions.ts | 15 +- docs/user/thread-sidebar.md | 9 +- packages/client-runtime/src/state/projects.ts | 10 ++ 9 files changed, 239 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 040b88c62eee..75ac35e41d25 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -8,7 +8,7 @@ import { } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; -import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -185,13 +185,11 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps project.environmentId === scratchEnvironment.environmentId), - scratchWorkspaceRoot, - ) !== undefined; + const scratchProjectExists = projects.some( + (project) => + project.environmentId === scratchEnvironment?.environmentId && + isScratchProject(project, scratchWorkspaceRoot), + ); const canStartScratch = scratchWorkspaceRoot !== null && reservedDestinationProject === null; const scratchStartInFlightRef = useRef(false); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 3270de92d4ce..9a68c74b6701 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -79,7 +79,7 @@ import { setPendingConnectionError, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; import { @@ -454,14 +454,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); // Scratch is a plain folder, so a worktree default would leave it unsendable. - const scratchWorkspaceRoot = selectedEnvironmentServerConfig?.scratchWorkspaceRoot; - const isScratchProject = + const defaultWorkspaceMode: WorkspaceMode = selectedProject !== null && - scratchWorkspaceRoot !== undefined && - findProjectByPath([selectedProject], scratchWorkspaceRoot) !== undefined; - const defaultWorkspaceMode: WorkspaceMode = isScratchProject - ? "local" - : projectSettings.settings.defaultThreadEnvMode; + isScratchProject(selectedProject, selectedEnvironmentServerConfig?.scratchWorkspaceRoot) + ? "local" + : projectSettings.settings.defaultThreadEnvMode; // While the file read is pending and nothing above it decided, the // resolved default is provisional. Nothing may write it into the draft // during that window (the auto-branch effect does), or the frozen interim diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5db222f7b17e..3eac284f8b28 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5428,6 +5428,91 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("gives each new Scratch thread its own folder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scratchProjectId = ProjectId.make("project-scratch"); + let scratchRoot = ""; + const created: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + if (command.type === "thread.create") created.push(command.worktreePath); + return { sequence: created.length }; + }), + readEvents: () => Stream.empty, + }, + projectionSnapshotQuery: { + getProjectShellById: (projectId) => + Effect.succeed( + projectId === scratchProjectId + ? Option.some({ + id: scratchProjectId, + title: "Scratch", + workspaceRoot: scratchRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-25T00:00:00.000Z", + updatedAt: "2026-09-25T00:00:00.000Z", + }) + : Option.none(), + ), + }, + }, + }); + + yield* Effect.scoped( + withWsRpcClient(yield* getWsServerUrl("/ws"), (client) => + Effect.gen(function* () { + scratchRoot = + (yield* client[WS_METHODS.serverGetConfig]({})).scratchWorkspaceRoot ?? ""; + const createdAt = "2026-09-25T10:00:00.000Z"; + const threadId = ThreadId.make("a1b2c3d4-scratch-thread"); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-scratch-turn-start"), + threadId, + message: { + messageId: MessageId.make("msg-scratch"), + role: "user", + text: "Convert these PNGs to WebP, please!", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: scratchProjectId, + title: "New thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + }, + createdAt, + }); + }), + ), + ); + + // The date is the server's receipt time, not the client's createdAt. + const folder = created[0] ?? ""; + assert.equal(path.dirname(folder), scratchRoot); + assert.match( + path.basename(folder), + /^\d{4}-\d{2}-\d{2}-convert-these-pngs-to-webp-a1b2c3d4$/, + ); + assert.isTrue(yield* fileSystem.exists(folder)); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("withholds Scratch when the data dir sits inside a work tree", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bb3431715e0d..509c3324072f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -178,6 +178,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -1773,7 +1774,114 @@ const makeWsRpcLayer = ( return yield* runBootstrap; }); - const dispatchNormalizedCommand = ( + const path = yield* Path.Path; + // Scratch threads run in a plain folder under the data dir. Inside a + // checkout (a dev worktree's .t3, a dotfiles home) that folder would + // inherit the repo's git status and checkpoints, so it is only offered + // when the data dir is outside any work tree. Detection failures and + // defects fail closed and hide the folder, never the config. + // Probed once per connection: a negative VCS detection is not cached. + // An interrupt stays an interrupt, so a config load cancelled mid-probe + // invalidates the cache and the next load probes again. + const [cachedScratchWorkspaceRoot, invalidateScratchWorkspaceRoot] = + yield* Effect.cachedInvalidateWithTTL( + gitWorkflow.isRepository(config.baseDir).pipe( + Effect.map((isRepository) => + isRepository ? undefined : path.resolve(config.baseDir, "scratch"), + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined), + ), + ), + Duration.infinity, + ); + const resolveScratchWorkspaceRoot = cachedScratchWorkspaceRoot.pipe( + Effect.onInterrupt(() => invalidateScratchWorkspaceRoot), + ); + + const fileSystem = yield* FileSystem.FileSystem; + // Each Scratch thread gets its own folder under the Scratch root, named + // from its date, first words, and id. It rides in worktreePath like any + // thread that runs outside its project root, so the provider, terminal, + // and file tree all use it. Threads that already name a folder keep it. + const scratchThreadFolder = (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly createdAt: string; + readonly text: string; + }): Effect.Effect => + Effect.gen(function* () { + if (input.worktreePath !== null) return null; + const scratchRoot = yield* resolveScratchWorkspaceRoot; + if (scratchRoot === undefined) return null; + const project = yield* projectionSnapshotQuery.getProjectShellById(input.projectId).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to look up the thread's project.", + cause, + }), + ), + ); + if ( + Option.isNone(project) || + normalizeProjectPathForComparison(project.value.workspaceRoot) !== + normalizeProjectPathForComparison(scratchRoot) + ) { + return null; + } + const words = input.text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + .slice(0, 5); + const folder = path.join( + scratchRoot, + [input.createdAt.slice(0, 10), ...words, input.threadId.slice(0, 8)].join("-"), + ); + yield* fileSystem.makeDirectory(folder, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the Scratch thread folder.", + cause, + }), + ), + ); + return folder; + }); + const withScratchThreadFolder = ( + command: OrchestrationCommand, + ): Effect.Effect => { + if (command.type === "thread.create") { + return scratchThreadFolder({ ...command, text: command.title }).pipe( + Effect.map((worktreePath) => + worktreePath === null ? command : { ...command, worktreePath }, + ), + ); + } + if (command.type !== "thread.turn.start") return Effect.succeed(command); + const bootstrap = command.bootstrap; + const createThread = bootstrap?.createThread; + if (bootstrap === undefined || createThread === undefined) return Effect.succeed(command); + return scratchThreadFolder({ + ...createThread, + threadId: command.threadId, + text: command.message.text, + }).pipe( + Effect.map((worktreePath) => + worktreePath === null + ? command + : { + ...command, + bootstrap: { ...bootstrap, createThread: { ...createThread, worktreePath } }, + }, + ), + ); + }; + + const dispatchPreparedCommand = ( normalizedCommand: OrchestrationCommand, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { const dispatchEffect = @@ -1802,36 +1910,15 @@ const makeWsRpcLayer = ( ); }; - const path = yield* Path.Path; - // Scratch threads run in a plain folder under the data dir. Inside a - // checkout (a dev worktree's .t3, a dotfiles home) that folder would - // inherit the repo's git status and checkpoints, so it is only offered - // when the data dir is outside any work tree. Detection failures and - // defects fail closed and hide the folder, never the config. - // Probed once per connection: a negative VCS detection is not cached. - // An interrupt stays an interrupt, so a config load cancelled mid-probe - // invalidates the cache and the next load probes again. - const [cachedScratchWorkspaceRoot, invalidateScratchWorkspaceRoot] = - yield* Effect.cachedInvalidateWithTTL( - gitWorkflow.isRepository(config.baseDir).pipe( - Effect.map((isRepository) => - isRepository ? undefined : path.resolve(config.baseDir, "scratch"), - ), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(undefined), - ), - ), - Duration.infinity, - ); - const resolveScratchWorkspaceRoot = cachedScratchWorkspaceRoot.pipe( - Effect.onInterrupt(() => invalidateScratchWorkspaceRoot), - ); + const dispatchNormalizedCommand = ( + command: OrchestrationCommand, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => + withScratchThreadFolder(command).pipe(Effect.flatMap(dispatchPreparedCommand)); // One Scratch project per environment, created the first time a client // asks. Two clients racing the create both reach dispatch; the loser's // duplicate-root rejection resolves to the project the winner made. // The folder is (re)made on every call so a deleted Scratch still runs. - const fileSystem = yield* FileSystem.FileSystem; const ensureScratchProject = Effect.gen(function* () { const workspaceRoot = yield* resolveScratchWorkspaceRoot; if (workspaceRoot === undefined) { diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 1991dc502283..fd2d5a238491 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -820,7 +820,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} - + {/* Branchless folders (Scratch threads) are not git worktrees. */} + {thread.branch ? : null} {terminalStatus && ( )} - {/* Branchless folders (Scratch threads) are not git worktrees. */} - {thread.branch ? : null} + {terminalStatus && ( Date: Fri, 25 Sep 2026 20:59:08 -0700 Subject: [PATCH 07/14] feat: mark Scratch with a dashed chat bubble The server gives a new Scratch project the lucide message-square-dashed icon in gray, once at create, so a user's own icon choice sticks. The palette action, empty-state button, and mobile row use the same bubble. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../mobile/src/features/threads/NewTaskRouteScreen.tsx | 4 ++-- apps/server/src/server.test.ts | 8 +++++++- apps/server/src/ws.ts | 10 ++++++++++ apps/web/src/components/CommandPalette.tsx | 4 ++-- apps/web/src/components/NoProjectsHero.tsx | 4 ++-- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 75ac35e41d25..e5c3ffbd0f44 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -482,7 +482,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void startScratch()} leading={ { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const created: Array<{ readonly projectId: ProjectId; readonly workspaceRoot: string }> = []; + const iconUpdates: Array = []; yield* buildAppUnderTest({ layers: { orchestrationEngine: { @@ -5346,7 +5347,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workspaceRoot: command.workspaceRoot, }); } - return { sequence: created.length }; + if (command.type === "project.meta.update") iconUpdates.push(command.projectIcon); + return { sequence: created.length + iconUpdates.length }; }), }, projectionSnapshotQuery: { @@ -5381,6 +5383,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(created[0]?.workspaceRoot, scratchRoot); assert.equal(first.projectId, created[0]?.projectId); assert.equal(second.projectId, first.projectId); + // The icon is set once, at create. + assert.deepEqual(iconUpdates, [ + { kind: "lucide", name: "message-square-dashed", color: "gray" }, + ]); assert.isTrue(yield* fileSystem.exists(scratchRoot)); }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 509c3324072f..9be771ce1b26 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1962,6 +1962,16 @@ const makeWsRpcLayer = ( createdAt: yield* nowIso, }); yield* dispatchNormalizedCommand(command); + // A dashed chat bubble in neutral gray marks Scratch. Set once at + // create, so a user's own icon choice is never overwritten. + yield* dispatchNormalizedCommand( + yield* normalizeDispatchCommand({ + type: "project.meta.update", + commandId: yield* serverCommandId("scratch-project-icon"), + projectId, + projectIcon: { kind: "lucide", name: "message-square-dashed", color: "gray" }, + }), + ); return { projectId }; }).pipe( Effect.catch((error) => diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 68486707f692..ecedc7e32a1f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -47,7 +47,7 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, - NotepadTextDashedIcon, + MessageSquareDashedIcon, LinkIcon, MessageSquareIcon, MonitorIcon, @@ -1797,7 +1797,7 @@ function OpenCommandPaletteDialog(props: { value: "action:new-scratch-thread", searchTerms: ["new thread", "scratch", "no project", "without project", "chat"], title: "New thread in Scratch", - icon: , + icon: , shortcutCommand: "chat.newScratch", run: () => startScratchThread(scratchTargetEnvironmentId), }); diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index 21d94fe87ee1..a247124dc3b2 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -1,4 +1,4 @@ -import { NotepadTextDashedIcon, PlusIcon } from "lucide-react"; +import { MessageSquareDashedIcon, PlusIcon } from "lucide-react"; import { useCallback } from "react"; import { openCommandPalette } from "../commandPaletteBus"; @@ -41,7 +41,7 @@ export function NoProjectsHero() { variant="outline" onClick={() => void startScratchThread(scratchTargetEnvironmentId)} > - + Start in Scratch )} From 95b64ade2a4f871af40a127cec2a22892da1a1c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 21:09:35 -0700 Subject: [PATCH 08/14] fix(server): keep Scratch thread folders unique and inside the root Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/server.test.ts | 73 ++++++++++++++++++---------------- apps/server/src/ws.ts | 18 +++++++-- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0532c3720736..6b05cdc850f3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5476,46 +5476,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => { scratchRoot = (yield* client[WS_METHODS.serverGetConfig]({})).scratchWorkspaceRoot ?? ""; const createdAt = "2026-09-25T10:00:00.000Z"; - const threadId = ThreadId.make("a1b2c3d4-scratch-thread"); - yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-scratch-turn-start"), - threadId, - message: { - messageId: MessageId.make("msg-scratch"), - role: "user", - text: "Convert these PNGs to WebP, please!", - attachments: [], - }, - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - bootstrap: { - createThread: { - projectId: scratchProjectId, - title: "New thread", - modelSelection: defaultModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt, + // The second id shares the first's short prefix; the third tries to + // climb out of the Scratch root. + const ids = ["a1b2c3d4-scratch-thread", "a1b2c3d4-other", "../../escape"]; + for (const [index, id] of ids.entries()) { + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-scratch-turn-start-${index}`), + threadId: ThreadId.make(id), + message: { + messageId: MessageId.make(`msg-scratch-${index}`), + role: "user", + text: "Convert these PNGs to WebP, please!", + attachments: [], }, - }, - createdAt, - }); + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: scratchProjectId, + title: "New thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + }, + createdAt, + }); + } }), ), ); // The date is the server's receipt time, not the client's createdAt. - const folder = created[0] ?? ""; - assert.equal(path.dirname(folder), scratchRoot); - assert.match( - path.basename(folder), - /^\d{4}-\d{2}-\d{2}-convert-these-pngs-to-webp-a1b2c3d4$/, - ); - assert.isTrue(yield* fileSystem.exists(folder)); + const names = created.map((folder) => path.basename(folder ?? "")); + assert.match(names[0] ?? "", /^\d{4}-\d{2}-\d{2}-convert-these-pngs-to-webp-a1b2c3d4$/); + assert.match(names[1] ?? "", /-convert-these-pngs-to-webp-a1b2c3d4other$/); + assert.match(names[2] ?? "", /-convert-these-pngs-to-webp-escape$/); + for (const folder of created) { + assert.equal(path.dirname(folder ?? ""), scratchRoot); + assert.isTrue(yield* fileSystem.exists(folder ?? "")); + } }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9be771ce1b26..0e169def8a65 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1831,15 +1831,25 @@ const makeWsRpcLayer = ( ) { return null; } + // Only [a-z0-9] reaches the name, so it stays one path segment + // inside the Scratch root. A short id is tried first; a taken name + // falls back to the full id so two threads never share a folder. const words = input.text .toLowerCase() .split(/[^a-z0-9]+/) .filter(Boolean) .slice(0, 5); - const folder = path.join( - scratchRoot, - [input.createdAt.slice(0, 10), ...words, input.threadId.slice(0, 8)].join("-"), - ); + const id = input.threadId.toLowerCase().replace(/[^a-z0-9]/g, ""); + const folderFor = (idPart: string) => + path.join( + scratchRoot, + [input.createdAt.slice(0, 10), ...words, idPart].filter(Boolean).join("-"), + ); + const shortFolder = folderFor(id.slice(0, 8)); + const shortTaken = yield* fileSystem + .exists(shortFolder) + .pipe(Effect.orElseSucceed(() => false)); + const folder = shortTaken ? folderFor(id) : shortFolder; yield* fileSystem.makeDirectory(folder, { recursive: true }).pipe( Effect.mapError( (cause) => From 3fb67f3d6f49beaa078156f2c71aebff2fd9a4a1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 21:16:52 -0700 Subject: [PATCH 09/14] fix(server): report a failed Scratch folder check instead of guessing Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/ws.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0e169def8a65..66715fbafebb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1846,9 +1846,15 @@ const makeWsRpcLayer = ( [input.createdAt.slice(0, 10), ...words, idPart].filter(Boolean).join("-"), ); const shortFolder = folderFor(id.slice(0, 8)); - const shortTaken = yield* fileSystem - .exists(shortFolder) - .pipe(Effect.orElseSucceed(() => false)); + const shortTaken = yield* fileSystem.exists(shortFolder).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to check the Scratch thread folder.", + cause, + }), + ), + ); const folder = shortTaken ? folderFor(id) : shortFolder; yield* fileSystem.makeDirectory(folder, { recursive: true }).pipe( Effect.mapError( From 62a744b8cd2a24978817792b5f50e068436d54d2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 13:52:07 -0700 Subject: [PATCH 10/14] feat: call it "No project" and add a No project picker option Scratch was too loud a name. Threads now start "without a project": - The project is titled "No project", and every label, error, and doc says "no project". The folder stays ~/.t3/scratch. - The draft heading menu and the palette's "New thread in..." list get a "No project" option. A no-project draft reads "What should we work on?" with the picker below to choose a project. - The hotkey is chat.newWithoutProject (mod+alt+n), shown in a tooltip on the "or start without a project" link. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/NewTaskRouteScreen.tsx | 26 ++-- apps/server/src/server.test.ts | 2 +- apps/server/src/ws.ts | 12 +- apps/web/src/components/CommandPalette.tsx | 46 ++++++-- apps/web/src/components/NoProjectsHero.tsx | 4 +- .../src/components/chat/DraftHeroHeadline.tsx | 111 +++++++++++++----- apps/web/src/hooks/useScratchProject.ts | 11 +- apps/web/src/routes/_chat.tsx | 2 +- docs/user/keybindings.md | 4 +- docs/user/thread-sidebar.md | 28 ++--- packages/contracts/src/keybindings.ts | 2 +- packages/shared/src/keybindings.ts | 2 +- 12 files changed, 163 insertions(+), 87 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index e5c3ffbd0f44..7a38796d1064 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -234,16 +234,18 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {canStartScratch ? ( void startScratch()} /> @@ -370,7 +372,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void startScratch()} > - Start in Scratch + + Start without a project +
) : null} @@ -477,8 +481,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void startScratch()} leading={ void startScratch()} className="flex-row items-center gap-3 bg-card px-4 py-3.5" > @@ -507,9 +511,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps
- Scratch + No project - Start a task without a repository + Start a task without a project { const ensure = yield* Effect.flip(client[WS_METHODS.projectsEnsureScratch]({})); assert.isUndefined(config.scratchWorkspaceRoot); - assert.include(String(ensure.message), "Scratch is not available"); + assert.include(String(ensure.message), "not available"); }), ), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 66715fbafebb..4914e65c5e44 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1850,7 +1850,7 @@ const makeWsRpcLayer = ( Effect.mapError( (cause) => new OrchestrationDispatchCommandError({ - message: "Failed to check the Scratch thread folder.", + message: "Failed to check the thread's folder.", cause, }), ), @@ -1860,7 +1860,7 @@ const makeWsRpcLayer = ( Effect.mapError( (cause) => new OrchestrationDispatchCommandError({ - message: "Failed to create the Scratch thread folder.", + message: "Failed to create the thread's folder.", cause, }), ), @@ -1939,14 +1939,14 @@ const makeWsRpcLayer = ( const workspaceRoot = yield* resolveScratchWorkspaceRoot; if (workspaceRoot === undefined) { return yield* new OrchestrationDispatchCommandError({ - message: "Scratch is not available on this environment.", + message: "Threads without a project are not available on this environment.", }); } yield* fileSystem.makeDirectory(workspaceRoot, { recursive: true }).pipe( Effect.mapError( (cause) => new OrchestrationDispatchCommandError({ - message: "Failed to create the Scratch folder.", + message: "Failed to create the folder for threads without a project.", cause, }), ), @@ -1958,7 +1958,7 @@ const makeWsRpcLayer = ( Effect.mapError( (cause) => new OrchestrationDispatchCommandError({ - message: "Failed to look up the Scratch project.", + message: "Failed to look up the home for threads without a project.", cause, }), ), @@ -1973,7 +1973,7 @@ const makeWsRpcLayer = ( type: "project.create", commandId: yield* serverCommandId("scratch-project-create"), projectId, - title: "Scratch", + title: "No project", workspaceRoot, createdAt: yield* nowIso, }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ecedc7e32a1f..a01a4c180e37 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -97,6 +97,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { useThreadSearch } from "../state/queries"; @@ -710,7 +711,7 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); - const { scratchEnvironmentId, startScratchThread } = useScratchProject(); + const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -1101,6 +1102,11 @@ function OpenCommandPaletteDialog(props: { const currentProjectEnvironmentId = activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + // Where "without a project" threads start: the current environment when it + // offers them, otherwise the first connected one that does. + const scratchTargetEnvironmentId = scratchEnvironmentId( + currentProjectEnvironmentId ?? primaryEnvironmentId, + ); const currentProjectCwd = currentProjectId ? (projectCwdById.get(currentProjectId) ?? null) : null; @@ -1271,9 +1277,12 @@ function OpenCommandPaletteDialog(props: { const projectThreadItems = useMemo( () => - enumerateCommandPaletteItems( - buildProjectActionItems({ - projects: pickerProjects, + enumerateCommandPaletteItems([ + ...buildProjectActionItems({ + // The no-project home shows once, as the "No project" item below. + projects: pickerProjects.filter( + (project) => !isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)), + ), valuePrefix: "new-thread-in", searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); @@ -1324,13 +1333,29 @@ function OpenCommandPaletteDialog(props: { ); }, }), - ), + ...(scratchTargetEnvironmentId === null + ? [] + : [ + { + kind: "action" as const, + value: "new-thread-in:no-project", + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + }, + ]), + ]), [ contextualProjectRef, handleNewThread, pickerProjects, projectEnvironmentLocationById, projectGroupByTargetKey, + scratchTargetEnvironmentId, + scratchWorkspaceRootFor, + startScratchThread, ], ); @@ -1788,17 +1813,14 @@ function OpenCommandPaletteDialog(props: { }); } - const scratchTargetEnvironmentId = scratchEnvironmentId( - currentProjectEnvironmentId ?? primaryEnvironmentId, - ); if (scratchTargetEnvironmentId !== null) { actionItems.push({ kind: "action", - value: "action:new-scratch-thread", - searchTerms: ["new thread", "scratch", "no project", "without project", "chat"], - title: "New thread in Scratch", + value: "action:new-thread-without-project", + searchTerms: ["new thread", "no project", "without project", "none", "chat"], + title: "New thread without a project", icon: , - shortcutCommand: "chat.newScratch", + shortcutCommand: "chat.newWithoutProject", run: () => startScratchThread(scratchTargetEnvironmentId), }); } diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index a247124dc3b2..00e044990393 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -28,7 +28,7 @@ export function NoProjectsHero() { {scratchTargetEnvironmentId === null ? "Add a project to start your first thread." - : "Add a project, or start in Scratch without one."} + : "Add a project, or start without one."}
)}
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 12813aa0d079..3d5b7bfd311d 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -3,10 +3,14 @@ import { useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; -import { FolderPlusIcon } from "lucide-react"; +import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; +import { shortcutLabelForCommand } from "~/keybindings"; +import { projectIconColorClassName } from "~/projectIconColors"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; @@ -37,6 +41,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +// Menu value for "No project"; real entries are keyed by logical project key. +const NO_PROJECT_VALUE = "no-project"; + interface DraftHeroHeadlineProps { readonly draftId: DraftId | null; readonly activeProjectRef: ScopedProjectRef | null; @@ -62,6 +69,7 @@ export function DraftHeroHeadline({ const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const environmentLabelById = useMemo( () => @@ -137,6 +145,12 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; + // The project that hosts threads without a project appears once, as the + // "No project" item, not as a project row. + const menuEntries = projectPickerEntries.filter( + ({ targetProject }) => + !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), + ); const activeProject = activeProjectRef === null ? null @@ -152,8 +166,8 @@ export function DraftHeroHeadline({ const isScratchDraft = activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot); - // The picker can change the draft's target while Scratch is still being - // opened; a stale continuation must not retarget it again. + // The picker can change the draft's target while the no-project home is + // still being opened; a stale continuation must not retarget it again. const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId }); useEffect(() => { latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; @@ -227,16 +241,22 @@ export function DraftHeroHeadline({ /> } > - {activeProjectDisplayName ?? "Choose a project"} + + {isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")} + - {activeProjectDisplayName ? ( + {activeProjectDisplayName && !isScratchDraft ? ( {activeProjectDisplayName} ) : null} { + if (value === NO_PROJECT_VALUE) { + void startScratch(); + return; + } const entry = projectEntryByKey.get(value as string); if (!entry || value === activeProjectKey) { return; @@ -244,7 +264,21 @@ export function DraftHeroHeadline({ selectProject(entry.targetProject, entry.group.projectKey); }} > - {projectPickerEntries.map(({ group }) => { + {scratchWorkspaceRoot === null ? null : ( + + + {/* Boxed like ProjectFavicon so the label lines up with project rows. */} + + No project + + + )} + {menuEntries.map(({ group }) => { return ( @@ -288,29 +322,39 @@ export function DraftHeroHeadline({ // a complete sentence too. The project picker is a control rendered inline // in the h1; without an explicit label its widget state bleeds into the // announced phrase. - const headingLabel = hasResolvedProject - ? `What should we build in ${activeProjectDisplayName}?` - : canChooseProject - ? `${activeProjectDisplayName ?? "Choose a project"} to start` - : "Add a project to start"; + const headingLabel = isScratchDraft + ? "What should we work on?" + : hasResolvedProject + ? `What should we build in ${activeProjectDisplayName}?` + : canChooseProject + ? `${activeProjectDisplayName ?? "Choose a project"} to start` + : "Add a project to start"; - // One click into Scratch, phrased as the alternative to the question above - // it. Focus moves to the project picker once this line has gone. - const orStartInScratch = + // One click out of the project, phrased as the alternative to the question + // above it. Focus moves to the project picker once this line has gone. + const noProjectShortcut = shortcutLabelForCommand(keybindings, "chat.newWithoutProject"); + const orStartWithoutProject = scratchWorkspaceRoot !== null && !isScratchDraft && (hasResolvedProject || canChooseProject) ? ( - - void startScratch().then((started) => { - if (started) { - document.querySelector("[data-draft-project-trigger]")?.focus(); - } - }) - } - > - or start in Scratch - + + + void startScratch().then((started) => { + if (started) { + document.querySelector("[data-draft-project-trigger]")?.focus(); + } + }) + } + /> + } + > + or start without a project + + {noProjectShortcut ? {noProjectShortcut} : null} + ) : null; return ( @@ -319,7 +363,9 @@ export function DraftHeroHeadline({ aria-label={headingLabel} className="w-full text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl" > - {hasResolvedProject ? ( + {isScratchDraft ? ( + <>What should we work on? + ) : hasResolvedProject ? ( <>What should we build in {projectSelector}? ) : canChooseProject ? ( <>{projectSelector} to start @@ -327,9 +373,12 @@ export function DraftHeroHeadline({ <>Add a project to start )} - {/* Reserved wherever Scratch exists so the heading does not move when the line goes. */} + {/* Reserved whenever threads can skip a project, so the heading does not + move. Without a project, the picker moves here to choose one. */} {scratchWorkspaceRoot === null ? null : ( -

{orStartInScratch}

+

+ {isScratchDraft ? projectSelector : orStartWithoutProject} +

)} ); diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index c9980f9ea6cf..0a3023285f71 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -18,23 +18,24 @@ function reportScratchFailure(error: unknown) { toastManager.add( stackedThreadToast({ type: "error", - title: "Could not open Scratch", + title: "Could not start without a project", description: error instanceof Error ? error.message : "An error occurred.", }), ); } /** - * Scratch runs threads in a plain folder the server owns instead of a - * repository. The server creates the Scratch project on first use; after that - * it is an ordinary project on the non-git path. + * Threads without a project live in the environment's scratch project, a + * plain folder the server owns (users see it as "No project"). The server + * creates it on first use; after that it is an ordinary project on the + * non-git path, and each thread gets its own subfolder. */ export function useScratchProject() { const { environments } = useEnvironments(); const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false }); const handleNewThread = useNewThreadHandler(); - /** The Scratch folder of a connected environment, or null when it offers none. */ + /** The scratch folder of a connected environment, or null when it offers none. */ const scratchWorkspaceRootFor = useCallback( (environmentId: EnvironmentId | null): string | null => { const environment = environments.find((entry) => entry.environmentId === environmentId); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 6b392b433e93..0a0b1adee06b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -107,7 +107,7 @@ function ChatRouteGlobalShortcuts() { return; } - if (command === "chat.newScratch") { + if (command === "chat.newWithoutProject") { const environmentId = scratchEnvironmentId( activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, ); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index eec94ebc11a5..1844eb62c211 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -122,8 +122,8 @@ through the pages you have visited, like a browser's back and forward buttons. `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your -[new-thread defaults](./thread-sidebar.md#start-a-thread). `chat.newScratch` -(`mod+alt+n`) starts a thread in [Scratch](./thread-sidebar.md#scratch). +[new-thread defaults](./thread-sidebar.md#start-a-thread). `chat.newWithoutProject` +(`mod+alt+n`) starts a thread [without a project](./thread-sidebar.md#start-without-a-project). ## Reserved shortcuts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index cc6c1b11170f..ae6f7198d3a7 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -13,20 +13,20 @@ an existing worktree, use **New thread in this worktree** from the branch toolba When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it. -### Scratch - -Scratch is for work that does not belong to a repository. To start there, click -**or start in Scratch** under a new thread's heading, choose **New thread in -Scratch** in the command palette, or press `mod+alt+n`. On mobile, pick -**Scratch** from the project list. To move a draft to a repository, pick the -project in the heading. - -Scratch is a project rooted in the `scratch` folder of your T3 data directory -(`~/.t3/scratch` by default). Each Scratch thread works in its own folder there, -named after its date and first message. Deleting a thread keeps its folder, so the -files the agent wrote stay until you delete them. Branch, worktree, and diff -controls stay hidden because Scratch folders are not Git repositories. Scratch is -unavailable when the data directory itself sits inside a Git checkout. +### Start without a project + +A thread does not need a project. To start one without a project, click **or +start without a project** under a new thread's heading, pick **No project** from +the project menu in that heading or from **New thread in...** in the command +palette, or press `mod+alt+n`. On mobile, pick **No project** from the project +list. To move a draft into a project, pick the project in the heading. + +Each thread without a project works in its own folder under `~/.t3/scratch` (the +`scratch` folder of your T3 data directory), named after its date and first +message. Deleting a thread keeps its folder, so the files the agent wrote stay +until you delete them. Branch, worktree, and diff controls stay hidden because +these folders are not Git repositories. This is unavailable when the data +directory itself sits inside a Git checkout. ### Start in the background diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3d64200d84d6..076c8a944933 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -91,7 +91,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.branch", "chat.new", "chat.newLocal", - "chat.newScratch", + "chat.newWithoutProject", "editor.openFavorite", "usage.cost", "usage.tokens", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index e723001748ca..a93409bb3cb4 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -49,7 +49,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, - { key: "mod+alt+n", command: "chat.newScratch", when: "!terminalFocus" }, + { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, From e8d581b01d57d922cca133499c1ba715e4d20e30 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 13:56:46 -0700 Subject: [PATCH 11/14] fix(web): sort keybinding rows with a total order The Usage-first comparator only ranked Usage page commands against each other and fell back to alphabetical otherwise, so it was not a total order. Adding the chat.newWithoutProject default reshuffled usage.open. Usage page commands now sort as one block in page order. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../KeybindingsSettings.logic.test.ts | 2 +- .../settings/KeybindingsSettings.logic.ts | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 7729ce183754..791ebccfa932 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -49,13 +49,13 @@ describe("KeybindingsSettings.logic", () => { it("orders Usage bindings and command choices like the page", () => { const expected = [ "usage.cost", - "usage.open", "usage.tokens", "usage.limits", "usage.period.day", "usage.period.week", "usage.period.month", "usage.period.quarter", + "usage.open", ]; const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed(); expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a06628e5d505..a1f445b5db82 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -19,10 +19,24 @@ const usageCommandOrder = new Map( [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), ); -function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null { - const leftIndex = usageCommandOrder.get(left); - const rightIndex = usageCommandOrder.get(right); - return leftIndex !== undefined && rightIndex !== undefined ? leftIndex - rightIndex : null; +const firstUsageCommand = METRIC_OPTIONS[0].command; + +/** + * Orders commands by `key`, except Usage page commands, which sort as one + * block in page order where the first of them would sort. A total order, so + * adding a binding elsewhere cannot reshuffle the Usage rows. + */ +function compareCommands( + left: KeybindingCommand, + right: KeybindingCommand, + key: (command: KeybindingCommand) => string, +): number { + const leftRank = usageCommandOrder.get(left); + const rightRank = usageCommandOrder.get(right); + if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank; + return key(leftRank === undefined ? left : firstUsageCommand).localeCompare( + key(rightRank === undefined ? right : firstUsageCommand), + ); } export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -215,9 +229,7 @@ export function buildKeybindingRows( }); rowsWithConflicts.sort((left, right) => { - const commandCompare = - compareUsageCommands(left.command, right.command) ?? - left.command.localeCompare(right.command); + const commandCompare = compareCommands(left.command, right.command, (command) => command); if (commandCompare !== 0) return commandCompare; return left.key.localeCompare(right.key); }); @@ -290,10 +302,7 @@ export function buildKeybindingCommandOptions( for (const binding of keybindings) { commands.add(binding.command); } - return [...commands].toSorted( - (left, right) => - compareUsageCommands(left, right) ?? commandLabel(left).localeCompare(commandLabel(right)), - ); + return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel)); } export function commandLabel(command: KeybindingCommand): string { From e20144833815fce12c2bb5668612b2f65f15ee4d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 14:00:16 -0700 Subject: [PATCH 12/14] docs: show an example no-project folder name Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/user/thread-sidebar.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ae6f7198d3a7..0335856145d3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -22,9 +22,10 @@ palette, or press `mod+alt+n`. On mobile, pick **No project** from the project list. To move a draft into a project, pick the project in the heading. Each thread without a project works in its own folder under `~/.t3/scratch` (the -`scratch` folder of your T3 data directory), named after its date and first -message. Deleting a thread keeps its folder, so the files the agent wrote stay -until you delete them. Branch, worktree, and diff controls stay hidden because +`scratch` folder of your T3 data directory), named after its date, the first words +of its first message, and a short id, like +`2026-09-25-convert-these-pngs-to-webp-a1b2c3d4`. Deleting a thread keeps its +folder, so the files the agent wrote stay until you delete them. Branch, worktree, and diff controls stay hidden because these folders are not Git repositories. This is unavailable when the data directory itself sits inside a Git checkout. From 8f24030b9ce4b8f800969d33509f83d12ce66daa Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 19:49:21 -0700 Subject: [PATCH 13/14] fix: harden no-project folders and keep them on the current machine - Folder names cap the words at 48 characters, so a pasted token cannot exceed the file name limit. - Each folder is claimed by creating it without `recursive`, so two starts can no longer race into the same folder. - "No project" only starts on the machine in use. With no current machine, it starts on the one machine that offers it; mobile names the machine when more than one is connected. - Mobile hides the workspace controls for no-project drafts, which are always local. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/NewTaskDraftScreen.tsx | 2 +- .../features/threads/NewTaskRouteScreen.tsx | 21 +++++-- .../threads/new-task-flow-provider.tsx | 19 +++++-- apps/server/src/server.test.ts | 17 ++++-- apps/server/src/ws.ts | 56 ++++++++++--------- apps/web/src/hooks/useScratchProject.ts | 19 ++++--- 6 files changed, 84 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7e90b0743374..12f296a15e69 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1598,7 +1598,7 @@ export function NewTaskDraftScreen(props: { /> ) : null} - {workspaceControls} + {flow.canChooseWorkspace ? {workspaceControls} : null} {modelUnavailable ? ( canCreateProjectInEnvironment(environment.connectionState) && @@ -191,6 +192,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps 1 ? (scratchEnvironment?.environmentLabel ?? null) : null; + const startScratchLabel = scratchMachineLabel + ? `Start without a project on ${scratchMachineLabel}` + : "Start without a project"; + const scratchRowSubtitle = scratchMachineLabel + ? `On ${scratchMachineLabel}` + : "Start a task without a project"; const scratchStartInFlightRef = useRef(false); async function selectProject(project: EnvironmentProject): Promise { @@ -342,7 +351,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {canStartScratch ? ( void startScratch()} /> @@ -373,7 +382,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void startScratch()} > - Start without a project + {startScratchLabel} ) : null} @@ -482,7 +491,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps void startScratch()} leading={ No project - Start a task without a project + {scratchRowSubtitle} { scratchRoot = (yield* client[WS_METHODS.serverGetConfig]({})).scratchWorkspaceRoot ?? ""; const createdAt = "2026-09-25T10:00:00.000Z"; - // The second id shares the first's short prefix; the third tries to - // climb out of the Scratch root. - const ids = ["a1b2c3d4-scratch-thread", "a1b2c3d4-other", "../../escape"]; - for (const [index, id] of ids.entries()) { + // The second id shares the first's short prefix, the third tries to + // climb out of the scratch root, and the fourth pastes a long token. + const text = "Convert these PNGs to WebP, please!"; + const starts = [ + { id: "a1b2c3d4-scratch-thread", text }, + { id: "a1b2c3d4-other", text }, + { id: "../../escape", text }, + { id: "f00dcafe-long", text: "x".repeat(300) }, + ]; + for (const [index, { id, text: messageText }] of starts.entries()) { yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", commandId: CommandId.make(`cmd-scratch-turn-start-${index}`), @@ -5487,7 +5493,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { message: { messageId: MessageId.make(`msg-scratch-${index}`), role: "user", - text: "Convert these PNGs to WebP, please!", + text: messageText, attachments: [], }, modelSelection: defaultModelSelection, @@ -5517,6 +5523,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.match(names[0] ?? "", /^\d{4}-\d{2}-\d{2}-convert-these-pngs-to-webp-a1b2c3d4$/); assert.match(names[1] ?? "", /-convert-these-pngs-to-webp-a1b2c3d4other$/); assert.match(names[2] ?? "", /-convert-these-pngs-to-webp-escape$/); + assert.match(names[3] ?? "", /^\d{4}-\d{2}-\d{2}-x{48}-f00dcafe$/); for (const folder of created) { assert.equal(path.dirname(folder ?? ""), scratchRoot); assert.isTrue(yield* fileSystem.exists(folder ?? "")); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4914e65c5e44..f5598a28c5e9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1831,41 +1831,47 @@ const makeWsRpcLayer = ( ) { return null; } - // Only [a-z0-9] reaches the name, so it stays one path segment - // inside the Scratch root. A short id is tried first; a taken name - // falls back to the full id so two threads never share a folder. + // Only [a-z0-9] reaches the name, so it stays one path segment inside + // the scratch root, and the words are capped so pasted data cannot + // outgrow a file name. Each leaf is created without `recursive`, so + // the create itself claims it: a taken short name falls back to the + // full id, which only the same thread can already hold. const words = input.text .toLowerCase() .split(/[^a-z0-9]+/) .filter(Boolean) - .slice(0, 5); + .slice(0, 5) + .join("-") + .slice(0, 48) + .replace(/-+$/, ""); const id = input.threadId.toLowerCase().replace(/[^a-z0-9]/g, ""); const folderFor = (idPart: string) => path.join( scratchRoot, - [input.createdAt.slice(0, 10), ...words, idPart].filter(Boolean).join("-"), + [input.createdAt.slice(0, 10), words, idPart].filter(Boolean).join("-"), + ); + const toFolderError = (cause: unknown) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the thread's folder.", + cause, + }); + yield* fileSystem + .makeDirectory(scratchRoot, { recursive: true }) + .pipe(Effect.mapError(toFolderError)); + const claim = (folder: string) => + fileSystem.makeDirectory(folder).pipe( + Effect.as(true), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(false), + ), + Effect.mapError(toFolderError), ); const shortFolder = folderFor(id.slice(0, 8)); - const shortTaken = yield* fileSystem.exists(shortFolder).pipe( - Effect.mapError( - (cause) => - new OrchestrationDispatchCommandError({ - message: "Failed to check the thread's folder.", - cause, - }), - ), - ); - const folder = shortTaken ? folderFor(id) : shortFolder; - yield* fileSystem.makeDirectory(folder, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new OrchestrationDispatchCommandError({ - message: "Failed to create the thread's folder.", - cause, - }), - ), - ); - return folder; + if (yield* claim(shortFolder)) return shortFolder; + const fullFolder = folderFor(id); + yield* claim(fullFolder); + return fullFolder; }); const withScratchThreadFolder = ( command: OrchestrationCommand, diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index 0a3023285f71..0d25f3385855 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -46,18 +46,21 @@ export function useScratchProject() { [environments], ); - // The hosted web app has no primary environment, so Scratch falls back to - // the first connected environment that offers it. + // A thread without a project starts on the machine the user is working on, + // and only there. With no current machine (the hosted app with nothing + // open), it starts on the one machine that offers it, never a silent pick. const scratchEnvironmentId = useCallback( - (preferred: EnvironmentId | null): EnvironmentId | null => - scratchWorkspaceRootFor(preferred) !== null - ? preferred - : (environments.find((entry) => scratchWorkspaceRootFor(entry.environmentId) !== null) - ?.environmentId ?? null), + (current: EnvironmentId | null): EnvironmentId | null => { + if (current !== null) return scratchWorkspaceRootFor(current) !== null ? current : null; + const offering = environments.filter( + (entry) => scratchWorkspaceRootFor(entry.environmentId) !== null, + ); + return offering.length === 1 ? (offering[0]?.environmentId ?? null) : null; + }, [environments, scratchWorkspaceRootFor], ); - /** Resolves to the Scratch project once it is in this client's store. */ + /** Resolves to the scratch project once it is in this client's store. */ const openScratchProject = useCallback( async (environmentId: EnvironmentId): Promise => { const result = await ensureScratch({ environmentId, input: {} }); From 8afa3977be8f867a36e7b1bf2b72fe906c7e372a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 19:56:59 -0700 Subject: [PATCH 14/14] fix: drop stale worktree choices for no-project tasks on mobile Queued mobile tasks read the draft's saved workspace choice first, so a choice made before the draft became no-project could still ask for a worktree. No-project drafts now ignore it. Also builds the folder errors where they happen instead of through a helper. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../threads/new-task-flow-provider.tsx | 5 +++- apps/server/src/ws.ts | 25 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 9e5d9c9bce39..a723c2fa29f6 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -984,7 +984,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (text.length === 0 || !draftModelSelection) { return null; } - const workspaceSelection = draft.workspaceSelection; + // A saved choice from before the project went no-project must not + // survive: those threads always run locally in their own folder. + const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined; // Fall back to the resolved mode (server default) so queued tasks drain // with the same mode the composer displayed. const mode = workspaceSelection?.mode ?? workspaceMode; @@ -1044,6 +1046,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }; }, [ + canChooseWorkspace, defaultRuntimeMode, editingPendingProject, editingPendingTask, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f5598a28c5e9..e9f5dc4d5d8f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1850,14 +1850,15 @@ const makeWsRpcLayer = ( scratchRoot, [input.createdAt.slice(0, 10), words, idPart].filter(Boolean).join("-"), ); - const toFolderError = (cause: unknown) => - new OrchestrationDispatchCommandError({ - message: "Failed to create the thread's folder.", - cause, - }); - yield* fileSystem - .makeDirectory(scratchRoot, { recursive: true }) - .pipe(Effect.mapError(toFolderError)); + yield* fileSystem.makeDirectory(scratchRoot, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the folder for threads without a project.", + cause, + }), + ), + ); const claim = (folder: string) => fileSystem.makeDirectory(folder).pipe( Effect.as(true), @@ -1865,7 +1866,13 @@ const makeWsRpcLayer = ( (error) => error.reason._tag === "AlreadyExists", () => Effect.succeed(false), ), - Effect.mapError(toFolderError), + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create the thread's folder.", + cause, + }), + ), ); const shortFolder = folderFor(id.slice(0, 8)); if (yield* claim(shortFolder)) return shortFolder;