mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* add daemon project location support * wire project locations into web settings * localize project location settings * move default project location to settings * polish project location selection cards * fix project location i18n gaps * fix external project validation cleanup
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
|
import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow';
|
|
import type {
|
|
ConnectorDetail,
|
|
ConnectorStatusResponse,
|
|
} from '@open-design/contracts';
|
|
import type { OpenDesignHostProjectImportSuccess } from '@open-design/host';
|
|
import {
|
|
DEFAULT_AUDIO_MODEL,
|
|
DEFAULT_IMAGE_MODEL,
|
|
DEFAULT_VIDEO_MODEL,
|
|
} from '../media/models';
|
|
import type {
|
|
AgentInfo,
|
|
ApiProtocol,
|
|
AppConfig,
|
|
AppTheme,
|
|
DesignSystemSummary,
|
|
ExecMode,
|
|
Project,
|
|
ProjectKind,
|
|
ProjectMetadata,
|
|
ProjectTemplate,
|
|
PromptTemplateSummary,
|
|
ProviderModelOption,
|
|
SkillSummary,
|
|
} from '../types';
|
|
// `EntryShell` owns the redesigned home layout (left rail + centered
|
|
// hero + recent projects + plugins). Keeping the redesign in a sibling
|
|
// component lets future rebases against upstream `EntryView` (props,
|
|
// connector lifecycle, exported helpers) stay close to a no-op here.
|
|
import { EntryShell } from './EntryShell';
|
|
import type { IntegrationTab } from './IntegrationsView';
|
|
import type { CreateInput, ImportClaudeDesignOutcome } from './NewProjectPanel';
|
|
import {
|
|
fetchConnectors,
|
|
fetchConnectorStatuses,
|
|
} from '../providers/registry';
|
|
import type {
|
|
PluginShareAction,
|
|
PluginShareProjectOutcome,
|
|
} from '../state/projects';
|
|
|
|
interface Props {
|
|
// Union of functional skills + design templates — used for id-based
|
|
// lookups (DesignsTab project chips, NewProjectPanel skill picker).
|
|
// The Templates gallery itself reads `designTemplates` instead so it
|
|
// doesn't accidentally show functional skills as renderable cards.
|
|
skills: SkillSummary[];
|
|
// Design templates only. Sourced from /api/design-templates. See
|
|
// specs/current/skills-and-design-templates.md.
|
|
designTemplates: SkillSummary[];
|
|
designSystems: DesignSystemSummary[];
|
|
projects: Project[];
|
|
templates: ProjectTemplate[];
|
|
onDeleteTemplate: (id: string) => Promise<boolean>;
|
|
promptTemplates: PromptTemplateSummary[];
|
|
defaultDesignSystemId: string | null;
|
|
agents: AgentInfo[];
|
|
// Execution / model-switching context forwarded to the EntryShell so the
|
|
// sticky top-bar can expose the active CLI/BYOK + model and persist
|
|
// changes through the same channels as the project view.
|
|
config: AppConfig;
|
|
providerModelsCache?: Record<string, ProviderModelOption[]>;
|
|
onProviderModelsCacheChange?: Dispatch<SetStateAction<Record<string, ProviderModelOption[]>>>;
|
|
integrationInitialTab?: IntegrationTab;
|
|
composioConfigLoading?: boolean;
|
|
daemonLive: boolean;
|
|
onModeChange: (mode: ExecMode) => void;
|
|
onAgentChange: (id: string) => void;
|
|
onAgentModelChange: (
|
|
id: string,
|
|
choice: { model?: string; reasoning?: string },
|
|
) => void;
|
|
onApiProtocolChange: (protocol: ApiProtocol) => void;
|
|
onApiModelChange: (model: string) => void;
|
|
onConfigPersist: (cfg: AppConfig) => Promise<void> | void;
|
|
onRefreshAgents: () => Promise<AgentInfo[]> | AgentInfo[];
|
|
// Quick theme switch invoked from the avatar-popover dropdown so the
|
|
// user can flip light/dark/system without opening the full Settings
|
|
// dialog. Persistence happens in `App`; this component just forwards.
|
|
onThemeChange: (theme: AppTheme) => void;
|
|
// Per-resource loading flags. Each tab gates its own content on whichever
|
|
// flag matches the data it renders, so a slow `/api/agents` probe does
|
|
// not block tabs that don't need agents. Templates are not gated here —
|
|
// the New project modal renders an empty state until they arrive (fast
|
|
// fetch), which keeps the prop surface narrower.
|
|
skillsLoading?: boolean;
|
|
designSystemsLoading?: boolean;
|
|
projectsLoading?: boolean;
|
|
promptTemplatesLoading?: boolean;
|
|
onCreateProject: (
|
|
input: CreateInput & {
|
|
pendingPrompt?: string;
|
|
pluginId?: string;
|
|
appliedPluginSnapshotId?: string;
|
|
pluginInputs?: Record<string, unknown>;
|
|
autoSendFirstMessage?: boolean;
|
|
pendingFiles?: File[];
|
|
},
|
|
) => Promise<boolean> | boolean | void;
|
|
onCreatePluginShareProject: (
|
|
pluginId: string,
|
|
action: PluginShareAction,
|
|
locale?: string,
|
|
) => Promise<PluginShareProjectOutcome>;
|
|
onImportClaudeDesign: (
|
|
file: File,
|
|
) => Promise<ImportClaudeDesignOutcome | void> | ImportClaudeDesignOutcome | void;
|
|
onImportFolder?: (baseDir: string) => Promise<void> | void;
|
|
onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise<void> | void;
|
|
onOpenProject: (id: string) => void;
|
|
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
|
|
onDeleteProject: (id: string) => void;
|
|
onRenameProject: (id: string, name: string) => void;
|
|
onChangeDefaultDesignSystem: (id: string) => void;
|
|
onCreateDesignSystem?: () => void;
|
|
renderDesignSystemCreation?: (
|
|
onBack: () => void,
|
|
hooks?: {
|
|
onBeforeGenerate?: (snapshot: DesignSystemGenerateSnapshot) => void;
|
|
onGenerateSettled?: (
|
|
snapshot: DesignSystemGenerateSnapshot,
|
|
outcome:
|
|
| { result: 'success' }
|
|
| { result: 'failed'; errorCode: string },
|
|
) => void;
|
|
},
|
|
) => ReactNode;
|
|
onOpenDesignSystem?: (id: string) => void;
|
|
onDesignSystemsRefresh?: () => Promise<void> | void;
|
|
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
|
|
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'projectLocations' | 'library' | 'about' | 'memory' | 'designSystems') => void;
|
|
onCompleteOnboarding: () => void;
|
|
}
|
|
|
|
const CONNECTOR_CALLBACK_MESSAGE_TYPE = 'open-design:connector-connected';
|
|
|
|
export function isTrustedConnectorCallbackOrigin(origin: string, currentOrigin?: string): boolean {
|
|
const expectedOrigin = currentOrigin ?? (typeof window === 'undefined' ? '' : window.location.origin);
|
|
if (origin === expectedOrigin) return true;
|
|
try {
|
|
const url = new URL(origin);
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function applyConnectorStatuses(
|
|
current: ConnectorDetail[],
|
|
statuses: ConnectorStatusResponse['statuses'],
|
|
): ConnectorDetail[] {
|
|
if (!Object.keys(statuses).length) return current;
|
|
return current.map((connector) => {
|
|
const next = statuses[connector.id];
|
|
if (!next) return connector;
|
|
const { accountLabel: _accountLabel, lastError: _lastError, ...base } = connector;
|
|
return {
|
|
...base,
|
|
status: next.status,
|
|
...(next.accountLabel === undefined ? {} : { accountLabel: next.accountLabel }),
|
|
...(next.lastError === undefined ? {} : { lastError: next.lastError }),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function sortConnectorsForDisplay(connectors: ConnectorDetail[]): ConnectorDetail[] {
|
|
return [...connectors].sort((a, b) => {
|
|
const aConnected = a.status === 'connected';
|
|
const bConnected = b.status === 'connected';
|
|
if (aConnected !== bConnected) return aConnected ? -1 : 1;
|
|
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.id.localeCompare(b.id);
|
|
});
|
|
}
|
|
|
|
function normalizedSearchValue(value: string | undefined): string {
|
|
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
}
|
|
|
|
function scoreConnectorText(value: string | undefined, query: string, baseScore: number): number | null {
|
|
const normalized = normalizedSearchValue(value);
|
|
if (!normalized) return null;
|
|
if (normalized === query) return baseScore;
|
|
if (normalized.startsWith(query)) return baseScore + 1;
|
|
if (normalized.includes(query)) return baseScore + 2;
|
|
return null;
|
|
}
|
|
|
|
export function getConnectorSearchScore(connector: ConnectorDetail, query: string): number | null {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
if (!normalizedQuery) return 0;
|
|
|
|
const scores: number[] = [];
|
|
const collect = (value: string | undefined, baseScore: number) => {
|
|
const score = scoreConnectorText(value, normalizedQuery, baseScore);
|
|
if (score !== null) scores.push(score);
|
|
};
|
|
|
|
// Connector identity fields carry the most intent: exact and prefix
|
|
// name/provider matches should beat incidental mentions elsewhere.
|
|
collect(connector.name, 0);
|
|
collect(connector.provider, 0);
|
|
|
|
// Secondary connector metadata is still searchable, but lower priority.
|
|
collect(connector.category, 3);
|
|
collect(connector.accountLabel, 3);
|
|
|
|
// Tool names/titles are more relevant than prose descriptions, but below
|
|
// connector-level identity matches.
|
|
for (const tool of connector.tools) {
|
|
collect(tool.title, 5);
|
|
collect(tool.name, 5);
|
|
}
|
|
|
|
// Prose descriptions are broad and often mention other products, so they
|
|
// are intentionally down-ranked rather than excluded.
|
|
collect(connector.description, 8);
|
|
for (const tool of connector.tools) {
|
|
collect(tool.description, 8);
|
|
}
|
|
|
|
return scores.length ? Math.min(...scores) : null;
|
|
}
|
|
|
|
export function sortConnectorsForSearch(
|
|
connectors: ConnectorDetail[],
|
|
query: string,
|
|
): ConnectorDetail[] {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
if (!normalizedQuery) return sortConnectorsForDisplay(connectors);
|
|
|
|
return [...connectors]
|
|
.map((connector) => ({ connector, score: getConnectorSearchScore(connector, normalizedQuery) }))
|
|
.filter((entry): entry is { connector: ConnectorDetail; score: number } => entry.score !== null)
|
|
.sort((a, b) => {
|
|
if (a.score !== b.score) return a.score - b.score;
|
|
const aConnected = a.connector.status === 'connected';
|
|
const bConnected = b.connector.status === 'connected';
|
|
if (aConnected !== bConnected) return aConnected ? -1 : 1;
|
|
return (
|
|
a.connector.name.localeCompare(b.connector.name, undefined, { sensitivity: 'base' }) ||
|
|
a.connector.id.localeCompare(b.connector.id)
|
|
);
|
|
})
|
|
.map((entry) => entry.connector);
|
|
}
|
|
|
|
export function EntryView({
|
|
skills,
|
|
designTemplates,
|
|
designSystems,
|
|
projects,
|
|
templates,
|
|
onDeleteTemplate,
|
|
promptTemplates,
|
|
defaultDesignSystemId,
|
|
agents,
|
|
config,
|
|
providerModelsCache,
|
|
onProviderModelsCacheChange,
|
|
integrationInitialTab,
|
|
composioConfigLoading = false,
|
|
daemonLive,
|
|
onModeChange,
|
|
onAgentChange,
|
|
onAgentModelChange,
|
|
onApiProtocolChange,
|
|
onApiModelChange,
|
|
onConfigPersist,
|
|
onRefreshAgents,
|
|
onThemeChange,
|
|
skillsLoading = false,
|
|
designSystemsLoading = false,
|
|
projectsLoading = false,
|
|
promptTemplatesLoading: _promptTemplatesLoading = false,
|
|
onCreateProject,
|
|
onCreatePluginShareProject,
|
|
onImportClaudeDesign,
|
|
onImportFolder,
|
|
onImportFolderResponse,
|
|
onOpenProject,
|
|
onOpenLiveArtifact,
|
|
onDeleteProject,
|
|
onRenameProject,
|
|
onChangeDefaultDesignSystem,
|
|
onCreateDesignSystem,
|
|
renderDesignSystemCreation,
|
|
onOpenDesignSystem,
|
|
onDesignSystemsRefresh,
|
|
onPersistComposioKey,
|
|
onOpenSettings,
|
|
onCompleteOnboarding,
|
|
}: Props) {
|
|
const [connectors, setConnectors] = useState<ConnectorDetail[]>([]);
|
|
const [connectorsLoading, setConnectorsLoading] = useState(false);
|
|
|
|
const reloadConnectorStatuses = useCallback(async () => {
|
|
const statuses = await fetchConnectorStatuses();
|
|
setConnectors((curr) => applyConnectorStatuses(curr, statuses));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
// Fetch connectors on mount so the New project modal can show
|
|
// already-configured connectors without waiting for the user to
|
|
// open the Settings → Connectors surface.
|
|
setConnectorsLoading(true);
|
|
(async () => {
|
|
const next = await fetchConnectors();
|
|
if (cancelled) return;
|
|
setConnectors(next);
|
|
setConnectorsLoading(false);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function onMessage(event: MessageEvent) {
|
|
const data = event.data;
|
|
if (!data || typeof data !== 'object' || (data as { type?: unknown }).type !== CONNECTOR_CALLBACK_MESSAGE_TYPE) return;
|
|
if (!isTrustedConnectorCallbackOrigin(event.origin)) return;
|
|
void reloadConnectorStatuses();
|
|
}
|
|
window.addEventListener('message', onMessage);
|
|
return () => window.removeEventListener('message', onMessage);
|
|
}, [reloadConnectorStatuses]);
|
|
|
|
// When the OAuth flow is handed off to the user's system browser (desktop
|
|
// shell opens connector auth URLs externally rather than in an Electron
|
|
// popup), the callback page has no `window.opener` to postMessage back to.
|
|
// Refresh connector statuses whenever the window regains focus so the UI
|
|
// picks up a just-completed connection without manual intervention.
|
|
useEffect(() => {
|
|
function onFocus() {
|
|
void reloadConnectorStatuses();
|
|
}
|
|
window.addEventListener('focus', onFocus);
|
|
return () => window.removeEventListener('focus', onFocus);
|
|
}, [reloadConnectorStatuses]);
|
|
|
|
return (
|
|
<EntryShell
|
|
skills={skills}
|
|
designTemplates={designTemplates}
|
|
designSystems={designSystems}
|
|
projects={projects}
|
|
templates={templates}
|
|
onDeleteTemplate={onDeleteTemplate}
|
|
promptTemplates={promptTemplates}
|
|
defaultDesignSystemId={defaultDesignSystemId}
|
|
connectors={connectors}
|
|
connectorsLoading={connectorsLoading}
|
|
{...(integrationInitialTab ? { integrationInitialTab } : {})}
|
|
composioConfigLoading={composioConfigLoading}
|
|
skillsLoading={skillsLoading}
|
|
designSystemsLoading={designSystemsLoading}
|
|
projectsLoading={projectsLoading}
|
|
config={config}
|
|
providerModelsCache={providerModelsCache}
|
|
onProviderModelsCacheChange={onProviderModelsCacheChange}
|
|
agents={agents}
|
|
daemonLive={daemonLive}
|
|
onModeChange={onModeChange}
|
|
onAgentChange={onAgentChange}
|
|
onAgentModelChange={onAgentModelChange}
|
|
onApiProtocolChange={onApiProtocolChange}
|
|
onApiModelChange={onApiModelChange}
|
|
onConfigPersist={onConfigPersist}
|
|
onRefreshAgents={onRefreshAgents}
|
|
onThemeChange={onThemeChange}
|
|
onCreateProject={onCreateProject}
|
|
onCreatePluginShareProject={onCreatePluginShareProject}
|
|
onImportClaudeDesign={onImportClaudeDesign}
|
|
{...(onImportFolder ? { onImportFolder } : {})}
|
|
{...(onImportFolderResponse ? { onImportFolderResponse } : {})}
|
|
onOpenProject={onOpenProject}
|
|
onOpenLiveArtifact={onOpenLiveArtifact}
|
|
onDeleteProject={onDeleteProject}
|
|
onRenameProject={onRenameProject}
|
|
onChangeDefaultDesignSystem={onChangeDefaultDesignSystem}
|
|
onCreateDesignSystem={onCreateDesignSystem}
|
|
renderDesignSystemCreation={renderDesignSystemCreation}
|
|
onOpenDesignSystem={onOpenDesignSystem}
|
|
onDesignSystemsRefresh={onDesignSystemsRefresh}
|
|
onPersistComposioKey={onPersistComposioKey}
|
|
onOpenSettings={onOpenSettings}
|
|
onCompleteOnboarding={onCompleteOnboarding}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Map a skill's declared mode to project metadata. Falls back to the same
|
|
// defaults the new-project form would apply (high-fidelity prototype, no
|
|
// speaker notes on decks, no template animations) so 'Use this prompt'
|
|
// produces a project indistinguishable from one created via the form. Per-
|
|
// skill hints in SKILL.md frontmatter (od.fidelity, od.speaker_notes,
|
|
// od.animations) override the defaults so each example reproduces the
|
|
// shipped example.html — e.g. wireframe-sketch declares fidelity:wireframe.
|
|
//
|
|
// Kept exported (and the kindForSkill helper too) so the New project modal
|
|
// and any future skill-driven creation surface can share the mapping.
|
|
export function metadataForSkill(skill: SkillSummary): ProjectMetadata {
|
|
const kind = kindForSkill(skill);
|
|
if (kind === 'prototype') {
|
|
return { kind, fidelity: skill.fidelity ?? 'high-fidelity' };
|
|
}
|
|
if (kind === 'deck') {
|
|
return {
|
|
kind,
|
|
speakerNotes:
|
|
typeof skill.speakerNotes === 'boolean' ? skill.speakerNotes : false,
|
|
};
|
|
}
|
|
if (kind === 'template') {
|
|
return {
|
|
kind,
|
|
animations:
|
|
typeof skill.animations === 'boolean' ? skill.animations : false,
|
|
};
|
|
}
|
|
if (kind === 'image') {
|
|
return { kind, imageModel: DEFAULT_IMAGE_MODEL, imageAspect: '1:1' };
|
|
}
|
|
if (kind === 'video') {
|
|
return { kind, videoModel: DEFAULT_VIDEO_MODEL, videoAspect: '16:9', videoLength: 5 };
|
|
}
|
|
if (kind === 'audio') {
|
|
return {
|
|
kind,
|
|
audioKind: 'speech',
|
|
audioModel: DEFAULT_AUDIO_MODEL.speech,
|
|
audioDuration: 10,
|
|
};
|
|
}
|
|
return { kind: 'other' };
|
|
}
|
|
|
|
export function kindForSkill(skill: SkillSummary): ProjectKind {
|
|
if (skill.mode === 'deck') return 'deck';
|
|
if (skill.mode === 'prototype') return 'prototype';
|
|
if (skill.mode === 'template') return 'template';
|
|
if (skill.mode === 'image' || skill.surface === 'image') return 'image';
|
|
if (skill.mode === 'video' || skill.surface === 'video') return 'video';
|
|
if (skill.mode === 'audio' || skill.surface === 'audio') return 'audio';
|
|
return 'other';
|
|
}
|