diff --git a/apps/web/src/components/chat/DraftHeroHeadline.browser.test.tsx b/apps/web/src/components/chat/DraftHeroHeadline.browser.test.tsx new file mode 100644 index 000000000000..4a1aef514ef4 --- /dev/null +++ b/apps/web/src/components/chat/DraftHeroHeadline.browser.test.tsx @@ -0,0 +1,93 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { ProjectId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { AppRoot } from "~/AppRoot"; +// The draft route loads ChatView lazily; importing it here keeps its first transform out of the test's timeout. +import "~/components/ChatView"; +import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { useUiStateStore } from "~/uiStateStore"; +import { + ENVIRONMENT_ID, + PROJECT_ID, + installFakeEnvironment, + type FakeDriver, +} from "../../../test/fakeEnvironment"; +import { render } from "../../../test/render"; + +// The socket is the only fake layer; see test/appScenarios.renders.tsx. +vi.mock( + "../../../../../packages/client-runtime/src/connection/driver.ts", + async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; + }, +); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +it("the new-thread project picker follows the manual sidebar project order", async () => { + using _environment = installFakeEnvironment( + [], + [ + { id: ProjectId.make("project-alpha"), title: "alpha" }, + { id: ProjectId.make("project-beta"), title: "beta" }, + ], + ); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + sidebarProjectSortOrder: "manual", + }); + useUiStateStore.setState({ + projectOrder: [ + `${ENVIRONMENT_ID}:/workspace/beta`, + `${ENVIRONMENT_ID}:/workspace/render`, + `${ENVIRONMENT_ID}:/workspace/alpha`, + ], + }); + using _order = { [Symbol.dispose]: () => useUiStateStore.setState({ projectOrder: [] }) }; + const draftId = DraftId.make("draft-manual-order"); + useComposerDraftStore + .getState() + .setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + // The shell snapshot has arrived once the sidebar lists the project. + await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible(); + const trigger = document.querySelector("[data-draft-project-trigger]"); + if (!trigger) throw new Error("The draft project picker did not render."); + await userEvent.click(page.elementLocator(trigger)); + await expect.element(page.getByRole("option", { name: "beta" })).toBeVisible(); + + // The draft's own project leads; the rest keep the sidebar's order. + expect( + page + .getByRole("option") + .elements() + .map((option) => option.textContent), + ).toEqual([ + expect.stringMatching(/render$/), + expect.stringMatching(/beta$/), + expect.stringMatching(/alpha$/), + "Add project", + ]); +}); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index dac76937a9cc..3a454fd5fa5e 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -24,10 +24,9 @@ import { } from "@t3tools/client-runtime/state/project-grouping"; import { buildSidebarProjectPickerEntries, - buildSidebarProjectSnapshots, projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; -import { useProjects, useVisibleThreadShells } from "~/state/entities"; +import { useProjects } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId, @@ -36,7 +35,7 @@ import { import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { isProjectPinShortcut, ProjectPinButton } from "../ProjectPinButton"; -import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; +import { useSortedProjectGroups } from "../CommandPaletteLookups"; import { Combobox, ComboboxEmpty, @@ -75,12 +74,10 @@ export function DraftHeroHeadline({ activeProjectTitle, }: DraftHeroHeadlineProps) { const projects = useProjects(); - const threads = useVisibleThreadShells(); const { environments } = useEnvironments(); const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, ); @@ -104,16 +101,7 @@ export function DraftHeroHeadline({ const environmentLabelById = new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ); - const projectGroups = sortLogicalProjectsForSidebar( - buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }), - threads, - projectSortOrder, - ); + const projectGroups = useSortedProjectGroups(environmentLabelById); const showProjectEnvironments = projectGroupsSpanEnvironments(projectGroups); const environmentMachineById = new Map( environments.map( diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 44cd45d127e1..3d60aa43833a 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -318,6 +318,7 @@ const json = (body: unknown, status = 200) => const threadSubscribers = new Map>(); let terminalMetadataSubscriber: Queue.Queue | null = null; let threads: ReadonlyArray = []; +let projects: ReadonlyArray = [project]; let sequence = 1; let vcsStatusSubscriber: Queue.Queue | null = null; @@ -349,7 +350,7 @@ const handlers: Record unknown> = { snapshot: { schemaVersion: 1, snapshotSequence: sequence, - projects: [project], + projects, threads: threads.map(shellOf), archivedThreads: [], }, @@ -433,8 +434,19 @@ const encodedDescriptor = Schema.encodeSync(ExecutionEnvironmentDescriptor)( ); /** Serves the environment's HTTP API and these threads until disposed. */ -export function installFakeEnvironment(fixtures: ReadonlyArray): Disposable { +export function installFakeEnvironment( + fixtures: ReadonlyArray, + extraProjects: ReadonlyArray> = [], +): Disposable { threads = fixtures; + projects = [ + project, + ...extraProjects.map((extra) => ({ + ...project, + ...extra, + workspaceRoot: `/workspace/${extra.title}`, + })), + ]; globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); if (url.origin !== window.location.origin) return realFetch(input, init); @@ -456,6 +468,7 @@ export function installFakeEnvironment(fixtures: ReadonlyArray): Dis [Symbol.dispose]: () => { globalThis.fetch = realFetch; threads = []; + projects = [project]; }, }; }