diff --git a/apps/desktop/src/renderer/src/pages/skill-detail-page.tsx b/apps/desktop/src/renderer/src/pages/skill-detail-page.tsx new file mode 100644 index 000000000..706450ed1 --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/skill-detail-page.tsx @@ -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 ; +} diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index 931fbd1af..794d07153 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -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: , handle: { title: "Skills" } }, + { + path: "skills/:id", + element: , + handle: { title: "Skill" }, + }, { path: "agents", element: , handle: { title: "Agents" } }, { path: "inbox", element: , handle: { title: "Inbox" } }, { path: "chat", element: , handle: { title: "Chat" } }, diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/skills/[id]/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/skills/[id]/page.tsx new file mode 100644 index 000000000..0b6d695c9 --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/skills/[id]/page.tsx @@ -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 ; +} diff --git a/packages/core/paths/paths.test.ts b/packages/core/paths/paths.test.ts index 8fa4e04b4..d9560738d 100644 --- a/packages/core/paths/paths.test.ts +++ b/packages/core/paths/paths.test.ts @@ -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"); }); diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts index 64075ba7d..eb6e9a60a 100644 --- a/packages/core/paths/paths.ts +++ b/packages/core/paths/paths.ts @@ -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`, }; } diff --git a/packages/core/workspace/queries.ts b/packages/core/workspace/queries.ts index 54cf94823..b7e9d995b 100644 --- a/packages/core/workspace/queries.ts +++ b/packages/core/workspace/queries.ts @@ -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` 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 { + const map = new Map(); + 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), diff --git a/packages/views/platform/index.ts b/packages/views/platform/index.ts index b01108eab..9633eadea 100644 --- a/packages/views/platform/index.ts +++ b/packages/views/platform/index.ts @@ -1,2 +1,3 @@ export { useImmersiveMode } from "./use-immersive-mode"; export { DragStrip } from "./drag-strip"; +export { openExternal } from "./open-external"; diff --git a/packages/views/platform/open-external.ts b/packages/views/platform/open-external.ts new file mode 100644 index 000000000..3d637ce98 --- /dev/null +++ b/packages/views/platform/open-external.ts @@ -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 `` 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 }; + } + ).desktopAPI; + if (desktopAPI?.openExternal) { + void desktopAPI.openExternal(url); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); +} diff --git a/packages/views/skills/components/create-skill-dialog.tsx b/packages/views/skills/components/create-skill-dialog.tsx new file mode 100644 index 000000000..4f32be7b1 --- /dev/null +++ b/packages/views/skills/components/create-skill-dialog.tsx @@ -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, + 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 ( +
+ {methods.map(({ key, icon: Icon, title, desc }) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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(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 ( + <> +
+
+ + { + setName(e.target.value); + setError(""); + }} + placeholder="e.g. review-helper" + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + }} + /> +

+ Must be unique within the workspace. +

+
+ +
+ +