open-design/apps/web/src/components/EntryView.tsx
Marc Chan e14b8092ea
feat: add Orbit activity summaries (#681)
* feat: add Orbit activity summaries

* fix(orbit): make runs navigable while agent continues

* fix(web): widen minimum chat panel

* feat: support Orbit template selection

* fix(daemon): avoid bogus skill side-file preflight

* fix(web): collapse orbit artifact project cards

* fix(web): preserve orbit project card titles

* fix: improve Orbit run daily briefing

* fix: handle Orbit digest data failures

* fix: load Orbit templates and connector tools reliably

* fix: keep Orbit summary counts consistent

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: apply Orbit template skill context

* fix: cache and curate connector tools for Orbit

* fix: align Orbit defaults and connector discovery

* fix: simplify Orbit template settings

* fix: move connectors into settings

* fix: compact connector settings catalog

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: prevent connector action button from stretching into pill

The icon-only connect/disconnect buttons in the embedded connectors
catalog inherited min-width: 92px / 106px from the non-embedded pill
rules, overriding the 24px square sizing and causing the buttons to
overlap the card head text. Reset min-width to 0 in the embedded
icon-only rule so the compact square layout holds.

* fix(web): align live artifact file rows

* fix: clean up Orbit connector settings lifecycle

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address Orbit review regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* feat(web): localize Orbit and connector settings

* feat(web): gate Orbit runs without connectors

* feat(web): refine connector settings UX

* feat(web): safeguard Composio key clearing

* fix(web): refresh Composio tool badges

* feat(web): show connector logos

* feat(daemon): localize Orbit prompt window

* fix(daemon): clarify blocked connector callback closes

* test(daemon): harden flaky async probes

* fix(web): align Indonesian connector locale keys

* test(web): align connector browser props

* fix(web): preserve explicit credential clears

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): time out Composio logo proxy fetches

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Indonesian connector settings copy

Translate the new connector settings strings in the Indonesian locale and lock them with a regression test so this surface no longer silently falls back to English.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve discovered connector tools

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve onboarding autosave completion

Keep settings autosave from clearing onboarding completion after the close gesture, and expose the desktop main types from source so workspace validation can typecheck packaged imports without a prior desktop build.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): defer Composio catalog cache hydration

Load persisted Composio catalog data only after the runtime data directory is configured so startup cannot read another namespace's cache. Add a regression test that exercises the module-load singleton path.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): treat discovery completion independently

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve latest settings draft on close

Use the latest persisted settings draft when the dialog closes so onboarding completion does not race a stale daemon sync and overwrite newer Orbit/template selections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): avoid syncing draft Composio key on Orbit run

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Orbit settings copy

Translate the new Indonesian Orbit and autosave strings so the settings UI no longer falls back to English and the locale regression stays covered.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): prefer fresh connector catalog state

Keep refetched connector status/auth data authoritative while retaining discovery-only tool metadata so the connectors UI stays consistent after refreshes.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): declare Indonesian locale fallback keys explicitly

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): inline Indonesian fallback strings for CI

Replace the Indonesian locale's per-key English lookups with explicit strings so workspace typecheck no longer depends on brittle build-mode resolution in CI.

Add a regression test that blocks those per-key English lookups from reappearing in the CI-sensitive fallback sections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): restrict proxied connector logos to image MIME types

Reject non-image upstream logo responses so the daemon never serves third-party HTML from its localhost origin.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* test(e2e): align settings dialog regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): decouple Orbit runs from media sync failures

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): keep SPA catch-all export-compatible

Disable dynamic catch-all params for the exported SPA shell so Next.js static builds can emit the root route again. Add a regression test covering the route config against the web export mode.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve Orbit config and workspace routes

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): block SVG in connector logo proxy

Reject SVG and other unsafe proxied logo responses so third-party logo content cannot execute under the daemon origin, while keeping raster logo fetches working and making rejected responses non-cacheable.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): fall back to static catalog for empty cache

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): disable Orbit run before connector gate resolves

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(desktop): export shipped desktop types

Point the desktop ./main type export at the generated declaration so installed consumers resolve the published file set.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore persisted question form selections

Render historical submitted answers directly so reloaded question forms keep their locked selections visible.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): retry forced media sync autosave

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep Composio logo timeout through body read

Keep the Composio logo fetch timeout active until the response body is fully consumed so stalled body reads abort and clear the inflight cache entry. Add a regression test that proves a delayed body read times out and the next request can recover.\n\nGenerated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): refresh Orbit gate after connector auth

Re-check connector availability when the settings window regains focus so Orbit unlocks as soon as a connector finishes authenticating in the same settings session.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep connector detail tool lists intact

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): ignore malformed Orbit summaries

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(e2e): stabilize design-system multi-select flow

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): cap Composio logo cache growth

