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 {...@@ -16,7 +16,8 @@ export {
1616
17export interface EnvironmentOption {17export interface EnvironmentOption {
18 environmentId: EnvironmentId;18 environmentId: EnvironmentId;
19 projectId: ProjectId;19 /** Null when the machine's "No project" folder is not created yet. */
20 projectId: ProjectId | null;
20 label: string;21 label: string;
21 isPrimary: boolean;22 isPrimary: boolean;
22 machine: EnvironmentMachineKind;23 machine: EnvironmentMachineKind;
apps/web/src/components/ChatView.tsx+157-38
...@@ -22,6 +22,8 @@ import {...@@ -22,6 +22,8 @@ import {
22 rememberCheckoutIsRepo,22 rememberCheckoutIsRepo,
23} from "./ChatView.logic";23} from "./ChatView.logic";
24import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";24import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
25import { useScratchProject } from "../hooks/useScratchProject";
26import { isScratchProject } from "@t3tools/client-runtime/state/projects";
25import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";27import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
26import {28import {
27 latestExecutedRun,29 latestExecutedRun,
...@@ -2642,26 +2644,56 @@ export default function ChatView(props: ChatViewProps) {...@@ -2642,26 +2644,56 @@ export default function ChatView(props: ChatViewProps) {
2642 },2644 },
2643 [navigate, setEnvironmentEnabled],2645 [navigate, setEnvironmentEnabled],
2644 );2646 );
2647 const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
2648 const activeProjectIsScratch =
2649 activeProject !== null &&
2650 isScratchProject(
2651 activeProject,
2652 environmentById.get(activeProject.environmentId)?.serverConfig?.scratchWorkspaceRoot ?? null,
2653 );
2645 const logicalProjectEnvironments = useMemo(() => {2654 const logicalProjectEnvironments = useMemo(() => {
2646 if (!activeProject) return [];2655 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>();
2652 const envs: EnvironmentOption[] = [];2656 const envs: EnvironmentOption[] = [];
2653 for (const p of memberProjects) {2657 const pushEnvironment = (environmentId: EnvironmentId, projectId: ProjectId | null) => {
2654 if (seen.has(p.environmentId)) continue;2658 const environment = environmentById.get(environmentId) ?? null;
2655 seen.add(p.environmentId);
2656 const isPrimary = p.environmentId === primaryEnvironmentId;
2657 const environment = environmentById.get(p.environmentId) ?? null;
2658 envs.push({2659 envs.push({
2659 environmentId: p.environmentId,2660 environmentId,
2660 projectId: p.id,2661 projectId,
2661 label: environment?.label ?? p.environmentId,2662 label: environment?.label ?? environmentId,
2662 isPrimary,2663 isPrimary: environmentId === primaryEnvironmentId,
2663 machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null),2664 machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null),
2664 });2665 });
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 }
2665 }2697 }
2666 // Sort: primary first, then alphabetical2698 // Sort: primary first, then alphabetical
2667 envs.sort((a, b) => {2699 envs.sort((a, b) => {
...@@ -2669,8 +2701,21 @@ export default function ChatView(props: ChatViewProps) {...@@ -2669,8 +2701,21 @@ export default function ChatView(props: ChatViewProps) {
2669 return a.label.localeCompare(b.label);2701 return a.label.localeCompare(b.label);
2670 });2702 });
2671 return envs;2703 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 ]);
2673 const hasMultipleEnvironments = logicalProjectEnvironments.length > 1;2715 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;
2674 const activeEnvironmentOption =2719 const activeEnvironmentOption =
2675 logicalProjectEnvironments.find(2720 logicalProjectEnvironments.find(
2676 (environment) => environment.environmentId === activeThread?.environmentId,2721 (environment) => environment.environmentId === activeThread?.environmentId,
...@@ -2889,7 +2934,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2889,7 +2934,7 @@ export default function ChatView(props: ChatViewProps) {
2889 clientSettingsHydrated &&2934 clientSettingsHydrated &&
2890 draftId &&2935 draftId &&
2891 !envLocked &&2936 !envLocked &&
2892 hasMultipleEnvironments &&2937 canAutoBalanceEnvironments &&
2893 loadBalancingSettings.loadBalancingEnabled &&2938 loadBalancingSettings.loadBalancingEnabled &&
2894 draftThread?.environmentSelection !== "manual" &&2939 draftThread?.environmentSelection !== "manual" &&
2895 (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&2940 (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&
...@@ -4191,7 +4236,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4191,7 +4236,7 @@ export default function ChatView(props: ChatViewProps) {
4191 const target = logicalProjectEnvironments.find(4236 const target = logicalProjectEnvironments.find(
4192 (environment) => environment.environmentId === loadBalancing.environmentId,4237 (environment) => environment.environmentId === loadBalancing.environmentId,
4193 );4238 );
4194 if (!target) return;4239 if (!target?.projectId) return;
4195 setDraftThreadContext(draftId, {4240 setDraftThreadContext(draftId, {
4196 projectRef: scopeProjectRef(target.environmentId, target.projectId),4241 projectRef: scopeProjectRef(target.environmentId, target.projectId),
4197 environmentSelection: "auto",4242 environmentSelection: "auto",
...@@ -4244,23 +4289,91 @@ export default function ChatView(props: ChatViewProps) {...@@ -4244,23 +4289,91 @@ export default function ChatView(props: ChatViewProps) {
4244 : "Auto balance"4289 : "Auto balance"
4245 : undefined;4290 : undefined;
42464291
4247 // Handle environment change for draft threads. When the user picks a4292 const environmentChangeRef = useRef<symbol | null>(null);
4248 // different environment we update the draft context to point at the physical4293 const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false);
4249 // project in that environment while keeping the same logical project.4294 useLayoutEffect(() => {
4295 return () => {
4296 environmentChangeRef.current = null;
4297 setIsEnvironmentChanging(false);
4298 };
4299 }, [draftId, activeProjectKey]);
4300
4250 const onEnvironmentChange = useCallback(4301 const onEnvironmentChange = useCallback(
4251 (nextEnvironmentId: EnvironmentId) => {4302 (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;
4253 const target = logicalProjectEnvironments.find(4306 const target = logicalProjectEnvironments.find(
4254 (env) => env.environmentId === nextEnvironmentId,4307 (env) => env.environmentId === nextEnvironmentId,
4255 );4308 );
4256 if (!target) return;4309 if (!target) return;
4257 setDraftThreadContext(draftId, {4310 const request = Symbol();
4258 projectRef: scopeProjectRef(target.environmentId, target.projectId),4311 environmentChangeRef.current = request;
4259 environmentSelection: "manual",4312 setIsEnvironmentChanging(false);
4260 loadBalancedEnvironmentId: null,4313 const retarget = (project: (typeof allProjects)[number]) => {
4261 });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);
4262 },4363 },
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 ],
4264 );4377 );
42654378
4266 const activeTerminalGroup =4379 const activeTerminalGroup =
...@@ -8131,6 +8244,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8131,6 +8244,7 @@ export default function ChatView(props: ChatViewProps) {
8131 isRevertingCheckpoint ||8244 isRevertingCheckpoint ||
8132 !clientSettingsHydrated ||8245 !clientSettingsHydrated ||
8133 threadDetailLoading ||8246 threadDetailLoading ||
8247 environmentChangeRef.current !== null ||
8134 sendInFlightRef.current ||8248 sendInFlightRef.current ||
8135 feedbackUploadsInFlightRef.current.has(routeThreadKey)8249 feedbackUploadsInFlightRef.current.has(routeThreadKey)
8136 ) {8250 ) {
...@@ -10439,7 +10553,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -10439,7 +10553,10 @@ export default function ChatView(props: ChatViewProps) {
10439 availableEnvironments: logicalProjectEnvironments,10553 availableEnvironments: logicalProjectEnvironments,
10440 autoEnvironmentLabel,10554 autoEnvironmentLabel,
10441 onAutoEnvironment:10555 onAutoEnvironment:
10442 draftId && !envLocked && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled10556 draftId &&
10557 !envLocked &&
10558 canAutoBalanceEnvironments &&
10559 loadBalancingSettings.loadBalancingEnabled
10443 ? onAutoEnvironment10560 ? onAutoEnvironment
10444 : undefined,10561 : undefined,
10445 onEnvironmentChange,10562 onEnvironmentChange,
...@@ -10896,15 +11013,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -10896,15 +11013,17 @@ export default function ChatView(props: ChatViewProps) {
10896 canResume={resumableRunId !== null || hasHeldQueuedRuns}11013 canResume={resumableRunId !== null || hasHeldQueuedRuns}
10897 isRevertingCheckpoint={isRevertingCheckpoint}11014 isRevertingCheckpoint={isRevertingCheckpoint}
10898 sendDisabledReason={11015 sendDisabledReason={
10899 isRevertingCheckpoint11016 isEnvironmentChanging
10900 ? "Rewinding conversation"11017 ? "Preparing machine"
10901 : feedbackUploading11018 : isRevertingCheckpoint
10902 ? "Sending feedback"11019 ? "Rewinding conversation"
10903 : threadDetailLoading11020 : feedbackUploading
10904 ? "Messages loading"11021 ? "Sending feedback"
10905 : worktreeSetupBlocksSend11022 : threadDetailLoading
10906 ? "Preparing worktree"11023 ? "Messages loading"
10907 : projectCloneSendBlockReason11024 : worktreeSetupBlocksSend
11025 ? "Preparing worktree"
11026 : projectCloneSendBlockReason
10908 }11027 }
10909 isPreparingWorktree={isPreparingWorktree}11028 isPreparingWorktree={isPreparingWorktree}
10910 queuedRunsControl={11029 queuedRunsControl={
...@@ -11085,7 +11204,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11085,7 +11204,7 @@ export default function ChatView(props: ChatViewProps) {
11085 onAutoEnvironment={11204 onAutoEnvironment={
11086 draftId &&11205 draftId &&
11087 !envLocked &&11206 !envLocked &&
11088 hasMultipleEnvironments &&11207 canAutoBalanceEnvironments &&
11089 loadBalancingSettings.loadBalancingEnabled11208 loadBalancingSettings.loadBalancingEnabled
11090 ? onAutoEnvironment11209 ? onAutoEnvironment
11091 : undefined11210 : undefined