From 525da3e773a1f3921938e8978b93cef33244b3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Fri, 24 Jul 2026 13:11:26 +0200 Subject: [PATCH 1/3] feat: skills/memories pages, PillMultiSelect, agent form updates, chat stream fix - Add Skills page (grid + create dialog + viewer with markdown rendering) - Add Memories page (grid + create dialog + viewer) - Add PillMultiSelect reusable component (toggle pills with readOnly mode) - Replace StringListEditor with SkillPillMultiSelect/MemoryPillMultiSelect in agent form - Add Skills/Memories display in AgentConfigViewer (read-only pills, selected only) - Add Store API layer (IStorePort, storeApi, 4 TanStack hooks) - Add frontmatter parser (parseFrontmatter/buildFrontmatter with js-yaml) - Add Sidebar entries: Skills (Sparkles), Memories (BookOpen) - Fix chat stream cleanup on thread change (clearStream in ChatPage useEffect) - Remove MiddlewareType, BackendType.FILESYSTEM/COMPOSITE/STATE, root_dir, store_backend - Frontend: 618 tests pass, TypeScript clean, eslint 0 errors --- src/application/App.tsx | 4 + .../components/agent/AgentConfigForm.tsx | 140 +++----- .../components/agent/AgentConfigViewer.tsx | 39 +-- .../components/agent/SkillMemorySelects.tsx | 107 ++++++ src/application/components/layout/Sidebar.tsx | 4 +- .../components/memory/CreateMemoryDialog.tsx | 132 ++++++++ .../components/memory/MemoryCard.tsx | 50 +++ .../components/memory/MemoryGrid.tsx | 102 ++++++ .../components/memory/MemoryViewer.tsx | 229 +++++++++++++ .../components/shared/PillMultiSelect.tsx | 80 +++++ .../components/skill/CreateSkillDialog.tsx | 156 +++++++++ .../components/skill/SkillCard.tsx | 50 +++ .../components/skill/SkillGrid.tsx | 108 ++++++ .../components/skill/SkillViewer.tsx | 275 +++++++++++++++ .../hooks/memory/useCreateMemory.ts | 25 ++ src/application/hooks/skill/useCreateSkill.ts | 29 ++ .../hooks/store/useDeleteStoreFile.ts | 17 + .../hooks/store/usePutStoreFile.ts | 19 ++ src/application/hooks/store/useStoreFile.ts | 17 + src/application/hooks/store/useStoreFiles.ts | 13 + src/application/lib/frontmatter.ts | 106 ++++++ src/application/lib/yaml.ts | 1 - src/application/pages/AgentsPage.tsx | 4 +- src/application/pages/ChatPage.tsx | 4 +- src/application/pages/MemoriesPage.tsx | 63 ++++ src/application/pages/SkillsPage.tsx | 63 ++++ src/domain/entities/agent/agentConfig.ts | 12 +- .../entities/agent/agentConfigSchema.ts | 67 ++-- src/domain/entities/store/storeFile.ts | 8 + src/domain/ports/store/storePort.ts | 8 + src/infrastructure/api/store/storeApi.ts | 46 +++ tests/fixtures/external.ts | 3 +- tests/unit/application/lib/yaml.test.ts | 155 ++++++++- .../components/agent/AgentConfigForm.test.tsx | 104 +++++- .../agent/AgentConfigViewer.test.tsx | 157 +++++++-- .../memory/CreateMemoryDialog.test.tsx | 69 ++++ .../components/memory/MemoryCard.test.tsx | 49 +++ .../shared/PillMultiSelect.test.tsx | 90 +++++ .../skill/CreateSkillDialog.test.tsx | 77 +++++ .../unit/components/skill/SkillCard.test.tsx | 37 +++ .../unit/domain/entities/agentConfig.test.ts | 120 ++++++- .../domain/entities/agentConfigSchema.test.ts | 312 +++++++++++------- tests/unit/domain/entities/storeFile.test.ts | 26 ++ .../unit/hooks/agent/useAgentConfig.test.tsx | 6 +- .../unit/hooks/agent/useCreateAgent.test.tsx | 6 +- .../unit/hooks/agent/useUpdateAgent.test.tsx | 6 +- .../hooks/store/useDeleteStoreFile.test.tsx | 73 ++++ .../unit/hooks/store/usePutStoreFile.test.tsx | 73 ++++ tests/unit/hooks/store/useStoreFile.test.tsx | 96 ++++++ tests/unit/hooks/store/useStoreFiles.test.tsx | 90 +++++ tests/unit/lib/frontmatter.test.ts | 84 +++++ 51 files changed, 3247 insertions(+), 364 deletions(-) create mode 100644 src/application/components/agent/SkillMemorySelects.tsx create mode 100644 src/application/components/memory/CreateMemoryDialog.tsx create mode 100644 src/application/components/memory/MemoryCard.tsx create mode 100644 src/application/components/memory/MemoryGrid.tsx create mode 100644 src/application/components/memory/MemoryViewer.tsx create mode 100644 src/application/components/shared/PillMultiSelect.tsx create mode 100644 src/application/components/skill/CreateSkillDialog.tsx create mode 100644 src/application/components/skill/SkillCard.tsx create mode 100644 src/application/components/skill/SkillGrid.tsx create mode 100644 src/application/components/skill/SkillViewer.tsx create mode 100644 src/application/hooks/memory/useCreateMemory.ts create mode 100644 src/application/hooks/skill/useCreateSkill.ts create mode 100644 src/application/hooks/store/useDeleteStoreFile.ts create mode 100644 src/application/hooks/store/usePutStoreFile.ts create mode 100644 src/application/hooks/store/useStoreFile.ts create mode 100644 src/application/hooks/store/useStoreFiles.ts create mode 100644 src/application/lib/frontmatter.ts create mode 100644 src/application/pages/MemoriesPage.tsx create mode 100644 src/application/pages/SkillsPage.tsx create mode 100644 src/domain/entities/store/storeFile.ts create mode 100644 src/domain/ports/store/storePort.ts create mode 100644 src/infrastructure/api/store/storeApi.ts create mode 100644 tests/unit/components/memory/CreateMemoryDialog.test.tsx create mode 100644 tests/unit/components/memory/MemoryCard.test.tsx create mode 100644 tests/unit/components/shared/PillMultiSelect.test.tsx create mode 100644 tests/unit/components/skill/CreateSkillDialog.test.tsx create mode 100644 tests/unit/components/skill/SkillCard.test.tsx create mode 100644 tests/unit/domain/entities/storeFile.test.ts create mode 100644 tests/unit/hooks/store/useDeleteStoreFile.test.tsx create mode 100644 tests/unit/hooks/store/usePutStoreFile.test.tsx create mode 100644 tests/unit/hooks/store/useStoreFile.test.tsx create mode 100644 tests/unit/hooks/store/useStoreFiles.test.tsx create mode 100644 tests/unit/lib/frontmatter.test.ts diff --git a/src/application/App.tsx b/src/application/App.tsx index 1dd0d88..28558a6 100644 --- a/src/application/App.tsx +++ b/src/application/App.tsx @@ -6,6 +6,8 @@ import SettingsPage from "@/application/pages/SettingsPage"; // Lazy-load secondary routes for code-splitting / smaller initial bundle. const AgentsPage = lazy(() => import("@/application/pages/AgentsPage")); const RagPage = lazy(() => import("@/application/pages/RagPage")); +const SkillsPage = lazy(() => import("@/application/pages/SkillsPage")); +const MemoriesPage = lazy(() => import("@/application/pages/MemoriesPage")); function PageFallback() { return ( @@ -24,6 +26,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> ); diff --git a/src/application/components/agent/AgentConfigForm.tsx b/src/application/components/agent/AgentConfigForm.tsx index 928ae72..ffae6c4 100644 --- a/src/application/components/agent/AgentConfigForm.tsx +++ b/src/application/components/agent/AgentConfigForm.tsx @@ -1,19 +1,15 @@ import { useCallback, memo } from "react"; -import { - useForm, - useFieldArray, - useWatch, - type Control, -} from "react-hook-form"; +import { useForm, useFieldArray, useWatch, type Control } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus } from "lucide-react"; import type { AgentConfig, SubAgentConfig } from "@/domain/entities/agent/agentConfig"; -import { BackendType, MiddlewareType } from "@/domain/entities/agent/agentConfig"; +import { BackendType } from "@/domain/entities/agent/agentConfig"; import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig"; import type { McpServerConfig } from "@/domain/entities/agent/mcpServerConfig"; import { agentConfigSchema, type AgentConfigFormData, + type AgentConfigFormInput, } from "@/domain/entities/agent/agentConfigSchema"; import { Button } from "@/application/components/ui/button"; import { Input } from "@/application/components/ui/input"; @@ -34,17 +30,17 @@ import { SelectValue, } from "@/application/components/ui/select"; import StringListEditor from "./StringListEditor"; +import { SkillPillMultiSelect, MemoryPillMultiSelect } from "./SkillMemorySelects"; import McpServerEditor from "./McpServerEditor"; import SubAgentEditor from "./SubAgentEditor"; import HITLEditor from "./HITLEditor"; import ResponseFormatEditor from "./ResponseFormatEditor"; import { AGENT_CONFIG_FORM_ID as FORM_ID } from "./formConstants"; -import { cn } from "@/application/lib/utils"; type SectionValue = | "general" | "system-prompt" - | "tools-middleware" + | "tools" | "backend" | "hitl" | "memory-skills" @@ -57,7 +53,7 @@ const DEFAULT_OPEN_SECTIONS: SectionValue[] = ["general", "system-prompt"]; const SECTION_META: Record = { general: { label: "General" }, "system-prompt": { label: "System Prompt" }, - "tools-middleware": { label: "Tools & Middleware" }, + tools: { label: "Tools" }, backend: { label: "Backend" }, hitl: { label: "HITL" }, "memory-skills": { label: "Memory & Skills" }, @@ -66,17 +62,9 @@ const SECTION_META: Record = { "response-format": { label: "Response Format" }, }; -const MIDDLEWARE_OPTIONS: { label: string; value: MiddlewareType }[] = [ - { value: MiddlewareType.TODO_LIST, label: "todo_list" }, - { value: MiddlewareType.FILESYSTEM, label: "filesystem" }, - { value: MiddlewareType.SUB_AGENT, label: "sub_agent" }, -]; - -const BACKEND_OPTIONS: { label: string; value: BackendType }[] = [ - { value: BackendType.STATE, label: "state" }, - { value: BackendType.STORE, label: "store" }, - { value: BackendType.FILESYSTEM, label: "filesystem" }, - { value: BackendType.COMPOSITE, label: "composite" }, +const BACKEND_STORAGE_OPTIONS: { label: string; value: "memory" | "postgres" }[] = [ + { value: "memory", label: "memory" }, + { value: "postgres", label: "postgres" }, ]; const EMPTY_MCP_SERVER: McpServerConfig = { @@ -107,8 +95,10 @@ const DEFAULT_FORM_VALUES: AgentConfigFormData = { system_prompt: "", system_prompt_file: "", tools: [], - middleware: [], - backend: { type: BackendType.STATE }, + backend: { + type: BackendType.STORE, + checkpoint_backend: "memory", + }, hitl: { rules: {} }, memory: [], skills: [], @@ -131,10 +121,6 @@ function getDefaultValues(mode: "create" | "edit", initialData?: AgentConfig): A return { ...DEFAULT_FORM_VALUES }; } -function showRootDir(type: BackendType): boolean { - return type === BackendType.FILESYSTEM || type === BackendType.COMPOSITE; -} - interface AgentConfigFormProps { mode: "create" | "edit"; initialData?: AgentConfig; @@ -155,18 +141,15 @@ export default function AgentConfigForm({ handleSubmit, control, setValue, - getValues, formState: { errors }, - } = useForm({ + } = useForm({ resolver: zodResolver(agentConfigSchema), defaultValues: getDefaultValues(mode, initialData), }); const subagentsArray = useFieldArray({ control, name: "subagents" }); - // eslint-disable-next-line react-hooks/incompatible-library - const backendType = useWatch({ control, name: "backend.type" }); - const middlewareValues = useWatch({ control, name: "middleware" }); + const checkpointBackend = useWatch({ control, name: "backend.checkpoint_backend" }); function handleFormSubmit(data: AgentConfigFormData) { const cleaned: AgentConfigFormData = { @@ -182,18 +165,6 @@ export default function AgentConfigForm({ subagentsArray.append({ ...EMPTY_SUBAGENT }); } - function toggleMiddleware(mw: MiddlewareType, isChecked: boolean) { - const current = getValues("middleware"); - if (isChecked) { - setValue("middleware", [...current, mw]); - } else { - setValue( - "middleware", - current.filter((m) => m !== mw), - ); - } - } - return (
- - + + setValue("tools", tools)} placeholder="Add tool…" /> -
- -
- {MIDDLEWARE_OPTIONS.map((opt) => { - const isActive = middlewareValues.includes(opt.value); - return ( - - ); - })} -
-
@@ -301,16 +248,22 @@ export default function AgentConfigForm({
- + +

store

+
+
+
- {showRootDir(backendType) && ( - - - - )}
@@ -345,18 +287,20 @@ export default function AgentConfigForm({ - setValue("memory", memory)} - placeholder="Add memory path…" - /> - setValue("skills", skills)} - placeholder="Add skill…" - /> +
+ + setValue("memory", memory)} + /> +
+
+ + setValue("skills", skills)} + /> +
@@ -461,7 +405,7 @@ const McpServersAccordionItem = memo(function McpServersAccordionItem({ control, defaultValue, }: Readonly<{ - control: Control; + control: Control; defaultValue: SectionValue[]; }>) { const mcpServersArray = useFieldArray({ control, name: "mcp_servers" }); diff --git a/src/application/components/agent/AgentConfigViewer.tsx b/src/application/components/agent/AgentConfigViewer.tsx index 54aa28b..a90b17c 100644 --- a/src/application/components/agent/AgentConfigViewer.tsx +++ b/src/application/components/agent/AgentConfigViewer.tsx @@ -9,6 +9,7 @@ import type { AgentConfig } from "@/domain/entities/agent/agentConfig"; import type { AgentConfigFormData } from "@/domain/entities/agent/agentConfigSchema"; import StatusBadge from "@/application/components/shared/StatusBadge"; import ToolTag from "@/application/components/shared/ToolTag"; +import { SkillPillMultiSelect, MemoryPillMultiSelect } from "./SkillMemorySelects"; import { Dialog, DialogContent, @@ -200,29 +201,29 @@ export default function AgentConfigViewer({ )} - {config.middleware.length > 0 && ( +
+ Backend +
+

+ checkpoint_backend:{" "} + {config.backend.checkpoint_backend} +

+
+
+ + {config.skills.length > 0 && (
- Middleware ({config.middleware.length}) -
- {config.middleware.map((mw) => ( - - ))} -
+ Skills ({config.skills.length}) +
)} -
- Backend -

- Type: {config.backend.type} - {config.backend.root_dir && ( - <> - {" · "}Root:{" "} - {config.backend.root_dir} - - )} -

-
+ {config.memory.length > 0 && ( +
+ Memories ({config.memory.length}) + +
+ )} {Object.keys(config.hitl.rules).length > 0 && (
diff --git a/src/application/components/agent/SkillMemorySelects.tsx b/src/application/components/agent/SkillMemorySelects.tsx new file mode 100644 index 0000000..62ccf31 --- /dev/null +++ b/src/application/components/agent/SkillMemorySelects.tsx @@ -0,0 +1,107 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { useStoreFiles } from "@/application/hooks/store/useStoreFiles"; +import { storeApi } from "@/infrastructure/api/store/storeApi"; +import { parseFrontmatter } from "@/application/lib/frontmatter"; +import PillMultiSelect, { + type PillMultiSelectOption, +} from "@/application/components/shared/PillMultiSelect"; + +const SKILLS_PREFIX = "/skills/"; +const MEMORIES_PREFIX = "/memories/"; + +/** + * Multi-select of available skills for use inside the agent form. Each option + * value is the skill directory path (e.g. `/skills/rag/`); labels come from + * each skill's frontmatter `name`. Skills are fetched in parallel via + * `useQueries` so we don't create an N+1 fetch waterfall. + */ +export function SkillPillMultiSelect({ + selected, + onChange, + readOnly = false, +}: Readonly<{ selected: string[]; onChange?: (selected: string[]) => void; readOnly?: boolean }>) { + const { data: files } = useStoreFiles(SKILLS_PREFIX); + + const skillPaths = useMemo( + () => + (files ?? []) + .map((f) => f.path) + .filter((p) => p.endsWith("SKILL.md")) + .sort(), + [files], + ); + + // Fetch every SKILL.md in parallel to read its frontmatter name. The query + // key mirrors useStoreFile so results are shared/cached with the viewer. + const fileResults = useQueries({ + queries: skillPaths.map((path) => ({ + queryKey: ["store-file", path], + queryFn: () => storeApi.getFile(path), + })), + }); + + const options = useMemo(() => { + return skillPaths.map((path, index) => { + const file = fileResults[index]?.data; + const dir = path.replace(/\/SKILL\.md$/, ""); + const name = file ? parseFrontmatter(file.content).data.name : null; + return { + value: `${dir}/`, + label: name ?? deriveNameFromPath(path), + description: file ? parseFrontmatter(file.content).data.description : undefined, + }; + }); + }, [skillPaths, fileResults]); + + return ( + + ); +} + +/** + * Multi-select of available memories for use inside the agent form. Each + * option value is the full memory path (e.g. `/memories/AGENTS.md`); the label + * is the filename. + */ +export function MemoryPillMultiSelect({ + selected, + onChange, + readOnly = false, +}: Readonly<{ selected: string[]; onChange?: (selected: string[]) => void; readOnly?: boolean }>) { + const { data: files } = useStoreFiles(MEMORIES_PREFIX); + + const options = useMemo( + () => + (files ?? []) + .map((f) => f.path) + .sort() + .map((path) => ({ + value: path, + label: path.split("/").filter(Boolean).pop() ?? path, + })), + [files], + ); + + return ( + + ); +} + +function deriveNameFromPath(path: string): string { + // /skills/rag/SKILL.md -> rag + const parts = path.split("/").filter(Boolean); + return parts.length >= 2 ? parts[1] : path; +} diff --git a/src/application/components/layout/Sidebar.tsx b/src/application/components/layout/Sidebar.tsx index c340807..1c01a0f 100644 --- a/src/application/components/layout/Sidebar.tsx +++ b/src/application/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { NavLink } from "react-router-dom"; -import { Bot, Database, MessagesSquare, Settings, X } from "lucide-react"; +import { BookOpen, Bot, Database, MessagesSquare, Settings, Sparkles, X } from "lucide-react"; import { cn } from "@/application/lib/utils"; import { useSidebarStore } from "@/application/stores/useSidebarStore"; @@ -14,6 +14,8 @@ interface NavItem { const NAV_ITEMS: readonly NavItem[] = [ { to: "/chat", label: "Orchestration", icon: MessagesSquare, odId: "nav-orchestration" }, { to: "/agents", label: "Agents", icon: Bot, odId: "nav-agents" }, + { to: "/skills", label: "Skills", icon: Sparkles, odId: "nav-skills" }, + { to: "/memories", label: "Memories", icon: BookOpen, odId: "nav-memories" }, { to: "/rag", label: "RAG Storage", icon: Database, odId: "nav-rag" }, ] as const; diff --git a/src/application/components/memory/CreateMemoryDialog.tsx b/src/application/components/memory/CreateMemoryDialog.tsx new file mode 100644 index 0000000..d172a44 --- /dev/null +++ b/src/application/components/memory/CreateMemoryDialog.tsx @@ -0,0 +1,132 @@ +import { useState, type FormEvent } from "react"; +import { toast } from "sonner"; +import { useCreateMemory } from "@/application/hooks/memory/useCreateMemory"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/application/components/ui/dialog"; +import { Button } from "@/application/components/ui/button"; +import { Input } from "@/application/components/ui/input"; +import { Label } from "@/application/components/ui/label"; +import { Textarea } from "@/application/components/ui/textarea"; + +interface CreateMemoryDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +const MEMORY_FORM_ID = "memory-create-form"; + +/** + * Dialog to create a new memory file: a name (becomes + * `/memories/{name}.md`) and a markdown body. No frontmatter for memories. + */ +export function CreateMemoryDialog({ + open, + onOpenChange, +}: Readonly) { + const [name, setName] = useState(""); + const [content, setContent] = useState(""); + const createMemory = useCreateMemory(); + + function handleClose() { + setName(""); + setContent(""); + onOpenChange(false); + } + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Memory name is required"); + return; + } + createMemory.mutate( + { name: trimmedName, content }, + { + onSuccess: () => { + toast.success("Memory created successfully"); + handleClose(); + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + } + + return ( + { + if (!v) handleClose(); + }} + > + +
+ + Create Memory + + Create a markdown memory file at + /memories/<name>.md. + + +
+ +
+ +
+ + setName(e.target.value)} + placeholder="AGENTS" + autoComplete="off" + spellCheck={false} + aria-label="Name" + /> +
+ +
+ +