feat(web): add Peer Card Seed Kits

Adds a /seed-kits page where users can author named "kits" of card
lines (e.g. "Personal core", "Work context") and one-click apply them
to any peer across any registered instance. Removes the chore of
manually re-typing the same identity facts into multiple agents.

Built-in starter kits are read-only but forkable. User kits live in
localStorage. The apply flow shows a side-by-side merge preview that
dedupes by the line's prefix (text before the first ":"), so applying
a kit with "Name: Ben Sheridan-Edwards" to a peer whose card already
has "Name: Ben" replaces the line rather than duplicating it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Agents
2026-05-24 18:28:23 +01:00
committed by Offending Commit
parent de8db4b7aa
commit 650bfd7b28
12 changed files with 1226 additions and 0 deletions

BIN
docs/seed-kits/01-list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -0,0 +1,94 @@
// One-off screenshot capture for PR documentation.
// Usage: BASE_URL=http://localhost:5177 node scripts/screenshot-seed-kits.mjs
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { chromium } from "@playwright/test";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:5177";
const OUT_DIR = resolve(process.cwd(), "docs/seed-kits");
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
await ctx.addInitScript(() => {
window.localStorage.setItem(
"openconcho:instances",
JSON.stringify({
instances: [
{ id: "neo", name: "Neo (personal)", baseUrl: "http://localhost:8001", token: "" },
{ id: "jeeves", name: "Jeeves (CodeWalnut)", baseUrl: "http://localhost:8002", token: "" },
],
activeId: "neo",
}),
);
window.localStorage.setItem(
"openconcho:seed-kits",
JSON.stringify([
{
id: "kit_ben_personal",
name: "Ben — personal core",
description: "Identity facts Ben wants every personal-tier agent to know.",
lines: [
"Name: Ben Sheridan-Edwards",
"Preferred address: Chief",
"Email: ben@codewalnut.com",
"Role: Founder",
"Github: BenSheridanEdwards",
],
},
{
id: "kit_codewalnut_context",
name: "CodeWalnut work context",
description: "Work-tier identity for Jeeves and any future CodeWalnut agents.",
lines: ["Employer: CodeWalnut", "Role: Founder", "Reports to: (self)"],
},
]),
);
});
const page = await ctx.newPage();
async function shot(name, options = {}) {
const file = resolve(OUT_DIR, `${name}.png`);
await page.screenshot({ path: file, fullPage: false, ...options });
console.log("wrote", file);
}
// 1. List view with built-ins + user kits
await page.goto(`${BASE_URL}/seed-kits`, { waitUntil: "networkidle" });
await page.waitForSelector("text=Seed Kits");
await page.waitForTimeout(400); // settle animations
await shot("01-list");
// 2. Create form (use the "New kit" button)
await page
.getByRole("button", { name: /^New kit$/ })
.first()
.click();
await page.waitForSelector("text=New seed kit");
await page.waitForTimeout(300);
await shot("02-create-form");
// 3. Back to list, then open apply dialog on the "Ben — personal core" user kit
await page.goto(`${BASE_URL}/seed-kits`, { waitUntil: "networkidle" });
await page.waitForSelector("text=Ben — personal core");
await page.waitForTimeout(400);
// Find the Apply button next to "Ben — personal core" card
const benCard = page.locator("div", { hasText: /^Ben — personal core/ }).first();
// Just click the visible Apply button on that kit row
const applyButtons = page.getByRole("button", { name: /^Apply$/ });
const applyCount = await applyButtons.count();
// User kits are rendered after built-ins; the Ben card is the 4th apply button (0-3 built-ins, 4 = first user kit)
await applyButtons.nth(3).click();
await page.waitForSelector("text=Apply seed kit");
await page.waitForTimeout(500);
await shot("03-apply-dialog");
await browser.close();
console.log("done");

View File

@@ -8,6 +8,7 @@ import {
ChevronsUpDown,
Eye,
EyeOff,
Layers,
LayoutDashboard,
Lightbulb,
MessageSquare,
@@ -29,6 +30,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>
);
}

View File

