From c2969e416e6ad7346bca49c963050e15ae3715f0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 3 Oct 2026 22:00:55 +0000 Subject: [PATCH 1/6] fix(web): no-project drafts can switch machines --- .../web/src/components/BranchToolbar.logic.ts | 3 +- apps/web/src/components/ChatView.tsx | 101 +++++++++++++----- 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index cfa62ad7fe92..d018802a590a 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -16,7 +16,8 @@ export { export interface EnvironmentOption { environmentId: EnvironmentId; - projectId: ProjectId; + /** Null when the machine's "No project" folder is not created yet. */ + projectId: ProjectId | null; label: string; isPrimary: boolean; machine: EnvironmentMachineKind; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92400fedd9b3..bbd7fd4185e0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,6 +22,8 @@ import { rememberCheckoutIsRepo, } from "./ChatView.logic"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; +import { useScratchProject } from "../hooks/useScratchProject"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { latestExecutedRun, @@ -2642,26 +2644,47 @@ export default function ChatView(props: ChatViewProps) { }, [navigate, setEnvironmentEnabled], ); + const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const activeProjectIsScratch = + activeProject !== null && + isScratchProject(activeProject, scratchWorkspaceRootFor(activeProject.environmentId)); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); - const memberProjects = allProjects.filter( - (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, - ); - const seen = new Set(); const envs: EnvironmentOption[] = []; - for (const p of memberProjects) { - if (seen.has(p.environmentId)) continue; - seen.add(p.environmentId); - const isPrimary = p.environmentId === primaryEnvironmentId; - const environment = environmentById.get(p.environmentId) ?? null; + const pushEnvironment = (environmentId: EnvironmentId, projectId: ProjectId | null) => { + const environment = environmentById.get(environmentId) ?? null; envs.push({ - environmentId: p.environmentId, - projectId: p.id, - label: environment?.label ?? p.environmentId, - isPrimary, + environmentId, + projectId, + label: environment?.label ?? environmentId, + isPrimary: environmentId === primaryEnvironmentId, machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); + }; + if (activeProjectIsScratch) { + // Each machine keeps its own "No project" folder at its own path, so they + // never group as one logical project. Offer every machine that has one. + for (const environment of environments) { + const scratchRoot = scratchWorkspaceRootFor(environment.environmentId); + if (scratchRoot === null) continue; + const scratchProject = + environment.environmentId === activeProject.environmentId + ? activeProject + : allProjects.find( + (p) => + p.environmentId === environment.environmentId && isScratchProject(p, scratchRoot), + ); + pushEnvironment(environment.environmentId, scratchProject?.id ?? null); + } + } else { + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const seen = new Set(); + for (const p of allProjects) { + if (seen.has(p.environmentId)) continue; + if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey) continue; + seen.add(p.environmentId); + pushEnvironment(p.environmentId, p.id); + } } // Sort: primary first, then alphabetical envs.sort((a, b) => { @@ -2669,8 +2692,20 @@ export default function ChatView(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); + }, [ + activeProject, + activeProjectIsScratch, + allProjects, + environments, + projectGroupingSettings, + primaryEnvironmentId, + environmentById, + scratchWorkspaceRootFor, + ]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + // Auto balance retargets to an existing project; a machine's "No project" + // folder may not exist until it is picked. + const canAutoBalanceEnvironments = hasMultipleEnvironments && !activeProjectIsScratch; const activeEnvironmentOption = logicalProjectEnvironments.find( (environment) => environment.environmentId === activeThread?.environmentId, @@ -2889,7 +2924,7 @@ export default function ChatView(props: ChatViewProps) { clientSettingsHydrated && draftId && !envLocked && - hasMultipleEnvironments && + canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled && draftThread?.environmentSelection !== "manual" && (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && @@ -4191,7 +4226,7 @@ export default function ChatView(props: ChatViewProps) { const target = logicalProjectEnvironments.find( (environment) => environment.environmentId === loadBalancing.environmentId, ); - if (!target) return; + if (!target?.projectId) return; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), environmentSelection: "auto", @@ -4244,6 +4279,9 @@ export default function ChatView(props: ChatViewProps) { : "Auto balance" : undefined; + // The last machine picked, so a slow "No project" setup cannot override a + // later pick. + const requestedEnvironmentIdRef = useRef(null); // Handle environment change for draft threads. When the user picks a // different environment we update the draft context to point at the physical // project in that environment while keeping the same logical project. @@ -4254,13 +4292,25 @@ export default function ChatView(props: ChatViewProps) { (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "manual", - loadBalancedEnvironmentId: null, + requestedEnvironmentIdRef.current = target.environmentId; + const retarget = (projectId: ProjectId) => + setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(target.environmentId, projectId), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + if (target.projectId !== null) { + retarget(target.projectId); + return; + } + // The server creates a machine's "No project" folder on first use. + void openScratchProject(target.environmentId).then((project) => { + if (project && requestedEnvironmentIdRef.current === target.environmentId) { + retarget(project.id); + } }); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [draftId, envLocked, logicalProjectEnvironments, openScratchProject, setDraftThreadContext], ); const activeTerminalGroup = @@ -10439,7 +10489,10 @@ export default function ChatView(props: ChatViewProps) { availableEnvironments: logicalProjectEnvironments, autoEnvironmentLabel, onAutoEnvironment: - draftId && !envLocked && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled + draftId && + !envLocked && + canAutoBalanceEnvironments && + loadBalancingSettings.loadBalancingEnabled ? onAutoEnvironment : undefined, onEnvironmentChange, @@ -11085,7 +11138,7 @@ export default function ChatView(props: ChatViewProps) { onAutoEnvironment={ draftId && !envLocked && - hasMultipleEnvironments && + canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled ? onAutoEnvironment : undefined From 143e2adcc9c6c42c1d051783dbafe33c2bd9bcf9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 3 Oct 2026 22:21:32 +0000 Subject: [PATCH 2/6] fix(web): guard pending scratch machine changes --- apps/web/src/components/ChatView.tsx | 118 +++++++++++++++++++-------- 1 file changed, 85 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bbd7fd4185e0..0de066ba5941 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2677,11 +2677,15 @@ export default function ChatView(props: ChatViewProps) { pushEnvironment(environment.environmentId, scratchProject?.id ?? null); } } else { - const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const logicalKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); const seen = new Set(); for (const p of allProjects) { if (seen.has(p.environmentId)) continue; - if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey) continue; + if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey) + continue; seen.add(p.environmentId); pushEnvironment(p.environmentId, p.id); } @@ -4279,38 +4283,83 @@ export default function ChatView(props: ChatViewProps) { : "Auto balance" : undefined; - // The last machine picked, so a slow "No project" setup cannot override a - // later pick. - const requestedEnvironmentIdRef = useRef(null); - // Handle environment change for draft threads. When the user picks a - // different environment we update the draft context to point at the physical - // project in that environment while keeping the same logical project. + const environmentChangeRef = useRef(null); + const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false); + useLayoutEffect(() => { + return () => { + environmentChangeRef.current = null; + setIsEnvironmentChanging(false); + }; + }, [draftId, activeProjectKey]); + const onEnvironmentChange = useCallback( (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId) return; + if (envLocked || !draftId || sendInFlightRef.current) return; + const originalDraft = getDraftSession(draftId); + if (!originalDraft || originalDraft.promotedTo) return; const target = logicalProjectEnvironments.find( (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; - requestedEnvironmentIdRef.current = target.environmentId; - const retarget = (projectId: ProjectId) => - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, projectId), - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); + const request = Symbol(); + environmentChangeRef.current = request; + setIsEnvironmentChanging(false); + const retarget = (project: (typeof allProjects)[number]) => { + const currentDraft = getDraftSession(draftId); + if ( + environmentChangeRef.current !== request || + sendInFlightRef.current || + !currentDraft || + currentDraft.promotedTo || + currentDraft.environmentId !== originalDraft.environmentId || + currentDraft.projectId !== originalDraft.projectId + ) + return; + // Scratch projects are machine-local, so move their logical mapping too. + setLogicalProjectDraftThreadId( + activeProjectIsScratch + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) + : originalDraft.logicalProjectKey, + scopeProjectRef(target.environmentId, project.id), + draftId, + { environmentSelection: "manual", loadBalancedEnvironmentId: null }, + ); + }; + const finish = () => { + if (environmentChangeRef.current === request) { + environmentChangeRef.current = null; + setIsEnvironmentChanging(false); + } + }; if (target.projectId !== null) { - retarget(target.projectId); + const project = allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ); + if (project) retarget(project); + finish(); return; } - // The server creates a machine's "No project" folder on first use. - void openScratchProject(target.environmentId).then((project) => { - if (project && requestedEnvironmentIdRef.current === target.environmentId) { - retarget(project.id); - } - }); + // Keep send disabled until the destination Scratch project is ready. + setIsEnvironmentChanging(true); + void openScratchProject(target.environmentId) + .then((project) => { + if (project) retarget(project); + }) + .finally(finish); }, - [draftId, envLocked, logicalProjectEnvironments, openScratchProject, setDraftThreadContext], + [ + activeProjectIsScratch, + allProjects, + draftId, + envLocked, + getDraftSession, + logicalProjectEnvironments, + openScratchProject, + projectGroupingSettings, + sendInFlightRef, + setLogicalProjectDraftThreadId, + ], ); const activeTerminalGroup = @@ -8181,6 +8230,7 @@ export default function ChatView(props: ChatViewProps) { isRevertingCheckpoint || !clientSettingsHydrated || threadDetailLoading || + environmentChangeRef.current !== null || sendInFlightRef.current || feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { @@ -10949,15 +10999,17 @@ export default function ChatView(props: ChatViewProps) { canResume={resumableRunId !== null || hasHeldQueuedRuns} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ - isRevertingCheckpoint - ? "Rewinding conversation" - : feedbackUploading - ? "Sending feedback" - : threadDetailLoading - ? "Messages loading" - : worktreeSetupBlocksSend - ? "Preparing worktree" - : projectCloneSendBlockReason + isEnvironmentChanging + ? "Preparing machine" + : isRevertingCheckpoint + ? "Rewinding conversation" + : feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} queuedRunsControl={ From daf38a56fdc31c1c847e2a114be500137fb0d562 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 3 Oct 2026 22:28:31 +0000 Subject: [PATCH 3/6] fix(web): preserve repository draft attachment references --- apps/web/src/components/ChatView.tsx | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0de066ba5941..379ec57bc805 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4315,15 +4315,22 @@ export default function ChatView(props: ChatViewProps) { currentDraft.projectId !== originalDraft.projectId ) return; - // Scratch projects are machine-local, so move their logical mapping too. - setLogicalProjectDraftThreadId( - activeProjectIsScratch - ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) - : originalDraft.logicalProjectKey, - scopeProjectRef(target.environmentId, project.id), - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); + const projectRef = scopeProjectRef(target.environmentId, project.id); + if (activeProjectIsScratch) { + // Scratch projects are machine-local, so move their logical mapping too. + setLogicalProjectDraftThreadId( + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), + projectRef, + draftId, + { environmentSelection: "manual", loadBalancedEnvironmentId: null }, + ); + } else { + setDraftThreadContext(draftId, { + projectRef, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + } }; const finish = () => { if (environmentChangeRef.current === request) { @@ -4358,6 +4365,7 @@ export default function ChatView(props: ChatViewProps) { openScratchProject, projectGroupingSettings, sendInFlightRef, + setDraftThreadContext, setLogicalProjectDraftThreadId, ], ); From abfe4fdf021266f64f38777c7011e5d58f2b3b02 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 3 Oct 2026 22:32:26 +0000 Subject: [PATCH 4/6] fix(web): retain scratch identity while disconnected --- apps/web/src/components/ChatView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 379ec57bc805..c7038f583c14 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2647,7 +2647,10 @@ export default function ChatView(props: ChatViewProps) { const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); const activeProjectIsScratch = activeProject !== null && - isScratchProject(activeProject, scratchWorkspaceRootFor(activeProject.environmentId)); + isScratchProject( + activeProject, + environmentById.get(activeProject.environmentId)?.serverConfig?.scratchWorkspaceRoot ?? null, + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const envs: EnvironmentOption[] = []; From fd6ba725373b7ddf969b6545fa370d62353aa82a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 3 Oct 2026 22:49:58 +0000 Subject: [PATCH 5/6] fix(web): limit scratch machine choices to drafts --- apps/web/src/components/ChatView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c7038f583c14..f7957d6d2937 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2664,7 +2664,7 @@ export default function ChatView(props: ChatViewProps) { machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); }; - if (activeProjectIsScratch) { + if (activeProjectIsScratch && draftId) { // Each machine keeps its own "No project" folder at its own path, so they // never group as one logical project. Offer every machine that has one. for (const environment of environments) { @@ -2703,6 +2703,7 @@ export default function ChatView(props: ChatViewProps) { activeProject, activeProjectIsScratch, allProjects, + draftId, environments, projectGroupingSettings, primaryEnvironmentId, From bcded2f1f040fc99bdc402f9bbc5c1a90e0bda72 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 3 Oct 2026 22:52:11 +0000 Subject: [PATCH 6/6] fix(web): keep offline scratch drafts switchable --- apps/web/src/components/ChatView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f7957d6d2937..e02eb60c9008 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2669,7 +2669,9 @@ export default function ChatView(props: ChatViewProps) { // never group as one logical project. Offer every machine that has one. for (const environment of environments) { const scratchRoot = scratchWorkspaceRootFor(environment.environmentId); - if (scratchRoot === null) continue; + // Keep the current machine visible so an offline source can still switch away. + if (scratchRoot === null && environment.environmentId !== activeProject.environmentId) + continue; const scratchProject = environment.environmentId === activeProject.environmentId ? activeProject