| ... | ... | @@ -22,6 +22,8 @@ import { |
| 22 | 22 | rememberCheckoutIsRepo, |
| 23 | 23 | } from "./ChatView.logic"; |
| 24 | 24 | import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; |
| 25 | import { useScratchProject } from "../hooks/useScratchProject"; |
| 26 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 25 | 27 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; |
| 26 | 28 | import { |
| 27 | 29 | latestExecutedRun, |
| ... | ... | @@ -2642,26 +2644,56 @@ export default function ChatView(props: ChatViewProps) { |
| 2642 | 2644 | }, |
| 2643 | 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 | 2654 | const logicalProjectEnvironments = useMemo(() => { |
| 2646 | 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 | 2656 | 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; |
| 2658 | 2659 | 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, |
| 2663 | 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 | 2698 | // Sort: primary first, then alphabetical |
| 2667 | 2699 | envs.sort((a, b) => { |
| ... | ... | @@ -2669,8 +2701,21 @@ export default function ChatView(props: ChatViewProps) { |
| 2669 | 2701 | return a.label.localeCompare(b.label); |
| 2670 | 2702 | }); |
| 2671 | 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 | 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 | 2719 | const activeEnvironmentOption = |
| 2675 | 2720 | logicalProjectEnvironments.find( |
| 2676 | 2721 | (environment) => environment.environmentId === activeThread?.environmentId, |
| ... | ... | @@ -2889,7 +2934,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2889 | 2934 | clientSettingsHydrated && |
| 2890 | 2935 | draftId && |
| 2891 | 2936 | !envLocked && |
| 2892 | | hasMultipleEnvironments && |
| 2937 | canAutoBalanceEnvironments && |
| 2893 | 2938 | loadBalancingSettings.loadBalancingEnabled && |
| 2894 | 2939 | draftThread?.environmentSelection !== "manual" && |
| 2895 | 2940 | (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && |
| ... | ... | @@ -4191,7 +4236,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4191 | 4236 | const target = logicalProjectEnvironments.find( |
| 4192 | 4237 | (environment) => environment.environmentId === loadBalancing.environmentId, |
| 4193 | 4238 | ); |
| 4194 | | if (!target) return; |
| 4239 | if (!target?.projectId) return; |
| 4195 | 4240 | setDraftThreadContext(draftId, { |
| 4196 | 4241 | projectRef: scopeProjectRef(target.environmentId, target.projectId), |
| 4197 | 4242 | environmentSelection: "auto", |
| ... | ... | @@ -4244,23 +4289,91 @@ export default function ChatView(props: ChatViewProps) { |
| 4244 | 4289 | : "Auto balance" |
| 4245 | 4290 | : undefined; |
| 4246 | 4291 | |
| 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 | |
| 4250 | 4301 | const onEnvironmentChange = useCallback( |
| 4251 | 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 | 4306 | const target = logicalProjectEnvironments.find( |
| 4254 | 4307 | (env) => env.environmentId === nextEnvironmentId, |
| 4255 | 4308 | ); |
| 4256 | 4309 | 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); |
| 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 | 4379 | const activeTerminalGroup = |
| ... | ... | @@ -8131,6 +8244,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8131 | 8244 | isRevertingCheckpoint || |
| 8132 | 8245 | !clientSettingsHydrated || |
| 8133 | 8246 | threadDetailLoading || |
| 8247 | environmentChangeRef.current !== null || |
| 8134 | 8248 | sendInFlightRef.current || |
| 8135 | 8249 | feedbackUploadsInFlightRef.current.has(routeThreadKey) |
| 8136 | 8250 | ) { |
| ... | ... | @@ -10439,7 +10553,10 @@ export default function ChatView(props: ChatViewProps) { |
| 10439 | 10553 | availableEnvironments: logicalProjectEnvironments, |
| 10440 | 10554 | autoEnvironmentLabel, |
| 10441 | 10555 | onAutoEnvironment: |
| 10442 | | draftId && !envLocked && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled |
| 10556 | draftId && |
| 10557 | !envLocked && |
| 10558 | canAutoBalanceEnvironments && |
| 10559 | loadBalancingSettings.loadBalancingEnabled |
| 10443 | 10560 | ? onAutoEnvironment |
| 10444 | 10561 | : undefined, |
| 10445 | 10562 | onEnvironmentChange, |
| ... | ... | @@ -10896,15 +11013,17 @@ export default function ChatView(props: ChatViewProps) { |
| 10896 | 11013 | canResume={resumableRunId !== null || hasHeldQueuedRuns} |
| 10897 | 11014 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 10898 | 11015 | 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 |
| 10908 | 11027 | } |
| 10909 | 11028 | isPreparingWorktree={isPreparingWorktree} |
| 10910 | 11029 | queuedRunsControl={ |
| ... | ... | @@ -11085,7 +11204,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11085 | 11204 | onAutoEnvironment={ |
| 11086 | 11205 | draftId && |
| 11087 | 11206 | !envLocked && |
| 11088 | | hasMultipleEnvironments && |
| 11207 | canAutoBalanceEnvironments && |
| 11089 | 11208 | loadBalancingSettings.loadBalancingEnabled |
| 11090 | 11209 | ? onAutoEnvironment |
| 11091 | 11210 | : undefined |