From 9c8adb141533c74a34a146418d47f18b4e093bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 7 May 2026 08:38:28 +0200 Subject: [PATCH 1/3] feat(ui): add project agents from selector --- packages/server/src/filesystem/browser.ts | 1 + packages/ui/src/components/agent-selector.tsx | 109 +++++++++++++++++- .../ui/src/lib/i18n/messages/en/settings.ts | 17 +++ .../ui/src/lib/i18n/messages/es/settings.ts | 17 +++ .../ui/src/lib/i18n/messages/fr/settings.ts | 17 +++ .../ui/src/lib/i18n/messages/he/settings.ts | 17 +++ .../ui/src/lib/i18n/messages/ja/settings.ts | 17 +++ .../ui/src/lib/i18n/messages/ru/settings.ts | 17 +++ .../src/lib/i18n/messages/zh-Hans/settings.ts | 17 +++ packages/ui/src/stores/session-state.ts | 14 +++ packages/ui/src/stores/sessions.ts | 2 + 11 files changed, 243 insertions(+), 2 deletions(-) diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index 679a88823..8c4c9f5b4 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -86,6 +86,7 @@ export class FileSystemBrowser { throw new Error("writeFile is not available in unrestricted mode") } const resolved = this.toRestrictedAbsolute(relativePath) + fs.mkdirSync(path.dirname(resolved), { recursive: true }) fs.writeFileSync(resolved, contents, "utf-8") } diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index b5c6d5da5..7cedcc80b 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -1,10 +1,12 @@ import { Select } from "@kobalte/core/select" -import { For, Show, createEffect, createMemo } from "solid-js" -import { agents, fetchAgents, sessions } from "../stores/sessions" +import { Show, createEffect, createMemo, createSignal } from "solid-js" +import { agents, fetchAgents, sessions, upsertAgent } from "../stores/sessions" import { ChevronDown } from "lucide-solid" import type { Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" +import { serverApi } from "../lib/api-client" +import { showToastNotification } from "../lib/notifications" const log = getLogger("session") @@ -17,6 +19,10 @@ interface AgentSelectorProps { export default function AgentSelector(props: AgentSelectorProps) { const { t } = useI18n() + const [newAgentName, setNewAgentName] = createSignal("") + const [newAgentDescription, setNewAgentDescription] = createSignal("") + const [newAgentPrompt, setNewAgentPrompt] = createSignal("") + const [isCreatingAgent, setIsCreatingAgent] = createSignal(false) const instanceAgents = () => agents().get(props.instanceId) || [] const session = createMemo(() => { @@ -65,6 +71,65 @@ export default function AgentSelector(props: AgentSelectorProps) { } } + const normalizedNewAgentName = createMemo(() => newAgentName().trim()) + const isNewAgentNameValid = createMemo(() => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(normalizedNewAgentName())) + const canCreateAgent = createMemo(() => isNewAgentNameValid() && !isCreatingAgent()) + + const quoteYamlString = (value: string) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` + + const buildAgentMarkdown = (name: string) => { + const description = newAgentDescription().trim() || t("agentSelector.add.defaultDescription", { agent: name }) + const prompt = newAgentPrompt().trim() || t("agentSelector.add.defaultPrompt", { agent: name }) + return [ + "---", + `description: ${quoteYamlString(description)}`, + "mode: primary", + "---", + prompt, + "", + ].join("\n") + } + + const handleCreateAgent = async (event: SubmitEvent) => { + event.preventDefault() + const name = normalizedNewAgentName() + if (!isNewAgentNameValid()) { + showToastNotification({ message: t("agentSelector.add.invalidName"), variant: "error" }) + return + } + if (instanceAgents().some((agent) => agent.name === name)) { + showToastNotification({ message: t("agentSelector.add.duplicate", { agent: name }), variant: "error" }) + return + } + + setIsCreatingAgent(true) + try { + const description = newAgentDescription().trim() || t("agentSelector.add.defaultDescription", { agent: name }) + const filePath = `.opencode/agents/${name}.md` + try { + await serverApi.readWorkspaceFile(props.instanceId, filePath) + showToastNotification({ message: t("agentSelector.add.fileExists", { agent: name }), variant: "error" }) + return + } catch { + // New project agents should not already have a matching file. + } + + await serverApi.writeWorkspaceFile(props.instanceId, filePath, buildAgentMarkdown(name)) + await fetchAgents(props.instanceId) + upsertAgent(props.instanceId, { name, description, mode: "primary" }) + await props.onAgentChange(name) + setNewAgentName("") + setNewAgentDescription("") + setNewAgentPrompt("") + showToastNotification({ message: t("agentSelector.add.success", { agent: name }), variant: "success" }) + } catch (error) { + log.error("Failed to create agent", error) + showToastNotification({ message: t("agentSelector.add.error"), variant: "error" }) + } finally { + setIsCreatingAgent(false) + } + } + return (