Files
nexu-io-open-design/apps/web/tests/components/FileWorkspace.test.tsx
Tom Huang 6be4e56544 feat(workspace): add plan mode and Excalidraw sketch flows (#4862)
* feat(daemon): add 'plan' session mode and update related functionality

- Introduced a new session mode 'plan' alongside existing 'design' and 'chat' modes, allowing for editable document creation.
- Updated various functions and interfaces to accommodate the new session mode, including normalization and usage in commands.
- Enhanced markdown rendering capabilities by integrating micromark and its GFM extension for improved markdown processing.
- Added new input types for question forms, expanding the range of user interactions.
- Updated UI components to reflect changes in session modes and ensure proper handling of next steps based on the current mode.

* feat(chat): integrate SessionModeToggle into ChatComposer and HomeHero

- Added SessionModeToggle component to both ChatComposer and HomeHero for improved session management.
- Updated HomeHero styles to accommodate the new mode switcher layout.
- Adjusted countdown timer in QuestionsPanel to extend the skip countdown from 120 seconds to 10 minutes, enhancing user experience.
- Added tests to ensure the countdown functionality works as expected.

* feat(FileViewer): implement synchronized scrolling for Markdown editor and preview

- Added functionality to synchronize scrolling between the Markdown editor and preview panes.
- Introduced new types and utility functions for managing scroll behavior.
- Enhanced the MarkdownViewer component to handle scroll events and maintain scroll position across different modes.
- Updated the component's state management to accommodate the new scrolling features.

* feat(excalidraw): integrate Excalidraw into the project

- Added @excalidraw/excalidraw as a dependency in package.json.
- Updated vitest configuration to include an alias for Excalidraw.
- Imported Excalidraw CSS in the layout component for styling.
- Modified AssistantMessage component to handle optional projectId.
- Enhanced FileOpsSummary to display delete operations.
- Implemented new Excalidraw scene management in SketchEditor and FileWorkspace components.
- Updated MarkdownViewer to support file mentions and improved file handling.
- Refactored various components to accommodate Excalidraw integration and ensure compatibility with existing features.

* feat(files): improve sketch and file handoff flows

* test: align post-merge expectations

* chore(nix): refresh pnpm deps hash

* fix(workspace): stabilize sketch persistence and ci checks

* fix(workspace): address review blockers in editable files

* fix(workspace): persist cleared sketch scenes

* test(workspace): type sketch editor mock scene

* fix(workspace): serialize sketch autosaves

* fix(workspace): keep sketch save revisions current

* test(e2e): stabilize project workspace smoke flows

* fix(analytics): preserve plan mode for BYOK runs

* test(e2e): stabilize new project rail interactions

* fix(files): stop bash delete parsing at shell operators

* test(e2e): stabilize ui cold-start suites

* fix(viewer): preserve absolute markdown image sources

* feat(workspace): preload sketches and enhance markdown save options

- Added functionality to preload persisted sketches before opening the tab.
- Introduced new MarkdownSaveOptions type to manage save behavior.
- Updated saveMarkdownText to handle options for refreshing files and showing saving state.
- Enhanced FileViewer to maintain focus and selection during metadata refresh.
- Implemented a Toast component for user feedback on save and export actions.

* fix(web): stabilize markdown and sketch editor polish

* fix(web): finish sketch editor merge resolution

* chore(nix): refresh pnpm deps hash

* fix(plan): bypass discovery and stabilize markdown sync

* fix(web): simplify scene retrieval in SketchEditor component

* feat(web): enhance markdown viewer with auto-save functionality

- Implemented passive auto-save status in the MarkdownViewer component, replacing the manual Save button with an auto-save indicator.
- Introduced new hooks and state management for tracking auto-save events and displaying the last saved time.
- Added support for synchronized scrolling between the markdown editor and preview.
- Created a new markdown-scroll-sync module to handle scroll synchronization logic.
- Updated localization files to include new strings for auto-save messages.
- Added a SketchEnginePrewarm component to optimize Excalidraw loading times.

* feat(web): enhance session mode toggle with cost indicators

- Added cost tiers for each session mode in the SessionModeToggle component, providing users with a visual representation of usage costs.
- Introduced a new ModeCostTag component to display cost information alongside session mode labels.
- Updated localization files to include new keys for cost labels and notes.
- Enhanced styling for cost indicators to improve user experience and clarity.
- Refactored EntryShell to open the new project modal instead of creating a blank project directly from the rail.
- Implemented a utility function in markdown-scroll-sync to check for vertical progression in block offsets.

* feat(web): add max height adjustment for session mode description card

- Introduced maxHeight prop to the ModeDescriptionCard component to control the height of the description card based on available space.
- Implemented useLayoutEffect in SessionModeToggle to dynamically calculate and set the maximum height of the description card, ensuring it does not overlap with the project tab bar.
- Updated tests to verify that session modes display their expected usage/cost correctly in the UI.
- Enhanced localization files to include new cost-related strings for various languages.

* feat(web): implement goBack function for improved navigation and update auto-open logic

- Added a new `goBack` function to handle in-app navigation, allowing users to return to the previous route instead of a hardcoded destination.
- Updated the `navigate` function to maintain history state for better back navigation.
- Refactored auto-open logic to prioritize produced artifacts, allowing markdown files to be opened alongside HTML files.
- Updated tests to cover new navigation behavior and artifact selection logic.
- Enhanced localization files to include new descriptions for workspace actions.

* refactor(web): remove create design system functionality and update design files panel actions

- Removed the `onCreateDesignSystem` prop and associated button from the DesignFilesPanel component.
- Updated the empty state actions to include a button for creating a new document via the `onPaste` function.
- Adjusted tests to reflect the removal of the design system creation action and ensure the new document button is functional.
- Enhanced the MarkdownViewer component by adding a placeholder for the text area and removing the header bar for a cleaner interface.
- Updated localization files to include a new placeholder string for the markdown editor.

* fix(web): restore markdown placeholder translations

* refactor(web): streamline MarkdownViewer and enhance localization

- Removed unnecessary state management and reload functionality from the MarkdownViewer component for improved performance.
- Added a placeholder text for the markdown editor in multiple localization files to enhance user guidance.
- Updated styles for the save state indicator in the viewer to improve visual clarity and alignment.
- Adjusted tests to reflect changes in the MarkdownViewer and ensure proper functionality.

* fix(web): adjust SketchEditor button size and remove shortcut hints

- Reduced the icon size in the SketchEditor component from 13 to 12 for better alignment.
- Updated the removeSketchMermaidShortcutHints function to also remove the submit shortcut hints from the dialog, enhancing the user interface by decluttering unnecessary elements.
- Adjusted tests to verify the absence of shortcut hints in the modal after updates.

* fix(web): address plan mode follow-up polish

* fix(web): align ci expectations after merge

* test(e2e): stabilize project workspace helpers

* test(e2e): scale settings visual timeout

* fix(prompts): lock ElevenLabs voice picker choices

* test(e2e): pin project workspace P0 worker

* test(e2e): use rail new project entry

* test(e2e): relax app restoration startup waits

* fix(web): limit markdown pipe escaping to tables

---------

Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
Co-authored-by: Amy <1184569493@qq.com>
2026-07-01 04:38:31 +00:00

2656 lines
80 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
DESIGN_FILES_TAB,
FileWorkspace,
scrollWorkspaceTabsWithWheel,
} from '../../src/components/FileWorkspace';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import { projectSplitClassName, projectSplitStyle } from '../../src/components/ProjectView';
import {
fetchProjectFileText,
uploadProjectFiles,
writeProjectTextFile,
fetchProjectFolders,
} from '../../src/providers/registry';
import type { ChatMessage, ProjectFile, ProjectFolder } from '../../src/types';
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
fetchProjectFileText: vi.fn(),
uploadProjectFiles: vi.fn(),
writeProjectBase64File: vi.fn(),
writeProjectTextFile: vi.fn(),
fetchProjectFolders: vi.fn().mockResolvedValue([]),
};
});
vi.mock('../../src/components/DesignBrowserPanel', () => ({
DesignBrowserPanel: ({
initialIconUrl,
initialTitle,
initialUrl,
navigateRequest,
onPageSnapshotToast,
}: {
initialIconUrl?: string;
initialTitle?: string;
initialUrl?: string;
navigateRequest?: { url: string; nonce: number };
onPageSnapshotToast?: (event: {
actionFileName?: string;
actionLabel?: string;
actionTarget?: 'design-files' | 'file';
elapsedSeconds?: number;
message: string;
status: 'loading' | 'success' | 'error' | 'canceled';
tabId: string;
ttlMs?: number;
}) => void;
}) => (
<div
data-testid="design-browser-panel"
data-initial-icon-url={initialIconUrl ?? ''}
data-initial-title={initialTitle ?? ''}
data-initial-url={initialUrl ?? ''}
data-navigate-url={navigateRequest?.url ?? ''}
data-navigate-nonce={navigateRequest?.nonce ?? ''}
>
<button
type="button"
data-testid="emit-browser-snapshot-success"
onClick={() => onPageSnapshotToast?.({
actionFileName: 'browser-archive/example/manifest.json',
actionLabel: 'View Design Files',
actionTarget: 'design-files',
elapsedSeconds: 0,
message: 'Saved page snapshot (HTML + CSS).',
status: 'success',
tabId: '__browser__:1',
ttlMs: 8000,
})}
>
emit snapshot success
</button>
</div>
),
labelFromUrl: (url: string) => {
try {
return new URL(url).hostname.replace(/^www\./, '') || url;
} catch {
return url;
}
},
normalizeBrowserAddress: (rawAddress: string) => {
const value = rawAddress.trim();
if (/^https?:\/\//i.test(value)) return value;
if (/^[\w.-]+\.[a-z]{2,}/i.test(value)) return `https://${value}`;
return value || 'about:blank';
},
}));
vi.mock('../../src/components/workspace/TerminalViewer', () => ({
TerminalViewer: ({ terminalId }: { terminalId: string }) => (
<div data-testid="terminal-viewer">{terminalId}</div>
),
}));
const { excalidrawWorkspaceMock } = vi.hoisted(() => ({
excalidrawWorkspaceMock: {
lastProps: null as Record<string, any> | null,
},
}));
vi.mock('@excalidraw/excalidraw', async () => {
const React = await import('react');
const MainMenu = Object.assign(
(props: Record<string, any>) => React.createElement('div', null, props.children),
{
Item: ({ children, disabled, icon, onClick, ...rest }: Record<string, any>) => React.createElement(
'button',
{
...rest,
type: 'button',
disabled,
onClick,
},
icon,
children,
),
DefaultItems: {
SearchMenu: () => null,
Help: () => null,
ClearCanvas: () => null,
ChangeCanvasBackground: () => null,
},
Separator: () => null,
},
);
return {
Excalidraw: (props: Record<string, any>) => {
excalidrawWorkspaceMock.lastProps = props;
React.useEffect(() => {
props.excalidrawAPI?.({
getSceneElementsIncludingDeleted: () => [{ id: 'workspace-element', type: 'freedraw', isDeleted: false }],
getAppState: () => ({ viewBackgroundColor: '#ffffff' }),
getFiles: () => ({}),
updateScene: vi.fn(),
setOpenDialog: vi.fn(),
});
}, [props]);
return React.createElement(
'div',
{ 'data-testid': 'excalidraw' },
React.createElement('canvas'),
props.renderTopRightUI?.(false, {}),
props.children,
);
},
MainMenu,
convertToExcalidrawElements: vi.fn((elements: unknown[]) => elements),
exportToBlob: vi.fn(async () => new Blob(['mock image'], { type: 'image/png' })),
};
});
// Records the `folders` prop DesignFilesPanel receives on EVERY render (still
// renders the real component). Lets a test observe the first render after a
// project switch — the pre-paint frame RTL's post-rerender DOM assertion can't
// see — to prove no stale folders ever reach the new panel.
const { designFilesPanelRenders } = vi.hoisted(() => ({
designFilesPanelRenders: [] as { projectId: string; folderCount: number }[],
}));
vi.mock('../../src/components/DesignFilesPanel', async () => {
const actual = await vi.importActual<typeof import('../../src/components/DesignFilesPanel')>(
'../../src/components/DesignFilesPanel',
);
const Real = actual.DesignFilesPanel;
return {
...actual,
DesignFilesPanel: (props: Parameters<typeof Real>[0]) => {
designFilesPanelRenders.push({
projectId: props.projectId,
folderCount: props.folders?.length ?? 0,
});
return <Real {...props} />;
},
};
});
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
const mockedWriteProjectTextFile = vi.mocked(writeProjectTextFile);
let root: Root | null = null;
let host: HTMLDivElement | null = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Needed else the ResizeObserver in SketchEditor crashes the test
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
unobserve() {}
};
});
beforeEach(() => {
mockedFetchProjectFileText.mockResolvedValue('');
});
afterEach(() => {
cleanup();
excalidrawWorkspaceMock.lastProps = null;
if (root) {
act(() => root?.unmount());
root = null;
}
host?.remove();
host = null;
vi.clearAllMocks();
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllGlobals();
});
function baseFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
return {
name: 'mock.png',
path: 'mock.png',
type: 'file',
size: 1024,
mtime: 1710000000,
kind: 'image',
mime: 'image/png',
...overrides,
};
}
function workspaceFile(name: string): ProjectFile {
return {
name,
path: name,
type: 'file',
size: 100,
mtime: 1700000000,
kind: name.endsWith('.html') ? 'html' : 'text',
mime: name.endsWith('.html') ? 'text/html' : 'text/plain',
};
}
function renderWorkspace(element: React.ReactElement) {
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
act(() => {
root?.render(element);
});
return host;
}
function getTabByName(container: HTMLElement, name: RegExp): HTMLElement {
const tabs = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]'));
const tab = tabs.find((node) => name.test(node.textContent ?? ''));
if (!tab) throw new Error(`Could not find tab matching ${name}`);
return tab;
}
function renderedTabLabels(): string[] {
return screen.getAllByRole('tab').map((tab) => tab.textContent?.trim() ?? '');
}
function createDragDataTransfer() {
const store = new Map<string, string>();
return {
effectAllowed: 'move',
dropEffect: 'move',
getData: vi.fn((type: string) => store.get(type) ?? ''),
setData: vi.fn((type: string, value: string) => {
store.set(type, value);
}),
};
}
function dispatchDragEvent(
target: HTMLElement,
type: string,
dataTransfer = createDragDataTransfer(),
clientX = 0,
relatedTarget: EventTarget | null = null,
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: clientX },
dataTransfer: { value: dataTransfer },
relatedTarget: { value: relatedTarget },
});
target.dispatchEvent(event);
return dataTransfer;
}
function stubTabRect(tab: HTMLElement, left = 0, width = 100) {
tab.getBoundingClientRect = vi.fn(() => ({
x: left,
y: 0,
left,
top: 0,
right: left + width,
bottom: 20,
width,
height: 20,
toJSON: () => ({}),
}));
}
function changeInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function renderDesignFilesPanel(overrides: Partial<React.ComponentProps<typeof DesignFilesPanel>> = {}) {
const props: React.ComponentProps<typeof DesignFilesPanel> = {
projectId: 'project-1',
files: [],
liveArtifacts: [],
onRefreshFiles: vi.fn(),
onOpenFile: vi.fn(),
onOpenLiveArtifact: vi.fn(),
onRenameFile: vi.fn(),
onDeleteFile: vi.fn(),
onDeleteFiles: vi.fn(),
onUpload: vi.fn(),
onUploadFiles: vi.fn(),
onPaste: vi.fn(),
onNewSketch: vi.fn(),
...overrides,
};
return render(<DesignFilesPanel {...props} />);
}
function unreadableDropDataTransfer(fallbackFiles: File[] = []) {
return {
files: fallbackFiles,
items: [
{
webkitGetAsEntry: () => ({
isFile: true,
isDirectory: false,
name: 'stale.png',
file: (_done: (file: File) => void, fail?: (error: DOMException) => void) => {
fail?.(new DOMException('missing', 'NotFoundError'));
},
}),
},
],
};
}
describe('FileWorkspace upload input', () => {
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
expect(markup).toContain('data-testid="design-files-upload-input"');
expect(markup).not.toContain('accept=');
});
it('auto-saves a newly created sketch into project files', async () => {
const onRefreshFiles = vi.fn();
const onTabsStateChange = vi.fn();
mockedWriteProjectTextFile.mockImplementation(async (_projectId, name) => ({
name,
path: name,
type: 'file',
size: 128,
mtime: 1710000000,
kind: 'sketch',
mime: 'application/json',
}));
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={onTabsStateChange}
/>,
);
fireEvent.click(screen.getByTestId('design-files-empty-new-sketch'));
await waitFor(() => expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1));
const [projectId, name, content] = mockedWriteProjectTextFile.mock.calls[0]!;
expect(projectId).toBe('project-1');
expect(name).toMatch(/^sketch-.*\.sketch\.json$/);
expect(JSON.parse(content as string)).toMatchObject({
type: 'excalidraw',
version: 2,
});
await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(onTabsStateChange).toHaveBeenCalledWith(
expect.objectContaining({
tabs: [name],
active: name,
}),
),
);
});
it('hides upload failure details during in-panel preview and restores them after closing preview', async () => {
mockedUploadProjectFiles.mockRejectedValueOnce(new Error('storage offline'));
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[baseFile()]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
fireEvent.change(screen.getByTestId('design-files-upload-input'), {
target: { files: [new File(['mock'], 'mock.png', { type: 'image/png' })] },
});
await waitFor(() => {
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
'storage offline',
);
});
const row = screen.getByTestId('design-file-row-mock.png');
const nameButton = row.querySelector<HTMLButtonElement>('.df-row-name-btn');
if (!nameButton) throw new Error('Could not find file name button');
fireEvent.click(nameButton);
expect(screen.getByTestId('design-file-preview')).toBeTruthy();
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Close preview' }));
await waitFor(() => {
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
'storage offline',
);
});
fireEvent.click(screen.getByTestId('upload-error-dismiss'));
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
});
it('keeps partial upload failures visible after a successful file opens', async () => {
mockedUploadProjectFiles.mockResolvedValueOnce({
uploaded: [
{
path: 'uploaded.png',
name: 'uploaded.png',
kind: 'image',
size: 1024,
},
],
failed: [{ name: 'failed.png', error: 'permission denied' }],
error: 'permission denied',
});
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[baseFile({ name: 'uploaded.png', path: 'uploaded.png' })]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
fireEvent.change(screen.getByTestId('design-files-upload-input'), {
target: {
files: [
new File(['uploaded'], 'uploaded.png', { type: 'image/png' }),
new File(['failed'], 'failed.png', { type: 'image/png' }),
],
},
});
await waitFor(() => {
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
'Uploaded 1 file(s), but 1 failed (permission denied).',
);
});
});
it('starts Design Files navigation fresh when switching projects', () => {
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-a',
projectKind: 'prototype',
files: [
workspaceFile('assets/logo.png'),
workspaceFile('top.html'),
],
liveArtifacts: [],
onRefreshFiles: vi.fn(),
isDeck: false,
tabsState: { tabs: [], active: null },
onTabsStateChange: vi.fn(),
};
const { container, rerender } = render(<FileWorkspace {...baseProps} />);
fireEvent.click(container.querySelector('.df-dir-row .df-row-name-btn')!);
expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
rerender(
<FileWorkspace
{...baseProps}
projectId="project-b"
files={[
workspaceFile('beta-assets/logo.png'),
workspaceFile('home.html'),
]}
/>,
);
expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('Project');
expect(screen.getByTestId('design-file-row-home.html')).toBeTruthy();
});
it('drops the previous project folders when switching, before the new fetch resolves', async () => {
const folder = (path: string): ProjectFolder => ({
name: path.split('/').pop() ?? path,
path,
type: 'dir',
size: 0,
mtime: 1700000000,
});
const mockedFolders = vi.mocked(fetchProjectFolders);
// project-a has an empty persisted folder; project-b's fetch stays pending.
// (One-time values take precedence over the factory default `[]`; no reset,
// so later tests keep that default.)
mockedFolders.mockResolvedValueOnce([folder('assets')]);
mockedFolders.mockReturnValueOnce(new Promise<ProjectFolder[]>(() => {}));
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-a',
projectKind: 'prototype',
files: [],
liveArtifacts: [],
onRefreshFiles: vi.fn(),
isDeck: false,
tabsState: { tabs: [], active: null },
onTabsStateChange: vi.fn(),
};
const { container, rerender } = render(<FileWorkspace {...baseProps} />);
// project-a's empty folder shows once its fetch resolves.
await waitFor(() => {
expect(
[...container.querySelectorAll('.df-dir-row .df-row-name')].some(
(e) => e.textContent === 'assets',
),
).toBe(true);
});
// Switch to project-b; its folder fetch is still pending. The previous
// project's 'assets' folder must be gone immediately (reset synchronously),
// not linger and suppress the new project's empty state.
designFilesPanelRenders.length = 0;
rerender(<FileWorkspace {...baseProps} projectId="project-b" files={[]} />);
expect(
[...container.querySelectorAll('.df-dir-row .df-row-name')].some(
(e) => e.textContent === 'assets',
),
).toBe(false);
// The reset happens during render, not in an effect — so the new panel's
// FIRST render (and every render thereafter) already sees zero folders.
// An effect-based reset would let project-b's first render observe the
// stale 'assets' folder before the effect cleared it; RTL's post-rerender
// DOM check above can't catch that frame, this can.
const projectBRenders = designFilesPanelRenders.filter((r) => r.projectId === 'project-b');
expect(projectBRenders.length).toBeGreaterThan(0);
expect(projectBRenders.every((r) => r.folderCount === 0)).toBe(true);
});
it('clears a prior upload failure after a later successful upload', async () => {
mockedUploadProjectFiles
.mockRejectedValueOnce(new Error('storage offline'))
.mockResolvedValueOnce({
uploaded: [
{
path: 'retry.png',
name: 'retry.png',
kind: 'image',
size: 1024,
},
],
failed: [],
});
const onRefreshFiles = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[baseFile({ name: 'retry.png', path: 'retry.png' })]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
const input = screen.getByTestId('design-files-upload-input');
fireEvent.change(input, {
target: { files: [new File(['failed'], 'failed.png', { type: 'image/png' })] },
});
await waitFor(() => {
expect(screen.getByTestId('upload-error-banner').textContent).toContain('storage offline');
});
fireEvent.change(input, {
target: { files: [new File(['retry'], 'retry.png', { type: 'image/png' })] },
});
await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.queryByTestId('upload-error-banner')).toBeNull());
});
it('falls back to the browser file list when a dragged entry cannot be read', async () => {
const fallbackFile = new File(['mock'], 'fallback.png', { type: 'image/png' });
const onUploadFiles = vi.fn();
const { container } = renderDesignFilesPanel({ onUploadFiles });
fireEvent.drop(container.querySelector('.df-body')!, {
dataTransfer: unreadableDropDataTransfer([fallbackFile]),
});
await waitFor(() => expect(onUploadFiles).toHaveBeenCalledWith([fallbackFile]));
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
});
it('uploads files pasted from the clipboard in Design Files', () => {
const pastedFile = new File(['mock'], 'clipboard.png', { type: 'image/png' });
const onUploadFiles = vi.fn();
const onClearUploadError = vi.fn();
renderDesignFilesPanel({ onUploadFiles, onClearUploadError });
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: {
files: [pastedFile],
items: [],
},
});
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(onClearUploadError).toHaveBeenCalledTimes(1);
expect(onUploadFiles).toHaveBeenCalledWith([pastedFile]);
});
it('does not steal clipboard files from text inputs', () => {
const pastedFile = new File(['mock'], 'clipboard.png', { type: 'image/png' });
const onUploadFiles = vi.fn();
renderDesignFilesPanel({ onUploadFiles });
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
try {
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: {
files: [pastedFile],
items: [],
},
});
textarea.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(onUploadFiles).not.toHaveBeenCalled();
} finally {
textarea.remove();
}
});
it('shows a recoverable read error when a dragged entry disappears before import', async () => {
const onUploadFiles = vi.fn();
const { container } = renderDesignFilesPanel({ onUploadFiles });
fireEvent.drop(container.querySelector('.df-body')!, {
dataTransfer: unreadableDropDataTransfer(),
});
await waitFor(() => {
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
'Could not read one or more dropped files or folders',
);
});
expect(onUploadFiles).not.toHaveBeenCalled();
});
it('hides the workspace focus control while the chat pane is open', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
focusMode={false}
onFocusModeChange={vi.fn()}
/>,
);
// While chat is visible the collapse trigger lives in ChatPane.
// FileWorkspace only renders an expand control once chat is hidden.
expect(markup).not.toContain('data-testid="workspace-focus-toggle"');
});
it('renders the expand control on the LEFT of the tab bar while focused', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
focusMode
onFocusModeChange={vi.fn()}
/>,
);
expect(markup).toContain('class="ws-tabs-shell"');
expect(markup).toContain('data-testid="workspace-focus-toggle"');
// The expand control sits before the tabs bar (left side) so its
// direction matches where the chat pane re-emerges from.
expect(markup).toMatch(
/<div class="ws-tabs-shell">\s*<button[^>]*data-testid="workspace-focus-toggle"[\s\S]*?<\/button>\s*<div class="ws-tabs-bar"/,
);
});
it('keeps the Design Files tab as the first workspace tab before opened files', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('artifact.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['artifact.html'], active: 'artifact.html' }}
onTabsStateChange={vi.fn()}
/>,
);
expect(markup).toContain('class="ws-tabs-bar"');
expect(markup).toMatch(
/role="tablist"[\s\S]*data-testid="design-files-tab"[\s\S]*artifact\.html/,
);
});
it('labels the same workspace control as chat restore while focused', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
focusMode
onFocusModeChange={vi.fn()}
/>,
);
expect(markup).toContain('Show chat');
});
});
describe('FileWorkspace launcher tab creation', () => {
it('does not report a Design Files context for an empty project', async () => {
// A brand-new project has no files, live artifacts, or folders. The
// composer must not auto-stage a "Design files" chip that points at
// nothing, so the active workspace context stays null.
const onActiveContextChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
resolvedDir="/tmp/open-design/project-1"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
onActiveContextChange={onActiveContextChange}
/>,
);
await waitFor(() => {
expect(onActiveContextChange).toHaveBeenCalled();
});
expect(onActiveContextChange).toHaveBeenLastCalledWith(null);
});
it('reports the active Design Files tab as workspace context once files exist', async () => {
const onActiveContextChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
resolvedDir="/tmp/open-design/project-1"
files={[workspaceFile('cover.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
onActiveContextChange={onActiveContextChange}
/>,
);
await waitFor(() => {
expect(onActiveContextChange).toHaveBeenLastCalledWith({
id: 'workspace:design-files',
kind: 'design-files',
label: 'Design Files',
tabId: '__design_files__',
absolutePath: '/tmp/open-design/project-1',
});
});
});
it('hides terminal creation while keeping browser creation available', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('workspace-add-tab'));
expect(screen.queryByRole('button', { name: /New Terminal/i })).toBeNull();
expect(screen.getByRole('button', { name: /New Browser/i })).toBeTruthy();
expect(
screen.getByText('Sketch rough layouts and notes for the agent to use as design context'),
).toBeTruthy();
expect(screen.getByText('Create new')).toBeTruthy();
});
it('renders terminal and side chat tabs after a Design Files-anchored browser tab', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['terminal:term-1', 'chat:conversation-1'],
active: 'chat:conversation-1',
browserTabs: [
{
id: '__browser__:1',
insertAfter: '__design_files__',
label: 'Browser',
},
],
}}
conversations={[
{
id: 'conversation-1',
projectId: 'project-1',
title: null,
createdAt: 1,
updatedAt: 1,
},
]}
onTabsStateChange={vi.fn()}
/>,
);
expect(renderedTabLabels()).toEqual([
'Design Files',
'Browser',
'New Terminal',
'Side chat',
]);
});
it('opens Design Files from the browser snapshot toast action instead of the manifest file', async () => {
const onTabsStateChange = vi.fn();
const browserTab = {
id: '__browser__:1',
insertAfter: '__design_files__',
label: 'Browser',
title: 'Example',
url: 'https://example.com',
};
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: [],
active: '__browser__:1',
browserTabs: [browserTab],
}}
onTabsStateChange={onTabsStateChange}
/>,
);
fireEvent.click(screen.getByTestId('emit-browser-snapshot-success'));
await screen.findByRole('button', { name: 'View Design Files' });
const toastAction = document.querySelector<HTMLButtonElement>('.od-toast-action');
if (!toastAction) throw new Error('Could not find browser snapshot toast action');
await act(async () => {
fireEvent.click(toastAction);
});
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: [],
active: DESIGN_FILES_TAB,
browserTabs: [browserTab],
});
});
expect(onTabsStateChange).not.toHaveBeenCalledWith(
expect.objectContaining({ active: 'browser-archive/example/manifest.json' }),
);
});
it('anchors a new browser after the visible tab tail', async () => {
const onTabsStateChange = vi.fn();
const rootBrowserTab = {
id: '__browser__:1',
insertAfter: '__design_files__',
label: 'Browser',
};
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['terminal:term-1'],
active: 'terminal:term-1',
browserTabs: [rootBrowserTab],
}}
onTabsStateChange={onTabsStateChange}
/>,
);
fireEvent.click(screen.getByTestId('workspace-add-tab'));
fireEvent.click(await screen.findByRole('button', { name: /New Browser/i }));
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['terminal:term-1'],
active: '__browser__:2',
browserTabs: [
rootBrowserTab,
{
id: '__browser__:2',
insertAfter: 'terminal:term-1',
label: 'Browser 2',
},
],
});
});
});
it('reanchors stale browser tabs before appending a file from the launcher', async () => {
const onTabsStateChange = vi.fn();
const staleBrowserTab = {
id: '__browser__:1',
insertAfter: 'deleted.html',
label: 'Browser',
};
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html'), workspaceFile('notes.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['cover.html'],
active: 'cover.html',
browserTabs: [staleBrowserTab],
}}
onTabsStateChange={onTabsStateChange}
/>,
);
fireEvent.click(screen.getByTestId('workspace-add-tab'));
fireEvent.click(await screen.findByRole('button', { name: /notes\.html/i }));
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['cover.html', 'notes.html'],
active: 'notes.html',
browserTabs: [
{
...staleBrowserTab,
insertAfter: 'cover.html',
},
],
});
});
});
it('opens the Design Files tab launcher with the browser new-tab shortcut', async () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
const allowedDefault = fireEvent.keyDown(window, {
key: 't',
metaKey: true,
});
expect(allowedDefault).toBe(false);
expect(screen.getByTestId('workspace-add-tab').getAttribute('aria-expanded')).toBe(
'true',
);
expect(await screen.findByRole('dialog', { name: /New tab/i })).toBeTruthy();
expect(screen.getByTestId('tab-launcher-search')).toBe(document.activeElement);
});
it('closes the active Design Files workspace tab with the browser close-tab shortcut', () => {
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('analysis.html'), workspaceFile('notes.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['analysis.html', 'notes.html'], active: 'notes.html' }}
onTabsStateChange={onTabsStateChange}
/>,
);
const allowedDefault = fireEvent.keyDown(window, {
key: 'w',
ctrlKey: true,
});
expect(allowedDefault).toBe(false);
expect(onTabsStateChange).toHaveBeenLastCalledWith({
tabs: ['analysis.html'],
active: 'analysis.html',
});
});
it('switches Design Files workspace tabs with browser-style next and previous shortcuts', async () => {
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('analysis.html'), workspaceFile('notes.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['analysis.html', 'notes.html'], active: 'analysis.html' }}
onTabsStateChange={onTabsStateChange}
/>,
);
const nextAllowedDefault = fireEvent.keyDown(window, {
key: 'Tab',
ctrlKey: true,
});
expect(nextAllowedDefault).toBe(false);
await waitFor(() => {
expect(screen.getByRole('tab', { name: /notes\.html/ }).getAttribute('aria-selected')).toBe(
'true',
);
});
expect(onTabsStateChange).toHaveBeenLastCalledWith({
tabs: ['analysis.html', 'notes.html'],
active: 'notes.html',
});
const previousAllowedDefault = fireEvent.keyDown(window, {
key: 'Tab',
ctrlKey: true,
shiftKey: true,
});
expect(previousAllowedDefault).toBe(false);
await waitFor(() => {
expect(screen.getByRole('tab', { name: /analysis\.html/ }).getAttribute('aria-selected')).toBe(
'true',
);
});
expect(onTabsStateChange).toHaveBeenLastCalledWith({
tabs: ['analysis.html', 'notes.html'],
active: 'analysis.html',
});
});
it('focuses a browser open request without adding it to file tabs', async () => {
const onTabsStateChange = vi.fn();
const browserTabs = [
{
id: '__browser__:1',
label: 'Browser 1',
title: 'Dribbble',
url: 'https://dribbble.com/',
},
];
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['cover.html'], active: 'cover.html', browserTabs }}
openRequest={{ name: '__browser__:1', nonce: 1 }}
onTabsStateChange={onTabsStateChange}
/>,
);
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['cover.html'],
active: '__browser__:1',
browserTabs,
});
});
});
it('creates and navigates a browser tab from a browser open request', async () => {
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['cover.html'], active: 'cover.html' }}
browserOpenRequest={{ tabId: '__browser__:1', url: 'https://economist.com/', nonce: 7 }}
onTabsStateChange={onTabsStateChange}
/>,
);
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['cover.html'],
active: '__browser__:1',
browserTabs: [
{
id: '__browser__:1',
insertAfter: 'cover.html',
label: 'Browser',
title: 'economist.com',
url: 'https://economist.com/',
},
],
});
});
await waitFor(() => {
expect(screen.getByTestId('design-browser-panel').getAttribute('data-navigate-url'))
.toBe('https://economist.com/');
});
});
it('opens a share request without dropping existing browser tabs', async () => {
const onTabsStateChange = vi.fn();
const browserTabs = [
{
id: '__browser__:1',
label: 'Browser 1',
title: 'Dribbble',
url: 'https://dribbble.com/',
},
];
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html'), workspaceFile('landing.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['cover.html'], active: '__browser__:1', browserTabs }}
shareRequest={{ name: 'landing.html', nonce: 1 }}
onTabsStateChange={onTabsStateChange}
/>,
);
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['cover.html', 'landing.html'],
active: 'landing.html',
browserTabs,
});
});
});
it('opens and activates the target file for a download request', async () => {
const onTabsStateChange = vi.fn();
const browserTabs = [
{ id: '__browser__:1', label: 'Browser 1', title: 'Dribbble', url: 'https://dribbble.com/' },
];
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html'), workspaceFile('landing.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['cover.html'], active: '__browser__:1', browserTabs }}
downloadRequest={{ name: 'landing.html', nonce: 1 }}
onTabsStateChange={onTabsStateChange}
/>,
);
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['cover.html', 'landing.html'],
active: 'landing.html',
browserTabs,
});
});
});
it('focuses the design-system workspace tab without adding it to file tabs', async () => {
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['cover.html'], active: 'cover.html' }}
openRequest={{ name: '__design_system__', nonce: 1 }}
onTabsStateChange={onTabsStateChange}
designSystemProject={{
id: 'neutral-modern',
title: 'Neutral Modern',
category: 'Starter',
source: 'bundled',
updatedAt: 1,
} as never}
/>,
);
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['cover.html'],
active: '__design_system__',
});
});
});
it('focuses an already-open file tab without adding a duplicate tab', async () => {
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('Web Prototype mutuals-v2.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['Web Prototype mutuals-v2.html'],
active: 'notes.html',
}}
openRequest={{ name: 'Web Prototype mutuals-v2.html', nonce: 1 }}
onTabsStateChange={onTabsStateChange}
/>,
);
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['Web Prototype mutuals-v2.html'],
active: 'Web Prototype mutuals-v2.html',
});
});
});
});
describe('DesignFilesPanel plugin folders', () => {
it('surfaces generated plugin folders with agent-routed CLI actions', async () => {
const onPluginFolderAgentAction = vi.fn();
const container = renderWorkspace(
<DesignFilesPanel
projectId="project-1"
files={[
workspaceFile('generated-plugin/open-design.json'),
workspaceFile('generated-plugin/SKILL.md'),
workspaceFile('generated-plugin/examples/demo.md'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onRenameFile={vi.fn()}
onUpload={vi.fn()}
onUploadFiles={vi.fn()}
onPaste={vi.fn()}
onNewSketch={vi.fn()}
onPluginFolderAgentAction={onPluginFolderAgentAction}
/>,
);
expect(container.querySelector('[data-testid="design-plugin-folder-generated-plugin"]')).toBeTruthy();
const install = container.querySelector<HTMLButtonElement>(
'[data-testid="design-plugin-folder-install-generated-plugin"]',
);
expect(install).toBeTruthy();
await act(async () => {
install?.click();
});
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'install');
const publish = container.querySelector<HTMLButtonElement>(
'[data-testid="design-plugin-folder-publish-generated-plugin"]',
);
const contribute = container.querySelector<HTMLButtonElement>(
'[data-testid="design-plugin-folder-contribute-generated-plugin"]',
);
expect(publish).toBeTruthy();
expect(contribute).toBeTruthy();
await act(async () => {
publish?.click();
});
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'publish');
await act(async () => {
contribute?.click();
});
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'contribute');
expect(container.textContent).not.toContain(
'Sent to the agent. The CLI run will continue in chat.',
);
});
});
describe('FileWorkspace tab reordering', () => {
it('persists a dragged file tab before the tab it is dropped on', () => {
const onTabsStateChange = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[
workspaceFile('analysis.html'),
workspaceFile('notes.md'),
workspaceFile('summary.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md', 'summary.html'],
active: null,
}}
onTabsStateChange={onTabsStateChange}
/>,
);
const source = getTabByName(container, /summary\.html/i);
const target = getTabByName(container, /analysis\.html/i);
stubTabRect(target);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
act(() => dispatchDragEvent(target, 'drop', dataTransfer));
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['summary.html', 'analysis.html', 'notes.md'],
active: null,
});
});
it('persists a dragged file tab after the tab when dropped on its right side', () => {
const onTabsStateChange = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[
workspaceFile('analysis.html'),
workspaceFile('notes.md'),
workspaceFile('summary.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md', 'summary.html'],
active: null,
}}
onTabsStateChange={onTabsStateChange}
/>,
);
const source = getTabByName(container, /analysis\.html/i);
const target = getTabByName(container, /summary\.html/i);
stubTabRect(target);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(target, 'drop', dataTransfer, 75));
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['notes.md', 'summary.html', 'analysis.html'],
active: null,
});
});
it('does not persist when a tab is dropped on itself', () => {
const onTabsStateChange = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md'],
active: null,
}}
onTabsStateChange={onTabsStateChange}
/>,
);
const tab = getTabByName(container, /analysis\.html/i);
stubTabRect(tab);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(tab, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(tab, 'drop', dataTransfer));
expect(onTabsStateChange).not.toHaveBeenCalled();
});
it('clears the drop indicator when the drag leaves the tab bar', () => {
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md'],
active: null,
}}
onTabsStateChange={vi.fn()}
/>,
);
const source = getTabByName(container, /analysis\.html/i);
const target = getTabByName(container, /notes\.md/i);
const tabBar = container.querySelector<HTMLElement>('.ws-tabs-bar');
if (!tabBar) throw new Error('Could not find tabs bar');
stubTabRect(target);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
expect(target.className).toContain('drag-over-before');
act(() => dispatchDragEvent(tabBar, 'dragleave', dataTransfer, 0, document.body));
expect(target.className).not.toContain('drag-over-before');
expect(target.className).not.toContain('drag-over-after');
});
});
describe('FileWorkspace Questions tab', () => {
const discoveryForm = {
id: 'discovery',
title: 'Quick brief',
questions: [
{
id: 'platform',
label: 'Platform',
type: 'radio' as const,
options: [
{ label: 'Mobile', value: 'Mobile' },
{ label: 'Desktop web', value: 'Desktop web' },
],
required: true,
},
],
};
it('shows the Questions tab while the form is unanswered', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
questionForm={discoveryForm}
/>,
);
expect(screen.getByTestId('questions-tab')).toBeTruthy();
});
it('closes the Questions preview after submit, then lets the answered form reopen', async () => {
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-1',
projectKind: 'prototype',
files: [],
liveArtifacts: [],
onRefreshFiles: vi.fn(),
isDeck: false,
tabsState: { tabs: [], active: null },
onTabsStateChange: vi.fn(),
questionForm: discoveryForm,
focusQuestionsRequest: { nonce: 1 },
};
const { rerender } = render(<FileWorkspace {...baseProps} />);
await waitFor(() => {
expect(screen.getByText('Quick brief')).toBeTruthy();
});
rerender(
<FileWorkspace
{...baseProps}
questionFormSubmittedAnswers={{ platform: 'Mobile' }}
/>,
);
await waitFor(() => {
expect(screen.queryByText('Quick brief')).toBeNull();
});
expect(screen.getByTestId('questions-tab')).toBeTruthy();
fireEvent.click(screen.getByTestId('questions-tab'));
expect(screen.getByText('Quick brief')).toBeTruthy();
expect(screen.getByText('Mobile')).toBeTruthy();
});
});
describe('projectSplitClassName', () => {
it('marks the project split as focused so the chat pane can collapse globally', () => {
expect(projectSplitClassName(false)).toBe('split');
expect(projectSplitClassName(true)).toBe('split split-focus');
});
it('uses CSS variables for split widths so pointer resize can update layout without rerendering workspace content', () => {
expect(projectSplitStyle(false, 512, 'minmax(420px, 1fr)')).toEqual({
'--project-chat-panel-width': '512px',
'--project-workspace-panel-track': 'minmax(420px, 1fr)',
gridTemplateColumns: '512px 8px minmax(420px, 1fr)',
});
expect(projectSplitStyle(true, 512, 'minmax(420px, 1fr)')).toBeUndefined();
});
});
describe('scrollWorkspaceTabsWithWheel', () => {
function makeTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
return { scrollLeft, scrollWidth, clientWidth } as HTMLDivElement;
}
function makeClampedTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
let value = scrollLeft;
return {
scrollWidth,
clientWidth,
get scrollLeft() {
return value;
},
set scrollLeft(next: number) {
value = Math.min(Math.max(next, 0), scrollWidth - clientWidth);
},
} as HTMLDivElement;
}
it('maps vertical mouse wheel movement to horizontal tab scrolling', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(52);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('supports reverse vertical wheel movement', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(52);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: -40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('normalizes line-based wheel deltas to useful pixel movement', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: false,
deltaMode: 1,
deltaX: 0,
deltaY: 3,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(60);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('normalizes page-based wheel deltas to useful pixel movement', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12, 600, 200);
const event = {
ctrlKey: false,
deltaMode: 2,
deltaX: 0,
deltaY: 1,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(172);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('leaves native horizontal wheel gestures alone', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 50,
deltaY: 10,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).not.toHaveBeenCalled();
});
it('leaves ctrl-wheel zoom gestures alone', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: true,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).not.toHaveBeenCalled();
});
it('does not intercept vertical wheel movement when tabs do not overflow', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12, 200, 200);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).not.toHaveBeenCalled();
});
it('lets page scrolling continue when the tab bar is already at the wheel boundary', () => {
const preventDefault = vi.fn();
const currentTarget = makeClampedTabBar(200, 400, 200);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(200);
expect(preventDefault).not.toHaveBeenCalled();
});
});
describe('FileWorkspace sketch save', () => {
it('opens a persisted sketch through the editor path without rendering the static preview first', async () => {
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
type: 'excalidraw',
version: 2,
elements: [{ id: 'box', type: 'rectangle', isDeleted: false }],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
}),
);
const { container } = render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
expect(screen.getByText('Loading sketch…')).toBeTruthy();
expect(container.querySelector('[data-testid="sketch-preview-svg"]')).toBeNull();
await waitFor(() => {
expect(screen.getByTestId('excalidraw')).toBeTruthy();
});
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1);
expect(mockedFetchProjectFileText).toHaveBeenCalledWith('project-1', 'test.sketch.json');
expect(container.querySelector('[data-testid="sketch-preview-svg"]')).toBeNull();
});
it('preloads persisted sketches before the tab is opened', async () => {
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
type: 'excalidraw',
version: 2,
elements: [{ id: 'box', type: 'rectangle', isDeleted: false }],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
}),
);
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-1',
projectKind: 'prototype',
files: [file],
liveArtifacts: [],
onRefreshFiles: vi.fn(),
isDeck: false,
tabsState: { tabs: [], active: null },
onTabsStateChange: vi.fn(),
};
const { rerender } = render(
<FileWorkspace {...baseProps} />,
);
await waitFor(() => {
expect(mockedFetchProjectFileText).toHaveBeenCalledWith('project-1', 'test.sketch.json');
});
rerender(
<FileWorkspace
{...baseProps}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
/>,
);
expect(screen.queryByText('Loading sketch…')).toBeNull();
expect(screen.getByTestId('excalidraw')).toBeTruthy();
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1);
});
it('keeps saving state visible for at least 500ms', async () => {
// Simulate user doing some edits in the workspace
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
version: 1,
items: [
{ kind: 'pen', points: [{ x: 10, y: 20 }], color: '#000', size: 2 },
],
}),
);
mockedWriteProjectTextFile.mockResolvedValue(file);
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(document.querySelector('canvas')).not.toBeNull();
});
vi.useFakeTimers();
const btn = screen.getByText('Save') as HTMLButtonElement;
expect(btn.disabled).toBe(false);
await act(async () => {
fireEvent.click(btn);
});
expect(btn.textContent).toBe('Saving…');
expect(btn.disabled).toBe(true);
// Before the 500ms floor is reached, still saving
await act(async () => {
vi.advanceTimersByTime(400);
});
expect(btn.textContent).toBe('Saving…');
expect(btn.disabled).toBe(true);
// After 500ms total, saving should end and the checkmark should appear
await act(async () => {
vi.advanceTimersByTime(100);
});
expect(btn.textContent).not.toBe('Saving…');
expect(btn.querySelector('svg')).not.toBeNull();
});
it('autosaves an empty sketch when clear happens before a pending sketch autosave', async () => {
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
type: 'excalidraw',
version: 2,
elements: [],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
}),
);
mockedWriteProjectTextFile.mockResolvedValue(file);
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('excalidraw')).toBeTruthy();
});
vi.useFakeTimers();
const props = excalidrawWorkspaceMock.lastProps;
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
act(() => {
props.onChange(
[{ id: 'baseline', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
props.onChange(
[{ id: 'drawn-before-clear', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
});
const clearButton = screen.getByRole('button', { name: 'Clear' }) as HTMLButtonElement;
expect(clearButton.disabled).toBe(false);
act(() => {
fireEvent.click(clearButton);
});
await act(async () => {
vi.advanceTimersByTime(800);
await Promise.resolve();
});
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
const savedText = mockedWriteProjectTextFile.mock.calls[0]?.[2];
if (typeof savedText !== 'string') throw new Error('expected saved sketch JSON');
const saved = JSON.parse(savedText) as { elements?: unknown[] };
expect(saved.elements).toEqual([]);
});
it('serializes overlapping sketch autosaves so the latest scene wins', async () => {
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
let resolveFirstSave!: (file: ProjectFile) => void;
const firstSave = new Promise<ProjectFile>((resolve) => {
resolveFirstSave = resolve;
});
const savedTexts: string[] = [];
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
type: 'excalidraw',
version: 2,
elements: [],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
}),
);
mockedWriteProjectTextFile.mockImplementation(async (_projectId, _name, text) => {
savedTexts.push(text);
return savedTexts.length === 1 ? firstSave : file;
});
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('excalidraw')).toBeTruthy();
});
vi.useFakeTimers();
const props = excalidrawWorkspaceMock.lastProps;
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
act(() => {
props.onChange(
[{ id: 'baseline', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
props.onChange(
[{ id: 'autosave-a', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
});
await act(async () => {
vi.advanceTimersByTime(800);
await Promise.resolve();
});
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
act(() => {
props.onChange(
[{ id: 'autosave-b', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
});
await act(async () => {
vi.advanceTimersByTime(800);
await Promise.resolve();
});
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
await act(async () => {
resolveFirstSave(file);
await firstSave;
await Promise.resolve();
await Promise.resolve();
});
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(2);
const firstSaved = JSON.parse(savedTexts[0]!) as { elements?: Array<{ id?: string }> };
const secondSaved = JSON.parse(savedTexts[1]!) as { elements?: Array<{ id?: string }> };
expect(firstSaved.elements?.[0]?.id).toBe('autosave-a');
expect(secondSaved.elements?.[0]?.id).toBe('autosave-b');
});
it('does not wire Excalidraw library item changes into sketch autosave', async () => {
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
type: 'excalidraw',
version: 2,
elements: [],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
libraryItems: [],
}),
);
mockedWriteProjectTextFile.mockResolvedValue(file);
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('excalidraw')).toBeTruthy();
});
const props = excalidrawWorkspaceMock.lastProps;
expect(props?.onLibraryChange).toBeUndefined();
expect(props?.initialData?.libraryItems).toBeUndefined();
expect(mockedWriteProjectTextFile).not.toHaveBeenCalled();
});
it('flushes pending sketch autosaves when the workspace unmounts before debounce', async () => {
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
type: 'excalidraw',
version: 2,
elements: [],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
libraryItems: [],
}),
);
mockedWriteProjectTextFile.mockResolvedValue(file);
const { unmount } = render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('excalidraw')).toBeTruthy();
});
const props = excalidrawWorkspaceMock.lastProps;
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
act(() => {
props.onChange([], { viewBackgroundColor: '#ffffff' }, {});
props.onChange(
[{ id: 'scene-before-unmount', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
unmount();
});
await waitFor(() => {
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
});
const savedText = mockedWriteProjectTextFile.mock.calls[0]?.[2];
if (typeof savedText !== 'string') throw new Error('expected saved sketch JSON');
const saved = JSON.parse(savedText) as { elements?: Array<{ id?: string }>; libraryItems?: unknown[] };
expect(saved.elements?.map((item) => item.id)).toEqual(['scene-before-unmount']);
expect(saved.libraryItems).toBeUndefined();
});
it('preserves a newer sketch scene while its autosave is still debouncing', async () => {
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
const writes: Array<{
text: string;
resolve: (file: ProjectFile | null) => void;
}> = [];
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
type: 'excalidraw',
version: 2,
elements: [],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
}),
);
mockedWriteProjectTextFile.mockImplementation((_projectId, _name, text) => new Promise((resolve) => {
if (typeof text !== 'string') throw new Error('expected saved sketch JSON');
writes.push({ text, resolve });
}));
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('excalidraw')).toBeTruthy();
});
vi.useFakeTimers();
const props = excalidrawWorkspaceMock.lastProps;
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
act(() => {
props.onChange(
[{ id: 'baseline', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
props.onChange(
[{ id: 'older-scene', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
});
await act(async () => {
vi.advanceTimersByTime(800);
await Promise.resolve();
});
expect(writes).toHaveLength(1);
act(() => {
props.onChange(
[{ id: 'latest-scene', type: 'rectangle', isDeleted: false }],
{ viewBackgroundColor: '#ffffff' },
{},
);
});
await act(async () => {
writes[0]?.resolve(file);
await Promise.resolve();
await Promise.resolve();
});
expect(writes).toHaveLength(2);
await act(async () => {
writes[1]?.resolve(file);
await Promise.resolve();
await Promise.resolve();
});
const firstSaved = JSON.parse(writes[0]?.text ?? '{}') as { elements?: Array<{ id?: string }> };
const latestSaved = JSON.parse(writes[1]?.text ?? '{}') as { elements?: Array<{ id?: string }> };
expect(firstSaved.elements?.map((element) => element.id)).toEqual(['older-scene']);
expect(latestSaved.elements?.map((element) => element.id)).toEqual(['latest-scene']);
});
});
describe('FileWorkspace add-module menu', () => {
it('opens the add-module menu with Browser available and Terminal hidden', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
const addButton = screen.getByTestId('workspace-add-tab');
expect(addButton.getAttribute('aria-expanded')).toBe('false');
act(() => {
fireEvent.click(addButton);
});
expect(addButton.getAttribute('aria-expanded')).toBe('true');
const browserItem = screen.getByRole('button', { name: /New Browser/ });
const menu = browserItem.closest('[data-testid="tab-launcher-menu"]');
expect(menu).not.toBeNull();
expect(screen.queryByRole('button', { name: /New Terminal/ })).toBeNull();
// The tab strip is a horizontal scroll container that also clips
// vertically, so the "+" button lives outside it in `.ws-add-tab`
// and the launcher menu is portaled to <body> -- neither can be clipped
// by the scrolling bar.
const tabsBar = document.querySelector('.ws-tabs-bar');
expect(tabsBar).not.toBeNull();
expect(tabsBar!.contains(addButton)).toBe(false);
expect(tabsBar!.contains(menu)).toBe(false);
expect(addButton.closest('.ws-add-tab')).not.toBeNull();
});
it('orders launcher sections as create new, files, then tabs in one scroll body', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['cover.html'],
active: 'cover.html',
browserTabs: [
{
id: '__browser__:1',
label: 'Reference Browser',
title: 'Behance',
url: 'https://www.behance.net/',
},
],
}}
onTabsStateChange={vi.fn()}
/>,
);
act(() => {
fireEvent.click(screen.getByTestId('workspace-add-tab'));
});
const scrollBody = screen.getByTestId('tab-launcher-scroll-body');
const createHeader = screen.getByText('Create new');
const fileHeader = screen.getByText('Open a file');
const tabsHeader = screen.getByText('Open tabs');
expect(scrollBody.contains(createHeader)).toBe(true);
expect(scrollBody.contains(fileHeader)).toBe(true);
expect(scrollBody.contains(tabsHeader)).toBe(true);
expect(createHeader.compareDocumentPosition(fileHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(fileHeader.compareDocumentPosition(tabsHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('adds a new browser tab every time the Browser module is selected', () => {
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={onTabsStateChange}
/>,
);
const addButton = screen.getByTestId('workspace-add-tab');
for (let i = 0; i < 3; i += 1) {
act(() => {
fireEvent.click(addButton);
});
act(() => {
fireEvent.click(screen.getByRole('button', { name: /New Browser/ }));
});
}
const browserTabs = screen
.getAllByRole('tab')
.filter((tab) => /Browser(?: \d+)?/.test(tab.textContent ?? ''));
expect(browserTabs).toHaveLength(3);
expect(browserTabs.map((tab) => tab.textContent?.trim())).toEqual([
'Browser',
'Browser 2',
'Browser 3',
]);
expect(browserTabs[2]!.getAttribute('aria-selected')).toBe('true');
const browserPanels = screen
.getAllByTestId('design-browser-panel')
.map((panel) => panel.closest('.ws-browser-panel'));
expect(browserPanels).toHaveLength(3);
expect(browserPanels[0]!.className).not.toContain('active');
expect(browserPanels[1]!.className).not.toContain('active');
expect(browserPanels[2]!.className).toContain('active');
expect(onTabsStateChange).toHaveBeenLastCalledWith({
tabs: [],
active: '__browser__:3',
browserTabs: [
{ id: '__browser__:1', insertAfter: '__design_files__', label: 'Browser' },
{ id: '__browser__:2', insertAfter: '__browser__:1', label: 'Browser 2' },
{ id: '__browser__:3', insertAfter: '__browser__:2', label: 'Browser 3' },
],
});
});
it('restores persisted browser tabs with their active URL state', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: [],
active: '__browser__:2',
browserTabs: [
{
id: '__browser__:2',
insertAfter: '__design_files__',
label: 'Browser 2',
title: 'SVG Repo',
url: 'https://www.svgrepo.com/',
iconUrl: 'https://www.svgrepo.com/favicon.ico',
},
],
}}
onTabsStateChange={vi.fn()}
/>,
);
const restoredTab = screen.getByRole('tab', { name: /SVG Repo/ });
expect(restoredTab.getAttribute('aria-selected')).toBe('true');
const browserPanel = screen.getByTestId('design-browser-panel');
expect(browserPanel.dataset.initialUrl).toBe('https://www.svgrepo.com/');
expect(browserPanel.dataset.initialTitle).toBe('SVG Repo');
expect(browserPanel.dataset.initialIconUrl).toBe('https://www.svgrepo.com/favicon.ico');
});
it('persists browser-tab removal when a browser tab is closed', () => {
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: [],
active: '__browser__:1',
browserTabs: [
{ id: '__browser__:1', insertAfter: '__design_files__', label: 'Browser' },
],
}}
onTabsStateChange={onTabsStateChange}
/>,
);
const restoredTab = screen.getByRole('tab', { name: /Browser/ });
const closeButton = restoredTab.querySelector<HTMLButtonElement>('.ws-tab-close');
expect(closeButton).not.toBeNull();
act(() => {
fireEvent.click(closeButton!);
});
expect(screen.queryByRole('tab', { name: /Browser/ })).toBeNull();
expect(screen.queryByTestId('design-browser-panel')).toBeNull();
expect(onTabsStateChange).toHaveBeenLastCalledWith({
tabs: [],
active: '__design_files__',
});
});
it('keeps the pinned brand browser tab mounted while another tab is active', () => {
const browserTabs = [
{
id: '__browser__:1',
label: 'Browser',
title: 'The Economist',
url: 'https://www.economist.com/',
},
];
// Without the pin, a browser tab that was never activated this session is
// not mounted while a file tab is active, so its live (post-wall) DOM can't
// be read — this is the failure the pin fixes.
const { unmount } = render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('brand.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['brand.html'], active: 'brand.html', browserTabs }}
onTabsStateChange={vi.fn()}
/>,
);
expect(screen.queryByTestId('design-browser-panel')).toBeNull();
unmount();
// With the pin, the same inactive browser tab stays mounted so the chat
// "Continue extraction" handler can read its post-wall DOM.
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('brand.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['brand.html'], active: 'brand.html', browserTabs }}
pinnedBrowserTabId="__browser__:1"
onTabsStateChange={vi.fn()}
/>,
);
const panel = screen.getByTestId('design-browser-panel');
expect(panel.dataset.initialTitle).toBe('The Economist');
});
});
describe('FileWorkspace empty-project generation contract', () => {
function assistantMessage(runStatus: 'running' | 'failed'): ChatMessage {
return {
id: `msg-${runStatus}`,
role: 'assistant',
content: '',
createdAt: 1700000000,
startedAt: 1700000000,
runId: `run-${runStatus}`,
runStatus,
agentId: 'claude',
preTurnFileNames: [],
events: [{ kind: 'status', label: runStatus === 'failed' ? 'error' : 'thinking' }],
};
}
// The generation-preview card / transient `generating-tab` were removed: an
// empty project keeps the plain `design-files-empty` placeholder for running
// AND failed turns, with no card hijacking the surface.
it.each(['running', 'failed'] as const)(
'keeps the design-files empty placeholder and shows no generation card for a %s turn',
(runStatus) => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
streaming={runStatus === 'running'}
tabsState={{ tabs: [], active: DESIGN_FILES_TAB }}
onTabsStateChange={vi.fn()}
messages={[assistantMessage(runStatus)]}
/>,
);
expect(screen.queryByTestId('generating-tab')).toBeNull();
expect(screen.queryByTestId('generation-preview-stage')).toBeNull();
expect(screen.getByTestId('design-files-empty')).toBeTruthy();
},
);
});