Fix template project creation flow (#2399)

This commit is contained in:
Siri-Ray 2026-05-20 17:18:24 +08:00 committed by GitHub
parent a5e43ae2a4
commit f4b8fbece2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 205 additions and 25 deletions

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { flushSync } from 'react-dom';
import { useAnalytics } from './analytics/provider';
import { trackProjectCreateResult } from './analytics/events';
import { detectClientType } from './analytics/identity';
@ -17,7 +18,7 @@ import { PetOverlay, type PetTaskCenter } from './components/pet/PetOverlay';
import { buildPetTaskCenter } from './components/pet/taskCenter';
import { migrateCustomPetAtlas } from './components/pet/pets';
import { ProjectView } from './components/ProjectView';
import { WorkspaceTabsBar } from './components/WorkspaceTabsBar';
import { openWorkspaceTab, WorkspaceTabsBar } from './components/WorkspaceTabsBar';
import {
DesignSystemCreationFlow,
DesignSystemDetailView,
@ -769,7 +770,7 @@ export function App() {
requestId?: string;
pendingFiles?: File[];
},
) => {
): Promise<boolean> => {
// Honor an explicit `null` design system — the create panel defaults
// to "None" for every kind now, and the user expects that to land
// as a no-design-system project rather than silently inheriting the
@ -809,7 +810,7 @@ export function App() {
},
{ requestId: input.requestId },
);
return;
return false;
}
const pendingFiles = Array.isArray(input.pendingFiles)
? input.pendingFiles.filter((file): file is File => file instanceof File)
@ -870,15 +871,20 @@ export function App() {
appliedPluginSnapshotId: result.appliedPluginSnapshotId,
}
: result.project;
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
navigate({
flushSync(() => {
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
});
const projectRoute = {
kind: 'project',
projectId: project.id,
fileName: null,
});
} as const;
openWorkspaceTab(projectRoute);
navigate(projectRoute);
return true;
},
[analytics.track],
);

View file

