Compare commits

..

1 Commits

Author SHA1 Message Date
Michal
e8c3803fac feat(web): bold redesign — Tailwind v4 + shadcn-style primitives + Skills/Proposals/Revisions UI
Phase 6 of the Skills + Revisions + Proposals work. The web UI gets a
new design language and first-class affordances for everything the
backend now supports.

## Visual direction

- Tailwind v4 with custom @theme block (oklch tokens). Dark-mode-only
  (internal tool — light mode doubles QA surface).
- Inter for UI, JetBrains Mono for code/IDs (loaded via Google Fonts;
  trivial to swap for self-hosted geist later — the fallback stack
  reads identically).
- Sidebar layout (always-visible at desktop widths) replacing the
  previous top-bar nav. Pending-proposals badge polls every 30 s so
  reviewers see a queue building without refreshing.
- Lucide icons throughout.
- Spacing and radii on Tailwind defaults.

Existing inline-styled pages (Projects, Agents, AgentDetail,
ProjectPrompts, PersonalityDetail, Login) continue to work unchanged
inside the new Layout — Tailwind doesn't conflict with their inline
styles. A follow-up can migrate them incrementally.

## What's added

### Build infra (src/web/)
- package.json: tailwindcss@^4 + @tailwindcss/vite, lucide-react,
  class-variance-authority, clsx, tailwind-merge, diff, geist (held
  for future self-hosting).
- vite.config.ts: registers the @tailwindcss/vite plugin.
- src/index.css: Tailwind import + @theme tokens + @layer base.
- src/main.tsx: imports index.css.
- src/lib/utils.ts: shadcn-style cn() helper.

### shadcn-style primitives (src/components/ui/)
Hand-written rather than generated via `npx shadcn` so the repo doesn't
depend on a CLI tool that needs interactive runtime:

- button.tsx — variants: primary / secondary / ghost / danger / link;
  sizes: sm / md / lg / icon.
- card.tsx — Card + Header/Title/Description/Content/Footer subparts.
- badge.tsx — variants: default / info / success / warning / danger /
  outline.
- input.tsx — Input + Textarea + Label.
- tabs.tsx — no-dep accessible Tabs (no Radix needed for our use).
- separator.tsx — h/v separator with role=separator.

### Diff component (src/components/Diff.tsx)
Wraps the `diff` package (already added in PR-2) for inline unified-
diff display with color-coded add/remove rows. Used by both the
proposal review page and the skill revision-history tab.

### New pages (src/pages/)
- Dashboard.tsx — at-a-glance home. Counts for skills, prompts,
  projects, agents, proposals; pending-proposals call-out card if any.
- Skills.tsx — list view, separated into Global vs Project/Agent-
  scoped sections.
- SkillDetail.tsx — name + semver + description; tabs for SKILL.md /
  Files / Metadata / History. History tab shows revisions with
  click-to-diff against the live body.
- Proposals.tsx — queue with Pending/Approved/Rejected tabs. Pending
  count is highlighted in amber.
- ProposalDetail.tsx — full body, diff against current resource (or
  "would create new" if it doesn't exist), approve button + reject-
  with-required-note flow.

### usePolling hook (src/hooks/)
Tiny polling-with-cancellation hook used by Layout and Proposals.

### Layout rewrite (src/components/Layout.tsx)
Sidebar with nav items: Dashboard, Projects, Agents, Skills,
Proposals. Lucide icons. Active-route highlighting via NavLink.
Pending-proposals warning badge on the Proposals item.

### Routes (src/App.tsx)
New routes: /dashboard, /skills, /skills/:name, /proposals,
/proposals/:id. Default redirects to /dashboard.

### API types (src/api.ts)
Type defs for Skill, VisibleSkill, Proposal, Revision (with the
shapes the new pages consume).

## Tests

Existing 7 web tests still pass (Login + api). New page-level tests
deferred — the new pages are mostly compositions of primitives and
fetch hooks that round-trip to the backend; the backend tests already
cover what they call. PR-7 polish can add render-and-click tests if
coverage drift surfaces.

## Verification

- `pnpm --filter @mcpctl/web build` clean, no warnings.
- `pnpm test:run` whole monorepo: 162 test files / 2157 tests green.
- Visual smoke deferred — needs a running mcpd to populate the
  fixtures. Manual smoke tested locally is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:54:55 +01:00
22 changed files with 2274 additions and 80 deletions

836
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,17 +12,26 @@
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^5.2.0",
"geist": "^1.5.1",
"lucide-react": "^0.487.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.7.0"
"react-router-dom": "^7.7.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.16",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@types/diff": "^5.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.1.0",
"jsdom": "^28.0.0",
"tailwindcss": "^4.1.16",
"vite": "^7.2.0"
}
}

View File

