diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 543edb7..89bd8dc 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -80,6 +80,14 @@ import { UI_FONTS, type Appearance, } from "./appearance.js"; +import { + AnnotationCellProvider, + AnnotationGlobalProvider, + useAnnotations, +} from "./annotation-context.js"; +import { annotationId } from "./annotations-store.js"; +import { AnnotationPopup } from "./components/AnnotationPopup.js"; +import { AnnotationToolbar } from "./components/AnnotationToolbar.js"; // react-grid-layout, width-measured + responsive (stacks to 1 column on narrow // viewports). The canvas engine: drag to place, resize from edges, vertical @@ -184,6 +192,10 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { clearOverrides(layoutKey); }, [layoutKey]); + // Annotate mode: flag graph entities, attach notes, copy a payload for an + // agent. Web-local, persisted per-notebook; never writes to the graph. + const ann = useAnnotations(config.notebook, config.label); + // Tabs partition the one flat cell list into named pages (host-shell view // tier). Derived from the *declared* cells so the bar is stable across runtime // status. Empty ⇒ no tab bar (single canvas). State is shared across tabs, so @@ -316,6 +328,7 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { onStateChange={handleStateChange} handlers={handlers} > + @@ -507,6 +520,25 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { setOpen={setCmdOpen} sections={commandSections} /> + {ann.pending && ( + + )} + + ); } @@ -950,7 +982,13 @@ function CellBody({ cell }: { cell: CellExecution }): React.ReactElement { )} {cell.spec !== null && ( - + + + )} {cell.error === null && cell.spec === null && cell.pending && (
diff --git a/packages/web/src/annotation-context.tsx b/packages/web/src/annotation-context.tsx new file mode 100644 index 0000000..697861e --- /dev/null +++ b/packages/web/src/annotation-context.tsx @@ -0,0 +1,236 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; +import type { Notebook } from "@modernrelay/notebook-core"; +import { + annotationId, + annotationsKey, + clearAnnotations, + loadAnnotations, + saveAnnotations, + serializeAnnotations, + type Annotation, + type AnnotationIntent, + type AnnotationSession, +} from "./annotations-store.js"; + +/** What a lens passes when an entity is clicked for annotation. */ +export interface AnnotationDraft { + key: string; + headline: string; + data: Record; +} + +/** The in-flight popup target — an entity click awaiting a note. */ +export interface PendingAnnotation { + cellId: string; + lens: string; + queryRef?: string; + draft: AnnotationDraft; + /** Anchor rect (viewport coords) of the clicked element, for popup placement. */ + rect: { top: number; left: number; bottom: number; right: number; width: number }; + existing?: Annotation; +} + +interface GlobalValue { + active: boolean; + open( + target: { + cellId: string; + lens: string; + queryRef?: string; + draft: AnnotationDraft; + }, + rect: DOMRect, + ): void; + /** 1-based annotation order for this entity, or null when not annotated. */ + numberOf(cellId: string, key: string): number | null; +} + +const GlobalContext = createContext(null); + +interface CellValue { + cellId: string; + lens: string; + queryRef?: string; +} +const CellContext = createContext(null); + +export function AnnotationGlobalProvider({ + value, + children, +}: { + value: GlobalValue; + children: React.ReactNode; +}): React.ReactElement { + return ( + {children} + ); +} + +/** Wraps each cell's `` so its leaf lens learns its cell id/lens/query. */ +export function AnnotationCellProvider({ + cellId, + lens, + queryRef, + children, +}: { + cellId: string; + lens: string; + queryRef?: string; + children: React.ReactNode; +}): React.ReactElement { + const value = useMemo( + () => ({ cellId, lens, queryRef }), + [cellId, lens, queryRef], + ); + return {children}; +} + +/** Leaf-lens hook: annotate-on-click + per-entity marker number for this cell. */ +export function useAnnotation(): { + active: boolean; + annotate: (draft: AnnotationDraft, e: React.MouseEvent) => void; + /** 1-based marker number for this entity, or null when not annotated. */ + numberOf: (key: string) => number | null; +} { + const g = useContext(GlobalContext); + const c = useContext(CellContext); + const active = g?.active ?? false; + const annotate = useCallback( + (draft: AnnotationDraft, e: React.MouseEvent) => { + if (!g || !c) return; + e.stopPropagation(); + g.open( + { cellId: c.cellId, lens: c.lens, queryRef: c.queryRef, draft }, + (e.currentTarget as HTMLElement).getBoundingClientRect(), + ); + }, + [g, c], + ); + const numberOf = useCallback( + (key: string) => (g && c ? g.numberOf(c.cellId, key) : null), + [g, c], + ); + return { active, annotate, numberOf }; +} + +/** Host hook: owns the persisted session, annotate mode, and the popup target. */ +export function useAnnotations(notebook: Notebook, label: string) { + const storageKey = useMemo(() => annotationsKey(notebook), [notebook]); + const [session, setSession] = useState(() => + loadAnnotations(storageKey), + ); + const [active, setActive] = useState(false); + const [pending, setPending] = useState(null); + + const persist = useCallback( + (next: AnnotationSession) => { + setSession(next); + saveAnnotations(storageKey, next); + }, + [storageKey], + ); + + const open = useCallback( + (target, rect) => { + const id = annotationId(target.cellId, target.draft.key); + setPending({ + ...target, + rect: { + top: rect.top, + left: rect.left, + bottom: rect.bottom, + right: rect.right, + width: rect.width, + }, + existing: session.items.find((a) => a.id === id), + }); + }, + [session.items], + ); + + const numberOf = useCallback( + (cellId: string, key: string) => { + const i = session.items.findIndex( + (a) => a.id === annotationId(cellId, key), + ); + return i === -1 ? null : i + 1; + }, + [session.items], + ); + + const save = useCallback( + (note: string, intent?: AnnotationIntent) => { + if (!pending) return; + const a: Annotation = { + id: annotationId(pending.cellId, pending.draft.key), + cellId: pending.cellId, + lens: pending.lens, + ...(pending.queryRef ? { queryRef: pending.queryRef } : {}), + key: pending.draft.key, + headline: pending.draft.headline, + data: pending.draft.data, + note, + ...(intent ? { intent } : {}), + }; + persist({ + ...session, + items: [...session.items.filter((x) => x.id !== a.id), a], + }); + setPending(null); + }, + [pending, session, persist], + ); + + const remove = useCallback( + (id: string) => { + persist({ ...session, items: session.items.filter((a) => a.id !== id) }); + setPending(null); + }, + [session, persist], + ); + + const setInstruction = useCallback( + (instruction: string) => persist({ ...session, instruction }), + [session, persist], + ); + + const clear = useCallback(() => { + persist({ items: [], instruction: "" }); + clearAnnotations(storageKey); + }, [persist, storageKey]); + + const copy = useCallback(async (): Promise => { + const md = serializeAnnotations(notebook, label, session); + try { + await navigator.clipboard?.writeText(md); + return true; + } catch { + return false; + } + }, [notebook, label, session]); + + const globalValue = useMemo( + () => ({ active, open, numberOf }), + [active, open, numberOf], + ); + + return { + active, + setActive, + session, + pending, + closePopup: useCallback(() => setPending(null), []), + save, + remove, + setInstruction, + clear, + copy, + globalValue, + }; +} diff --git a/packages/web/src/annotations-store.test.ts b/packages/web/src/annotations-store.test.ts new file mode 100644 index 0000000..001ee71 --- /dev/null +++ b/packages/web/src/annotations-store.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { Notebook } from "@modernrelay/notebook-core"; + +// The web vitest env is node (no window); stub a minimal in-memory localStorage, +// matching the vi.stubGlobal pattern used by App.test.ts. +function memoryStorage(): Storage { + const m = new Map(); + return { + getItem: (k) => m.get(k) ?? null, + setItem: (k, v) => void m.set(k, v), + removeItem: (k) => void m.delete(k), + clear: () => m.clear(), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + } as Storage; +} +beforeEach(() => vi.stubGlobal("window", { localStorage: memoryStorage() })); +afterEach(() => vi.unstubAllGlobals()); +import { + annotationId, + annotationsKey, + clearAnnotations, + loadAnnotations, + saveAnnotations, + serializeAnnotations, + type AnnotationSession, +} from "./annotations-store.js"; + +const NB: Notebook = { + version: 1, + title: "Company context", + cells: [ + { id: "decisions", lens: "Table", query: { ref: "decisions_by_urgency" }, props: {} }, + { id: "clauses", lens: "ActionList", query: { ref: "policy_clauses" }, props: {} }, + ], +}; + +const SESSION: AnnotationSession = { + instruction: "Reconcile the flagged clauses.", + items: [ + { + id: annotationId("decisions", "adopt-soc2"), + cellId: "decisions", + lens: "Table", + queryRef: "decisions_by_urgency", + key: "adopt-soc2", + headline: "Adopt SOC2 Type II controls", + data: { slug: "adopt-soc2", status: "accepted", urgency: "high" }, + note: "revisit the timeline", + intent: "question", + }, + { + id: annotationId("clauses", "pdr-c3"), + cellId: "clauses", + lens: "ActionList", + queryRef: "policy_clauses", + key: "pdr-c3", + headline: "EU territoriality", + data: { id: "pdr-c3", title: "EU territoriality" }, + note: "conflicts with zero-retention", + }, + ], +}; + +describe("serializeAnnotations", () => { + const md = serializeAnnotations(NB, "server: x · graph: company", SESSION); + + it("includes a title, label, and the overall instruction", () => { + expect(md).toContain("# Notebook annotations — Company context"); + expect(md).toContain("server: x · graph: company"); + expect(md).toContain("Reconcile the flagged clauses."); + }); + + it("groups by cell with the source query and renders each item", () => { + expect(md).toContain("## decisions (Table · query: decisions_by_urgency)"); + expect(md).toContain("## clauses (ActionList · query: policy_clauses)"); + expect(md).toContain("[question] `adopt-soc2` — Adopt SOC2 Type II controls"); + expect(md).toContain("status: accepted · urgency: high"); + expect(md).toContain("note: revisit the timeline"); + // no intent tag when absent + expect(md).toContain("`pdr-c3` — EU territoriality"); + expect(md).not.toContain("[fix]"); + }); +}); + +describe("annotation persistence", () => { + const key = annotationsKey(NB); + + it("annotationsKey is stable for the same notebook", () => { + expect(annotationsKey(NB)).toBe(key); + expect(key).toMatch(/^dashbook:annotations:v1:/); + }); + + it("save → load round-trips; clear empties", () => { + saveAnnotations(key, SESSION); + const loaded = loadAnnotations(key); + expect(loaded.items).toHaveLength(2); + expect(loaded.instruction).toBe("Reconcile the flagged clauses."); + clearAnnotations(key); + expect(loadAnnotations(key).items).toHaveLength(0); + }); + + it("returns an empty session for absent / unparseable storage", () => { + expect(loadAnnotations("nope").items).toEqual([]); + window.localStorage.setItem(key, "{not json"); + expect(loadAnnotations(key)).toEqual({ items: [], instruction: "" }); + }); +}); diff --git a/packages/web/src/annotations-store.ts b/packages/web/src/annotations-store.ts new file mode 100644 index 0000000..dcd17a5 --- /dev/null +++ b/packages/web/src/annotations-store.ts @@ -0,0 +1,151 @@ +import type { Notebook } from "@modernrelay/notebook-core"; + +/** + * Browser-local annotation session — agentation's "click → note → copy" flow, + * graph-native. The user flags graph entities (a Table row, a Subgraph node, …) + * across a notebook and attaches a free-text note + optional intent; the + * collection is copied to the clipboard as agent-ready markdown. Annotations are + * ephemeral *feedback*, never written to the graph — so this is host-shell-only, + * persisted per-notebook in `localStorage` (mirrors `layout-overrides.ts`). The + * pure functions here stay testable apart from React and `localStorage`. + */ +export type AnnotationIntent = "fix" | "change" | "question" | "approve"; + +export interface Annotation { + /** `${cellId}::${key}` — stable, so re-annotating an entity upserts. */ + id: string; + cellId: string; + lens: string; + queryRef?: string; + /** The entity's identity value (id/select column, or `#` fallback). */ + key: string; + headline: string; + /** The entity's displayed columns — free context for the agent. */ + data: Record; + note: string; + intent?: AnnotationIntent; +} + +export interface AnnotationSession { + items: Annotation[]; + /** A free-text instruction prepended to the export (what the agent should do). */ + instruction: string; +} + +export const EMPTY_SESSION: AnnotationSession = { items: [], instruction: "" }; +const KEY_PREFIX = "dashbook:annotations:v1:"; + +export function annotationId(cellId: string, key: string): string { + return `${cellId}::${key}`; +} + +/** djb2 → base36: a compact, stable fingerprint of the cell-id set. */ +function hashIds(ids: string[]): string { + const joined = [...ids].sort().join(" "); + let h = 5381; + for (let i = 0; i < joined.length; i++) { + h = ((h << 5) + h + joined.charCodeAt(i)) >>> 0; + } + return h.toString(36); +} + +/** Stable `localStorage` key for a notebook's annotation session. */ +export function annotationsKey(notebook: Notebook): string { + return `${KEY_PREFIX}${notebook.title}:${hashIds(notebook.cells.map((c) => c.id))}`; +} + +export function loadAnnotations(key: string): AnnotationSession { + try { + const raw = window.localStorage.getItem(key); + if (!raw) return EMPTY_SESSION; + const parsed = JSON.parse(raw) as Partial; + return { + items: Array.isArray(parsed.items) ? parsed.items.filter(isAnnotation) : [], + instruction: + typeof parsed.instruction === "string" ? parsed.instruction : "", + }; + } catch { + return EMPTY_SESSION; // unparseable / unavailable storage + } +} + +export function saveAnnotations(key: string, s: AnnotationSession): void { + try { + window.localStorage.setItem(key, JSON.stringify(s)); + } catch { + /* private mode / quota — best-effort */ + } +} + +export function clearAnnotations(key: string): void { + try { + window.localStorage.removeItem(key); + } catch { + /* ignore */ + } +} + +function isAnnotation(v: unknown): v is Annotation { + if (typeof v !== "object" || v === null) return false; + const o = v as Record; + return ( + typeof o.id === "string" && + typeof o.cellId === "string" && + typeof o.key === "string" && + typeof o.note === "string" + ); +} + +/** + * Serialize a session to agent-ready markdown, grouped by cell (first-seen + * order). Each item carries type+key, the source query, the displayed columns, + * the intent tag, and the note — enough for an agent to act by slug. + */ +export function serializeAnnotations( + notebook: Notebook, + label: string, + session: AnnotationSession, +): string { + const lines: string[] = [ + `# Notebook annotations — ${notebook.title}`, + `> ${label}`, + "", + ]; + const instruction = session.instruction.trim(); + if (instruction) { + lines.push(instruction, ""); + } + const byCell = new Map(); + for (const a of session.items) { + const arr = byCell.get(a.cellId) ?? []; + arr.push(a); + byCell.set(a.cellId, arr); + } + for (const [cellId, items] of byCell) { + const first = items[0]!; + const q = first.queryRef ? ` · query: ${first.queryRef}` : ""; + lines.push(`## ${cellId} (${first.lens}${q})`); + for (const a of items) { + const tag = a.intent ? `[${a.intent}] ` : ""; + lines.push(`- ${tag}\`${a.key}\` — ${a.headline || a.key}`); + const ctx = contextLine(a.data); + if (ctx) lines.push(` - ${ctx}`); + if (a.note.trim()) lines.push(` - note: ${a.note.trim()}`); + } + lines.push(""); + } + return `${lines.join("\n").trimEnd()}\n`; +} + +/** A compact "col: val · col: val" context line; skips empties and long prose. */ +function contextLine(data: Record): string { + const parts: string[] = []; + for (const [k, v] of Object.entries(data)) { + if (v === null || v === undefined || v === "") continue; + const s = typeof v === "object" ? JSON.stringify(v) : String(v); + if (s.length > 80) continue; + parts.push(`${k}: ${s}`); + if (parts.length >= 6) break; + } + return parts.join(" · "); +} diff --git a/packages/web/src/components/ActionList.tsx b/packages/web/src/components/ActionList.tsx index 210d4f1..bbbd469 100644 --- a/packages/web/src/components/ActionList.tsx +++ b/packages/web/src/components/ActionList.tsx @@ -4,6 +4,8 @@ import type { ActionListRuntimeProps } from "@modernrelay/notebook-core"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { useAnnotation } from "../annotation-context.js"; +import { AnnotationMarker } from "./AnnotationMarker.js"; interface ComponentCtx

