mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(analytics): scaffold PostHog product-analytics integration
- Add @open-design/contracts/analytics subpath with the 17 P0 event
payload types, header constants, and code↔CSV enum mapping helpers.
- Add apps/daemon/src/analytics.ts with env-gated posthog-node client,
request-scoped analytics context reader, and artifact-id anonymizer.
- Expose GET /api/analytics/config so the web bundle never embeds the
PostHog key at build time; daemon owns POSTHOG_KEY / POSTHOG_HOST.
- Add apps/web/src/analytics module (identity + lazy posthog-js client
+ React provider) and mount it under <I18nProvider> in app/layout.
No event wiring yet — that lands in the next commit alongside trigger
points (App.tsx, EntryView, NewProjectPanel, SettingsDialog, FileViewer,
runs.ts).
* feat(analytics): wire app_launch, home_view, home_click, project_create_result
- App.tsx: fire app_launch once after first effect tick. handleCreateProject
now emits project_create_result on both success and failure paths.
- EntryView.tsx: home_view (page) gated on agents loading so
has_available_cli isn't transiently false; home_view (asset_panel) fires
per top-tab change with the right result_count.
- NewProjectPanel.tsx: home_click create_button fires before delegating to
the parent; a fresh request_id is generated here and threaded through
onCreate so the matching project_create_result stitches via $insert_id.
- contracts/analytics: tighten createTabToTracking and topTabToTracking
for the worktree branch's renamed tabs (live-artifact, templates).
* feat(analytics): wire settings_view + 3 settings_click events
- settings_view fires on dialog mount and on every section switch,
carrying the active section (mapped via settingsSectionToTracking
for the 16-section worktree layout), execution_mode, and the
selected CLI provider id when present.
- settings_click execution_mode_tab: setMode now emits before/after
values whenever the user toggles between Local CLI and BYOK.
- settings_click cli_provider_card: agent card onClick reports
cli_provider_id via agentIdToTracking (kiro → other).
- settings_click byok_field: onFocus added to api_key, model select,
and base_url inputs; provider_id widened to include google so the
worktree's Gemini protocol slot type-checks.
* feat(analytics): wire studio_view + studio_click chat, studio_view artifact
- packages/contracts/src/analytics/artifact-id.ts: FNV-1a 64-bit helper
produces a 16-hex anonymized id for (projectId, fileName). Stable
cross-platform so the daemon and the web bundle resolve the same id
without a Web Crypto round-trip; daemon now re-exports it.
- ChatComposer: studio_view chat_panel fires once per project mount,
studio_click chat_composer fires on attachment + send buttons with
estimated user_query_tokens (length/4) and has_attachment.
- FileViewer: studio_view artifact fires once per (project, file) at
the dispatcher level, before any sub-viewer renders, with
artifact_kind derived from the renderer registry / file.kind table.
- Widen TrackingExportFormat to include markdown and cloudflare_pages
so the worktree branch's full share menu can emit verbatim.
* feat(analytics): wire studio_click share_option + artifact_export_result
HtmlViewer's share menu now emits both events per click via a
fireShareExport helper:
- studio_click share_option fires immediately on click with the chosen
export_format and a fresh request_id.
- artifact_export_result fires when the export resolves — success for
sync exporters (html, markdown, template) the moment the call
returns, success/failed for async exporters (pdf, zip, deploy)
via .then/.catch. The same request_id threads both events so
PostHog stitches click → result via $insert_id.
DEPLOY_PROVIDER_OPTIONS maps to the CSV's vercel / cloudflare_pages
slots; markdown is now a first-class export_format value.
Also ignore .env.local so local POSTHOG_KEY / .env-style secrets
don't get committed.
* feat(analytics): emit run_created and run_finished from the daemon
POST /api/runs now reads the analytics context off the
x-od-analytics-* headers the web client sets on every fetch, then:
- Captures run_created with project_id, conversation_id, run_id,
model_id, agent_provider_id (mapped via agentIdToTracking),
skill_id, design_system_id, plus the token_count_source marker.
- Schedules a run_finished capture on runs.wait(run) resolution,
mapping succeeded/canceled/failed to success/cancelled/failed and
reporting total_duration_ms.
Both events use a stable insert_id derived from the same uuid so
PostHog dedupes the daemon-side mirror against any future
web-side capture without double-counting.
Token sub-fields (user_query_tokens/system_prompt_tokens/...) stay
omitted in v1 — the claude-stream parser only exposes input/output
totals today. See tracking-doc-issues.md §3.2.
* feat(analytics): emit settings_cli_test_result + settings_byok_test_result
The original BLOCKING-list assumed these CSV P0 events were not
implementable in this branch because main lacked Test buttons. The
worktree HEAD actually wires `handleTestAgent` and `handleTestProvider`
in SettingsDialog, so both events are now in scope.
- handleTestAgent emits settings_cli_test_result on success and
failure paths with cli_provider_id mapped via agentIdToTracking,
result drawn from result.ok / catch branch, error_code from
result.kind or the thrown error name, and duration_ms timed via
performance.now().
- handleTestProvider emits settings_byok_test_result analogously,
using apiProtocol (anthropic|openai|azure|ollama|google) directly
as provider_id — wider than the CSV's 5-value enum, documented in
tracking-doc-issues.md §2.5.
Contracts: add SettingsCliTestResultProps / SettingsByokTestResultProps
plus matching track* helpers. AnalyticsEventName union now covers all
14 P0 events this branch supports.
* feat(analytics): gate PostHog on the existing telemetry.metrics consent
The integration now reuses the same first-launch privacy banner +
Settings → Privacy toggle that gates Langfuse, so a single user
decision controls both telemetry sinks.
- /api/analytics/config now consults the persisted AppConfigPrefs:
it returns enabled=true only when POSTHOG_KEY is set AND the user
has chosen "Share usage data" (telemetry.metrics === true). The
response also echoes installationId so the web client uses the
same anonymous id Langfuse keys off of — one identity per install,
shared across both sinks.
- Web AnalyticsProvider:
- Bootstrap fetch resolves installationId and threads it through
the x-od-analytics-anonymous-id header on every /api/* fetch,
so daemon-side captures (run_created / run_finished /
project_create_result) land on the same person record.
- Exposes a setConsent(granted) method that calls posthog-js's
opt_in_capturing / opt_out_capturing, wired from App.tsx via a
useEffect watching config.telemetry?.metrics. Toggling Privacy
→ metrics now stops/resumes events immediately, no reload.
- app_launch additionally gates on telemetry.metrics so a freshly-
declined user fires nothing, and a freshly-opted-in user fires on
the next reload.
* feat(packaging): bake POSTHOG_KEY into packaged daemon spawn env
Wires PostHog product analytics through the same Langfuse-style build-
secret pipeline so official Open Design builds ship with the key while
fork builds compile without it (the integration short-circuits cleanly
when POSTHOG_KEY is absent).
tools/pack
- resolveToolPackConfig reads POSTHOG_KEY / POSTHOG_HOST from
process.env at packaging time, validates them (no whitespace in the
key, http(s) URL for host, trailing-slash strip), and stamps them on
ToolPackConfig. Fork builds without the env vars simply omit the
fields; the daemon-side gate keeps things off in that case.
- Mac, Windows, and Linux packaged-config writers each append the two
fields to open-design-config.json next to the existing
telemetryRelayUrl entry.
apps/packaged
- RawPackagedConfig / PackagedConfig surface posthogKey / posthogHost
so the Electron entry and headless entry both forward them to the
daemon sidecar.
- buildPackagedDaemonSpawnEnv emits POSTHOG_KEY / POSTHOG_HOST into
the daemon child env when present. The daemon's existing analytics
module reads these via process.env — no daemon-side changes needed.
- The headless packaged path falls back to process.env for fields the
builder hasn't injected, mirroring how OPEN_DESIGN_TELEMETRY_RELAY_URL
is read there.
CI
- release-beta.yml and release-stable.yml expose POSTHOG_KEY (secret)
and POSTHOG_HOST (var) at workflow-env scope so every packaging job
inherits them. PR / fork builds without these set simply skip the
bake step.
Tests
- tools/pack: config.test.ts covers bake-through, fork-build omission,
whitespace rejection, invalid-URL rejection, and trailing-slash
normalization.
- apps/packaged: sidecars.test.ts covers buildPackagedDaemonSpawnEnv
forwarding the keys when present and omitting them when null.
* feat(analytics): enable PostHog autocapture + perf + exceptions
Flip on the PostHog SDK's automatic diagnostic features so we capture
click paths, page transitions, web vitals, dead clicks, and browser
exceptions without scattering instrumentation through the codebase.
Privacy defense lives in one place — apps/web/src/analytics/scrub.ts —
wired in via posthog-js's `before_send` hook so every outgoing event
passes through the same audit point:
- $autocapture / $rageclick / $dead_click / $copy_autocapture:
strips $el_text and value/placeholder/aria-label attrs from any
input, textarea, password input, or contenteditable element. PostHog
autocapture does not capture input.value by default, but $el_text
on a <textarea> reflects the typed content — that's the prompt
body for us, so it has to be scrubbed every time.
- $pageview / $pageleave: drops query string and fragment from
$current_url / $referrer so any future ?q=… can't leak.
- $exception: rewrites file:// and absolute filesystem paths in
stack frames to app://apps/<repo-relative> so we don't ship the
user's home directory.
- Suppresses $opt_in entirely — duplicate of our explicit
setConsent toggle in App.tsx.
Element-level defense in depth is limited to the single most sensitive
surface: the chat composer textarea gets `ph-no-capture` so PostHog
never even generates an event for clicks inside that subtree. Every
other input relies on scrub.ts — sprinkling the class through every
form would be noisy and easy to forget on new surfaces.
The existing Privacy → "Share usage data" toggle continues to gate
every new feature: posthog-js's opt_out_capturing() halts autocapture,
$pageview, $exception, web vitals, and dead clicks alongside the
explicit capture() calls — one global switch.
11 unit tests pin the scrub rules in apps/web/tests/analytics-scrub.test.ts.
* ci(nix): bump pnpmDepsHash for posthog-js + posthog-node additions
Adding posthog-js to apps/web and posthog-node to apps/daemon changed
pnpm-lock.yaml, which Nix's fixed-output pnpmDeps derivation pins by
sha256. The CI nix flake check failed with:
specified: sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc=
got: sha256-PGFgX4lYyeH2TRAXfUq52A3EOa6bb1gO59hPsXhEk3s=
Copy the new hash into both nix/package-web.nix and
nix/package-daemon.nix per the procedure documented in nix/README.md
§"First-build hash pinning".
* feat(analytics): unify PostHog identity with Langfuse installationId
PostHog's distinct_id is the installationId stamped by /api/analytics/
config; Langfuse already reads the same id off app-config.json to
populate trace.userId. With both sinks keying off the same anonymous
identity, dashboards can correlate user actions (PostHog events) with
LLM runs (Langfuse traces) without re-identifying.
Two gaps closed:
1. applyConsent(false) — clear posthog-js's persisted ph_*_posthog
localStorage entry on opt-out via posthog.reset(). Without this, a
user who opts out, then clicks Delete my data, then re-opts in
would see PostHog stitch their new session to the deleted identity
because bootstrap.distinctID only takes effect on first init.
2. applyIdentity(newInstallationId) — Delete my data rotates the
installationId in app-config; App.tsx now watches config.installationId
and calls posthog.reset() then identify(newId) so the next event
batch is fully decoupled from the deleted one. Idempotent on
same-id re-renders so benign config refreshes don't churn PostHog
identities.
The fetch wrapper's x-od-analytics-anonymous-id header also flips to
the new id on rotation so daemon-side captures (run_created /
run_finished) land on the same person record from the very next API
call, not after a reload.
The end-to-end rotation flow is verified against a live PostHog
project; these unit tests pin the safety guards (no-client paths, null
inputs) since stubbing posthog-js's init-loaded callback chain is
brittle.
* fix(langfuse): require both metrics AND content consent for trace reports
Tightens the Langfuse gate so a user who shares anonymous metrics but
NOT conversation content stops emitting Langfuse traces entirely —
Langfuse is used for turn-quality evals which only make sense with
prompt/output bodies. PostHog (product analytics, content-free) stays
gated on `metrics` alone and is unaffected.
i18n: "Conversation content" → "Conversation and tool content" with
hints expanded to mention tool inputs/outputs so the consent surface
matches what the trace actually carries (en + zh-CN).
Bundled here per PR scope — change originated outside this PostHog
PR but lands cleanly on the same files; gating Langfuse strictly
on `content` makes the dual-sink consent model (PostHog = metrics,
Langfuse = metrics + content) symmetric across both i18n locales and
the daemon-side gate.
* feat(analytics): wire byok_provider_option + fix PR review P1s
Adds the BYOK protocol-chip click event (5-value provider_id mirroring
the apiProtocol Settings UI) and resolves four P1 review threads on
PR #1428.
byok_provider_option:
- New SettingsClickByokProviderOptionProps in contracts (provider_id =
anthropic|openai|azure|google|ollama; maps to CSV's 5 values per
tracking-doc-issues.md §2.5).
- trackSettingsClickByokProviderOption helper in apps/web/src/analytics.
- SettingsDialog hooks it on the protocol-chip onClick alongside the
existing setApiProtocol call; is_selected reflects whether the chip
was already active.
Review fixes:
1. client.ts (Siri-Ray): clear `initPromise` when the resolution is
null so a Privacy → metrics opt-in after a previous decline triggers
a fresh /api/analytics/config fetch. Without this, the disabled
response was cached forever — first-session opt-in needed a reload
to start sending PostHog events.
2. provider.tsx (Siri-Ray): replace `url.includes('/api/')` with a
strict same-origin + /api/ pathname check (shared
`isSameOriginApiCall` helper). Outbound third-party URLs containing
`/api/` (e.g. provider.example.com/api/x) no longer receive our
x-od-analytics-* headers.
3. provider.tsx (codex-connector, lefarcen): gate header injection on
`resolvedAnonId` being non-null. When Privacy → metrics is off,
/api/analytics/config returns enabled=false → resolvedAnonId stays
null → wrapper never installs → daemon can't read consent-bearing
headers → no daemon-side PostHog event. setConsent now also clears
resolvedAnonId on opt-out and re-fetches on opt-in.
4. daemon/analytics.ts (defense in depth): createAnalyticsService now
takes dataDir and capture() re-reads app-config to check
telemetry.metrics inside the fire-and-forget wrapper. Even if a
stale header somehow reaches the daemon after opt-out, the capture
is dropped before posthog-node.capture is called.
* fix(web): place "Share usage data" on the right in privacy consent banner
Swap button order in PrivacyConsentModal and the in-settings ConsentCard
so the affirmative "Share usage data" lands on the right and "Not now"
on the left. Matches the OK-on-the-right pattern users expect for
primary actions.
Both buttons keep equal visual prominence (same .privacy-consent-action
styling) so the swap doesn't change the EDPB equal-prominence stance
called out in the original Langfuse telemetry spec.
* feat(analytics): populate run_finished token totals from claude-stream usage
Daemon's claude-stream parser already emits agent usage events with
input_tokens / output_tokens totals; the run service buffers them in
run.events and Langfuse reads them out the same way. The run_finished
PostHog event was leaving these fields empty.
Scan run.events for the most recent agent usage frame on terminal
transition and emit input_tokens / output_tokens / total_tokens when
present. token_count_source flips to 'provider_usage' only when at
least one count landed; runs without provider-side usage data keep
'unknown'.
Provider does not break the input down into the 7 sub-fields the
tracking doc lists (memory / context / attachment / system_prompt /
…); those stay omitted until a parser change exposes them.
* feat(analytics): estimate user_query_tokens from prompt length
The user_query_tokens field for run_created / run_finished was hardcoded
to 0. We can't tokenize without bundling a model-specific tokenizer, but
the character/4 heuristic is the industry-standard estimate when one
isn't available and is enough for funnel analysis (prompt-length cohorts,
short-vs-long-query conversion rates).
Extracted from req.body via the same telemetryPromptFromRunRequest
pattern the daemon already uses for langfuse-bridge (currentPrompt then
message fallback). Only the integer count goes to PostHog — the prompt
text itself never leaves the daemon.
token_count_source flips appropriately:
- run_created with a prompt: 'estimated' (was 'unknown')
- run_created with no prompt: 'unknown'
- run_finished with provider usage: 'provider_usage' (overrides
baseProps' 'estimated' value)
- run_finished without provider usage: inherits 'estimated' or 'unknown'
from baseProps so input/output absent doesn't mask the estimate.
2596 lines
87 KiB
TypeScript
2596 lines
87 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
createTabToTracking,
|
|
projectKindToTracking,
|
|
} from '@open-design/contracts/analytics';
|
|
import { useAnalytics } from '../analytics/provider';
|
|
import { trackHomeClickCreateButton } from '../analytics/events';
|
|
import type { ConnectorDetail, ImportFolderResponse } from '@open-design/contracts';
|
|
|
|
// Window.electronAPI is declared globally in apps/web/src/types/electron.d.ts
|
|
// so the new openPath + pickAndImport methods (#451 / PR #974) and
|
|
// existing openExternal stay in one place. PR #974 deleted the raw
|
|
// `pickFolder` bridge: the renderer no longer receives a filesystem
|
|
// path from the main process, only the daemon's import response.
|
|
|
|
import { useT } from '../i18n';
|
|
import type { Dict } from '../i18n/types';
|
|
import { fetchPromptTemplate } from '../providers/registry';
|
|
import { isStoredMediaProviderEntryPresent } from '../state/config';
|
|
import type {
|
|
AudioKind,
|
|
DesignSystemSummary,
|
|
MediaAspect,
|
|
ProjectKind,
|
|
ProjectMetadata,
|
|
ProjectPlatform,
|
|
ProjectTemplate,
|
|
MediaProviderCredentials,
|
|
PromptTemplateSummary,
|
|
SkillSummary,
|
|
} from '../types';
|
|
import {
|
|
AUDIO_DURATIONS_SEC,
|
|
AUDIO_MODELS_BY_KIND,
|
|
DEFAULT_AUDIO_MODEL,
|
|
DEFAULT_IMAGE_MODEL,
|
|
DEFAULT_VIDEO_MODEL,
|
|
findProvider,
|
|
IMAGE_MODELS,
|
|
MEDIA_ASPECTS,
|
|
type MediaModel,
|
|
VIDEO_LENGTHS_SEC,
|
|
VIDEO_MODELS,
|
|
} from '../media/models';
|
|
import { Icon } from './Icon';
|
|
import { Skeleton } from './Loading';
|
|
import { Toast } from './Toast';
|
|
|
|
/**
|
|
* Best-effort flattening of the `details` field that the
|
|
* pickAndImport main-process handler attaches when the daemon returned
|
|
* a structured error envelope (PR #974 round-4 mrcfps). Daemon errors
|
|
* carry `error.message` and sometimes nested `error.details.reason`;
|
|
* we surface the most operator-actionable string we can find without
|
|
* over-coupling to any particular error code.
|
|
*/
|
|
function formatPickAndImportErrorDetails(details: unknown): string | undefined {
|
|
if (typeof details === 'string' && details.length > 0) return details;
|
|
if (details == null || typeof details !== 'object') return undefined;
|
|
const record = details as Record<string, unknown>;
|
|
const error = record.error;
|
|
if (error != null && typeof error === 'object') {
|
|
const errRecord = error as Record<string, unknown>;
|
|
const message = errRecord.message;
|
|
const nestedDetails = errRecord.details;
|
|
if (typeof message === 'string' && message.length > 0) {
|
|
if (nestedDetails != null && typeof nestedDetails === 'object') {
|
|
const nestedReason = (nestedDetails as Record<string, unknown>).reason;
|
|
if (typeof nestedReason === 'string' && nestedReason.length > 0) {
|
|
return `${message} (${nestedReason})`;
|
|
}
|
|
}
|
|
return message;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Snapshot of a curated prompt template, captured at New Project time and
|
|
// folded into ProjectMetadata.promptTemplate. The user may have edited the
|
|
// prompt body before clicking Create — that edited copy lives here.
|
|
type PromptTemplatePick = {
|
|
summary: PromptTemplateSummary;
|
|
prompt: string;
|
|
};
|
|
|
|
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
|
|
|
type NewProjectPlatform = Exclude<ProjectPlatform, 'auto'>;
|
|
|
|
const DESIGN_PLATFORMS: Array<{
|
|
value: NewProjectPlatform;
|
|
label: string;
|
|
hint: string;
|
|
}> = [
|
|
{
|
|
value: 'responsive',
|
|
label: 'Responsive web',
|
|
hint: 'One web experience adapted for desktop, tablet, and mobile browsers',
|
|
},
|
|
{
|
|
value: 'web-desktop',
|
|
label: 'Desktop web',
|
|
hint: 'Browser-first product or landing page',
|
|
},
|
|
{
|
|
value: 'mobile-ios',
|
|
label: 'iOS app',
|
|
hint: 'iPhone frames and iOS interaction rules',
|
|
},
|
|
{
|
|
value: 'mobile-android',
|
|
label: 'Android app',
|
|
hint: 'Pixel frames and Material interaction rules',
|
|
},
|
|
{
|
|
value: 'tablet',
|
|
label: 'Tablet app',
|
|
hint: 'Native-style tablet experience with split views',
|
|
},
|
|
{
|
|
value: 'desktop-app',
|
|
label: 'Desktop app',
|
|
hint: 'macOS/Windows app chrome',
|
|
},
|
|
];
|
|
|
|
export type CreateTab = 'prototype' | 'live-artifact' | 'deck' | 'template' | 'media' | 'other';
|
|
export type MediaSurface = 'image' | 'video' | 'audio';
|
|
|
|
export interface CreateInput {
|
|
name: string;
|
|
skillId: string | null;
|
|
designSystemId: string | null;
|
|
metadata: ProjectMetadata;
|
|
}
|
|
|
|
interface Props {
|
|
skills: SkillSummary[];
|
|
designSystems: DesignSystemSummary[];
|
|
defaultDesignSystemId: string | null;
|
|
templates: ProjectTemplate[];
|
|
promptTemplates: PromptTemplateSummary[];
|
|
onCreate: (input: CreateInput & { requestId?: string }) => void;
|
|
onImportClaudeDesign?: (file: File) => Promise<void> | void;
|
|
// Web fallback: the user types an absolute baseDir into the manual
|
|
// input and the renderer POSTs `/api/import/folder` itself. Browser
|
|
// builds have no `shell.openPath` surface, so the renderer naming a
|
|
// path here cannot escalate (PR #974 trust model).
|
|
onImportFolder?: (baseDir: string) => Promise<void> | void;
|
|
// Electron flow: the desktop main process owns the picker dialog and
|
|
// the import call atomically (`pickAndImport` IPC). The renderer
|
|
// never sees the path or the HMAC token; it only receives the
|
|
// daemon's import response and forwards it here so App-level state
|
|
// can update without a second fetch.
|
|
onImportFolderResponse?: (response: ImportFolderResponse) => Promise<void> | void;
|
|
mediaProviders?: Record<string, MediaProviderCredentials>;
|
|
connectors?: ConnectorDetail[];
|
|
connectorsLoading?: boolean;
|
|
onOpenConnectorsTab?: () => void;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const TAB_LABEL_KEYS: Record<CreateTab, keyof Dict> = {
|
|
prototype: 'newproj.tabPrototype',
|
|
'live-artifact': 'newproj.tabLiveArtifact',
|
|
deck: 'newproj.tabDeck',
|
|
template: 'newproj.tabTemplate',
|
|
media: 'newproj.tabMedia',
|
|
other: 'newproj.tabOther',
|
|
};
|
|
|
|
const MEDIA_SURFACE_LABEL_KEYS: Record<MediaSurface, keyof Dict> = {
|
|
image: 'newproj.surfaceImage',
|
|
video: 'newproj.surfaceVideo',
|
|
audio: 'newproj.surfaceAudio',
|
|
};
|
|
|
|
export function defaultDesignSystemSelection(
|
|
defaultDesignSystemId: string | null,
|
|
designSystems: DesignSystemSummary[],
|
|
): string[] {
|
|
if (!defaultDesignSystemId) return [];
|
|
return designSystems.some((d) => d.id === defaultDesignSystemId)
|
|
? [defaultDesignSystemId]
|
|
: [];
|
|
}
|
|
|
|
export function buildDesignSystemCreateSelection(
|
|
showDesignSystemPicker: boolean,
|
|
selectedIds: string[],
|
|
): { primary: string | null; inspirations: string[] } {
|
|
return showDesignSystemPicker
|
|
? {
|
|
primary: selectedIds[0] ?? null,
|
|
inspirations: selectedIds.slice(1),
|
|
}
|
|
: { primary: null, inspirations: [] };
|
|
}
|
|
|
|
export function NewProjectPanel({
|
|
skills,
|
|
designSystems,
|
|
defaultDesignSystemId,
|
|
templates,
|
|
promptTemplates,
|
|
onCreate,
|
|
onImportClaudeDesign,
|
|
onImportFolder,
|
|
onImportFolderResponse,
|
|
mediaProviders,
|
|
connectors,
|
|
connectorsLoading = false,
|
|
onOpenConnectorsTab,
|
|
loading = false,
|
|
}: Props) {
|
|
const t = useT();
|
|
const analytics = useAnalytics();
|
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [importing, setImporting] = useState(false);
|
|
const [baseDir, setBaseDir] = useState('');
|
|
const [importingFolder, setImportingFolder] = useState(false);
|
|
// PR #974 round-4 (mrcfps): pickAndImport now returns structured
|
|
// failure shapes (`desktop auth secret not registered`, `web sidecar
|
|
// URL not available`, `daemon returned HTTP X`) — surfacing them
|
|
// gives the user a recovery hint instead of a silent no-op.
|
|
// Shape: `{ message, details? }`. `null` means no toast.
|
|
const [importFolderError, setImportFolderError] = useState<
|
|
{ message: string; details?: string } | null
|
|
>(null);
|
|
const [tab, setTab] = useState<CreateTab>('prototype');
|
|
// Media tab consolidates image / video / audio. The active surface picks
|
|
// which set of options + skill resolution applies; submission still maps
|
|
// back to the existing image/video/audio ProjectKind branches so the
|
|
// backend contract is unchanged.
|
|
const [mediaSurface, setMediaSurface] = useState<MediaSurface>('image');
|
|
const tabsRef = useRef<HTMLDivElement | null>(null);
|
|
const [tabScroll, setTabScroll] = useState({ left: false, right: false });
|
|
const [name, setName] = useState('');
|
|
// Design-system selection is now an *array* internally so the same
|
|
// component can drive both single-select and multi-select modes without
|
|
// duplicating state. Single-select coerces to length 0/1.
|
|
const initialDefaultDsSelection = useMemo(
|
|
() => defaultDesignSystemSelection(defaultDesignSystemId, designSystems),
|
|
[defaultDesignSystemId, designSystems],
|
|
);
|
|
const [selectedDsIds, setSelectedDsIds] = useState<string[]>(
|
|
() => initialDefaultDsSelection,
|
|
);
|
|
const [dsSelectionTouched, setDsSelectionTouched] = useState(false);
|
|
const [dsMulti, setDsMulti] = useState(false);
|
|
|
|
// Per-tab metadata. Tracked independently so switching tabs preserves
|
|
// each tab's pick rather than resetting to defaults.
|
|
const [fidelity, setFidelity] = useState<'wireframe' | 'high-fidelity'>(
|
|
'high-fidelity',
|
|
);
|
|
const [platformTargets, setPlatformTargets] = useState<NewProjectPlatform[]>(['responsive']);
|
|
const [includeLandingPage, setIncludeLandingPage] = useState(false);
|
|
const [includeOsWidgets, setIncludeOsWidgets] = useState(false);
|
|
const [speakerNotes, setSpeakerNotes] = useState(false);
|
|
const [animations, setAnimations] = useState(false);
|
|
const [templateId, setTemplateId] = useState<string | null>(null);
|
|
const [imageModel, setImageModel] = useState(DEFAULT_IMAGE_MODEL);
|
|
const [imageAspect, setImageAspect] = useState<MediaAspect>('1:1');
|
|
const [videoModel, setVideoModel] = useState(DEFAULT_VIDEO_MODEL);
|
|
const [videoModelTouched, setVideoModelTouched] = useState(false);
|
|
const [videoAspect, setVideoAspect] = useState<MediaAspect>('16:9');
|
|
const [videoLength, setVideoLength] = useState(5);
|
|
const [audioKind, setAudioKind] = useState<AudioKind>('speech');
|
|
const [audioModel, setAudioModel] = useState(DEFAULT_AUDIO_MODEL.speech);
|
|
const [audioDuration, setAudioDuration] = useState(10);
|
|
const [voice, setVoice] = useState('');
|
|
// Per-surface curated prompt template the user picked. Tracked
|
|
// independently for image vs video so flipping tabs doesn't clobber the
|
|
// other one's pick. The body is editable in-line and the edited copy is
|
|
// what gets carried to the agent — that's the "optimize the template"
|
|
// affordance the design brief asks for.
|
|
const [imagePromptTemplate, setImagePromptTemplate] =
|
|
useState<PromptTemplatePick | null>(null);
|
|
const [videoPromptTemplate, setVideoPromptTemplate] =
|
|
useState<PromptTemplatePick | null>(null);
|
|
|
|
// Design system is meaningful only for the structured/visual surfaces
|
|
// (prototype, deck, template, and the freeform "other" canvas). The
|
|
// media surfaces use prompt templates instead — design tokens don't map
|
|
// onto image/video/audio generations, and the picker just adds noise
|
|
// there. Keep this list explicit so future tabs declare their intent.
|
|
const tabSupportsDesignSystem =
|
|
tab === 'prototype' ||
|
|
tab === 'deck' ||
|
|
tab === 'template' ||
|
|
tab === 'other';
|
|
// Orbit briefings ship their own complete visual language baked into
|
|
// example.html and explicitly opt out of DESIGN.md injection via
|
|
// `od.design_system.requires: false`. Hide the picker only for those
|
|
// Orbit scenario skills; the general prototype creation surface should
|
|
// still honor the user's configured default design system even when a
|
|
// non-Orbit default skill does not require one.
|
|
const tabDefaultSkillForcesNoDs = useMemo(() => {
|
|
const tabSkillId = ((): string | null => {
|
|
if (tab === 'prototype' || tab === 'live-artifact') {
|
|
const list = skills.filter((s) => s.mode === 'prototype');
|
|
return list.find((s) => s.defaultFor.includes('prototype'))?.id
|
|
?? list[0]?.id ?? null;
|
|
}
|
|
if (tab === 'deck') {
|
|
const list = skills.filter((s) => s.mode === 'deck');
|
|
return list.find((s) => s.defaultFor.includes('deck'))?.id
|
|
?? list[0]?.id ?? null;
|
|
}
|
|
return null;
|
|
})();
|
|
if (!tabSkillId) return false;
|
|
const s = skills.find((x) => x.id === tabSkillId);
|
|
return s
|
|
? s.scenario === 'orbit' && s.designSystemRequired === false
|
|
: false;
|
|
}, [tab, skills]);
|
|
const showDesignSystemPicker =
|
|
tabSupportsDesignSystem && !tabDefaultSkillForcesNoDs;
|
|
|
|
useEffect(() => {
|
|
if (dsSelectionTouched) return;
|
|
setSelectedDsIds(initialDefaultDsSelection);
|
|
}, [dsSelectionTouched, initialDefaultDsSelection]);
|
|
|
|
// When entering the template tab, snap to the first user-saved template
|
|
// if there is one (and we don't already have a valid pick). The template
|
|
// tab no longer offers a built-in fallback — the entire point is to
|
|
// start from a template *the user* created via Share.
|
|
useEffect(() => {
|
|
if (tab !== 'template') return;
|
|
if (templates.length === 0) {
|
|
setTemplateId(null);
|
|
return;
|
|
}
|
|
if (templateId == null || !templates.some((t) => t.id === templateId)) {
|
|
setTemplateId(templates[0]!.id);
|
|
}
|
|
}, [tab, templates, templateId]);
|
|
|
|
// The skill the request still routes through — kept so prototype/deck
|
|
// pick a default-rendered skill (so the agent gets the right SKILL.md
|
|
// body) without requiring the user to choose one explicitly.
|
|
const skillIdForTab = useMemo(() => {
|
|
if (tab === 'other') return null;
|
|
if (tab === 'prototype') {
|
|
const list = skills.filter((s) => s.mode === 'prototype');
|
|
return list.find((s) => s.defaultFor.includes('prototype'))?.id
|
|
?? list[0]?.id
|
|
?? null;
|
|
}
|
|
if (tab === 'live-artifact') {
|
|
const exact = skills.find((s) => s.id === 'live-artifact' || s.name === 'live-artifact');
|
|
if (exact) return exact.id;
|
|
const hinted = skills.find((s) => {
|
|
const haystack = `${s.id} ${s.name} ${s.description} ${s.triggers.join(' ')}`.toLowerCase();
|
|
return haystack.includes('live artifact') || haystack.includes('live-artifact');
|
|
});
|
|
if (hinted) return hinted.id;
|
|
const prototypes = skills.filter((s) => s.mode === 'prototype');
|
|
return prototypes.find((s) => s.defaultFor.includes('prototype'))?.id
|
|
?? prototypes[0]?.id
|
|
?? null;
|
|
}
|
|
if (tab === 'deck') {
|
|
const list = skills.filter((s) => s.mode === 'deck');
|
|
return list.find((s) => s.defaultFor.includes('deck'))?.id
|
|
?? list[0]?.id
|
|
?? null;
|
|
}
|
|
if (tab === 'media') {
|
|
const list = skills.filter(
|
|
(s) => s.mode === mediaSurface || s.surface === mediaSurface,
|
|
);
|
|
// The HyperFrames-HTML render path lives in the `hyperframes` skill.
|
|
// When the user has chosen `hyperframes-html` (via dropdown or template),
|
|
// pin the project to that skill explicitly.
|
|
if (mediaSurface === 'video' && videoModel === 'hyperframes-html') {
|
|
const hyper = list.find((s) => s.id === 'hyperframes');
|
|
if (hyper) return hyper.id;
|
|
}
|
|
return list.find((s) => s.defaultFor.includes(mediaSurface))?.id
|
|
?? list[0]?.id
|
|
?? null;
|
|
}
|
|
return null;
|
|
}, [tab, mediaSurface, skills, videoModel]);
|
|
|
|
// When the user picks a curated prompt template, propagate the template's
|
|
// declared `model` and `aspect` onto the actual project state. Without
|
|
// this the user picks (e.g.) a HyperFrames template but `videoModel`
|
|
// stays on the default seedance — the agent then dispatches the wrong
|
|
// model and the render path mismatches the prompt.
|
|
function handleImagePromptTemplate(pick: PromptTemplatePick | null) {
|
|
setImagePromptTemplate(pick);
|
|
const m = pick?.summary.model;
|
|
if (m && IMAGE_MODELS.some((x) => x.id === m)) setImageModel(m);
|
|
const a = pick?.summary.aspect;
|
|
if (a && (MEDIA_ASPECTS as readonly string[]).includes(a)) {
|
|
setImageAspect(a as MediaAspect);
|
|
}
|
|
}
|
|
function handleVideoPromptTemplate(pick: PromptTemplatePick | null) {
|
|
setVideoPromptTemplate(pick);
|
|
const m = pick?.summary.model;
|
|
if (m && VIDEO_MODELS.some((x) => x.id === m)) {
|
|
setVideoModel(m);
|
|
setVideoModelTouched(true);
|
|
}
|
|
const a = pick?.summary.aspect;
|
|
if (a && (MEDIA_ASPECTS as readonly string[]).includes(a)) {
|
|
setVideoAspect(a as MediaAspect);
|
|
}
|
|
}
|
|
function handleVideoModel(id: string) {
|
|
setVideoModel(id);
|
|
setVideoModelTouched(true);
|
|
}
|
|
|
|
// The HyperFrames skill renders HTML compositions through a local
|
|
// `npx hyperframes render` path, which dispatches under the
|
|
// `hyperframes-html` model — not seedance/veo/sora. When the resolved
|
|
// skill for the video tab is hyperframes, default `videoModel` so the
|
|
// model dropdown matches the actual render path. Once the user has
|
|
// explicitly chosen a model (via the dropdown or by picking a template
|
|
// that declares a model), `videoModelTouched` latches and this effect
|
|
// becomes a no-op for the rest of the panel session — re-entering the
|
|
// Media tab's Video surface no longer silently rewrites their override back to
|
|
// hyperframes-html.
|
|
useEffect(() => {
|
|
if (tab !== 'media' || mediaSurface !== 'video') return;
|
|
if (skillIdForTab !== 'hyperframes') return;
|
|
if (videoModelTouched) return;
|
|
if (videoPromptTemplate) return;
|
|
if (!VIDEO_MODELS.some((m) => m.id === 'hyperframes-html')) return;
|
|
setVideoModel('hyperframes-html');
|
|
// Intentionally leaving videoPromptTemplate / videoModel out of deps
|
|
// so this only fires when the user toggles the tab or the skill
|
|
// resolution shifts — not whenever the user changes the dropdown.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tab, mediaSurface, skillIdForTab, videoModelTouched]);
|
|
|
|
const canCreate =
|
|
!loading && (tab !== 'template' || templateId != null);
|
|
|
|
function updateTabScrollState() {
|
|
const el = tabsRef.current;
|
|
if (!el) return;
|
|
const maxLeft = el.scrollWidth - el.clientWidth;
|
|
setTabScroll({
|
|
left: el.scrollLeft > 2,
|
|
right: el.scrollLeft < maxLeft - 2,
|
|
});
|
|
}
|
|
|
|
function scrollTabs(direction: -1 | 1) {
|
|
const el = tabsRef.current;
|
|
if (!el) return;
|
|
el.scrollBy({
|
|
left: direction * Math.max(120, el.clientWidth * 0.65),
|
|
behavior: 'smooth',
|
|
});
|
|
}
|
|
|
|
function handleDesignSystemChange(ids: string[]) {
|
|
setDsSelectionTouched(true);
|
|
setSelectedDsIds(ids);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const el = tabsRef.current;
|
|
if (!el) return;
|
|
updateTabScrollState();
|
|
const onScroll = () => updateTabScrollState();
|
|
el.addEventListener('scroll', onScroll, { passive: true });
|
|
const ro = new ResizeObserver(updateTabScrollState);
|
|
ro.observe(el);
|
|
return () => {
|
|
el.removeEventListener('scroll', onScroll);
|
|
ro.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const el = tabsRef.current;
|
|
const active = el?.querySelector<HTMLButtonElement>('.newproj-tab.active');
|
|
active?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
|
|
window.setTimeout(updateTabScrollState, 180);
|
|
}, [tab]);
|
|
|
|
function handleCreate() {
|
|
if (!canCreate) return;
|
|
// Media surfaces don't carry a design system pick. Force the primary
|
|
// and inspiration ids to empty there so the New Project panel can't
|
|
// accidentally bind a stale DS that the user can no longer see in the
|
|
// form (the picker is hidden for image/video/audio).
|
|
const { primary: primaryDs, inspirations } =
|
|
buildDesignSystemCreateSelection(showDesignSystemPicker, selectedDsIds);
|
|
const promptTemplatePick =
|
|
tab === 'media'
|
|
? mediaSurface === 'image'
|
|
? imagePromptTemplate
|
|
: mediaSurface === 'video'
|
|
? videoPromptTemplate
|
|
: null
|
|
: null;
|
|
const metadata = buildMetadata({
|
|
tab,
|
|
mediaSurface,
|
|
fidelity,
|
|
platformTargets,
|
|
includeLandingPage,
|
|
includeOsWidgets,
|
|
speakerNotes,
|
|
animations,
|
|
templateId,
|
|
templates,
|
|
imageModel,
|
|
imageAspect,
|
|
videoModel,
|
|
videoAspect,
|
|
videoLength,
|
|
audioKind,
|
|
audioModel,
|
|
audioDuration,
|
|
voice,
|
|
inspirationIds: inspirations,
|
|
promptTemplate: promptTemplatePick,
|
|
});
|
|
// Generate the click→result correlation id here so the home_click and
|
|
// the eventual project_create_result share request_id.
|
|
const requestId = analytics.newRequestId();
|
|
const trackedKind = projectKindToTracking(metadata?.kind ?? null) ?? 'prototype';
|
|
trackHomeClickCreateButton(
|
|
analytics.track,
|
|
{
|
|
page: 'home',
|
|
area: 'create_panel',
|
|
element: 'create_button',
|
|
action: 'create_project',
|
|
source_tab: createTabToTracking(tab),
|
|
project_kind: trackedKind,
|
|
has_project_name: name.trim().length > 0,
|
|
},
|
|
{ requestId },
|
|
);
|
|
onCreate({
|
|
name: name.trim() || autoName(tab, mediaSurface, t),
|
|
skillId: skillIdForTab,
|
|
designSystemId: primaryDs,
|
|
metadata,
|
|
requestId,
|
|
});
|
|
}
|
|
|
|
async function handleImportPicked(ev: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = ev.target.files?.[0];
|
|
ev.target.value = '';
|
|
if (!file || !onImportClaudeDesign) return;
|
|
setImporting(true);
|
|
try {
|
|
await onImportClaudeDesign(file);
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
}
|
|
|
|
// PR #974: the bridge no longer exposes `pickFolder` (raw path
|
|
// crossing to the renderer). The Electron flow now uses
|
|
// `pickAndImport`, which performs the picker + the HMAC-gated import
|
|
// atomically in the main process and returns the daemon response.
|
|
// The web fallback continues to use the manual baseDir input —
|
|
// browser builds have no `shell.openPath` surface so a renderer-named
|
|
// path cannot escalate.
|
|
const hasElectronPickAndImport =
|
|
typeof window !== 'undefined' && typeof window.electronAPI?.pickAndImport === 'function';
|
|
|
|
async function handleOpenFolder() {
|
|
if (hasElectronPickAndImport) {
|
|
if (!onImportFolderResponse) return;
|
|
setImportFolderError(null);
|
|
setImportingFolder(true);
|
|
try {
|
|
const result = await window.electronAPI!.pickAndImport!();
|
|
if (!result) return;
|
|
if (result.ok === true) {
|
|
await onImportFolderResponse(result.response);
|
|
return;
|
|
}
|
|
// Round-4 (mrcfps #2): every non-OK shape used to fall through
|
|
// a silent `return`. Reserve silent for the explicit cancel
|
|
// case; surface the structured reason for everything else
|
|
// (auth-not-registered, web-sidecar-down, daemon HTTP errors,
|
|
// network errors). The pickAndImport handler already pre-shapes
|
|
// these into a `{ ok: false, reason, details? }` envelope.
|
|
if ('canceled' in result && result.canceled === true) return;
|
|
const reason = 'reason' in result && typeof result.reason === 'string'
|
|
? result.reason
|
|
: 'unknown failure';
|
|
const details = 'details' in result && result.details != null
|
|
? formatPickAndImportErrorDetails(result.details)
|
|
: undefined;
|
|
setImportFolderError({
|
|
message: `Open folder failed: ${reason}`,
|
|
...(details ? { details } : {}),
|
|
});
|
|
} finally {
|
|
setImportingFolder(false);
|
|
}
|
|
return;
|
|
}
|
|
if (!onImportFolder) return;
|
|
const trimmed = baseDir.trim();
|
|
if (!trimmed) return;
|
|
setImportingFolder(true);
|
|
try {
|
|
await onImportFolder(trimmed);
|
|
} finally {
|
|
setImportingFolder(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="newproj" data-testid="new-project-panel">
|
|
<div className={`newproj-tabs-shell${tabScroll.left ? ' can-left' : ''}${tabScroll.right ? ' can-right' : ''}`}>
|
|
<button
|
|
type="button"
|
|
className={`newproj-tabs-arrow left${tabScroll.left ? '' : ' hidden'}`}
|
|
onClick={() => scrollTabs(-1)}
|
|
aria-label="Scroll project types left"
|
|
tabIndex={tabScroll.left ? 0 : -1}
|
|
>
|
|
<Icon name="chevron-left" size={16} strokeWidth={2} />
|
|
</button>
|
|
<div className="newproj-tabs" role="tablist" ref={tabsRef}>
|
|
{(Object.keys(TAB_LABEL_KEYS) as CreateTab[]).map((entry) => (
|
|
<button
|
|
key={entry}
|
|
role="tab"
|
|
data-testid={`new-project-tab-${entry}`}
|
|
aria-selected={tab === entry}
|
|
className={`newproj-tab ${tab === entry ? 'active' : ''}`}
|
|
onClick={() => setTab(entry)}
|
|
>
|
|
{t(TAB_LABEL_KEYS[entry])}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`newproj-tabs-arrow right${tabScroll.right ? '' : ' hidden'}`}
|
|
onClick={() => scrollTabs(1)}
|
|
aria-label="Scroll project types right"
|
|
tabIndex={tabScroll.right ? 0 : -1}
|
|
>
|
|
<Icon name="chevron-right" size={16} strokeWidth={2} />
|
|
</button>
|
|
</div>
|
|
<div className="newproj-body">
|
|
<h3 className="newproj-title">
|
|
<span className="newproj-title-text">{titleForTab(tab, mediaSurface, t)}</span>
|
|
{tab === 'live-artifact' ? (
|
|
// "Beta" is an internationally adopted brand-style status marker;
|
|
// intentionally not run through t() (consistent with short product
|
|
// status pills that read the same across our supported locales).
|
|
<span className="newproj-title-badge" aria-label="Beta feature">Beta</span>
|
|
) : null}
|
|
</h3>
|
|
|
|
<input
|
|
className="newproj-name"
|
|
data-testid="new-project-name"
|
|
placeholder={t('newproj.namePlaceholder')}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
|
|
{showDesignSystemPicker ? (
|
|
<DesignSystemPicker
|
|
designSystems={designSystems}
|
|
defaultDesignSystemId={defaultDesignSystemId}
|
|
selectedIds={selectedDsIds}
|
|
multi={dsMulti}
|
|
onChangeMulti={setDsMulti}
|
|
onChange={handleDesignSystemChange}
|
|
loading={loading}
|
|
/>
|
|
) : null}
|
|
|
|
{tab === 'media' ? (
|
|
<div
|
|
className="newproj-media-segmented"
|
|
role="tablist"
|
|
aria-label={t('newproj.tabMedia')}
|
|
>
|
|
{(Object.keys(MEDIA_SURFACE_LABEL_KEYS) as MediaSurface[]).map((surface) => (
|
|
<button
|
|
key={surface}
|
|
type="button"
|
|
role="tab"
|
|
data-testid={`new-project-media-surface-${surface}`}
|
|
aria-selected={mediaSurface === surface}
|
|
className={`newproj-media-surface ${mediaSurface === surface ? 'active' : ''}`}
|
|
onClick={() => setMediaSurface(surface)}
|
|
>
|
|
{t(MEDIA_SURFACE_LABEL_KEYS[surface])}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{tab === 'media' && mediaSurface === 'image' ? (
|
|
<PromptTemplatePicker
|
|
surface="image"
|
|
templates={promptTemplates}
|
|
value={imagePromptTemplate}
|
|
onChange={handleImagePromptTemplate}
|
|
/>
|
|
) : null}
|
|
|
|
{tab === 'media' && mediaSurface === 'video' ? (
|
|
<PromptTemplatePicker
|
|
surface="video"
|
|
templates={promptTemplates}
|
|
value={videoPromptTemplate}
|
|
onChange={handleVideoPromptTemplate}
|
|
/>
|
|
) : null}
|
|
|
|
{tab === 'prototype' || tab === 'live-artifact' || tab === 'template' || tab === 'other' ? (
|
|
<PlatformPicker value={platformTargets} onChange={setPlatformTargets} />
|
|
) : null}
|
|
|
|
{tab === 'prototype' || tab === 'live-artifact' || tab === 'template' || tab === 'other' ? (
|
|
<SurfaceOptions
|
|
includeLandingPage={includeLandingPage}
|
|
includeOsWidgets={includeOsWidgets}
|
|
osWidgetsAvailable={platformTargetsSupportOsWidgets(platformTargets)}
|
|
onIncludeLandingPage={setIncludeLandingPage}
|
|
onIncludeOsWidgets={setIncludeOsWidgets}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Live artifact always renders at high fidelity — its whole point
|
|
is data-bound polished UI, so the wireframe option is hidden. */}
|
|
{tab === 'prototype' ? (
|
|
<FidelityPicker value={fidelity} onChange={setFidelity} />
|
|
) : null}
|
|
|
|
{tab === 'live-artifact' ? (
|
|
<ConnectorsSection
|
|
connectors={connectors}
|
|
loading={connectorsLoading}
|
|
onOpenConnectorsTab={onOpenConnectorsTab}
|
|
/>
|
|
) : null}
|
|
|
|
{tab === 'deck' ? (
|
|
<ToggleRow
|
|
label={t('newproj.toggleSpeakerNotes')}
|
|
hint={t('newproj.toggleSpeakerNotesHint')}
|
|
checked={speakerNotes}
|
|
onChange={setSpeakerNotes}
|
|
/>
|
|
) : null}
|
|
|
|
{tab === 'template' ? (
|
|
<>
|
|
<TemplatePicker
|
|
templates={templates}
|
|
value={templateId}
|
|
onChange={setTemplateId}
|
|
/>
|
|
<ToggleRow
|
|
label={t('newproj.toggleAnimations')}
|
|
hint={t('newproj.toggleAnimationsHint')}
|
|
checked={animations}
|
|
onChange={setAnimations}
|
|
/>
|
|
</>
|
|
) : null}
|
|
|
|
{tab === 'media' && mediaSurface === 'image' ? (
|
|
<MediaProjectOptions
|
|
surface="image"
|
|
imageModel={imageModel}
|
|
imageAspect={imageAspect}
|
|
mediaProviders={mediaProviders}
|
|
onImageModel={setImageModel}
|
|
onImageAspect={setImageAspect}
|
|
/>
|
|
) : null}
|
|
|
|
{tab === 'media' && mediaSurface === 'video' ? (
|
|
<MediaProjectOptions
|
|
surface="video"
|
|
videoModel={videoModel}
|
|
videoAspect={videoAspect}
|
|
videoLength={videoLength}
|
|
mediaProviders={mediaProviders}
|
|
onVideoModel={handleVideoModel}
|
|
onVideoAspect={setVideoAspect}
|
|
onVideoLength={setVideoLength}
|
|
/>
|
|
) : null}
|
|
|
|
{tab === 'media' && mediaSurface === 'audio' ? (
|
|
<MediaProjectOptions
|
|
surface="audio"
|
|
audioKind={audioKind}
|
|
audioModel={audioModel}
|
|
audioDuration={audioDuration}
|
|
voice={voice}
|
|
mediaProviders={mediaProviders}
|
|
onAudioKind={(kind) => {
|
|
setAudioKind(kind);
|
|
setAudioModel(DEFAULT_AUDIO_MODEL[kind]);
|
|
}}
|
|
onAudioModel={setAudioModel}
|
|
onAudioDuration={setAudioDuration}
|
|
onVoice={setVoice}
|
|
/>
|
|
) : null}
|
|
|
|
<button
|
|
className="primary newproj-create"
|
|
data-testid="create-project"
|
|
onClick={handleCreate}
|
|
disabled={!canCreate}
|
|
title={
|
|
tab === 'template' && templateId == null
|
|
? t('newproj.createDisabledTitle')
|
|
: undefined
|
|
}
|
|
>
|
|
<Icon name="plus" size={13} />
|
|
<span>
|
|
{tab === 'template'
|
|
? t('newproj.createFromTemplate')
|
|
: tab === 'live-artifact'
|
|
? t('newproj.createLiveArtifact')
|
|
: t('newproj.create')}
|
|
</span>
|
|
</button>
|
|
{onImportClaudeDesign ? (
|
|
<>
|
|
<input
|
|
ref={importInputRef}
|
|
type="file"
|
|
accept=".zip,application/zip"
|
|
hidden
|
|
onChange={handleImportPicked}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="ghost newproj-import"
|
|
disabled={loading || importing}
|
|
title={t('newproj.importClaudeZipTitle')}
|
|
onClick={() => importInputRef.current?.click()}
|
|
>
|
|
<Icon name="import" size={13} />
|
|
<span>
|
|
{importing
|
|
? t('newproj.importingClaudeZip')
|
|
: t('newproj.importClaudeZip')}
|
|
</span>
|
|
</button>
|
|
</>
|
|
) : null}
|
|
{(hasElectronPickAndImport ? onImportFolderResponse : onImportFolder) ? (
|
|
<div className="newproj-open-folder">
|
|
{!hasElectronPickAndImport ? (
|
|
<input
|
|
type="text"
|
|
className="newproj-folder-input"
|
|
placeholder="/path/to/project"
|
|
value={baseDir}
|
|
onChange={(e) => setBaseDir(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') void handleOpenFolder(); }}
|
|
disabled={importingFolder}
|
|
/>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="ghost newproj-import"
|
|
disabled={(!hasElectronPickAndImport && !baseDir.trim()) || importingFolder}
|
|
onClick={() => void handleOpenFolder()}
|
|
>
|
|
<Icon name="folder" size={13} />
|
|
<span>{importingFolder ? 'Opening…' : 'Open folder'}</span>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="newproj-footer">{t('newproj.privacyFooter')}</div>
|
|
{importFolderError ? (
|
|
<Toast
|
|
message={importFolderError.message}
|
|
details={importFolderError.details ?? null}
|
|
ttlMs={6000}
|
|
onDismiss={() => setImportFolderError(null)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlatformPicker({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: NewProjectPlatform[];
|
|
onChange: (v: NewProjectPlatform[]) => void;
|
|
}) {
|
|
function togglePlatform(next: NewProjectPlatform) {
|
|
const active = value.includes(next);
|
|
const updated = active
|
|
? value.filter((item) => item !== next)
|
|
: [...value, next];
|
|
onChange(updated.length > 0 ? updated : ['responsive']);
|
|
}
|
|
|
|
return (
|
|
<div className="newproj-section">
|
|
<label className="newproj-label">Target platforms</label>
|
|
<p className="platform-picker-hint">
|
|
Pick one or more. Responsive web covers browser breakpoints only; add iOS,
|
|
Android, tablet app, or desktop app for native cross-platform variants.
|
|
</p>
|
|
<div className="platform-grid">
|
|
{DESIGN_PLATFORMS.map((option) => {
|
|
const active = value.includes(option.value);
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={`newproj-card platform-card${active ? ' active' : ''}`}
|
|
onClick={() => togglePlatform(option.value)}
|
|
title={option.hint}
|
|
aria-pressed={active}
|
|
>
|
|
<span className="platform-card-title">{option.label}</span>
|
|
<span className="platform-card-hint">{option.hint}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SurfaceOptions({
|
|
includeLandingPage,
|
|
includeOsWidgets,
|
|
osWidgetsAvailable,
|
|
onIncludeLandingPage,
|
|
onIncludeOsWidgets,
|
|
}: {
|
|
includeLandingPage: boolean;
|
|
includeOsWidgets: boolean;
|
|
osWidgetsAvailable: boolean;
|
|
onIncludeLandingPage: (v: boolean) => void;
|
|
onIncludeOsWidgets: (v: boolean) => void;
|
|
}) {
|
|
const t = useT();
|
|
return (
|
|
<div className="newproj-section surface-options">
|
|
<label className="newproj-label">{t('newproj.surfaceOptionsLabel')}</label>
|
|
<ToggleRow
|
|
label={t('newproj.includeLandingPage')}
|
|
hint={t('newproj.includeLandingPageHint')}
|
|
checked={includeLandingPage}
|
|
onChange={onIncludeLandingPage}
|
|
/>
|
|
<ToggleRow
|
|
label={t('newproj.includeOsWidgets')}
|
|
hint={
|
|
osWidgetsAvailable
|
|
? t('newproj.includeOsWidgetsHint')
|
|
: t('newproj.includeOsWidgetsDisabledHint')
|
|
}
|
|
checked={osWidgetsAvailable && includeOsWidgets}
|
|
disabled={!osWidgetsAvailable}
|
|
onChange={onIncludeOsWidgets}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FidelityPicker({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: 'wireframe' | 'high-fidelity';
|
|
onChange: (v: 'wireframe' | 'high-fidelity') => void;
|
|
}) {
|
|
const t = useT();
|
|
return (
|
|
<div className="newproj-section">
|
|
<label className="newproj-label">{t('newproj.fidelityLabel')}</label>
|
|
<div className="fidelity-grid">
|
|
<FidelityCard
|
|
active={value === 'wireframe'}
|
|
onClick={() => onChange('wireframe')}
|
|
label={t('newproj.fidelityWireframe')}
|
|
variant="wireframe"
|
|
/>
|
|
<FidelityCard
|
|
active={value === 'high-fidelity'}
|
|
onClick={() => onChange('high-fidelity')}
|
|
label={t('newproj.fidelityHigh')}
|
|
variant="high-fidelity"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
Connectors section (live-artifact only).
|
|
- Lists configured connectors as compact chips so the user can
|
|
see at a glance what data sources this artifact can pull from.
|
|
- When no connector is configured (or the list hasn't loaded yet
|
|
and ended up empty), shows a guidance card that, on click, opens
|
|
the Settings → Connectors surface (the new home of the catalog).
|
|
============================================================ */
|
|
function ConnectorsSection({
|
|
connectors,
|
|
loading,
|
|
onOpenConnectorsTab,
|
|
}: {
|
|
connectors?: ConnectorDetail[];
|
|
loading: boolean;
|
|
onOpenConnectorsTab?: () => void;
|
|
}) {
|
|
const t = useT();
|
|
const configured = useMemo(
|
|
() => (connectors ?? []).filter((c) => c.status === 'connected'),
|
|
[connectors],
|
|
);
|
|
const hasConfigured = configured.length > 0;
|
|
|
|
if (loading && !connectors) {
|
|
return (
|
|
<div className="newproj-section newproj-connectors">
|
|
<label className="newproj-label">{t('newproj.connectorsLabel')}</label>
|
|
<Skeleton height={56} width="100%" radius={8} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="newproj-section newproj-connectors"
|
|
data-testid="new-project-connectors"
|
|
>
|
|
<div className="newproj-connectors-head">
|
|
<label className="newproj-label">{t('newproj.connectorsLabel')}</label>
|
|
{hasConfigured ? (
|
|
<button
|
|
type="button"
|
|
className="newproj-connectors-manage"
|
|
onClick={() => onOpenConnectorsTab?.()}
|
|
data-testid="new-project-connectors-manage"
|
|
>
|
|
{t('newproj.connectorsManage')}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{hasConfigured ? (
|
|
<>
|
|
<span className="newproj-connectors-hint">
|
|
{configured.length === 1
|
|
? t('newproj.connectorsCountOne', { n: configured.length })
|
|
: t('newproj.connectorsCountMany', { n: configured.length })}
|
|
<span aria-hidden> · </span>
|
|
{t('newproj.connectorsHint')}
|
|
</span>
|
|
<ul className="newproj-connectors-list" aria-label={t('newproj.connectorsLabel')}>
|
|
{configured.map((c) => (
|
|
<li
|
|
key={c.id}
|
|
className="newproj-connector-chip"
|
|
title={c.name}
|
|
>
|
|
<span className="newproj-connector-dot" aria-hidden />
|
|
<span className="newproj-connector-name">{c.name}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="newproj-connectors-empty"
|
|
onClick={() => onOpenConnectorsTab?.()}
|
|
data-testid="new-project-connectors-empty"
|
|
aria-label={t('newproj.connectorsEmptyCta')}
|
|
>
|
|
<span className="newproj-connectors-empty-icon" aria-hidden>
|
|
<Icon name="link" size={14} />
|
|
</span>
|
|
<span className="newproj-connectors-empty-text">
|
|
<span className="newproj-connectors-empty-title">
|
|
{t('newproj.connectorsEmptyTitle')}
|
|
</span>
|
|
<span className="newproj-connectors-empty-body">
|
|
{t('newproj.connectorsEmptyBody')}
|
|
</span>
|
|
<span className="newproj-connectors-empty-cta">
|
|
{t('newproj.connectorsEmptyCta')}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FidelityCard({
|
|
active,
|
|
onClick,
|
|
label,
|
|
variant,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
label: string;
|
|
variant: 'wireframe' | 'high-fidelity';
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`fidelity-card${active ? ' active' : ''}`}
|
|
onClick={onClick}
|
|
aria-pressed={active}
|
|
>
|
|
<span className={`fidelity-thumb fidelity-thumb-${variant}`} aria-hidden>
|
|
{variant === 'wireframe' ? <WireframeArt /> : <HighFidelityArt />}
|
|
</span>
|
|
<span className="fidelity-label">{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function WireframeArt() {
|
|
return (
|
|
<svg viewBox="0 0 120 70" width="100%" height="100%" aria-hidden>
|
|
<rect x="6" y="8" width="46" height="6" rx="2" fill="#d8d4cb" />
|
|
<rect x="6" y="20" width="34" height="4" rx="2" fill="#ebe8e1" />
|
|
<rect x="6" y="28" width="38" height="4" rx="2" fill="#ebe8e1" />
|
|
<rect x="6" y="36" width="30" height="4" rx="2" fill="#ebe8e1" />
|
|
<circle cx="22" cy="56" r="6" fill="none" stroke="#d8d4cb" strokeWidth="1.4" />
|
|
<rect x="64" y="8" width="50" height="54" rx="3" fill="none" stroke="#d8d4cb" strokeWidth="1.4" />
|
|
<rect x="70" y="14" width="38" height="4" rx="2" fill="#ebe8e1" />
|
|
<rect x="70" y="22" width="32" height="4" rx="2" fill="#ebe8e1" />
|
|
<rect x="70" y="30" width="38" height="4" rx="2" fill="#ebe8e1" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function HighFidelityArt() {
|
|
return (
|
|
<svg viewBox="0 0 120 70" width="100%" height="100%" aria-hidden>
|
|
<rect x="6" y="8" width="34" height="6" rx="2" fill="#1a1916" />
|
|
<rect x="6" y="20" width="46" height="4" rx="2" fill="#74716b" />
|
|
<rect x="6" y="28" width="42" height="4" rx="2" fill="#b3b0a8" />
|
|
<rect x="6" y="40" width="22" height="9" rx="2" fill="#c96442" />
|
|
<rect x="64" y="8" width="50" height="54" rx="4" fill="#fbeee5" />
|
|
<rect x="70" y="14" width="38" height="4" rx="2" fill="#c96442" />
|
|
<rect x="70" y="22" width="32" height="3" rx="1.5" fill="#74716b" />
|
|
<rect x="70" y="29" width="36" height="3" rx="1.5" fill="#b3b0a8" />
|
|
<rect x="70" y="36" width="20" height="6" rx="2" fill="#c96442" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({
|
|
label,
|
|
hint,
|
|
checked,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`toggle-row${checked ? ' on' : ''}${disabled ? ' disabled' : ''}`}
|
|
onClick={() => { if (!disabled) onChange(!checked); }}
|
|
aria-pressed={checked}
|
|
disabled={disabled}
|
|
>
|
|
<div className="toggle-row-text">
|
|
<span className="toggle-row-label">{label}</span>
|
|
{hint ? <span className="toggle-row-hint">{hint}</span> : null}
|
|
</div>
|
|
<span className="toggle-row-switch" aria-hidden />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TemplatePicker({
|
|
templates,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
templates: ProjectTemplate[];
|
|
value: string | null;
|
|
onChange: (id: string | null) => void;
|
|
}) {
|
|
const t = useT();
|
|
return (
|
|
<div className="newproj-section">
|
|
<label className="newproj-label">{t('newproj.templateLabel')}</label>
|
|
{templates.length === 0 ? (
|
|
<div className="template-howto">
|
|
<span className="template-howto-title">
|
|
{t('newproj.noTemplatesTitle')}
|
|
</span>
|
|
<span className="template-howto-body">
|
|
{t('newproj.noTemplatesBody')}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="template-list">
|
|
{templates.map((tpl) => {
|
|
const fallbackDesc = `${t('newproj.savedTemplate')} · ${tpl.files.length} ${
|
|
tpl.files.length === 1
|
|
? t('newproj.fileSingular')
|
|
: t('newproj.filePlural')
|
|
}`;
|
|
return (
|
|
<TemplateOption
|
|
key={tpl.id}
|
|
active={value === tpl.id}
|
|
onClick={() => onChange(tpl.id)}
|
|
name={tpl.name}
|
|
description={tpl.description ?? fallbackDesc}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
Prompt template picker — for the image/video tabs only.
|
|
- Trigger card (mirrors the design-system trigger) opens a popover
|
|
with a search field and a thumbnail-card list filtered by surface.
|
|
- When a template is picked we lazily fetch the full prompt body via
|
|
fetchPromptTemplate(...) and drop it into a textarea so the user
|
|
can tune ("optimize") the wording before clicking Create.
|
|
- The (possibly edited) body lands in metadata.promptTemplate.prompt
|
|
and becomes part of the system prompt — the agent treats it as a
|
|
stylistic + structural reference for the generation request.
|
|
============================================================ */
|
|
function PromptTemplatePicker({
|
|
surface,
|
|
templates,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
surface: 'image' | 'video';
|
|
templates: PromptTemplateSummary[];
|
|
value: PromptTemplatePick | null;
|
|
onChange: (next: PromptTemplatePick | null) => void;
|
|
}) {
|
|
const t = useT();
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Last template we tried to pick that failed — kept so the inline
|
|
// banner can offer a one-click retry without making the user re-find
|
|
// the card in the popover (which auto-closed on success). Cleared as
|
|
// soon as a pick succeeds or the user picks a different template.
|
|
const [lastFailedPick, setLastFailedPick] =
|
|
useState<PromptTemplateSummary | null>(null);
|
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const surfaceScoped = useMemo(
|
|
() => templates.filter((tpl) => tpl.surface === surface),
|
|
[templates, surface],
|
|
);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return surfaceScoped;
|
|
return surfaceScoped.filter((tpl) => {
|
|
return (
|
|
tpl.title.toLowerCase().includes(q) ||
|
|
tpl.summary.toLowerCase().includes(q) ||
|
|
(tpl.category || '').toLowerCase().includes(q) ||
|
|
(tpl.tags ?? []).some((tag) => tag.toLowerCase().includes(q))
|
|
);
|
|
});
|
|
}, [surfaceScoped, query]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const id = window.setTimeout(() => searchRef.current?.focus(), 30);
|
|
return () => window.clearTimeout(id);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function onPointer(e: MouseEvent) {
|
|
if (wrapRef.current?.contains(e.target as Node)) return;
|
|
setOpen(false);
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
}
|
|
const id = window.setTimeout(() => {
|
|
document.addEventListener('mousedown', onPointer);
|
|
document.addEventListener('keydown', onKey);
|
|
}, 0);
|
|
return () => {
|
|
window.clearTimeout(id);
|
|
document.removeEventListener('mousedown', onPointer);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
async function pickTemplate(summary: PromptTemplateSummary) {
|
|
setLoadingId(summary.id);
|
|
setError(null);
|
|
try {
|
|
const detail = await fetchPromptTemplate(summary.surface, summary.id);
|
|
if (!detail) {
|
|
setError(t('promptTemplates.fetchError'));
|
|
setLastFailedPick(summary);
|
|
return;
|
|
}
|
|
onChange({ summary, prompt: detail.prompt });
|
|
setLastFailedPick(null);
|
|
setOpen(false);
|
|
setQuery('');
|
|
} catch {
|
|
// fetchPromptTemplate already swallows errors and returns null in
|
|
// the happy path; this catch is a defensive net for unexpected
|
|
// throws so the inline banner still surfaces and the user can
|
|
// retry instead of being stuck on a permanent loading spinner.
|
|
setError(t('promptTemplates.fetchError'));
|
|
setLastFailedPick(summary);
|
|
} finally {
|
|
setLoadingId(null);
|
|
}
|
|
}
|
|
|
|
function clear() {
|
|
onChange(null);
|
|
setLastFailedPick(null);
|
|
setError(null);
|
|
setOpen(false);
|
|
setQuery('');
|
|
}
|
|
|
|
const triggerTitle = value?.summary.title ?? t('newproj.promptTemplateNoneTitle');
|
|
const triggerSub = value
|
|
? value.summary.category || value.summary.summary || t('newproj.promptTemplateRefSub')
|
|
: t('newproj.promptTemplateNoneSub');
|
|
|
|
return (
|
|
<div className="newproj-section ds-picker prompt-template-picker" ref={wrapRef}>
|
|
<label className="newproj-label">{t('newproj.promptTemplateLabel')}</label>
|
|
<button
|
|
type="button"
|
|
data-testid="prompt-template-trigger"
|
|
className={`ds-picker-trigger${open ? ' open' : ''}${value ? '' : ' empty'}`}
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
>
|
|
<PromptTemplateAvatar summary={value?.summary ?? null} />
|
|
<span className="ds-picker-meta">
|
|
<span className="ds-picker-title">{triggerTitle}</span>
|
|
<span className="ds-picker-sub">{triggerSub}</span>
|
|
</span>
|
|
<Icon
|
|
name="chevron-down"
|
|
size={14}
|
|
className="ds-picker-chevron"
|
|
style={{ transform: open ? 'rotate(180deg)' : undefined }}
|
|
/>
|
|
</button>
|
|
{open ? (
|
|
<div className="ds-picker-popover" role="listbox">
|
|
<div className="ds-picker-head">
|
|
<input
|
|
ref={searchRef}
|
|
data-testid="prompt-template-search"
|
|
className="ds-picker-search"
|
|
placeholder={t('newproj.promptTemplateSearch')}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="ds-picker-list">
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={value === null}
|
|
className={`ds-picker-item${value === null ? ' active' : ''}`}
|
|
onClick={clear}
|
|
>
|
|
<span className="ds-picker-item-avatar">
|
|
<NoneAvatar />
|
|
</span>
|
|
<span className="ds-picker-item-text">
|
|
<span className="ds-picker-item-title">
|
|
{t('newproj.promptTemplateNoneTitle')}
|
|
</span>
|
|
<span className="ds-picker-item-sub">
|
|
{t('newproj.promptTemplateNoneSub')}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{filtered.length === 0 ? (
|
|
<div className="ds-picker-empty">
|
|
{surfaceScoped.length === 0
|
|
? t('newproj.promptTemplateEmpty')
|
|
: t('promptTemplates.emptyNoMatch')}
|
|
</div>
|
|
) : (
|
|
filtered.map((tpl) => {
|
|
const active = value?.summary.id === tpl.id;
|
|
return (
|
|
<button
|
|
key={tpl.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
className={`ds-picker-item${active ? ' active' : ''}`}
|
|
onClick={() => void pickTemplate(tpl)}
|
|
disabled={loadingId === tpl.id}
|
|
>
|
|
<span className="ds-picker-item-avatar">
|
|
<PromptTemplateAvatar summary={tpl} />
|
|
</span>
|
|
<span className="ds-picker-item-text">
|
|
<span className="ds-picker-item-title">
|
|
{tpl.title}
|
|
{loadingId === tpl.id ? (
|
|
<span className="ds-picker-item-badge">
|
|
{t('common.loading')}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="ds-picker-item-sub">
|
|
{tpl.summary || tpl.category}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{error ? (
|
|
<div
|
|
className="prompt-template-error"
|
|
role="alert"
|
|
data-testid="prompt-template-error"
|
|
>
|
|
<span className="prompt-template-error-msg">{error}</span>
|
|
{lastFailedPick ? (
|
|
<button
|
|
type="button"
|
|
className="ghost prompt-template-error-retry"
|
|
data-testid="prompt-template-retry"
|
|
onClick={() => void pickTemplate(lastFailedPick)}
|
|
disabled={loadingId === lastFailedPick.id}
|
|
>
|
|
{loadingId === lastFailedPick.id
|
|
? t('common.loading')
|
|
: t('promptTemplates.retry')}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{value ? (
|
|
<div className="prompt-template-edit">
|
|
<div className="prompt-template-edit-head">
|
|
<span className="prompt-template-edit-label">
|
|
{t('newproj.promptTemplateBodyLabel')}
|
|
</span>
|
|
<span className="prompt-template-edit-hint">
|
|
{t('newproj.promptTemplateOptimizeHint')}
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
data-testid="prompt-template-body"
|
|
className="prompt-template-edit-textarea"
|
|
value={value.prompt}
|
|
rows={6}
|
|
onChange={(e) =>
|
|
onChange({ summary: value.summary, prompt: e.target.value })
|
|
}
|
|
/>
|
|
{value.prompt.trim().length === 0 ? (
|
|
<div
|
|
className="prompt-template-edit-empty"
|
|
data-testid="prompt-template-empty-hint"
|
|
>
|
|
{t('newproj.promptTemplateBodyEmpty')}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PromptTemplateAvatar({
|
|
summary,
|
|
}: {
|
|
summary: PromptTemplateSummary | null;
|
|
}) {
|
|
if (!summary) return <NoneAvatar />;
|
|
if (summary.previewImageUrl) {
|
|
return (
|
|
<span className="ds-avatar prompt-template-avatar" aria-hidden>
|
|
<img
|
|
src={summary.previewImageUrl}
|
|
alt=""
|
|
loading="lazy"
|
|
draggable={false}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="ds-avatar prompt-template-avatar fallback" aria-hidden>
|
|
<Icon name={summary.surface === 'video' ? 'play' : 'image'} size={14} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function TemplateOption({
|
|
active,
|
|
onClick,
|
|
name,
|
|
description,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
name: string;
|
|
description: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`template-option${active ? ' active' : ''}`}
|
|
onClick={onClick}
|
|
aria-pressed={active}
|
|
>
|
|
<span className={`template-radio${active ? ' active' : ''}`} aria-hidden />
|
|
<span className="template-option-text">
|
|
<span className="template-option-name">{name}</span>
|
|
<span className="template-option-desc">{description}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
Design system picker — custom popover (replaces native <select>).
|
|
- Single-select by default. Toggle in the popover header switches to
|
|
multi-select, which lets users blend up to a few inspirations
|
|
(first pick is the primary; the rest go into metadata).
|
|
- Trigger card mirrors the claude.ai/design treatment: a tiny brand
|
|
swatch strip + title + "Default" subtitle + chevron.
|
|
============================================================ */
|
|
function DesignSystemPicker({
|
|
designSystems,
|
|
defaultDesignSystemId,
|
|
selectedIds,
|
|
multi,
|
|
onChange,
|
|
onChangeMulti,
|
|
loading,
|
|
}: {
|
|
designSystems: DesignSystemSummary[];
|
|
defaultDesignSystemId: string | null;
|
|
selectedIds: string[];
|
|
multi: boolean;
|
|
onChange: (ids: string[]) => void;
|
|
onChangeMulti: (v: boolean) => void;
|
|
loading: boolean;
|
|
}) {
|
|
const t = useT();
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const byId = useMemo(() => {
|
|
const map = new Map<string, DesignSystemSummary>();
|
|
for (const d of designSystems) map.set(d.id, d);
|
|
return map;
|
|
}, [designSystems]);
|
|
|
|
// Sort: selected first (in pick order), then default DS, then alpha
|
|
// by category then title. Keeps the popover scannable while honoring
|
|
// the user's existing picks.
|
|
const ordered = useMemo(() => {
|
|
const picked = selectedIds
|
|
.map((id) => byId.get(id))
|
|
.filter((d): d is DesignSystemSummary => Boolean(d));
|
|
const pickedSet = new Set(picked.map((d) => d.id));
|
|
const rest = designSystems
|
|
.filter((d) => !pickedSet.has(d.id))
|
|
.sort((a, b) => {
|
|
if (a.id === defaultDesignSystemId) return -1;
|
|
if (b.id === defaultDesignSystemId) return 1;
|
|
const ca = a.category || 'Other';
|
|
const cb = b.category || 'Other';
|
|
if (ca !== cb) return ca.localeCompare(cb);
|
|
return a.title.localeCompare(b.title);
|
|
});
|
|
return [...picked, ...rest];
|
|
}, [designSystems, byId, selectedIds, defaultDesignSystemId]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return ordered;
|
|
return ordered.filter((d) => {
|
|
return (
|
|
d.title.toLowerCase().includes(q) ||
|
|
(d.summary || '').toLowerCase().includes(q) ||
|
|
(d.category || '').toLowerCase().includes(q)
|
|
);
|
|
});
|
|
}, [ordered, query]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const t = window.setTimeout(() => searchRef.current?.focus(), 30);
|
|
return () => window.clearTimeout(t);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function onPointer(e: MouseEvent) {
|
|
if (wrapRef.current?.contains(e.target as Node)) return;
|
|
setOpen(false);
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
}
|
|
// Defer listener registration by a tick so the very click that opened
|
|
// the popover doesn't get re-interpreted as an outside-click on the
|
|
// mousedown that follows in the same event cycle (StrictMode also
|
|
// double-invokes the effect, which can race the same event).
|
|
const t = window.setTimeout(() => {
|
|
document.addEventListener('mousedown', onPointer);
|
|
document.addEventListener('keydown', onKey);
|
|
}, 0);
|
|
return () => {
|
|
window.clearTimeout(t);
|
|
document.removeEventListener('mousedown', onPointer);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
function toggle(id: string) {
|
|
if (multi) {
|
|
// Multi-select: tapping toggles membership; the *first* id in the
|
|
// array is treated as the primary across the rest of the app.
|
|
const has = selectedIds.includes(id);
|
|
if (has) {
|
|
onChange(selectedIds.filter((x) => x !== id));
|
|
} else {
|
|
onChange([...selectedIds, id]);
|
|
}
|
|
} else {
|
|
onChange([id]);
|
|
setOpen(false);
|
|
}
|
|
}
|
|
|
|
function clearAll() {
|
|
onChange([]);
|
|
if (!multi) setOpen(false);
|
|
}
|
|
|
|
const primaryId = selectedIds[0] ?? null;
|
|
const primary = primaryId ? byId.get(primaryId) ?? null : null;
|
|
const extraCount = Math.max(0, selectedIds.length - 1);
|
|
const isDefault = !!primary && primary.id === defaultDesignSystemId;
|
|
|
|
if (loading && designSystems.length === 0) {
|
|
return (
|
|
<div className="newproj-section">
|
|
<label className="newproj-label">{t('newproj.designSystem')}</label>
|
|
<Skeleton height={56} width="100%" radius={8} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="newproj-section ds-picker" data-testid="design-system-picker" ref={wrapRef}>
|
|
<label className="newproj-label">{t('newproj.designSystem')}</label>
|
|
<button
|
|
type="button"
|
|
data-testid="design-system-trigger"
|
|
className={`ds-picker-trigger${open ? ' open' : ''}${primary ? '' : ' empty'}`}
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
>
|
|
<DesignSystemAvatar system={primary} extraCount={extraCount} />
|
|
<span className="ds-picker-meta">
|
|
<span className="ds-picker-title">
|
|
{primary ? primary.title : t('newproj.dsNoneFreeform')}
|
|
{extraCount > 0 ? (
|
|
<span className="ds-picker-extra-pill">+{extraCount}</span>
|
|
) : null}
|
|
</span>
|
|
<span className="ds-picker-sub">
|
|
{primary
|
|
? isDefault
|
|
? t('common.default')
|
|
: primary.category || t('newproj.dsCategoryFallback')
|
|
: t('newproj.dsNoneSubtitleEmpty')}
|
|
</span>
|
|
</span>
|
|
<Icon
|
|
name="chevron-down"
|
|
size={14}
|
|
className="ds-picker-chevron"
|
|
style={{ transform: open ? 'rotate(180deg)' : undefined }}
|
|
/>
|
|
</button>
|
|
{open ? (
|
|
<div className="ds-picker-popover" role="listbox">
|
|
<div className="ds-picker-head">
|
|
<input
|
|
ref={searchRef}
|
|
data-testid="design-system-search"
|
|
className="ds-picker-search"
|
|
placeholder={t('newproj.dsSearch')}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
/>
|
|
<div
|
|
className="ds-picker-mode"
|
|
role="tablist"
|
|
aria-label={t('newproj.dsModeAria')}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={!multi}
|
|
className={`ds-picker-mode-btn${!multi ? ' active' : ''}`}
|
|
onClick={() => {
|
|
onChangeMulti(false);
|
|
if (selectedIds.length > 1) onChange(selectedIds.slice(0, 1));
|
|
}}
|
|
>
|
|
{t('newproj.dsModeSingle')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={multi}
|
|
className={`ds-picker-mode-btn${multi ? ' active' : ''}`}
|
|
onClick={() => onChangeMulti(true)}
|
|
>
|
|
{t('newproj.dsModeMulti')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="ds-picker-list ds-picker-list-design-systems">
|
|
<DsPickerItem
|
|
active={selectedIds.length === 0}
|
|
multi={multi}
|
|
onClick={clearAll}
|
|
avatar={<NoneAvatar />}
|
|
title={t('newproj.dsNoneTitle')}
|
|
subtitle={t('newproj.dsNoneSub')}
|
|
/>
|
|
{filtered.length === 0 ? (
|
|
<div className="ds-picker-empty">
|
|
{t('newproj.dsEmpty', { query })}
|
|
</div>
|
|
) : (
|
|
filtered.map((d) => {
|
|
const active = selectedIds.includes(d.id);
|
|
const order = active ? selectedIds.indexOf(d.id) : -1;
|
|
return (
|
|
<DsPickerItem
|
|
key={d.id}
|
|
active={active}
|
|
multi={multi}
|
|
order={order}
|
|
onClick={() => toggle(d.id)}
|
|
avatar={<DesignSystemAvatar system={d} />}
|
|
title={d.title}
|
|
badge={
|
|
d.id === defaultDesignSystemId
|
|
? t('newproj.dsBadgeDefault')
|
|
: undefined
|
|
}
|
|
subtitle={d.summary || d.category || ''}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
{multi && selectedIds.length > 1 ? (
|
|
<div className="ds-picker-foot">
|
|
<span className="ds-picker-foot-text">
|
|
<strong>{primary?.title ?? t('newproj.dsPrimaryFallback')}</strong>{' '}
|
|
{extraCount === 1
|
|
? t('newproj.dsFootSingular')
|
|
: t('newproj.dsFootPlural')}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="ds-picker-clear"
|
|
onClick={clearAll}
|
|
>
|
|
{t('newproj.dsFootClear')}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DsPickerItem({
|
|
active,
|
|
multi,
|
|
order,
|
|
onClick,
|
|
avatar,
|
|
title,
|
|
subtitle,
|
|
badge,
|
|
}: {
|
|
active: boolean;
|
|
multi: boolean;
|
|
order?: number;
|
|
onClick: () => void;
|
|
avatar: React.ReactNode;
|
|
title: string;
|
|
subtitle: string;
|
|
badge?: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
className={`ds-picker-item${active ? ' active' : ''}`}
|
|
onClick={onClick}
|
|
>
|
|
<span className="ds-picker-item-avatar">{avatar}</span>
|
|
<span className="ds-picker-item-text">
|
|
<span className="ds-picker-item-title">
|
|
{title}
|
|
{badge ? <span className="ds-picker-item-badge">{badge}</span> : null}
|
|
</span>
|
|
<span className="ds-picker-item-sub">{subtitle}</span>
|
|
</span>
|
|
<span
|
|
className={`ds-picker-mark ${multi ? 'check' : 'radio'}${active ? ' active' : ''}`}
|
|
aria-hidden
|
|
>
|
|
{multi ? (
|
|
active ? (order != null && order >= 0 ? order + 1 : '✓') : ''
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function DesignSystemAvatar({
|
|
system,
|
|
extraCount = 0,
|
|
}: {
|
|
system: DesignSystemSummary | null;
|
|
extraCount?: number;
|
|
}) {
|
|
if (!system) return <NoneAvatar />;
|
|
const swatches = system.swatches && system.swatches.length > 0
|
|
? system.swatches.slice(0, 4)
|
|
: fallbackSwatches(system.title);
|
|
return (
|
|
<span className="ds-avatar" aria-hidden>
|
|
<span className="ds-avatar-grid">
|
|
{swatches.map((c, i) => (
|
|
<span key={i} className="ds-avatar-cell" style={{ background: c }} />
|
|
))}
|
|
</span>
|
|
{extraCount > 0 ? (
|
|
<span className="ds-avatar-stack">+{extraCount}</span>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function NoneAvatar() {
|
|
return (
|
|
<span className="ds-avatar ds-avatar-none" aria-hidden>
|
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
|
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.6" />
|
|
<line x1="6" y1="18" x2="18" y2="6" stroke="currentColor" strokeWidth="1.6" />
|
|
</svg>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Deterministic fallback swatches for design systems whose DESIGN.md doesn't
|
|
// expose its tokens via the bold-and-hex format. Keeps the avatar visually
|
|
// distinct per-system without extra metadata fetches.
|
|
function fallbackSwatches(seed: string): string[] {
|
|
let h = 0;
|
|
for (let i = 0; i < seed.length; i++) {
|
|
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
|
}
|
|
const base = h % 360;
|
|
return [
|
|
`hsl(${base}, 18%, 96%)`,
|
|
`hsl(${(base + 90) % 360}, 22%, 78%)`,
|
|
`hsl(${(base + 180) % 360}, 30%, 32%)`,
|
|
`hsl(${(base + 30) % 360}, 70%, 52%)`,
|
|
];
|
|
}
|
|
|
|
function MediaProjectOptions(props:
|
|
| {
|
|
surface: 'image';
|
|
imageModel: string;
|
|
imageAspect: MediaAspect;
|
|
mediaProviders?: Record<string, MediaProviderCredentials>;
|
|
onImageModel: (value: string) => void;
|
|
onImageAspect: (value: MediaAspect) => void;
|
|
}
|
|
| {
|
|
surface: 'video';
|
|
videoModel: string;
|
|
videoAspect: MediaAspect;
|
|
videoLength: number;
|
|
mediaProviders?: Record<string, MediaProviderCredentials>;
|
|
onVideoModel: (value: string) => void;
|
|
onVideoAspect: (value: MediaAspect) => void;
|
|
onVideoLength: (value: number) => void;
|
|
}
|
|
| {
|
|
surface: 'audio';
|
|
audioKind: AudioKind;
|
|
audioModel: string;
|
|
audioDuration: number;
|
|
voice: string;
|
|
mediaProviders?: Record<string, MediaProviderCredentials>;
|
|
onAudioKind: (value: AudioKind) => void;
|
|
onAudioModel: (value: string) => void;
|
|
onAudioDuration: (value: number) => void;
|
|
onVoice: (value: string) => void;
|
|
}
|
|
) {
|
|
const t = useT();
|
|
|
|
if (props.surface === 'image') {
|
|
return (
|
|
<div className="newproj-media-options">
|
|
<MediaModelCards
|
|
label={t('newproj.modelLabel')}
|
|
models={supportedModels('image', IMAGE_MODELS)}
|
|
mediaProviders={props.mediaProviders}
|
|
value={props.imageModel}
|
|
onChange={props.onImageModel}
|
|
/>
|
|
<AspectCards
|
|
label={t('newproj.aspectLabel')}
|
|
value={props.imageAspect}
|
|
onChange={props.onImageAspect}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (props.surface === 'video') {
|
|
return (
|
|
<div className="newproj-media-options">
|
|
<MediaModelCards
|
|
label={t('newproj.modelLabel')}
|
|
models={supportedModels('video', VIDEO_MODELS)}
|
|
mediaProviders={props.mediaProviders}
|
|
value={props.videoModel}
|
|
onChange={props.onVideoModel}
|
|
/>
|
|
<AspectCards
|
|
label={t('newproj.aspectLabel')}
|
|
value={props.videoAspect}
|
|
onChange={props.onVideoAspect}
|
|
/>
|
|
<label className="newproj-label">
|
|
<span>{t('newproj.videoLengthLabel')}</span>
|
|
<select value={props.videoLength} onChange={(e) => props.onVideoLength(Number(e.target.value))}>
|
|
{VIDEO_LENGTHS_SEC.map((sec) => (
|
|
<option key={sec} value={sec}>{t('newproj.videoLengthSeconds', { n: sec })}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const models = supportedModels('audio', AUDIO_MODELS_BY_KIND[props.audioKind]);
|
|
return (
|
|
<div className="newproj-media-options">
|
|
<OptionCards
|
|
label={t('newproj.audioKindLabel')}
|
|
options={[
|
|
{ value: 'speech' as const, title: t('newproj.audioKindSpeech') },
|
|
]}
|
|
value={props.audioKind}
|
|
onChange={props.onAudioKind}
|
|
/>
|
|
<MediaModelCards
|
|
label={t('newproj.modelLabel')}
|
|
models={models}
|
|
mediaProviders={props.mediaProviders}
|
|
value={props.audioModel}
|
|
onChange={props.onAudioModel}
|
|
/>
|
|
<label className="newproj-label">
|
|
<span>{t('newproj.audioDurationLabel')}</span>
|
|
<select value={props.audioDuration} onChange={(e) => props.onAudioDuration(Number(e.target.value))}>
|
|
{AUDIO_DURATIONS_SEC.map((sec) => (
|
|
<option key={sec} value={sec}>{t('newproj.audioDurationSeconds', { n: sec })}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{props.audioKind === 'speech' ? (
|
|
<label className="newproj-label">
|
|
<span>{t('newproj.voiceLabel')}</span>
|
|
<input
|
|
value={props.voice}
|
|
placeholder={t('newproj.voicePlaceholder')}
|
|
onChange={(e) => props.onVoice(e.target.value)}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function supportedModels(surface: 'image' | 'video' | 'audio', models: MediaModel[]): MediaModel[] {
|
|
const supportedProviders: Record<'image' | 'video' | 'audio', Set<string>> = {
|
|
image: new Set(['openai', 'volcengine', 'grok', 'nanobanana']),
|
|
video: new Set(['volcengine', 'hyperframes', 'grok']),
|
|
audio: new Set(['minimax', 'fishaudio']),
|
|
};
|
|
return models.filter((model) => {
|
|
const provider = findProvider(model.provider);
|
|
return provider?.integrated === true && supportedProviders[surface].has(model.provider);
|
|
});
|
|
}
|
|
|
|
function MediaModelCards({
|
|
label,
|
|
models,
|
|
mediaProviders,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
models: MediaModel[];
|
|
mediaProviders?: Record<string, MediaProviderCredentials>;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const t = useT();
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// Group models by provider once. The trigger row needs the same provider
|
|
// metadata (label + status) to render the selected model's caption, so we
|
|
// compute groups regardless of whether the popover is open.
|
|
const groups = useMemo(() => {
|
|
const out: Array<{
|
|
providerId: string;
|
|
providerLabel: string;
|
|
status: 'configured' | 'integrated' | 'unsupported';
|
|
models: MediaModel[];
|
|
}> = [];
|
|
for (const model of models) {
|
|
const provider = findProvider(model.provider);
|
|
const providerId = provider?.id ?? model.provider;
|
|
const entry = mediaProviders?.[providerId];
|
|
const configured =
|
|
provider?.credentialsRequired === false ||
|
|
isStoredMediaProviderEntryPresent(entry);
|
|
let group = out.find((g) => g.providerId === providerId);
|
|
if (!group) {
|
|
group = {
|
|
providerId,
|
|
providerLabel: provider?.label ?? model.provider,
|
|
status: configured
|
|
? 'configured'
|
|
: provider?.integrated
|
|
? 'integrated'
|
|
: 'unsupported',
|
|
models: [],
|
|
};
|
|
out.push(group);
|
|
}
|
|
group.models.push(model);
|
|
}
|
|
return out;
|
|
}, [models, mediaProviders]);
|
|
|
|
const selected = useMemo(() => {
|
|
for (const group of groups) {
|
|
const hit = group.models.find((m) => m.id === value);
|
|
if (hit) return { model: hit, group };
|
|
}
|
|
return null;
|
|
}, [groups, value]);
|
|
|
|
const filteredGroups = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return groups;
|
|
return groups
|
|
.map((g) => ({
|
|
...g,
|
|
models: g.models.filter((m) => {
|
|
return (
|
|
m.id.toLowerCase().includes(q) ||
|
|
m.label.toLowerCase().includes(q) ||
|
|
m.hint.toLowerCase().includes(q) ||
|
|
g.providerLabel.toLowerCase().includes(q)
|
|
);
|
|
}),
|
|
}))
|
|
.filter((g) => g.models.length > 0);
|
|
}, [groups, query]);
|
|
|
|
const totalMatches = filteredGroups.reduce((n, g) => n + g.models.length, 0);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const id = window.setTimeout(() => searchRef.current?.focus(), 30);
|
|
return () => window.clearTimeout(id);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function onPointer(e: MouseEvent) {
|
|
if (wrapRef.current?.contains(e.target as Node)) return;
|
|
setOpen(false);
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
}
|
|
const id = window.setTimeout(() => {
|
|
document.addEventListener('mousedown', onPointer);
|
|
document.addEventListener('keydown', onKey);
|
|
}, 0);
|
|
return () => {
|
|
window.clearTimeout(id);
|
|
document.removeEventListener('mousedown', onPointer);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
function pick(modelId: string) {
|
|
onChange(modelId);
|
|
setOpen(false);
|
|
setQuery('');
|
|
}
|
|
|
|
const triggerTitle = selected?.model.label ?? t('newproj.modelMissingTitle');
|
|
// The model.hint frequently leads with the provider name (e.g.
|
|
// "OpenAI · 4K, native multimodal"), so emitting providerLabel as a
|
|
// separate prefix would duplicate it. If the hint already opens with the
|
|
// provider label, just use the hint verbatim — otherwise prefix it.
|
|
const triggerSub = selected
|
|
? selected.model.hint.toLowerCase().startsWith(selected.group.providerLabel.toLowerCase())
|
|
? selected.model.hint
|
|
: `${selected.group.providerLabel} · ${selected.model.hint}`
|
|
: t('newproj.modelMissingSub');
|
|
|
|
return (
|
|
<div className="newproj-section ds-picker model-picker" ref={wrapRef}>
|
|
<label className="newproj-label">{label}</label>
|
|
<button
|
|
type="button"
|
|
data-testid="model-picker-trigger"
|
|
className={`ds-picker-trigger${open ? ' open' : ''}${selected ? '' : ' empty'}`}
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
>
|
|
<span className="ds-picker-meta">
|
|
<span className="ds-picker-title">{triggerTitle}</span>
|
|
<span className="ds-picker-sub">{triggerSub}</span>
|
|
</span>
|
|
<Icon
|
|
name="chevron-down"
|
|
size={14}
|
|
className="ds-picker-chevron"
|
|
style={{ transform: open ? 'rotate(180deg)' : undefined }}
|
|
/>
|
|
</button>
|
|
{open ? (
|
|
<div className="ds-picker-popover" role="listbox">
|
|
<div className="ds-picker-head">
|
|
<input
|
|
ref={searchRef}
|
|
data-testid="model-picker-search"
|
|
className="ds-picker-search"
|
|
placeholder={t('newproj.modelSearch')}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="ds-picker-list">
|
|
{totalMatches === 0 ? (
|
|
<div className="ds-picker-empty">{t('newproj.modelEmpty')}</div>
|
|
) : (
|
|
filteredGroups.map((group) => (
|
|
<div className="ds-picker-group" key={group.providerId}>
|
|
<div className="ds-picker-group-head">
|
|
<span>{group.providerLabel}</span>
|
|
<span className={`newproj-provider-badge ${group.status}`}>
|
|
{group.status === 'configured'
|
|
? 'Configured'
|
|
: group.status === 'integrated'
|
|
? 'Integrated'
|
|
: 'Unsupported'}
|
|
</span>
|
|
</div>
|
|
{group.models.map((model) => {
|
|
const active = value === model.id;
|
|
return (
|
|
<button
|
|
key={model.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
data-testid={`model-picker-option-${model.id}`}
|
|
className={`ds-picker-item${active ? ' active' : ''}`}
|
|
onClick={() => pick(model.id)}
|
|
>
|
|
<span className="ds-picker-item-text">
|
|
<span className="ds-picker-item-title">
|
|
{model.label}
|
|
{model.default ? (
|
|
<span className="ds-picker-item-badge">
|
|
{t('newproj.modelRecommended')}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="ds-picker-item-sub">{model.hint}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AspectCards({
|
|
label,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: MediaAspect;
|
|
onChange: (value: MediaAspect) => void;
|
|
}) {
|
|
const labels: Record<MediaAspect, string> = {
|
|
'1:1': 'Square',
|
|
'16:9': 'Landscape',
|
|
'9:16': 'Portrait',
|
|
'4:3': 'Wide',
|
|
'3:4': 'Tall',
|
|
};
|
|
return (
|
|
<div className="newproj-media-field">
|
|
<div className="newproj-label">{label}</div>
|
|
<div className="newproj-aspect-segmented" role="radiogroup" aria-label={label}>
|
|
{MEDIA_ASPECTS.map((aspect) => {
|
|
const active = value === aspect;
|
|
return (
|
|
<button
|
|
key={aspect}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={active}
|
|
title={`${labels[aspect]} · ${aspect}`}
|
|
className={`newproj-aspect-pill${active ? ' active' : ''}`}
|
|
onClick={() => onChange(aspect)}
|
|
>
|
|
<span
|
|
className={`newproj-aspect-icon newproj-aspect-icon-${aspect.replace(':', '-')}`}
|
|
aria-hidden
|
|
/>
|
|
<span className="newproj-aspect-ratio">{aspect}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OptionCards<T extends string | number>({
|
|
label,
|
|
options,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
options: Array<{ value: T; title: string; hint?: string }>;
|
|
value: T;
|
|
onChange: (value: T) => void;
|
|
}) {
|
|
return (
|
|
<div className="newproj-media-field">
|
|
<div className="newproj-label">{label}</div>
|
|
<div className="newproj-option-grid compact">
|
|
{options.map((option) => (
|
|
<button
|
|
key={String(option.value)}
|
|
type="button"
|
|
className={`newproj-card newproj-option-card${value === option.value ? ' active' : ''}`}
|
|
onClick={() => onChange(option.value)}
|
|
aria-pressed={value === option.value}
|
|
>
|
|
<span>{option.title}</span>
|
|
{option.hint ? <small>{option.hint}</small> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildMetadata(input: {
|
|
tab: CreateTab;
|
|
mediaSurface: MediaSurface;
|
|
fidelity: 'wireframe' | 'high-fidelity';
|
|
platformTargets: NewProjectPlatform[];
|
|
includeLandingPage: boolean;
|
|
includeOsWidgets: boolean;
|
|
speakerNotes: boolean;
|
|
animations: boolean;
|
|
templateId: string | null;
|
|
templates: ProjectTemplate[];
|
|
imageModel: string;
|
|
imageAspect: MediaAspect;
|
|
videoModel: string;
|
|
videoAspect: MediaAspect;
|
|
videoLength: number;
|
|
audioKind: AudioKind;
|
|
audioModel: string;
|
|
audioDuration: number;
|
|
voice: string;
|
|
inspirationIds: string[];
|
|
promptTemplate: PromptTemplatePick | null;
|
|
}): ProjectMetadata {
|
|
const kind: ProjectKind =
|
|
input.tab === 'live-artifact'
|
|
? 'prototype'
|
|
: input.tab === 'media'
|
|
? input.mediaSurface
|
|
: input.tab;
|
|
const selectedPlatforms = normalizeSelectedPlatforms(input.platformTargets);
|
|
const concreteTargets = platformTargetsFor(selectedPlatforms);
|
|
const canIncludeOsWidgets = platformTargetsSupportOsWidgets(concreteTargets);
|
|
const surfaceOptions = {
|
|
...(input.includeLandingPage ? { includeLandingPage: true } : {}),
|
|
...(input.includeOsWidgets && canIncludeOsWidgets ? { includeOsWidgets: true } : {}),
|
|
};
|
|
const base = {
|
|
platform: selectedPlatforms[0],
|
|
platformTargets: concreteTargets,
|
|
...surfaceOptions,
|
|
};
|
|
const inspirations = input.inspirationIds.length > 0
|
|
? { inspirationDesignSystemIds: input.inspirationIds }
|
|
: {};
|
|
if (input.tab === 'prototype' || input.tab === 'live-artifact') {
|
|
return {
|
|
kind,
|
|
...base,
|
|
// Live artifact is locked to high fidelity (the picker is hidden in
|
|
// the panel) — wireframe live artifacts don't make sense.
|
|
fidelity: input.tab === 'live-artifact' ? 'high-fidelity' : input.fidelity,
|
|
...(input.tab === 'live-artifact' ? { intent: 'live-artifact' as const } : {}),
|
|
...inspirations,
|
|
};
|
|
}
|
|
if (input.tab === 'deck') {
|
|
return { kind, speakerNotes: input.speakerNotes, ...inspirations };
|
|
}
|
|
if (input.tab === 'template') {
|
|
if (input.templateId == null) {
|
|
return { kind, ...base, animations: input.animations, ...inspirations };
|
|
}
|
|
const tpl = input.templates.find((x) => x.id === input.templateId);
|
|
// The fallback label is consumed by the agent prompt rather than the
|
|
// UI, so we keep it in English to match the rest of the prompt corpus.
|
|
return {
|
|
kind,
|
|
...base,
|
|
animations: input.animations,
|
|
templateId: input.templateId,
|
|
templateLabel: tpl?.name ?? 'Saved template',
|
|
...inspirations,
|
|
};
|
|
}
|
|
if (input.tab === 'media') {
|
|
if (input.mediaSurface === 'image') {
|
|
return {
|
|
kind,
|
|
imageModel: input.imageModel,
|
|
imageAspect: input.imageAspect,
|
|
...buildPromptTemplateMetadata(input.promptTemplate),
|
|
...inspirations,
|
|
};
|
|
}
|
|
if (input.mediaSurface === 'video') {
|
|
return {
|
|
kind,
|
|
videoModel: input.videoModel,
|
|
videoAspect: input.videoAspect,
|
|
videoLength: input.videoLength,
|
|
...buildPromptTemplateMetadata(input.promptTemplate),
|
|
...inspirations,
|
|
};
|
|
}
|
|
return {
|
|
kind,
|
|
audioKind: input.audioKind,
|
|
audioModel: input.audioModel,
|
|
audioDuration: input.audioDuration,
|
|
voice: input.voice.trim() || undefined,
|
|
...inspirations,
|
|
};
|
|
}
|
|
return { kind: 'other', ...base, ...inspirations };
|
|
}
|
|
|
|
function normalizeSelectedPlatforms(platforms: NewProjectPlatform[]): NewProjectPlatform[] {
|
|
const seen = new Set<NewProjectPlatform>();
|
|
for (const platform of platforms) {
|
|
if (DESIGN_PLATFORMS.some((option) => option.value === platform)) {
|
|
seen.add(platform);
|
|
}
|
|
}
|
|
return seen.size > 0 ? [...seen] : ['responsive'];
|
|
}
|
|
|
|
function platformTargetsSupportOsWidgets(platforms: ProjectPlatform[] | NewProjectPlatform[]): boolean {
|
|
return platforms.some((platform) =>
|
|
platform === 'mobile-ios'
|
|
|| platform === 'mobile-android'
|
|
|| platform === 'tablet',
|
|
);
|
|
}
|
|
|
|
function platformTargetsFor(platforms: NewProjectPlatform[]): ProjectPlatform[] {
|
|
const targets = new Set<ProjectPlatform>();
|
|
for (const platform of platforms) {
|
|
switch (platform) {
|
|
case 'responsive':
|
|
targets.add('responsive');
|
|
break;
|
|
case 'web-desktop':
|
|
targets.add('web-desktop');
|
|
break;
|
|
case 'mobile-ios':
|
|
targets.add('mobile-ios');
|
|
break;
|
|
case 'mobile-android':
|
|
targets.add('mobile-android');
|
|
break;
|
|
case 'tablet':
|
|
targets.add('tablet');
|
|
break;
|
|
case 'desktop-app':
|
|
targets.add('desktop-app');
|
|
break;
|
|
default: {
|
|
const exhaustive: never = platform;
|
|
targets.add(exhaustive);
|
|
}
|
|
}
|
|
}
|
|
return targets.size > 0 ? [...targets] : ['responsive'];
|
|
}
|
|
|
|
function buildPromptTemplateMetadata(
|
|
pick: PromptTemplatePick | null,
|
|
): { promptTemplate?: ProjectMetadata['promptTemplate'] } {
|
|
if (!pick) return {};
|
|
const trimmed = pick.prompt.trim();
|
|
if (trimmed.length === 0) return {};
|
|
const { summary } = pick;
|
|
return {
|
|
promptTemplate: {
|
|
id: summary.id,
|
|
surface: summary.surface,
|
|
title: summary.title,
|
|
prompt: trimmed,
|
|
summary: summary.summary || undefined,
|
|
category: summary.category || undefined,
|
|
tags: summary.tags && summary.tags.length > 0 ? summary.tags : undefined,
|
|
model: summary.model,
|
|
aspect: summary.aspect,
|
|
source: summary.source
|
|
? {
|
|
repo: summary.source.repo,
|
|
license: summary.source.license,
|
|
author: summary.source.author,
|
|
url: summary.source.url,
|
|
}
|
|
: undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
function titleForTab(
|
|
tab: CreateTab,
|
|
mediaSurface: MediaSurface,
|
|
t: TranslateFn,
|
|
): string {
|
|
switch (tab) {
|
|
case 'prototype':
|
|
return t('newproj.titlePrototype');
|
|
case 'live-artifact':
|
|
return t('newproj.titleLiveArtifact');
|
|
case 'deck':
|
|
return t('newproj.titleDeck');
|
|
case 'template':
|
|
return t('newproj.titleTemplate');
|
|
case 'media': {
|
|
// Title tracks the active surface so the heading still reads "New
|
|
// image" / "New video" / "New audio" — the shared "Media" label only
|
|
// appears on the tab strip itself.
|
|
const key: keyof Dict =
|
|
mediaSurface === 'image'
|
|
? 'newproj.titleImage'
|
|
: mediaSurface === 'video'
|
|
? 'newproj.titleVideo'
|
|
: 'newproj.titleAudio';
|
|
return t(key);
|
|
}
|
|
case 'other':
|
|
return t('newproj.titleOther');
|
|
}
|
|
}
|
|
|
|
function autoName(
|
|
tab: CreateTab,
|
|
mediaSurface: MediaSurface,
|
|
t: TranslateFn,
|
|
): string {
|
|
const stamp = new Date().toLocaleDateString();
|
|
// For the Media tab the auto name reads "Image · {date}" / "Video · …" /
|
|
// "Audio · …" so the project list still surfaces the actual surface.
|
|
const labelKey: keyof Dict =
|
|
tab === 'media' ? MEDIA_SURFACE_LABEL_KEYS[mediaSurface] : TAB_LABEL_KEYS[tab];
|
|
return `${t(labelKey)} · ${stamp}`;
|
|
}
|