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..d24e099f8b60 --- /dev/null +++ b/apps/web/src/components/chat/DraftHeroHeadline.browser.test.tsx @@ -0,0 +1,94 @@ +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([], { + extraProjects: [ + { 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. + await vi.waitFor(() => { + 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 e621f491527d..26c741cd8b8d 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 6fc18e58dea8..5e6706d861a8 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -408,6 +408,7 @@ let terminalMetadataSubscriber: Queue.Queue | null let threads: ReadonlyArray = []; let dispatched: OrchestrationV2Command[] = []; let acceptCommands = false; +let projects: ReadonlyArray = [project]; let holdCommand: (command: OrchestrationV2Command) => boolean = () => false; let pendingDispatches: Array<{ readonly command: OrchestrationV2Command; @@ -463,7 +464,7 @@ const handlers: Record unknown> = { snapshot: { schemaVersion: 1, snapshotSequence: sequence, - projects: [project], + projects, threads: threads.filter((thread) => !thread.olderSettled).map(shellOf), archivedThreads: [], }, @@ -561,6 +562,7 @@ export function installFakeEnvironment( fixtures: ReadonlyArray, options?: { readonly acceptCommands?: boolean; + readonly extraProjects?: ReadonlyArray>; /** Commands left unanswered even when accepting, until `rejectPendingCommand`. */ readonly holdCommand?: (command: OrchestrationV2Command) => boolean; readonly providers?: ReadonlyArray; @@ -570,6 +572,14 @@ export function installFakeEnvironment( baseServerConfig = makeServerConfig(options?.providers ?? [CODEX_PROVIDER]); dispatched = []; acceptCommands = options?.acceptCommands === true; + projects = [ + project, + ...(options?.extraProjects ?? []).map((extra) => ({ + ...project, + ...extra, + workspaceRoot: `/workspace/${extra.title}`, + })), + ]; holdCommand = options?.holdCommand ?? (() => false); globalThis.fetch = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); @@ -595,6 +605,7 @@ export function installFakeEnvironment( baseServerConfig = makeServerConfig([CODEX_PROVIDER]); dispatched = []; acceptCommands = false; + projects = [project]; holdCommand = () => false; pendingDispatches = []; pendingAssetUrls = [];