Bound the Composio logo cache with LRU eviction and expired-entry pruning so repeated untrusted logo requests cannot grow daemon memory without limit.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): bound proxied Composio logo payloads

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): align autosave settings tests

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): remove stray CSS conflict marker

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fixer: address PR #681 follow-up items

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore restart routes and connector flows

* fix(web): keep SPA export route static

* fix(web): stabilize chat scroll tests

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 14:27:46 +08:00

712 lines
24 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts';
import { useT } from '../i18n';
import {
DEFAULT_AUDIO_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
} from '../media/models';
import type {
AgentInfo,
AppConfig,
DesignSystemSummary,
Project,
ProjectKind,
ProjectMetadata,
ProjectTemplate,
PromptTemplateSummary,
SkillSummary,
} from '../types';
import { DesignsTab } from './DesignsTab';
import { DesignSystemPreviewModal } from './DesignSystemPreviewModal';
import { DesignSystemsTab } from './DesignSystemsTab';
import { ExamplesTab } from './ExamplesTab';
import { AppChromeHeader } from './AppChromeHeader';
import { Icon } from './Icon';
import { LanguageMenu } from './LanguageMenu';
import { CenteredLoader } from './Loading';
import { NewProjectPanel, type CreateInput } from './NewProjectPanel';
import {
fetchConnectors,
fetchConnectorStatuses,
} from '../providers/registry';
import { PetRail } from './pet/PetRail';
import { PromptTemplatePreviewModal } from './PromptTemplatePreviewModal';
import { PromptTemplatesTab } from './PromptTemplatesTab';
import { apiProtocolLabel } from '../utils/apiProtocol';
type TopTab = 'designs' | 'examples' | 'design-systems' | 'image-templates' | 'video-templates';
interface Props {
skills: SkillSummary[];
designSystems: DesignSystemSummary[];
projects: Project[];
templates: ProjectTemplate[];
promptTemplates: PromptTemplateSummary[];
defaultDesignSystemId: string | null;
config: AppConfig;
agents: AgentInfo[];
loading?: boolean;
onCreateProject: (input: CreateInput & { pendingPrompt?: string }) => void;
onImportClaudeDesign: (file: File) => Promise<void> | void;
onImportFolder?: (baseDir: string) => Promise<void> | void;
onOpenProject: (id: string) => void;
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
onDeleteProject: (id: string) => void;
onChangeDefaultDesignSystem: (id: string) => void;
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'language' | 'appearance' | 'notifications' | 'pet' | 'about') => void;
onAdoptPet: () => void;
onAdoptPetInline: (petId: string) => void;
onTogglePet: () => void;
}
const SIDEBAR_MIN = 320;
const SIDEBAR_MAX = 560;
const SIDEBAR_DEFAULT = 380;
const SIDEBAR_STORAGE_KEY = 'od-entry-sidebar-width';
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;
}
}
// Lets the user fully remove the right-side pet rail from the entry
// layout. They re-summon it from the entry-view avatar dropdown — the
// PetRail's own collapse toggle only narrows the column, so this state
// is the "the rail isn't there at all" escape hatch.
const PET_RAIL_HIDDEN_KEY = 'open-design:pet-rail-hidden';
function loadSidebarWidth(): number {
try {
const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (!raw) return SIDEBAR_DEFAULT;
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return SIDEBAR_DEFAULT;
return Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, n));
} catch {
return SIDEBAR_DEFAULT;
}
}
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);
}
function loadPetRailHidden(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(PET_RAIL_HIDDEN_KEY) === '1';
} catch {
return false;
}
}
export function EntryView({
skills,
designSystems,
projects,
templates,
promptTemplates,
defaultDesignSystemId,
config,
agents,
loading = false,
onCreateProject,
onImportClaudeDesign,
onImportFolder,
onOpenProject,
onOpenLiveArtifact,
onDeleteProject,
onChangeDefaultDesignSystem,
onOpenSettings,
onAdoptPet,
onAdoptPetInline,
onTogglePet,
}: Props) {
const t = useT();
const [topTab, setTopTab] = useState<TopTab>('designs');
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
const [previewPromptTemplate, setPreviewPromptTemplate] =
useState<PromptTemplateSummary | null>(null);
const [sidebarWidth, setSidebarWidth] = useState<number>(() => loadSidebarWidth());
const [resizing, setResizing] = useState(false);
const [connectors, setConnectors] = useState<ConnectorDetail[]>([]);
const [connectorsLoading, setConnectorsLoading] = useState(false);
const [petRailHidden, setPetRailHiddenState] = useState<boolean>(() => loadPetRailHidden());
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false);
const avatarMenuRef = useRef<HTMLDivElement | null>(null);
function setPetRailHidden(next: boolean) {
setPetRailHiddenState(next);
try {
window.localStorage.setItem(PET_RAIL_HIDDEN_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
}
const currentAgent = useMemo(
() => agents.find((a) => a.id === config.agentId) ?? null,
[agents, config.agentId],
);
const envMetaLine = useMemo(() => {
if (config.mode === 'api') {
try {
return `${config.model} · ${new URL(config.baseUrl).host}`;
} catch {
return config.model;
}
}
return currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('settings.noAgentSelected');
}, [config.mode, config.model, config.baseUrl, currentAgent, t]);
// 'Use this prompt' on an example card is a fast path — skip the form and
// create the project immediately with sane defaults derived from the skill,
// seeding the chat composer with the example prompt via pendingPrompt.
function usePromptFromSkill(skill: SkillSummary) {
onCreateProject({
name: skill.name,
skillId: skill.id,
designSystemId: null,
metadata: metadataForSkill(skill),
pendingPrompt: skill.examplePrompt || skill.description,
});
}
function previewDesignSystem(id: string) {
setPreviewSystemId(id);
}
const previewSystem = useMemo(
() => (previewSystemId ? designSystems.find((d) => d.id === previewSystemId) ?? null : null),
[designSystems, previewSystemId],
);
function handleCreate(input: CreateInput) {
onCreateProject(input);
}
const startWidthRef = useRef(0);
const startXRef = useRef(0);
useEffect(() => {
if (!resizing) return;
function onMove(e: MouseEvent) {
const dx = e.clientX - startXRef.current;
const next = Math.max(
SIDEBAR_MIN,
Math.min(SIDEBAR_MAX, startWidthRef.current + dx),
);
setSidebarWidth(next);
}
function onUp() {
setResizing(false);
}
document.body.classList.add('entry-resizing');
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
document.body.classList.remove('entry-resizing');
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [resizing]);
useEffect(() => {
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
} catch {
/* ignore */
}
}, [sidebarWidth]);
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 panel can show
// already-configured connectors on the live-artifact tab 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]);
// Dismiss the avatar dropdown on outside-click / Escape so it behaves
// like the project-view AvatarMenu (which uses the same shell CSS).
useEffect(() => {
if (!avatarMenuOpen) return;
const onClick = (e: MouseEvent) => {
if (!avatarMenuRef.current) return;
if (!avatarMenuRef.current.contains(e.target as Node)) {
setAvatarMenuOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setAvatarMenuOpen(false);
};
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [avatarMenuOpen]);
const avatarMenu = (
<div className="avatar-menu" ref={avatarMenuRef}>
<button
type="button"
className="settings-icon-btn"
onClick={() => setAvatarMenuOpen((v) => !v)}
title={t('entry.openSettingsTitle')}
aria-label={t('entry.openSettingsAria')}
aria-haspopup="menu"
aria-expanded={avatarMenuOpen}
>
<Icon name="settings" size={17} />
</button>
{avatarMenuOpen ? (
<div className="avatar-popover" role="menu">
<button
type="button"
className="avatar-item"
onClick={() => {
setPetRailHidden(!petRailHidden);
setAvatarMenuOpen(false);
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name={petRailHidden ? 'sparkles' : 'eye'} size={14} />
</span>
<span>
{petRailHidden
? t('pet.railShow')
: t('pet.railHide')}
</span>
</button>
<div style={{ height: 1, background: 'var(--border-soft)', margin: '4px 6px' }} />
<button
type="button"
className="avatar-item"
onClick={() => {
setAvatarMenuOpen(false);
onOpenSettings();
}}
>
<span className="avatar-item-icon" aria-hidden>
<Icon name="settings" size={14} />
</span>
<span>{t('avatar.settings')}</span>
</button>
</div>
) : null}
</div>
);
return (
<div className="entry-shell">
<AppChromeHeader actions={avatarMenu} />
<div
className={`entry${petRailHidden ? '' : ' has-pet-rail'}`}
style={{
gridTemplateColumns: petRailHidden
? `${sidebarWidth}px 1fr`
: `${sidebarWidth}px 1fr auto`,
}}
>
<aside className="entry-side" style={{ width: sidebarWidth }}>
<NewProjectPanel
skills={skills}
designSystems={designSystems}
defaultDesignSystemId={defaultDesignSystemId}
templates={templates}
promptTemplates={promptTemplates}
onCreate={handleCreate}
onImportClaudeDesign={onImportClaudeDesign}
onImportFolder={onImportFolder}
mediaProviders={config.mediaProviders}
connectors={connectors}
connectorsLoading={connectorsLoading}
onOpenConnectorsTab={() => onOpenSettings('composio')}
loading={loading}
/>
<div className="entry-side-foot">
<button
type="button"
className={`foot-pill pet-pill${config.pet?.adopted ? '' : ' pet-pill-fresh'}`}
onClick={onAdoptPet}
title={
config.pet?.adopted
? t('pet.changePet')
: t('pet.adoptCallout')
}
>
<span className="pet-pill-glyph" aria-hidden>
{config.pet?.adopted
? config.pet.petId === 'custom'
? config.pet.custom.glyph || '🦄'
: '🐾'
: '🐾'}
</span>
<span>
{config.pet?.adopted
? t('pet.changePet')
: t('pet.adoptCallout')}
</span>
{!config.pet?.adopted ? <span className="pet-pill-dot" aria-hidden /> : null}
</button>
<button
type="button"
className="foot-pill"
onClick={() => onOpenSettings()}
aria-label={t('settings.envConfigure')}
title={t('settings.envConfigure')}
>
<Icon name="settings" size={12} />
<span>
{config.mode === 'daemon'
? t('settings.localCli')
: apiProtocolLabel(config.apiProtocol)}
</span>
<span style={{ color: 'var(--text-faint)' }}>·</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>
{envMetaLine}
</span>
</button>
<a
className="foot-pill"
href="https://x.com/nexudotio"
target="_blank"
rel="noreferrer noopener"
title="Follow @nexudotio on X for releases and milestones"
aria-label="Follow @nexudotio on X"
>
<Icon name="external-link" size={12} />
<span>Follow @nexudotio</span>
</a>
<LanguageMenu />
</div>
<button
type="button"
aria-label={t('entry.resizeAria')}
className={`entry-side-resizer${resizing ? ' dragging' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
startWidthRef.current = sidebarWidth;
startXRef.current = e.clientX;
setResizing(true);
}}
/>
</aside>
<main className="entry-main">
<div className="entry-header">
<div className="entry-tabs" role="tablist">
<TopTabButton current={topTab} value="designs" label={t('entry.tabDesigns')} onClick={setTopTab} />
<TopTabButton current={topTab} value="examples" label={t('entry.tabExamples')} onClick={setTopTab} />
<TopTabButton
current={topTab}
value="design-systems"
label={t('entry.tabDesignSystems')}
onClick={setTopTab}
/>
<TopTabButton
current={topTab}
value="image-templates"
label={t('entry.tabImageTemplates')}
onClick={setTopTab}
/>
<TopTabButton
current={topTab}
value="video-templates"
label={t('entry.tabVideoTemplates')}
onClick={setTopTab}
/>
</div>
</div>
<div className="entry-tab-content">
{loading ? (
<CenteredLoader label={t('entry.loadingWorkspace')} />
) : (
<>
{topTab === 'designs' ? (
<DesignsTab
projects={projects}
skills={skills}
designSystems={designSystems}
onOpen={onOpenProject}
onOpenLiveArtifact={onOpenLiveArtifact}
onDelete={onDeleteProject}
/>
) : null}
{topTab === 'examples' ? (
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
) : null}
{topTab === 'design-systems' ? (
<DesignSystemsTab
systems={designSystems}
selectedId={defaultDesignSystemId}
onSelect={onChangeDefaultDesignSystem}
onPreview={previewDesignSystem}
/>
) : null}
{topTab === 'image-templates' ? (
<PromptTemplatesTab
surface="image"
templates={promptTemplates}
onPreview={setPreviewPromptTemplate}
/>
) : null}
{topTab === 'video-templates' ? (
<PromptTemplatesTab
surface="video"
templates={promptTemplates}
onPreview={setPreviewPromptTemplate}
/>
) : null}
</>
)}
</div>
</main>
{petRailHidden ? null : (
<PetRail
config={config}
onAdoptInline={onAdoptPetInline}
onOpenPetSettings={onAdoptPet}
onTuck={onTogglePet}
onHide={() => setPetRailHidden(true)}
/>
)}
</div>
{previewSystem ? (
<DesignSystemPreviewModal
system={previewSystem}
onClose={() => setPreviewSystemId(null)}
/>
) : null}
{previewPromptTemplate ? (
<PromptTemplatePreviewModal
summary={previewPromptTemplate}
onClose={() => setPreviewPromptTemplate(null)}
/>
) : null}
</div>
);
}
function TopTabButton({
current,
value,
label,
onClick,
}: {
current: TopTab;
value: TopTab;
label: string;
onClick: (v: TopTab) => void;
}) {
return (
<button
role="tab"
data-testid={`entry-tab-${value}`}
aria-selected={current === value}
className={`entry-tab ${current === value ? 'active' : ''}`}
onClick={() => onClick(value)}
>
{label}
</button>
);
}
// 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.
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' };
}
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';
}