@@ -9,6 +9,11 @@ import { ProjectPromptsPage } from './pages/ProjectPrompts';
import { AgentsPage } from './pages/Agents';
import { AgentDetailPage } from './pages/AgentDetail';
import { PersonalityDetailPage } from './pages/PersonalityDetail';
import { DashboardPage } from './pages/Dashboard';
import { SkillsPage } from './pages/Skills';
import { SkillDetailPage } from './pages/SkillDetail';
import { ProposalsPage } from './pages/Proposals';
import { ProposalDetailPage } from './pages/ProposalDetail';
export function App(): React.JSX.Element {
const [tokenPresent, setTokenPresent] = useState(getToken() !== null);
@@ -28,13 +33,19 @@ export function App(): React.JSX.Element {
<BrowserRouter basename="/ui">
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:name/prompts" element={<ProjectPromptsPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/agents/:name" element={<AgentDetailPage />} />
<Route path="/personalities/:id" element={<PersonalityDetailPage />} />
<Route path="*" element={<Navigate to="/projects" replace />} />
{/* PR-6: Skills + Proposals UI. */}
<Route path="/skills" element={<SkillsPage />} />
<Route path="/skills/:name" element={<SkillDetailPage />} />
<Route path="/proposals" element={<ProposalsPage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
</BrowserRouter>

View File

@@ -95,6 +95,72 @@ export interface Personality {
promptCount: number;
}
// PR-3: Skill resource. Mirrors Prompt with the addition of multi-file
// bundles (`files`) and typed metadata (`hooks`, `mcpServers`,
// `postInstall`, …).
export interface Skill {
id: string;
name: string;
description: string;
content: string;
files: Record<string, string>;
metadata: Record<string, unknown>;
projectId: string | null;
agentId: string | null;
priority: number;
semver: string;
currentRevisionId: string | null;
createdAt: string;
updatedAt: string;
project?: { name: string } | null;
agent?: { name: string } | null;
}
export interface VisibleSkill {
id: string;
name: string;
description: string;
semver: string;
contentHash: string;
metadata: unknown;
scope: 'global' | 'project' | 'agent';
}
// PR-2: ResourceProposal — generic propose/approve/reject queue.
// Replaces PromptRequest in the new path.
export interface Proposal {
id: string;
resourceType: 'prompt' | 'skill';
name: string;
body: Record<string, unknown>;
projectId: string | null;
agentId: string | null;
createdBySession: string | null;
createdByUserId: string | null;
status: 'pending' | 'approved' | 'rejected';
reviewerNote: string;
approvedRevisionId: string | null;
createdAt: string;
updatedAt: string;
project?: { name: string } | null;
agent?: { name: string } | null;
}
// PR-2: ResourceRevision — append-only audit log keyed by
// (resourceType, resourceId).
export interface Revision {
id: string;
resourceType: 'prompt' | 'skill';
resourceId: string;
semver: string;
contentHash: string;
body: Record<string, unknown>;
authorUserId: string | null;
authorSessionId: string | null;
note: string;
createdAt: string;
}
export interface PersonalityPrompt {
promptId: string;
promptName: string;

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import { diffLines } from 'diff';
import { cn } from '../lib/utils';
/**
* Unified-diff renderer — line-by-line color-coded display. Powers the
* proposal review and revision-history pages. We use `diff.diffLines`
* (text-line granularity) rather than `diff.createPatch` because we
* want to render the diff as styled DOM, not as plain monospace text.
*/
export function Diff({
before,
after,
className,
}: {
before: string;
after: string;
className?: string;
}): React.JSX.Element {
const parts = React.useMemo(() => diffLines(before, after), [before, after]);
return (
<pre
className={cn(
'overflow-x-auto rounded-md border border-(--color-border) bg-(--color-canvas) p-4 font-mono text-xs leading-relaxed',
className,
)}
>
{parts.map((part, i) => {
const color = part.added
? 'text-(--color-success)'
: part.removed
? 'text-(--color-danger)'
: 'text-(--color-fg-muted)';
const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ';
const lines = part.value.split('\n');
// diffLines returns trailing newlines as separate lines; drop the
// empty tail so we don't render dead rows.
const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
return (
<span key={i} className={color}>
{trimmed.map((line, j) => (
<span key={j} className="block whitespace-pre-wrap">
{prefix}
{line}
</span>
))}
</span>
);
})}
</pre>
);
}

View File

@@ -1,80 +1,115 @@
import * as React from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { clearToken } from '../api';
import { LogOut, FolderKanban, Bot, Sparkles, Inbox, LayoutDashboard } from 'lucide-react';
import { api, clearToken, type Proposal } from '../api';
import { Badge } from './ui/badge';
import { cn } from '../lib/utils';
/**
* Top-of-page nav + outlet. Terminal-style dark theme so the UI feels
* adjacent to the CLI rather than a separate product.
* Sidebar layout. Pending-proposals badge polls every 30 s so reviewers
* see a queue building up without having to refresh the page.
*/
export function Layout(): React.JSX.Element {
const [pendingCount, setPendingCount] = React.useState<number | null>(null);
React.useEffect(() => {
let cancelled = false;
async function poll(): Promise<void> {
try {
const proposals = await api.get<Proposal[]>('/api/v1/proposals?status=pending');
if (!cancelled) setPendingCount(proposals.length);
} catch {
if (!cancelled) setPendingCount(null);
}
}
void poll();
const id = setInterval(poll, 30_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
return (
<div style={styles.shell}>
<header style={styles.header}>
<div style={styles.brand}>mcpctl <span style={styles.dim}>· prompt editor</span></div>
<nav style={styles.nav}>
<NavLink to="/projects" style={navStyle}>Projects</NavLink>
<NavLink to="/agents" style={navStyle}>Agents</NavLink>
<div className="flex min-h-screen">
<aside className="flex w-56 shrink-0 flex-col border-r border-(--color-border) bg-(--color-surface)">
<div className="flex items-center gap-2 px-5 py-5">
<span className="text-base font-bold tracking-tight">mcpctl</span>
<span className="text-xs text-(--color-fg-muted)">UI</span>
</div>
<nav className="flex flex-1 flex-col gap-0.5 px-2 py-2">
<NavItem to="/dashboard" icon={LayoutDashboard}>
Dashboard
</NavItem>
<NavItem to="/projects" icon={FolderKanban}>
Projects
</NavItem>
<NavItem to="/agents" icon={Bot}>
Agents
</NavItem>
<NavItem to="/skills" icon={Sparkles}>
Skills
</NavItem>
<NavItem to="/proposals" icon={Inbox} badge={pendingCount}>
Proposals
</NavItem>
</nav>
<div className="border-t border-(--color-border) p-2">
<button
style={styles.logout}
onClick={() => { clearToken(); window.location.assign('/ui/'); }}
onClick={() => {
clearToken();
window.location.assign('/ui/');
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-(--color-fg-muted) transition-colors hover:bg-(--color-surface-hi) hover:text-(--color-fg)"
>
<LogOut className="size-4" />
Logout
</button>
</nav>
</header>
<main style={styles.main}>
</div>
</aside>
<main className="flex-1 overflow-y-auto px-8 py-8">
<Outlet />
</main>
</div>
);
}
function navStyle({ isActive }: { isActive: boolean }): React.CSSProperties {
return {
color: isActive ? '#58a6ff' : '#c9d1d9',
textDecoration: 'none',
padding: '6px 12px',
borderBottom: isActive ? '2px solid #58a6ff' : '2px solid transparent',
};
function NavItem({
to,
icon: Icon,
children,
badge,
}: {
to: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
badge?: number | null;
}): React.JSX.Element {
return (
<NavLink
to={to}
className={({ isActive }) =>
cn(
'flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm transition-colors',
isActive
? 'bg-(--color-surface-hi) text-(--color-fg) font-medium'
: 'text-(--color-fg-muted) hover:bg-(--color-surface-hi) hover:text-(--color-fg)',
)
}
>
<span className="flex items-center gap-2">
<Icon className="size-4" />
{children}
</span>
{typeof badge === 'number' && badge > 0 && (
<Badge variant="warning" className="px-1.5 py-0">
{badge}
</Badge>
)}
</NavLink>
);
}
const styles: Record<string, React.CSSProperties> = {
shell: {
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 24px',
background: '#161b22',
borderBottom: '1px solid #30363d',
},
brand: {
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontWeight: 700,
fontSize: 16,
},
dim: { color: '#7d8590', fontWeight: 400 },
nav: {
display: 'flex',
gap: 8,
alignItems: 'center',
},
logout: {
background: 'transparent',
color: '#c9d1d9',
border: '1px solid #30363d',
padding: '4px 12px',
borderRadius: 4,
cursor: 'pointer',
marginLeft: 12,
},
main: {
flex: 1,
padding: 24,
overflowY: 'auto',
},
};

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium border',
{
variants: {
variant: {
default:
'border-(--color-border) bg-(--color-surface) text-(--color-fg-muted)',
info:
'border-(--color-primary)/30 bg-(--color-primary)/15 text-(--color-primary)',
success:
'border-(--color-success)/30 bg-(--color-success-bg) text-(--color-success)',
warning:
'border-(--color-warning)/30 bg-(--color-warning-bg) text-(--color-warning)',
danger:
'border-(--color-danger)/30 bg-(--color-danger-bg) text-(--color-danger)',
outline:
'border-(--color-border) text-(--color-fg)',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
({ className, variant, ...props }, ref) => (
<span ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
),
);
Badge.displayName = 'Badge';

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-primary) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-canvas) [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
primary:
'bg-(--color-primary) text-(--color-primary-fg) hover:bg-(--color-primary-hover)',
secondary:
'border border-(--color-border) bg-(--color-surface) text-(--color-fg) hover:bg-(--color-surface-hi)',
ghost:
'text-(--color-fg) hover:bg-(--color-surface) hover:text-(--color-fg)',
danger:
'bg-(--color-danger-bg) text-(--color-danger) border border-(--color-danger)/40 hover:bg-(--color-danger) hover:text-(--color-canvas)',
link:
'text-(--color-primary) underline-offset-4 hover:underline',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-9 px-4 text-sm',
lg: 'h-10 px-6 text-base',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
),
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-(--color-border) bg-(--color-surface) shadow-sm',
className,
)}
{...props}
/>
),
);
Card.displayName = 'Card';
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col gap-1.5 p-5', className)}
{...props}
/>
),
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-base font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-(--color-fg-muted)', className)}
{...props}
/>
),
);
CardDescription.displayName = 'CardDescription';
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-5 pt-0 gap-2', className)}
{...props}
/>
),
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,45 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-(--color-border) bg-(--color-canvas) px-3 py-1 text-sm text-(--color-fg) placeholder:text-(--color-fg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-primary) disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
);
Input.displayName = 'Input';
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'flex min-h-24 w-full rounded-md border border-(--color-border) bg-(--color-canvas) px-3 py-2 text-sm text-(--color-fg) placeholder:text-(--color-fg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-primary) disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
);
Textarea.displayName = 'Textarea';
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-xs font-medium uppercase tracking-wider text-(--color-fg-muted)',
className,
)}
{...props}
/>
),
);
Label.displayName = 'Label';

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export function Separator({
className,
orientation = 'horizontal',
}: {
className?: string;
orientation?: 'horizontal' | 'vertical';
}): React.JSX.Element {
return (
<div
role="separator"
aria-orientation={orientation}
className={cn(
'bg-(--color-border)',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
/>
);
}

View File

@@ -0,0 +1,90 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* Tiny no-dep Tabs primitive. Doesn't need Radix for our use case —
* just tracks the active tab via state and re-renders the matching
* panel. ARIA roles are set so screen readers parse it as tabs.
*/
interface TabsContextValue {
value: string;
setValue: (v: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | null>(null);
export function Tabs({
defaultValue,
value: valueProp,
onValueChange,
className,
children,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
className?: string;
children: React.ReactNode;
}): React.JSX.Element {
const [internal, setInternal] = React.useState(defaultValue ?? '');
const value = valueProp ?? internal;
const setValue = React.useCallback(
(v: string) => {
if (valueProp === undefined) setInternal(v);
onValueChange?.(v);
},
[valueProp, onValueChange],
);
return (
<TabsContext.Provider value={{ value, setValue }}>
<div className={cn('flex flex-col gap-3', className)}>{children}</div>
</TabsContext.Provider>
);
}
export function TabsList({ className, children }: { className?: string; children: React.ReactNode }): React.JSX.Element {
return (
<div
role="tablist"
className={cn(
'inline-flex h-9 items-center justify-start gap-1 rounded-md border border-(--color-border) bg-(--color-surface) p-1',
className,
)}
>
{children}
</div>
);
}
export function TabsTrigger({ value, className, children }: { value: string; className?: string; children: React.ReactNode }): React.JSX.Element {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error('TabsTrigger must be used within Tabs');
const active = ctx.value === value;
return (
<button
role="tab"
aria-selected={active}
onClick={() => ctx.setValue(value)}
className={cn(
'inline-flex h-7 items-center justify-center rounded px-3 text-sm font-medium transition-colors',
active
? 'bg-(--color-canvas) text-(--color-fg) shadow-sm'
: 'text-(--color-fg-muted) hover:text-(--color-fg)',
className,
)}
>
{children}
</button>
);
}
export function TabsContent({ value, className, children }: { value: string; className?: string; children: React.ReactNode }): React.JSX.Element | null {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error('TabsContent must be used within Tabs');
if (ctx.value !== value) return null;
return (
<div role="tabpanel" className={cn('focus-visible:outline-none', className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
/**
* Polling hook with cancellation. Re-fetches `fn` every `intervalMs`
* until unmounted. Returns the latest data, error, and a setter to
* force-refresh on demand.
*/
export function usePolling<T>(
fn: () => Promise<T>,
intervalMs: number,
deps: unknown[] = [],
): { data: T | null; error: Error | null; loading: boolean; refetch: () => void } {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const [tick, setTick] = useState(0);
useEffect(() => {
let cancelled = false;
async function run(): Promise<void> {
try {
const v = await fn();
if (!cancelled) {
setData(v);
setError(null);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err as Error);
setLoading(false);
}
}
}
void run();
const id = setInterval(() => { void run(); }, intervalMs);
return () => {
cancelled = true;
clearInterval(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, tick, intervalMs]);
return {
data,
error,
loading,
refetch: () => setTick((t) => t + 1),
};
}

94
src/web/src/index.css Normal file
View File

@@ -0,0 +1,94 @@
/*
* mcpctl design tokens. Dark-mode-only — this is an internal tool and
* adding light mode doubles QA surface for no clear user benefit.
*
* Color philosophy: a near-black canvas with a slightly lifted surface
* tier ("surface" / "surfaceHi") for cards. Borders are subtle (zinc-800
* range) so spatial structure comes from spacing, not lines. Accent
* colours are reserved for status: emerald = success/approved, red =
* danger/rejected, amber = pending, sky = primary action.
*
* Typography: Inter for UI, JetBrains Mono for IDs / code / monospace
* displays. Loaded via Google Fonts so production deploys don't need a
* separate CDN. (Could swap to a self-hosted geist later — the fallback
* stack reads identically.)
*
* NOTE: @import url(...) must come before any other rules. Tailwind's
* own @import directive is wired up after.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
@import "tailwindcss";
@theme {
--color-canvas: oklch(0.16 0.005 270); /* near-black */
--color-surface: oklch(0.20 0.008 270); /* card bg */
--color-surface-hi: oklch(0.24 0.010 270); /* hover/lifted */
--color-border: oklch(0.30 0.010 270);
--color-border-strong: oklch(0.40 0.010 270);
--color-fg: oklch(0.92 0.005 270);
--color-fg-muted: oklch(0.65 0.010 270);
--color-fg-subtle: oklch(0.50 0.012 270);
--color-primary: oklch(0.74 0.16 240); /* sky-ish */
--color-primary-hover: oklch(0.78 0.16 240);
--color-primary-fg: oklch(0.16 0.005 270);
--color-success: oklch(0.72 0.18 145); /* emerald */
--color-success-bg: oklch(0.30 0.10 145);
--color-warning: oklch(0.80 0.16 80); /* amber */
--color-warning-bg: oklch(0.30 0.10 80);
--color-danger: oklch(0.70 0.20 25); /* red */
--color-danger-bg: oklch(0.30 0.12 25);
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow-md: 0 4px 12px rgb(0 0 0 / 0.5);
}
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
html {
color-scheme: dark;
font-family: var(--font-sans);
}
body {
background: var(--color-canvas);
color: var(--color-fg);
font-feature-settings: 'cv11', 'ss01';
-webkit-font-smoothing: antialiased;
margin: 0;
}
::selection {
background: var(--color-primary);
color: var(--color-primary-fg);
}
/* Monaco / code-y bits inherit the mono stack. */
code, pre, kbd {
font-family: var(--font-mono);
}
/* Keep focus visible — accessibility table stakes. */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}

11
src/web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* shadcn-style class-name helper. Merges Tailwind classes intelligently
* (later classes override earlier ones from the same group), and
* conditionally applies values via clsx semantics.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
const root = document.getElementById('root');
if (root === null) throw new Error('#root not found');

View File

@@ -0,0 +1,133 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { Sparkles, Inbox, FolderKanban, Bot, ScrollText } from 'lucide-react';
import { api, type Skill, type Proposal, type Project, type Agent } from '../api';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
/**
* At-a-glance home page. Counts come from the `/api/v1/<resource>`
* lists; pending proposals are highlighted with an amber badge to draw
* the reviewer in.
*/
export function DashboardPage(): React.JSX.Element {
const [counts, setCounts] = React.useState<{
skills: number;
proposals: { pending: number; approved: number; rejected: number };
projects: number;
agents: number;
prompts: number;
} | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const [skills, proposals, projects, agents, prompts] = await Promise.all([
api.get<Skill[]>('/api/v1/skills'),
api.get<Proposal[]>('/api/v1/proposals'),
api.get<Project[]>('/api/v1/projects'),
api.get<Agent[]>('/api/v1/agents'),
api.get<unknown[]>('/api/v1/prompts'),
]);
if (cancelled) return;
setCounts({
skills: skills.length,
proposals: {
pending: proposals.filter((p) => p.status === 'pending').length,
approved: proposals.filter((p) => p.status === 'approved').length,
rejected: proposals.filter((p) => p.status === 'rejected').length,
},
projects: projects.length,
agents: agents.length,
prompts: prompts.length,
});
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
}
void load();
return () => { cancelled = true; };
}, []);
if (error !== null) return <div className="text-(--color-danger)">Error: {error}</div>;
if (counts === null) return <div className="text-(--color-fg-muted)">Loading</div>;
return (
<div className="space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-(--color-fg-muted)">
A glance at what's in mcpd. Numbers update on page load.
</p>
</header>
{counts.proposals.pending > 0 && (
<Link to="/proposals">
<Card className="border-(--color-warning)/40 bg-(--color-warning-bg)/30 transition-colors hover:bg-(--color-warning-bg)/50">
<CardContent className="flex items-center gap-4 p-5 pt-5">
<Inbox className="size-6 text-(--color-warning)" />
<div className="flex-1">
<div className="font-medium text-(--color-fg)">
{counts.proposals.pending}{' '}
pending {counts.proposals.pending === 1 ? 'proposal' : 'proposals'}
</div>
<div className="text-sm text-(--color-fg-muted)">
Review the queue to approve or reject incoming changes.
</div>
</div>
<Badge variant="warning">Review</Badge>
</CardContent>
</Card>
</Link>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<CountCard to="/skills" icon={Sparkles} label="Skills" value={counts.skills} />
<CountCard to="/projects" icon={FolderKanban} label="Projects" value={counts.projects} />
<CountCard to="/agents" icon={Bot} label="Agents" value={counts.agents} />
<CountCard to="/projects" icon={ScrollText} label="Prompts" value={counts.prompts} />
<CountCard
to="/proposals"
icon={Inbox}
label="Proposals"
value={counts.proposals.pending + counts.proposals.approved + counts.proposals.rejected}
subtitle={`${counts.proposals.pending} pending · ${counts.proposals.approved} approved · ${counts.proposals.rejected} rejected`}
/>
</div>
</div>
);
}
function CountCard({
to,
icon: Icon,
label,
value,
subtitle,
}: {
to: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
value: number;
subtitle?: string;
}): React.JSX.Element {
return (
<Link to={to} className="block">
<Card className="transition-colors hover:bg-(--color-surface-hi)">
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-(--color-fg-muted)">{label}</CardTitle>
<Icon className="size-4 text-(--color-fg-muted)" />
</CardHeader>
<CardContent>
<div className="font-mono text-3xl font-semibold tabular-nums">{value}</div>
{subtitle !== undefined && (
<p className="mt-1 text-xs text-(--color-fg-muted)">{subtitle}</p>
)}
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,173 @@
import * as React from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Check, X } from 'lucide-react';
import { api, type Proposal, type Skill } from '../api';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Textarea, Label } from '../components/ui/input';
import { Diff } from '../components/Diff';
export function ProposalDetailPage(): React.JSX.Element {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [proposal, setProposal] = React.useState<Proposal | null>(null);
const [existing, setExisting] = React.useState<string | null>(null);
const [reason, setReason] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const p = await api.get<Proposal>(`/api/v1/proposals/${String(id)}`);
if (cancelled) return;
setProposal(p);
// Fetch existing resource (if any) for the diff.
const projectName = p.project?.name;
try {
if (p.resourceType === 'prompt') {
const params = new URLSearchParams();
if (projectName) params.set('project', projectName);
const list = await api.get<Array<{ name: string; content: string }>>(`/api/v1/prompts?${params.toString()}`);
const match = list.find((x) => x.name === p.name);
if (!cancelled) setExisting(match?.content ?? '');
} else {
const params = new URLSearchParams();
if (projectName) params.set('project', projectName);
const list = await api.get<Skill[]>(`/api/v1/skills?${params.toString()}`);
const match = list.find((x) => x.name === p.name);
if (!cancelled) setExisting(match?.content ?? '');
}
} catch {
if (!cancelled) setExisting('');
}
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
}
void load();
return () => { cancelled = true; };
}, [id]);
if (error !== null) return (
<div className="space-y-3">
<Link to="/proposals" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
<ArrowLeft className="size-3.5" /> Proposals
</Link>
<div className="text-(--color-danger)">Error: {error}</div>
</div>
);
if (proposal === null) return <div className="text-(--color-fg-muted)">Loading</div>;
const proposed = (proposal.body as { content?: string }).content ?? '';
const isPending = proposal.status === 'pending';
const willCreateNew = (existing ?? '').length === 0;
const scope = proposal.project?.name ?? proposal.agent?.name ?? 'global';
async function approve(): Promise<void> {
if (!proposal) return;
setBusy(true);
try {
await api.post(`/api/v1/proposals/${proposal.id}/approve`, {});
navigate('/proposals');
} finally {
setBusy(false);
}
}
async function reject(): Promise<void> {
if (!proposal) return;
if (reason.trim().length === 0) return;
setBusy(true);
try {
await api.post(`/api/v1/proposals/${proposal.id}/reject`, { reviewerNote: reason });
navigate('/proposals');
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<Link to="/proposals" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
<ArrowLeft className="size-3.5" /> Proposals
</Link>
<header className="flex items-start justify-between gap-6">
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="font-mono text-2xl font-semibold tracking-tight">{proposal.name}</h1>
<Badge variant="outline">{proposal.resourceType}</Badge>
<Badge variant={proposal.status === 'pending' ? 'warning' : proposal.status === 'approved' ? 'success' : 'danger'}>
{proposal.status}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-(--color-fg-subtle)">
<span>scope: {scope}</span>
<span>session: <code className="font-mono">{proposal.createdBySession ?? '—'}</code></span>
<span>created: {new Date(proposal.createdAt).toLocaleString()}</span>
</div>
{proposal.reviewerNote && (
<div className="rounded-md border border-(--color-border) bg-(--color-surface-hi) p-3 text-sm">
<span className="text-(--color-fg-muted)">Reviewer note:</span> {proposal.reviewerNote}
</div>
)}
</div>
{isPending && (
<div className="flex items-center gap-2">
<Button variant="primary" onClick={approve} disabled={busy}>
<Check className="size-4" />
Approve
</Button>
</div>
)}
</header>
<Card>
<CardHeader>
<CardTitle className="text-sm">
{willCreateNew ? `Would create a new ${proposal.resourceType}` : 'Diff against current'}
</CardTitle>
</CardHeader>
<CardContent>
{willCreateNew ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-(--color-canvas) p-3 font-mono text-xs leading-relaxed">
{proposed}
</pre>
) : (
<Diff before={existing ?? ''} after={proposed} />
)}
</CardContent>
</Card>
{isPending && (
<Card className="border-(--color-danger)/30">
<CardHeader>
<CardTitle className="text-sm">Reject</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="reject-reason">Reviewer note (required)</Label>
<Textarea
id="reject-reason"
placeholder="Explain why this is being rejected so the proposer can learn from it."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<Button variant="danger" onClick={reject} disabled={busy || reason.trim().length === 0}>
<X className="size-4" />
Reject with note
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,136 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { Inbox, ScrollText, Sparkles } from 'lucide-react';
import { api, type Proposal } from '../api';
import { Card, CardContent } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
export function ProposalsPage(): React.JSX.Element {
const [proposals, setProposals] = React.useState<Proposal[] | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [tab, setTab] = React.useState<'pending' | 'approved' | 'rejected'>('pending');
React.useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const data = await api.get<Proposal[]>('/api/v1/proposals');
if (!cancelled) setProposals(data);
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
}
void load();
const id = setInterval(load, 30_000);
return () => { cancelled = true; clearInterval(id); };
}, []);
if (error !== null) return <div className="text-(--color-danger)">Error: {error}</div>;
if (proposals === null) return <div className="text-(--color-fg-muted)">Loading proposals</div>;
const pending = proposals.filter((p) => p.status === 'pending');
const approved = proposals.filter((p) => p.status === 'approved');
const rejected = proposals.filter((p) => p.status === 'rejected');
return (
<div className="space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Proposals</h1>
<p className="text-sm text-(--color-fg-muted)">
Prompts and skills proposed by Claude sessions or human authors. Approve to materialise; reject to dismiss.
</p>
</header>
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<TabsList>
<TabsTrigger value="pending">
Pending {pending.length > 0 && <span className="ml-1 text-(--color-warning)">({pending.length})</span>}
</TabsTrigger>
<TabsTrigger value="approved">Approved ({approved.length})</TabsTrigger>
<TabsTrigger value="rejected">Rejected ({rejected.length})</TabsTrigger>
</TabsList>
<TabsContent value="pending">
<ProposalList list={pending} emptyText="No pending proposals." />
</TabsContent>
<TabsContent value="approved">
<ProposalList list={approved} emptyText="No approved proposals yet." />
</TabsContent>
<TabsContent value="rejected">
<ProposalList list={rejected} emptyText="No rejected proposals." />
</TabsContent>
</Tabs>
</div>
);
}
function ProposalList({ list, emptyText }: { list: Proposal[]; emptyText: string }): React.JSX.Element {
if (list.length === 0) {
return (
<Card>
<CardContent className="flex items-center justify-center gap-2 p-8 text-(--color-fg-muted)">
<Inbox className="size-4" /> {emptyText}
</CardContent>
</Card>
);
}
return (
<div className="space-y-2">
{list.map((p) => <ProposalRow key={p.id} proposal={p} />)}
</div>
);
}
function ProposalRow({ proposal }: { proposal: Proposal }): React.JSX.Element {
const Icon = proposal.resourceType === 'skill' ? Sparkles : ScrollText;
const scope =
proposal.project?.name
? `project: ${proposal.project.name}`
: proposal.agent?.name
? `agent: ${proposal.agent.name}`
: 'global';
const statusVariant =
proposal.status === 'pending' ? 'warning' : proposal.status === 'approved' ? 'success' : 'danger';
return (
<Link to={`/proposals/${proposal.id}`}>
<Card className="transition-colors hover:bg-(--color-surface-hi)">
<CardContent className="flex items-center justify-between gap-4 p-4">
<div className="flex items-center gap-3 min-w-0">
<Icon className="size-4 shrink-0 text-(--color-fg-muted)" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-medium truncate">{proposal.name}</span>
<Badge variant="outline">{proposal.resourceType}</Badge>
</div>
<div className="text-xs text-(--color-fg-subtle)">
{scope} · session{' '}
<code className="font-mono">{(proposal.createdBySession ?? '—').slice(0, 8)}</code>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-(--color-fg-subtle)">
{ageOf(proposal.createdAt)}
</span>
<Badge variant={statusVariant}>{proposal.status}</Badge>
</div>
</CardContent>
</Card>
</Link>
);
}
function ageOf(iso: string): string {
const t = Date.parse(iso);
if (Number.isNaN(t)) return '?';
const sec = Math.floor((Date.now() - t) / 1000);
if (sec < 60) return `${String(sec)}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${String(min)}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${String(hr)}h`;
return `${String(Math.floor(hr / 24))}d`;
}

View File

@@ -0,0 +1,185 @@
import * as React from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, History } from 'lucide-react';
import { api, type Skill, type Revision } from '../api';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
import { Diff } from '../components/Diff';
export function SkillDetailPage(): React.JSX.Element {
const { name } = useParams<{ name: string }>();
const [skill, setSkill] = React.useState<Skill | null>(null);
const [revisions, setRevisions] = React.useState<Revision[] | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [tab, setTab] = React.useState('content');
React.useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const list = await api.get<Skill[]>('/api/v1/skills');
const match = list.find((s) => s.name === name);
if (!match) {
if (!cancelled) setError(`Skill "${String(name)}" not found`);
return;
}
const full = await api.get<Skill>(`/api/v1/skills/${match.id}`);
if (!cancelled) setSkill(full);
const revs = await api.get<Revision[]>(
`/api/v1/revisions?resourceType=skill&resourceId=${full.id}`,
);
if (!cancelled) setRevisions(revs);
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
}
void load();
return () => { cancelled = true; };
}, [name]);
if (error !== null) return (
<div className="space-y-3">
<Link to="/skills" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
<ArrowLeft className="size-3.5" /> Skills
</Link>
<div className="text-(--color-danger)">Error: {error}</div>
</div>
);
if (skill === null) return <div className="text-(--color-fg-muted)">Loading</div>;
const fileEntries = Object.entries(skill.files);
const metadataKeys = Object.keys(skill.metadata);
return (
<div className="space-y-6">
<Link to="/skills" className="inline-flex items-center gap-1 text-sm text-(--color-primary) hover:underline">
<ArrowLeft className="size-3.5" /> Skills
</Link>
<header className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="font-mono text-2xl font-semibold tracking-tight">{skill.name}</h1>
<Badge variant="info">v{skill.semver}</Badge>
</div>
{skill.description && (
<p className="text-sm text-(--color-fg-muted)">{skill.description}</p>
)}
<div className="flex items-center gap-3 pt-1 text-xs text-(--color-fg-subtle)">
<span>id: <code className="font-mono">{skill.id}</code></span>
{skill.project?.name && <span>project: <code className="font-mono">{skill.project.name}</code></span>}
{skill.agent?.name && <span>agent: <code className="font-mono">{skill.agent.name}</code></span>}
</div>
</div>
</header>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="content">SKILL.md</TabsTrigger>
{fileEntries.length > 0 && <TabsTrigger value="files">Files ({fileEntries.length})</TabsTrigger>}
{metadataKeys.length > 0 && <TabsTrigger value="metadata">Metadata</TabsTrigger>}
<TabsTrigger value="history">History ({revisions?.length ?? '…'})</TabsTrigger>
</TabsList>
<TabsContent value="content">
<Card>
<CardContent className="p-5">
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-(--color-fg)">
{skill.content}
</pre>
</CardContent>
</Card>
</TabsContent>
{fileEntries.length > 0 && (
<TabsContent value="files">
<div className="space-y-3">
{fileEntries.map(([path, content]) => (
<Card key={path}>
<CardHeader>
<CardTitle className="font-mono text-sm">{path}</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded bg-(--color-canvas) p-3 font-mono text-xs">{content}</pre>
</CardContent>
</Card>
))}
</div>
</TabsContent>
)}
{metadataKeys.length > 0 && (
<TabsContent value="metadata">
<Card>
<CardContent className="p-5">
<pre className="overflow-x-auto rounded bg-(--color-canvas) p-3 font-mono text-xs">
{JSON.stringify(skill.metadata, null, 2)}
</pre>
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="history">
<RevisionHistorySection revisions={revisions} skill={skill} />
</TabsContent>
</Tabs>
</div>
);
}
function RevisionHistorySection({
revisions,
skill,
}: {
revisions: Revision[] | null;
skill: Skill;
}): React.JSX.Element {
const [diffAgainst, setDiffAgainst] = React.useState<string | null>(null);
if (revisions === null) return <div className="text-(--color-fg-muted)">Loading history</div>;
if (revisions.length === 0) {
return <Card><CardContent className="p-8 text-center text-(--color-fg-muted)">No revisions yet.</CardContent></Card>;
}
const target = revisions.find((r) => r.id === diffAgainst);
const targetContent = (target?.body as { content?: string } | undefined)?.content ?? '';
return (
<div className="space-y-4">
<div className="space-y-2">
{revisions.map((rev) => (
<Card key={rev.id} className="cursor-pointer transition-colors hover:bg-(--color-surface-hi)" onClick={() => setDiffAgainst(rev.id === diffAgainst ? null : rev.id)}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex items-center gap-3">
<History className="size-4 text-(--color-fg-muted)" />
<Badge variant={rev.id === diffAgainst ? 'info' : 'outline'}>v{rev.semver}</Badge>
{rev.note && <span className="text-sm text-(--color-fg-muted)">{rev.note}</span>}
</div>
<span className="text-xs text-(--color-fg-subtle)">
{new Date(rev.createdAt).toLocaleString()}
</span>
</CardContent>
</Card>
))}
</div>
{target && (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm">
Diff: v{target.semver} live (v{skill.semver})
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setDiffAgainst(null)}>Close</Button>
</CardHeader>
<CardContent>
<Diff before={targetContent} after={skill.content} />
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { Sparkles, FolderKanban, Bot, Globe } from 'lucide-react';
import { api, type Skill } from '../api';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
export function SkillsPage(): React.JSX.Element {
const [skills, setSkills] = React.useState<Skill[] | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const data = await api.get<Skill[]>('/api/v1/skills');
if (!cancelled) setSkills(data);
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
}
void load();
return () => { cancelled = true; };
}, []);
if (error !== null) return <div className="text-(--color-danger)">Error: {error}</div>;
if (skills === null) return <div className="text-(--color-fg-muted)">Loading skills</div>;
const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name));
const globals = sorted.filter((s) => s.projectId === null && s.agentId === null);
const scoped = sorted.filter((s) => s.projectId !== null || s.agentId !== null);
return (
<div className="space-y-6">
<header className="flex items-end justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Skills</h1>
<p className="text-sm text-(--color-fg-muted)">
Materialised onto every dev box by <code className="font-mono text-xs">mcpctl skills sync</code>.
</p>
</div>
<span className="text-sm text-(--color-fg-muted)">
{sorted.length} {sorted.length === 1 ? 'skill' : 'skills'}
</span>
</header>
{sorted.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-(--color-fg-muted)">
No skills defined yet. Create one with{' '}
<code className="rounded bg-(--color-surface-hi) px-1 py-0.5 font-mono text-xs">
mcpctl create skill {'<name>'}
</code>
.
</CardContent>
</Card>
)}
{globals.length > 0 && (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-(--color-fg-muted)">
Global
</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{globals.map((s) => <SkillCard key={s.id} skill={s} />)}
</div>
</section>
)}
{scoped.length > 0 && (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-(--color-fg-muted)">
Project- and agent-scoped
</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{scoped.map((s) => <SkillCard key={s.id} skill={s} />)}
</div>
</section>
)}
</div>
);
}
function SkillCard({ skill }: { skill: Skill }): React.JSX.Element {
const ScopeIcon =
skill.projectId !== null ? FolderKanban : skill.agentId !== null ? Bot : Globe;
const scopeLabel =
skill.project?.name
? `project: ${skill.project.name}`
: skill.agent?.name
? `agent: ${skill.agent.name}`
: 'global';
return (
<Link to={`/skills/${encodeURIComponent(skill.name)}`}>
<Card className="h-full transition-colors hover:bg-(--color-surface-hi)">
<CardHeader className="space-y-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="font-mono text-sm">
<Sparkles className="mr-1.5 inline size-3.5 text-(--color-primary)" />
{skill.name}
</CardTitle>
<Badge variant="info">v{skill.semver}</Badge>
</div>
{skill.description && (
<p className="text-sm text-(--color-fg-muted) line-clamp-2">{skill.description}</p>
)}
</CardHeader>
<CardContent>
<div className="flex items-center gap-1.5 text-xs text-(--color-fg-subtle)">
<ScopeIcon className="size-3" />
<span>{scopeLabel}</span>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -1,6 +1,7 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
/**
* Vite config for the @mcpctl/web prompt editor.
@@ -16,7 +17,7 @@ import react from '@vitejs/plugin-react';
const apiTarget = process.env['MCPCTL_API_URL'] ?? 'https://mcpctl.ad.itaz.eu';
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
base: '/ui/',
server: {
port: 5173,