Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion apps/web/src/hooks/useHandleNewThread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const testState = vi.hoisted(() => {
let completeProjectFileRead: (value: null) => void = () => undefined;
let projectFileRead = Promise.resolve<null>(null);
let projectRemoved = false;
let configMissing = false;
let connectedEnvironmentIds = ["environment-primary", "environment-ssh"];
let targetSettings = {
defaultThreadEnvMode: "local" as "local" | "worktree" | null,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = {
Expand Down
19 changes: 14 additions & 5 deletions apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -190,7 +191,15 @@ export function createNewThreadHandler(router: AnyRouter) {
currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
destinationDraftId,
});
const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode | null> => {
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;
Comment thread
nateEc marked this conversation as resolved.
Resolved
}
// 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 =
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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();
Expand All @@ -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 === "/");
Expand All @@ -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);
Expand All @@ -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 ? (
Expand Down
Loading