@ -113,14 +113,56 @@ function defaultPluginInputsForCreate(
input: CreateInput,
pluginId: string | null,
): Record<string, unknown> | null {
if (pluginId !== 'od-media-generation') return null;
const kind = input.metadata.kind;
const projectName = input.name.trim();
if (pluginId === 'example-web-prototype') {
return {
artifactKind: input.metadata.includeLandingPage
? 'landing page'
: 'web prototype',
fidelity: input.metadata.fidelity ?? 'high-fidelity',
audience: 'product evaluators',
designSystem: 'the active project design system',
template: input.metadata.templateLabel ?? 'the bundled web prototype seed',
};
}
if (pluginId === 'example-simple-deck') {
return {
deckType: 'pitch deck',
topic: projectName || 'the user brief',
audience: 'decision makers',
slideCount: 10,
speakerNotes: input.metadata.speakerNotes
? 'include speaker notes'
: 'no speaker notes',
designSystem: 'the active project design system',
};
}
if (pluginId === 'od-new-generation') {
const templateLabel = input.metadata.templateLabel?.trim();
const artifactKind =
kind === 'template'
? 'artifact based on a saved template'
: kind === 'other'
? 'custom design artifact'
: `${kind} artifact`;
return {
artifactKind,
audience: 'product and design reviewers',
topic: templateLabel || projectName || 'the user brief',
};
}
if (pluginId !== 'od-media-generation') return null;
if (kind !== 'image' && kind !== 'video' && kind !== 'audio') return null;
const promptTemplate = input.metadata.promptTemplate;
const subject =
promptTemplate?.prompt?.trim()
|| input.name.trim()
|| projectName
|| promptTemplate?.title?.trim()
|| `${kind} concept`;
const style =
@ -250,7 +292,7 @@ interface Props {
autoSendFirstMessage?: boolean;
pendingFiles?: File[];
},
) => void;
) => Promise<boolean> | boolean | void;
onCreatePluginShareProject: (
pluginId: string,
action: PluginShareAction,
@ -459,7 +501,7 @@ export function EntryShell({
// single row without touching the form.
const pluginId = defaultPluginIdForKind(input.metadata);
const pluginInputs = defaultPluginInputsForCreate(input, pluginId);
onCreateProject({
return onCreateProject({
...input,
...(pluginId ? { pluginId } : {}),
...(pluginInputs ? { pluginInputs } : {}),

View file

@ -94,7 +94,7 @@ interface Props {
autoSendFirstMessage?: boolean;
pendingFiles?: File[];
},
) => void;
) => Promise<boolean> | boolean | void;
onCreatePluginShareProject: (
pluginId: string,
action: PluginShareAction,

View file

@ -5,10 +5,10 @@
// (prototype / live-artifact / deck / template / image / video /
// audio / other) and their connector / template / design-system
// pickers carry over without duplication. The modal closes itself
// when the panel calls onCreate (success path) or when the user
// when the panel calls onCreate and it completes (success path) or when the user
// clicks the backdrop / Esc.
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { ConnectorDetail } from '@open-design/contracts';
import type {
DesignSystemSummary,
@ -32,7 +32,7 @@ interface Props {
connectors?: ConnectorDetail[];
connectorsLoading?: boolean;
loading?: boolean;
onCreate: (input: CreateInput) => void;
onCreate: (input: CreateInput & { requestId?: string }) => Promise<boolean> | boolean | void;
onImportClaudeDesign?: (file: File) => Promise<void> | void;
onImportFolder?: (baseDir: string) => Promise<void> | void;
onOpenConnectorsTab?: () => void;
@ -60,15 +60,17 @@ export function NewProjectModal({
initialTab,
}: Props) {
const closeRef = useRef<HTMLButtonElement | null>(null);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape' && !creating) onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
}, [creating, open, onClose]);
useEffect(() => {
if (!open) return;
@ -81,11 +83,31 @@ export function NewProjectModal({
useEffect(() => {
if (!open) return;
setCreating(false);
setCreateError(null);
closeRef.current?.focus();
}, [open]);
if (!open) return null;
async function handleCreate(input: CreateInput & { requestId?: string }) {
if (creating) return;
setCreating(true);
setCreateError(null);
try {
const result = await onCreate(input);
if (result === false) {
setCreateError('Could not create project. Please try again.');
return;
}
onClose();
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Could not create project. Please try again.');
} finally {
setCreating(false);
}
}
return (
<div
className="new-project-modal-backdrop"
@ -94,7 +116,7 @@ export function NewProjectModal({
aria-label="New project"
data-testid="new-project-modal"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
if (e.target === e.currentTarget && !creating) onClose();
}}
>
<div className="new-project-modal">
@ -105,6 +127,7 @@ export function NewProjectModal({
type="button"
className="new-project-modal__close"
onClick={onClose}
disabled={creating}
aria-label="Close"
title="Close (Esc)"
>
@ -122,16 +145,25 @@ export function NewProjectModal({
{...(mediaProviders ? { mediaProviders } : {})}
{...(connectors ? { connectors } : {})}
{...(typeof connectorsLoading === 'boolean' ? { connectorsLoading } : {})}
{...(typeof loading === 'boolean' ? { loading } : {})}
loading={Boolean(loading) || creating}
onCreate={(input) => {
onCreate(input);
onClose();
void handleCreate(input);
}}
{...(onImportClaudeDesign ? { onImportClaudeDesign } : {})}
{...(onImportFolder ? { onImportFolder } : {})}
{...(onOpenConnectorsTab ? { onOpenConnectorsTab } : {})}
{...(initialTab ? { initialTab } : {})}
/>
{creating ? (
<div className="new-project-modal__status" role="status">
Creating project
</div>
) : null}
{createError ? (
<div className="new-project-modal__status error" role="alert">
{createError}
</div>
) : null}
</div>
</div>
</div>

View file

@ -49,11 +49,20 @@ interface Props {
}
const STORAGE_KEY = 'open-design:workspace-tabs:v1';
const OPEN_WORKSPACE_TAB_EVENT = 'open-design:workspace-tabs:open';
const MAX_VISIBLE_CHROME_TABS = 16;
const MAX_SEARCH_RESULTS = 80;
const TAB_STRIP_CONTROL_WIDTH = 112;
const MIN_VISIBLE_TAB_WIDTH = 76;
export function openWorkspaceTab(route: Route): void {
window.dispatchEvent(
new CustomEvent<{ route: Route }>(OPEN_WORKSPACE_TAB_EVENT, {
detail: { route },
}),
);
}
function nowId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
@ -292,6 +301,26 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
setState((current) => syncStateToRoute(current, route));
}, [route]);
useEffect(() => {
function onOpenWorkspaceTab(event: Event) {
const detail = (event as CustomEvent<{ route?: Route }>).detail;
const nextRoute = detail?.route;
if (!nextRoute) return;
const nextTab = tabFromRoute(nextRoute);
setState((current) => {
const normalized = normalizeTabsState(current);
return normalizeTabsState({
tabs: [...normalized.tabs, nextTab],
activeTabId: nextTab.id,
});
});
setTabsMenuOpen(false);
}
window.addEventListener(OPEN_WORKSPACE_TAB_EVENT, onOpenWorkspaceTab);
return () => window.removeEventListener(OPEN_WORKSPACE_TAB_EVENT, onOpenWorkspaceTab);
}, []);
useEffect(() => {
const stripElement = stripRef.current;
if (!stripElement) return undefined;

View file

@ -66,6 +66,10 @@
background: var(--bg-subtle);
color: var(--text);
}
.new-project-modal__close:disabled {
cursor: default;
opacity: 0.45;
}
.new-project-modal__body {
flex: 1 1 auto;
min-height: 0;
@ -82,3 +86,18 @@
min-height: 0;
padding-top: 0;
}
.new-project-modal__status {
flex: 0 0 auto;
margin-top: 10px;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 12px;
}
.new-project-modal__status.error {
border-color: color-mix(in oklab, var(--danger, #d4543b) 45%, var(--border));
background: color-mix(in oklab, var(--danger, #d4543b) 10%, var(--bg-panel));
color: var(--danger, #d4543b);
}

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NewProjectModal } from '../../src/components/NewProjectModal';
@ -80,6 +80,42 @@ describe('NewProjectModal layout', () => {
expect(screen.getByTestId('new-project-panel')).toBeTruthy();
expect(screen.getByTestId('create-project')).toBeTruthy();
});
it('keeps the modal open with a waiting state until project creation finishes', async () => {
let resolveCreate!: (value: boolean) => void;
const onCreate = vi.fn(
() => new Promise<boolean>((resolve) => {
resolveCreate = resolve;
}),
);
const onClose = vi.fn();
render(
<NewProjectModal
open
skills={skills}
designSystems={designSystems}
defaultDesignSystemId={null}
templates={[]}
promptTemplates={[]}
onCreate={onCreate}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByTestId('create-project'));
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('status').textContent).toContain('Creating project…');
expect((screen.getByTestId('create-project') as HTMLButtonElement).disabled).toBe(true);
resolveCreate(true);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
});
describe('NewProjectModal template deletion plumbing', () => {

View file

@ -3,7 +3,10 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WorkspaceTabsBar } from '../../src/components/WorkspaceTabsBar';
import {
openWorkspaceTab,
WorkspaceTabsBar,
} from '../../src/components/WorkspaceTabsBar';
import { navigate, type Route } from '../../src/router';
import type { Project } from '../../src/types';
@ -85,6 +88,19 @@ describe('WorkspaceTabsBar navigation semantics', () => {
});
});
it('can append and focus a project tab for create-project flows', async () => {
render(<WorkspaceTabsBar route={homeRoute} projects={[project]} />);
openWorkspaceTab(projectRoute);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(2);
expect(labels.some((label) => label.includes('Home'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
});
it('preserves restored Home tabs instead of collapsing them by route', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',