Merge pull request #20 from offendingcommit/feat/peer-card-seed-kits

feat(web): add peer card seed kits
This commit is contained in:
Offending Commit
2026-05-28 14:24:48 -05:00
committed by GitHub
15 changed files with 1236 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import {
ChevronsUpDown,
Eye,
EyeOff,
Layers,
LayoutDashboard,
Lightbulb,
MessageSquare,
@@ -30,6 +31,7 @@ import { COLOR } from "@/lib/constants";
const TOP_NAV = [
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
{ to: "/seed-kits" as const, label: "Seed Kits", icon: Layers, exact: false },
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
];

View File

@@ -0,0 +1,311 @@
import { useQuery } from "@tanstack/react-query";
import { Check, Loader2, Sparkles, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useScopedPeerCard, useScopedPeers } from "@/api/compareQueries";
import { createScopedClient } from "@/api/scopedClient";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Body, Caption, Muted } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances";
import type { Instance } from "@/lib/config";
import { mergeCardLines, type SeedKit } from "@/lib/seedKits";
interface ApplyKitDialogProps {
kit: SeedKit | null;
open: boolean;
onClose: () => void;
}
function err(e: unknown): never {
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
}
function useScopedWorkspacesAll(instance: Instance | null) {
return useQuery({
queryKey: ["seed-kits-workspaces", instance?.id ?? "none"],
queryFn: async () => {
if (!instance) return [] as Array<{ id: string }>;
const client = createScopedClient(instance);
const { data, error } = await client.POST("/v3/workspaces/list", {
params: { query: { page: 1, page_size: 100 } },
body: {},
});
const payload = data ?? err(error);
return ((payload as { items?: Array<{ id: string }> }).items ?? []) as Array<{ id: string }>;
},
enabled: Boolean(instance),
});
}
export function ApplyKitDialog({ kit, open, onClose }: ApplyKitDialogProps) {
const { instances, activeId } = useInstances();
const [instanceId, setInstanceId] = useState<string | null>(activeId);
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
const [peerId, setPeerId] = useState<string | null>(null);
const [submitState, setSubmitState] = useState<
{ kind: "idle" } | { kind: "pending" } | { kind: "ok" } | { kind: "error"; message: string }
>({ kind: "idle" });
useEffect(() => {
if (open) {
setInstanceId(activeId);
setWorkspaceId(null);
setPeerId(null);
setSubmitState({ kind: "idle" });
}
}, [open, activeId]);
const instance = instances.find((i) => i.id === instanceId) ?? null;
const workspaces = useScopedWorkspacesAll(instance);
const peers = useScopedPeers(instance as Instance, workspaceId ?? "", 1, 100);
const existingCard = useScopedPeerCard(instance as Instance, workspaceId ?? "", peerId ?? "");
const peerItems = (peers.data as { items?: Array<{ id: string }> } | undefined)?.items ?? [];
const existingLines = useMemo(() => {
const card = existingCard.data as { peer_card?: unknown } | undefined;
if (Array.isArray(card?.peer_card)) return card.peer_card as string[];
if (typeof card === "string") return [card];
return [] as string[];
}, [existingCard.data]);
const mergedLines = useMemo(
() => (kit ? mergeCardLines(existingLines, kit.lines) : existingLines),
[kit, existingLines],
);
const canApply =
kit !== null &&
instance !== null &&
Boolean(workspaceId) &&
Boolean(peerId) &&
submitState.kind !== "pending";
async function handleApply() {
if (!kit || !instance || !workspaceId || !peerId) return;
setSubmitState({ kind: "pending" });
try {
const client = createScopedClient(instance);
const { error } = await client.PUT("/v3/workspaces/{workspace_id}/peers/{peer_id}/card", {
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
body: { peer_card: mergedLines },
});
if (error) {
setSubmitState({
kind: "error",
message: typeof error === "object" ? JSON.stringify(error) : String(error),
});
return;
}
setSubmitState({ kind: "ok" });
await existingCard.refetch();
setTimeout(onClose, 700);
} catch (e) {
setSubmitState({
kind: "error",
message: e instanceof Error ? e.message : String(e),
});
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles
className="w-4 h-4"
style={{ color: "var(--accent-text)" }}
strokeWidth={1.8}
/>
Apply seed kit
</DialogTitle>
<DialogDescription>
{kit ? (
<>
Apply <span className="font-medium">{kit.name}</span> to a peer's card. Existing
lines with a matching prefix are replaced; everything else is appended.
</>
) : (
"Pick a kit to apply."
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<PickerRow label="Instance">
<select
value={instanceId ?? ""}
onChange={(e) => {
setInstanceId(e.target.value || null);
setWorkspaceId(null);
setPeerId(null);
}}
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none"
style={{
background: "var(--surface)",
color: "var(--text-1)",
border: "1px solid var(--border-2)",
}}
>
<option value=""> pick an instance </option>
{instances.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</PickerRow>
<PickerRow label="Workspace">
<select
value={workspaceId ?? ""}
onChange={(e) => {
setWorkspaceId(e.target.value || null);
setPeerId(null);
}}
disabled={!instance || workspaces.isLoading}
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-50"
style={{
background: "var(--surface)",
color: "var(--text-1)",
border: "1px solid var(--border-2)",
}}
>
<option value="">{workspaces.isLoading ? "loading…" : "— pick a workspace —"}</option>
{(workspaces.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.id}
</option>
))}
</select>
</PickerRow>
<PickerRow label="Peer">
<select
value={peerId ?? ""}
onChange={(e) => setPeerId(e.target.value || null)}
disabled={!workspaceId || peers.isLoading}
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-50"
style={{
background: "var(--surface)",
color: "var(--text-1)",
border: "1px solid var(--border-2)",
}}
>
<option value="">{peers.isLoading ? "loading…" : "— pick a peer —"}</option>
{peerItems.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</PickerRow>
</div>
{kit && peerId && (
<div className="mt-4 space-y-3">
<MergePreview
existing={existingLines}
merged={mergedLines}
loading={existingCard.isLoading}
/>
</div>
)}
{submitState.kind === "error" && (
<p className="text-xs mt-3" style={{ color: "#f87171" }}>
{submitState.message}
</p>
)}
<div className="flex justify-end gap-2 pt-4 mt-4">
<Button type="button" variant="surface" onClick={onClose}>
<X className="w-3.5 h-3.5" strokeWidth={2} />
Cancel
</Button>
<Button type="button" variant="accent" onClick={handleApply} disabled={!canApply}>
{submitState.kind === "pending" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={2} />
) : submitState.kind === "ok" ? (
<Check className="w-3.5 h-3.5" strokeWidth={2} />
) : (
<Sparkles className="w-3.5 h-3.5" strokeWidth={2} />
)}
{submitState.kind === "ok" ? "Applied" : "Apply kit"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function PickerRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: select element is provided via `children`
<label className="flex items-center gap-3">
<span className="text-xs font-medium w-20 shrink-0" style={{ color: "var(--text-3)" }}>
{label}
</span>
{children}
</label>
);
}
interface MergePreviewProps {
existing: string[];
merged: string[];
loading: boolean;
}
function MergePreview({ existing, merged, loading }: MergePreviewProps) {
const existingSet = useMemo(() => new Set(existing), [existing]);
if (loading) {
return <Muted className="text-xs">Loading existing card</Muted>;
}
if (merged.length === 0) {
return <Muted className="text-xs">Nothing to apply.</Muted>;
}
return (
<div>
<Caption className="block mb-1.5">Preview after apply</Caption>
<div
className="rounded-lg p-3 font-mono text-xs space-y-0.5 max-h-64 overflow-y-auto"
style={{
background: "var(--surface)",
border: "1px solid var(--border-2)",
}}
>
{merged.map((line, i) => {
const isNew = !existingSet.has(line);
return (
<div
key={`${i}-${line}`}
style={{
color: isNew ? "var(--accent-text)" : "var(--text-2)",
fontWeight: isNew ? 500 : 400,
}}
>
{isNew ? "+ " : " "}
{line || <span style={{ color: "var(--text-4)" }}>(empty)</span>}
</div>
);
})}
</div>
<Body className="text-xs mt-1.5" style={{ color: "var(--text-3)" }}>
<span style={{ color: "var(--accent-text)" }}>+ </span>
new or replaced {merged.length} total line{merged.length === 1 ? "" : "s"}
</Body>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { Save, X } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input";
import type { SeedKit } from "@/lib/seedKits";
interface SeedKitFormProps {
initial?: Pick<SeedKit, "name" | "description" | "lines">;
onSubmit: (input: { name: string; description: string; lines: string[] }) => void;
onCancel: () => void;
submitLabel?: string;
}
export function SeedKitForm({
initial,
onSubmit,
onCancel,
submitLabel = "Save kit",
}: SeedKitFormProps) {
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [linesText, setLinesText] = useState((initial?.lines ?? []).join("\n"));
const [error, setError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setError("Name is required");
return;
}
const lines = linesText
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => l.length > 0);
if (lines.length === 0) {
setError("Add at least one line");
return;
}
setError(null);
onSubmit({ name: trimmedName, description: description.trim(), lines });
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label
htmlFor="kit-name"
className="text-xs font-medium block mb-1"
style={{ color: "var(--text-3)" }}
>
Name
</label>
<Input
id="kit-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Personal core"
/>
</div>
<div>
<label
htmlFor="kit-description"
className="text-xs font-medium block mb-1"
style={{ color: "var(--text-3)" }}
>
Description{" "}
<span style={{ color: "var(--text-4)" }} className="font-normal">
(optional)
</span>
</label>
<Input
id="kit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Short summary of what this kit seeds"
/>
</div>
<div>
<label
htmlFor="kit-lines"
className="text-xs font-medium block mb-1"
style={{ color: "var(--text-3)" }}
>
Lines{" "}
<span style={{ color: "var(--text-4)" }} className="font-normal">
(one per line e.g. <span className="font-mono">Name: Ben</span>)
</span>
</label>
<Textarea
id="kit-lines"
value={linesText}
onChange={(e) => setLinesText(e.target.value)}
rows={10}
className="font-mono resize-y"
style={{ minHeight: "12rem" }}
placeholder={"Name: \nEmail: \nRole: "}
/>
</div>
{error && (
<p className="text-xs" style={{ color: "#f87171" }}>
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="surface" onClick={onCancel}>
<X className="w-3.5 h-3.5" strokeWidth={2} />
Cancel
</Button>
<Button type="submit" variant="accent">
<Save className="w-3.5 h-3.5" strokeWidth={2} />
{submitLabel}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,365 @@
import { motion } from "framer-motion";
import { Copy, Layers, Pencil, Plus, Sparkles, Trash2 } from "lucide-react";
import { useState, useSyncExternalStore } from "react";
import { ApplyKitDialog } from "@/components/seed-kits/ApplyKitDialog";
import { SeedKitForm } from "@/components/seed-kits/SeedKitForm";
import { EmptyState } from "@/components/shared/EmptyState";
import { Button } from "@/components/ui/button";
import { Body, Caption, MonoCaption, PageTitle } from "@/components/ui/typography";
import {
BUILTIN_KITS,
createKit,
deleteKit,
isBuiltinKit,
loadUserKits,
type SeedKit,
updateKit,
} from "@/lib/seedKits";
const EVENT = "openconcho:seed-kits-changed";
function emit() {
window.dispatchEvent(new Event(EVENT));
}
function subscribe(cb: () => void): () => void {
window.addEventListener(EVENT, cb);
window.addEventListener("storage", cb);
return () => {
window.removeEventListener(EVENT, cb);
window.removeEventListener("storage", cb);
};
}
let cachedKey = "";
let cachedSnapshot: SeedKit[] = [];
function getSnapshot(): SeedKit[] {
const next = loadUserKits();
const key = JSON.stringify(next);
if (key !== cachedKey) {
cachedKey = key;
cachedSnapshot = next;
}
return cachedSnapshot;
}
function getServerSnapshot(): SeedKit[] {
return cachedSnapshot;
}
function useUserKits(): SeedKit[] {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
type Mode =
| { kind: "list" }
| { kind: "create"; initial?: Pick<SeedKit, "name" | "description" | "lines"> }
| { kind: "edit"; id: string };
export function SeedKitsView() {
const userKits = useUserKits();
const [mode, setMode] = useState<Mode>({ kind: "list" });
const [applyTarget, setApplyTarget] = useState<SeedKit | null>(null);
if (mode.kind === "create") {
return (
<div className="page-container">
<header className="mb-6">
<PageTitle>New seed kit</PageTitle>
<Body className="mt-1">Define the lines that will be merged into a peer's card.</Body>
</header>
<div
className="rounded-xl p-5"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
}}
>
<SeedKitForm
initial={mode.initial}
submitLabel="Create kit"
onSubmit={(input) => {
createKit(input);
emit();
setMode({ kind: "list" });
}}
onCancel={() => setMode({ kind: "list" })}
/>
</div>
</div>
);
}
if (mode.kind === "edit") {
const kit = userKits.find((k) => k.id === mode.id);
if (!kit) {
return (
<div className="page-container">
<Body>Kit not found.</Body>
<Button variant="ghost" onClick={() => setMode({ kind: "list" })} className="mt-3">
Back
</Button>
</div>
);
}
return (
<div className="page-container">
<header className="mb-6">
<PageTitle>Edit seed kit</PageTitle>
<Body className="mt-1">{kit.name}</Body>
</header>
<div
className="rounded-xl p-5"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
}}
>
<SeedKitForm
initial={kit}
submitLabel="Save changes"
onSubmit={(input) => {
updateKit(kit.id, input);
emit();
setMode({ kind: "list" });
}}
onCancel={() => setMode({ kind: "list" })}
/>
</div>
</div>
);
}
return (
<div className="page-container">
<motion.header
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 flex items-start justify-between gap-4 flex-wrap"
>
<div>
<PageTitle className="flex items-center gap-2">
<Layers className="w-5 h-5" style={{ color: "var(--accent-text)" }} strokeWidth={1.5} />
Seed Kits
</PageTitle>
<Body className="mt-1 max-w-xl">
Pre-defined sets of card lines you can apply to any peer in one click. Useful for
seeding identity facts across multiple agents.
</Body>
</div>
<Button variant="accent" onClick={() => setMode({ kind: "create" })}>
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
New kit
</Button>
</motion.header>
<section className="mb-8">
<div className="flex items-center gap-2 mb-3">
<h2
className="text-xs font-medium uppercase tracking-wider"
style={{ color: "var(--text-3)" }}
>
Built-in starters
</h2>
<Caption>Read-only fork to customize</Caption>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{BUILTIN_KITS.map((kit) => (
<KitCard
key={kit.id}
kit={kit}
onApply={() => setApplyTarget(kit)}
onFork={() =>
setMode({
kind: "create",
initial: {
name: `${kit.name} (copy)`,
description: kit.description,
lines: [...kit.lines],
},
})
}
/>
))}
</div>
</section>
<section>
<div className="flex items-center gap-2 mb-3">
<h2
className="text-xs font-medium uppercase tracking-wider"
style={{ color: "var(--text-3)" }}
>
Your kits
</h2>
<Caption>
{userKits.length} kit{userKits.length === 1 ? "" : "s"}
</Caption>
</div>
{userKits.length === 0 ? (
<EmptyState
icon={Sparkles}
title="No custom kits yet"
description="Create your first kit, or fork a built-in to get started."
action={
<Button variant="accent" onClick={() => setMode({ kind: "create" })}>
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
New kit
</Button>
}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{userKits.map((kit) => (
<KitCard
key={kit.id}
kit={kit}
onApply={() => setApplyTarget(kit)}
onEdit={() => setMode({ kind: "edit", id: kit.id })}
onDelete={() => {
deleteKit(kit.id);
emit();
}}
onFork={() =>
setMode({
kind: "create",
initial: {
name: `${kit.name} (copy)`,
description: kit.description,
lines: [...kit.lines],
},
})
}
/>
))}
</div>
)}
</section>
<ApplyKitDialog
kit={applyTarget}
open={applyTarget !== null}
onClose={() => setApplyTarget(null)}
/>
</div>
);
}
interface KitCardProps {
kit: SeedKit;
onApply: () => void;
onEdit?: () => void;
onDelete?: () => void;
onFork: () => void;
}
function KitCard({ kit, onApply, onEdit, onDelete, onFork }: KitCardProps) {
const builtin = isBuiltinKit(kit);
const [confirmingDelete, setConfirmingDelete] = useState(false);
return (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-4 flex flex-col gap-3"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
}}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3
className="text-sm font-medium truncate"
style={{ color: "var(--text-1)" }}
title={kit.name}
>
{kit.name}
</h3>
{builtin && (
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium"
style={{
background: "var(--accent-dim)",
color: "var(--accent-text)",
border: "1px solid var(--accent-border)",
}}
>
built-in
</span>
)}
</div>
{kit.description && <Caption className="block leading-relaxed">{kit.description}</Caption>}
</div>
<div
className="rounded-md px-3 py-2 font-mono text-xs space-y-0.5 max-h-32 overflow-y-auto"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
{kit.lines.length === 0 ? (
<MonoCaption>(no lines)</MonoCaption>
) : (
kit.lines.map((line, i) => (
<div
key={`${i}-${line}`}
className="truncate"
style={{ color: "var(--text-2)" }}
title={line}
>
{line || <span style={{ color: "var(--text-4)" }}>(empty)</span>}
</div>
))
)}
</div>
<div className="flex items-center justify-between gap-2 mt-auto">
<Button variant="accent" size="sm" onClick={onApply}>
<Sparkles className="w-3 h-3" strokeWidth={2} />
Apply
</Button>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={onFork} title="Fork into a new kit">
<Copy className="w-3 h-3" strokeWidth={2} />
</Button>
{!builtin && onEdit && (
<Button variant="ghost" size="sm" onClick={onEdit} title="Edit">
<Pencil className="w-3 h-3" strokeWidth={2} />
</Button>
)}
{!builtin &&
onDelete &&
(confirmingDelete ? (
<button
type="button"
onClick={() => {
onDelete();
setConfirmingDelete(false);
}}
className="text-xs font-medium px-2 py-1 rounded-md"
style={{
color: "#f87171",
border: "1px solid #f87171",
}}
>
Confirm
</button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingDelete(true)}
title="Delete"
>
<Trash2 className="w-3 h-3" strokeWidth={2} />
</Button>
))}
</div>
</div>
</motion.div>
);
}