Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ export type WorkspaceCreateResponse = WorkspaceDescriptor
export type WorkspaceListResponse = WorkspaceDescriptor[]
export type WorkspaceDetailResponse = WorkspaceDescriptor

export interface WorkspaceAgentCreateRequest {
name: string
contents: string
}

export interface WorkspaceAgentCreateResponse {
name: string
path: string
}

export interface WorkspaceDeleteResponse {
id: string
status: WorkspaceStatus
Expand Down
5 changes: 3 additions & 2 deletions packages/server/src/filesystem/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,13 @@ export class FileSystemBrowser {
return { path: relativePath, absolutePath }
}

writeFile(relativePath: string, contents: string): void {
writeFile(relativePath: string, contents: string, options: { overwrite?: boolean } = {}): void {
if (this.unrestricted) {
throw new Error("writeFile is not available in unrestricted mode")
}
const resolved = this.toRestrictedAbsolute(relativePath)
fs.writeFileSync(resolved, contents, "utf-8")
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, contents, { encoding: "utf-8", flag: options.overwrite === false ? "wx" : "w" })
}

readFile(relativePath: string): string {
Expand Down
23 changes: 23 additions & 0 deletions packages/server/src/server/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const WorkspaceFileContentBodySchema = z.object({
contents: z.string(),
})

const WorkspaceAgentCreateSchema = z.object({
name: z.string().trim().regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/, "Invalid agent name"),
contents: z.string(),
})

const WorktreeGitDiffQuerySchema = z.object({
path: z.string().trim().min(1, "Path is required"),
originalPath: z.string().trim().optional(),
Expand Down Expand Up @@ -137,6 +142,24 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
}
})

app.post<{
Params: { id: string }
}>("/api/workspaces/:id/agents", async (request, reply) => {
try {
const body = WorkspaceAgentCreateSchema.parse(request.body ?? {})
const relativePath = `.opencode/agents/${body.name}.md`
deps.workspaceManager.createFile(request.params.id, relativePath, body.contents)
reply.code(201)
return { name: body.name, path: relativePath }
} catch (error: any) {
if (error?.code === "EEXIST") {
reply.code(409)
return { error: "Agent already exists" }
}
return handleWorkspaceError(error, reply)
}
})

