diff --git a/docs/seed-kits/dark/01-list.png b/docs/seed-kits/dark/01-list.png new file mode 100644 index 0000000..e68e080 Binary files /dev/null and b/docs/seed-kits/dark/01-list.png differ diff --git a/docs/seed-kits/dark/02-create-form.png b/docs/seed-kits/dark/02-create-form.png new file mode 100644 index 0000000..a7a154a Binary files /dev/null and b/docs/seed-kits/dark/02-create-form.png differ diff --git a/docs/seed-kits/dark/03-apply-dialog.png b/docs/seed-kits/dark/03-apply-dialog.png new file mode 100644 index 0000000..55fb13f Binary files /dev/null and b/docs/seed-kits/dark/03-apply-dialog.png differ diff --git a/docs/seed-kits/light/01-list.png b/docs/seed-kits/light/01-list.png new file mode 100644 index 0000000..e5bb67b Binary files /dev/null and b/docs/seed-kits/light/01-list.png differ diff --git a/docs/seed-kits/light/02-create-form.png b/docs/seed-kits/light/02-create-form.png new file mode 100644 index 0000000..12e6c26 Binary files /dev/null and b/docs/seed-kits/light/02-create-form.png differ diff --git a/docs/seed-kits/light/03-apply-dialog.png b/docs/seed-kits/light/03-apply-dialog.png new file mode 100644 index 0000000..0cef2fd Binary files /dev/null and b/docs/seed-kits/light/03-apply-dialog.png differ diff --git a/packages/web/scripts/screenshot-seed-kits.mjs b/packages/web/scripts/screenshot-seed-kits.mjs new file mode 100644 index 0000000..cfbe791 --- /dev/null +++ b/packages/web/scripts/screenshot-seed-kits.mjs @@ -0,0 +1,104 @@ +// One-off screenshot capture for PR documentation. +// Usage: BASE_URL=http://localhost:5177 node scripts/screenshot-seed-kits.mjs +// +// Produces both light- and dark-mode variants for each panel into +// docs/seed-kits/{light,dark}/. + +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_ROOT = resolve(process.cwd(), "docs/seed-kits"); + +const SEED_INSTANCES = { + instances: [ + { id: "neo", name: "Neo (personal)", baseUrl: "http://localhost:8001", token: "" }, + { id: "jeeves", name: "Jeeves (CodeWalnut)", baseUrl: "http://localhost:8002", token: "" }, + ], + activeId: "neo", +}; + +const SEED_KITS = [ + { + 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)"], + }, +]; + +async function captureTheme(browser, theme) { + const outDir = resolve(OUT_ROOT, theme); + await mkdir(outDir, { recursive: true }); + + const ctx = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + colorScheme: theme === "dark" ? "dark" : "light", + }); + + await ctx.addInitScript( + ([instances, kits, themeValue]) => { + window.localStorage.setItem("openconcho:instances", instances); + window.localStorage.setItem("openconcho:seed-kits", kits); + window.localStorage.setItem("openconcho:theme", themeValue); + }, + [JSON.stringify(SEED_INSTANCES), JSON.stringify(SEED_KITS), theme], + ); + + const page = await ctx.newPage(); + + async function shot(name) { + const file = resolve(outDir, `${name}.png`); + await page.screenshot({ path: file, fullPage: false }); + 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 first user kit + await page.goto(`${BASE_URL}/seed-kits`, { waitUntil: "networkidle" }); + await page.waitForSelector("text=Ben — personal core"); + await page.waitForTimeout(400); + const applyButtons = page.getByRole("button", { name: /^Apply$/ }); + // User kits render after the 3 built-ins, so index 3 = 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 ctx.close(); +} + +const browser = await chromium.launch(); +for (const theme of ["light", "dark"]) { + await captureTheme(browser, theme); +} +await browser.close(); +console.log("done"); diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 4615861..ca523dd 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -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 }, ]; diff --git a/packages/web/src/components/seed-kits/ApplyKitDialog.tsx b/packages/web/src/components/seed-kits/ApplyKitDialog.tsx new file mode 100644 index 0000000..245b488 --- /dev/null +++ b/packages/web/src/components/seed-kits/ApplyKitDialog.tsx @@ -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(activeId); + const [workspaceId, setWorkspaceId] = useState(null); + const [peerId, setPeerId] = useState(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 ( + !v && onClose()}> + + + + + Apply seed kit + + + {kit ? ( + <> + Apply {kit.name} to a peer's card. Existing + lines with a matching prefix are replaced; everything else is appended. + + ) : ( + "Pick a kit to apply." + )} + + + +
+ + + + + + + + + + + +
+ + {kit && peerId && ( +
+ +
+ )} + + {submitState.kind === "error" && ( +

+ {submitState.message} +

+ )} + +
+ + +
+
+
+ ); +} + +function PickerRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( + // biome-ignore lint/a11y/noLabelWithoutControl: select element is provided via `children` + + ); +} + +interface MergePreviewProps { + existing: string[]; + merged: string[]; + loading: boolean; +} + +function MergePreview({ existing, merged, loading }: MergePreviewProps) { + const existingSet = useMemo(() => new Set(existing), [existing]); + + if (loading) { + return Loading existing card…; + } + + if (merged.length === 0) { + return Nothing to apply.; + } + + return ( +
+ Preview after apply +
+ {merged.map((line, i) => { + const isNew = !existingSet.has(line); + return ( +
+ {isNew ? "+ " : " "} + {line || (empty)} +
+ ); + })} +
+ + + + new or replaced • {merged.length} total line{merged.length === 1 ? "" : "s"} + +
+ ); +} diff --git a/packages/web/src/components/seed-kits/SeedKitForm.tsx b/packages/web/src/components/seed-kits/SeedKitForm.tsx new file mode 100644 index 0000000..31b771c --- /dev/null +++ b/packages/web/src/components/seed-kits/SeedKitForm.tsx @@ -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; + 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(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 ( +
+
+ + setName(e.target.value)} + placeholder="e.g. Personal core" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Short summary of what this kit seeds" + /> +
+ +
+ +