From 59dc8e7b59b9bb274faa88a54e1eba9648f3b42e Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Fri, 23 Oct 2026 19:19:18 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(web):=20=E9=81=BF=E5=85=8D=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9C=AA=E5=8A=A0=E8=BD=BD=E6=97=B6=E8=AF=AF=E7=94=A8?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 环境配置缺失时提示重试,避免创建或重置为错误的本地草稿。 保留显式工作区选择和已缓存配置的离线行为,重试时重新读取配置。 新增缺失配置与恢复路径回归测试,11 条定向测试和 check:changed 均通过;完成隔离客户端前后验证。 --- apps/web/src/hooks/useHandleNewThread.test.ts | 69 ++++++++++++++++++- apps/web/src/hooks/useHandleNewThread.ts | 19 +++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 678d72b038dab..fced0b60bde7e 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, @@ -39,6 +40,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() { @@ -68,6 +76,7 @@ const testState = vi.hoisted(() => { ) { storedDraft = nextStoredDraft; projectRemoved = false; + configMissing = false; connectedEnvironmentIds = ["environment-primary", "environment-ssh"]; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, @@ -98,9 +107,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) => @@ -228,6 +243,38 @@ describe.each([ }, ], ])("useNewThreadHandler with a %s draft", (_, draft) => { + it.each([true, false])( + "does not guess before settings load and uses them on retry (connected: %s)", + async (connected) => { + testState.reset(draft, { envMode: "worktree", startFromOrigin: true }); + testState.toast.mockClear(); + testState.setConfigMissing(true); + if (!connected) testState.disconnect("environment-ssh"); + 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(); @@ -262,6 +309,26 @@ describe.each([ }); describe("useNewThreadHandler", () => { + it.each(["local", "worktree"] as const)( + "keeps an explicit %s choice while the target settings are unavailable", + async (envMode) => { + testState.reset(null); + testState.setConfigMissing(true); + await expect( + useNewThreadHandler()( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { envMode }, + ), + ).resolves.not.toBeNull(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + expect.anything(), + "draft-delayed", + expect.objectContaining({ envMode }), + ); + }, + ); + it("opens the draft without waiting on a project file from an offline machine", async () => { testState.reset(null, { envMode: null, startFromOrigin: false }); testState.disconnect("environment-ssh"); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index f079e65c81293..09a497a950cea 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, @@ -81,8 +82,8 @@ export function useNewThreadHandler() { // Read at call time: every provider status push replaces the config map, and // subscribing would hand every sidebar row a new 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, @@ -139,7 +140,15 @@ export function useNewThreadHandler() { 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 = @@ -210,7 +219,7 @@ export function useNewThreadHandler() { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); - if (abandonedSinceRequest()) { + if (defaultEnvMode === null || abandonedSinceRequest()) { return null; } // If the draft was opened (a concurrent invocation's navigation @@ -333,7 +342,7 @@ export function useNewThreadHandler() { const createdAt = new Date().toISOString(); return (async () => { const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); - if (abandonedSinceRequest()) { + if (initialEnvMode === null || abandonedSinceRequest()) { return null; } // The await yields, so a concurrent invocation may have registered a From 3ba80c526f8d4371886737697054d1c6b19b7503 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sun, 25 Oct 2026 01:35:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(web):=20=E9=85=8D=E7=BD=AE=E6=9C=AA?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=E6=97=B6=E7=AD=89=E5=BE=85=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E6=96=B0=E7=BA=BF=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建草稿被拒绝时显示现有错误提示,不再递增尝试编号形成自动重试循环。 - 保留明确的手动重试入口,并加入真实路由组件的浏览器回归测试。 - 12 项针对性测试及受影响的格式、静态检查和类型检查通过。 --- .../src/routes/-_chat.index.browser.test.tsx | 49 +++++++++++++++++++ apps/web/src/routes/_chat.index.tsx | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/routes/-_chat.index.browser.test.tsx diff --git a/apps/web/src/routes/-_chat.index.browser.test.tsx b/apps/web/src/routes/-_chat.index.browser.test.tsx new file mode 100644 index 0000000000000..75f663f3038f0 --- /dev/null +++ b/apps/web/src/routes/-_chat.index.browser.test.tsx @@ -0,0 +1,49 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; + +const fixture = vi.hoisted(() => ({ + openThread: vi.fn(), + project: { environmentId: "environment-test", id: "project-test" }, +})); + +vi.mock("../hooks/useHandleNewThread", () => ({ + useNewThreadHandler: () => fixture.openThread, +})); +vi.mock("../state/entities", () => ({ + readProject: () => fixture.project, + useProjects: () => [fixture.project], + useThreadShells: () => [], + useAllEnvironmentShellsBootstrapped: () => true, +})); +vi.mock("../components/Sidebar.logic", () => ({ + sortScopedProjectsForSidebar: () => [fixture.project], +})); +vi.mock("../components/NoProjectsHero", () => ({ NoProjectsHero: () => null })); +vi.mock("../components/HostedEmptyState", () => ({ HostedEmptyState: () => null })); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ environments: [], isReady: true }), +})); + +import { Route } from "./_chat.index"; + +function setup() { + fixture.openThread.mockReset(); + // A second unexpected attempt rejects so the pre-fix retry loop terminates. + fixture.openThread.mockResolvedValueOnce(null).mockRejectedValue(new Error("Unavailable")); + vi.spyOn(Route, "useRouteContext").mockReturnValue({ + authGateState: { status: "authenticated" }, + } as never); +} + +it("waits for a manual retry after the environment refuses draft creation", async () => { + setup(); + const IndexRoute = Route.options.component!; + await using screen = await render(); + await expect.element(screen.getByRole("button", { name: "Try again" })).toBeVisible(); + expect(fixture.openThread).toHaveBeenCalledTimes(1); + + await screen.getByRole("button", { name: "Try again" }).click(); + await expect.poll(() => fixture.openThread.mock.calls.length).toBe(2); + await expect.element(screen.getByRole("button", { name: "Try again" })).toBeVisible(); +}); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index ccb34b2458287..4f13b52d4f02f 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -61,7 +61,7 @@ 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(() => { From d3040180259ffc75bec3cd93f9739ec88dbb4ed1 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sun, 25 Oct 2026 02:01:06 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(web):=20=E6=8C=89=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E8=A7=84=E8=8C=83=E7=AD=89=E5=BE=85=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将重试调用次数断言改为仓库要求的 vi.waitFor。 - 12 项针对性测试、格式检查、静态检查和受影响类型检查通过。 --- apps/web/src/routes/-_chat.index.browser.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/-_chat.index.browser.test.tsx b/apps/web/src/routes/-_chat.index.browser.test.tsx index 75f663f3038f0..ee936512c859f 100644 --- a/apps/web/src/routes/-_chat.index.browser.test.tsx +++ b/apps/web/src/routes/-_chat.index.browser.test.tsx @@ -44,6 +44,6 @@ it("waits for a manual retry after the environment refuses draft creation", asyn expect(fixture.openThread).toHaveBeenCalledTimes(1); await screen.getByRole("button", { name: "Try again" }).click(); - await expect.poll(() => fixture.openThread.mock.calls.length).toBe(2); + await vi.waitFor(() => expect(fixture.openThread).toHaveBeenCalledTimes(2)); await expect.element(screen.getByRole("button", { name: "Try again" })).toBeVisible(); }); From 44c479bdb89ebde10b4140d200751918fa91c147 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:59:07 +0000 Subject: [PATCH 4/4] fix(web): wait for environment config before the landing opens a draft Cached projects render before their environment connects, so the index landing would hit the new "settings still loading" guard on every cold start. Start the landing draft once the target config is loaded, and trim the tests to the guard's two code paths. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useHandleNewThread.test.ts | 90 +++++++++---------- .../src/routes/-_chat.index.browser.test.tsx | 49 ---------- apps/web/src/routes/_chat.index.tsx | 15 +++- 3 files changed, 55 insertions(+), 99 deletions(-) delete mode 100644 apps/web/src/routes/-_chat.index.browser.test.tsx diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 9e72ad54c5a9e..76b57409dec30 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -280,37 +280,33 @@ describe.each([ }, ], ])("useNewThreadHandler with a %s draft", (_, draft) => { - it.each([true, false])( - "does not guess before settings load and uses them on retry (connected: %s)", - async (connected) => { - testState.reset(draft, { envMode: "worktree", startFromOrigin: true }); - testState.toast.mockClear(); - testState.setConfigMissing(true); - if (!connected) testState.disconnect("environment-ssh"); - const openThread = useNewThreadHandler(); - const projectRef = { - environmentId: "environment-ssh", - projectId: "project-remote", - } as never; + 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" }), - ); + 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 }), - ); - }, - ); + 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); @@ -365,25 +361,23 @@ describe.each([ }); describe("useNewThreadHandler", () => { - it.each(["local", "worktree"] as const)( - "keeps an explicit %s choice while the target settings are unavailable", - async (envMode) => { - testState.reset(null); - testState.setConfigMissing(true); - await expect( - useNewThreadHandler()( - { environmentId: "environment-ssh", projectId: "project-remote" } as never, - { envMode }, - ), - ).resolves.not.toBeNull(); - expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( - "remote-project", - expect.anything(), - "draft-delayed", - expect.objectContaining({ envMode }), - ); - }, - ); + 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/routes/-_chat.index.browser.test.tsx b/apps/web/src/routes/-_chat.index.browser.test.tsx deleted file mode 100644 index ee936512c859f..0000000000000 --- a/apps/web/src/routes/-_chat.index.browser.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { expect, it, vi } from "vite-plus/test"; - -import { render } from "../../test/render"; - -const fixture = vi.hoisted(() => ({ - openThread: vi.fn(), - project: { environmentId: "environment-test", id: "project-test" }, -})); - -vi.mock("../hooks/useHandleNewThread", () => ({ - useNewThreadHandler: () => fixture.openThread, -})); -vi.mock("../state/entities", () => ({ - readProject: () => fixture.project, - useProjects: () => [fixture.project], - useThreadShells: () => [], - useAllEnvironmentShellsBootstrapped: () => true, -})); -vi.mock("../components/Sidebar.logic", () => ({ - sortScopedProjectsForSidebar: () => [fixture.project], -})); -vi.mock("../components/NoProjectsHero", () => ({ NoProjectsHero: () => null })); -vi.mock("../components/HostedEmptyState", () => ({ HostedEmptyState: () => null })); -vi.mock("../state/environments", () => ({ - useEnvironments: () => ({ environments: [], isReady: true }), -})); - -import { Route } from "./_chat.index"; - -function setup() { - fixture.openThread.mockReset(); - // A second unexpected attempt rejects so the pre-fix retry loop terminates. - fixture.openThread.mockResolvedValueOnce(null).mockRejectedValue(new Error("Unavailable")); - vi.spyOn(Route, "useRouteContext").mockReturnValue({ - authGateState: { status: "authenticated" }, - } as never); -} - -it("waits for a manual retry after the environment refuses draft creation", async () => { - setup(); - const IndexRoute = Route.options.component!; - await using screen = await render(); - await expect.element(screen.getByRole("button", { name: "Try again" })).toBeVisible(); - expect(fixture.openThread).toHaveBeenCalledTimes(1); - - await screen.getByRole("button", { name: "Try again" }).click(); - await vi.waitFor(() => expect(fixture.openThread).toHaveBeenCalledTimes(2)); - await expect.element(screen.getByRole("button", { name: "Try again" })).toBeVisible(); -}); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 0c052615749a9..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); @@ -72,7 +83,7 @@ function IndexDraftLanding() { .catch(() => { setStartState((state) => ({ ...state, failed: true })); }); - }, [attempt, handleNewThread, mostRecentProject]); + }, [attempt, configLoaded, handleNewThread, mostRecentProject]); if (mostRecentProject !== null) { return startState.failed ? (