Files
open-design/src/App.tsx
T
pftom f2d28a1cca feat: per-CLI model picker for local agents (closes #8)
Each agent CLI declares its selectable models (and reasoning effort, for
Codex) on the daemon side; the frontend renders a model dropdown in the
avatar menu and the Settings dialog scoped to the currently picked CLI,
persists the choice per-agent in the AppConfig, and threads it through
/api/chat to the spawn argv. "Default" leaves the flag off so the CLI's
own config wins.
2026-04-28 22:54:18 +08:00

338 lines
11 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { EntryView } from './components/EntryView';
import type { CreateInput } from './components/NewProjectPanel';
import { ProjectView } from './components/ProjectView';
import { SettingsDialog } from './components/SettingsDialog';
import {
daemonIsLive,
fetchAgents,
fetchDesignSystems,
fetchSkills,
} from './providers/registry';
import { navigate, useRoute } from './router';
import { loadConfig, saveConfig } from './state/config';
import {
createProject,
deleteProject as deleteProjectApi,
listProjects,
listTemplates,
patchProject,
} from './state/projects';
import type {
AgentInfo,
AppConfig,
DesignSystemSummary,
Project,
ProjectTemplate,
SkillSummary,
} from './types';
export function App() {
const [config, setConfig] = useState<AppConfig>(() => loadConfig());
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsWelcome, setSettingsWelcome] = useState(false);
const [daemonLive, setDaemonLive] = useState(false);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
// Goes false once the bootstrap effect has finished its initial round of
// fetches. The entry view uses this to show shimmer / skeleton states
// instead of an "empty" page that flickers before data lands.
const [bootstrapping, setBootstrapping] = useState(true);
const route = useRoute();
// Bootstrap — detect daemon, load pickers, seed sensible defaults.
useEffect(() => {
let cancelled = false;
(async () => {
const alive = await daemonIsLive();
if (cancelled) return;
setDaemonLive(alive);
const [agentList, skillList, dsList, projectList, templateList] =
await Promise.all([
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
alive
? fetchDesignSystems()
: Promise.resolve([] as DesignSystemSummary[]),
alive ? listProjects() : Promise.resolve([] as Project[]),
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
]);
if (cancelled) return;
setAgents(agentList);
setSkills(skillList);
setDesignSystems(dsList);
setProjects(projectList);
setTemplates(templateList);
setConfig((prev) => {
const next = { ...prev };
if (alive) {
if (!next.agentId) {
const firstAvailable = agentList.find((a) => a.available);
if (firstAvailable) next.agentId = firstAvailable.id;
}
if (!next.designSystemId && dsList.length > 0) {
next.designSystemId = dsList.find((d) => d.id === 'default')?.id
?? dsList[0]!.id;
}
} else {
next.mode = 'api';
}
saveConfig(next);
// Pop the onboarding modal only on the first run. Once the user has
// saved or skipped past it once, we trust their stored config and
// let them re-open Settings explicitly via the env pill.
if (!next.onboardingCompleted) {
setSettingsWelcome(true);
setSettingsOpen(true);
}
return next;
});
setBootstrapping(false);
})();
return () => {
cancelled = true;
};
}, []);
const refreshProjects = useCallback(async () => {
const list = await listProjects();
setProjects(list);
}, []);
const refreshTemplates = useCallback(async () => {
const list = await listTemplates();
setTemplates(list);
}, []);
const handleConfigSave = useCallback((next: AppConfig) => {
// Saving from any settings dialog (welcome or regular) counts as
// having completed onboarding — the user has actively chosen a
// configuration, so future page loads can skip the auto-popup.
const withOnboarding: AppConfig = { ...next, onboardingCompleted: true };
saveConfig(withOnboarding);
setConfig(withOnboarding);
setSettingsOpen(false);
}, []);
const handleModeChange = useCallback(
(mode: AppConfig['mode']) => {
const next = { ...config, mode };
saveConfig(next);
setConfig(next);
},
[config],
);
const handleAgentChange = useCallback(
(agentId: string) => {
const next = { ...config, agentId };
saveConfig(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);
setConfig(next);
},
[config],
);
const handleChangeDefaultDesignSystem = useCallback(
(designSystemId: string) => {
const next = { ...config, designSystemId };
saveConfig(next);
setConfig(next);
},
[config],
);
const refreshAgents = useCallback(async () => {
const next = await fetchAgents();
setAgents(next);
}, []);
const handleCreateProject = useCallback(
async (input: CreateInput & { pendingPrompt?: string }) => {
// 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 result = await createProject({
name: input.name,
skillId: input.skillId,
designSystemId: input.designSystemId,
pendingPrompt: input.pendingPrompt,
metadata: input.metadata,
});
if (!result) return;
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
navigate({ kind: 'project', projectId: result.project.id, fileName: null });
},
[],
);
const handleOpenProject = useCallback((id: string) => {
navigate({ kind: 'project', projectId: id, fileName: null });
}, []);
const handleDeleteProject = useCallback(async (id: string) => {
const ok = await deleteProjectApi(id);
if (!ok) return;
setProjects((curr) => curr.filter((p) => p.id !== id));
if (route.kind === 'project' && route.projectId === id) {
navigate({ kind: 'home' });
}
}, [route]);
const handleBack = useCallback(() => {
navigate({ kind: '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: undefined });
}, [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) =>
curr.map((p) => (p.id === updated.id ? updated : p)),
);
}, []);
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 list = await listProjects();
if (cancelled) return;
setProjects(list);
if (!list.find((p) => p.id === route.projectId)) {
navigate({ kind: 'home' }, { replace: true });
}
})();
return () => {
cancelled = true;
};
}, [route, activeProject, projects, daemonLive]);
const openSettings = useCallback(() => {
setSettingsWelcome(false);
setSettingsOpen(true);
}, []);
// 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]);
return (
<>
{activeProject ? (
<ProjectView
key={activeProject.id}
project={activeProject}
routeFileName={route.kind === 'project' ? route.fileName : null}
config={config}
agents={agents}
skills={skills}
designSystems={designSystems}
daemonLive={daemonLive}
onModeChange={handleModeChange}
onAgentChange={handleAgentChange}
onAgentModelChange={handleAgentModelChange}
onRefreshAgents={refreshAgents}
onOpenSettings={openSettings}
onBack={handleBack}
onClearPendingPrompt={handleClearPendingPrompt}
onTouchProject={handleTouchProject}
onProjectChange={handleProjectChange}
onProjectsRefresh={refreshProjects}
/>
) : (
<EntryView
skills={skills}
designSystems={designSystems}
projects={projects}
templates={templates}
defaultDesignSystemId={config.designSystemId}
config={config}
agents={agents}
loading={bootstrapping}
onCreateProject={handleCreateProject}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem}
onOpenSettings={openSettings}
/>
)}
{settingsOpen ? (
<SettingsDialog
initial={config}
agents={agents}
daemonLive={daemonLive}
welcome={settingsWelcome}
onSave={handleConfigSave}
onClose={() => {
// Dismissing the welcome modal (Skip for now / backdrop click)
// also counts as onboarding-done; we don't want to keep
// re-prompting on every refresh just because the user opted
// not to save.
if (settingsWelcome && !config.onboardingCompleted) {
const next: AppConfig = { ...config, onboardingCompleted: true };
saveConfig(next);
setConfig(next);
}
setSettingsOpen(false);
}}
onRefreshAgents={refreshAgents}
/>
) : null}
</>
);
}