refactor(skills): redesign list page and add skill detail page (#1607)

* feat(core): add skill detail path and query helpers

- paths.workspace(slug).skillDetail(id) → /:slug/skills/:id
- skillDetailOptions(wsId, skillId) for fetching a single skill
- selectSkillAssignments(agents) folds the cached agent list into
  Map<skillId, Agent[]>; returns a stable reference so consumers can
  memoize against agent-array identity without re-rendering on unrelated
  agent updates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(views): add cross-platform openExternal helper

On Electron, route through window.desktopAPI.openExternal so the
http/https-only guard in the main process kicks in — direct window.open
inside Electron opens a new renderer window instead of handing the URL
to the OS shell. On web, fall back to window.open with noopener+noreferrer.
SSR-safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(skills): extract edit-permission hook and origin helper

- use-can-edit-skill: mirrors the server's rule (admin/owner ∨ creator)
  so the UI can hide/disable actions instead of waiting for a 403. Takes
  wsId explicitly per the repo rule for workspace-aware hooks.
- lib/origin: discriminated view over Skill.config.origin (manual /
  runtime_local / clawhub / skills_sh) so consumers don't spread JSONB
  parsing across the UI tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(skills): rewrite skills list page and collapse import UI

- SkillsPage rewritten: new hero header, single table layout with
  columns (Name / Used by / Source · Added by / Updated), agent avatar
  stack per skill, filter tabs aligned with Issues/MyIssues header
  (Button variant=outline + Tooltip + bg-accent active state).
- CreateSkillDialog: dedicated dialog for the manual/import entry
  points, replaces the inline row-triggered dialog.
- runtime-local import: dialog variant deleted; panel is now the single
  entry point, embeddable inside CreateSkillDialog. Panel covered by a
  new test.
- Deleted runtime-local-skill-row (no longer needed — row rendering
  lives in SkillsPage directly) and the old skills-page.test.tsx
  (structure diverged beyond salvaging; will be re-added alongside the
  detail-page tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skills): add skill detail page and wire routes on web and desktop

- SkillDetailPage: dedicated view for a single skill (name, description,
  origin, assignments, file listing). Uses skillDetailOptions and the
  new origin / use-can-edit-skill helpers.
- apps/web: /:workspaceSlug/skills/:id Next.js route.
- apps/desktop: /:slug/skills/:id added to the memory router under
  WorkspaceRouteLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(skills): bump runtime-local-skill-import-panel timeouts for CI

The test chains a five-step async cascade (runtime list → setSelectedRuntimeId
effect → skills query → auto-select effect → row render). Comfortable on
local (~600ms) but tight against RTL's 1 s default on CI where jsdom +
Vitest import takes ~100s. Bump findByText and the two waitFor calls to
5 s each — no production behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-24 13:51:58 +08:00
committed by GitHub
parent 9ed1fa95fc
commit 7067d8f125
20 changed files with 2529 additions and 1368 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,3 @@
export { useImmersiveMode } from "./use-immersive-mode";
export { DragStrip } from "./drag-strip";
export { openExternal } from "./open-external";

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

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

View File

@@ -1 +1,2 @@
export { default as SkillsPage } from "./skills-page";
export { SkillDetailPage } from "./skill-detail-page";

View File

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

View File

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

View File

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

View File

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

View 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&rsquo;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 &ldquo;{skill.name}&rdquo; 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

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

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

View File

@@ -1 +1 @@
export { SkillsPage } from "./components";
export { SkillsPage, SkillDetailPage } from "./components";

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