@@ -0,0 +1,147 @@
import { z } from "zod";
const STORE_KEY = "openconcho:seed-kits";
export const seedKitSchema = z.object({
id: z.string().min(1),
name: z.string().min(1, { message: "Name is required" }),
description: z.string().default(""),
lines: z.array(z.string()).default([]),
});
export type SeedKit = z.infer<typeof seedKitSchema>;
const storeSchema = z.array(seedKitSchema);
const BUILTIN_PREFIX = "builtin:";
export const BUILTIN_KITS: SeedKit[] = [
{
id: `${BUILTIN_PREFIX}personal-core`,
name: "Personal core",
description: "Name, email, preferred form of address, and role.",
lines: ["Name: ", "Email: ", "Preferred address: ", "Role: "],
},
{
id: `${BUILTIN_PREFIX}github-social`,
name: "GitHub / social",
description: "Common developer-identity handles.",
lines: ["Github: ", "Linkedin: ", "X: ", "Twitter: "],
},
{
id: `${BUILTIN_PREFIX}work-context`,
name: "Work context",
description: "Employer, role, and reporting line.",
lines: ["Employer: ", "Role: ", "Reports to: "],
},
];
export function isBuiltinKit(kit: Pick<SeedKit, "id">): boolean {
return kit.id.startsWith(BUILTIN_PREFIX);
}
function newId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `kit_${Math.random().toString(36).slice(2)}_${Date.now()}`;
}
export function loadUserKits(): SeedKit[] {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return [];
return storeSchema.parse(JSON.parse(raw));
} catch {
return [];
}
}
export function saveUserKits(kits: SeedKit[]): void {
localStorage.setItem(STORE_KEY, JSON.stringify(kits));
}
export function allKits(): SeedKit[] {
return [...BUILTIN_KITS, ...loadUserKits()];
}
export function createKit(input: Omit<SeedKit, "id">): SeedKit {
const kits = loadUserKits();
const kit: SeedKit = { id: newId(), ...input };
saveUserKits([...kits, kit]);
return kit;
}
export function updateKit(id: string, patch: Partial<Omit<SeedKit, "id">>): void {
if (id.startsWith(BUILTIN_PREFIX)) return;
const kits = loadUserKits();
const idx = kits.findIndex((k) => k.id === id);
if (idx < 0) return;
kits[idx] = { ...kits[idx], ...patch };
saveUserKits(kits);
}
export function deleteKit(id: string): void {
if (id.startsWith(BUILTIN_PREFIX)) return;
saveUserKits(loadUserKits().filter((k) => k.id !== id));
}
// ─── Merge logic ──────────────────────────────────────────────────────────────
/**
* Lines look like "Name: Ben" — the prefix is everything before the first `:`.
* Used for additive-replace merge so an incoming line updates the matching
* existing line instead of duplicating it.
*/
export function linePrefix(line: string): string | null {
const i = line.indexOf(":");
if (i <= 0) return null;
const prefix = line.slice(0, i).trim();
if (!prefix) return null;
return prefix.toLowerCase();
}
/**
* Merge `incoming` into `existing`. Lines with a prefix dedupe by prefix
* (incoming wins, in-place). Lines without a prefix dedupe by exact match.
* The merge is additive — nothing in `existing` is dropped unless replaced.
*/
export function mergeCardLines(existing: string[], incoming: string[]): string[] {
const incomingByPrefix = new Map<string, string>();
const incomingExact = new Set<string>();
for (const line of incoming) {
const p = linePrefix(line);
if (p) incomingByPrefix.set(p, line);
else incomingExact.add(line);
}
const out: string[] = [];
const placedPrefixes = new Set<string>();
const placedExact = new Set<string>();
for (const line of existing) {
const p = linePrefix(line);
if (p && incomingByPrefix.has(p)) {
out.push(incomingByPrefix.get(p) as string);
placedPrefixes.add(p);
} else {
out.push(line);
if (!p) placedExact.add(line);
}
}
for (const line of incoming) {
const p = linePrefix(line);
if (p) {
if (placedPrefixes.has(p)) continue;
out.push(line);
placedPrefixes.add(p);
} else {
if (placedExact.has(line)) continue;
out.push(line);
placedExact.add(line);
}
}
return out;
}

View File

