| ... | @@ -22,6 +22,8 @@ import { | ... | @@ -22,6 +22,8 @@ import { |
| 22 | rememberCheckoutIsRepo, | 22 | rememberCheckoutIsRepo, |
| 23 | } from "./ChatView.logic"; | 23 | } from "./ChatView.logic"; |
| 24 | import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; | 24 | import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; |
| | 25 | import { useScratchProject } from "../hooks/useScratchProject"; |
| | 26 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 25 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; | 27 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; |
| 26 | import { | 28 | import { |
| 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 alphabetical | 2698 | // 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; |
| 4246 | | 4291 | |
| 4247 | // Handle environment change for draft threads. When the user picks a | 4292 | const environmentChangeRef = useRef<symbol | null>(null); |
| 4248 | // different environment we update the draft context to point at the physical | 4293 | 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 | ); |
| 4265 | | 4378 | |
| 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.loadBalancingEnabled | 10556 | draftId && |
| | 10557 | !envLocked && |
| | 10558 | canAutoBalanceEnvironments && |
| | 10559 | loadBalancingSettings.loadBalancingEnabled |
| 10443 | ? onAutoEnvironment | 10560 | ? 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 | isRevertingCheckpoint | 11016 | isEnvironmentChanging |
| 10900 | ? "Rewinding conversation" | 11017 | ? "Preparing machine" |
| 10901 | : feedbackUploading | 11018 | : isRevertingCheckpoint |
| 10902 | ? "Sending feedback" | 11019 | ? "Rewinding conversation" |
| 10903 | : threadDetailLoading | 11020 | : feedbackUploading |
| 10904 | ? "Messages loading" | 11021 | ? "Sending feedback" |
| 10905 | : worktreeSetupBlocksSend | 11022 | : threadDetailLoading |
| 10906 | ? "Preparing worktree" | 11023 | ? "Messages loading" |
| 10907 | : projectCloneSendBlockReason | 11024 | : 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.loadBalancingEnabled | 11208 | loadBalancingSettings.loadBalancingEnabled |
| 11090 | ? onAutoEnvironment | 11209 | ? onAutoEnvironment |
| 11091 | : undefined | 11210 | : undefined |