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
68 changes: 67 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 @@ -260,6 +275,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();
Expand Down Expand Up @@ -313,6 +360,25 @@ 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 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 @@ -89,8 +90,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 @@ -154,7 +155,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 @@ -230,7 +239,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 @@ -346,7 +355,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
49 changes: 49 additions & 0 deletions apps/web/src/routes/-_chat.index.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<IndexRoute />);
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();
});
2 changes: 1 addition & 1 deletion apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,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(() => {
Expand Down
Loading