authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-03 20:46:06-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 20:46:06-03:00
logc47f4263f927a68645270c2892d8fa18ba7dcb3b
tree8c8d991fecf352b7d01cdc333b4be9a02fb8e23c
parent243d1e7c4499d7ba4dc5b22794fcd2c880936c2d
signature Signed by PGP key B5690EEEBB952194

fix(web): no-project drafts can switch machines (#15356)


2 files changed, 159 insertions(+), 39 deletions(-)

apps/web/src/components/BranchToolbar.logic.ts+2-1
......@@ -16,7 +16,8 @@ export {
1616
1717export interface EnvironmentOption {
1818 environmentId: EnvironmentId;
19 projectId: ProjectId;
19 /** Null when the machine's "No project" folder is not created yet. */
20 projectId: ProjectId | null;
2021 label: string;
2122 isPrimary: boolean;
2223 machine: EnvironmentMachineKind;
apps/web/src/components/ChatView.tsx+157-38
......@@ -22,6 +22,8 @@ import {
2222 rememberCheckoutIsRepo,
2323} from "./ChatView.logic";
2424import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
25import { useScratchProject } from "../hooks/useScratchProject";
26import { isScratchProject } from "@t3tools/client-runtime/state/projects";
2527import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
2628import {
2729 latestExecutedRun,
......@@ -2642,26 +2644,56 @@ export default function ChatView(props: ChatViewProps) {
26422644 },
26432645 [navigate, setEnvironmentEnabled],
26442646 );
2647 const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
2648 const activeProjectIsScratch =
2649 activeProject !== null &&
2650 isScratchProject(
2651 activeProject,
2652 environmentById.get(activeProject.environmentId)?.serverConfig?.scratchWorkspaceRoot ?? null,
2653 );
26452654 const logicalProjectEnvironments = useMemo(() => {
26462655 if (!activeProject) return [];
2647 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
2648 const memberProjects = allProjects.filter(
2649 (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey,
2650 );
2651 const seen = new Set<string>();
26522656 const envs: EnvironmentOption[] = [];
2653 for (const p of memberProjects) {
2654 if (seen.has(p.environmentId)) continue;
2655 seen.add(p.environmentId);
2656 const isPrimary = p.environmentId === primaryEnvironmentId;
2657 const environment = environmentById.get(p.environmentId) ?? null;
2657 const pushEnvironment = (environmentId: EnvironmentId, projectId: ProjectId | null) => {
2658 const environment = environmentById.get(environmentId) ?? null;
26582659 envs.push({
2659 environmentId: p.environmentId,
2660 projectId: p.id,
2661 label: environment?.label ?? p.environmentId,
2662 isPrimary,
2660 environmentId,
2661 projectId,
2662 label: environment?.label ?? environmentId,
2663 isPrimary: environmentId === primaryEnvironmentId,
26632664 machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null),
26642665 });
2666 };
2667 if (activeProjectIsScratch && draftId) {
2668 // Each machine keeps its own "No project" folder at its own path, so they
2669 // never group as one logical project. Offer every machine that has one.
2670 for (const environment of environments) {
2671 const scratchRoot = scratchWorkspaceRootFor(environment.environmentId);
2672 // Keep the current machine visible so an offline source can still switch away.
2673 if (scratchRoot === null && environment.environmentId !== activeProject.environmentId)
2674 continue;
2675 const scratchProject =
2676 environment.environmentId === activeProject.environmentId
2677 ? activeProject
2678 : allProjects.find(
2679 (p) =>
2680 p.environmentId === environment.environmentId && isScratchProject(p, scratchRoot),
2681 );
2682 pushEnvironment(environment.environmentId, scratchProject?.id ?? null);
2683 }
2684 } else {
2685 const logicalKey = deriveLogicalProjectKeyFromSettings(
2686 activeProject,
2687 projectGroupingSettings,
2688 );
2689 const seen = new Set<string>();
2690 for (const p of allProjects) {
2691 if (seen.has(p.environmentId)) continue;
2692 if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey)
2693 continue;
2694 seen.add(p.environmentId);
2695 pushEnvironment(p.environmentId, p.id);
2696 }
26652697 }
26662698 // Sort: primary first, then alphabetical
26672699 envs.sort((a, b) => {
......@@ -2669,8 +2701,21 @@ export default function ChatView(props: ChatViewProps) {
26692701 return a.label.localeCompare(b.label);
26702702 });
26712703 return envs;
2672 }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]);
2704 }, [
2705 activeProject,
2706 activeProjectIsScratch,
2707 allProjects,
2708 draftId,
2709 environments,
2710 projectGroupingSettings,
2711 primaryEnvironmentId,
2712 environmentById,
2713 scratchWorkspaceRootFor,
2714 ]);
26732715 const hasMultipleEnvironments = logicalProjectEnvironments.length > 1;
2716 // Auto balance retargets to an existing project; a machine's "No project"
2717 // folder may not exist until it is picked.
2718 const canAutoBalanceEnvironments = hasMultipleEnvironments && !activeProjectIsScratch;
26742719 const activeEnvironmentOption =
26752720 logicalProjectEnvironments.find(
26762721 (environment) => environment.environmentId === activeThread?.environmentId,
......@@ -2889,7 +2934,7 @@ export default function ChatView(props: ChatViewProps) {
28892934 clientSettingsHydrated &&
28902935 draftId &&
28912936 !envLocked &&
2892 hasMultipleEnvironments &&
2937 canAutoBalanceEnvironments &&
28932938 loadBalancingSettings.loadBalancingEnabled &&
28942939 draftThread?.environmentSelection !== "manual" &&
28952940 (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&
......@@ -4191,7 +4236,7 @@ export default function ChatView(props: ChatViewProps) {
41914236 const target = logicalProjectEnvironments.find(
41924237 (environment) => environment.environmentId === loadBalancing.environmentId,
41934238 );
4194 if (!target) return;
4239 if (!target?.projectId) return;
41954240 setDraftThreadContext(draftId, {
41964241 projectRef: scopeProjectRef(target.environmentId, target.projectId),
41974242 environmentSelection: "auto",
......@@ -4244,23 +4289,91 @@ export default function ChatView(props: ChatViewProps) {
42444289 : "Auto balance"
42454290 : undefined;
42464291
4247 // Handle environment change for draft threads. When the user picks a
4248 // different environment we update the draft context to point at the physical
4249 // project in that environment while keeping the same logical project.
4292 const environmentChangeRef = useRef<symbol | null>(null);
4293 const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false);
4294 useLayoutEffect(() => {
4295 return () => {
4296 environmentChangeRef.current = null;
4297 setIsEnvironmentChanging(false);
4298 };
4299 }, [draftId, activeProjectKey]);
4300
42504301 const onEnvironmentChange = useCallback(
42514302 (nextEnvironmentId: EnvironmentId) => {
4252 if (envLocked || !draftId) return;
4303 if (envLocked || !draftId || sendInFlightRef.current) return;
4304 const originalDraft = getDraftSession(draftId);
4305 if (!originalDraft || originalDraft.promotedTo) return;
42534306 const target = logicalProjectEnvironments.find(
42544307 (env) => env.environmentId === nextEnvironmentId,
42554308 );
42564309 if (!target) return;
4257 setDraftThreadContext(draftId, {
4258 projectRef: scopeProjectRef(target.environmentId, target.projectId),
4259 environmentSelection: "manual",
4260 loadBalancedEnvironmentId: null,
4261 });
4310 const request = Symbol();
4311 environmentChangeRef.current = request;
4312 setIsEnvironmentChanging(false);
4313 const retarget = (project: (typeof allProjects)[number]) => {
4314 const currentDraft = getDraftSession(draftId);
4315 if (
4316 environmentChangeRef.current !== request ||
4317 sendInFlightRef.current ||
4318 !currentDraft ||
4319 currentDraft.promotedTo ||
4320 currentDraft.environmentId !== originalDraft.environmentId ||
4321 currentDraft.projectId !== originalDraft.projectId
4322 )
4323 return;
4324 const projectRef = scopeProjectRef(target.environmentId, project.id);
4325 if (activeProjectIsScratch) {
4326 // Scratch projects are machine-local, so move their logical mapping too.
4327 setLogicalProjectDraftThreadId(
4328 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings),
4329 projectRef,
4330 draftId,
4331 { environmentSelection: "manual", loadBalancedEnvironmentId: null },
4332 );
4333 } else {
4334 setDraftThreadContext(draftId, {
4335 projectRef,
4336 environmentSelection: "manual",
4337 loadBalancedEnvironmentId: null,
4338 });
4339 }
4340 };
4341 const finish = () => {
4342 if (environmentChangeRef.current === request) {
4343 environmentChangeRef.current = null;
4344 setIsEnvironmentChanging(false);
4345 }
4346 };
4347 if (target.projectId !== null) {
4348 const project = allProjects.find(
4349 (project) =>
4350 project.environmentId === target.environmentId && project.id === target.projectId,
4351 );
4352 if (project) retarget(project);
4353 finish();
4354 return;
4355 }
4356 // Keep send disabled until the destination Scratch project is ready.
4357 setIsEnvironmentChanging(true);
4358 void openScratchProject(target.environmentId)
4359 .then((project) => {
4360 if (project) retarget(project);
4361 })
4362 .finally(finish);
42624363 },
4263 [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext],
4364 [
4365 activeProjectIsScratch,
4366 allProjects,
4367 draftId,
4368 envLocked,
4369 getDraftSession,
4370 logicalProjectEnvironments,
4371 openScratchProject,
4372 projectGroupingSettings,
4373 sendInFlightRef,
4374 setDraftThreadContext,
4375 setLogicalProjectDraftThreadId,
4376 ],
42644377 );
42654378
42664379 const activeTerminalGroup =
......@@ -8131,6 +8244,7 @@ export default function ChatView(props: ChatViewProps) {
81318244 isRevertingCheckpoint ||
81328245 !clientSettingsHydrated ||
81338246 threadDetailLoading ||
8247 environmentChangeRef.current !== null ||
81348248 sendInFlightRef.current ||
81358249 feedbackUploadsInFlightRef.current.has(routeThreadKey)
81368250 ) {
......@@ -10439,7 +10553,10 @@ export default function ChatView(props: ChatViewProps) {
1043910553 availableEnvironments: logicalProjectEnvironments,
1044010554 autoEnvironmentLabel,
1044110555 onAutoEnvironment:
10442 draftId && !envLocked && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled
10556 draftId &&
10557 !envLocked &&
10558 canAutoBalanceEnvironments &&
10559 loadBalancingSettings.loadBalancingEnabled
1044310560 ? onAutoEnvironment
1044410561 : undefined,
1044510562 onEnvironmentChange,
......@@ -10896,15 +11013,17 @@ export default function ChatView(props: ChatViewProps) {
1089611013 canResume={resumableRunId !== null || hasHeldQueuedRuns}
1089711014 isRevertingCheckpoint={isRevertingCheckpoint}
1089811015 sendDisabledReason={
10899 isRevertingCheckpoint
10900 ? "Rewinding conversation"
10901 : feedbackUploading
10902 ? "Sending feedback"
10903 : threadDetailLoading
10904 ? "Messages loading"
10905 : worktreeSetupBlocksSend
10906 ? "Preparing worktree"
10907 : projectCloneSendBlockReason
11016 isEnvironmentChanging
11017 ? "Preparing machine"
11018 : isRevertingCheckpoint
11019 ? "Rewinding conversation"
11020 : feedbackUploading
11021 ? "Sending feedback"
11022 : threadDetailLoading
11023 ? "Messages loading"
11024 : worktreeSetupBlocksSend
11025 ? "Preparing worktree"
11026 : projectCloneSendBlockReason
1090811027 }
1090911028 isPreparingWorktree={isPreparingWorktree}
1091011029 queuedRunsControl={
......@@ -11085,7 +11204,7 @@ export default function ChatView(props: ChatViewProps) {
1108511204 onAutoEnvironment={
1108611205 draftId &&
1108711206 !envLocked &&
11088 hasMultipleEnvironments &&
11207 canAutoBalanceEnvironments &&
1108911208 loadBalancingSettings.loadBalancingEnabled
1109011209 ? onAutoEnvironment
1109111210 : undefined