{ props: P; @@ -51,6 +53,7 @@ export function ActionList({ props: p, }: ComponentCtx): React.ReactElement { const actions = useActions(); + const annot = useAnnotation(); const statusMap = useStateValue>(p.status_state ?? "/__never__") ?? {}; @@ -62,6 +65,8 @@ export function ActionList({

    {p.rows.map((row, idx) => { const id = String(row[p.id_column] ?? ""); + const akey = id || `#${idx}`; + const n = annot.numberOf(akey); const title = String(row[p.title_column] ?? ""); const body = p.body_column ? String(row[p.body_column] ?? "") : ""; const meta = @@ -98,8 +103,20 @@ export function ActionList({ saving ? "border-info/40 bg-info/4" : "border-border bg-card", )} > -
    +
    + annot.annotate( + { key: akey, headline: title, data: row }, + e, + ), + } + : {})} + >
    + {n !== null && }

    {title}

    {status && ( @@ -138,7 +155,7 @@ export function ActionList({ ? "outline" : (BUTTON_VARIANT[act.variant ?? "default"] ?? "outline") } - disabled={saving} + disabled={saving || annot.active} aria-pressed={isCurrent} onClick={() => fireAction(actions, act, row, id, p.runtime?.cell_id)} > diff --git a/packages/web/src/components/AnnotationMarker.tsx b/packages/web/src/components/AnnotationMarker.tsx new file mode 100644 index 0000000..75c0f1a --- /dev/null +++ b/packages/web/src/components/AnnotationMarker.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { A } from "./annotation-style.js"; + +/** + * A numbered circular pin (agentation's annotation marker) — 22px blue circle + * with the annotation's 1-based order in white. + */ +export function AnnotationMarker({ n }: { n: number }): React.ReactElement { + return ( + + {n} + + ); +} diff --git a/packages/web/src/components/AnnotationPopup.tsx b/packages/web/src/components/AnnotationPopup.tsx new file mode 100644 index 0000000..a3cff58 --- /dev/null +++ b/packages/web/src/components/AnnotationPopup.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Trash2Icon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { AnnotationIntent } from "../annotations-store.js"; +import type { PendingAnnotation } from "../annotation-context.js"; +import { A } from "./annotation-style.js"; + +const INTENTS: AnnotationIntent[] = ["fix", "change", "question", "approve"]; + +/** + * Anchored note popup — a faithful port of agentation's annotation popup + * (#1a1a1a card, 16px radius, soft drop + 1px inner ring, single-line header, + * rows=2 textarea, accent-focus, delete pinned left, pill actions). Centered + * under the clicked entity. Parent keys this by entity id so state resets. + */ +export function AnnotationPopup({ + pending, + onSave, + onRemove, + onClose, +}: { + pending: PendingAnnotation; + onSave: (note: string, intent?: AnnotationIntent) => void; + onRemove: (id: string) => void; + onClose: () => void; +}): React.ReactElement { + const [note, setNote] = useState(pending.existing?.note ?? ""); + const [intent, setIntent] = useState( + pending.existing?.intent, + ); + const [focused, setFocused] = useState(false); + const ref = useRef(null); + useEffect(() => { + ref.current?.focus(); + }, []); + + const save = (): void => onSave(note.trim(), intent); + + // Centered under the entity (agentation anchors with translateX(-50%)). + const center = pending.rect.left + pending.rect.width / 2; + const left = Math.min(Math.max(center, 148), Math.max(148, window.innerWidth - 148)); + const top = Math.min(pending.rect.bottom + 8, Math.max(8, window.innerHeight - 280)); + + return ( + <> +
    +
    e.stopPropagation()} + > + {/* Single-line header — the entity (agentation's `.element`). */} +
    + {pending.draft.key} + {pending.draft.headline && pending.draft.headline !== pending.draft.key && ( + {pending.draft.headline} + )} +
    + +