diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3ec717cc24a6f..50c3452404344 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -622,9 +622,7 @@ function OpenCommandPaletteDialog(props: { const currentProjectEnvironmentId = activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; - const scratchTargetEnvironmentId = scratchEnvironmentId( - currentProjectEnvironmentId ?? primaryEnvironmentId, - ); + const scratchTargetEnvironmentId = scratchEnvironmentId(currentProjectEnvironmentId); const supportsTriage = (environmentId: EnvironmentId | null) => environments.find((environment) => environment.environmentId === environmentId)?.serverConfig ?.environment.capabilities.triage === true; diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 8c0ce81b52991..4a8a894bedbb6 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -143,12 +143,11 @@ export function DraftHeroHeadline({ project.environmentId === activeProjectRef.environmentId && project.id === activeProjectRef.projectId, ) ?? null); - const scratchTargetEnvironmentId = scratchEnvironmentId( - activeProjectRef?.environmentId ?? primaryEnvironmentId, - ); + const scratchTargetEnvironmentId = scratchEnvironmentId(activeProjectRef?.environmentId ?? null); const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId); const isScratchDraft = - activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot); + activeProject !== null && + isScratchProject(activeProject, scratchWorkspaceRootFor(activeProject.environmentId)); const pickerItems: readonly PickerItem[] = [ ...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })), diff --git a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx index 60b114fe2382f..f8e76b565d6b9 100644 --- a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx +++ b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx @@ -175,8 +175,7 @@ export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) { }; const openScratchDraft = async () => { - const environmentId = - scratchEnvironmentId(lastActiveEnvironmentId()) ?? scratchEnvironmentId(null); + const environmentId = scratchEnvironmentId(lastActiveEnvironmentId()); const project = environmentId === null ? null : await openScratchProject(environmentId); if (project === null) { setState({ diff --git a/apps/web/src/hooks/useScratchProject.test.ts b/apps/web/src/hooks/useScratchProject.test.ts new file mode 100644 index 0000000000000..103f66ba5077f --- /dev/null +++ b/apps/web/src/hooks/useScratchProject.test.ts @@ -0,0 +1,49 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { createScratchProjectActions } from "./useScratchProject"; + +const local = "local" as EnvironmentId; +const remote = "remote" as EnvironmentId; +const other = "other" as EnvironmentId; + +function scratchEnvironment(environmentId: EnvironmentId, scratchWorkspaceRoot: string | null) { + return { + environmentId, + connection: { phase: "connected" }, + serverConfig: { scratchWorkspaceRoot }, + } as never; +} + +function actions( + primary: EnvironmentId | null, + localRoot: string | null, + remoteRoot: string | null = "/remote/scratch", +) { + const environments = [ + scratchEnvironment(local, localRoot), + scratchEnvironment(remote, remoteRoot), + scratchEnvironment(other, "/other/scratch"), + ]; + return createScratchProjectActions({ + environments: () => environments, + primaryEnvironmentId: () => primary, + registry: AtomRegistry.make(), + handleNewThread: async () => null, + }); +} + +describe("scratchEnvironmentId", () => { + it("prefers the primary environment over the one on screen", () => { + expect(actions(local, "/local/scratch").scratchEnvironmentId(remote)).toBe(local); + expect(actions(local, "/local/scratch").scratchEnvironmentId(null)).toBe(local); + }); + + it("falls back when the primary environment offers no scratch root", () => { + expect(actions(local, null).scratchEnvironmentId(remote)).toBe(remote); + expect(actions(null, null).scratchEnvironmentId(remote)).toBe(remote); + expect(actions(null, null, null).scratchEnvironmentId(remote)).toBe(other); + expect(actions(local, null).scratchEnvironmentId(null)).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index 936da40965981..9c46302351856 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -12,7 +12,11 @@ import type { AtomRegistry } from "effect/reactivity"; import { useContext } from "react"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { type EnvironmentPresentation, useEnvironments } from "~/state/environments"; +import { + type EnvironmentPresentation, + useEnvironments, + usePrimaryEnvironmentId, +} from "~/state/environments"; import { projectEnvironment } from "~/state/projects"; import { type NewThreadHandler, useNewThreadHandler } from "./useHandleNewThread"; @@ -32,6 +36,7 @@ type ScratchEnvironment = Pick Iterable; + readonly primaryEnvironmentId: () => EnvironmentId | null; readonly registry: AtomRegistry.AtomRegistry; readonly handleNewThread: NewThreadHandler; }) { @@ -43,8 +48,11 @@ export function createScratchProjectActions(input: { return availableScratchWorkspaceRoot(environment?.connection.phase, environment?.serverConfig); }; - const scratchEnvironmentId = (current: EnvironmentId | null): EnvironmentId | null => { - if (current !== null) return scratchWorkspaceRootFor(current) !== null ? current : null; + // The primary (local) environment wins; `fallback` only applies when it offers no scratch root. + const scratchEnvironmentId = (fallback: EnvironmentId | null): EnvironmentId | null => { + const primary = input.primaryEnvironmentId(); + if (primary !== null && scratchWorkspaceRootFor(primary) !== null) return primary; + if (fallback !== null && scratchWorkspaceRootFor(fallback) !== null) return fallback; const offering = [...input.environments()].filter( (entry) => scratchWorkspaceRootFor(entry.environmentId) !== null, ); @@ -85,10 +93,12 @@ export function createScratchProjectActions(input: { export function useScratchProject() { const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const registry = useContext(RegistryContext); const handleNewThread = useNewThreadHandler(); return createScratchProjectActions({ environments: () => environments, + primaryEnvironmentId: () => primaryEnvironmentId, registry, handleNewThread, }); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index 7851dcb3224e9..e832ca295284e 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -39,6 +39,7 @@ export function installChatRouteCommands(router: AnyRouter): () => void { [...appAtomRegistry.get(environmentPresentations.presentationsAtom)].map( ([environmentId, presentation]) => ({ ...presentation, environmentId }), ), + primaryEnvironmentId: () => appAtomRegistry.get(primaryEnvironmentIdAtom), registry: appAtomRegistry, handleNewThread, }); @@ -109,9 +110,7 @@ export function installChatRouteCommands(router: AnyRouter): () => void { "chat.newWithoutProject": (event) => { const { activeThread, activeDraftThread } = readThreadContext(); const environmentId = scratch.scratchEnvironmentId( - activeThread?.environmentId ?? - activeDraftThread?.environmentId ?? - appAtomRegistry.get(primaryEnvironmentIdAtom), + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null, ); if (environmentId === null) return; claim(event);