From 9ecaabd87c0e51b8a7408dabb0133a9344586859 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 22 May 2026 15:24:13 +0900 Subject: feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling - Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete - Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered - Agent definitions stored as TOML with cwd field, loaded from global/project dirs - Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message - CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator - Subagent CWD validated against parent's effective CWD using path.relative - Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries - UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle - Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed - Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder - ModelSelector: Agent/Manual toggle, agent list, Agent Settings link - Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts --- .../src/lib/components/AgentBuilder.svelte | 746 +++++++++++++++++++++ .../frontend/src/lib/components/ChatInput.svelte | 4 +- .../frontend/src/lib/components/ChatMessage.svelte | 2 +- .../frontend/src/lib/components/ChatPanel.svelte | 74 +- .../frontend/src/lib/components/ClaudeReset.svelte | 247 +++---- .../frontend/src/lib/components/ConfigPanel.svelte | 177 ++--- packages/frontend/src/lib/components/Header.svelte | 3 +- .../src/lib/components/HotReloadIndicator.svelte | 38 +- .../frontend/src/lib/components/KeyUsage.svelte | 320 +++++---- .../src/lib/components/MarkdownRenderer.svelte | 308 ++++----- .../src/lib/components/ModelSelector.svelte | 202 +++++- .../frontend/src/lib/components/ModelStatus.svelte | 254 +++---- .../src/lib/components/PermissionPrompt.svelte | 5 +- .../src/lib/components/SettingsPanel.svelte | 201 +++--- .../src/lib/components/SidebarPanel.svelte | 44 +- .../src/lib/components/SkillsBrowser.svelte | 247 +++++-- .../src/lib/components/SystemPromptPanel.svelte | 66 +- packages/frontend/src/lib/components/TabBar.svelte | 5 +- .../src/lib/components/TaskListPanel.svelte | 11 - .../src/lib/components/ThemeSwitcher.svelte | 16 +- .../src/lib/components/ToolPermissions.svelte | 99 ++- 21 files changed, 2075 insertions(+), 994 deletions(-) create mode 100644 packages/frontend/src/lib/components/AgentBuilder.svelte (limited to 'packages/frontend/src/lib/components') diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte new file mode 100644 index 0000000..4a2d5d8 --- /dev/null +++ b/packages/frontend/src/lib/components/AgentBuilder.svelte @@ -0,0 +1,746 @@ + + + + +
+ +
+

Agent Settings

