Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ test-integration: node_modules/.installed build-main ## Run all tests (unit + in

test-unit: node_modules/.installed build-main ## Run unit tests
@bun test src
@bun test ./tests/ui/storybook/
@bun test ./tests/ui/storybook/ ./tests/ui/domIsolation.test.ts

test: test-unit ## Alias for test-unit

Expand Down
5 changes: 3 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ module.exports = {
"\\.txt$": "<rootDir>/tests/__mocks__/textMock.js",
"\\.svg$": "<rootDir>/tests/__mocks__/svgMock.js",
},
// Storybook UI tests use bun:test and are run via `bun test`, so Jest must skip them.
testPathIgnorePatterns: ["<rootDir>/tests/ui/storybook/"],
// Storybook UI tests and the DOM-harness isolation guards use bun:test and
// are run via `bun test`, so Jest must skip them.
testPathIgnorePatterns: ["<rootDir>/tests/ui/storybook/", "<rootDir>/tests/ui/domIsolation\\.test\\.ts"],
// Avoid haste module collision with vscode extension
modulePathIgnorePatterns: ["<rootDir>/vscode/"],
transform: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { cleanup, fireEvent, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
import { installDom } from "../../../../../tests/ui/dom";
import { restoreModulesAfterSuite } from "../../../../../tests/ui/moduleMocks";
import * as RealDialogModule from "@/browser/components/Dialog/Dialog";

restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]);

