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
22 changes: 18 additions & 4 deletions src/browser/components/AgentsInitBanner/AgentsInitBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
onDismiss: () => void;
Expand All @@ -11,10 +14,7 @@ interface AgentsInitBannerProps {
*/
export function AgentsInitBanner(props: AgentsInitBannerProps) {
return (
<div
className="bg-bg-dark border-border-medium flex items-center gap-3 rounded-lg border px-4 py-3"
data-testid="agents-init-banner"
>
<div className={AGENTS_INIT_BANNER_FRAME_CLASS} data-testid="agents-init-banner">
<Bot className="text-muted-foreground h-5 w-5 shrink-0" />
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-foreground text-sm font-medium">
Expand Down Expand Up @@ -49,3 +49,17 @@ export function AgentsInitBanner(props: AgentsInitBannerProps) {
</div>
);
}

export function AgentsInitBannerPlaceholder() {
return (
<div
// Render the real banner invisibly so future copy or wrapping changes reserve
// the exact same responsive height while provider config hydrates.
aria-hidden="true"
className="invisible"
data-testid="agents-init-banner-placeholder"
>
<AgentsInitBanner onRunInit={() => undefined} onDismiss={() => undefined} />
</div>
);
}
68 changes: 44 additions & 24 deletions src/browser/components/AppLoader/AppLoader.auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,37 +28,56 @@ void mock.module("lottie-react", () => ({
default: () => <div data-testid="LottieMock" />,
}));

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<UseAPIResult | null>(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 <MockAPIContext.Provider value={value}>{props.children}</MockAPIContext.Provider>;
},
useAPI: () => injectedAPIValue ?? getMockAPIContextValue(),
useOptionalAPI: () => injectedAPIValue ?? getMockAPIContextValue(),
}));

void mock.module("@/browser/components/LoadingScreen/LoadingScreen", () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
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";
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 (
<div
// User-reported hydration flash: keep this loading slot height tied to
// ConfiguredProvidersBar so ProjectPage cannot jump when provider config resolves.
className={CONFIGURED_PROVIDERS_BAR_FRAME_CLASS}
data-component="ConfiguredProvidersBarSkeleton"
>
<Skeleton className="h-6 w-32" />
</div>
);
}

interface ConfiguredProvidersBarProps {
providersConfig: ProvidersConfigMap;
}
Expand Down Expand Up @@ -35,7 +51,10 @@ export function ConfiguredProvidersBar(props: ConfiguredProvidersBarProps) {
.join(", ");

return (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-1.5 text-sm">
<div
className={`text-muted-foreground ${CONFIGURED_PROVIDERS_BAR_FRAME_CLASS}`}
data-component="ConfiguredProvidersBar"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="border-border/50 hover:border-border inline-flex items-center gap-1.5 rounded border px-2 py-1 text-sm transition-colors">
Expand Down
36 changes: 25 additions & 11 deletions src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,14 +23,18 @@ function registerProjectPageMocks() {
default: () => <div data-testid="LottieMock" />,
}));

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
Expand All @@ -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: () => <div data-testid="ConfiguredProvidersBarMock" />,
CONFIGURED_PROVIDERS_BAR_FRAME_CLASS: providerBarFrameClass,
ConfiguredProvidersBar: () => (
<div className={providerBarFrameClass} data-component="ConfiguredProvidersBar" />
),
ConfiguredProvidersBarSkeleton: () => (
<div className={providerBarFrameClass} data-component="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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -165,8 +179,8 @@ describe("ProjectPage", () => {
</RouterProvider>
);

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(
Expand All @@ -179,7 +193,7 @@ describe("ProjectPage", () => {
</RouterProvider>
);

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);
Expand Down
Loading
Loading