From 2f03dfa0a6657fc3a9803e8fab67af8d720b6215 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 4 Jun 2026 15:05:50 -0500 Subject: [PATCH] fix: stabilize hydration layout slots --- .../AgentsInitBanner/AgentsInitBanner.tsx | 22 +- .../AppLoader/AppLoader.auth.test.tsx | 68 ++++-- .../ConfiguredProvidersBar.tsx | 21 +- .../ProjectPage.autofocus.test.tsx | 36 ++- .../components/ProjectPage/ProjectPage.tsx | 152 ++++++++---- src/browser/features/ChatInput/index.tsx | 6 +- src/browser/hooks/useProvidersConfig.test.ts | 82 +++++++ src/browser/hooks/useProvidersConfig.ts | 70 +++++- src/common/constants/storage.ts | 7 + .../hydrationLayoutStability.spec.ts | 225 ++++++++++++++++++ 10 files changed, 603 insertions(+), 86 deletions(-) create mode 100644 src/browser/hooks/useProvidersConfig.test.ts create mode 100644 tests/e2e/scenarios/hydrationLayoutStability.spec.ts diff --git a/src/browser/components/AgentsInitBanner/AgentsInitBanner.tsx b/src/browser/components/AgentsInitBanner/AgentsInitBanner.tsx index e2218d7c7a..eeeee78d7b 100644 --- a/src/browser/components/AgentsInitBanner/AgentsInitBanner.tsx +++ b/src/browser/components/AgentsInitBanner/AgentsInitBanner.tsx @@ -1,5 +1,8 @@ import { Bot, X } from "lucide-react"; +const AGENTS_INIT_BANNER_FRAME_CLASS = + "bg-bg-dark border-border-medium flex min-h-[4.5rem] items-center gap-3 rounded-lg border px-4 py-3"; + interface AgentsInitBannerProps { onRunInit: () => void | Promise; onDismiss: () => void; @@ -11,10 +14,7 @@ interface AgentsInitBannerProps { */ export function AgentsInitBanner(props: AgentsInitBannerProps) { return ( -
+
@@ -49,3 +49,17 @@ export function AgentsInitBanner(props: AgentsInitBannerProps) {
); } + +export function AgentsInitBannerPlaceholder() { + return ( + + ); +} diff --git a/src/browser/components/AppLoader/AppLoader.auth.test.tsx b/src/browser/components/AppLoader/AppLoader.auth.test.tsx index c2a12c0639..928df2f03b 100644 --- a/src/browser/components/AppLoader/AppLoader.auth.test.tsx +++ b/src/browser/components/AppLoader/AppLoader.auth.test.tsx @@ -4,6 +4,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { cleanup, render } from "@testing-library/react"; import { useTheme } from "../../contexts/ThemeContext"; +import type { APIClient, UseAPIResult } from "../../contexts/API"; import { installDom } from "../../../../tests/ui/dom"; let cleanupDom: (() => void) | null = null; @@ -27,37 +28,56 @@ void mock.module("lottie-react", () => ({ default: () =>
, })); -void mock.module("@/browser/contexts/API", () => ({ - APIProvider: (props: { children: React.ReactNode }) => props.children, - useAPI: () => { - if (apiStatus === "auth_required") { - return { - api: null, - status: "auth_required" as const, - error: apiError, - authenticate: () => undefined, - retry: () => undefined, - }; - } - - if (apiStatus === "error") { - return { - api: null, - status: "error" as const, - error: apiError ?? "Connection error", - authenticate: () => undefined, - retry: () => undefined, - }; - } +function getMockAPIContextValue(): UseAPIResult { + if (apiStatus === "auth_required") { + return { + api: null, + status: "auth_required" as const, + error: apiError, + authenticate: () => undefined, + retry: () => undefined, + }; + } + if (apiStatus === "error") { return { api: null, - status: "connecting" as const, - error: null, + status: "error" as const, + error: apiError ?? "Connection error", authenticate: () => undefined, retry: () => undefined, }; + } + + return { + api: null, + status: "connecting" as const, + error: null, + authenticate: () => undefined, + retry: () => undefined, + }; +} + +const MockAPIContext = React.createContext(null); +let injectedAPIValue: UseAPIResult | null = null; + +void mock.module("@/browser/contexts/API", () => ({ + APIContext: MockAPIContext, + APIProvider: (props: { children: React.ReactNode; client?: APIClient }) => { + const value = props.client + ? { + api: props.client, + status: "connected" as const, + error: null, + authenticate: () => undefined, + retry: () => undefined, + } + : getMockAPIContextValue(); + injectedAPIValue = value; + return {props.children}; }, + useAPI: () => injectedAPIValue ?? getMockAPIContextValue(), + useOptionalAPI: () => injectedAPIValue ?? getMockAPIContextValue(), })); void mock.module("@/browser/components/LoadingScreen/LoadingScreen", () => ({ diff --git a/src/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar.tsx b/src/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar.tsx index 1c5a51fd07..c0a3298ab4 100644 --- a/src/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar.tsx +++ b/src/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar.tsx @@ -1,5 +1,6 @@ import { Check, Settings } from "lucide-react"; import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { Skeleton } from "../Skeleton/Skeleton"; import { formatProviderDisplayName } from "@/common/utils/providers/customProviders"; import { usePolicy } from "@/browser/contexts/PolicyContext"; import { getAllowedProvidersForUi } from "@/browser/utils/policyUi"; @@ -7,6 +8,21 @@ import { hasProviderIcon, ProviderIcon } from "../ProviderIcon/ProviderIcon"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip/Tooltip"; +const CONFIGURED_PROVIDERS_BAR_FRAME_CLASS = "flex h-9 items-center justify-center gap-2 text-sm"; + +export function ConfiguredProvidersBarSkeleton() { + return ( +
+ +
+ ); +} + interface ConfiguredProvidersBarProps { providersConfig: ProvidersConfigMap; } @@ -35,7 +51,10 @@ export function ConfiguredProvidersBar(props: ConfiguredProvidersBarProps) { .join(", "); return ( -
+
diff --git a/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx b/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx index 314af5729b..5444818e0f 100644 --- a/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx +++ b/src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { createContext, useEffect, type ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { requireTestModule } from "@/browser/testUtils"; import { RouterProvider } from "@/browser/contexts/RouterContext"; @@ -23,14 +23,18 @@ function registerProjectPageMocks() { default: () =>
, })); + const apiContextValue = { + api: null, + status: "connecting" as const, + error: null, + authenticate: () => undefined, + retry: () => undefined, + }; void mock.module("@/browser/contexts/API", () => ({ - useAPI: () => ({ - api: null, - status: "connecting" as const, - error: null, - authenticate: () => undefined, - retry: () => undefined, - }), + APIContext: createContext(apiContextValue), + APIProvider: (props: { children: ReactNode }) => props.children, + useAPI: () => apiContextValue, + useOptionalAPI: () => apiContextValue, })); // Mock useProvidersConfig to return a configured provider so ChatInput renders @@ -43,13 +47,21 @@ function registerProjectPageMocks() { })); // Mock ConfiguredProvidersBar to avoid tooltip/context dependencies + const providerBarFrameClass = "flex h-9 items-center justify-center gap-2 text-sm"; void mock.module("@/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar", () => ({ - ConfiguredProvidersBar: () =>
, + CONFIGURED_PROVIDERS_BAR_FRAME_CLASS: providerBarFrameClass, + ConfiguredProvidersBar: () => ( +
+ ), + ConfiguredProvidersBarSkeleton: () => ( +
+ ), })); // Mock ProjectContext to provide the minimal routing/project surface used by the // real WorkspaceProvider/AgentProvider stack without wiring the full app shell. void mock.module("@/browser/contexts/ProjectContext", () => ({ + ProjectProvider: (props: { children: ReactNode }) => props.children, useProjectContext: () => ({ userProjects: new Map(), systemProjectPath: null, @@ -91,6 +103,8 @@ function registerProjectPageMocks() { // Mock ChatInput to simulate the old (buggy) behavior where onReady can fire again // on unrelated re-renders (e.g. workspace list updates). void mock.module("@/browser/features/ChatInput/index", () => ({ + CREATION_CHAT_INPUT_SECTION_FRAME_CLASS: + "bg-surface-primary border-border-light min-h-56 w-full max-w-3xl rounded-lg border px-6 py-5 shadow-lg", ChatInput: (props: { onReady?: (api: { focus: () => void; @@ -165,8 +179,8 @@ describe("ProjectPage", () => { ); - await waitFor(() => expect(readyCalls).toBe(1)); await waitFor(() => expect(focusMock).toHaveBeenCalledTimes(1)); + const readyCallsAfterInitialHydration = readyCalls; // Simulate an unrelated App re-render that changes an inline callback identity. rerender( @@ -179,7 +193,7 @@ describe("ProjectPage", () => { ); - await waitFor(() => expect(readyCalls).toBe(2)); + await waitFor(() => expect(readyCalls).toBeGreaterThan(readyCallsAfterInitialHydration)); // Focus should not be re-triggered (would move caret to end). expect(focusMock).toHaveBeenCalledTimes(1); diff --git a/src/browser/components/ProjectPage/ProjectPage.tsx b/src/browser/components/ProjectPage/ProjectPage.tsx index 5b70a79ea0..78cfe76949 100644 --- a/src/browser/components/ProjectPage/ProjectPage.tsx +++ b/src/browser/components/ProjectPage/ProjectPage.tsx @@ -4,18 +4,28 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { cn } from "@/common/lib/utils"; import { AgentProvider } from "@/browser/contexts/AgentContext"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; -import { ChatInput } from "@/browser/features/ChatInput/index"; +import { + ChatInput, + CREATION_CHAT_INPUT_SECTION_FRAME_CLASS, +} from "@/browser/features/ChatInput/index"; import type { ChatInputAPI, WorkspaceCreatedOptions } from "@/browser/features/ChatInput/types"; import { ProjectMCPOverview } from "../ProjectMCPOverview/ProjectMCPOverview"; import { ArchivedWorkspaces } from "../ArchivedWorkspaces/ArchivedWorkspaces"; import { useAPI } from "@/browser/contexts/API"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { GitInitBanner } from "../GitInitBanner/GitInitBanner"; -import { ConfiguredProvidersBar } from "../ConfiguredProvidersBar/ConfiguredProvidersBar"; +import { + ConfiguredProvidersBar, + ConfiguredProvidersBarSkeleton, +} from "../ConfiguredProvidersBar/ConfiguredProvidersBar"; import { ConfigureProvidersPrompt } from "../ConfigureProvidersPrompt/ConfigureProvidersPrompt"; +import { Skeleton } from "../Skeleton/Skeleton"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import type { ProvidersConfigMap } from "@/common/orpc/types"; -import { AgentsInitBanner } from "../AgentsInitBanner/AgentsInitBanner"; +import { + AgentsInitBanner, + AgentsInitBannerPlaceholder, +} from "../AgentsInitBanner/AgentsInitBanner"; import { usePersistedState, updatePersistedState, @@ -25,13 +35,13 @@ import { getAgentIdKey, getAgentsInitNudgeKey, getArchivedWorkspacesKey, + HAS_CONFIGURED_PROVIDER_CACHE_KEY, getDraftScopeId, getInputKey, getPendingScopeId, getProjectScopeId, } from "@/common/constants/storage"; import { Button } from "@/browser/components/Button/Button"; -import { Skeleton } from "@/browser/components/Skeleton/Skeleton"; import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; interface ProjectPageProps { @@ -65,6 +75,40 @@ function hasConfiguredProvider(config: ProvidersConfigMap | null): boolean { return Object.values(config).some((provider) => provider?.isConfigured); } +const PROJECT_CREATION_PROVIDER_GATE_CLASS = "flex min-h-[30rem] flex-col justify-end gap-4"; + +function CreationChatInputSkeleton() { + return ( + + ); +} + /** * Project page shown when a project is selected but no workspace is active. * Combines workspace creation with archived workspaces view. @@ -90,9 +134,19 @@ export const ProjectPage: React.FC = ({ false, { listener: true } ); + const [cachedHasProviders] = useState(() => + readPersistedState(HAS_CONFIGURED_PROVIDER_CACHE_KEY, null) + ); const { config: providersConfig, loading: providersLoading } = useProvidersConfig(); const hasProviders = hasConfiguredProvider(providersConfig); - const shouldShowAgentsInitBanner = !providersLoading && hasProviders && showAgentsInitNudge; + const effectiveHasProviders = providersLoading ? cachedHasProviders : hasProviders; + const isProviderAvailabilityUnknown = effectiveHasProviders === null; + const shouldShowProviderPrompt = effectiveHasProviders === false; + const shouldRenderCreationChat = effectiveHasProviders === true; + const shouldReserveAgentsInitBanner = + showAgentsInitNudge && (providersLoading || shouldRenderCreationChat); + const shouldShowAgentsInitBanner = + !providersLoading && shouldRenderCreationChat && showAgentsInitNudge; // Git repository state for the banner const [branchesLoaded, setBranchesLoaded] = useState(false); @@ -296,44 +350,56 @@ export const ProjectPage: React.FC = ({ {isNonGitRepo && ( )} - {/* Show configure prompt when no providers, otherwise show ChatInput */} - {!providersLoading && !hasProviders ? ( - - ) : ( - <> - {shouldShowAgentsInitBanner && ( - - )} - {/* Configured providers bar - compact icon carousel */} - {providersLoading ? ( - // Skeleton placeholder matching ConfiguredProvidersBar height -
- -
- ) : ( - hasProviders && - providersConfig && ( - - ) - )} - {/* ChatInput for workspace creation. */} - - - )} +
+ {/* Show configure prompt when no providers, otherwise show ChatInput. */} + {shouldShowProviderPrompt ? ( + + ) : ( + <> + {shouldReserveAgentsInitBanner && + (shouldShowAgentsInitBanner ? ( + + ) : ( + + ))} + {/* Configured providers bar - compact icon carousel */} + {providersLoading ? ( + + ) : ( + hasProviders && + providersConfig && ( + + ) + )} + {/* ChatInput for workspace creation. */} + {isProviderAvailabilityUnknown ? ( + + ) : shouldRenderCreationChat ? ( + + ) : null} + + )} +
diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index e0213d2075..4e31597fed 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -211,6 +211,9 @@ function replaceSuggestions(prev: SlashSuggestion[], next: SlashSuggestion[]): S return prev.length === 0 && next.length === 0 ? prev : next; } +export const CREATION_CHAT_INPUT_SECTION_FRAME_CLASS = + "bg-surface-primary border-border-light min-h-56 w-full max-w-3xl rounded-lg border px-6 py-5 shadow-lg"; + const PDF_MEDIA_TYPE = "application/pdf"; function getBaseMediaType(mediaType: string): string { @@ -2964,11 +2967,12 @@ const ChatInputInner: React.FC = (props) => { className={cn( "relative flex flex-col gap-1", variant === "creation" - ? "bg-surface-primary w-full max-w-3xl rounded-lg border border-border-light px-6 py-5 shadow-lg" + ? CREATION_CHAT_INPUT_SECTION_FRAME_CLASS : `bg-surface-primary border-border-light px-4 pb-[max(8px,min(env(safe-area-inset-bottom,0px),40px))] mb-[calc(-1*min(env(safe-area-inset-bottom,0px),40px))]` )} + data-chat-input-variant={variant} data-component="ChatInputSection" data-autofocus-state="done" > diff --git a/src/browser/hooks/useProvidersConfig.test.ts b/src/browser/hooks/useProvidersConfig.test.ts new file mode 100644 index 0000000000..5d6824832e --- /dev/null +++ b/src/browser/hooks/useProvidersConfig.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; + +import type { ProviderConfigInfo } from "@/common/orpc/types"; +import { getOptimisticConfiguredProvider } from "./useProvidersConfig"; + +function providerConfig(overrides: Partial = {}): ProviderConfigInfo { + return { + apiKeySet: false, + isEnabled: true, + isConfigured: false, + ...overrides, + }; +} + +describe("getOptimisticConfiguredProvider", () => { + test("requires custom provider base URL even when auth is present", () => { + const withAuthOnly = getOptimisticConfiguredProvider( + "custom-openai-compatible", + providerConfig({ + apiKeySet: true, + apiKeySource: "config", + providerType: "openai-compatible", + isCustom: true, + }) + ); + + expect(withAuthOnly.isConfigured).toBe(false); + + const withBaseUrl = getOptimisticConfiguredProvider( + "custom-openai-compatible", + providerConfig({ + apiKeySet: true, + apiKeySource: "config", + baseUrl: "https://models.example.test/v1", + providerType: "openai-compatible", + isCustom: true, + }) + ); + + expect(withBaseUrl.isConfigured).toBe(true); + }); + + test("mirrors Bedrock region-based configuredness instead of credential flags", () => { + const credentialsWithoutRegion = getOptimisticConfiguredProvider( + "bedrock", + providerConfig({ + aws: { + bearerTokenSet: true, + accessKeyIdSet: true, + secretAccessKeySet: true, + }, + }) + ); + + expect(credentialsWithoutRegion.isConfigured).toBe(false); + + const regionOnly = getOptimisticConfiguredProvider( + "bedrock", + providerConfig({ + aws: { + region: "us-east-1", + bearerTokenSet: false, + accessKeyIdSet: false, + secretAccessKeySet: false, + }, + }) + ); + + expect(regionOnly.isConfigured).toBe(true); + }); + + test("keeps keyless local providers tied to explicit base URL or model config", () => { + const emptyOllama = getOptimisticConfiguredProvider("ollama", providerConfig()); + expect(emptyOllama.isConfigured).toBe(false); + + const withModel = getOptimisticConfiguredProvider( + "ollama", + providerConfig({ models: [{ id: "llama3.2" }] }) + ); + expect(withModel.isConfigured).toBe(true); + }); +}); diff --git a/src/browser/hooks/useProvidersConfig.ts b/src/browser/hooks/useProvidersConfig.ts index 71db535306..fae77835c4 100644 --- a/src/browser/hooks/useProvidersConfig.ts +++ b/src/browser/hooks/useProvidersConfig.ts @@ -1,11 +1,66 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useAPI } from "@/browser/contexts/API"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { HAS_CONFIGURED_PROVIDER_CACHE_KEY } from "@/common/constants/storage"; import type { ProviderConfigInfo, ProviderModelEntry, ProvidersConfigMap, } from "@/common/orpc/types"; +function hasConfiguredProvider(config: ProvidersConfigMap): boolean { + return Object.values(config).some((provider) => provider?.isConfigured); +} + +function hasText(value: string | null | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +export function getOptimisticConfiguredProvider( + provider: string, + info: ProviderConfigInfo +): ProviderConfigInfo { + const hasBaseUrl = hasText(info.baseUrl) || hasText(info.baseUrlResolved); + const hasModels = (info.models?.length ?? 0) > 0; + const isEnabled = info.isEnabled !== false; + + const isConfigured = (() => { + if (!isEnabled) return false; + + if (provider === "bedrock") { + return hasText(info.aws?.region); + } + + if (provider === "mux-gateway") { + return info.couponCodeSet === true; + } + + if (info.isCustom === true || info.providerType === "openai-compatible") { + return hasBaseUrl; + } + + if (provider === "ollama") { + return hasBaseUrl || hasModels; + } + + // This is a deliberately conservative browser-side mirror of the backend's + // computed configuredness. The backend refresh remains authoritative, but the + // local mirror prevents ProjectPage from hydrating through a stale provider branch. + return ( + info.apiKeySet === true || + info.apiKeyFile != null || + info.apiKeySource === "env" || + info.codexOauthSet === true + ); + })(); + + return { ...info, isConfigured }; +} + +function updateHasConfiguredProviderCache(config: ProvidersConfigMap): void { + updatePersistedState(HAS_CONFIGURED_PROVIDER_CACHE_KEY, hasConfiguredProvider(config)); +} + /** * Hook to get provider config with automatic refresh on config changes. * Subscribes to the backend's onConfigChanged event for external changes. @@ -33,6 +88,7 @@ export function useProvidersConfig() { // Only update if this is the latest fetch (ignore stale responses) if (myVersion === fetchVersionRef.current) { configRef.current = cfg; + updateHasConfiguredProviderCache(cfg); setConfig(cfg); } } catch { @@ -58,12 +114,17 @@ export function useProvidersConfig() { const prev = configRef.current; if (!prev) return; + const nextProvider = getOptimisticConfiguredProvider(provider, { + ...prev[provider], + ...updates, + }); const next: ProvidersConfigMap = { ...prev, - [provider]: { ...prev[provider], ...updates }, + [provider]: nextProvider, }; configRef.current = next; + updateHasConfiguredProviderCache(next); setConfig(next); }, [] @@ -88,12 +149,17 @@ export function useProvidersConfig() { const currentModels = prev[provider]?.models ?? []; const newModels = updater(currentModels); + const nextProvider = getOptimisticConfiguredProvider(provider, { + ...prev[provider], + models: newModels, + }); const next: ProvidersConfigMap = { ...prev, - [provider]: { ...prev[provider], models: newModels }, + [provider]: nextProvider, }; configRef.current = next; + updateHasConfiguredProviderCache(next); setConfig(next); return newModels; }, diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 793180b9a5..8bb438d476 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -315,6 +315,13 @@ export function getTrunkBranchKey(projectPath: string): string { return `trunkBranch:${projectPath}`; } +/** + * Synchronous mirror of whether any provider was configured on the last provider config load. + * ProjectPage uses this to choose the same creation shell during hydration instead of + * briefly showing the wrong no-provider/configured-provider layout while config loads. + */ +export const HAS_CONFIGURED_PROVIDER_CACHE_KEY = "hasConfiguredProviderCache"; + /** * Get the localStorage key for whether to show the "Initialize with AGENTS.md" nudge for a project. * Set to true when a project is first added; cleared when user dismisses or runs /init. diff --git a/tests/e2e/scenarios/hydrationLayoutStability.spec.ts b/tests/e2e/scenarios/hydrationLayoutStability.spec.ts new file mode 100644 index 0000000000..fbe856d72b --- /dev/null +++ b/tests/e2e/scenarios/hydrationLayoutStability.spec.ts @@ -0,0 +1,225 @@ +import type { Page } from "@playwright/test"; +import { electronTest as test, electronExpect as expect } from "../electronTest"; +import { + LAST_VISITED_ROUTE_KEY, + SELECTED_WORKSPACE_KEY, +} from "../../../src/common/constants/storage"; + +// Real-browser hydration regression coverage. happy-dom cannot observe the small +// first-paint geometry deltas users reported, so this samples consecutive Chromium +// animation frames and treats sub-pixel noise as OK while catching visible pixel jumps. +test.skip( + ({ browserName }) => browserName !== "chromium", + "Electron scenario runs on chromium only" +); + +const CREATION_CHAT_INPUT_SECTION = + '[data-component="ChatInputSection"][data-chat-input-variant="creation"]'; +const MESSAGE_WINDOW = '[data-testid="message-window"]'; +const COMPOSER_DOCK = '[data-testid="chat-composer-dock"]'; +const FIRST_MESSAGE = "[data-message-id]"; + +const MAX_VISIBLE_VERTICAL_SHIFT_PX = 0.75; +const MAX_STARTUP_FRAMES = 600; +const FRAMES_AFTER_TARGET_VISIBLE = 45; +const SAMPLE_INTERVAL_MS = 25; + +interface RectSnapshot { + top: number; + bottom: number; + height: number; +} + +interface HydrationFrame { + frame: number; + timestamp: number; + hasMarker: boolean; + chatInput: RectSnapshot | null; + messageWindow: RectSnapshot | null; + composer: RectSnapshot | null; + firstMessage: RectSnapshot | null; +} + +type RectKey = "chatInput" | "messageWindow" | "composer" | "firstMessage"; +type RectProperty = keyof RectSnapshot; + +function workspaceRoute(workspaceId: string): string { + return `/workspace/${encodeURIComponent(workspaceId)}`; +} + +async function restoreRouteOnReload(page: Page, route: string) { + await page.evaluate( + ({ routeKey, selectedWorkspaceKey, value }) => { + localStorage.setItem(routeKey, JSON.stringify(value)); + if (value.startsWith("/project")) { + localStorage.removeItem(selectedWorkspaceKey); + } + }, + { routeKey: LAST_VISITED_ROUTE_KEY, selectedWorkspaceKey: SELECTED_WORKSPACE_KEY, value: route } + ); +} + +async function sampleHydrationFrame( + page: Page, + options: { frame: number; marker?: string } +): Promise { + return await page.evaluate( + ({ frame, marker, selectors }) => { + const snapshotRect = (selector: string): RectSnapshot | null => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + bottom: rect.bottom, + height: rect.height, + }; + }; + + const bodyText = document.body.textContent ?? ""; + return { + frame, + timestamp: performance.now(), + hasMarker: marker ? bodyText.includes(marker) : false, + chatInput: snapshotRect(selectors.chatInput), + messageWindow: snapshotRect(selectors.messageWindow), + composer: snapshotRect(selectors.composerDock), + firstMessage: snapshotRect(selectors.firstMessage), + }; + }, + { + frame: options.frame, + marker: options.marker ?? null, + selectors: { + chatInput: CREATION_CHAT_INPUT_SECTION, + messageWindow: MESSAGE_WINDOW, + composerDock: COMPOSER_DOCK, + firstMessage: FIRST_MESSAGE, + }, + } + ); +} + +async function loadRouteForSampling(page: Page, route: string): Promise { + await restoreRouteOnReload(page, route); + + const currentUrl = new URL(page.url()); + if (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") { + await page.goto(new URL(route, currentUrl.origin).toString(), { + waitUntil: "domcontentloaded", + }); + return; + } + + await page.reload({ waitUntil: "domcontentloaded" }); +} + +async function reloadAndSampleHydrationFrames( + page: Page, + options: { route?: string; marker?: string; target: RectKey } +): Promise { + if (options.route) { + await loadRouteForSampling(page, options.route); + } + + // Match the composer layout stability test: Playwright drives sampling because + // renderer requestAnimationFrame can be throttled under headless xvfb. + const frames: HydrationFrame[] = []; + let visibleTargetFrameCount = 0; + for (let frame = 0; frame < MAX_STARTUP_FRAMES; frame += 1) { + const sample = await sampleHydrationFrame(page, { frame, marker: options.marker }); + frames.push(sample); + + if (sample[options.target] !== null && (!options.marker || sample.hasMarker)) { + visibleTargetFrameCount += 1; + if (visibleTargetFrameCount >= FRAMES_AFTER_TARGET_VISIBLE) { + break; + } + } + + await page.waitForTimeout(SAMPLE_INTERVAL_MS); + } + + return frames; +} + +function firstFrameIndex( + frames: readonly HydrationFrame[], + predicate: (frame: HydrationFrame) => boolean +): number { + const index = frames.findIndex(predicate); + if (index === -1) { + throw new Error("Expected hydration frame was never observed"); + } + return index; +} + +function maxRectDelta( + frames: readonly HydrationFrame[], + key: RectKey, + property: RectProperty, + fromIndex: number +): number { + const visible = frames + .slice(fromIndex) + .map((frame) => frame[key]) + .filter(Boolean) as RectSnapshot[]; + if (visible.length < 2) { + throw new Error(`Need at least two visible ${key} frames to measure layout stability`); + } + + const values = visible.map((rect) => rect[property]); + return Math.max(...values) - Math.min(...values); +} + +async function waitForCompletedMockResponse(page: Page, marker: string): Promise { + await page.waitForFunction( + (expectedMarker: string) => { + const messages = document.querySelectorAll("[data-message-block]"); + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null; + const lastMessageText = lastMessage?.textContent ?? ""; + const actionButtonCount = + lastMessage?.querySelectorAll("[data-message-meta-actions] button").length ?? 0; + return lastMessageText.includes(`Mock response: ${expectedMarker}`) && actionButtonCount > 1; + }, + marker, + { timeout: 60_000 } + ); +} + +test.describe("Hydration layout stability", () => { + test("keeps an existing chat shell vertically stable while a routed transcript opens", async ({ + page, + ui, + workspace, + }) => { + await ui.projects.openFirstWorkspace(); + const marker = "[[hydration-layout-stability-existing-chat]]"; + await ui.chat.sendMessage(`${marker} seed a completed transcript before reload`); + await waitForCompletedMockResponse(page, marker); + + const frames = await reloadAndSampleHydrationFrames(page, { + route: workspaceRoute(workspace.demoProject.workspaceId), + marker, + target: "messageWindow", + }); + const firstWindowFrame = firstFrameIndex(frames, (frame) => frame.messageWindow !== null); + const firstTranscriptFrame = firstFrameIndex( + frames, + (frame) => frame.hasMarker && frame.firstMessage !== null + ); + + expect(maxRectDelta(frames, "messageWindow", "top", firstWindowFrame)).toBeLessThanOrEqual( + MAX_VISIBLE_VERTICAL_SHIFT_PX + ); + expect(maxRectDelta(frames, "messageWindow", "height", firstWindowFrame)).toBeLessThanOrEqual( + MAX_VISIBLE_VERTICAL_SHIFT_PX + ); + expect(maxRectDelta(frames, "composer", "top", firstWindowFrame)).toBeLessThanOrEqual( + MAX_VISIBLE_VERTICAL_SHIFT_PX + ); + expect(maxRectDelta(frames, "firstMessage", "top", firstTranscriptFrame)).toBeLessThanOrEqual( + MAX_VISIBLE_VERTICAL_SHIFT_PX + ); + }); +});