void mock.module("@/browser/components/Dialog/Dialog", () => ({
Dialog: (props: {
Expand Down
22 changes: 20 additions & 2 deletions src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,37 @@ import { RouterProvider } from "@/browser/contexts/RouterContext";
import { SettingsProvider } from "@/browser/contexts/SettingsContext";
import { cleanup, render, waitFor } from "@testing-library/react";
import { installDom } from "../../../../tests/ui/dom";
import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks";
import * as RealLottieModule from "lottie-react";
import * as RealAPIModule from "@/browser/contexts/API";
import * as RealProvidersConfigModule from "@/browser/hooks/useProvidersConfig";
import * as RealConfiguredProvidersBarModule from "@/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar";
import * as RealProjectContextModule from "@/browser/contexts/ProjectContext";
import * as RealChatInputModule from "@/browser/features/ChatInput/index";
import type * as ProjectPageModule from "@/browser/components/ProjectPage/ProjectPage";
import type * as WorkspaceContextModule from "@/browser/contexts/WorkspaceContext";

let cleanupDom: (() => void) | null = null;
let focusMock: ReturnType<typeof mock> | null = null;
let readyCalls = 0;

restoreModulesAfterSuite([
["lottie-react", { ...RealLottieModule }],
["@/browser/contexts/API", { ...RealAPIModule }],
["@/browser/hooks/useProvidersConfig", { ...RealProvidersConfigModule }],
[
"@/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar",
{ ...RealConfiguredProvidersBarModule },
],
["@/browser/contexts/ProjectContext", { ...RealProjectContextModule }],
["@/browser/features/ChatInput/index", { ...RealChatInputModule }],
]);

function registerProjectPageMocks() {
// Re-register mocks before each test because afterEach restores them and this
// file should not depend on top-level module mock state leaking across tests.

// Mock lottie-react so CreationCenterContent/WorkspaceShell imports don't execute
// lottie-web canvas initialization in happy-dom (which causes unhandled errors).
// Mock lottie-react so tests don't run lottie-web animation internals in happy-dom.
void mock.module("lottie-react", () => ({
__esModule: true,
default: () => <div data-testid="LottieMock" />,
Expand Down
29 changes: 28 additions & 1 deletion src/browser/components/ProjectSidebar/TaskGroupListItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,34 @@ import "../../../../tests/ui/dom";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { installDom } from "../../../../tests/ui/dom";
import { TaskGroupListItem } from "./TaskGroupListItem";
import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks";
import * as RealPositionedMenuModule from "../PositionedMenu/PositionedMenu";
import type { TaskGroupListItem as TaskGroupListItemComponent } from "./TaskGroupListItem";

// Radix portal content is unreliable in happy-dom (see AGENTS.md), so render
// the menu inline. The row's shortcut handling under test only needs menu-item
// events to bubble through the React tree, which the inline stub preserves.
void mock.module("@/browser/components/PositionedMenu/PositionedMenu", () => ({
PositionedMenu: (props: { open: boolean; children: React.ReactNode }) =>
props.open ? <div>{props.children}</div> : null,
PositionedMenuItem: (props: {
label: string;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
}) => (
<button type="button" onClick={props.onClick}>
{props.label}
</button>
),
}));
restoreModulesAfterSuite([
["@/browser/components/PositionedMenu/PositionedMenu", { ...RealPositionedMenuModule }],
]);

/* eslint-disable @typescript-eslint/no-require-imports */
const { TaskGroupListItem } = require("./TaskGroupListItem") as {
TaskGroupListItem: typeof TaskGroupListItemComponent;
};
/* eslint-enable @typescript-eslint/no-require-imports */

function renderTaskGroup(overrides: Partial<React.ComponentProps<typeof TaskGroupListItem>> = {}) {
return render(
Expand Down
87 changes: 87 additions & 0 deletions src/browser/components/ScratchPage/ScratchPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { userEvent, waitFor } from "@storybook/test";

import { appMeta, AppWithMocks, type AppStory } from "@/browser/stories/meta.js";
import { createMockORPCClient } from "@/browser/stories/mocks/orpc";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import { LEFT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage";

// Integration: stories render the full app so the sidebar's "New scratch chat"
// entry can navigate to the real scratch creation route.
export default {
...appMeta,
title: "Components/ScratchPage",
};

/**
* With multiple projects configured, the scratch header must show "Scratch"
* as the current scope and offer the project switcher: on mobile the sidebar
* auto-collapses after navigation and is otherwise the only way to reach a
* project's creation page.
*/
export const ScratchCreationWithProjects: AppStory = {
// Mirrors the Pixel phone variant so local viewing reproduces the mobile flow
// the story covers; the test-runner ignores globals and plays at desktop width.
globals: {
viewport: { value: "mobile1", isRotated: false },
},
parameters: {
pixel: {
matrix: { themes: ["dark", "light"], viewports: ["laptop", "phone"] },
Comment thread
ibetitsmike marked this conversation as resolved.
},
},
render: () => (
<AppWithMocks
setup={() => {
// Start expanded so the play function can click the sidebar entry
// even in mobile viewport modes.
updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, false);
return createMockORPCClient({
projects: new Map([
["/Users/dev/frontend-app", { workspaces: [] }],
["/Users/dev/backend-api", { workspaces: [] }],
]),
workspaces: [],
});
}}
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const storyRoot = document.getElementById("storybook-root") ?? canvasElement;

try {
const newScratchButton = await waitFor(
() => {
// Guard: a previous story's play-function may have left the sidebar
// collapsed via localStorage.
if (document.documentElement.dataset.leftSidebarCollapsed === "true") {
const expandBtn = storyRoot.querySelector<HTMLElement>("[aria-label='Expand sidebar']");
if (expandBtn) expandBtn.click();
throw new Error("Sidebar collapsed: expanding");
}
const el = storyRoot.querySelector<HTMLElement>("[aria-label='New scratch chat']");
if (!el) throw new Error("New scratch chat button not found");
return el;
},
{ timeout: 10_000 }
);
await userEvent.click(newScratchButton);

await waitFor(
() => {
const group = storyRoot.querySelector("[data-component='ScratchProjectGroup']");
if (!group) throw new Error("Scratch project header not found");
const selector = group.querySelector("[data-testid='project-selector']");
if (!selector) throw new Error("Project switcher not found on scratch page");
if (!(selector.textContent ?? "").includes("Scratch")) {
throw new Error("Project switcher does not show the Scratch scope");
}
},
{ timeout: 10_000 }
);
} finally {
// Remove the sidebar-state override so later stories start from the
// default expanded-on-desktop state even if assertions fail.
updatePersistedState(LEFT_SIDEBAR_COLLAPSED_KEY, null);
}
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
} from "@/browser/testUtils";
import type { ReactNode } from "react";
import { installDom } from "../../../../tests/ui/dom";
import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks";
import * as RealDialogModule from "@/browser/components/Dialog/Dialog";

restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]);

// Self-contained dialog stub — bun's mock.module is process-global, so other
// test files may register incomplete Dialog stubs that omit
Expand Down
4 changes: 4 additions & 0 deletions src/browser/features/Analytics/SavedQuerySqlDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { restoreModulesAfterSuite } from "../../../../tests/ui/moduleMocks";
import * as RealDialogModule from "@/browser/components/Dialog/Dialog";

restoreModulesAfterSuite([["@/browser/components/Dialog/Dialog", { ...RealDialogModule }]]);

void mock.module("@/browser/components/Dialog/Dialog", () => ({
Dialog: (props: { open: boolean; children: ReactNode }) =>
Expand Down
48 changes: 11 additions & 37 deletions src/browser/features/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { GitBranch, Loader2, Wand2 } from "lucide-react";
import type { ProjectConfig } from "@/common/types/project";
import { formatProjectHierarchyLabel } from "@/common/utils/subProjects";
import { CreationProjectSelect } from "./CreationProjectSelect";
import { RuntimeConfigInput } from "@/browser/components/RuntimeConfigInput/RuntimeConfigInput";
import { usePerfRenderMarker } from "@/browser/utils/perf/PerfRenderMarker";
import { cn } from "@/common/lib/utils";
Expand Down Expand Up @@ -741,43 +742,16 @@ function CreationControlsContent(props: CreationControlsProps) {
// selecting "gbot/bbot" would show "gbot" because props.projectPath
// is normalized to the owning parent for runtime/config scoping.
const selected = props.selectedProjectPath ?? props.projectPath;
const selectedLabel = formatProjectHierarchyLabel(selected, props.userProjects);
return props.userProjects.size > 1 ? (
<RadixSelect value={selected} onValueChange={props.onSelectedProjectPathChange}>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
aria-label="Select project"
data-testid="project-selector"
className="text-foreground hover:bg-toggle-bg/70 h-7 w-auto max-w-[280px] shrink-0 border-transparent bg-transparent px-0 text-lg font-semibold shadow-none"
>
{/*
* Render the hierarchy label as the explicit child instead of
* relying on Radix's <SelectValue/> mirror of the matched
* <SelectItem/> text. This keeps the trigger label in sync
* with the SelectItem labels (which also use the hierarchy
* label) and avoids fallbacks to bare basenames.
*/}
<SelectValue placeholder={selectedLabel}>{selectedLabel}</SelectValue>
</SelectTrigger>
</TooltipTrigger>
<TooltipContent align="start">{selected}</TooltipContent>
</Tooltip>
<SelectContent>
{Array.from(props.userProjects.keys()).map((path) => (
<SelectItem key={path} value={path}>
{formatProjectHierarchyLabel(path, props.userProjects)}
</SelectItem>
))}
</SelectContent>
</RadixSelect>
) : (
<Tooltip>
<TooltipTrigger asChild>
<h2 className="text-foreground shrink-0 text-lg font-semibold">{selectedLabel}</h2>
</TooltipTrigger>
<TooltipContent align="start">{selected}</TooltipContent>
</Tooltip>
return (
<CreationProjectSelect
selected={selected}
selectedLabel={formatProjectHierarchyLabel(selected, props.userProjects)}
options={Array.from(props.userProjects.keys()).map((path) => ({
value: path,
label: formatProjectHierarchyLabel(path, props.userProjects),
}))}
onChange={props.onSelectedProjectPathChange}
/>
);
})()}
<span className="text-muted-foreground mx-2 text-lg">/</span>
Expand Down
61 changes: 61 additions & 0 deletions src/browser/features/ChatInput/CreationProjectSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
Select as RadixSelect,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/browser/components/SelectPrimitive/SelectPrimitive";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip";

interface CreationProjectSelectProps {
selected: string;
selectedLabel: string;
tooltip?: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}

/**
* Current-scope heading for creation composers: a project switcher when there
* is more than one choice, otherwise a static heading. Shared by the project
* creation header (CreationControls) and the scratch creation header.
*/
export function CreationProjectSelect(props: CreationProjectSelectProps) {
const tooltip = props.tooltip ?? props.selected;
if (props.options.length <= 1) {
return (
<Tooltip>
<TooltipTrigger asChild>
<h2 className="text-foreground shrink-0 text-lg font-semibold">{props.selectedLabel}</h2>
</TooltipTrigger>
<TooltipContent align="start">{tooltip}</TooltipContent>
</Tooltip>
);
}
return (
<RadixSelect value={props.selected} onValueChange={props.onChange}>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
aria-label="Select project"
data-testid="project-selector"
className="text-foreground hover:bg-toggle-bg/70 h-7 w-auto max-w-[280px] shrink-0 border-transparent bg-transparent px-0 text-lg font-semibold shadow-none"
>
{/* Explicit child instead of Radix's <SelectValue/> mirror of the
matched <SelectItem/> text, so unmatched values still render
the caller's label rather than falling back to nothing. */}
<SelectValue placeholder={props.selectedLabel}>{props.selectedLabel}</SelectValue>
</SelectTrigger>
</TooltipTrigger>
<TooltipContent align="start">{tooltip}</TooltipContent>
</Tooltip>
<SelectContent>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</RadixSelect>
);
}
33 changes: 32 additions & 1 deletion src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ import {
getInlineSkillSuggestions,
shouldRefreshInlineSkillSuggestions,
} from "@/browser/utils/agentSkills/inlineSkillSuggestions";
import { resolveWorkspaceCreationScope } from "@/common/utils/subProjects";
import {
formatProjectHierarchyLabel,
resolveWorkspaceCreationScope,
} from "@/common/utils/subProjects";
import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_PROJECT_NAME } from "@/common/constants/scratch";
import { CreationProjectSelect } from "./CreationProjectSelect";
import { getCommandGhostHint } from "@/browser/utils/slashCommands/registry";
import {
getSlashCommandSuggestions,
Expand Down Expand Up @@ -3301,6 +3306,32 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
{/* Creation header controls - shown above textarea for creation variant */}
{creationControlsProps && <CreationControls {...creationControlsProps} />}

{/* Scratch chats have no CreationControls, but mobile users still
need the current scope visible and a way to reach a project's
creation page: on narrow viewports the sidebar auto-collapses
after opening the scratch page from it. */}
{variant === "creation" && props.kind === "scratch" && (
<div className="mb-3 flex items-center" data-component="ScratchProjectGroup">
<CreationProjectSelect
selected={SCRATCH_PROJECT_CONFIG_KEY}
selectedLabel={SCRATCH_PROJECT_NAME}
tooltip={SCRATCH_PROJECT_NAME}
options={[
{ value: SCRATCH_PROJECT_CONFIG_KEY, label: SCRATCH_PROJECT_NAME },
...Array.from(userProjects.keys()).map((path) => ({
value: path,
label: formatProjectHierarchyLabel(path, userProjects),
})),
]}
onChange={(path) => {
if (path !== SCRATCH_PROJECT_CONFIG_KEY) {
beginWorkspaceCreation(path);
}
}}
/>
</div>
)}

<CodexOauthWarningBanner
requiresCodexOauth={requiresCodexOauth(baseModel)}
codexOauthSet={codexOauthSet}
Expand Down
Loading
Loading