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:
BIN
docs/seed-kits/01-list.png
Normal file
BIN
docs/seed-kits/01-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
BIN
docs/seed-kits/02-create-form.png
Normal file
BIN
docs/seed-kits/02-create-form.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
BIN
docs/seed-kits/03-apply-dialog.png
Normal file
BIN
docs/seed-kits/03-apply-dialog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
94
packages/web/scripts/screenshot-seed-kits.mjs
Normal file
94
packages/web/scripts/screenshot-seed-kits.mjs
Normal 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");
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
311
packages/web/src/components/seed-kits/ApplyKitDialog.tsx
Normal file
311
packages/web/src/components/seed-kits/ApplyKitDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
packages/web/src/components/seed-kits/SeedKitForm.tsx
Normal file
121
packages/web/src/components/seed-kits/SeedKitForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
365
packages/web/src/components/seed-kits/SeedKitsView.tsx
Normal file
365
packages/web/src/components/seed-kits/SeedKitsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
packages/web/src/lib/seedKits.ts
Normal file
147
packages/web/src/lib/seedKits.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
6
packages/web/src/routes/seed-kits.tsx
Normal file
6
packages/web/src/routes/seed-kits.tsx
Normal 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,
|
||||
});
|
||||
159
packages/web/src/test/seed-kits.test.ts
Normal file
159
packages/web/src/test/seed-kits.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user