open-design/apps/web/src/App.tsx
BayesWang af4a62b69a
Add configurable project locations (#2041)
* 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
2026-05-31 04:47:45 +00:00

1669 lines
63 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { flushSync } from 'react-dom';
import { useAnalytics } from './analytics/provider';
import {
trackFileUploadResult,
trackProjectCreateResult,
} from './analytics/events';
import { deriveUploadCohort } from './analytics/upload-tracking';
import { detectClientType } from './analytics/identity';
import {
deriveConfigureGlobals,
projectKindToTracking,
fidelityToTracking,
} from '@open-design/contracts/analytics';
import { EntryView } from './components/EntryView';
import type { IntegrationTab } from './components/IntegrationsView';
import { MarketplaceView } from './components/MarketplaceView';
import { PluginDetailView } from './components/PluginDetailView';
import type { CreateInput, ImportClaudeDesignOutcome } from './components/NewProjectPanel';
import { MemoryToast } from './components/MemoryToast';
import { PetOverlay, type PetTaskCenter } from './components/pet/PetOverlay';
import { buildPetTaskCenter } from './components/pet/taskCenter';
import { migrateCustomPetAtlas } from './components/pet/pets';
import { ProjectView } from './components/ProjectView';
import { openWorkspaceTab, WorkspaceTabsBar } from './components/WorkspaceTabsBar';
import {
DesignSystemCreationFlow,
DesignSystemDetailView,
} from './components/DesignSystemFlow';
import {
IframeKeepAliveProvider,
useIframeKeepAlivePool,
} from './components/IframeKeepAlivePool';
import {
SettingsDialog,
switchApiProtocolConfig,
updateCurrentApiProtocolConfig,
type SettingsSection,
type SettingsHighlight,
} from './components/SettingsDialog';
import { PrivacyConsentModal } from './components/PrivacyConsentModal';
import {
daemonIsLive,
fetchAppVersionInfo,
fetchAgents,
fetchDesignSystems,
fetchDesignTemplates,
fetchPromptTemplates,
fetchSkills,
uploadProjectFiles,
} from './providers/registry';
import { RUNS_CHANGED_EVENT, listProjectRuns } from './providers/daemon';
import { navigate, useRoute } from './router';
import {
fetchDaemonConfig,
DEFAULT_PET,
fetchMediaProvidersFromDaemon,
hasAnyConfiguredProvider,
fetchComposioConfigFromDaemon,
loadConfig,
mergeDaemonConfig,
mergeDaemonMediaProviders,
saveConfig,
shouldSyncLocalMediaProvidersToDaemon,
syncComposioConfigToDaemon,
syncConfigToDaemon,
syncMediaProvidersToDaemon,
} from './state/config';
import { applyAppearanceToDocument } from './state/appearance';
import { isMacPlatform } from './utils/platform';
import {
createProject,
createPluginShareProject,
deleteProject as deleteProjectApi,
getProject,
importClaudeDesignZip,
importFolderProject,
listProjects,
listTemplates,
deleteTemplate,
patchProject,
} from './state/projects';
import type {
PluginShareAction,
PluginShareProjectOutcome,
} from './state/projects';
import type { OpenDesignHostProjectImportSuccess } from '@open-design/host';
import { useI18n } from './i18n';
import { liveArtifactTabId } from './types';
import type {
AgentInfo,
ApiProtocol,
AppConfig,
AppVersionInfo,
ChatAttachment,
DesignSystemSummary,
Project,
ProjectTemplate,
ProviderModelOption,
PromptTemplateSummary,
SkillSummary,
} from './types';
export function shouldSyncMediaProvidersOnSave(
mediaProviders: AppConfig['mediaProviders'],
options?: { force?: boolean },
): boolean {
return Boolean(options?.force) || hasAnyConfiguredProvider(mediaProviders);
}
function normalizeSavedComposioConfig(config: AppConfig['composio']): AppConfig['composio'] {
const apiKey = config?.apiKey?.trim() ?? '';
if (apiKey) {
return {
...config,
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: apiKey.slice(-4),
};
}
return { ...(config ?? {}) };
}
export async function persistComposioConfigChange(
current: AppConfig,
composio: AppConfig['composio'],
sync: (config: AppConfig['composio']) => Promise<boolean> = syncComposioConfigToDaemon,
): Promise<AppConfig> {
const saved = await sync(composio);
if (!saved) throw new Error('Composio config save failed');
return {
...current,
composio: normalizeSavedComposioConfig(composio),
};
}
export function buildPersistedConfig(next: AppConfig, current: AppConfig): AppConfig {
return {
...next,
onboardingCompleted: current.onboardingCompleted ? true : next.onboardingCompleted,
composio: next.composio
? {
apiKey: '',
apiKeyConfigured: Boolean(next.composio.apiKeyConfigured),
apiKeyTail: next.composio.apiKeyTail ?? '',
}
: next.composio,
};
}
/**
* True when `next` and `last` produce an identical persisted shape —
* i.e. the only diffs between them are fields that buildPersistedConfig
* intentionally strips before disk/daemon writes (the Composio API key
* draft today; any future save-on-explicit-confirm secrets later).
*
* The autosave loop in Settings uses this to skip the "All changes
* saved" indicator transition when the user has only typed an unsaved
* secret. Without it, autosave completes a no-op write and flashes
* "Saved" — misleading users into trusting that a sensitive key has
* been persisted when in fact only the section-local "Save key"
* gesture commits it.
*/
export function isAutosaveDraftOnlyChange(next: AppConfig, last: AppConfig): boolean {
return (
JSON.stringify(buildPersistedConfig(next, next))
=== JSON.stringify(buildPersistedConfig(last, last))
);
}
export function resolveSettingsCloseConfig(
rendered: AppConfig,
latestPersisted: AppConfig,
): AppConfig {
const base = latestPersisted === rendered ? rendered : latestPersisted;
return base.onboardingCompleted ? base : { ...base, onboardingCompleted: true };
}
export function App() {
return (
<IframeKeepAliveProvider>
<AppInner />
</IframeKeepAliveProvider>
);
}
function AppInner() {
const { t } = useI18n();
const iframeKeepAlivePool = useIframeKeepAlivePool();
const clientType = useMemo(() => detectClientType(), []);
// Observability marker. `apps/web/src/observability/white-screen.ts`
// keys its "app actually mounted" success condition on this attribute
// because the dynamic-import loading shell (`<div class="od-loading-shell">
// Loading Open Design…</div>`) is itself >MIN_VISIBLE_TEXT and would
// otherwise be mistaken for a real mount. Survives subsequent render
// crashes — once App has mounted at least once, it's no longer a white
// screen (subsequent failures show up as `$exception`).
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-od-app-mounted', '1');
}
}, []);
const [config, setConfig] = useState<AppConfig>(() => loadConfig());
const configRef = useRef(config);
configRef.current = config;
const latestPersistedConfigRef = useRef(config);
latestPersistedConfigRef.current = config;
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsWelcome, setSettingsWelcome] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSection>('execution');
const [settingsHighlight, setSettingsHighlight] = useState<SettingsHighlight>(null);
const [integrationInitialTab, setIntegrationInitialTab] = useState<IntegrationTab>('mcp');
const [daemonLive, setDaemonLive] = useState(false);
const [agents, setAgents] = useState<AgentInfo[]>([]);
// Functional skills (capabilities the agent invokes mid-task) — stays
// small and lives under the Settings → Skills surface.
const [skills, setSkills] = useState<SkillSummary[]>([]);
// Design templates (rendering catalogue: decks, prototypes, image/video/
// audio templates) — sourced from /api/design-templates and shown in the
// EntryView Templates tab. See specs/current/skills-and-design-templates.md.
const [designTemplates, setDesignTemplates] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [petTaskCenter, setPetTaskCenter] = useState<PetTaskCenter>({
running: [],
queued: [],
recent: [],
});
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
const [promptTemplates, setPromptTemplates] = useState<
PromptTemplateSummary[]
>([]);
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(
null,
);
const [providerModelsCache, setProviderModelsCache] = useState<
Record<string, ProviderModelOption[]>
>({});
const [daemonMediaProviders, setDaemonMediaProviders] = useState<
AppConfig['mediaProviders'] | null
>(null);
const [daemonMediaProvidersFetchState, setDaemonMediaProvidersFetchState] = useState<
'idle' | 'ok' | 'error'
>('idle');
const [mediaProvidersNotice, setMediaProvidersNotice] = useState<string | null>(null);
// Per-resource loading flags. Each goes false the moment its own fetch
// resolves so each entry-view tab can render as its data lands instead of
// every tab waiting on the slowest endpoint (typically `/api/agents`,
// which probes CLI versions and can take seconds on cold start). The entry
// view picks the right flag for whichever tab the user is currently on.
const [agentsLoading, setAgentsLoading] = useState(true);
const [skillsLoading, setSkillsLoading] = useState(true);
const [dsLoading, setDsLoading] = useState(true);
const [projectsLoading, setProjectsLoading] = useState(true);
const [promptTemplatesLoading, setPromptTemplatesLoading] = useState(true);
// Goes true once the daemon-persisted config (agentId/designSystemId/etc.)
// has merged into local state. Auto-selection effects below wait on this
// so they don't race ahead of the daemon-stored choice and overwrite it
// with a freshly picked first-available agent.
const [daemonConfigLoaded, setDaemonConfigLoaded] = useState(false);
// Narrower flag dedicated to the Composio API key hydration. The key is
// persisted by the daemon (and only reflected back via apiKeyConfigured
// + apiKeyTail), so after a dev-server restart there is a window where
// the dialog can render an empty Composio input even though a saved key
// exists. Settings → Connectors uses this to render a skeleton over the
// input + buttons instead of an empty input that the user might
// mistake for "no key saved" — and to disable Save/Clear so a misclick
// can't overwrite the saved state with `''` before hydration lands.
const [composioConfigLoading, setComposioConfigLoading] = useState(true);
const route = useRoute();
const analytics = useAnalytics();
// v2 schema removed the standalone `app_launch` event; the initial
// page_view fires from each top-level page surface (home / projects /
// automations / plugins / design_systems / integrations) instead.
// `detectClientType` still feeds analytics identity via the provider.
void detectClientType;
// Propagate the Privacy toggle through to PostHog without a reload —
// posthog-js's opt_out_capturing flips a localStorage flag that makes
// every subsequent capture() a no-op. When the user opts back in we
// call opt_in_capturing to resume.
useEffect(() => {
analytics.setConsent(config.telemetry?.metrics === true);
}, [analytics.setConsent, config.telemetry?.metrics]);
// Sync PostHog's distinct_id with the anonymous installationId, both on
// first opt-in (when the daemon stamps a fresh id) and on Delete-my-data
// rotation (when PrivacySection.tsx generates a new one). posthog-js
// caches the previous id in localStorage; identify() alone would stitch
// the two ids together, so applyIdentity() does reset() first to
// guarantee the new session is fully decoupled from the deleted one.
useEffect(() => {
if (config.telemetry?.metrics !== true) return;
analytics.setIdentity(config.installationId ?? null);
}, [analytics.setIdentity, config.installationId, config.telemetry?.metrics]);
// v2 analytics requires every event to carry the configure-state
// triplet (has_available_configure_cli / configure_type /
// configure_availability). We push it into the PostHog global register
// whenever the user's execution-mode config or the detected agent list
// changes; the next capture inherits the fresh values, so dashboards
// can segment by execution setup without per-helper boilerplate.
//
// Gated on `agentsLoading` so the cold-start probe (`fetchAgents()`
// lands asynchronously after this effect's first run) does not stamp
// the first home/projects/plugins page_view with
// has_available_configure_cli=false / configure_availability=unavailable
// on machines that DO have an installed CLI. While the probe is in
// flight we leave the boot defaults ('unknown'/'unknown') in place,
// matching what the helper would return for an empty agent list with
// no mode pinned.
useEffect(() => {
if (agentsLoading) return;
const byokConfigured = (() => {
const protocols = config.apiProtocolConfigs;
if (!protocols) return Boolean(config.apiKey?.trim());
return Object.values(protocols).some(
(cfg) => Boolean(cfg?.apiKey?.trim()),
);
})();
const globals = deriveConfigureGlobals({
mode: config.mode,
agentId: config.agentId,
agents: agents.map((a) => ({ id: a.id, available: a.available })),
byokConfigured,
});
analytics.setConfigureGlobals(globals);
}, [
analytics.setConfigureGlobals,
agentsLoading,
config.mode,
config.agentId,
config.apiKey,
config.apiProtocolConfigs,
agents,
]);
// Sync theme preference to the <html> element so CSS variables pick it up.
// useLayoutEffect (vs useEffect) fires before the browser paints, so a
// live theme switch in Settings applies atomically — no 1-frame flash of
// the old theme. Safe here because the component tree is ssr:false.
useLayoutEffect(() => {
applyAppearanceToDocument({
theme: config.theme ?? 'system',
accentColor: config.accentColor,
});
}, [config.theme, config.accentColor]);
// Tell the daemon what the user is currently looking at, so the MCP
// server can surface it as `get_active_context` to a coding agent in
// another repo. Best-effort fire-and-forget; the daemon holds it in
// memory with a short TTL and the MCP layer falls back to
// {active:false} if this hasn't run.
const activeProjectId = route.kind === 'project' ? route.projectId : null;
const activeFileName = route.kind === 'project' ? route.fileName : null;
// Gate the privacy banner on three things:
// 1. Daemon config has hydrated (privacyDecisionAt is daemon-owned).
// 2. The user has not yet made a privacy decision.
// 3. Onboarding is complete (Skip and design-system creation both flip
// onboardingCompleted to true; see handleCompleteOnboarding wiring).
// Once onboarding is done the banner is allowed on any route — including
// the project view the design-system finish path drops the user into, so
// they can read and acknowledge the disclosure while the first generation
// is running. Settings is irrelevant to visibility; the banner sits above
// the modal-backdrop layer in index.css so opening Settings does not hide
// it.
const showPrivacyConsent =
daemonConfigLoaded &&
config.privacyDecisionAt == null &&
config.onboardingCompleted === true;
useEffect(() => {
const body = activeProjectId
? { projectId: activeProjectId, fileName: activeFileName }
: { active: false };
fetch('/api/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {
// Daemon down or transient network — not worth surfacing.
});
}, [activeProjectId, activeFileName]);
// Bootstrap — detect daemon, then fan out independent fetches so each
// entry-view tab can render the moment its own data lands. Earlier this
// was one Promise.all behind a global "Loading workspace…" placeholder,
// which made the slowest endpoint (typically `/api/agents` on cold start)
// gate every tab including the ones that don't need agents at all.
useEffect(() => {
let cancelled = false;
(async () => {
const alive = await daemonIsLive();
if (cancelled) return;
setDaemonLive(alive);
if (!alive) {
// No daemon — clear every loading flag so empty states render
// instead of the entry view sitting on indefinite spinners.
setAgentsLoading(false);
setSkillsLoading(false);
setDsLoading(false);
setProjectsLoading(false);
setPromptTemplatesLoading(false);
setDaemonConfigLoaded(true);
// Composio hydration also depends on the daemon. With no daemon
// we just keep whatever localStorage already held; drop the
// skeleton so the Settings → Connectors input reflects state.
setComposioConfigLoading(false);
return;
}
void fetchAgents().then((list) => {
if (cancelled) return;
setAgents(list);
setAgentsLoading(false);
});
// Functional skills + design templates land independently. Both
// gate `skillsLoading` together so the EntryView stops rendering
// its loader once both registries respond — neither tab would have
// a complete picture if we cleared the flag on the first reply.
let functionalReady = false;
let templatesReady = false;
const maybeClearLoading = () => {
if (functionalReady && templatesReady) setSkillsLoading(false);
};
void fetchSkills().then((list) => {
if (cancelled) return;
setSkills(list);
functionalReady = true;
maybeClearLoading();
});
void fetchDesignTemplates().then((list) => {
if (cancelled) return;
setDesignTemplates(list);
templatesReady = true;
maybeClearLoading();
});
void fetchDesignSystems().then((list) => {
if (cancelled) return;
setDesignSystems(list);
setDsLoading(false);
});
void listProjects().then((list) => {
if (cancelled) return;
setProjects(list);
setProjectsLoading(false);
});
void listTemplates().then((list) => {
if (cancelled) return;
setTemplates(list);
});
void fetchPromptTemplates().then((list) => {
if (cancelled) return;
setPromptTemplates(list);
setPromptTemplatesLoading(false);
});
void fetchAppVersionInfo().then((info) => {
if (cancelled) return;
setAppVersionInfo(info);
});
// Daemon-persisted config + composio config + media provider config land
// together so the welcome-modal decision and daemon-backed settings
// apply in one merge, avoiding a flash where local-only state is shown
// before daemon overrides it.
void Promise.all([
fetchDaemonConfig(),
fetchComposioConfigFromDaemon(),
fetchMediaProvidersFromDaemon(),
]).then(([
daemonConfig,
daemonComposioConfig,
daemonMediaProvidersResult,
]) => {
if (cancelled) return;
const daemonMediaProvidersLoaded =
daemonMediaProvidersResult.status === 'ok'
? daemonMediaProvidersResult.providers
: null;
setDaemonMediaProviders(daemonMediaProvidersLoaded);
setDaemonMediaProvidersFetchState(daemonMediaProvidersResult.status);
setMediaProvidersNotice(
daemonMediaProvidersResult.status === 'error'
? t('settings.mediaProviderLoadError')
: null,
);
// Compute the next config outside the setConfig updater so we can
// both (a) call navigate() after setConfig returns — calling it
// inside the updater would trigger a Router setState during React's
// render phase — and (b) read next.onboardingCompleted synchronously,
// since React batches setConfig and the updater doesn't run until
// the next render. latestPersistedConfigRef is kept in sync with
// the rendered config and is safe to read here.
const baseConfig = latestPersistedConfigRef.current;
const migratedLocalMediaProviders = shouldSyncLocalMediaProvidersToDaemon(
baseConfig.mediaProviders,
daemonMediaProvidersLoaded,
);
const next = mergeDaemonMediaProviders(
mergeDaemonConfig(baseConfig, daemonConfig),
daemonMediaProvidersLoaded,
);
const hasLocalComposioKey = Boolean(next.composio?.apiKey?.trim());
if (!hasLocalComposioKey && daemonComposioConfig) {
next.composio = daemonComposioConfig;
}
saveConfig(next);
if (
daemonMediaProvidersResult.status === 'ok' &&
migratedLocalMediaProviders &&
hasAnyConfiguredProvider(next.mediaProviders)
) {
void syncMediaProvidersToDaemon(next.mediaProviders, {
daemonProviders: daemonMediaProvidersLoaded,
});
}
// Migrate localStorage prefs to daemon on first boot with the new
// endpoint. If daemon already had values the merge above used them;
// writing back is idempotent and keeps both sides in sync.
void syncConfigToDaemon(next);
void syncComposioConfigToDaemon(next.composio);
latestPersistedConfigRef.current = next;
setConfig(next);
// Route first-run users through the global onboarding panel.
// The onboarding panel and the privacy banner have independent
// lifecycles: onboarding keys off `onboardingCompleted`, the
// banner keys off `privacyDecisionAt`. They may coexist on the
// first launch; the banner sits above the modal layer so it
// stays actionable regardless of the active view.
if (!next.onboardingCompleted) {
navigate({ kind: 'home', view: 'onboarding' }, { replace: true });
}
setDaemonConfigLoaded(true);
// Composio key hydration is part of this same daemon-config
// fetch — by the time we land here the daemon has either
// returned the saved-key shape (apiKeyConfigured + tail) or
// it errored and we kept whatever localStorage held. Either
// way it is safe to drop the skeleton.
setComposioConfigLoading(false);
});
})();
return () => {
cancelled = true;
};
}, []);
// Auto-pick the first available agent once both the daemon-stored config
// and the agents listing have landed. Splitting this out of bootstrap
// avoids racing the local-config initial value against a slow agents
// probe — by the time this runs, daemonConfig has already overlaid the
// user's previous choice, so we only fill an empty slot.
useEffect(() => {
if (!daemonConfigLoaded || agentsLoading) return;
if (config.agentId) return;
const firstAvailable = agents.find((a) => a.available);
if (!firstAvailable) return;
setConfig((prev) => {
if (prev.agentId) return prev;
const next: AppConfig = { ...prev, agentId: firstAvailable.id };
saveConfig(next);
void syncConfigToDaemon(next);
return next;
});
}, [daemonConfigLoaded, agentsLoading, agents, config.agentId]);
// Auto-pick the default design system the same way — only after daemon
// config has merged so we never overwrite a daemon-stored selection.
useEffect(() => {
if (!daemonConfigLoaded || dsLoading) return;
if (config.designSystemId) return;
if (designSystems.length === 0) return;
const id =
designSystems.find((d) => d.id === 'default')?.id ?? designSystems[0]!.id;
setConfig((prev) => {
if (prev.designSystemId) return prev;
const next: AppConfig = { ...prev, designSystemId: id };
saveConfig(next);
void syncConfigToDaemon(next);
return next;
});
}, [daemonConfigLoaded, dsLoading, designSystems, config.designSystemId]);
// One-shot self-healing migration for pets adopted before the
// overlay learned atlas-row switching. If the stored pet is a
// custom / codex pet whose imageUrl is a single-row strip
// (no atlas), we silently re-download the full spritesheet so
// hover, drag, and idle-ambient variety all light up on next render.
useEffect(() => {
let cancelled = false;
void (async () => {
const upgraded = await migrateCustomPetAtlas(config);
if (!upgraded || cancelled) return;
setConfig((prev) => {
if (!prev.pet) return prev;
const next: AppConfig = {
...prev,
pet: { ...prev.pet, custom: upgraded },
};
saveConfig(next);
return next;
});
})();
return () => {
cancelled = true;
};
// Snapshot the config at mount; migration is one-shot per session
// and should not re-run every time config changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const refreshProjects = useCallback(async () => {
const list = await listProjects();
setProjects(list);
}, []);
const refreshDesignSystems = useCallback(async () => {
const list = await fetchDesignSystems();
setDesignSystems(list);
}, []);
const refreshSkills = useCallback(async () => {
const list = await fetchSkills();
setSkills(list);
}, []);
const refreshTemplates = useCallback(async () => {
const list = await listTemplates();
setTemplates(list);
}, []);
const handleDeleteTemplate = useCallback(async (id: string) => {
const ok = await deleteTemplate(id);
if (ok) await refreshTemplates();
return ok;
}, [refreshTemplates]);
const reloadMediaProvidersFromDaemon = useCallback(async () => {
const result = await fetchMediaProvidersFromDaemon();
if (result.status !== 'ok') {
setDaemonMediaProvidersFetchState('error');
setMediaProvidersNotice(
t('settings.mediaProviderLoadError'),
);
return null;
}
setDaemonMediaProviders(result.providers);
setDaemonMediaProvidersFetchState('ok');
setMediaProvidersNotice(null);
setConfig((prev) => {
const merged = mergeDaemonMediaProviders(prev, result.providers);
saveConfig(merged);
return merged;
});
return result.providers;
}, []);
/**
* Autosave-driven persistence path. The settings dialog calls this on
* every committed edit (via a debounced effect) so localStorage and
* the daemon stay in lock-step with the user's draft. We deliberately
* do NOT touch the Composio secret here — it has its own gesture
* (handleConfigPersistComposioKey) so partial keys never leave the
* browser. Onboarding is also left alone; the dialog's close path
* is the canonical "I'm done" signal.
*/
const handleConfigPersist = useCallback(async (
next: AppConfig,
options?: { forceMediaProviderSync?: boolean },
) => {
// Strip the in-flight Composio secret before anything hits disk so
// a half-typed key can't survive in localStorage. If the dialog is
// closing, preserve any onboarding completion that the close gesture
// already committed so an unmount autosave cannot re-open the welcome flow.
const persisted = buildPersistedConfig(next, configRef.current);
latestPersistedConfigRef.current = persisted;
saveConfig(persisted);
setConfig(persisted);
const shouldSyncMediaProviders =
daemonMediaProvidersFetchState === 'ok'
&& shouldSyncMediaProvidersOnSave(persisted.mediaProviders, {
force: options?.forceMediaProviderSync,
});
await Promise.all([
shouldSyncMediaProviders
? syncMediaProvidersToDaemon(persisted.mediaProviders, {
force: options?.forceMediaProviderSync,
daemonProviders: daemonMediaProviders,
throwOnError: options?.forceMediaProviderSync,
})
: Promise.resolve(),
syncConfigToDaemon(persisted),
]);
}, [daemonMediaProviders, daemonMediaProvidersFetchState]);
/**
* Explicit Composio API-key save. Called from the section-local
* "Save key" button so secrets never ride the autosave keystroke
* loop. Once the daemon confirms, we normalize the saved config
* (strip the secret, store apiKeyConfigured + apiKeyTail) and feed
* it back into local state so the saved-key badge appears.
*/
const handleConfigPersistComposioKey = useCallback(
async (composio: AppConfig['composio']) => {
const next = await persistComposioConfigChange(config, composio);
setConfig((curr) => {
const merged: AppConfig = { ...curr, composio: next.composio };
saveConfig(merged);
return merged;
});
},
[config],
);
const handleModeChange = useCallback(
(mode: AppConfig['mode']) => {
const next = { ...config, mode };
saveConfig(next);
setConfig(next);
},
[config],
);
// Quick theme switch from the settings dropdown in the entry view.
// Skips the full SettingsDialog round-trip so the appearance flip
// feels instantaneous; the live preview comes for free because the
// `useLayoutEffect` above re-runs `applyAppearanceToDocument` the
// moment `config.theme` changes. We still persist to localStorage
// and the daemon so the choice survives reloads.
const handleThemeChange = useCallback(
(theme: AppConfig['theme']) => {
const next = { ...config, theme };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
);
const handleAgentChange = useCallback(
(agentId: string) => {
const next = { ...config, agentId };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
);
const handleAgentModelChange = useCallback(
(agentId: string, choice: { model?: string; reasoning?: string }) => {
const prev = config.agentModels?.[agentId] ?? {};
const merged = { ...prev, ...choice };
const nextAgentModels = {
...(config.agentModels ?? {}),
[agentId]: merged,
};
const next = { ...config, agentModels: nextAgentModels };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
);
// BYOK protocol switch — also flips `mode` to 'api' so the user does
// not have to take a second step after picking a provider from the
// inline switcher. The helper preserves any per-protocol fields the
// user had previously configured for the target protocol.
const handleApiProtocolChange = useCallback(
(protocol: ApiProtocol) => {
const next = switchApiProtocolConfig(config, protocol);
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
);
// BYOK model picker — patches `model` (and the per-protocol shadow
// copy) without touching apiKey/baseUrl so the user can swap models
// mid-session without retyping their key.
const handleApiModelChange = useCallback(
(model: string) => {
const next = updateCurrentApiProtocolConfig(config, { model });
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
);
const handleChangeDefaultDesignSystem = useCallback(
(designSystemId: string | null) => {
const next = { ...config, designSystemId };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
);
const refreshAgents = useCallback(
async (options?: { throwOnError?: boolean; agentCliEnv?: AppConfig['agentCliEnv'] }) => {
if (options && Object.prototype.hasOwnProperty.call(options, 'agentCliEnv')) {
const nextConfig = { ...config, agentCliEnv: options.agentCliEnv ?? {} };
saveConfig(nextConfig);
await syncConfigToDaemon(nextConfig);
setConfig(nextConfig);
}
const next = await fetchAgents({ throwOnError: options?.throwOnError });
setAgents(next);
return next;
},
[config],
);
const handleCreateProject = useCallback(
async (
input: CreateInput & {
pendingPrompt?: string;
pluginId?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
autoSendFirstMessage?: boolean;
requestId?: string;
pendingFiles?: File[];
},
): Promise<boolean> => {
// Honor an explicit `null` design system — the create panel defaults
// to "None" for every kind now, and the user expects that to land
// as a no-design-system project rather than silently inheriting the
// workspace default.
const derivedPendingPrompt =
input.pendingPrompt ??
(input.metadata?.promptTemplate?.prompt?.trim() || undefined);
const kind = input.metadata?.kind ?? null;
const fidelity = fidelityToTracking(input.metadata?.fidelity ?? null);
const creationSource: 'blank' | 'template' | 'zip' | 'folder' =
kind === 'template' ? 'template' : 'blank';
const result = await createProject({
name: input.name,
skillId: input.skillId,
designSystemId: input.designSystemId,
pendingPrompt: derivedPendingPrompt,
metadata: input.metadata,
...(input.pluginId ? { pluginId: input.pluginId } : {}),
...(input.appliedPluginSnapshotId
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }
: {}),
...(input.pluginInputs ? { pluginInputs: input.pluginInputs } : {}),
});
if (!result) {
trackProjectCreateResult(
analytics.track,
{
page_name: 'home',
area: 'new_project',
project_source: 'create_button',
project_id: null,
project_kind: projectKindToTracking(kind),
fidelity,
result: 'failed',
error_code: 'CREATE_REQUEST_FAILED',
},
{ requestId: input.requestId },
);
return false;
}
const pendingFiles = Array.isArray(input.pendingFiles)
? input.pendingFiles.filter((file): file is File => file instanceof File)
: [];
let firstMessageAttachments: ChatAttachment[] = [];
if (pendingFiles.length > 0) {
// Home composer attaches stay client-side until submit lands a
// project; the actual upload happens here. v2 doc wants one
// file_upload_result per surface — `page_name='home'` /
// `area='chat_composer'` so it's distinguishable from the
// file_manager Upload button and the chat_panel composer.
const cohort = deriveUploadCohort(pendingFiles);
const uploadResult = await uploadProjectFiles(result.project.id, pendingFiles);
firstMessageAttachments = uploadResult.uploaded;
const partial = uploadResult.failed.length > 0;
if (partial) {
console.warn('Some Home attachments failed to upload', uploadResult.failed);
}
trackFileUploadResult(analytics.track, {
page_name: 'home',
area: 'chat_composer',
project_id: result.project.id,
...cohort,
result: partial ? 'failed' : 'success',
...(partial && uploadResult.error
? { error_code: uploadResult.error }
: {}),
});
}
trackProjectCreateResult(
analytics.track,
{
page_name: 'home',
area: 'new_project',
project_source: 'create_button',
project_id: result.project.id,
project_kind: projectKindToTracking(kind),
fidelity,
result: 'success',
},
{ requestId: input.requestId },
);
// PluginLoopHome flow: the user already typed (or accepted) the
// first message on Home. Mark this project so ProjectView fires
// sendMessage(pendingPrompt) once on mount instead of just
// pre-filling the composer. Scoped to sessionStorage so a page
// reload after the run has started does not refire.
if (
input.autoSendFirstMessage &&
(derivedPendingPrompt !== undefined || firstMessageAttachments.length > 0)
) {
try {
window.sessionStorage.setItem(
`od:auto-send-first:${result.project.id}`,
'1',
);
if (firstMessageAttachments.length > 0) {
window.sessionStorage.setItem(
`od:auto-send-attachments:${result.project.id}`,
JSON.stringify(firstMessageAttachments),
);
} else {
window.sessionStorage.removeItem(
`od:auto-send-attachments:${result.project.id}`,
);
}
} catch {
/* sessionStorage may be unavailable (e.g. SSR / private mode); fall
back to manual send. */
}
}
const project = result.appliedPluginSnapshotId
? {
...result.project,
appliedPluginSnapshotId: result.appliedPluginSnapshotId,
}
: result.project;
flushSync(() => {
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
});
const projectRoute = {
kind: 'project',
projectId: project.id,
fileName: null,
} as const;
openWorkspaceTab(projectRoute);
navigate(projectRoute);
return true;
},
[analytics.track],
);
const handleCreatePluginShareProject = useCallback(
async (
pluginId: string,
action: PluginShareAction,
locale?: string,
): Promise<PluginShareProjectOutcome> => {
const outcome = await createPluginShareProject(pluginId, action, locale);
if (!outcome.ok) return outcome;
try {
window.sessionStorage.setItem(
`od:auto-send-first:${outcome.project.id}`,
'1',
);
} catch {
// If sessionStorage is unavailable, the project still opens with
// the prepared prompt in the composer.
}
const project = outcome.appliedPluginSnapshotId
? {
...outcome.project,
appliedPluginSnapshotId: outcome.appliedPluginSnapshotId,
}
: outcome.project;
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
navigate({
kind: 'project',
projectId: project.id,
fileName: null,
});
return outcome;
},
[],
);
const handleImportClaudeDesign = useCallback(async (
file: File,
): Promise<ImportClaudeDesignOutcome> => {
try {
const result = await importClaudeDesignZip(file);
setProjects((curr) => [
result.project,
...curr.filter((p) => p.id !== result.project.id),
]);
navigate({
kind: 'project',
projectId: result.project.id,
fileName: result.entryFile,
});
return { ok: true };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : 'The ZIP could not be imported.',
};
}
}, []);
const handleImportFolder = useCallback(async (baseDir: string) => {
const result = await importFolderProject({ baseDir });
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
navigate({
kind: 'project',
projectId: result.project.id,
fileName: result.entryFile,
});
}, []);
// PR #974: on desktop, the host bridge owns the picker and import POST
// atomically. The renderer never sees the path, token, or daemon DTO;
// it receives host-owned project identifiers and refreshes project state
// through the normal daemon API.
const handleImportFolderResponse = useCallback(async (result: OpenDesignHostProjectImportSuccess) => {
const project = await getProject(result.projectId);
if (project != null) {
setProjects((curr) => [project, ...curr.filter((p) => p.id !== project.id)]);
} else {
const list = await listProjects();
setProjects(list);
}
navigate({
kind: 'project',
projectId: result.projectId,
fileName: result.entryFile,
});
}, []);
const handleOpenProject = useCallback((id: string) => {
navigate({ kind: 'project', projectId: id, fileName: null });
}, []);
useEffect(() => {
if (!config.pet?.enabled || !daemonLive) {
setPetTaskCenter({ running: [], queued: [], recent: [] });
return;
}
let cancelled = false;
const refresh = async () => {
const runs = await listProjectRuns();
if (cancelled) return;
setPetTaskCenter(buildPetTaskCenter(projects, runs));
};
const handleRunsChanged = () => {
void refresh();
};
void refresh();
window.addEventListener(RUNS_CHANGED_EVENT, handleRunsChanged);
const id = window.setInterval(refresh, 2000);
return () => {
cancelled = true;
window.removeEventListener(RUNS_CHANGED_EVENT, handleRunsChanged);
window.clearInterval(id);
};
}, [config.pet?.enabled, daemonLive, projects]);
const handleOpenLiveArtifact = useCallback((projectId: string, artifactId: string) => {
navigate({ kind: 'project', projectId, fileName: liveArtifactTabId(artifactId) });
}, []);
const handleDeleteProject = useCallback(async (id: string) => {
const ok = await deleteProjectApi(id);
if (!ok) return false;
iframeKeepAlivePool.evictProject(id, { includeActive: true });
setProjects((curr) => curr.filter((p) => p.id !== id));
if (route.kind === 'project' && route.projectId === id) {
navigate({ kind: 'home', view: 'home' });
}
return true;
}, [iframeKeepAlivePool, route]);
const handleRenameProject = useCallback(async (id: string, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
setProjects((curr) =>
curr.map((p) => (p.id === id ? { ...p, name: trimmed } : p)),
);
void patchProject(id, { name: trimmed });
}, []);
const handleBack = useCallback(() => {
navigate({ kind: 'home', view: 'home' });
}, []);
const handleClearPendingPrompt = useCallback(() => {
const projectId = route.kind === 'project' ? route.projectId : null;
if (!projectId) return;
setProjects((curr) =>
curr.map((p) =>
p.id === projectId ? { ...p, pendingPrompt: undefined } : p,
),
);
void patchProject(projectId, { pendingPrompt: null });
}, [route]);
const handleTouchProject = useCallback(() => {
const projectId = route.kind === 'project' ? route.projectId : null;
if (!projectId) return;
const updatedAt = Date.now();
setProjects((curr) =>
curr.map((p) => (p.id === projectId ? { ...p, updatedAt } : p)),
);
void patchProject(projectId, { updatedAt });
}, [route]);
const handleProjectChange = useCallback((updated: Project) => {
setProjects((curr) => {
const previous = curr.find((p) => p.id === updated.id);
if (
previous
&& (
previous.skillId !== updated.skillId
|| previous.designSystemId !== updated.designSystemId
|| previous.customInstructions !== updated.customInstructions
)
) {
iframeKeepAlivePool.evictProject(updated.id, { includeActive: true });
}
return curr.map((p) => (p.id === updated.id ? updated : p));
});
}, [iframeKeepAlivePool]);
// ProjectView's prompt-context signature derives from SkillSummary /
// DesignSystemSummary fields, so a body-only registry edit (same name,
// description, etc.) leaves every signature unchanged and the active
// preview keeps serving stale prompt context. Settings → Skills /
// Settings → Design Systems call back through these handlers after
// every successful mutation; we drop any pool entry whose project
// depends on the affected id — active or parked — so the next mount
// recomposes the system prompt with the new body. A ref tracks
// projects so the callback is stable across renders.
const projectsRef = useRef<Project[]>(projects);
useEffect(() => {
projectsRef.current = projects;
}, [projects]);
const handleSkillsChanged = useCallback(
(affectedSkillId?: string) => {
void fetchSkills().then((list) => setSkills(list));
void fetchDesignTemplates().then((list) => setDesignTemplates(list));
iframeKeepAlivePool.evictMatching(
(entry) => {
const proj = projectsRef.current.find((p) => p.id === entry.projectId);
if (!proj) return false;
if (affectedSkillId) return proj.skillId === affectedSkillId;
return proj.skillId != null;
},
{ includeActive: true },
);
},
[iframeKeepAlivePool],
);
const handleDesignSystemsChanged = useCallback(
(affectedDesignSystemId?: string) => {
void fetchDesignSystems().then((list) => setDesignSystems(list));
iframeKeepAlivePool.evictMatching(
(entry) => {
const proj = projectsRef.current.find((p) => p.id === entry.projectId);
if (!proj) return false;
if (affectedDesignSystemId) {
return proj.designSystemId === affectedDesignSystemId;
}
return proj.designSystemId != null;
},
{ includeActive: true },
);
},
[iframeKeepAlivePool],
);
const activeProject =
route.kind === 'project'
? (projects.find((p) => p.id === route.projectId) ?? null)
: null;
// Deep-linked route to a project we don't have yet (e.g. after a refresh
// that finishes after the project list comes back). Fetch it in the
// background so the view can render rather than bouncing to home.
useEffect(() => {
if (route.kind !== 'project') return;
if (activeProject) return;
if (!projects.length && !daemonLive) return;
if (projects.some((p) => p.id === route.projectId)) return;
let cancelled = false;
(async () => {
const project = await getProject(route.projectId);
if (cancelled) return;
if (project) {
setProjects((curr) => {
const existingIndex = curr.findIndex((candidate) => candidate.id === project.id);
if (existingIndex < 0) {
return [...curr, project];
}
return curr.map((candidate) => (candidate.id === project.id ? project : candidate));
});
return;
}
const list = await listProjects();
if (cancelled) return;
setProjects(list);
if (!list.find((p) => p.id === route.projectId)) {
navigate({ kind: 'home', view: 'home' }, { replace: true });
}
})();
return () => {
cancelled = true;
};
}, [route, activeProject, projects, daemonLive]);
const openSettings = useCallback((
section: SettingsSection = 'execution',
opts?: { highlight?: SettingsHighlight },
) => {
if (section === 'composio' || section === 'mcpClient' || section === 'integrations') {
setIntegrationInitialTab(
section === 'composio'
? 'connectors'
: section === 'mcpClient'
? 'mcp'
: 'use-everywhere',
);
navigate({ kind: 'home', view: 'integrations' });
return;
}
setSettingsWelcome(false);
setSettingsInitialSection(section);
setSettingsHighlight(opts?.highlight ?? null);
setSettingsOpen(true);
}, []);
// Entry point from the failed-run AMR nudge: open Settings on the execution
// section and flag the AMR agent card for a one-shot scroll-into-view +
// highlight (and a sign-in coachmark when not yet authorized).
const openAmrSettings = useCallback(() => {
openSettings('execution', { highlight: 'amr' });
}, [openSettings]);
const openPetSettings = useCallback(() => {
setSettingsWelcome(false);
setSettingsInitialSection('pet');
setSettingsOpen(true);
}, []);
const openMcpSettings = useCallback(() => {
setIntegrationInitialTab('mcp');
navigate({ kind: 'home', view: 'integrations' });
}, []);
const handleCompleteOnboarding = useCallback(() => {
const current = latestPersistedConfigRef.current;
if (current.onboardingCompleted) return;
const next: AppConfig = { ...current, onboardingCompleted: true };
latestPersistedConfigRef.current = next;
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
}, []);
// Cmd+, (mac) / Ctrl+, (win/linux) opens Settings. Capture phase so we
// beat the browser's default Preferences dialog. Platform-gated so
// meta/ctrl don't conflict across OS.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const primary = isMacPlatform() ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
if (primary && !e.shiftKey && !e.altKey && e.key === ',') {
if (e.isComposing) return;
e.preventDefault();
openSettings();
}
};
window.addEventListener('keydown', onKeyDown, { capture: true });
return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
}, [openSettings]);
// Explicit enabled toggle — true = wake, false = tuck. Persists to
// localStorage so the overlay state survives across reloads. We keep
// `adopted` untouched so the entry-view CTA does not regress to
// "adopt me" once the user has already chosen.
const handleSetPetEnabled = useCallback((enabled: boolean) => {
setConfig((curr) => {
const prev = curr.pet ?? DEFAULT_PET;
const next: AppConfig = { ...curr, pet: { ...prev, enabled } };
saveConfig(next);
return next;
});
}, []);
const handleTuckPet = useCallback(
() => handleSetPetEnabled(false),
[handleSetPetEnabled],
);
// Toggle wake/tuck — used by the pet rail and the composer button.
const handleTogglePet = useCallback(() => {
setConfig((curr) => {
const prev = curr.pet ?? DEFAULT_PET;
const next: AppConfig = {
...curr,
pet: { ...prev, enabled: !prev.enabled },
};
saveConfig(next);
return next;
});
}, []);
// Inline adopt — the right-hand pet rail and the composer's pet menu
// both call this to switch pets without bouncing the user into
// Settings. It always wakes the overlay so the change is visible.
const handleAdoptPet = useCallback((petId: string) => {
setConfig((curr) => {
const prev = curr.pet ?? DEFAULT_PET;
const next: AppConfig = {
...curr,
pet: { ...prev, adopted: true, enabled: true, petId },
};
saveConfig(next);
return next;
});
}, []);
// When the user lands on the entry view (route.kind === 'home'), pull
// a fresh template list. The template store is global — if they just
// saved a template inside a project, returning home should reflect it
// immediately in the From-template tab without forcing a page reload.
useEffect(() => {
if (route.kind !== 'home') return;
void refreshTemplates();
}, [route.kind, refreshTemplates]);
// Existing card grids (DesignsTab, ProjectView), pickers (NewProjectPanel,
// ChatComposer mention) all look skills up by id without caring whether
// the id resolves to a functional skill or a design template. Pass them
// the union so the post-split refactor stays invisible to those callers.
const allSkillSummaries = useMemo(
() => [...skills, ...designTemplates],
[skills, designTemplates],
);
const enabledSkills = useMemo(
() =>
allSkillSummaries.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[allSkillSummaries, config.disabledSkills],
);
// Functional-skills-only enabled subset — what ProjectView's chat
// composer @-picker should see. Without this, a skill the user has
// disabled in Settings still appears in an existing project's @-mention
// popover and can ride along to the daemon via skillIds, breaking the
// Library toggle for projects opened on the post-split branch.
const enabledFunctionalSkills = useMemo(
() =>
skills.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[skills, config.disabledSkills],
);
// Templates-only enabled subset — what the EntryView Templates gallery
// actually renders. Filtering in App keeps the EntryView prop surface
// narrow ("here are the templates the user has not disabled").
const enabledDesignTemplates = useMemo(
() =>
designTemplates.filter(
(s) => !(config.disabledSkills ?? []).includes(s.id),
),
[designTemplates, config.disabledSkills],
);
const enabledDS = useMemo(
() =>
designSystems.filter(
(d) => !(config.disabledDesignSystems ?? []).includes(d.id),
),
[designSystems, config.disabledDesignSystems],
);
// Phase 2B / spec §11.6 — marketplace deep UI dispatch. The
// /marketplace and /marketplace/:id routes render outside the
// EntryView / ProjectView split so the discovery surface stays
// independent of any active project.
let appMain: ReactNode;
if (route.kind === 'marketplace') {
appMain = <MarketplaceView />;
} else if (route.kind === 'marketplace-detail') {
appMain = <PluginDetailView pluginId={route.pluginId} />;
} else if (route.kind === 'design-system-create') {
appMain = (
<DesignSystemCreationFlow
onBack={() => navigate({ kind: 'home', view: 'design-systems' })}
onCreated={(projectId, project) => {
if (project) {
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
}
navigate({ kind: 'project', projectId, conversationId: null, fileName: null });
}}
onProjectPrepared={(project) => {
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
}}
onSystemsRefresh={refreshDesignSystems}
config={config}
onOpenConnectorsTab={() => openSettings('composio')}
/>
);
} else if (route.kind === 'design-system-detail') {
appMain = (
<DesignSystemDetailView
id={route.designSystemId}
selectedId={config.designSystemId}
config={config}
agents={agents}
onBack={() => navigate({ kind: 'home', view: 'design-systems' })}
onOpenProject={(projectId) => navigate({ kind: 'project', projectId, conversationId: null, fileName: null })}
onSetDefault={handleChangeDefaultDesignSystem}
onSystemsRefresh={refreshDesignSystems}
onProjectsRefresh={refreshProjects}
/>
);
} else if (activeProject) {
appMain = (
<ProjectView
key={activeProject.id}
project={activeProject}
routeFileName={route.kind === 'project' ? route.fileName : null}
routeConversationId={route.kind === 'project' ? route.conversationId : null}
config={config}
agents={agents}
skills={enabledFunctionalSkills}
designTemplates={designTemplates}
designSystems={designSystems}
daemonLive={daemonLive}
onModeChange={handleModeChange}
onAgentChange={handleAgentChange}
onAgentModelChange={handleAgentModelChange}
onRefreshAgents={refreshAgents}
onOpenSettings={openSettings}
onOpenAmrSettings={openAmrSettings}
onOpenMcpSettings={openMcpSettings}
onAdoptPetInline={handleAdoptPet}
onTogglePet={handleTogglePet}
onOpenPetSettings={openPetSettings}
onBack={handleBack}
onClearPendingPrompt={handleClearPendingPrompt}
onTouchProject={handleTouchProject}
onProjectChange={handleProjectChange}
onProjectsRefresh={refreshProjects}
onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem}
onDesignSystemsRefresh={refreshDesignSystems}
/>
);
} else {
appMain = (
<EntryView
skills={enabledSkills}
designTemplates={enabledDesignTemplates}
designSystems={enabledDS}
projects={projects}
templates={templates}
onDeleteTemplate={handleDeleteTemplate}
promptTemplates={promptTemplates}
defaultDesignSystemId={config.designSystemId}
agents={agents}
config={config}
providerModelsCache={providerModelsCache}
onProviderModelsCacheChange={setProviderModelsCache}
integrationInitialTab={integrationInitialTab}
composioConfigLoading={composioConfigLoading}
daemonLive={daemonLive}
onModeChange={handleModeChange}
onAgentChange={handleAgentChange}
onAgentModelChange={handleAgentModelChange}
onApiProtocolChange={handleApiProtocolChange}
onApiModelChange={handleApiModelChange}
onConfigPersist={handleConfigPersist}
onRefreshAgents={refreshAgents}
onThemeChange={handleThemeChange}
skillsLoading={skillsLoading}
designSystemsLoading={dsLoading}
projectsLoading={projectsLoading}
promptTemplatesLoading={promptTemplatesLoading}
onCreateProject={handleCreateProject}
onCreatePluginShareProject={handleCreatePluginShareProject}
onImportClaudeDesign={handleImportClaudeDesign}
onImportFolder={handleImportFolder}
onImportFolderResponse={handleImportFolderResponse}
onOpenProject={handleOpenProject}
onOpenLiveArtifact={handleOpenLiveArtifact}
onDeleteProject={handleDeleteProject}
onRenameProject={handleRenameProject}
onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem}
onCreateDesignSystem={() => navigate({ kind: 'design-system-create' })}
renderDesignSystemCreation={(onBack, hooks) => (
<DesignSystemCreationFlow
chrome="embedded"
onBack={onBack}
onCreated={(projectId, project) => {
if (project) {
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
}
// Creating a design system from the onboarding step 2 panel
// counts as completing onboarding, even though the user is
// about to leave the entry shell for the project view. The
// Skip path already marks completion via finishOnboarding;
// mirror that here so the first-run privacy banner can
// surface when the user later returns to home.
handleCompleteOnboarding();
navigate({ kind: 'project', projectId, conversationId: null, fileName: null });
}}
onProjectPrepared={(project) => {
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
]);
}}
onSystemsRefresh={refreshDesignSystems}
config={config}
onOpenConnectorsTab={() => openSettings('composio')}
{...(hooks?.onBeforeGenerate ? { onBeforeGenerate: hooks.onBeforeGenerate } : {})}
{...(hooks?.onGenerateSettled ? { onGenerateSettled: hooks.onGenerateSettled } : {})}
/>
)}
onOpenDesignSystem={(id: string) => navigate({ kind: 'design-system-detail', designSystemId: id })}
onDesignSystemsRefresh={refreshDesignSystems}
onPersistComposioKey={handleConfigPersistComposioKey}
onOpenSettings={openSettings}
onCompleteOnboarding={handleCompleteOnboarding}
/>
);
}
return (
<>
<div
className={`workspace-shell workspace-shell--${clientType}`}
data-client-type={clientType}
>
<WorkspaceTabsBar
route={route}
projects={projects}
/>
<div className="workspace-shell__body">{appMain}</div>
</div>
{clientType === 'desktop' ? null : (
<PetOverlay
pet={config.pet?.enabled ? config.pet : undefined}
taskCenter={petTaskCenter}
onOpenProject={handleOpenProject}
/>
)}
{settingsOpen ? (
<SettingsDialog
initial={config}
agents={agents}
agentsLoading={agentsLoading}
daemonLive={daemonLive}
appVersionInfo={appVersionInfo}
welcome={settingsWelcome}
initialSection={settingsInitialSection}
initialHighlight={settingsHighlight}
composioConfigLoading={composioConfigLoading}
onPersist={handleConfigPersist}
onPersistComposioKey={handleConfigPersistComposioKey}
onClose={() => {
// Closing the dialog is the canonical "I'm done" gesture
// now that there is no global Save button. We mark
// onboardingCompleted on close so the welcome modal stops
// re-prompting on every refresh, regardless of whether
// the user changed anything during the session.
const next = resolveSettingsCloseConfig(config, latestPersistedConfigRef.current);
if (!next.onboardingCompleted || !config.onboardingCompleted) {
latestPersistedConfigRef.current = next;
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
}
setSettingsOpen(false);
setSettingsHighlight(null);
}}
onRefreshAgents={refreshAgents}
onSkillsRefresh={refreshSkills}
daemonMediaProviders={daemonMediaProviders}
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
mediaProvidersNotice={mediaProvidersNotice}
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
onProjectsRefresh={refreshProjects}
onSkillsChanged={handleSkillsChanged}
onDesignSystemsChanged={handleDesignSystemsChanged}
providerModelsCache={providerModelsCache}
onProviderModelsCacheChange={setProviderModelsCache}
/>
) : null}
<MemoryToast onOpenMemory={() => openSettings('memory')} />
{/* First-run privacy consent banner. It waits for daemon config
hydration because privacyDecisionAt is daemon-owned and stripped
from localStorage. It waits for `onboardingCompleted` so first-run
users see the welcome panel before the disclosure (Skip and
finish both flip the flag). Independent of Settings: z-index in
index.css sits above modal backdrops so opening Settings does
not hide the banner. */}
{showPrivacyConsent ? (
<PrivacyConsentModal
onAccept={() => {
// Default opt-in: clicking "I get it" enables the same telemetry
// surface the previous two-button "Share usage data" path opted
// into. The banner footer + PrivacySection give the user a
// one-click path to flip everything off later.
// The banner owns only the privacy decision; it does not drive
// navigation. Onboarding is gated by `onboardingCompleted` on
// its own and runs in parallel.
const installationId = generateInstallationIdSafe();
void handleConfigPersist({
...latestPersistedConfigRef.current,
installationId,
privacyDecisionAt: Date.now(),
telemetry: { metrics: true, content: true, artifactManifest: false },
});
}}
/>
) : null}
</>
);
}
function generateInstallationIdSafe(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `inst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}