@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as WorkspacesRouteImport } from './routes/workspaces'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SeedKitsRouteImport } from './routes/seed-kits'
import { Route as ExploreRouteImport } from './routes/explore'
import { Route as IndexRouteImport } from './routes/index'
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
@@ -32,6 +33,11 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const SeedKitsRoute = SeedKitsRouteImport.update({
id: '/seed-kits',
path: '/seed-kits',
getParentRoute: () => rootRouteImport,
} as any)
const ExploreRoute = ExploreRouteImport.update({
id: '/explore',
path: '/explore',
@@ -93,6 +99,7 @@ const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/explore': typeof ExploreRoute
'/seed-kits': typeof SeedKitsRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
@@ -107,6 +114,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/explore': typeof ExploreRoute
'/seed-kits': typeof SeedKitsRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
@@ -122,6 +130,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/explore': typeof ExploreRoute
'/seed-kits': typeof SeedKitsRoute
'/settings': typeof SettingsRoute
'/workspaces': typeof WorkspacesRoute
'/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute
@@ -138,6 +147,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/explore'
| '/seed-kits'
| '/settings'
| '/workspaces'
| '/workspaces/$workspaceId'
@@ -152,6 +162,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/explore'
| '/seed-kits'
| '/settings'
| '/workspaces'
| '/workspaces/$workspaceId'
@@ -166,6 +177,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/explore'
| '/seed-kits'
| '/settings'
| '/workspaces'
| '/workspaces_/$workspaceId'
@@ -181,6 +193,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ExploreRoute: typeof ExploreRoute
SeedKitsRoute: typeof SeedKitsRoute
SettingsRoute: typeof SettingsRoute
WorkspacesRoute: typeof WorkspacesRoute
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute
@@ -209,6 +222,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/seed-kits': {
id: '/seed-kits'
path: '/seed-kits'
fullPath: '/seed-kits'
preLoaderRoute: typeof SeedKitsRouteImport
parentRoute: typeof rootRouteImport
}
'/explore': {
id: '/explore'
path: '/explore'
@@ -285,6 +305,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ExploreRoute: ExploreRoute,
SeedKitsRoute: SeedKitsRoute,
SettingsRoute: SettingsRoute,
WorkspacesRoute: WorkspacesRoute,
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SeedKitsView } from "@/components/seed-kits/SeedKitsView";
export const Route = createFileRoute("/seed-kits")({
component: SeedKitsView,
});

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_KITS,
createKit,
deleteKit,
isBuiltinKit,
linePrefix,
loadUserKits,
mergeCardLines,
type SeedKit,
saveUserKits,
updateKit,
} from "@/lib/seedKits";
describe("seedKits — storage round-trip", () => {
it("returns [] when nothing is stored", () => {
expect(loadUserKits()).toEqual([]);
});
it("round-trips a kit through localStorage", () => {
const kits: SeedKit[] = [
{ id: "k1", name: "First", description: "desc", lines: ["a", "b", "c"] },
{ id: "k2", name: "Second", description: "", lines: [] },
];
saveUserKits(kits);
expect(loadUserKits()).toEqual(kits);
});
it("recovers from corrupt storage by returning []", () => {
localStorage.setItem("openconcho:seed-kits", "{not json");
expect(loadUserKits()).toEqual([]);
});
it("ignores entries that fail schema validation", () => {
localStorage.setItem(
"openconcho:seed-kits",
JSON.stringify([{ id: "", name: "", lines: "not-an-array" }]),
);
expect(loadUserKits()).toEqual([]);
});
});
describe("seedKits — CRUD", () => {
it("createKit assigns an id and persists", () => {
const kit = createKit({ name: "Test", description: "", lines: ["x"] });
expect(kit.id).toBeTruthy();
expect(loadUserKits()).toHaveLength(1);
expect(loadUserKits()[0].name).toBe("Test");
});
it("updateKit patches a stored kit by id", () => {
const kit = createKit({ name: "Original", description: "", lines: [] });
updateKit(kit.id, { name: "Renamed", lines: ["new line"] });
const reloaded = loadUserKits()[0];
expect(reloaded.name).toBe("Renamed");
expect(reloaded.lines).toEqual(["new line"]);
});
it("deleteKit removes by id", () => {
const a = createKit({ name: "A", description: "", lines: [] });
createKit({ name: "B", description: "", lines: [] });
deleteKit(a.id);
const remaining = loadUserKits();
expect(remaining).toHaveLength(1);
expect(remaining[0].name).toBe("B");
});
it("refuses to mutate built-in kits", () => {
const builtin = BUILTIN_KITS[0];
updateKit(builtin.id, { name: "Hacked" });
deleteKit(builtin.id);
expect(loadUserKits()).toEqual([]);
expect(BUILTIN_KITS[0].name).not.toBe("Hacked");
});
});
describe("seedKits — isBuiltinKit", () => {
it("flags ids starting with 'builtin:'", () => {
expect(isBuiltinKit({ id: "builtin:foo" })).toBe(true);
expect(isBuiltinKit({ id: "kit_abc" })).toBe(false);
});
});
describe("seedKits — linePrefix", () => {
it("returns the lowercased text before the first colon", () => {
expect(linePrefix("Name: Ben")).toBe("name");
expect(linePrefix("EMAIL: foo@bar.com")).toBe("email");
expect(linePrefix("Preferred address: Chief")).toBe("preferred address");
});
it("returns null for lines without a colon or with empty prefix", () => {
expect(linePrefix("loves coffee")).toBeNull();
expect(linePrefix(": no key")).toBeNull();
expect(linePrefix("")).toBeNull();
});
});
describe("seedKits — mergeCardLines", () => {
it("replaces existing lines with matching prefix instead of duplicating", () => {
const existing = ["Name: Ben", "Email: ben@old.com"];
const incoming = ["Name: Ben Sheridan-Edwards"];
const merged = mergeCardLines(existing, incoming);
// Matching-prefix wins → "Name: Ben" replaced; no duplicate Name line.
expect(merged).toEqual(["Name: Ben Sheridan-Edwards", "Email: ben@old.com"]);
});
it("appends incoming lines whose prefix is not in existing", () => {
const existing = ["Name: Ben"];
const incoming = ["Email: ben@codewalnut.com", "Role: Founder"];
expect(mergeCardLines(existing, incoming)).toEqual([
"Name: Ben",
"Email: ben@codewalnut.com",
"Role: Founder",
]);
});
it("treats prefix comparison case-insensitively", () => {
const existing = ["NAME: Ben"];
const incoming = ["Name: Ben Sheridan-Edwards"];
expect(mergeCardLines(existing, incoming)).toEqual(["Name: Ben Sheridan-Edwards"]);
});
it("preserves order of existing lines when replacing", () => {
const existing = ["A: 1", "B: 2", "C: 3"];
const incoming = ["B: 22"];
expect(mergeCardLines(existing, incoming)).toEqual(["A: 1", "B: 22", "C: 3"]);
});
it("dedupes no-prefix lines by exact match", () => {
const existing = ["loves coffee", "Name: Ben"];
const incoming = ["loves coffee", "loves tea"];
// "loves coffee" already present → kept once; "loves tea" appended.
expect(mergeCardLines(existing, incoming)).toEqual(["loves coffee", "Name: Ben", "loves tea"]);
});
it("never drops existing lines that have no incoming match", () => {
const existing = ["Name: Ben", "Quirk: drinks tea, not coffee"];
const incoming = ["Email: ben@x.com"];
expect(mergeCardLines(existing, incoming)).toEqual([
"Name: Ben",
"Quirk: drinks tea, not coffee",
"Email: ben@x.com",
]);
});
it("handles empty existing and empty incoming", () => {
expect(mergeCardLines([], [])).toEqual([]);
expect(mergeCardLines([], ["A: 1"])).toEqual(["A: 1"]);
expect(mergeCardLines(["A: 1"], [])).toEqual(["A: 1"]);
});
it("dedupes within incoming when an existing prefix is matched", () => {
// Two incoming lines share a prefix → the LAST one wins (Map semantics) and
// the earlier duplicate is collapsed.
const existing = ["Name: Ben"];
const incoming = ["Name: First", "Name: Second"];
expect(mergeCardLines(existing, incoming)).toEqual(["Name: Second"]);
});
});