mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
6 Commits
agent/lamb
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb75a74d3 | ||
|
|
e205ab6f8d | ||
|
|
c15fc65bae | ||
|
|
45dd4f9fca | ||
|
|
d77af9e672 | ||
|
|
9221768908 |
17
apps/desktop/src/renderer/src/pages/skill-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/skill-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SkillDetailPage as SharedSkillDetailPage } from "@multica/views/skills";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillDetailOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function SkillDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: skill } = useQuery(skillDetailOptions(wsId, id ?? ""));
|
||||
|
||||
useDocumentTitle(skill?.name ?? "Skill");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedSkillDetailPage skillId={id} />;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { RouteObject } from "react-router-dom";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
@@ -118,6 +119,11 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{
|
||||
path: "skills/:id",
|
||||
element: <SkillDetailPage />,
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { SkillDetailPage } from "@multica/views/skills";
|
||||
|
||||
export default function SkillDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <SkillDetailPage skillId={id} />;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ describe("paths.workspace(slug)", () => {
|
||||
expect(ws.myIssues()).toBe("/acme/my-issues");
|
||||
expect(ws.runtimes()).toBe("/acme/runtimes");
|
||||
expect(ws.skills()).toBe("/acme/skills");
|
||||
expect(ws.skillDetail("skl_123")).toBe("/acme/skills/skl_123");
|
||||
expect(ws.settings()).toBe("/acme/settings");
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ function workspaceScoped(slug: string) {
|
||||
myIssues: () => `${ws}/my-issues`,
|
||||
runtimes: () => `${ws}/runtimes`,
|
||||
skills: () => `${ws}/skills`,
|
||||
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
|
||||
settings: () => `${ws}/settings`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { Workspace } from "../types";
|
||||
import type { Agent, Workspace } from "../types";
|
||||
|
||||
export const workspaceKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId] as const,
|
||||
@@ -50,6 +50,42 @@ export function skillListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function skillDetailOptions(wsId: string, skillId: string) {
|
||||
return queryOptions({
|
||||
queryKey: [...workspaceKeys.skills(wsId), skillId] as const,
|
||||
queryFn: () => api.getSkill(skillId),
|
||||
enabled: !!skillId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a `Map<skillId, Agent[]>` from the cached agent list. The server
|
||||
* already returns each agent with its full skill list inline, so no extra
|
||||
* request is needed — "which agents use skill X" is pure client-side fold.
|
||||
*
|
||||
* Exposed as a plain helper rather than a `queryOptions` with `select` so
|
||||
* the Map's identity is stable across unrelated agent-cache rerenders —
|
||||
* callers wrap this in `useMemo(..., [agents])` and only re-fold when the
|
||||
* agent array identity actually changes. Previously this was `{ select }`,
|
||||
* which returned a new Map every subscription tick and triggered cascading
|
||||
* re-renders on every `agent:updated` WS event.
|
||||
*/
|
||||
export function selectSkillAssignments(
|
||||
agents: Agent[] | undefined,
|
||||
): Map<string, Agent[]> {
|
||||
const map = new Map<string, Agent[]>();
|
||||
if (!agents) return map;
|
||||
for (const a of agents) {
|
||||
if (a.archived_at) continue;
|
||||
for (const s of a.skills ?? []) {
|
||||
const existing = map.get(s.id);
|
||||
if (existing) existing.push(a);
|
||||
else map.set(s.id, [a]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function invitationListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.invitations(wsId),
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useImmersiveMode } from "./use-immersive-mode";
|
||||
export { DragStrip } from "./drag-strip";
|
||||
export { openExternal } from "./open-external";
|
||||
|
||||
28
packages/views/platform/open-external.ts
Normal file
28
packages/views/platform/open-external.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Open a URL in the user's default browser, regardless of platform.
|
||||
*
|
||||
* On Electron (desktop) this routes through `window.desktopAPI.openExternal`,
|
||||
* which in turn calls the IPC-gated `shell.openExternal` in the main process —
|
||||
* that's the only channel with the `http/https`-only guard. Direct
|
||||
* `window.open(url, "_blank")` inside Electron would create a new renderer
|
||||
* window instead of handing the URL to the OS shell.
|
||||
*
|
||||
* On web this falls back to `window.open` with the standard `noopener`+
|
||||
* `noreferrer` flags, which is the same thing an `<a target="_blank">` would
|
||||
* do but without requiring markup.
|
||||
*
|
||||
* SSR-safe: no-op if `window` is not defined.
|
||||
*/
|
||||
export function openExternal(url: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const desktopAPI = (
|
||||
window as unknown as {
|
||||
desktopAPI?: { openExternal?: (u: string) => Promise<void> | void };
|
||||
}
|
||||
).desktopAPI;
|
||||
if (desktopAPI?.openExternal) {
|
||||
void desktopAPI.openExternal(url);
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
561
packages/views/skills/components/create-skill-dialog.tsx
Normal file
561
packages/views/skills/components/create-skill-dialog.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
X as XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Skill } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
skillDetailOptions,
|
||||
workspaceKeys,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { openExternal } from "../../platform";
|
||||
import { RuntimeLocalSkillImportPanel } from "./runtime-local-skill-import-panel";
|
||||
|
||||
type Method = "chooser" | "manual" | "url" | "runtime";
|
||||
|
||||
/** After create/import, seed the detail cache with the freshly-returned skill
|
||||
* so the next navigation renders immediately — no extra round-trip. Also
|
||||
* refreshes skills/agents lists since the new row needs to surface. */
|
||||
function seedAfterCreate(
|
||||
qc: ReturnType<typeof useQueryClient>,
|
||||
wsId: string,
|
||||
skill: Skill,
|
||||
) {
|
||||
qc.setQueryData(skillDetailOptions(wsId, skill.id).queryKey, skill);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
}
|
||||
|
||||
/** Matches only name-conflict errors so we don't confuse users with the
|
||||
* "try a different name" hint when the real problem is something else. */
|
||||
function isNameConflictError(msg: string): boolean {
|
||||
return /\b(409|conflict|already exists|unique constraint)\b/i.test(msg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chooser — initial method picker (3 cards)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MethodChooser({ onChoose }: { onChoose: (m: Method) => void }) {
|
||||
const methods: {
|
||||
key: Method;
|
||||
icon: typeof Plus;
|
||||
title: string;
|
||||
desc: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "manual",
|
||||
icon: Plus,
|
||||
title: "Create manually",
|
||||
desc: "Start from a blank SKILL.md and write your own instructions.",
|
||||
},
|
||||
{
|
||||
key: "url",
|
||||
icon: Download,
|
||||
title: "Import from URL",
|
||||
desc: "Pull a published skill from ClawHub or Skills.sh.",
|
||||
},
|
||||
{
|
||||
key: "runtime",
|
||||
icon: HardDrive,
|
||||
title: "Copy from runtime",
|
||||
desc: "Promote a skill already installed on your local runtime.",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid gap-2 p-5">
|
||||
{methods.map(({ key, icon: Icon, title, desc }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onChoose(key)}
|
||||
className="group flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground group-hover:text-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{desc}</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ManualForm({
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreated: (skill: Skill) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const skill = await api.createSkill({
|
||||
name: trimmed,
|
||||
description: description.trim(),
|
||||
});
|
||||
seedAfterCreate(qc, wsId, skill);
|
||||
toast.success("Skill created");
|
||||
onCreated(skill);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create skill");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 min-h-0 space-y-4 overflow-y-auto px-5 py-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="create-skill-name"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="create-skill-name"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="e.g. review-helper"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be unique within the workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="create-skill-desc"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="create-skill-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="One sentence on when to assign this skill to an agent."
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{error}
|
||||
{isNameConflictError(error) && (
|
||||
<> Try a different name and submit again.</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t bg-muted/30 px-5 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!name.trim() || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
"Create skill"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL import form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DetectedSource = "clawhub" | "skills.sh" | null;
|
||||
|
||||
function detectUrlSource(url: string): DetectedSource {
|
||||
const u = url.trim().toLowerCase();
|
||||
if (u.includes("clawhub.ai")) return "clawhub";
|
||||
if (u.includes("skills.sh")) return "skills.sh";
|
||||
return null;
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
label,
|
||||
exampleHost,
|
||||
browseUrl,
|
||||
active,
|
||||
}: {
|
||||
label: string;
|
||||
exampleHost: string;
|
||||
browseUrl: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border px-3 py-2.5 transition-colors ${
|
||||
active ? "border-primary bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">{label}</div>
|
||||
{/* Host example doubles as a "browse" link — brand-colored underline
|
||||
matches the repo's convention for in-flow links (see editor CSS). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal(browseUrl)}
|
||||
className="mt-0.5 block max-w-full truncate text-left font-mono text-xs text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand"
|
||||
>
|
||||
{exampleHost}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlForm({
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreated: (skill: Skill) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [url, setUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const source = detectUrlSource(url);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const skill = await api.importSkill({ url: trimmed });
|
||||
seedAfterCreate(qc, wsId, skill);
|
||||
toast.success("Skill imported");
|
||||
onCreated(skill);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Import failed");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submittingLabel = (() => {
|
||||
if (!loading) return "Import";
|
||||
if (source === "clawhub") return "Importing from ClawHub…";
|
||||
if (source === "skills.sh") return "Importing from Skills.sh…";
|
||||
return "Importing…";
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 min-h-0 space-y-4 overflow-y-auto px-5 py-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="import-url" className="text-xs text-muted-foreground">
|
||||
Skill URL
|
||||
</Label>
|
||||
<Input
|
||||
id="import-url"
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="https://clawhub.ai/owner/skill"
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Supported sources
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SourceCard
|
||||
label="ClawHub"
|
||||
exampleHost="clawhub.ai/owner/skill"
|
||||
browseUrl="https://clawhub.ai"
|
||||
active={source === "clawhub"}
|
||||
/>
|
||||
<SourceCard
|
||||
label="Skills.sh"
|
||||
exampleHost="skills.sh/owner/repo/skill"
|
||||
browseUrl="https://skills.sh"
|
||||
active={source === "skills.sh"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{error}
|
||||
{isNameConflictError(error) && (
|
||||
<>
|
||||
{" "}
|
||||
The imported skill’s name already exists — delete the
|
||||
existing one before retrying.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t bg-muted/30 px-5 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!url.trim() || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{submittingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
{submittingLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const METHOD_TITLES: Record<Method, string> = {
|
||||
chooser: "New skill",
|
||||
manual: "Create manually",
|
||||
url: "Import from URL",
|
||||
runtime: "Copy from runtime",
|
||||
};
|
||||
|
||||
const METHOD_DESCS: Record<Method, string> = {
|
||||
chooser: "Choose how you want to add a skill to this workspace.",
|
||||
manual: "Write a new SKILL.md from scratch.",
|
||||
url: "Fetch a published skill by URL. Files are pulled server-side.",
|
||||
runtime:
|
||||
"Scan a local runtime and promote one of its on-disk skills into this workspace.",
|
||||
};
|
||||
|
||||
export function CreateSkillDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated?: (skill: Skill) => void;
|
||||
}) {
|
||||
const [method, setMethod] = useState<Method>("chooser");
|
||||
|
||||
// Reset to chooser each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (open) setMethod("chooser");
|
||||
}, [open]);
|
||||
|
||||
const handleCreated = (skill: Skill) => {
|
||||
onCreated?.(skill);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const wide = method === "runtime";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"flex flex-col gap-0 overflow-hidden p-0",
|
||||
// Smooth width/height transition when switching between method
|
||||
// dimensions — matches the CreateIssue modal's ease curve so the
|
||||
// expand feels native rather than snapping.
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
wide
|
||||
? // `min(600px, 85vh)` keeps the dialog from overflowing short
|
||||
// viewports (smaller laptops) while still feeling generous on
|
||||
// normal screens.
|
||||
"!h-[min(600px,85vh)] !max-w-2xl !w-full"
|
||||
: "!h-auto !max-h-[85vh] !max-w-md !w-full",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-start justify-between gap-3 border-b px-5 pt-4 pb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{method !== "chooser" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("chooser")}
|
||||
className="-ml-1 rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:bg-accent/60 hover:opacity-100"
|
||||
aria-label="Back to method chooser"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Back</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate text-base font-medium">
|
||||
{METHOD_TITLES[method]}
|
||||
</DialogTitle>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{METHOD_DESCS[method]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:bg-accent/60 hover:opacity-100"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Method body — each form owns its scroll middle + footer */}
|
||||
{method === "chooser" && <MethodChooser onChoose={setMethod} />}
|
||||
{method === "manual" && (
|
||||
<ManualForm
|
||||
onCreated={handleCreated}
|
||||
onCancel={() => setMethod("chooser")}
|
||||
/>
|
||||
)}
|
||||
{method === "url" && (
|
||||
<UrlForm
|
||||
onCreated={handleCreated}
|
||||
onCancel={() => setMethod("chooser")}
|
||||
/>
|
||||
)}
|
||||
{method === "runtime" && (
|
||||
<RuntimeLocalSkillImportPanel onImported={handleCreated} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { default as SkillsPage } from "./skills-page";
|
||||
export { SkillDetailPage } from "./skill-detail-page";
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HardDrive, Download, AlertCircle } from "lucide-react";
|
||||
import type { AgentRuntime, Skill } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
runtimeListOptions,
|
||||
runtimeLocalSkillsKeys,
|
||||
runtimeLocalSkillsOptions,
|
||||
resolveRuntimeLocalSkillImport,
|
||||
} from "@multica/core/runtimes";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { RuntimeLocalSkillRow } from "./runtime-local-skill-row";
|
||||
|
||||
function runtimeLabel(runtime: AgentRuntime): string {
|
||||
return `${runtime.name} (${runtime.provider})`;
|
||||
}
|
||||
|
||||
export function RuntimeLocalSkillImportDialog({
|
||||
open,
|
||||
onClose,
|
||||
initialRuntimeId,
|
||||
initialSkillKey,
|
||||
fixedRuntimeId,
|
||||
onImported,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialRuntimeId?: string | null;
|
||||
initialSkillKey?: string | null;
|
||||
fixedRuntimeId?: string | null;
|
||||
onImported?: (skill: Skill) => void;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
const localRuntimes = useMemo(
|
||||
() => runtimes.filter((runtime) => runtime.runtime_mode === "local"),
|
||||
[runtimes],
|
||||
);
|
||||
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState<string>("");
|
||||
const [selectedSkillKey, setSelectedSkillKey] = useState<string>("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredRuntimeId =
|
||||
fixedRuntimeId ??
|
||||
initialRuntimeId ??
|
||||
localRuntimes[0]?.id ??
|
||||
"";
|
||||
setSelectedRuntimeId(preferredRuntimeId);
|
||||
}, [fixedRuntimeId, initialRuntimeId, localRuntimes, open]);
|
||||
|
||||
const selectedRuntime = localRuntimes.find(
|
||||
(runtime) => runtime.id === selectedRuntimeId,
|
||||
);
|
||||
const canBrowseSkills = open && !!selectedRuntimeId && selectedRuntime?.status === "online";
|
||||
const skillsQuery = useQuery({
|
||||
...runtimeLocalSkillsOptions(selectedRuntimeId || null),
|
||||
enabled: canBrowseSkills,
|
||||
});
|
||||
|
||||
const runtimeSkills = skillsQuery.data?.skills ?? [];
|
||||
const selectedSkill = runtimeSkills.find((skill) => skill.key === selectedSkillKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredSkill =
|
||||
(initialSkillKey
|
||||
? runtimeSkills.find((skill) => skill.key === initialSkillKey)
|
||||
: null) ?? runtimeSkills[0];
|
||||
const nextSkill = preferredSkill;
|
||||
if (!nextSkill) {
|
||||
setSelectedSkillKey("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtimeSkills.some((skill) => skill.key === selectedSkillKey)) {
|
||||
setSelectedSkillKey(nextSkill.key);
|
||||
setName(nextSkill.name);
|
||||
setDescription(nextSkill.description ?? "");
|
||||
}
|
||||
}, [initialSkillKey, open, runtimeSkills, selectedSkillKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkill) {
|
||||
return;
|
||||
}
|
||||
setName(selectedSkill.name);
|
||||
setDescription(selectedSkill.description ?? "");
|
||||
}, [selectedSkillKey, selectedSkill]);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedRuntimeId || !selectedSkill) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await resolveRuntimeLocalSkillImport(selectedRuntimeId, {
|
||||
skill_key: selectedSkill.key,
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId) }),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }),
|
||||
]);
|
||||
toast.success("Skill imported");
|
||||
onImported?.(result.skill);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to import skill");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSkillContent = () => {
|
||||
if (localRuntimes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No local runtimes available</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Connect a local runtime to browse and import its local skills.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedRuntime) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">Choose a runtime to continue</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedRuntime.status !== "online") {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md bg-warning/10 px-3 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
Runtime must be online to browse local skills.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-lg border px-4 py-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-2 h-3 w-48" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillsQuery.error) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{skillsQuery.error instanceof Error
|
||||
? skillsQuery.error.message
|
||||
: "Failed to load runtime local skills"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!skillsQuery.data?.supported) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
This runtime provider does not expose local skill inventory yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeSkills.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No local skills found</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This runtime does not have any discoverable local skills yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{runtimeSkills.map((skill) => (
|
||||
<RuntimeLocalSkillRow
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
selected={selectedSkillKey === skill.key}
|
||||
onSelect={() => setSelectedSkillKey(skill.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSkill && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Workspace skill name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="Optional description override"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Runtime Skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Local skills are runtime-owned and auto-used. Import creates a workspace copy for team sharing and editing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!fixedRuntimeId && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<Select value={selectedRuntimeId} onValueChange={(value) => value && setSelectedRuntimeId(value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a local runtime" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localRuntimes.map((runtime) => (
|
||||
<SelectItem key={runtime.id} value={runtime.id}>
|
||||
{runtimeLabel(runtime)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRuntime && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{runtimeLabel(selectedRuntime)}</span>
|
||||
<Badge variant={selectedRuntime.status === "online" ? "secondary" : "outline"}>
|
||||
{selectedRuntime.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderSkillContent()}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Symlinks, unreadable files, oversized files, and very large bundles are ignored during import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={
|
||||
importing ||
|
||||
!selectedRuntime ||
|
||||
selectedRuntime.status !== "online" ||
|
||||
!selectedSkill ||
|
||||
!name.trim()
|
||||
}
|
||||
>
|
||||
{importing ? (
|
||||
"Importing..."
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
Import to Workspace
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const mockListSkills = vi.hoisted(() => vi.fn());
|
||||
const mockResolveRuntimeLocalSkillImport = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeListOptions = vi.hoisted(() => vi.fn());
|
||||
const mockRuntimeLocalSkillsOptions = vi.hoisted(() => vi.fn());
|
||||
@@ -13,29 +12,15 @@ vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// The runtime selector now filters to runtimes owned by the current user
|
||||
// to mirror the Runtimes page's "Mine" default. Stub useAuthStore so the
|
||||
// panel sees user-1 — the owner of the seeded runtime in beforeEach.
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const stateUser = { id: "user-1", email: "u@example.com", name: "User" };
|
||||
const useAuthStore = (selector?: any) => {
|
||||
const useAuthStore = (selector?: (s: { user: typeof stateUser }) => unknown) => {
|
||||
const state = { user: stateUser };
|
||||
return selector ? selector(state) : state;
|
||||
};
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listSkills: (...args: unknown[]) => mockListSkills(...args),
|
||||
createSkill: vi.fn(),
|
||||
importSkill: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
deleteSkill: vi.fn(),
|
||||
getSkill: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeListOptions: (...args: unknown[]) => mockRuntimeListOptions(...args),
|
||||
runtimeLocalSkillsOptions: (...args: unknown[]) =>
|
||||
@@ -47,19 +32,6 @@ vi.mock("@multica/core/runtimes", () => ({
|
||||
mockResolveRuntimeLocalSkillImport(...args),
|
||||
}));
|
||||
|
||||
vi.mock("react-resizable-panels", () => ({
|
||||
useDefaultLayout: () => ({
|
||||
defaultLayout: undefined,
|
||||
onLayoutChanged: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/resizable", () => ({
|
||||
ResizablePanelGroup: ({ children }: any) => <div>{children}</div>,
|
||||
ResizablePanel: ({ children }: any) => <div>{children}</div>,
|
||||
ResizableHandle: () => <div data-testid="resize-handle" />,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
@@ -67,29 +39,23 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import SkillsPage from "./skills-page";
|
||||
import { RuntimeLocalSkillImportPanel } from "./runtime-local-skill-import-panel";
|
||||
|
||||
function renderSkillsPage() {
|
||||
function renderPanel() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SkillsPage />
|
||||
<RuntimeLocalSkillImportPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SkillsPage", () => {
|
||||
describe("RuntimeLocalSkillImportPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockListSkills.mockResolvedValue([]);
|
||||
mockRuntimeListOptions.mockReturnValue({
|
||||
queryKey: ["runtimes", "ws-1", "list"],
|
||||
queryFn: () =>
|
||||
@@ -145,36 +111,40 @@ describe("SkillsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("imports a local skill via the From Runtime tab in the Add Skill dialog", async () => {
|
||||
renderSkillsPage();
|
||||
it("imports a local skill from the selected runtime", async () => {
|
||||
renderPanel();
|
||||
|
||||
// Old flow had a dedicated "Import From Runtime" button. The dialog
|
||||
// now has a single "+ Add skill" entry point with three tabs; the
|
||||
// empty-state row also surfaces the same "Add Skill" button. Either
|
||||
// opens the unified dialog.
|
||||
const addButtons = await screen.findAllByRole("button", { name: /Add Skill/i });
|
||||
fireEvent.click(addButtons[0]!);
|
||||
|
||||
expect(await screen.findByText("Add Workspace Skill")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /From Runtime/i }));
|
||||
|
||||
expect(await screen.findByText("Review Helper")).toBeInTheDocument();
|
||||
// Five-step async cascade (runtime list → setSelectedRuntimeId effect →
|
||||
// skills query → auto-select effect → row render). Fast locally, slow on
|
||||
// CI — bump timeouts above RTL's 1 s default so the jsdom/Vitest work
|
||||
// queue actually has time to drain.
|
||||
expect(
|
||||
await screen.findByText("Review Helper", {}, { timeout: 5000 }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const importButton = screen.getByRole("button", {
|
||||
name: /Import to Workspace/i,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(importButton).not.toBeDisabled();
|
||||
});
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(importButton).not.toBeDisabled();
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
fireEvent.click(importButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockResolveRuntimeLocalSkillImport).toHaveBeenCalledWith("runtime-1", {
|
||||
skill_key: "review-helper",
|
||||
name: "Review Helper",
|
||||
description: "Review pull requests",
|
||||
});
|
||||
});
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockResolveRuntimeLocalSkillImport).toHaveBeenCalledWith(
|
||||
"runtime-1",
|
||||
{
|
||||
skill_key: "review-helper",
|
||||
name: "Review Helper",
|
||||
description: "Review pull requests",
|
||||
},
|
||||
);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HardDrive, Download, AlertCircle } from "lucide-react";
|
||||
import type { AgentRuntime, Skill } from "@multica/core/types";
|
||||
import { AlertCircle, Download, FileText, HardDrive, Loader2 } from "lucide-react";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
RuntimeLocalSkillSummary,
|
||||
Skill,
|
||||
} from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
@@ -12,7 +16,10 @@ import {
|
||||
runtimeLocalSkillsOptions,
|
||||
resolveRuntimeLocalSkillImport,
|
||||
} from "@multica/core/runtimes";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
skillDetailOptions,
|
||||
workspaceKeys,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
@@ -25,51 +32,124 @@ import {
|
||||
SelectValue,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { toast } from "sonner";
|
||||
import { RuntimeLocalSkillRow } from "./runtime-local-skill-row";
|
||||
|
||||
function runtimeLabel(runtime: AgentRuntime): string {
|
||||
return `${runtime.name} (${runtime.provider})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the "import local runtime skill into workspace" flow, extracted
|
||||
* from RuntimeLocalSkillImportDialog so it can be reused inside the unified
|
||||
* Add-Workspace-Skill dialog as a tab. Owns its own state, runtime/skill
|
||||
* picker, and Import button so the parent only needs to render it inside a
|
||||
* scroll/dialog container — no slot juggling.
|
||||
*
|
||||
* `active` lets the parent (e.g. a Tabs panel) tell the panel when it is
|
||||
* the visible tab; the panel uses that to seed defaults the first time it
|
||||
* opens, mirroring how the standalone dialog reacts to `open` going true.
|
||||
*/
|
||||
export function RuntimeLocalSkillImportPanel({
|
||||
active,
|
||||
onImported,
|
||||
initialRuntimeId,
|
||||
initialSkillKey,
|
||||
fixedRuntimeId,
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill row with inline-expanded name/description editor when selected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SkillItem({
|
||||
skill,
|
||||
selected,
|
||||
onSelect,
|
||||
name,
|
||||
description,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
}: {
|
||||
skill: RuntimeLocalSkillSummary;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
name: string;
|
||||
description: string;
|
||||
onNameChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-lg border transition-colors ${
|
||||
selected ? "border-primary bg-primary/5" : "hover:bg-accent/40"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex w-full items-start gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{skill.name}</span>
|
||||
<Badge variant="secondary">{skill.provider}</Badge>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">
|
||||
{skill.source_path}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{skill.file_count} file{skill.file_count === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-2.5 border-t bg-card px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Workspace skill name
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder={skill.name}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="Optional — describe when an agent should use this skill."
|
||||
rows={2}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel — three-section layout: sticky top / scrollable middle / sticky bottom
|
||||
//
|
||||
// Previously took an `active` prop to defer work inside a tabbed parent; the
|
||||
// parent now unmounts the panel when it's not the active method, so `active`
|
||||
// is always implicitly true here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RuntimeLocalSkillImportPanel({
|
||||
onImported,
|
||||
}: {
|
||||
active: boolean;
|
||||
onImported?: (skill: Skill) => void;
|
||||
initialRuntimeId?: string | null;
|
||||
initialSkillKey?: string | null;
|
||||
fixedRuntimeId?: string | null;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Only the runtime owner can browse + import its local skills (server-side
|
||||
// ACL enforces this), so listing other people's runtimes here just sets
|
||||
// the user up for a permission error after the fact. Match the Runtimes
|
||||
// page's "Mine" tab default and only show the caller's own local runtimes.
|
||||
// Only the runtime owner can browse + import local skills (server-side ACL).
|
||||
const localRuntimes = useMemo(
|
||||
() =>
|
||||
runtimes.filter(
|
||||
(runtime) =>
|
||||
runtime.runtime_mode === "local" &&
|
||||
(userId == null || runtime.owner_id === userId),
|
||||
(r) =>
|
||||
r.runtime_mode === "local" &&
|
||||
(userId == null || r.owner_id === userId),
|
||||
),
|
||||
[runtimes, userId],
|
||||
);
|
||||
@@ -80,49 +160,50 @@ export function RuntimeLocalSkillImportPanel({
|
||||
const [description, setDescription] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
// Default to the first local runtime once the list lands.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const preferredRuntimeId =
|
||||
fixedRuntimeId ?? initialRuntimeId ?? localRuntimes[0]?.id ?? "";
|
||||
setSelectedRuntimeId(preferredRuntimeId);
|
||||
}, [fixedRuntimeId, initialRuntimeId, localRuntimes, active]);
|
||||
setSelectedRuntimeId((prev) => prev || localRuntimes[0]?.id || "");
|
||||
}, [localRuntimes]);
|
||||
|
||||
const selectedRuntime = localRuntimes.find(
|
||||
(runtime) => runtime.id === selectedRuntimeId,
|
||||
);
|
||||
const canBrowseSkills = active && !!selectedRuntimeId && selectedRuntime?.status === "online";
|
||||
// Switching runtimes: clear the stale skill selection immediately so the
|
||||
// old highlight / inline editor don't flash during the next query's fetch
|
||||
// window. The auto-seed effect below picks the new first-skill once the
|
||||
// scan lands.
|
||||
useEffect(() => {
|
||||
setSelectedSkillKey("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
}, [selectedRuntimeId]);
|
||||
|
||||
const selectedRuntime = localRuntimes.find((r) => r.id === selectedRuntimeId);
|
||||
const canBrowseSkills =
|
||||
!!selectedRuntimeId && selectedRuntime?.status === "online";
|
||||
const skillsQuery = useQuery({
|
||||
...runtimeLocalSkillsOptions(selectedRuntimeId || null),
|
||||
enabled: canBrowseSkills,
|
||||
});
|
||||
const runtimeSkills = useMemo(
|
||||
() => skillsQuery.data?.skills ?? [],
|
||||
[skillsQuery.data],
|
||||
);
|
||||
const selectedSkill = runtimeSkills.find((s) => s.key === selectedSkillKey);
|
||||
|
||||
const runtimeSkills = skillsQuery.data?.skills ?? [];
|
||||
const selectedSkill = runtimeSkills.find((skill) => skill.key === selectedSkillKey);
|
||||
|
||||
// After a scan, auto-select the first skill so the Import button has a
|
||||
// valid target without requiring a click.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const preferredSkill =
|
||||
(initialSkillKey
|
||||
? runtimeSkills.find((skill) => skill.key === initialSkillKey)
|
||||
: null) ?? runtimeSkills[0];
|
||||
if (!preferredSkill) {
|
||||
setSelectedSkillKey("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
return;
|
||||
}
|
||||
if (!runtimeSkills.some((skill) => skill.key === selectedSkillKey)) {
|
||||
setSelectedSkillKey(preferredSkill.key);
|
||||
setName(preferredSkill.name);
|
||||
setDescription(preferredSkill.description ?? "");
|
||||
}
|
||||
}, [initialSkillKey, active, runtimeSkills, selectedSkillKey]);
|
||||
if (runtimeSkills.length === 0) return;
|
||||
if (runtimeSkills.some((s) => s.key === selectedSkillKey)) return;
|
||||
const first = runtimeSkills[0]!;
|
||||
setSelectedSkillKey(first.key);
|
||||
setName(first.name);
|
||||
setDescription(first.description ?? "");
|
||||
}, [runtimeSkills, selectedSkillKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkill) return;
|
||||
setName(selectedSkill.name);
|
||||
setDescription(selectedSkill.description ?? "");
|
||||
}, [selectedSkillKey, selectedSkill]);
|
||||
const handleRowSelect = (s: RuntimeLocalSkillSummary) => {
|
||||
setSelectedSkillKey(s.key);
|
||||
setName(s.name);
|
||||
setDescription(s.description ?? "");
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedRuntimeId || !selectedSkill) return;
|
||||
@@ -133,25 +214,48 @@ export function RuntimeLocalSkillImportPanel({
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
// Seed the detail cache so navigation lands with data pre-populated.
|
||||
qc.setQueryData(
|
||||
skillDetailOptions(wsId, result.skill.id).queryKey,
|
||||
result.skill,
|
||||
);
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId) }),
|
||||
qc.invalidateQueries({
|
||||
queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId),
|
||||
}),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }),
|
||||
]);
|
||||
toast.success("Skill imported");
|
||||
onImported?.(result.skill);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to import skill");
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import skill",
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSkillContent = () => {
|
||||
const canImport =
|
||||
!!selectedRuntime &&
|
||||
selectedRuntime.status === "online" &&
|
||||
!!selectedSkill &&
|
||||
!!name.trim() &&
|
||||
!importing;
|
||||
|
||||
// --- Scroll fade for the middle region ---
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
|
||||
// --- Middle body — depends on discovery state ---
|
||||
const middle = (() => {
|
||||
if (localRuntimes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No local runtimes available</p>
|
||||
<div className="rounded-lg border border-dashed px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No local runtimes available
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Connect a local runtime to browse and import its local skills.
|
||||
</p>
|
||||
@@ -160,8 +264,10 @@ export function RuntimeLocalSkillImportPanel({
|
||||
}
|
||||
if (!selectedRuntime) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">Choose a runtime to continue</p>
|
||||
<div className="rounded-lg border border-dashed px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a runtime to continue
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -176,8 +282,8 @@ export function RuntimeLocalSkillImportPanel({
|
||||
if (skillsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-lg border px-4 py-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border px-4 py-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-2 h-3 w-48" />
|
||||
</div>
|
||||
@@ -205,7 +311,7 @@ export function RuntimeLocalSkillImportPanel({
|
||||
}
|
||||
if (runtimeSkills.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<div className="rounded-lg border border-dashed px-4 py-10 text-center">
|
||||
<p className="text-sm text-muted-foreground">No local skills found</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This runtime does not have any discoverable local skills yet.
|
||||
@@ -214,91 +320,114 @@ export function RuntimeLocalSkillImportPanel({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{runtimeSkills.map((skill) => (
|
||||
<RuntimeLocalSkillRow
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
selected={selectedSkillKey === skill.key}
|
||||
onSelect={() => setSelectedSkillKey(skill.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSkill && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Workspace skill name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="Optional description override"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{runtimeSkills.map((s) => (
|
||||
<SkillItem
|
||||
key={s.key}
|
||||
skill={s}
|
||||
selected={selectedSkillKey === s.key}
|
||||
onSelect={() => handleRowSelect(s)}
|
||||
name={selectedSkillKey === s.key ? name : ""}
|
||||
description={selectedSkillKey === s.key ? description : ""}
|
||||
onNameChange={setName}
|
||||
onDescriptionChange={setDescription}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const canImport = !!selectedRuntime
|
||||
&& selectedRuntime.status === "online"
|
||||
&& !!selectedSkill
|
||||
&& !!name.trim()
|
||||
&& !importing;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!fixedRuntimeId && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Sticky top: runtime picker + status */}
|
||||
<div
|
||||
// While importing, lock the whole runtime/skill selection so the user
|
||||
// can't switch targets out from under the in-flight request.
|
||||
aria-disabled={importing || undefined}
|
||||
className={`shrink-0 space-y-2 border-b px-5 py-3 ${
|
||||
importing ? "pointer-events-none opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<Select value={selectedRuntimeId} onValueChange={(value) => value && setSelectedRuntimeId(value)}>
|
||||
<Select
|
||||
value={selectedRuntimeId}
|
||||
onValueChange={(v) => v && setSelectedRuntimeId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a local runtime">
|
||||
{selectedRuntime ? runtimeLabel(selectedRuntime) : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localRuntimes.map((runtime) => (
|
||||
<SelectItem key={runtime.id} value={runtime.id}>
|
||||
{runtimeLabel(runtime)}
|
||||
{localRuntimes.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{runtimeLabel(r)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRuntime && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{runtimeLabel(selectedRuntime)}</span>
|
||||
<Badge variant={selectedRuntime.status === "online" ? "secondary" : "outline"}>
|
||||
{selectedRuntime.status}
|
||||
</Badge>
|
||||
{selectedRuntime && (
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/20 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{runtimeLabel(selectedRuntime)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
selectedRuntime.status === "online" ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{selectedRuntime.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable middle — also locked during import. */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeStyle}
|
||||
aria-disabled={importing || undefined}
|
||||
className={`flex-1 min-h-0 overflow-y-auto px-5 py-3 ${
|
||||
importing ? "pointer-events-none opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{middle}
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Symlinks, unreadable files, oversized files, and very large bundles
|
||||
are ignored during import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sticky bottom: Import button + context */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-t bg-muted/30 px-5 py-3">
|
||||
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{selectedSkill ? (
|
||||
<>
|
||||
Ready to import{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{name.trim() || selectedSkill.name}
|
||||
</span>{" "}
|
||||
into this workspace.
|
||||
</>
|
||||
) : (
|
||||
"Select a skill to continue."
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderSkillContent()}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Symlinks, unreadable files, oversized files, and very large bundles are ignored during import.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleImport} disabled={!canImport}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
disabled={!canImport}
|
||||
>
|
||||
{importing ? (
|
||||
"Importing..."
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Importing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import type { RuntimeLocalSkillSummary } from "@multica/core/types";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
|
||||
export function RuntimeLocalSkillRow({
|
||||
skill,
|
||||
selected = false,
|
||||
onSelect,
|
||||
action,
|
||||
}: {
|
||||
skill: RuntimeLocalSkillSummary;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{skill.name}</span>
|
||||
<Badge variant="secondary">{skill.provider}</Badge>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{skill.source_path}
|
||||
</p>
|
||||
</div>
|
||||
{action ?? (
|
||||
<Badge variant="outline">
|
||||
{skill.file_count} file{skill.file_count === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onSelect) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`flex w-full items-start gap-3 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
selected ? "border-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="flex items-start gap-3 rounded-lg border px-4 py-3">{content}</div>;
|
||||
}
|
||||
948
packages/views/skills/components/skill-detail-page.tsx
Normal file
948
packages/views/skills/components/skill-detail-page.tsx
Normal file
@@ -0,0 +1,948 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
AgentRuntime,
|
||||
MemberWithUser,
|
||||
Skill,
|
||||
SkillFile,
|
||||
UpdateSkillRequest,
|
||||
} from "@multica/core/types";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import { timeAgo } from "@multica/core/utils";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import {
|
||||
agentListOptions,
|
||||
memberListOptions,
|
||||
selectSkillAssignments,
|
||||
skillDetailOptions,
|
||||
workspaceKeys,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
||||
import { Button, buttonVariants } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { AppLink, useNavigation } from "../../navigation";
|
||||
import { useCanEditSkill } from "../hooks/use-can-edit-skill";
|
||||
import { readOrigin, totalFileCount, type OriginInfo } from "../lib/origin";
|
||||
import { FileTree } from "./file-tree";
|
||||
import { FileViewer } from "./file-viewer";
|
||||
|
||||
const SKILL_MD = "SKILL.md";
|
||||
|
||||
type DraftFile = { id?: string; path: string; content: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File path validation + inline add
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateNewFilePath(path: string, existing: string[]): string {
|
||||
const p = path.trim();
|
||||
if (!p) return "Path cannot be empty.";
|
||||
if (p.startsWith("/")) return "Absolute paths are not allowed.";
|
||||
if (p.split("/").includes("..")) return 'Paths cannot contain "..".';
|
||||
if (p === SKILL_MD) return "SKILL.md is reserved for the main file.";
|
||||
if (existing.includes(p)) return "A file at this path already exists.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function AddFileInline({
|
||||
existingPaths,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}: {
|
||||
existingPaths: string[];
|
||||
onAdd: (path: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [path, setPath] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const err = validateNewFilePath(path, existingPaths);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
onAdd(path.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b bg-muted/30 px-2 py-2">
|
||||
<Input
|
||||
autoFocus
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
setPath(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
placeholder="templates/review.md"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="mt-1 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<Button type="button" size="xs" onClick={submit}>
|
||||
Add
|
||||
</Button>
|
||||
<Button type="button" size="xs" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UsedBySection({ agents }: { agents: Agent[] }) {
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
Not assigned to any agent yet. Open an agent’s Skills tab to
|
||||
assign.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{agents.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-card px-2.5 py-1.5"
|
||||
>
|
||||
<ActorAvatar
|
||||
name={a.name}
|
||||
initials={a.name.slice(0, 2).toUpperCase()}
|
||||
avatarUrl={a.avatar_url}
|
||||
isAgent
|
||||
size={22}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{a.name}</div>
|
||||
{a.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{a.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function OriginSidebarCard({
|
||||
origin,
|
||||
runtime,
|
||||
}: {
|
||||
origin: OriginInfo;
|
||||
runtime: AgentRuntime | null;
|
||||
}) {
|
||||
if (origin.type === "manual") return null;
|
||||
|
||||
const isRuntime = origin.type === "runtime_local";
|
||||
const label =
|
||||
origin.type === "runtime_local"
|
||||
? "Imported from local runtime"
|
||||
: origin.type === "clawhub"
|
||||
? "Imported from ClawHub"
|
||||
: "Imported from Skills.sh";
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
{isRuntime ? (
|
||||
<HardDrive className="h-3 w-3" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3" />
|
||||
)}
|
||||
{label}
|
||||
</div>
|
||||
{runtime && (
|
||||
<div className="mt-1 break-all text-xs text-foreground">
|
||||
{runtime.name}
|
||||
</div>
|
||||
)}
|
||||
{origin.source_path && (
|
||||
<div className="mt-1 break-all font-mono text-xs text-foreground">
|
||||
{origin.source_path}
|
||||
</div>
|
||||
)}
|
||||
{origin.source_url && (
|
||||
<div className="mt-1 break-all font-mono text-xs text-foreground">
|
||||
{origin.source_url}
|
||||
</div>
|
||||
)}
|
||||
{origin.provider && (
|
||||
<div className="mt-1 font-mono text-xs text-muted-foreground">
|
||||
provider · {origin.provider}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SkillDetailPage({ skillId }: { skillId: string }) {
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const paths = useWorkspacePaths();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const {
|
||||
data: skill,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery(skillDetailOptions(wsId, skillId));
|
||||
const { data: agents = [], error: agentsError } = useQuery(
|
||||
agentListOptions(wsId),
|
||||
);
|
||||
const { data: members = [], error: membersError } = useQuery(
|
||||
memberListOptions(wsId),
|
||||
);
|
||||
const { data: runtimes = [], error: runtimesError } = useQuery(
|
||||
runtimeListOptions(wsId),
|
||||
);
|
||||
|
||||
// Build the skillId → agents map once per agent-list identity, not on every
|
||||
// render — see selectSkillAssignments' doc comment.
|
||||
const assignments = useMemo(
|
||||
() => selectSkillAssignments(agents),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const canEdit = useCanEditSkill(skill, wsId);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [files, setFiles] = useState<DraftFile[]>([]);
|
||||
const [selectedPath, setSelectedPath] = useState(SKILL_MD);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [addingFile, setAddingFile] = useState(false);
|
||||
// When a WS refetch lands newer server state while we have in-progress
|
||||
// edits, we surface a banner instead of silently clobbering the draft.
|
||||
const [conflictPending, setConflictPending] = useState(false);
|
||||
|
||||
// Ref to latest draft — lets the seeding effect decide whether user has
|
||||
// in-progress edits without itself depending on draft state (which would
|
||||
// fire the effect on every keystroke).
|
||||
const draftRef = useRef({ name, description, content, files });
|
||||
draftRef.current = { name, description, content, files };
|
||||
|
||||
// Tracks `${wsId}:${id}@${updated_at}` we last seeded from. `wsId` guards
|
||||
// against the rare cross-workspace race where `skillId` collides across
|
||||
// workspaces (same UUID wouldn't in practice, but the scope is correct).
|
||||
const seededKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Seed draft from server state. Preserves in-progress edits when a WS
|
||||
// invalidation refetches the same skill; surfaces a conflict banner if
|
||||
// server state has drifted under unedited-yet-mid-edit conditions.
|
||||
useEffect(() => {
|
||||
if (!skill) return;
|
||||
const key = `${wsId}:${skill.id}@${skill.updated_at}`;
|
||||
if (seededKeyRef.current === key) return;
|
||||
|
||||
const sameSkill =
|
||||
seededKeyRef.current !== null &&
|
||||
seededKeyRef.current.startsWith(`${wsId}:${skill.id}@`);
|
||||
|
||||
if (sameSkill) {
|
||||
const d = draftRef.current;
|
||||
const serverFilesJson = JSON.stringify(
|
||||
(skill.files ?? []).map((f) => ({ path: f.path, content: f.content })),
|
||||
);
|
||||
const draftFilesJson = JSON.stringify(
|
||||
d.files.map((f) => ({ path: f.path, content: f.content })),
|
||||
);
|
||||
const hasEdits =
|
||||
d.name.trim() !== skill.name ||
|
||||
d.description.trim() !== skill.description ||
|
||||
d.content !== skill.content ||
|
||||
draftFilesJson !== serverFilesJson;
|
||||
if (hasEdits) {
|
||||
setConflictPending(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
seededKeyRef.current = key;
|
||||
setConflictPending(false);
|
||||
setName(skill.name);
|
||||
setDescription(skill.description);
|
||||
setContent(skill.content);
|
||||
setFiles(
|
||||
(skill.files ?? []).map((f: SkillFile) => ({
|
||||
id: f.id,
|
||||
path: f.path,
|
||||
content: f.content,
|
||||
})),
|
||||
);
|
||||
if (!sameSkill) setSelectedPath(SKILL_MD);
|
||||
}, [skill, wsId]);
|
||||
|
||||
const creator = useMemo<MemberWithUser | null>(
|
||||
() =>
|
||||
skill?.created_by
|
||||
? members.find((m) => m.user_id === skill.created_by) ?? null
|
||||
: null,
|
||||
[members, skill?.created_by],
|
||||
);
|
||||
|
||||
const origin = useMemo(
|
||||
() => (skill ? readOrigin(skill) : null),
|
||||
[skill],
|
||||
);
|
||||
const originRuntime = useMemo<AgentRuntime | null>(() => {
|
||||
if (!origin || origin.type !== "runtime_local" || !origin.runtime_id)
|
||||
return null;
|
||||
return runtimes.find((r) => r.id === origin.runtime_id) ?? null;
|
||||
}, [origin, runtimes]);
|
||||
|
||||
const skillAgents = useMemo(
|
||||
() => assignments.get(skillId) ?? [],
|
||||
[assignments, skillId],
|
||||
);
|
||||
|
||||
const fileMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
map.set(SKILL_MD, content);
|
||||
for (const f of files) if (f.path.trim()) map.set(f.path, f.content);
|
||||
return map;
|
||||
}, [content, files]);
|
||||
const filePaths = useMemo(() => Array.from(fileMap.keys()), [fileMap]);
|
||||
const selectedContent = fileMap.get(selectedPath) ?? "";
|
||||
|
||||
// If the selected file disappeared (user deleted it, or WS refresh removed
|
||||
// it), jump back to SKILL.md so the viewer never shows an empty body with
|
||||
// a ghost path label.
|
||||
useEffect(() => {
|
||||
if (selectedPath !== SKILL_MD && !fileMap.has(selectedPath)) {
|
||||
setSelectedPath(SKILL_MD);
|
||||
}
|
||||
}, [fileMap, selectedPath]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!skill) return false;
|
||||
const serverFiles = (skill.files ?? []).map((f: SkillFile) => ({
|
||||
path: f.path,
|
||||
content: f.content,
|
||||
}));
|
||||
const draftFiles = files.map((f) => ({ path: f.path, content: f.content }));
|
||||
return (
|
||||
name.trim() !== skill.name ||
|
||||
description.trim() !== skill.description ||
|
||||
content !== skill.content ||
|
||||
JSON.stringify(draftFiles) !== JSON.stringify(serverFiles)
|
||||
);
|
||||
}, [skill, name, description, content, files]);
|
||||
|
||||
const seedFromSkill = (s: Skill) => {
|
||||
setName(s.name);
|
||||
setDescription(s.description);
|
||||
setContent(s.content);
|
||||
setFiles(
|
||||
(s.files ?? []).map((f: SkillFile) => ({
|
||||
id: f.id,
|
||||
path: f.path,
|
||||
content: f.content,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!skill || !canEdit) return;
|
||||
const trimmedName = name.trim();
|
||||
const trimmedDesc = description.trim();
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: UpdateSkillRequest = {
|
||||
name: trimmedName,
|
||||
description: trimmedDesc,
|
||||
content,
|
||||
files: files.filter((f) => f.path.trim()),
|
||||
};
|
||||
const updated = await api.updateSkill(skill.id, payload);
|
||||
// Seed local state + cache with the authoritative server response so
|
||||
// draft → clean transition is immediate. Syncs ALL fields (not just
|
||||
// name/desc) — otherwise server-side normalization of content/files
|
||||
// would leave isDirty stuck at true.
|
||||
qc.setQueryData(
|
||||
skillDetailOptions(wsId, skill.id).queryKey,
|
||||
updated,
|
||||
);
|
||||
seedFromSkill(updated);
|
||||
seededKeyRef.current = `${wsId}:${updated.id}@${updated.updated_at}`;
|
||||
setConflictPending(false);
|
||||
// Invalidate list (list rows carry `updated_at`, name, description) AND
|
||||
// agents (each agent inlines its `skills` array, so a rename here must
|
||||
// sync there too). `exact: true` on skills keeps the detail cache we
|
||||
// just wrote from getting re-fetched.
|
||||
qc.invalidateQueries({
|
||||
queryKey: workspaceKeys.skills(wsId),
|
||||
exact: true,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Skill saved");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save skill");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (!skill) return;
|
||||
seedFromSkill(skill);
|
||||
seededKeyRef.current = `${wsId}:${skill.id}@${skill.updated_at}`;
|
||||
setConflictPending(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!skill) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteSkill(skill.id);
|
||||
// Navigate first so the detail route unmounts BEFORE invalidation
|
||||
// refetches the now-404 row — otherwise users see a "Skill not found"
|
||||
// flash. Deleting also cascade-removes junction rows on the server,
|
||||
// so agents cache must refresh too.
|
||||
navigation.replace(paths.skills());
|
||||
qc.removeQueries({
|
||||
queryKey: skillDetailOptions(wsId, skill.id).queryKey,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Skill deleted");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to delete skill",
|
||||
);
|
||||
setDeleting(false);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFile = (path: string) => {
|
||||
setFiles((prev) => [...prev, { path, content: "" }]);
|
||||
setSelectedPath(path);
|
||||
setAddingFile(false);
|
||||
};
|
||||
|
||||
const handleDeleteFile = () => {
|
||||
if (selectedPath === SKILL_MD) return;
|
||||
setFiles((prev) => prev.filter((f) => f.path !== selectedPath));
|
||||
setSelectedPath(SKILL_MD);
|
||||
};
|
||||
|
||||
const handleFileContentChange = (newContent: string) => {
|
||||
if (!canEdit) return;
|
||||
if (selectedPath === SKILL_MD) {
|
||||
setContent(newContent);
|
||||
} else {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.path === selectedPath ? { ...f, content: newContent } : f,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const supportingQueryDown =
|
||||
!!agentsError || !!membersError || !!runtimesError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-3 w-3 rounded" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<div className="space-y-3 p-6">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !skill) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
render={<AppLink href={paths.skills()} />}
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
All skills
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm font-medium">Skill not found</p>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "This skill may have been deleted or you lost access."}
|
||||
</p>
|
||||
<AppLink
|
||||
href={paths.skills()}
|
||||
className={`${buttonVariants({ variant: "outline", size: "xs" })} mt-2`}
|
||||
>
|
||||
Back to Skills
|
||||
</AppLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-line metadata for the header ---
|
||||
const originLabel = (() => {
|
||||
if (!origin) return null;
|
||||
if (origin.type === "runtime_local") {
|
||||
return originRuntime
|
||||
? `Local runtime · ${originRuntime.name}`
|
||||
: origin.provider
|
||||
? `Local runtime · ${origin.provider}`
|
||||
: "Local runtime";
|
||||
}
|
||||
if (origin.type === "clawhub") return "Imported · ClawHub";
|
||||
if (origin.type === "skills_sh") return "Imported · Skills.sh";
|
||||
return "Workspace";
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Topbar */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
render={<AppLink href={paths.skills()} />}
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
All skills
|
||||
</Button>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-xs text-foreground">
|
||||
{skill.name}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!canEdit && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
Read-only
|
||||
</span>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label="Delete skill"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Delete skill</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supporting query error banner (non-blocking — the page still works
|
||||
but agent attribution / runtime names / permission checks are
|
||||
partial). */}
|
||||
{supportingQueryDown && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex shrink-0 items-start gap-2 border-b bg-warning/10 px-4 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<span>
|
||||
Some workspace data failed to load. Creator attribution, runtime
|
||||
names, or edit permissions may appear incomplete until the next
|
||||
refresh.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body: file tree | editor | sidebar */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* File tree */}
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b px-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Files · {totalFileCount(skill)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setAddingFile(true)}
|
||||
className="text-muted-foreground"
|
||||
aria-label="Add file"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Add file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{addingFile && (
|
||||
<AddFileInline
|
||||
existingPaths={filePaths}
|
||||
onAdd={handleAddFile}
|
||||
onCancel={() => setAddingFile(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<FileTree
|
||||
filePaths={filePaths}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
</div>
|
||||
{selectedPath !== SKILL_MD && canEdit && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleDeleteFile}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete file
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Editor */}
|
||||
<section className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Name + description + subline */}
|
||||
<div className="space-y-2 border-b px-5 py-4">
|
||||
<Input
|
||||
value={name}
|
||||
readOnly={!canEdit}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="skill-name"
|
||||
className="h-9 border-0 bg-transparent px-0 text-lg font-semibold shadow-none focus-visible:ring-0 read-only:cursor-default"
|
||||
aria-label="Skill name"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="skill-description"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="skill-description"
|
||||
value={description}
|
||||
readOnly={!canEdit}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="One sentence describing when an agent should use this skill…"
|
||||
rows={2}
|
||||
className="resize-none text-sm read-only:cursor-default"
|
||||
/>
|
||||
</div>
|
||||
{/* Subline: origin · updated · creator — each segment atomic so
|
||||
flex-wrap doesn't orphan the separators. */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{originLabel && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{origin?.type === "runtime_local" ? (
|
||||
<HardDrive className="h-3 w-3" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3" />
|
||||
)}
|
||||
{originLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span aria-hidden>·</span>
|
||||
<span>Updated {timeAgo(skill.updated_at)}</span>
|
||||
</span>
|
||||
{creator && (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ActorAvatar
|
||||
name={creator.name}
|
||||
initials={creator.name.slice(0, 2).toUpperCase()}
|
||||
avatarUrl={creator.avatar_url}
|
||||
size={14}
|
||||
/>
|
||||
by {creator.name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict banner — surfaces when a WS refetch arrived with newer
|
||||
server state while the user had edits in flight. */}
|
||||
{conflictPending && canEdit && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-xs"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-foreground">
|
||||
Someone else updated this skill
|
||||
</div>
|
||||
<div className="mt-0.5 text-muted-foreground">
|
||||
Your edits are preserved. Discard to pull their changes, or
|
||||
Save to overwrite.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File viewer */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileViewer
|
||||
key={selectedPath}
|
||||
path={selectedPath}
|
||||
content={selectedContent}
|
||||
onChange={handleFileContentChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save bar */}
|
||||
{isDirty && canEdit && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-center gap-2 border-t bg-muted/30 px-4 py-2"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-brand" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Unsaved changes — will overwrite the live skill on save
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3 w-3" />
|
||||
Save changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l bg-muted/20 px-4 py-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Metadata
|
||||
</h3>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
<div className="flex gap-2">
|
||||
<dt className="min-w-20 text-muted-foreground">Created</dt>
|
||||
<dd className="min-w-0 flex-1">
|
||||
{timeAgo(skill.created_at)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="min-w-20 text-muted-foreground">Updated</dt>
|
||||
<dd className="min-w-0 flex-1">
|
||||
{timeAgo(skill.updated_at)}
|
||||
</dd>
|
||||
</div>
|
||||
{creator && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="min-w-20 text-muted-foreground">Created by</dt>
|
||||
<dd className="min-w-0 flex-1">{creator.name}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<dt className="min-w-20 text-muted-foreground">Files</dt>
|
||||
<dd className="min-w-0 flex-1">{totalFileCount(skill)}</dd>
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
title={skill.id}
|
||||
>
|
||||
<dt className="min-w-20 text-muted-foreground">ID</dt>
|
||||
<dd className="min-w-0 flex-1 truncate font-mono text-muted-foreground">
|
||||
{skill.id.slice(0, 8)}…
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{origin && origin.type !== "manual" && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Origin
|
||||
</h3>
|
||||
<OriginSidebarCard origin={origin} runtime={originRuntime} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Used by {skillAgents.length} agent
|
||||
{skillAgents.length === 1 ? "" : "s"}
|
||||
</h3>
|
||||
<UsedBySection agents={skillAgents} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Permissions
|
||||
</h3>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{canEdit
|
||||
? "You can edit and delete this skill. Changes take effect on the next agent run."
|
||||
: `Only the creator${creator ? ` (${creator.name})` : ""} or a workspace admin can edit this skill.`}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setConfirmDelete(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete skill?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete “{skill.name}” and remove
|
||||
it from{" "}
|
||||
{skillAgents.length > 0
|
||||
? `${skillAgents.length} agent${skillAgents.length === 1 ? "" : "s"} currently using it.`
|
||||
: "all agents."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
This action cannot be undone.
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Deleting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete permanently
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
66
packages/views/skills/hooks/use-can-edit-skill.test.ts
Normal file
66
packages/views/skills/hooks/use-can-edit-skill.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Skill } from "@multica/core/types";
|
||||
import { canEditSkill } from "./use-can-edit-skill";
|
||||
|
||||
function makeSkill(createdBy: string | null): Skill {
|
||||
return {
|
||||
id: "skl_x",
|
||||
workspace_id: "ws_1",
|
||||
name: "x",
|
||||
description: "",
|
||||
content: "",
|
||||
config: {},
|
||||
files: [],
|
||||
created_by: createdBy,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("canEditSkill", () => {
|
||||
const skill = makeSkill("user-alice");
|
||||
|
||||
it("allows workspace owners to edit any skill", () => {
|
||||
expect(
|
||||
canEditSkill(skill, { userId: "user-bob", role: "owner" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows workspace admins to edit any skill", () => {
|
||||
expect(
|
||||
canEditSkill(skill, { userId: "user-bob", role: "admin" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the creator to edit their own skill", () => {
|
||||
expect(
|
||||
canEditSkill(skill, { userId: "user-alice", role: "member" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("denies non-creator members", () => {
|
||||
expect(
|
||||
canEditSkill(skill, { userId: "user-bob", role: "member" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("denies unknown-role users even if they match created_by", () => {
|
||||
// role=null models a member list that hasn't loaded yet or a user who
|
||||
// isn't a member at all; we still honor created_by identity.
|
||||
expect(
|
||||
canEditSkill(skill, { userId: "user-alice", role: null }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when created_by is null (legacy / system-created)", () => {
|
||||
expect(
|
||||
canEditSkill(makeSkill(null), { userId: "user-alice", role: "member" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("denies when userId is null (logged-out edge case)", () => {
|
||||
expect(
|
||||
canEditSkill(skill, { userId: null, role: "member" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
42
packages/views/skills/hooks/use-can-edit-skill.ts
Normal file
42
packages/views/skills/hooks/use-can-edit-skill.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { MemberRole, Skill } from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
|
||||
/**
|
||||
* Whether the current user may edit/delete the given skill.
|
||||
*
|
||||
* Rule: workspace admins & owners can edit any skill; everyone else can only
|
||||
* edit skills they created. Server enforces this independently; the hook
|
||||
* mirrors it so the UI can hide/disable actions instead of waiting for a 403.
|
||||
*
|
||||
* `wsId` is explicit (not read from `WorkspaceIdProvider`) so this hook stays
|
||||
* usable in components that render before workspace context is wired, and so
|
||||
* the scope of the permission check is always obvious to the caller. Matches
|
||||
* the repo rule for workspace-aware hooks.
|
||||
*/
|
||||
export function useCanEditSkill(
|
||||
skill: Skill | null | undefined,
|
||||
wsId: string,
|
||||
): boolean {
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
|
||||
if (!skill) return false;
|
||||
const myRole = members.find((m) => m.user_id === userId)?.role ?? null;
|
||||
return canEditSkill(skill, { userId, role: myRole });
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-hook variant for places that already have the role + userId at hand
|
||||
* (e.g. list rows that compute role once for the whole page).
|
||||
*/
|
||||
export function canEditSkill(
|
||||
skill: Skill,
|
||||
opts: { userId: string | null; role: MemberRole | null },
|
||||
): boolean {
|
||||
if (opts.role === "admin" || opts.role === "owner") return true;
|
||||
return skill.created_by === opts.userId;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { SkillsPage } from "./components";
|
||||
export { SkillsPage, SkillDetailPage } from "./components";
|
||||
|
||||
35
packages/views/skills/lib/origin.ts
Normal file
35
packages/views/skills/lib/origin.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Skill } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* Discriminated view over `Skill.config.origin` — the JSONB blob the backend
|
||||
* writes when a skill was imported from outside (local runtime, ClawHub,
|
||||
* Skills.sh). Manual creates have no origin, so we synthesize `{ type:
|
||||
* "manual" }` for them to keep the consumer code uniform.
|
||||
*
|
||||
* NOTE: the backend currently only writes `runtime_local` origins. URL
|
||||
* imports leave `config.origin` empty, so `clawhub`/`skills_sh` variants are
|
||||
* declared here for forward compatibility but should never be rendered in
|
||||
* the UI until the server fills them in.
|
||||
*/
|
||||
export type OriginInfo = {
|
||||
type: "runtime_local" | "clawhub" | "skills_sh" | "manual";
|
||||
provider?: string;
|
||||
runtime_id?: string;
|
||||
source_path?: string;
|
||||
source_url?: string;
|
||||
};
|
||||
|
||||
export function readOrigin(skill: Skill): OriginInfo {
|
||||
const raw = (skill.config?.origin ?? null) as
|
||||
| (OriginInfo & Record<string, unknown>)
|
||||
| null;
|
||||
if (raw?.type === "runtime_local") return raw;
|
||||
if (raw?.type === "clawhub") return raw;
|
||||
if (raw?.type === "skills_sh") return raw;
|
||||
return { type: "manual" };
|
||||
}
|
||||
|
||||
/** SKILL.md is always present plus any additional attached files. */
|
||||
export function totalFileCount(skill: Skill): number {
|
||||
return (skill.files?.length ?? 0) + 1;
|
||||
}
|
||||
Reference in New Issue
Block a user