fix: fix some displays and features

This commit is contained in:
2026-06-06 17:06:45 +02:00
parent 2332b7f815
commit 3be55795a6
31 changed files with 3118 additions and 30 deletions

View File

@ -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,
};

View File

@ -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),
};
}

View File

@ -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",

View 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 },
});
}
}

View File

@ -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;
}
// ---------------------------------------------------------------------------

View File

@ -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">

View File

@ -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} />

View 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&#10;&#10;..."
/>
) : (
<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>
);
}

View 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>
</>
);
}

View 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";

View 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" });
});
});

View 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,
};
}

View File

@ -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;
}