+ +
+ +
+ {#if error} +
{error}
+ {/if} + + {#if editing} + +
+
+

{editingSlug ? "Edit Agent" : "New Agent"}

+ + {#if formError} +
{formError}
+ {/if} + + +
+ + + {#if formName} + slug: {slugify(formName)} + {/if} +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + {#if formCwd.trim()} + {#if cwdExists === true} + + {:else if cwdExists === false} + + {:else} + + {/if} + {/if} +
+ {#if cwdExists === false && formCwd.trim()} + Directory will be created automatically on first message. + {:else} + Absolute path. Leave empty to use the project root. + {/if} +
+ + +
+
+ Models * +
+
+ {#each formModels as entry, i (i)} +
{ + dragIndex = i; + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + }} + ondragover={(e) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragOverIndex = i; + }} + ondragleave={() => { + if (dragOverIndex === i) dragOverIndex = null; + }} + ondrop={(e) => { + e.preventDefault(); + if (dragIndex !== null && dragIndex !== i) { + const reordered = [...formModels]; + const moved = reordered.splice(dragIndex, 1)[0]; + if (moved) { + reordered.splice(i, 0, moved); + formModels = reordered; + } + } + dragIndex = null; + dragOverIndex = null; + }} + ondragend={() => { dragIndex = null; dragOverIndex = null; }} + > + {i + 1} + + + +
+ {/each} +
+ +
+ + +
+
+ Tools +
+ +
+ + +
+
+ Skills +
+ +
+ + +
+ {#if editingSlug && !(editingSlug === "default" && formScope === "global")} + + {:else} +
+ {/if} +
+ {#if saving} + Saving... + {/if} +
+
+
+
+
+ {:else} + + {#if loading} +
+ +
+ {:else} + +
+

Global Agents

+
+ {#each globalAgents as agent} + + {/each} + +
+
+ + + {#if projectAgents.length > 0 || dirs.some((d) => d.scope !== "global")} +
+

Project Agents

+ {#if projectAgents.length === 0} +

No project agents yet.

+ {:else} +
+ {#each projectAgents as agent} + + {/each} +
+ {/if} +
+ {/if} + {/if} + {/if} +
+
+ + +{#if modelModalIndex !== null && modelModalType === "key"} + +{/if} + + +{#if modelModalIndex !== null && modelModalType === "model"} + +{/if} + + +{#if deletingAgent !== null} + {@const agentToDelete = deletingAgent} + +{/if} diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 8910bb4..21c85fe 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -24,13 +24,13 @@ function submit() { } -
+
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte index bcefbcf..baddb73 100644 --- a/packages/frontend/src/lib/components/ClaudeReset.svelte +++ b/packages/frontend/src/lib/components/ClaudeReset.svelte @@ -1,151 +1,152 @@
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte index 25cbe98..f517d28 100644 --- a/packages/frontend/src/lib/components/ConfigPanel.svelte +++ b/packages/frontend/src/lib/components/ConfigPanel.svelte @@ -1,94 +1,101 @@
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index ada5500..06b8ac1 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -1,4 +1,5 @@ {#if visible} diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index f5f7d6d..fc85739 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -1,201 +1,197 @@
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte index f73140f..0fbf314 100644 --- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte +++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte @@ -1,172 +1,172 @@
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte index 0af5ae6..21364f9 100644 --- a/packages/frontend/src/lib/components/ModelSelector.svelte +++ b/packages/frontend/src/lib/components/ModelSelector.svelte @@ -1,10 +1,22 @@ -
-
- Key - + +
+ +
+ { + const val = e.currentTarget.value.trim(); + onWorkingDirectoryChange(val || null); + }} + /> + {#if workingDirectory} + {#if cwdExists === true} + + {:else if cwdExists === false} + + {:else} + + {/if} + {/if} +
-
- Model - +
- {#if activeModelId} + {#if mode === "manual"} +
+ Key + +
+
- Thinking - + Model +
+ + {#if activeModelId} +
+ Thinking + +
+ {/if} + {:else} + + {#if loadingAgents} +
+ + Loading agents... +
+ {:else if agents.length === 0} +

No agents configured.

+ {:else} +
+ {#each agents as agent (agent.slug + ":" + agent.scope)} + + {/each} +
+ {/if} + {/if}
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte index a627de8..1270fcc 100644 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -1,148 +1,154 @@
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte index ce7afd7..3a67b30 100644 --- a/packages/frontend/src/lib/components/PermissionPrompt.svelte +++ b/packages/frontend/src/lib/components/PermissionPrompt.svelte @@ -1,7 +1,10 @@
@@ -103,26 +106,30 @@

Title Generation Model

Used to generate short titles for new tabs after the first message.

- - + - - +
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index a0aeb1d..041679a 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -7,7 +7,6 @@ import ModelSelector from "./ModelSelector.svelte"; import ModelStatus from "./ModelStatus.svelte"; import SettingsPanel from "./SettingsPanel.svelte"; import SkillsBrowser from "./SkillsBrowser.svelte"; -import SystemPromptPanel from "./SystemPromptPanel.svelte"; import TaskListPanel from "./TaskListPanel.svelte"; import ToolPermissions from "./ToolPermissions.svelte"; @@ -19,9 +18,13 @@ const { activeKeyId = null, activeModelId = null, reasoningEffort = "max", + activeAgentSlug = null as string | null, + workingDirectory = null as string | null, onKeyChange, onModelChange, onReasoningChange, + onAgentChange = (_agent: any) => {}, + onWorkingDirectoryChange = (_dir: string | null) => {}, }: { keys?: KeyInfo[]; tasks?: TaskItem[]; @@ -30,9 +33,13 @@ const { activeKeyId?: string | null; activeModelId?: string | null; reasoningEffort?: string; + activeAgentSlug?: string | null; + workingDirectory?: string | null; onKeyChange: (keyId: string) => void; onModelChange: (keyId: string, modelId: string) => void; onReasoningChange: (effort: string) => void; + onAgentChange?: (agent: any) => void; + onWorkingDirectoryChange?: (dir: string | null) => void; } = $props(); interface Panel { @@ -41,11 +48,11 @@ interface Panel { } let nextId = 0; -let panels = $state([{ id: nextId++, selected: "Model Choice" }]); +let panels = $state([{ id: nextId++, selected: "Chat Settings" }]); const viewOptions = [ "Select a view", - "Model Choice", + "Chat Settings", "Key Usage", "Claude Reset", "Model Status", @@ -53,7 +60,6 @@ const viewOptions = [ "Config", "Skills", "Tools", - "System Prompt", "Settings", ]; @@ -104,16 +110,20 @@ function contentClass(selected: string): string {
- {#if panel.selected === "Model Choice"} - + {#if panel.selected === "Chat Settings"} + {:else if panel.selected === "Key Usage"} {:else if panel.selected === "Claude Reset"} @@ -128,16 +138,14 @@ function contentClass(selected: string): string { {:else if panel.selected === "Tools"} - {:else if panel.selected === "System Prompt"} - - {:else if panel.selected === "Settings"} + {:else if panel.selected === "Settings"} {/if}
{/each} -
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte index 685f890..add43e4 100644 --- a/packages/frontend/src/lib/components/SkillsBrowser.svelte +++ b/packages/frontend/src/lib/components/SkillsBrowser.svelte @@ -7,7 +7,7 @@ interface Skill { description: string; tags: string[]; scope: "global" | "project"; - directory: "default" | "agents" | "project"; + directory: string; } interface SkillsResponse { @@ -20,7 +20,27 @@ interface SkillDetail extends Skill { source: string; } -const { apiBase }: { apiBase: string } = $props(); +interface DirGroup { + path: string; + label: string; + scope: "global" | "project"; + skills: Skill[]; +} + +const { + apiBase, + checkedSkills = null, + onSkillToggle = null, +}: { + apiBase: string; + /** External checked set (agent builder mode). When null, uses appSettings. */ + checkedSkills?: Set | null; + /** Callback when a skill is toggled in external mode. */ + onSkillToggle?: ((key: string, checked: boolean) => void) | null; +} = $props(); + +/** Whether we're in external (agent builder) mode */ +const externalMode = $derived(checkedSkills !== null && onSkillToggle !== null); let skills = $state([]); let loading = $state(false); @@ -28,6 +48,7 @@ let error = $state(null); let expandedSkill = $state(null); let expandedDetail = $state(null); let loadingDetail = $state(false); +let collapsedDirs = $state>(new Set()); async function fetchSkills() { loading = true; @@ -48,23 +69,64 @@ function skillKey(skill: Skill): string { return `${skill.scope}:${skill.name}`; } +/** Build a unique key for a directory group (scope + path) */ +function dirKey(group: DirGroup): string { + return `${group.scope}:${group.path}`; +} + function isChecked(skill: Skill): boolean { - return appSettings.skillChecks[skillKey(skill)] === true; + const key = skillKey(skill); + if (externalMode) { + return checkedSkills!.has(key); + } + return appSettings.skillChecks[key] === true; } function isInjected(skill: Skill): boolean { + if (externalMode) return false; return tabStore.activeTab?.injectedSkills.includes(skillKey(skill)) ?? false; } function toggleCheck(skill: Skill): void { const key = skillKey(skill); + if (externalMode) { + onSkillToggle!(key, !checkedSkills!.has(key)); + return; + } appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) }; } function resetChecks(): void { + if (externalMode) return; appSettings.skillChecks = {}; } +function toggleDir(key: string): void { + const next = new Set(collapsedDirs); + if (next.has(key)) next.delete(key); + else next.add(key); + collapsedDirs = next; +} + +/** Check if a group is hidden because an ancestor directory is collapsed */ +function isHiddenByParent(group: DirGroup): boolean { + if (!group.path.includes("/")) return false; + // Check each ancestor path segment + const parts = group.path.split("/"); + for (let i = 1; i < parts.length; i++) { + const ancestorPath = parts.slice(0, i).join("/"); + const ancestorKey = `${group.scope}:${ancestorPath}`; + if (collapsedDirs.has(ancestorKey)) return true; + } + return false; +} + +/** Get the top-level directory for grouping spacing */ +function topLevelDir(group: DirGroup): string { + const slash = group.path.indexOf("/"); + return slash === -1 ? group.path : group.path.slice(0, slash); +} + async function toggleExpand(skill: Skill) { const key = skillKey(skill); if (expandedSkill === key) { @@ -92,7 +154,37 @@ $effect(() => { fetchSkills(); }); -const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) => v).length); +const checkedCount = $derived( + externalMode + ? checkedSkills!.size + : Object.values(appSettings.skillChecks).filter((v) => v).length, +); + +/** Group skills by scope + directory, sorted */ +const dirGroups = $derived.by((): DirGroup[] => { + const map = new Map(); + for (const skill of skills) { + const key = `${skill.scope}:${skill.directory}`; + let group = map.get(key); + if (!group) { + const label = skill.directory || "(root)"; + group = { path: skill.directory, label, scope: skill.scope, skills: [] }; + map.set(key, group); + } + group.skills.push(skill); + } + // Sort: global before project, then alphabetically by path + const groups = Array.from(map.values()); + groups.sort((a, b) => { + if (a.scope !== b.scope) return a.scope === "global" ? -1 : 1; + return a.path.localeCompare(b.path); + }); + // Sort skills within each group alphabetically + for (const g of groups) { + g.skills.sort((a, b) => a.name.localeCompare(b.name)); + } + return groups; +});
@@ -102,7 +194,7 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) {skills.length} {/if} {#if checkedCount > 0} - {checkedCount} queued + {checkedCount} {externalMode ? 'selected' : 'queued'} {/if}
-

Check skills to inject with your next message.

+ {#if !externalMode} +

Check skills to inject with your next message.

+ {/if} {#if loading}
@@ -127,64 +221,97 @@ const checkedCount = $derived(Object.values(appSettings.skillChecks).filter((v) No skills found. Create .skills/ directories to get started.

{:else} -
- {#each skills as skill (skillKey(skill))} - {@const key = skillKey(skill)} - {@const checked = isChecked(skill)} - {@const injected = isInjected(skill)} -
- - - {#if expandedSkill === key} -
- {#if loadingDetail} - - {:else if expandedDetail} -
{expandedDetail.content}
- {:else} -

Failed to load skill content.

+
+ {#each dirGroups as group, idx (dirKey(group))} + {@const collapsed = collapsedDirs.has(dirKey(group))} + {@const hidden = isHiddenByParent(group)} + {@const prevGroup = dirGroups[idx - 1]} + {@const isNewTopLevel = idx === 0 || !prevGroup || topLevelDir(group) !== topLevelDir(prevGroup) || group.scope !== prevGroup.scope} + {#if !hidden} + {#if isNewTopLevel && idx > 0} +
+ {/if} +
+
+ + + + + {#if !collapsed} +
+ {#each group.skills as skill (skillKey(skill))} + {@const key = skillKey(skill)} + {@const checked = isChecked(skill)} + {@const injected = isInjected(skill)} +
+ + + {#if expandedSkill === key} +
+ {#if loadingDetail} + + {:else if expandedDetail} +
{expandedDetail.content}
+ {:else} +

Failed to load skill content.

+ {/if} +
+ {/if} +
+ {/each} +
{/if}
- {/if} -
+
+ {/if} {/each}
{/if} - + {#if !externalMode} + + {/if}
diff --git a/packages/frontend/src/lib/components/SystemPromptPanel.svelte b/packages/frontend/src/lib/components/SystemPromptPanel.svelte index 6b91ebe..d9039f4 100644 --- a/packages/frontend/src/lib/components/SystemPromptPanel.svelte +++ b/packages/frontend/src/lib/components/SystemPromptPanel.svelte @@ -1,42 +1,42 @@
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte index cfa5887..feb1e5b 100644 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -25,12 +25,13 @@ const activeUserTabId = $derived(
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} >
{ if (e.target === e.currentTarget) tabStore.createNewTab(); }} > @@ -72,7 +73,7 @@ const activeUserTabId = $derived( {#if hasSubagentTabs} -
+
diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte index fe12cc5..418fcea 100644 --- a/packages/frontend/src/lib/components/ThemeSwitcher.svelte +++ b/packages/frontend/src/lib/components/ThemeSwitcher.svelte @@ -1,8 +1,18 @@
Tool Permissions
-

Changes are applied when you send your next message.

+ {#if !externalMode} +

Changes are applied when you send your next message.

+ {/if}
{#each toolPermissions as perm (perm.id)} @@ -73,7 +96,7 @@ onMount(() => { togglePermission(perm.id)} />
@@ -84,35 +107,37 @@ onMount(() => { {/each}
- + {#if !externalMode} + -

Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.

+

Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.

- - {#if entries.length > 0} -
- -
- Log ({entries.length}) -
-
- {#each entries as entry (entry.id)} -
- - {entry.action} - - {entry.permission} - {entry.timestamp} -
-

{entry.description}

- {/each} + + {#if entries.length > 0} +
+ +
+ Log ({entries.length}) +
+
+ {#each entries as entry (entry.id)} +
+ + {entry.action} + + {entry.permission} + {entry.timestamp} +
+

{entry.description}

+ {/each} +
-
+ {/if} {/if}
-- cgit v1.2.3