Skip to content
Closed
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
284 changes: 259 additions & 25 deletions apps/web/src/hooks/useHandleNewThread.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vite-plus/test";
import type { RuntimeMode } from "@t3tools/contracts";
import { EnvironmentId, ProjectId, type RuntimeMode } from "@t3tools/contracts";

const testState = vi.hoisted(() => {
let completeProjectFileRead: (value: null) => void = () => undefined;
let projectFileRead = Promise.resolve<null>(null);
let targetSettings = {
defaultThreadEnvMode: "local" as "local" | "worktree",
defaultThreadEnvMode: "local" as "local" | "worktree" | null,
newWorktreesStartFromOrigin: false,
defaultModelSelection: null,
defaultRuntimeMode: "full-access" as RuntimeMode,
Expand All @@ -16,6 +16,57 @@ const testState = vi.hoisted(() => {
readonly promotedTo: null;
readonly threadId: string;
} | null = null;
const repositoryIdentity = {
canonicalKey: "github.com/example/shared-repo",
locator: {
source: "git-remote" as const,
remoteName: "origin",
remoteUrl: "https://github.com/example/shared-repo.git",
},
};
const remoteProject = {
id: "project-remote",
environmentId: "environment-ssh",
title: "shared-repo",
workspaceRoot: "/remote/project",
repositoryIdentity: null,
defaultThreadEnvMode: null,
defaultModelSelection: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
scripts: [] as [],
};
const localProject = {
id: "project-local",
environmentId: "environment-primary",
title: "shared-repo",
workspaceRoot: "/local/project",
repositoryIdentity,
defaultThreadEnvMode: null,
defaultModelSelection: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
scripts: [] as [],
};
type TestEnvironment = {
environmentId: string;
label: string;
connection: { phase: "connected" | "reconnecting" };
};
const connectedEnvironments = (): TestEnvironment[] => [
{
environmentId: "environment-primary",
label: "Local",
connection: { phase: "connected" },
},
{
environmentId: "environment-ssh",
label: "Build box",
connection: { phase: "connected" },
},
];
const projectFileReads: Array<{ environmentId: string; workspaceRoot: string }> = [];
const toastAdd = vi.fn();
const router = {
state: {
location: { href: "/" },
Expand All @@ -36,7 +87,7 @@ const testState = vi.hoisted(() => {
setModelSelection: vi.fn(),
};

return {
const state = {
completeProjectFileRead: (value: null) => completeProjectFileRead(value),
draftStore,
get projectFileRead() {
Expand All @@ -45,6 +96,13 @@ const testState = vi.hoisted(() => {
get targetSettings() {
return targetSettings;
},
environments: connectedEnvironments(),
localProject,
projectFileReads,
projects: [remoteProject] as Array<typeof remoteProject | typeof localProject>,
remoteProject,
repositoryIdentity,
toastAdd,
reset(
nextStoredDraft: typeof storedDraft,
workspaceDefaults = {
Expand All @@ -59,6 +117,10 @@ const testState = vi.hoisted(() => {
defaultModelSelection: null,
defaultRuntimeMode: "full-access",
};
state.environments = connectedEnvironments();
state.projects = [remoteProject];
projectFileReads.length = 0;
toastAdd.mockClear();
router.state.location.href = "/";
router.navigate.mockClear();
draftStore.setDraftThreadContext.mockClear();
Expand All @@ -69,6 +131,8 @@ const testState = vi.hoisted(() => {
},
router,
};

return state;
});

vi.mock("@effect/atom-react", () => ({
Expand All @@ -93,10 +157,14 @@ vi.mock("@t3tools/client-runtime/environment", () => ({
scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
}));
vi.mock("@t3tools/contracts", () => ({
DEFAULT_RUNTIME_MODE: "default",
DEFAULT_SERVER_SETTINGS: {},
}));
vi.mock("@t3tools/contracts", async (importOriginal) => {
const actual = await importOriginal<typeof import("@t3tools/contracts")>();
return {
...actual,
DEFAULT_RUNTIME_MODE: "default",
DEFAULT_SERVER_SETTINGS: {},
};
});
vi.mock("@t3tools/shared/projectSettings", () => ({
// Environment settings pass through; the tests set project fields on the
// project record, which the hook still honors until the server folds them.
Expand Down Expand Up @@ -128,6 +196,10 @@ vi.mock("react", () => ({
useMemo: <T>(factory: () => T) => factory(),
}));
vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] }));
vi.mock("../components/ui/toast", () => ({
stackedThreadToast: (options: unknown) => options,
toastManager: { add: (...args: unknown[]) => testState.toastAdd(...args) },
}));
vi.mock("../composerDraftStore", () => {
const useComposerDraftStore = Object.assign(() => null, {
getState: () => testState.draftStore,
Expand All @@ -144,31 +216,34 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
resolveNewThreadModelSelectionOverride: () => null,
}));
vi.mock("../lib/t3ProjectFileDefaults", () => ({
readT3ProjectFile: () => testState.projectFileRead,
readT3ProjectFile: (environmentId: string, workspaceRoot: string) => {
testState.projectFileReads.push({ environmentId, workspaceRoot });
return testState.projectFileRead;
},
}));
vi.mock("../lib/utils", () => ({
newDraftId: () => "draft-delayed",
newThreadId: () => "thread-delayed",
}));
vi.mock("../logicalProject", () => ({
deriveLogicalProjectKeyFromSettings: () => "remote-project",
getProjectOrderKey: () => "remote-project",
selectProjectGroupingSettings: () => ({}),
}));
vi.mock("../logicalProject", async (importOriginal) => {
const actual = await importOriginal<typeof import("../logicalProject")>();
return {
...actual,
deriveLogicalProjectKeyFromSettings: () => "remote-project",
getProjectOrderKey: () => "remote-project",
selectProjectGroupingSettings: () => ({}),
};
});
vi.mock("../state/entities", () => ({
readProjects: () => [
{
id: "project-remote",
environmentId: "environment-ssh",
workspaceRoot: "/remote/project",
defaultThreadEnvMode: null,
defaultModelSelection: null,
},
],
readProjects: () => testState.projects,
readThreadShell: () => null,
useProjects: () => [],
useThread: () => null,
}));
vi.mock("../state/environments", () => ({
useEnvironments: () => ({ environments: testState.environments }),
usePrimaryEnvironmentId: () => "environment-primary",
}));
vi.mock("../state/server", () => ({
environmentServerConfigsAtom: {},
primaryServerSettingsAtom: "primary-settings",
Expand All @@ -180,8 +255,38 @@ vi.mock("../uiStateStore", () => ({
}));
vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) }));

import { derivePhysicalProjectKey } from "../logicalProject";
import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
import { useNewThreadHandler } from "./useHandleNewThread";

const groupingSettings = {
sidebarProjectGroupingMode: "repository" as const,
sidebarProjectGroupingOverrides: {},
};

/** Logical key New Chat should register: the group key from `buildProjectGroups`, not a re-derivation. */
function expectedLogicalProjectKey(project: {
environmentId: string;
workspaceRoot: string;
}): string {
return (
buildPhysicalToLogicalProjectKeyMap({
projects: testState.projects.map((entry) => ({
...entry,
id: ProjectId.make(entry.id),
environmentId: EnvironmentId.make(entry.environmentId),
})),
settings: groupingSettings,
primaryEnvironmentId: EnvironmentId.make("environment-primary"),
}).get(
derivePhysicalProjectKey({
environmentId: EnvironmentId.make(project.environmentId),
workspaceRoot: project.workspaceRoot,
}),
) ?? "remote-project"
);
}

describe.each([
["new", null],
[
Expand All @@ -208,7 +313,7 @@ describe.each([
const opened = await pendingOpen;

expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
"remote-project",
expectedLogicalProjectKey(testState.remoteProject),
projectRef,
opened!.draftId,
expect.objectContaining({ runtimeMode }),
Expand Down Expand Up @@ -252,7 +357,7 @@ describe.each([
threadId: draft?.threadId ?? "thread-delayed",
});
expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
"remote-project",
expectedLogicalProjectKey(testState.remoteProject),
projectRef,
opened!.draftId,
expect.objectContaining({ envMode: "worktree", startFromOrigin }),
Expand All @@ -279,11 +384,140 @@ describe.each([
const opened = await openThread(projectRef, { envMode: "worktree", startFromOrigin });

expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
"remote-project",
expectedLogicalProjectKey(testState.remoteProject),
projectRef,
opened!.draftId,
expect.objectContaining({ envMode: "worktree", startFromOrigin }),
);
},
);
});

describe("useNewThreadHandler with a disconnected remote", () => {
const remoteRef = {
environmentId: "environment-ssh",
projectId: "project-remote",
} as never;

/** Marks the SSH environment reconnecting so New Chat cannot target it. */
function disconnectRemote() {
testState.environments = testState.environments.map((environment) =>
environment.environmentId === "environment-ssh"
? { ...environment, connection: { phase: "reconnecting" as const } }
: environment,
);
}

it("toasts instead of reading t3.json when no reachable copy exists", async () => {
testState.reset(null);
testState.targetSettings.defaultThreadEnvMode = null;
disconnectRemote();

const opened = await useNewThreadHandler()(remoteRef);

expect(opened).toBeNull();
expect(testState.projectFileReads).toEqual([]);
expect(testState.router.navigate).not.toHaveBeenCalled();
expect(testState.toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: "error",
title: "Environment unavailable",
description: "Build box is not connected.",
}),
);
});

it("retargets a stale remote row to the reachable local copy", async () => {
testState.reset(null);
testState.targetSettings.defaultThreadEnvMode = null;
const canonicalRemote = {
...testState.remoteProject,
id: "project-canonical-remote",
repositoryIdentity: testState.repositoryIdentity,
updatedAt: "2026-01-02T00:00:00.000Z",
};
testState.projects = [
{ ...testState.remoteProject, repositoryIdentity: null },
canonicalRemote,
testState.localProject,
];
disconnectRemote();

const pendingOpen = useNewThreadHandler()(remoteRef, {
branch: "feat",
worktreePath: "/remote/worktree",
});
expect(testState.projectFileReads).toEqual([
{ environmentId: "environment-primary", workspaceRoot: "/local/project" },
]);
testState.completeProjectFileRead(null);
const opened = await pendingOpen;

expect(opened).toEqual({ draftId: "draft-delayed", threadId: "thread-delayed" });
expect(testState.toastAdd).not.toHaveBeenCalled();
expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
expectedLogicalProjectKey(testState.localProject),
{ environmentId: "environment-primary", projectId: "project-local" },
"draft-delayed",
expect.objectContaining({
branch: null,
worktreePath: null,
envMode: "local",
}),
);
});

it("reuses the group key when the reachable winner has no repository identity", async () => {
testState.reset(null);
const unidentifiedLocal = {
...testState.localProject,
repositoryIdentity: null,
updatedAt: "2026-01-04T00:00:00.000Z",
};
testState.projects = [
{ ...testState.remoteProject, repositoryIdentity: testState.repositoryIdentity },
unidentifiedLocal,
{
...testState.localProject,
id: "project-local-identified",
updatedAt: "2026-01-03T00:00:00.000Z",
},
];
disconnectRemote();

const pendingOpen = useNewThreadHandler()(remoteRef);
testState.completeProjectFileRead(null);
await pendingOpen;

expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
testState.repositoryIdentity.canonicalKey,
{ environmentId: "environment-primary", projectId: "project-local" },
"draft-delayed",
expect.objectContaining({ envMode: "local" }),
);
});

it("keeps the requested worktree when that environment is connected", async () => {
testState.reset(null);
const featureWorktree = {
...testState.localProject,
id: "project-worktree",
workspaceRoot: "/local/project-feature",
};
testState.projects = [testState.localProject, featureWorktree];

const pendingOpen = useNewThreadHandler()({
environmentId: "environment-primary",
projectId: "project-worktree",
} as never);
testState.completeProjectFileRead(null);
await pendingOpen;

expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
expectedLogicalProjectKey(featureWorktree),
{ environmentId: "environment-primary", projectId: "project-worktree" },
"draft-delayed",
expect.objectContaining({ envMode: "local" }),
);
});
});
Loading
Loading