fix: fix some displays and features
This commit is contained in:
@ -19,6 +19,7 @@ import { TauriTerminalGateway } from "./terminal";
|
||||
import { TauriLayoutGateway } from "./layout";
|
||||
import { TauriProfileGateway } from "./profile";
|
||||
import { TauriTemplateGateway } from "./template";
|
||||
import { TauriSkillGateway } from "./skill";
|
||||
import { TauriGitGateway } from "./git";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
@ -47,6 +48,7 @@ export function createTauriGateways(): Gateways {
|
||||
remote: new TauriRemoteGateway(),
|
||||
profile: new TauriProfileGateway(),
|
||||
template: new TauriTemplateGateway(),
|
||||
skill: new TauriSkillGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -58,5 +60,6 @@ export {
|
||||
TauriLayoutGateway,
|
||||
TauriProfileGateway,
|
||||
TauriTemplateGateway,
|
||||
TauriSkillGateway,
|
||||
TauriGitGateway,
|
||||
};
|
||||
|
||||
@ -23,12 +23,15 @@ import type {
|
||||
LayoutTree,
|
||||
Project,
|
||||
ProfileAvailability,
|
||||
Skill,
|
||||
SkillScope,
|
||||
Template,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
AgentGateway,
|
||||
CreateAgentInput,
|
||||
CreateSkillInput,
|
||||
CreateTemplateInput,
|
||||
Gateways,
|
||||
GitGateway,
|
||||
@ -38,6 +41,7 @@ import type {
|
||||
ProjectGateway,
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
SkillGateway,
|
||||
SystemGateway,
|
||||
TemplateGateway,
|
||||
TerminalGateway,
|
||||
@ -191,6 +195,7 @@ export class MockAgentGateway implements AgentGateway {
|
||||
profileId: input.profileId,
|
||||
origin: { type: "scratch" },
|
||||
synchronized: false,
|
||||
skills: [],
|
||||
};
|
||||
list.push(agent);
|
||||
this.contexts.set(
|
||||
@ -282,6 +287,18 @@ export class MockAgentGateway implements AgentGateway {
|
||||
return this.getAgents(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an agent's assigned skills in-place.
|
||||
* Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways
|
||||
* share the same in-memory store.
|
||||
*/
|
||||
_setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void {
|
||||
const list = this.getAgents(projectId);
|
||||
const idx = list.findIndex((a) => a.id === agentId);
|
||||
if (idx === -1) return;
|
||||
list[idx] = { ...list[idx], skills };
|
||||
}
|
||||
|
||||
async launchAgent(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
@ -931,6 +948,7 @@ export class MockTemplateGateway implements TemplateGateway {
|
||||
syncedTemplateVersion: template.version,
|
||||
},
|
||||
synchronized,
|
||||
skills: [],
|
||||
};
|
||||
|
||||
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
|
||||
@ -990,6 +1008,130 @@ export class MockTemplateGateway implements TemplateGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful in-memory skill gateway (L12).
|
||||
*
|
||||
* Skills are keyed by `${scope}` (project scope is further partitioned by
|
||||
* `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the
|
||||
* agent record held by the injected {@link MockAgentGateway}, so both gateways
|
||||
* share one store — exactly like {@link MockTemplateGateway}.
|
||||
*/
|
||||
export class MockSkillGateway implements SkillGateway {
|
||||
// Global skills are project-independent; project skills live under their id.
|
||||
private global: Skill[] = [];
|
||||
private byProject = new Map<string, Skill[]>();
|
||||
private seq = 0;
|
||||
|
||||
constructor(private readonly agentGateway: MockAgentGateway) {}
|
||||
|
||||
private bucket(projectId: string, scope: SkillScope): Skill[] {
|
||||
if (scope === "global") return this.global;
|
||||
let list = this.byProject.get(projectId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.byProject.set(projectId, list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||
return structuredClone(this.bucket(projectId, scope));
|
||||
}
|
||||
|
||||
async createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||
this.seq += 1;
|
||||
const skill: Skill = {
|
||||
id: `mock-skill-${this.seq}`,
|
||||
name: input.name,
|
||||
contentMd: input.content,
|
||||
scope: input.scope,
|
||||
};
|
||||
this.bucket(input.projectId, input.scope).push(skill);
|
||||
return structuredClone(skill);
|
||||
}
|
||||
|
||||
async updateSkill(
|
||||
projectId: string,
|
||||
scope: SkillScope,
|
||||
skillId: string,
|
||||
content: string,
|
||||
): Promise<Skill> {
|
||||
const list = this.bucket(projectId, scope);
|
||||
const idx = list.findIndex((s) => s.id === skillId);
|
||||
if (idx === -1) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `skill ${skillId} not found`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
list[idx] = { ...list[idx], contentMd: content };
|
||||
return structuredClone(list[idx]);
|
||||
}
|
||||
|
||||
async deleteSkill(
|
||||
projectId: string,
|
||||
scope: SkillScope,
|
||||
skillId: string,
|
||||
): Promise<void> {
|
||||
const list = this.bucket(projectId, scope);
|
||||
const idx = list.findIndex((s) => s.id === skillId);
|
||||
if (idx === -1) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `skill ${skillId} not found`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
list.splice(idx, 1);
|
||||
}
|
||||
|
||||
async assignSkill(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
scope: SkillScope,
|
||||
): Promise<void> {
|
||||
const agent = this.agentGateway
|
||||
._rawAgents(projectId)
|
||||
.find((a) => a.id === agentId);
|
||||
if (!agent) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} not found in project ${projectId}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent
|
||||
this.agentGateway._setSkills(projectId, agentId, [
|
||||
...agent.skills,
|
||||
{ skillId, scope },
|
||||
]);
|
||||
}
|
||||
|
||||
async unassignSkill(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
): Promise<void> {
|
||||
const agent = this.agentGateway
|
||||
._rawAgents(projectId)
|
||||
.find((a) => a.id === agentId);
|
||||
if (!agent) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} not found in project ${projectId}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.agentGateway._setSkills(
|
||||
projectId,
|
||||
agentId,
|
||||
agent.skills.filter((s) => s.skillId !== skillId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
@ -1003,6 +1145,7 @@ export function createMockGateways(): Gateways {
|
||||
remote: new MockRemoteGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
skill: new MockSkillGateway(agentGateway),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
||||
const gateways: Gateways = createMockGateways();
|
||||
|
||||
describe("createMockGateways", () => {
|
||||
it("exposes all nine gateways", () => {
|
||||
it("exposes all ten gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"git",
|
||||
@ -20,6 +20,7 @@ describe("createMockGateways", () => {
|
||||
"profile",
|
||||
"project",
|
||||
"remote",
|
||||
"skill",
|
||||
"system",
|
||||
"template",
|
||||
"terminal",
|
||||
|
||||
70
frontend/src/adapters/skill.ts
Normal file
70
frontend/src/adapters/skill.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tauri adapter for {@link SkillGateway} (L12).
|
||||
*
|
||||
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
|
||||
* with the other adapters in this directory. The `scope` value (`"global"` /
|
||||
* `"project"`) maps directly onto the backend `SkillScope` serde enum.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { Skill, SkillScope } from "@/domain";
|
||||
import type { CreateSkillInput, SkillGateway } from "@/ports";
|
||||
|
||||
export class TauriSkillGateway implements SkillGateway {
|
||||
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||
return invoke<Skill[]>("list_skills", { projectId, scope });
|
||||
}
|
||||
|
||||
createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||
return invoke<Skill>("create_skill", {
|
||||
request: {
|
||||
projectId: input.projectId,
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
scope: input.scope,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateSkill(
|
||||
projectId: string,
|
||||
scope: SkillScope,
|
||||
skillId: string,
|
||||
content: string,
|
||||
): Promise<Skill> {
|
||||
return invoke<Skill>("update_skill", {
|
||||
request: { projectId, scope, skillId, content },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSkill(
|
||||
projectId: string,
|
||||
scope: SkillScope,
|
||||
skillId: string,
|
||||
): Promise<void> {
|
||||
await invoke("delete_skill", { projectId, scope, skillId });
|
||||
}
|
||||
|
||||
async assignSkill(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
scope: SkillScope,
|
||||
): Promise<void> {
|
||||
await invoke("assign_skill_to_agent", {
|
||||
request: { projectId, agentId, skillId, scope },
|
||||
});
|
||||
}
|
||||
|
||||
async unassignSkill(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
): Promise<void> {
|
||||
await invoke("unassign_skill_from_agent", {
|
||||
request: { projectId, agentId, skillId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -236,6 +236,35 @@ export interface Agent {
|
||||
profileId: string;
|
||||
origin: AgentOrigin;
|
||||
synchronized: boolean;
|
||||
/** Skills assigned to this agent (injected into its convention file). */
|
||||
skills: SkillRef[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills (L12) — mirror of the domain `Skill` / `SkillRef`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Where a skill lives (selects its backing store): `global` skills are reusable
|
||||
* across projects; `project` skills are specific to one project's `.ideai/`.
|
||||
*/
|
||||
export type SkillScope = "global" | "project";
|
||||
|
||||
/**
|
||||
* A reusable, model-agnostic workflow assignable to agents (mirror of the
|
||||
* backend `Skill` DTO, camelCase wire format).
|
||||
*/
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
contentMd: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
|
||||
/** A reference from an agent to one assigned skill (mirror of `SkillRef`). */
|
||||
export interface SkillRef {
|
||||
skillId: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -54,6 +54,50 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
void templateGw.listTemplates().then(setTemplates).catch(() => {});
|
||||
}
|
||||
|
||||
// Skills available for assignment (global + project), loaded lazily once.
|
||||
const skillGw = gateways.skill ?? null;
|
||||
const [skills, setSkills] = useState<import("@/domain").Skill[]>([]);
|
||||
const [skillsLoaded, setSkillsLoaded] = useState(false);
|
||||
const [skillToAssign, setSkillToAssign] = useState("");
|
||||
|
||||
async function refreshSkills() {
|
||||
if (!skillGw) return;
|
||||
try {
|
||||
const [globals, projects] = await Promise.all([
|
||||
skillGw.listSkills(projectId, "global"),
|
||||
skillGw.listSkills(projectId, "project"),
|
||||
]);
|
||||
setSkills([...projects, ...globals]);
|
||||
} catch {
|
||||
// Skills are optional — leave the list empty on failure.
|
||||
}
|
||||
}
|
||||
|
||||
if (!skillsLoaded && skillGw) {
|
||||
setSkillsLoaded(true);
|
||||
void refreshSkills();
|
||||
}
|
||||
|
||||
async function handleAssignSkill() {
|
||||
if (!skillGw || !vm.selectedAgentId || !skillToAssign) return;
|
||||
const target = skills.find((s) => s.id === skillToAssign);
|
||||
if (!target) return;
|
||||
await skillGw.assignSkill(
|
||||
projectId,
|
||||
vm.selectedAgentId,
|
||||
target.id,
|
||||
target.scope,
|
||||
);
|
||||
setSkillToAssign("");
|
||||
await vm.refresh();
|
||||
}
|
||||
|
||||
async function handleUnassignSkill(skillId: string) {
|
||||
if (!skillGw || !vm.selectedAgentId) return;
|
||||
await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId);
|
||||
await vm.refresh();
|
||||
}
|
||||
|
||||
// Context editor state — local copy before Save
|
||||
const [editedContext, setEditedContext] = useState(vm.context);
|
||||
|
||||
@ -351,6 +395,79 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Skill assignment ── */}
|
||||
{selectedAgent && skillGw && (
|
||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Skills — {selectedAgent.name}
|
||||
</h4>
|
||||
|
||||
{/* Assigned skills */}
|
||||
{selectedAgent.skills.length === 0 ? (
|
||||
<p className="text-xs text-muted">No skills assigned.</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{selectedAgent.skills.map((ref) => {
|
||||
const name =
|
||||
skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId;
|
||||
return (
|
||||
<li
|
||||
key={ref.skillId}
|
||||
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
|
||||
>
|
||||
<span>{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`unassign ${name}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void handleUnassignSkill(ref.skillId)}
|
||||
className="text-faint hover:text-danger"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Assign selector */}
|
||||
<div className="flex items-end gap-2">
|
||||
<select
|
||||
aria-label="skill to assign"
|
||||
value={skillToAssign}
|
||||
onChange={(e) => setSkillToAssign(e.target.value)}
|
||||
disabled={vm.busy}
|
||||
className={cn(
|
||||
"h-9 min-w-0 flex-1 rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="">— assign a skill —</option>
|
||||
{skills
|
||||
.filter(
|
||||
(s) =>
|
||||
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
||||
)
|
||||
.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.scope})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
aria-label="assign skill"
|
||||
disabled={vm.busy || !skillToAssign}
|
||||
onClick={() => void handleAssignSkill()}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Context editor ── */}
|
||||
{selectedAgent && (
|
||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||
|
||||
@ -34,17 +34,19 @@ import type { LayoutInfo } from "@/domain";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { GitPanel, GitGraphView } from "@/features/git";
|
||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjects } from "./useProjects";
|
||||
|
||||
type SidebarTab = "projects" | "agents" | "templates" | "git";
|
||||
type SidebarTab = "projects" | "agents" | "templates" | "skills" | "git";
|
||||
|
||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ id: "projects", label: "Projects" },
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "git", label: "Git" },
|
||||
];
|
||||
|
||||
@ -259,6 +261,14 @@ export function ProjectsView() {
|
||||
<p className="text-sm text-muted">Open a project to manage templates.</p>
|
||||
)}
|
||||
|
||||
{/* Skills panel */}
|
||||
{sidebarTab === "skills" && active && (
|
||||
<SkillsPanel projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "skills" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||||
)}
|
||||
|
||||
{/* Git panel */}
|
||||
{sidebarTab === "git" && active && (
|
||||
<GitPanel projectId={active.id} />
|
||||
|
||||
207
frontend/src/features/skills/SkillEditor.tsx
Normal file
207
frontend/src/features/skills/SkillEditor.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
/**
|
||||
* `SkillEditor` — a fullscreen overlay for creating or editing skills (L12).
|
||||
* Rendered on top of the rest of the UI (`fixed inset-0`).
|
||||
*
|
||||
* Provides:
|
||||
* - Name field and scope selector (create-mode only; scope is fixed in edit-mode)
|
||||
* - Two tabs: "Edit" (textarea) and "Preview" (react-markdown)
|
||||
* - Save (create/update) and Cancel/Close buttons
|
||||
*
|
||||
* Pure presentation: all mutations are delegated to the callbacks supplied by
|
||||
* the parent (`SkillsPanel`). Styled with `@/shared`; no inline styles.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import type { Skill, SkillScope } from "@/domain";
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
|
||||
export interface SkillEditorProps {
|
||||
/**
|
||||
* When set, the editor is in edit-mode for the given skill; otherwise it is in
|
||||
* create-mode (all fields start empty).
|
||||
*/
|
||||
skill?: Skill | null;
|
||||
/** Called when the user submits the form. */
|
||||
onSave: (name: string, content: string, scope: SkillScope) => Promise<void>;
|
||||
/** Called when the user cancels / closes the overlay. */
|
||||
onClose: () => void;
|
||||
/** Whether a save operation is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
type EditorTab = "edit" | "preview";
|
||||
|
||||
export function SkillEditor({
|
||||
skill,
|
||||
onSave,
|
||||
onClose,
|
||||
busy = false,
|
||||
}: SkillEditorProps) {
|
||||
const [name, setName] = useState(skill?.name ?? "");
|
||||
const [content, setContent] = useState(skill?.contentMd ?? "");
|
||||
const [scope, setScope] = useState<SkillScope>(skill?.scope ?? "project");
|
||||
const [tab, setTab] = useState<EditorTab>("edit");
|
||||
|
||||
const editing = skill != null;
|
||||
const canSave = name.trim().length > 0 && content.trim().length > 0 && !busy;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSave) return;
|
||||
await onSave(name.trim(), content, scope);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="skill editor"
|
||||
>
|
||||
{/* ── Header bar ── */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-content">
|
||||
{editing ? `Edit skill — ${skill.name}` : "New skill"}
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="close skill editor"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Form body ── */}
|
||||
<form
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
className="flex flex-1 flex-col gap-0 overflow-hidden"
|
||||
>
|
||||
{/* ── Meta fields ── */}
|
||||
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label htmlFor="se-name" className="text-xs font-medium text-muted">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="se-name"
|
||||
aria-label="skill name"
|
||||
placeholder="My skill"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={busy}
|
||||
className="min-w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<label htmlFor="se-scope" className="text-xs font-medium text-muted">
|
||||
Scope
|
||||
</label>
|
||||
<select
|
||||
id="se-scope"
|
||||
aria-label="skill scope"
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value as SkillScope)}
|
||||
disabled={busy || editing}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="project">Project</option>
|
||||
<option value="global">Global</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tab strip ── */}
|
||||
<div className="flex shrink-0 items-center gap-0 border-b border-border px-4">
|
||||
{(["edit", "preview"] as EditorTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label={t === "edit" ? "Edit" : "Preview"}
|
||||
aria-selected={tab === t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium capitalize transition-colors",
|
||||
tab === t
|
||||
? "border-b-2 border-primary text-content"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{t === "edit" ? "Edit" : "Preview"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Edit / Preview pane ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
|
||||
{tab === "edit" ? (
|
||||
<textarea
|
||||
aria-label="skill content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
|
||||
"border border-border outline-none transition-colors resize-none",
|
||||
"focus:border-primary placeholder:text-faint",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
placeholder="# Skill workflow ..."
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-auto rounded-md border border-border bg-raised px-6 py-4",
|
||||
"prose prose-sm prose-invert max-w-none",
|
||||
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
|
||||
"[&_p]:text-content/90 [&_li]:text-content/90",
|
||||
"[&_code]:bg-canvas [&_code]:text-primary [&_code]:rounded [&_code]:px-1",
|
||||
"[&_pre]:bg-canvas [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md",
|
||||
"[&_a]:text-primary [&_a:hover]:underline",
|
||||
"[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:text-muted",
|
||||
"[&_hr]:border-border",
|
||||
)}
|
||||
>
|
||||
{content.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted italic">
|
||||
Nothing to preview yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
aria-label="Save skill"
|
||||
disabled={!canSave}
|
||||
loading={busy}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
frontend/src/features/skills/SkillsPanel.tsx
Normal file
154
frontend/src/features/skills/SkillsPanel.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* `SkillsPanel` — feature component for skill management (L12).
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useSkills}. Styled with
|
||||
* `@/shared` design system tokens; no inline styles.
|
||||
*
|
||||
* Provides, per scope (Global + Project):
|
||||
* - Skill list (name + scope)
|
||||
* - "New skill" button that opens {@link SkillEditor} in create-mode
|
||||
* - Per-skill "Edit" (opens the editor in edit-mode) and "Delete"
|
||||
*
|
||||
* Skills are assigned to agents from the `AgentsPanel`, not here — this panel is
|
||||
* the authoring surface only ("no direct invoke": a skill is never run from the
|
||||
* UI, only injected into an agent's convention file at activation).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Skill, SkillScope } from "@/domain";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
import { SkillEditor } from "./SkillEditor";
|
||||
import { useSkills } from "./useSkills";
|
||||
|
||||
export interface SkillsPanelProps {
|
||||
/** The project whose skills to manage. */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** Editor open-state: "create" or edit-mode for a specific skill. */
|
||||
type EditorState = { mode: "create" } | { mode: "edit"; skill: Skill };
|
||||
|
||||
export function SkillsPanel({ projectId }: SkillsPanelProps) {
|
||||
const vm = useSkills(projectId);
|
||||
|
||||
const [editorState, setEditorState] = useState<EditorState | null>(null);
|
||||
const [editorBusy, setEditorBusy] = useState(false);
|
||||
|
||||
async function handleSave(name: string, content: string, scope: SkillScope) {
|
||||
if (!editorState) return;
|
||||
setEditorBusy(true);
|
||||
try {
|
||||
if (editorState.mode === "create") {
|
||||
await vm.createSkill({ name, content, scope });
|
||||
} else {
|
||||
await vm.updateSkill(editorState.skill.scope, editorState.skill.id, content);
|
||||
}
|
||||
setEditorState(null);
|
||||
} finally {
|
||||
setEditorBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(scope: SkillScope, skills: Skill[]) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
{skills.length === 0 ? (
|
||||
<p className="text-sm text-muted">No {scope} skills yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{skills.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="min-w-0 font-medium text-content">{s.name}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`edit skill ${s.name}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => setEditorState({ mode: "edit", skill: s })}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`delete skill ${s.name}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.deleteSkill(s.scope, s.id)}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Fullscreen skill editor overlay ── */}
|
||||
{editorState !== null && (
|
||||
<SkillEditor
|
||||
skill={editorState.mode === "edit" ? editorState.skill : null}
|
||||
onSave={handleSave}
|
||||
onClose={() => setEditorState(null)}
|
||||
busy={editorBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Panel title="Skills" className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Create button ── */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Skills
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
aria-label="create skill"
|
||||
disabled={vm.busy}
|
||||
onClick={() => setEditorState({ mode: "create" })}
|
||||
>
|
||||
New skill
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Project skills ── */}
|
||||
<div className="border-b border-border px-4 pt-3">
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Project
|
||||
</h5>
|
||||
</div>
|
||||
{renderList("project", vm.projectSkills)}
|
||||
|
||||
{/* ── Global skills ── */}
|
||||
<div className="border-y border-border px-4 pt-3">
|
||||
<h5 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Global
|
||||
</h5>
|
||||
</div>
|
||||
{renderList("global", vm.globalSkills)}
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
6
frontend/src/features/skills/index.ts
Normal file
6
frontend/src/features/skills/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** Public surface of the skills feature (L12). */
|
||||
export { SkillsPanel } from "./SkillsPanel";
|
||||
export type { SkillsPanelProps } from "./SkillsPanel";
|
||||
export { SkillEditor } from "./SkillEditor";
|
||||
export { useSkills } from "./useSkills";
|
||||
export type { SkillsViewModel } from "./useSkills";
|
||||
297
frontend/src/features/skills/skills.test.tsx
Normal file
297
frontend/src/features/skills/skills.test.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
/**
|
||||
* L12 — skills feature + agent assignment, wired to the stateful
|
||||
* `MockSkillGateway` (sharing a `MockAgentGateway`) via the real `DIProvider`.
|
||||
*
|
||||
* Covers:
|
||||
* - createSkill (project + global) → skill appears under the right scope
|
||||
* - updateSkill → content persisted in the gateway
|
||||
* - deleteSkill → skill removed from the list
|
||||
* - assignSkill / unassignSkill in AgentsPanel → reflected on the agent record
|
||||
* - guardrail "no direct invoke": SkillsPanel never offers a run/launch action
|
||||
*
|
||||
* Also includes MockSkillGateway unit tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockProfileGateway,
|
||||
MockSkillGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { SkillsPanel } from "./SkillsPanel";
|
||||
import { AgentsPanel } from "@/features/agents/AgentsPanel";
|
||||
|
||||
const PROJECT_ID = "proj-skill-test";
|
||||
|
||||
function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
|
||||
const a = agent ?? new MockAgentGateway();
|
||||
const sk = skill ?? new MockSkillGateway(a);
|
||||
const gateways = { agent: a, skill: sk } as unknown as Gateways;
|
||||
return {
|
||||
agent: a,
|
||||
skill: sk,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<SkillsPanel projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
|
||||
const profile = new MockProfileGateway();
|
||||
const gateways = { agent, profile, skill } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSkillsIdle() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "create skill" })).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the SkillEditor, fills it, and saves. */
|
||||
async function createSkill(
|
||||
name: string,
|
||||
content = "# Workflow",
|
||||
scope: "project" | "global" = "project",
|
||||
) {
|
||||
await waitForSkillsIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("skill name")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("skill name"), {
|
||||
target: { value: name },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("skill content"), {
|
||||
target: { value: content },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("skill scope"), {
|
||||
target: { value: scope },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillsPanel feature tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SkillsPanel (with MockSkillGateway)", () => {
|
||||
it("shows empty state for both scopes initially", async () => {
|
||||
renderSkillsPanel();
|
||||
await waitForSkillsIdle();
|
||||
expect(screen.getByText("No project skills yet.")).toBeTruthy();
|
||||
expect(screen.getByText("No global skills yet.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating a project skill adds it to the list", async () => {
|
||||
const { skill } = renderSkillsPanel();
|
||||
await createSkill("Refactor", "## steps", "project");
|
||||
await screen.findByText("Refactor");
|
||||
const projects = await skill.listSkills(PROJECT_ID, "project");
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0].name).toBe("Refactor");
|
||||
});
|
||||
|
||||
it("creating a global skill stores it under the global scope", async () => {
|
||||
const { skill } = renderSkillsPanel();
|
||||
await createSkill("Review", "## global", "global");
|
||||
await screen.findByText("Review");
|
||||
expect(await skill.listSkills(PROJECT_ID, "global")).toHaveLength(1);
|
||||
expect(await skill.listSkills(PROJECT_ID, "project")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Save is disabled when name or content is empty", async () => {
|
||||
renderSkillsPanel();
|
||||
await waitForSkillsIdle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "create skill" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Save skill" })).toBeTruthy(),
|
||||
);
|
||||
const save = screen.getByRole("button", {
|
||||
name: "Save skill",
|
||||
}) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("editing a skill persists the new content", async () => {
|
||||
const { skill } = renderSkillsPanel();
|
||||
await createSkill("Editable", "old", "project");
|
||||
await screen.findByText("Editable");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "edit skill Editable" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("skill content")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("skill content"), {
|
||||
target: { value: "new content" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save skill" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const list = await skill.listSkills(PROJECT_ID, "project");
|
||||
expect(list[0].contentMd).toBe("new content");
|
||||
});
|
||||
});
|
||||
|
||||
it("deleting a skill removes it from the list", async () => {
|
||||
renderSkillsPanel();
|
||||
await createSkill("ToDelete", "x", "project");
|
||||
await screen.findByText("ToDelete");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "delete skill ToDelete" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("ToDelete")).toBeNull());
|
||||
});
|
||||
|
||||
it("guardrail: never offers a run/launch action for a skill (no direct invoke)", async () => {
|
||||
renderSkillsPanel();
|
||||
await createSkill("Plain", "x", "project");
|
||||
await screen.findByText("Plain");
|
||||
// Only Edit/Delete are exposed; nothing that would "run" the skill.
|
||||
expect(screen.queryByRole("button", { name: /^run/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^launch/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /invoke/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignment integration (AgentsPanel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
|
||||
it("assigns a skill to the selected agent and shows it as a chip", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const skill = new MockSkillGateway(agent);
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Worker",
|
||||
profileId: "p1",
|
||||
});
|
||||
await skill.createSkill({
|
||||
projectId: PROJECT_ID,
|
||||
name: "Deploy",
|
||||
content: "## deploy",
|
||||
scope: "project",
|
||||
});
|
||||
|
||||
renderAgentsPanel(agent, skill);
|
||||
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||
|
||||
// Select the agent
|
||||
fireEvent.click(screen.getByText("Worker"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("skill to assign")).toBeTruthy(),
|
||||
);
|
||||
|
||||
// Choose the skill and assign
|
||||
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
|
||||
fireEvent.change(screen.getByLabelText("skill to assign"), {
|
||||
target: { value: sk.id },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
|
||||
|
||||
// The agent record now carries the skill ref
|
||||
await waitFor(async () => {
|
||||
const agents = await agent.listAgents(PROJECT_ID);
|
||||
const a = agents.find((x) => x.id === created.id)!;
|
||||
expect(a.skills.map((s) => s.skillId)).toContain(sk.id);
|
||||
});
|
||||
// Chip with an unassign control appears
|
||||
expect(screen.getByRole("button", { name: "unassign Deploy" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("unassigns a skill from the agent", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const skill = new MockSkillGateway(agent);
|
||||
const a = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Worker2",
|
||||
profileId: "p1",
|
||||
});
|
||||
const sk = await skill.createSkill({
|
||||
projectId: PROJECT_ID,
|
||||
name: "Cleanup",
|
||||
content: "## cleanup",
|
||||
scope: "global",
|
||||
});
|
||||
await skill.assignSkill(PROJECT_ID, a.id, sk.id, sk.scope);
|
||||
|
||||
renderAgentsPanel(agent, skill);
|
||||
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
|
||||
fireEvent.click(screen.getByText("Worker2"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "unassign Cleanup" }),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "unassign Cleanup" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const agents = await agent.listAgents(PROJECT_ID);
|
||||
expect(agents.find((x) => x.id === a.id)!.skills).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockSkillGateway unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MockSkillGateway (unit)", () => {
|
||||
it("scopes are isolated: a project skill never appears in global and vice-versa", async () => {
|
||||
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||
await gw.createSkill({
|
||||
projectId: "p",
|
||||
name: "P",
|
||||
content: "x",
|
||||
scope: "project",
|
||||
});
|
||||
await gw.createSkill({
|
||||
projectId: "p",
|
||||
name: "G",
|
||||
content: "y",
|
||||
scope: "global",
|
||||
});
|
||||
expect(await gw.listSkills("p", "project")).toHaveLength(1);
|
||||
expect(await gw.listSkills("p", "global")).toHaveLength(1);
|
||||
expect((await gw.listSkills("p", "project"))[0].name).toBe("P");
|
||||
expect((await gw.listSkills("p", "global"))[0].name).toBe("G");
|
||||
});
|
||||
|
||||
it("updateSkill throws NOT_FOUND for unknown skill", async () => {
|
||||
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||
await expect(
|
||||
gw.updateSkill("p", "project", "ghost", "x"),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("assignSkill is idempotent (no duplicate refs)", async () => {
|
||||
const agentGw = new MockAgentGateway();
|
||||
const gw = new MockSkillGateway(agentGw);
|
||||
const a = await agentGw.createAgent("p", { name: "A", profileId: "p1" });
|
||||
const s = await gw.createSkill({
|
||||
projectId: "p",
|
||||
name: "S",
|
||||
content: "x",
|
||||
scope: "project",
|
||||
});
|
||||
await gw.assignSkill("p", a.id, s.id, s.scope);
|
||||
await gw.assignSkill("p", a.id, s.id, s.scope);
|
||||
const agents = await agentGw.listAgents("p");
|
||||
expect(agents[0].skills).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("assignSkill throws NOT_FOUND for unknown agent", async () => {
|
||||
const gw = new MockSkillGateway(new MockAgentGateway());
|
||||
await expect(
|
||||
gw.assignSkill("p", "ghost", "s", "project"),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
145
frontend/src/features/skills/useSkills.ts
Normal file
145
frontend/src/features/skills/useSkills.ts
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* `useSkills` — view-model hook for the skills feature (L12).
|
||||
*
|
||||
* Owns the skill lists for both scopes (`global` + `project`) of one project and
|
||||
* the CRUD actions. Consumes {@link SkillGateway} exclusively; never touches
|
||||
* `invoke()` or `@tauri-apps/api`, keeping the component layer testable with
|
||||
* mock gateways (ARCHITECTURE §1.3).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, Skill, SkillScope } from "@/domain";
|
||||
import type { CreateSkillInput } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the skills UI needs from this hook. */
|
||||
export interface SkillsViewModel {
|
||||
/** Global skills (reusable across projects). */
|
||||
globalSkills: Skill[];
|
||||
/** Project-scoped skills. */
|
||||
projectSkills: Skill[];
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Reloads both scope lists. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Creates a new skill and refreshes its scope's list. */
|
||||
createSkill: (input: Omit<CreateSkillInput, "projectId">) => Promise<void>;
|
||||
/** Updates the content of an existing skill. */
|
||||
updateSkill: (
|
||||
scope: SkillScope,
|
||||
skillId: string,
|
||||
content: string,
|
||||
) => Promise<void>;
|
||||
/** Deletes a skill by id from its scope. */
|
||||
deleteSkill: (scope: SkillScope, skillId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function useSkills(projectId: string): SkillsViewModel {
|
||||
const { skill } = useGateways();
|
||||
|
||||
const [globalSkills, setGlobalSkills] = useState<Skill[]>([]);
|
||||
const [projectSkills, setProjectSkills] = useState<Skill[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [globals, projects] = await Promise.all([
|
||||
skill.listSkills(projectId, "global"),
|
||||
skill.listSkills(projectId, "project"),
|
||||
]);
|
||||
setGlobalSkills(globals);
|
||||
setProjectSkills(projects);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [skill, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const setter = (scope: SkillScope) =>
|
||||
scope === "global" ? setGlobalSkills : setProjectSkills;
|
||||
|
||||
const createSkill = useCallback(
|
||||
async (input: Omit<CreateSkillInput, "projectId">) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await skill.createSkill({ ...input, projectId });
|
||||
setter(input.scope)((prev) => [...prev, created]);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[skill, projectId],
|
||||
);
|
||||
|
||||
const updateSkill = useCallback(
|
||||
async (scope: SkillScope, skillId: string, content: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await skill.updateSkill(
|
||||
projectId,
|
||||
scope,
|
||||
skillId,
|
||||
content,
|
||||
);
|
||||
setter(scope)((prev) =>
|
||||
prev.map((s) => (s.id === skillId ? updated : s)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[skill, projectId],
|
||||
);
|
||||
|
||||
const deleteSkill = useCallback(
|
||||
async (scope: SkillScope, skillId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await skill.deleteSkill(projectId, scope, skillId);
|
||||
setter(scope)((prev) => prev.filter((s) => s.id !== skillId));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[skill, projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
globalSkills,
|
||||
projectSkills,
|
||||
error,
|
||||
busy,
|
||||
refresh,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
};
|
||||
}
|
||||
@ -25,6 +25,8 @@ import type {
|
||||
LayoutTree,
|
||||
Project,
|
||||
ProfileAvailability,
|
||||
Skill,
|
||||
SkillScope,
|
||||
Template,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
@ -260,6 +262,53 @@ export interface TemplateGateway {
|
||||
): Promise<{ synced: boolean; version: number | null }>;
|
||||
}
|
||||
|
||||
/** Input for {@link SkillGateway.createSkill}. */
|
||||
export interface CreateSkillInput {
|
||||
/** Owning project (resolved to a root; ignored on disk for `global`). */
|
||||
projectId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills (L12): CRUD for reusable, model-agnostic workflows in either scope, and
|
||||
* agent↔skill assignment. Assigned skills are injected into the agent's
|
||||
* convention file at activation (handled backend-side, ARCHITECTURE §14.2).
|
||||
*/
|
||||
export interface SkillGateway {
|
||||
/** Lists the skills in one scope for the given project. */
|
||||
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]>;
|
||||
/** Creates a new skill; returns the created skill. */
|
||||
createSkill(input: CreateSkillInput): Promise<Skill>;
|
||||
/** Updates a skill's content; returns the updated skill. */
|
||||
updateSkill(
|
||||
projectId: string,
|
||||
scope: SkillScope,
|
||||
skillId: string,
|
||||
content: string,
|
||||
): Promise<Skill>;
|
||||
/** Deletes a skill by id from its scope's store. */
|
||||
deleteSkill(
|
||||
projectId: string,
|
||||
scope: SkillScope,
|
||||
skillId: string,
|
||||
): Promise<void>;
|
||||
/** Assigns a skill to an agent (idempotent). */
|
||||
assignSkill(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
scope: SkillScope,
|
||||
): Promise<void>;
|
||||
/** Unassigns a skill from an agent (idempotent). */
|
||||
unassignSkill(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
skillId: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI profiles & first-run (L5). Drives the first-run wizard and profile
|
||||
* management: the pre-filled reference catalogue, detection of installed CLIs,
|
||||
@ -296,4 +345,5 @@ export interface Gateways {
|
||||
remote: RemoteGateway;
|
||||
profile: ProfileGateway;
|
||||
template: TemplateGateway;
|
||||
skill: SkillGateway;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user