authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-03 09:33:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 09:33:56-07:00
log57626eb6eaa436b22260068d23f4e3df5f389cd1
tree7301ce7d6262015532239bc2adc58f7a23b6899c
parent098bf5329727fcd7d973bf842e6b4d50d6e7b924
signature Signed by PGP key B5690EEEBB952194

fix(web): prevent loading ssh environments from overriding navigation (#9168)


2 files changed, 162 insertions(+), 0 deletions(-)

apps/web/src/hooks/useHandleNewThread.test.ts created+154
......@@ -0,0 +1,154 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3const testState = vi.hoisted(() => {
4 let completeProjectFileRead: (value: null) => void = () => undefined;
5 let projectFileRead = Promise.resolve<null>(null);
6 let storedDraft: {
7 readonly draftId: string;
8 readonly environmentId: string;
9 readonly promotedTo: null;
10 readonly threadId: string;
11 } | null = null;
12 const router = {
13 state: {
14 location: { href: "/" },
15 matches: [{ params: {} }],
16 },
17 navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => {
18 router.state.location.href = `/draft/${request.params.draftId}`;
19 }),
20 };
21 const draftStore = {
22 getComposerDraft: vi.fn(() => ({})),
23 getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft),
24 getDraftSession: vi.fn(() => null),
25 getDraftThread: vi.fn(() => null),
26 applyStickyState: vi.fn(),
27 setDraftThreadContext: vi.fn(),
28 setLogicalProjectDraftThreadId: vi.fn(),
29 setModelSelection: vi.fn(),
30 };
31
32 return {
33 completeProjectFileRead: (value: null) => completeProjectFileRead(value),
34 draftStore,
35 get projectFileRead() {
36 return projectFileRead;
37 },
38 reset(nextStoredDraft: typeof storedDraft) {
39 storedDraft = nextStoredDraft;
40 router.state.location.href = "/";
41 router.navigate.mockClear();
42 draftStore.setLogicalProjectDraftThreadId.mockClear();
43 projectFileRead = new Promise<null>((resolve) => {
44 completeProjectFileRead = resolve;
45 });
46 },
47 router,
48 };
49});
50
51vi.mock("@effect/atom-react", () => ({
52 useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }),
53}));
54vi.mock("@t3tools/client-runtime/environment", () => ({
55 scopedProjectKey: () => "remote-project",
56 scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
57 scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
58}));
59vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" }));
60vi.mock("@t3tools/shared/threadEnvMode", () => ({
61 resolveDefaultThreadEnvMode: (input: {
62 readonly projectFile: "local" | "worktree" | null;
63 readonly globalDefault: "local" | "worktree";
64 }) => input.projectFile ?? input.globalDefault,
65}));
66vi.mock("@tanstack/react-router", () => ({
67 useParams: () => null,
68 useRouter: () => testState.router,
69}));
70vi.mock("react", () => ({
71 useCallback: <T>(callback: T) => callback,
72 useMemo: <T>(factory: () => T) => factory(),
73}));
74vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] }));
75vi.mock("../composerDraftStore", () => {
76 const useComposerDraftStore = Object.assign(() => null, {
77 getState: () => testState.draftStore,
78 });
79 return {
80 composerDraftHasUserContent: () => false,
81 markPromotedDraftThreadByRef: vi.fn(),
82 useComposerDraftStore,
83 };
84});
85vi.mock("../lib/chatThreadActions", () => ({
86 hasExplicitComposerModelSelection: () => false,
87 resolveNewDraftStartFromOrigin: () => false,
88 resolveNewThreadModelSelectionOverride: () => null,
89}));
90vi.mock("../lib/t3ProjectFileDefaults", () => ({
91 readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead,
92}));
93vi.mock("../lib/utils", () => ({
94 newDraftId: () => "draft-delayed",
95 newThreadId: () => "thread-delayed",
96}));
97vi.mock("../logicalProject", () => ({
98 deriveLogicalProjectKeyFromSettings: () => "remote-project",
99 getProjectOrderKey: () => "remote-project",
100 selectProjectGroupingSettings: () => ({}),
101}));
102vi.mock("../state/entities", () => ({
103 readProjects: () => [
104 {
105 id: "project-remote",
106 environmentId: "environment-ssh",
107 workspaceRoot: "/remote/project",
108 defaultThreadEnvMode: null,
109 defaultModelSelection: null,
110 },
111 ],
112 readThreadShell: () => null,
113 useProjects: () => [],
114 useThread: () => null,
115}));
116vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} }));
117vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null }));
118vi.mock("../uiStateStore", () => ({
119 legacyProjectCwdPreferenceKey: () => "remote-project",
120 useUiStateStore: () => [],
121}));
122vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) }));
123
124import { useNewThreadHandler } from "./useHandleNewThread";
125
126describe("useNewThreadHandler", () => {
127 it.each([
128 ["new", null],
129 [
130 "reusable",
131 {
132 draftId: "draft-existing",
133 environmentId: "environment-ssh",
134 promotedTo: null,
135 threadId: "thread-existing",
136 },
137 ],
138 ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => {
139 testState.reset(draft);
140 const openThread = useNewThreadHandler();
141 const pendingOpen = openThread(
142 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
143 { replace: true },
144 );
145
146 testState.router.state.location.href = "/usage";
147 testState.completeProjectFileRead(null);
148 await pendingOpen;
149
150 expect(testState.router.state.location.href).toBe("/usage");
151 expect(testState.router.navigate).not.toHaveBeenCalled();
152 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
153 });
154});
apps/web/src/hooks/useHandleNewThread.ts+8
......@@ -93,6 +93,8 @@ export function useNewThreadHandler() {
9393 setLogicalProjectDraftThreadId,
9494 setModelSelection,
9595 } = useComposerDraftStore.getState();
96 const requestingRouteHref = router.state.location.href;
97 const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref;
9698 const currentRouteTarget = getCurrentRouteTarget();
9799 // A new thread carries the user's working mode from the thread being
98100 // viewed. The target project's configured model still wins; runtime and
......@@ -219,6 +221,9 @@ export function useNewThreadHandler() {
219221 workspaceContext = pickExplicitWorkspaceOptions(options);
220222 } else if (!isDraftAlreadyOpen) {
221223 const defaultEnvMode = await resolveDefaultEnvMode();
224 if (routeChangedSinceRequest()) {
225 return null;
226 }
222227 // The await yields. If the draft was opened (a concurrent
223228 // invocation's navigation landed), promoted to a real thread,
224229 // remapped away (a concurrent invocation registered a fresh
......@@ -355,6 +360,9 @@ export function useNewThreadHandler() {
355360 const createdAt = new Date().toISOString();
356361 return (async () => {
357362 const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());
363 if (routeChangedSinceRequest()) {
364 return null;
365 }
358366 // The await yields, so a concurrent invocation may have registered a
359367 // draft for this logical project in the meantime. Registering ours
360368 // too would evict that draft while its navigation is in flight —