From f4b8fbece26d38af97d5faccea70d51cd5232406 Mon Sep 17 00:00:00 2001 From: Siri-Ray <109605599+Siri-Ray@users.noreply.github.com> Date: Wed, 20 May 2026 17:18:24 +0800 Subject: [PATCH] Fix template project creation flow (#2399) --- apps/web/src/App.tsx | 24 +++++---- apps/web/src/components/EntryShell.tsx | 50 +++++++++++++++++-- apps/web/src/components/EntryView.tsx | 2 +- apps/web/src/components/NewProjectModal.tsx | 50 +++++++++++++++---- apps/web/src/components/WorkspaceTabsBar.tsx | 29 +++++++++++ .../web/src/styles/home/new-project-modal.css | 19 +++++++ .../tests/components/NewProjectModal.test.tsx | 38 +++++++++++++- .../components/WorkspaceTabsBar.test.tsx | 18 ++++++- 8 files changed, 205 insertions(+), 25 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fa5c58a56..e59161654 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 => { // 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], ); diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index 4a967dbaf..bcc69faf3 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -113,14 +113,56 @@ function defaultPluginInputsForCreate( input: CreateInput, pluginId: string | null, ): Record | 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 | 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 } : {}), diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index 013b8cc23..9c08acf8d 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -94,7 +94,7 @@ interface Props { autoSendFirstMessage?: boolean; pendingFiles?: File[]; }, - ) => void; + ) => Promise | boolean | void; onCreatePluginShareProject: ( pluginId: string, action: PluginShareAction, diff --git a/apps/web/src/components/NewProjectModal.tsx b/apps/web/src/components/NewProjectModal.tsx index 80a53ebfa..f514d24db 100644 --- a/apps/web/src/components/NewProjectModal.tsx +++ b/apps/web/src/components/NewProjectModal.tsx @@ -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 | void; onImportClaudeDesign?: (file: File) => Promise | void; onImportFolder?: (baseDir: string) => Promise | void; onOpenConnectorsTab?: () => void; @@ -60,15 +60,17 @@ export function NewProjectModal({ initialTab, }: Props) { const closeRef = useRef(null); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(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 (
{ - if (e.target === e.currentTarget) onClose(); + if (e.target === e.currentTarget && !creating) onClose(); }} >
@@ -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 ? ( +
+ Creating project… +
+ ) : null} + {createError ? ( +
+ {createError} +
+ ) : null}
diff --git a/apps/web/src/components/WorkspaceTabsBar.tsx b/apps/web/src/components/WorkspaceTabsBar.tsx index 9c7e8fddc..86f0c2d76 100644 --- a/apps/web/src/components/WorkspaceTabsBar.tsx +++ b/apps/web/src/components/WorkspaceTabsBar.tsx @@ -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; diff --git a/apps/web/src/styles/home/new-project-modal.css b/apps/web/src/styles/home/new-project-modal.css index 68f8c60e8..3ad818760 100644 --- a/apps/web/src/styles/home/new-project-modal.css +++ b/apps/web/src/styles/home/new-project-modal.css @@ -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); +} diff --git a/apps/web/tests/components/NewProjectModal.test.tsx b/apps/web/tests/components/NewProjectModal.test.tsx index 1072493f1..196ab4596 100644 --- a/apps/web/tests/components/NewProjectModal.test.tsx +++ b/apps/web/tests/components/NewProjectModal.test.tsx @@ -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((resolve) => { + resolveCreate = resolve; + }), + ); + const onClose = vi.fn(); + + render( + , + ); + + 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', () => { diff --git a/apps/web/tests/components/WorkspaceTabsBar.test.tsx b/apps/web/tests/components/WorkspaceTabsBar.test.tsx index 77cb1432b..306ff241a 100644 --- a/apps/web/tests/components/WorkspaceTabsBar.test.tsx +++ b/apps/web/tests/components/WorkspaceTabsBar.test.tsx @@ -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(); + + 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',