app.get<{
Params: { id: string; slug: string }
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/server/src/workspaces/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ export class WorkspaceManager {
browser.writeFile(relativePath, contents)
}

createFile(workspaceId: string, relativePath: string, contents: string): void {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
browser.writeFile(relativePath, contents, { overwrite: false })
}

async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {

const id = `${Date.now().toString(36)}`
Expand Down
104 changes: 103 additions & 1 deletion packages/ui/src/components/agent-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Select } from "@kobalte/core/select"
import { For, Show, createEffect, createMemo } from "solid-js"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { agents, fetchAgents, sessions } 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")


Expand All @@ -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(() => {
Expand Down Expand Up @@ -65,6 +71,68 @@ 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 {
await serverApi.createWorkspaceAgent(props.instanceId, {
name,
contents: buildAgentMarkdown(name),
})
const refreshedAgents = await fetchAgents(props.instanceId, { throwOnError: true })
if (!refreshedAgents.some((agent) => agent.name === name)) {
throw new Error(`Created agent ${name} was not loaded by OpenCode`)
}
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)
if (error instanceof Error && error.message.includes("Agent already exists")) {
showToastNotification({ message: t("agentSelector.add.fileExists", { agent: name }), variant: "error" })
return
}
if (error instanceof Error && error.message.includes("was not loaded by OpenCode")) {
showToastNotification({ message: t("agentSelector.add.notLoaded", { agent: name }), variant: "error" })
return
}
showToastNotification({ message: t("agentSelector.add.error"), variant: "error" })
} finally {
setIsCreatingAgent(false)
}
}

return (
<div class="sidebar-selector">
<Select
Expand Down Expand Up @@ -123,6 +191,40 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Content>
</Select.Portal>
</Select>
<form class="mt-2 space-y-2" onSubmit={handleCreateAgent}>
<div class="selector-section-title">{t("agentSelector.add.title")}</div>
<input
class="selector-input w-full"
value={newAgentName()}
onInput={(event) => setNewAgentName(event.currentTarget.value)}
placeholder={t("agentSelector.add.name.placeholder")}
aria-label={t("agentSelector.add.name.ariaLabel")}
/>
<input
class="selector-input w-full"
value={newAgentDescription()}
onInput={(event) => setNewAgentDescription(event.currentTarget.value)}
placeholder={t("agentSelector.add.description.placeholder")}
aria-label={t("agentSelector.add.description.ariaLabel")}
/>
<textarea
class="selector-input w-full min-h-20 resize-y"
value={newAgentPrompt()}
onInput={(event) => setNewAgentPrompt(event.currentTarget.value)}
placeholder={t("agentSelector.add.prompt.placeholder")}
aria-label={t("agentSelector.add.prompt.ariaLabel")}
/>
<button
type="submit"
class="selector-button selector-button-primary"
disabled={!canCreateAgent()}
>
{isCreatingAgent() ? t("agentSelector.add.creating") : t("agentSelector.add.action")}
</button>
<Show when={normalizedNewAgentName() && !isNewAgentNameValid()}>
<p class="selector-validation-error-text">{t("agentSelector.add.name.help")}</p>
</Show>
</form>
</div>
)
}
8 changes: 8 additions & 0 deletions packages/ui/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
WorktreeGitMutationResponse,
WorktreeGitPathsRequest,
WorkspaceCreateRequest,
WorkspaceAgentCreateRequest,
WorkspaceAgentCreateResponse,
WorkspaceDescriptor,
WorkspaceFileResponse,
WorkspaceFileSearchResponse,
Expand Down Expand Up @@ -280,6 +282,12 @@ export const serverApi = {
deleteWorkspace(id: string): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
},
createWorkspaceAgent(id: string, payload: WorkspaceAgentCreateRequest): Promise<WorkspaceAgentCreateResponse> {
return request<WorkspaceAgentCreateResponse>(`/api/workspaces/${encodeURIComponent(id)}/agents`, {
method: "POST",
body: JSON.stringify(payload),
})
},
listWorkspaceFiles(id: string, relativePath = "."): Promise<FileSystemEntry[]> {
const params = new URLSearchParams({ path: relativePath })
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/lib/i18n/messages/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export const settingsMessages = {
"agentSelector.badge.subagent": "subagent",
"agentSelector.none": "None",
"agentSelector.trigger.primary": "Agent: {agent}",
"agentSelector.add.title": "Add project agent",
"agentSelector.add.name.placeholder": "agent-name",
"agentSelector.add.name.ariaLabel": "Agent name",
"agentSelector.add.description.placeholder": "Short description",
"agentSelector.add.description.ariaLabel": "Agent description",
"agentSelector.add.prompt.placeholder": "System prompt (optional)",
"agentSelector.add.prompt.ariaLabel": "Agent prompt",
"agentSelector.add.action": "Add agent",
"agentSelector.add.creating": "Adding...",
"agentSelector.add.name.help": "Use letters, numbers, dashes, or underscores.",
"agentSelector.add.invalidName": "Use letters, numbers, dashes, or underscores for the agent name.",
"agentSelector.add.duplicate": "Agent {agent} already exists.",
"agentSelector.add.fileExists": "An agent file for {agent} already exists.",
"agentSelector.add.success": "Added agent {agent}.",
"agentSelector.add.notLoaded": "Created agent {agent}, but OpenCode has not loaded it yet.",
"agentSelector.add.error": "Failed to add agent.",
"agentSelector.add.defaultDescription": "Custom project agent {agent}",
"agentSelector.add.defaultPrompt": "You are {agent}, a project-specific coding assistant.",

"modelSelector.placeholder.search": "Search models...",
"modelSelector.none": "None",
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/lib/i18n/messages/es/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export const settingsMessages = {
"agentSelector.badge.subagent": "subagente",
"agentSelector.none": "Ninguno",
"agentSelector.trigger.primary": "Agente: {agent}",
"agentSelector.add.title": "Agregar agente del proyecto",
"agentSelector.add.name.placeholder": "nombre-agente",
"agentSelector.add.name.ariaLabel": "Nombre del agente",
"agentSelector.add.description.placeholder": "Descripción breve",
"agentSelector.add.description.ariaLabel": "Descripción del agente",
"agentSelector.add.prompt.placeholder": "Prompt del sistema (opcional)",
"agentSelector.add.prompt.ariaLabel": "Prompt del agente",
"agentSelector.add.action": "Agregar agente",
"agentSelector.add.creating": "Agregando...",
"agentSelector.add.name.help": "Usa letras, números, guiones o guiones bajos.",
"agentSelector.add.invalidName": "Usa letras, números, guiones o guiones bajos para el nombre del agente.",
"agentSelector.add.duplicate": "El agente {agent} ya existe.",
"agentSelector.add.fileExists": "Ya existe un archivo de agente para {agent}.",
"agentSelector.add.success": "Agente {agent} agregado.",
"agentSelector.add.notLoaded": "Agente {agent} creado, pero OpenCode aún no lo ha cargado.",
"agentSelector.add.error": "No se pudo agregar el agente.",
"agentSelector.add.defaultDescription": "Agente personalizado del proyecto {agent}",
"agentSelector.add.defaultPrompt": "Eres {agent}, un asistente de código específico del proyecto.",

"modelSelector.placeholder.search": "Buscar modelos...",
"modelSelector.none": "Ninguno",
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/lib/i18n/messages/fr/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export const settingsMessages = {
"agentSelector.badge.subagent": "sous-agent",
"agentSelector.none": "Aucun",
"agentSelector.trigger.primary": "Agent : {agent}",
"agentSelector.add.title": "Ajouter un agent projet",
"agentSelector.add.name.placeholder": "nom-agent",
"agentSelector.add.name.ariaLabel": "Nom de l'agent",
"agentSelector.add.description.placeholder": "Description courte",
"agentSelector.add.description.ariaLabel": "Description de l'agent",
"agentSelector.add.prompt.placeholder": "Prompt système (facultatif)",
"agentSelector.add.prompt.ariaLabel": "Prompt de l'agent",
"agentSelector.add.action": "Ajouter l'agent",
"agentSelector.add.creating": "Ajout...",
"agentSelector.add.name.help": "Utilisez lettres, chiffres, tirets ou underscores.",
"agentSelector.add.invalidName": "Utilisez lettres, chiffres, tirets ou underscores pour le nom de l'agent.",
"agentSelector.add.duplicate": "L'agent {agent} existe déjà.",
"agentSelector.add.fileExists": "Un fichier d'agent pour {agent} existe déjà.",
"agentSelector.add.success": "Agent {agent} ajouté.",
"agentSelector.add.notLoaded": "Agent {agent} créé, mais OpenCode ne l'a pas encore chargé.",
"agentSelector.add.error": "Impossible d'ajouter l'agent.",
"agentSelector.add.defaultDescription": "Agent projet personnalisé {agent}",
"agentSelector.add.defaultPrompt": "Vous êtes {agent}, un assistant de code spécifique au projet.",

"modelSelector.placeholder.search": "Rechercher des modèles...",
"modelSelector.none": "Aucun",
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/lib/i18n/messages/he/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export const settingsMessages = {
"agentSelector.badge.subagent": "תת-סוכן",
"agentSelector.none": "ללא",
"agentSelector.trigger.primary": "סוכן: {agent}",
"agentSelector.add.title": "הוסף סוכן פרויקט",
"agentSelector.add.name.placeholder": "agent-name",
"agentSelector.add.name.ariaLabel": "שם הסוכן",
"agentSelector.add.description.placeholder": "תיאור קצר",
"agentSelector.add.description.ariaLabel": "תיאור הסוכן",
"agentSelector.add.prompt.placeholder": "פרומפט מערכת (אופציונלי)",
"agentSelector.add.prompt.ariaLabel": "פרומפט הסוכן",
"agentSelector.add.action": "הוסף סוכן",
"agentSelector.add.creating": "מוסיף...",
"agentSelector.add.name.help": "השתמש באותיות, מספרים, מקפים או קווים תחתונים.",
"agentSelector.add.invalidName": "השתמש באותיות, מספרים, מקפים או קווים תחתונים לשם הסוכן.",
"agentSelector.add.duplicate": "הסוכן {agent} כבר קיים.",
"agentSelector.add.fileExists": "קובץ סוכן עבור {agent} כבר קיים.",
"agentSelector.add.success": "הסוכן {agent} נוסף.",
"agentSelector.add.notLoaded": "הסוכן {agent} נוצר, אך OpenCode עדיין לא טען אותו.",
"agentSelector.add.error": "הוספת הסוכן נכשלה.",
"agentSelector.add.defaultDescription": "סוכן פרויקט מותאם אישית {agent}",
"agentSelector.add.defaultPrompt": "אתה {agent}, עוזר קוד ייעודי לפרויקט.",

"modelSelector.placeholder.search": "חפש מודלים...",
"modelSelector.none": "ללא",
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/lib/i18n/messages/ja/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export const settingsMessages = {
"agentSelector.badge.subagent": "サブエージェント",
"agentSelector.none": "なし",
"agentSelector.trigger.primary": "エージェント: {agent}",
"agentSelector.add.title": "プロジェクトエージェントを追加",
"agentSelector.add.name.placeholder": "agent-name",
"agentSelector.add.name.ariaLabel": "エージェント名",
"agentSelector.add.description.placeholder": "短い説明",
"agentSelector.add.description.ariaLabel": "エージェントの説明",
"agentSelector.add.prompt.placeholder": "システムプロンプト(任意)",
"agentSelector.add.prompt.ariaLabel": "エージェントプロンプト",
"agentSelector.add.action": "エージェントを追加",
"agentSelector.add.creating": "追加中...",
"agentSelector.add.name.help": "英数字、ダッシュ、アンダースコアを使用してください。",
"agentSelector.add.invalidName": "エージェント名には英数字、ダッシュ、アンダースコアを使用してください。",
"agentSelector.add.duplicate": "エージェント {agent} は既に存在します。",
"agentSelector.add.fileExists": "{agent} のエージェントファイルは既に存在します。",
"agentSelector.add.success": "エージェント {agent} を追加しました。",
"agentSelector.add.notLoaded": "エージェント {agent} を作成しましたが、OpenCode はまだ読み込んでいません。",
"agentSelector.add.error": "エージェントを追加できませんでした。",
"agentSelector.add.defaultDescription": "カスタムプロジェクトエージェント {agent}",
"agentSelector.add.defaultPrompt": "あなたは {agent}、プロジェクト固有のコーディングアシスタントです。",

"modelSelector.placeholder.search": "モデルを検索...",
"modelSelector.none": "なし",
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/lib/i18n/messages/ru/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export const settingsMessages = {
"agentSelector.badge.subagent": "субагент",
"agentSelector.none": "Нет",
"agentSelector.trigger.primary": "Агент: {agent}",
"agentSelector.add.title": "Добавить агента проекта",
"agentSelector.add.name.placeholder": "agent-name",
"agentSelector.add.name.ariaLabel": "Имя агента",
"agentSelector.add.description.placeholder": "Краткое описание",
"agentSelector.add.description.ariaLabel": "Описание агента",
"agentSelector.add.prompt.placeholder": "Системный промпт (необязательно)",
"agentSelector.add.prompt.ariaLabel": "Промпт агента",
"agentSelector.add.action": "Добавить агента",
"agentSelector.add.creating": "Добавление...",
"agentSelector.add.name.help": "Используйте буквы, цифры, дефисы или подчеркивания.",
"agentSelector.add.invalidName": "Для имени агента используйте буквы, цифры, дефисы или подчеркивания.",
"agentSelector.add.duplicate": "Агент {agent} уже существует.",
"agentSelector.add.fileExists": "Файл агента для {agent} уже существует.",
"agentSelector.add.success": "Агент {agent} добавлен.",
"agentSelector.add.notLoaded": "Агент {agent} создан, но OpenCode еще не загрузил его.",
"agentSelector.add.error": "Не удалось добавить агента.",
"agentSelector.add.defaultDescription": "Пользовательский агент проекта {agent}",
"agentSelector.add.defaultPrompt": "Вы {agent}, помощник по коду для этого проекта.",

"modelSelector.placeholder.search": "Поиск моделей…",
"modelSelector.none": "Нет",
Expand Down
Loading
Loading