diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6f318bce409ce..76b57409dec30 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -5,6 +5,7 @@ const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; let projectFileRead = Promise.resolve(null); let projectRemoved = false; + let configMissing = false; let connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; let targetSettings = { defaultThreadEnvMode: "local" as "local" | "worktree" | null, @@ -53,6 +54,13 @@ const testState = vi.hoisted(() => { }; return { + toast: vi.fn(), + get configMissing() { + return configMissing; + }, + setConfigMissing(value: boolean) { + configMissing = value; + }, completeProjectFileRead: (value: null) => completeProjectFileRead(value), draftStore, get projectFileRead() { @@ -95,6 +103,7 @@ const testState = vi.hoisted(() => { openDraftRoute = null; draftsWithUserText = new Set(); projectRemoved = false; + configMissing = false; connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, @@ -126,9 +135,15 @@ const testServerConfigs = () => }, }, ], - ["environment-ssh", { settings: testState.targetSettings }], + ...(!testState.configMissing + ? [["environment-ssh", { settings: testState.targetSettings }] as const] + : []), ]); +vi.mock("../components/ui/toast", () => ({ + toastManager: { add: testState.toast }, +})); + vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: { get: (atom: unknown) => @@ -265,6 +280,34 @@ describe.each([ }, ], ])("useNewThreadHandler with a %s draft", (_, draft) => { + it("does not guess before settings load and uses them on retry", async () => { + testState.reset(draft, { envMode: "worktree", startFromOrigin: true }); + testState.toast.mockClear(); + testState.setConfigMissing(true); + const openThread = useNewThreadHandler(); + const projectRef = { + environmentId: "environment-ssh", + projectId: "project-remote", + } as never; + + await expect(openThread(projectRef)).resolves.toBeNull(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + expect(testState.draftStore.setDraftThreadContext).not.toHaveBeenCalled(); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Environment settings are still loading" }), + ); + + testState.setConfigMissing(false); + await expect(openThread(projectRef)).resolves.not.toBeNull(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + projectRef, + draft?.draftId ?? "draft-delayed", + expect.objectContaining({ envMode: "worktree", startFromOrigin: true }), + ); + }); + it("abandons a delayed draft open when the user navigates elsewhere", async () => { testState.reset(draft); const openThread = useNewThreadHandler(); @@ -318,6 +361,23 @@ describe.each([ }); describe("useNewThreadHandler", () => { + it("keeps an explicit choice while the target settings are unavailable", async () => { + testState.reset(null); + testState.setConfigMissing(true); + await expect( + useNewThreadHandler()( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { envMode: "worktree" }, + ), + ).resolves.not.toBeNull(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + expect.anything(), + "draft-delayed", + expect.objectContaining({ envMode: "worktree" }), + ); + }); + it("keeps the text of a draft that gained some while it waited", async () => { testState.reset(null); const raced = { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 44215edfc5be7..0fc55b57b5f65 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -15,6 +15,7 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; +import { toastManager } from "../components/ui/toast"; import { deriveLogicalProjectKeyFromSettings, getProjectOrderKey, @@ -106,8 +107,8 @@ export function createNewThreadHandler(router: AnyRouter) { const projectGroupingSettings = selectProjectGroupingSettings(getClientSettings()); // Read at call time; status pushes replace the config map and would recreate every sidebar handler. const environmentServerConfigs = appAtomRegistry.get(environmentServerConfigsAtom); - const targetServerSettings = - environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; + const targetServerConfig = environmentServerConfigs.get(projectRef.environmentId); + const targetServerSettings = targetServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS; const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -190,7 +191,15 @@ export function createNewThreadHandler(router: AnyRouter) { currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, destinationDraftId, }); - const resolveDefaultEnvMode = async (): Promise => { + const resolveDefaultEnvMode = async (): Promise => { + if (targetServerConfig === undefined) { + toastManager.add({ + type: "warning", + title: "Environment settings are still loading", + description: "Wait for this environment to finish connecting, then try again.", + }); + return null; + } // Environment queries wait for a connection, so reading the project file on an // offline machine would never settle and the draft would never open. const consultProjectFile = @@ -266,7 +275,7 @@ export function createNewThreadHandler(router: AnyRouter) { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); - if (abandonedSinceRequest()) { + if (defaultEnvMode === null || abandonedSinceRequest()) { return null; } // Concurrent calls may change the draft; stale resets, remaps, or navigation could clobber it. @@ -382,7 +391,7 @@ export function createNewThreadHandler(router: AnyRouter) { const createdAt = new Date().toISOString(); return (async () => { const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); - if (abandonedSinceRequest()) { + if (initialEnvMode === null || abandonedSinceRequest()) { return null; } // Reuse drafts registered while awaiting so their pending navigation cannot lose them. diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index b511be0fa9255..7cdca411c2ab9 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,6 +1,7 @@ import { workspaceAreaProps } from "../lib/workspaceFocus"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { useAtomValue } from "@effect/atom-react"; import { createFileRoute, useRouter } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef, useState } from "react"; @@ -20,6 +21,7 @@ import { useVisibleThreadShells, } from "../state/entities"; import { useEnvironments } from "../state/environments"; +import { environmentServerConfigsAtom } from "../state/server"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -38,6 +40,7 @@ function IndexDraftLanding() { const threads = useVisibleThreadShells(); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const handleNewThread = useNewThreadHandler(); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); const router = useRouter(); // Read at effect time; this page can stay mounted while the router is already navigating away. const isStillOnIndex = useEffectEvent(() => router.state.location.pathname === "/"); @@ -51,11 +54,19 @@ function IndexDraftLanding() { "updated_at", )[0] ?? null; const attempt = startState.retryRequest; + // Cached projects show before their environment connects; the handler refuses to guess until it does. + const configLoaded = + mostRecentProject !== null && serverConfigs.has(mostRecentProject.environmentId); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts a draft thread in the most recent project and navigates to it while still on the index useEffect(() => { // Starting a draft after the user has left would replace the page they picked. - if (mostRecentProject === null || startedAttemptRef.current === attempt || !isStillOnIndex()) { + if ( + mostRecentProject === null || + !configLoaded || + startedAttemptRef.current === attempt || + !isStillOnIndex() + ) { return; } const projectRef = scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id); @@ -66,13 +77,13 @@ function IndexDraftLanding() { void handleNewThread(projectRef, { replace: true }) .then((opened) => { if (opened === null) { - setStartState((state) => ({ ...state, retryRequest: state.retryRequest + 1 })); + setStartState((state) => ({ ...state, failed: true })); } }) .catch(() => { setStartState((state) => ({ ...state, failed: true })); }); - }, [attempt, handleNewThread, mostRecentProject]); + }, [attempt, configLoaded, handleNewThread, mostRecentProject]); if (mostRecentProject !== null) { return startState.failed ? (