@manta-eu/horns-client 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,8 @@ type HornStudioProps = {
4
4
  datastoreId: string;
5
5
  /** Absent for a Horn that has never been saved: its first save names it from its title. */
6
6
  slug?: string;
7
+ /** Which Studio object holds this conversation. Defaults to the slug, which an unsaved Horn has not got. */
8
+ conversation?: string;
7
9
  stored: Horn;
8
10
  /** Absent for a Horn that has never been saved: there is no revision to build on. */
9
11
  revision?: string;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
+ import { useStudioConversation } from "../lib/manta/studio-conversation";
3
4
  import { useQuery } from "@tanstack/react-query";
4
5
  import { isPresent } from "@manta-eu/lib/present";
5
6
  import { useNavigate } from "react-router";
@@ -10,6 +11,7 @@ import { hornSnapshotSchema } from "@manta-eu/horns/render";
10
11
  import { describeHornChange, minViewWidth, visualEditOps } from "@manta-eu/horns/studio";
11
12
  import { HornSnapshotView } from "@manta-eu/horns-kit";
12
13
  import { writtenHornView } from "../components/written-horn-view";
14
+ import { PlanChanges } from "../components/plan-changes";
13
15
  import { catalogCardView } from "../components/catalog-card-view";
14
16
  import { ReadTheCode, ReadTheCodeDialog, writtenReviewStates } from "../components/read-the-code";
15
17
  import { PlanRunDialog, usePlanRuns } from "../components/plan-runs";
@@ -18,25 +20,13 @@ import { nameOfView, reviewHorn } from "../lib/horn-review";
18
20
  import { StudioChat } from "../components/studio-chat";
19
21
  import { ErrorState, Loading } from "../components/states";
20
22
  import { isRecord } from "@manta-eu/horns";
21
- import { readNdjson } from "@manta-eu/horns-server/shared";
22
23
  import { problemOf } from "../lib/problem";
23
- import { studioProgressLine } from "../lib/studio-progress";
24
24
  import { appTheme } from "../lib/theme";
25
25
  const restyleStarters = [
26
26
  "Make it dark and give it a calmer accent",
27
27
  "Turn the biggest chart into a donut",
28
28
  "Tighter cards, and a KPI row across the top",
29
29
  ];
30
- function readReply(body) {
31
- if (!isRecord(body))
32
- return undefined;
33
- const horn = hornSchema.safeParse(body["horn"]);
34
- const reply = body["reply"];
35
- if (!horn.success || typeof reply !== "string")
36
- return undefined;
37
- const status = body["status"];
38
- return { horn: horn.data, reply, ...(typeof status === "string" ? { status } : {}) };
39
- }
40
30
  function failureOf(body, status) {
41
31
  return problemOf(body, `The Studio could not answer (${status}).`);
42
32
  }
@@ -62,6 +52,17 @@ export function HornStudio(props) {
62
52
  const navigate = useNavigate();
63
53
  const mobile = useIsMobile();
64
54
  const [pane, setPane] = useState("preview");
55
+ // One conversation per Horn: the worker holds its messages and its draft.
56
+ const looking = useRef({ horn: stored, asked: "" });
57
+ const studio = useStudioConversation({
58
+ datastoreId,
59
+ conversation: props.conversation ?? props.slug ?? stored.slug,
60
+ look: async (why) => {
61
+ const { horn, asked } = looking.current;
62
+ const seen = await reviewHorn({ datastoreId, horn, asked: why || asked, variables });
63
+ return describeReview(seen);
64
+ },
65
+ });
65
66
  const [versions, setVersions] = useState([{ id: 0, horn: stored }]);
66
67
  const nextId = useRef(1);
67
68
  const [viewing, setViewing] = useState();
@@ -84,6 +85,7 @@ export function HornStudio(props) {
84
85
  resizeFrom: "right",
85
86
  });
86
87
  const latest = versions[versions.length - 1] ?? { id: 0, horn: stored };
88
+ looking.current = { horn: studio.draft ?? latest.horn, asked: looking.current.asked };
87
89
  const shown = versions.find((version) => version.id === viewing) ?? latest;
88
90
  // Hashing is async, so the states are held rather than derived: a card shows what was last worked out for
89
91
  // the draft it is drawing.
@@ -140,35 +142,15 @@ export function HornStudio(props) {
140
142
  const base = latest;
141
143
  const fail = (content) => setEntries([...conversation, { kind: "failure", content }]);
142
144
  try {
143
- const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/studio`, {
144
- method: "POST",
145
- headers: { "content-type": "application/json" },
146
- body: JSON.stringify({
147
- horn: base.horn,
148
- // Failures are the app's, not the conversation's; the model is shown only what was said.
149
- messages: conversation.flatMap((entry) => entry.kind === "failure" ? [] : [{ role: entry.kind, content: entry.content }]),
150
- }),
151
- });
152
- if (!response.ok) {
153
- fail(failureOf(await response.json().catch(() => undefined), response.status));
154
- return;
155
- }
156
- const body = await readNdjson(response, (line) => {
157
- const said = line["type"] === "progress" ? studioProgressLine(line) : undefined;
158
- if (!isPresent(said))
159
- return;
160
- thinking.push(said);
161
- setProgress([...thinking]);
162
- });
163
- if (body?.["type"] === "error") {
164
- fail(problemOf(body, "The Studio failed."));
165
- return;
166
- }
167
- const reply = readReply(body);
168
- if (!isPresent(reply)) {
145
+ // The object holds the conversation and the draft: a turn sends words and reads the Horn back.
146
+ looking.current = { horn: base.horn, asked };
147
+ await studio.open(base.horn);
148
+ const turn = await studio.ask(asked);
149
+ if (!isPresent(turn.horn)) {
169
150
  fail("The Studio answered with something that is not a Horn.");
170
151
  return;
171
152
  }
153
+ const reply = { horn: turn.horn, reply: turn.reply };
172
154
  const summary = describeHornChange(base.horn, reply.horn);
173
155
  const changed = isPresent(summary.details);
174
156
  const version = { id: nextId.current, horn: reply.horn };
@@ -444,7 +426,7 @@ export function HornStudio(props) {
444
426
  // Only once the keep is in the draft: the save that follows the last one reads the draft, and
445
427
  // a draft still missing it looks exactly like code nobody read.
446
428
  void edit(change, `Read and kept the code for "${reading.title}".`).then(() => setToRead((was) => was.slice(1)));
447
- } })), _jsxs("header", { className: "flex h-14 shrink-0 items-center gap-3 px-3", children: [mobile ? (_jsx(Button, { variant: "ghost", size: "sm", iconOnly: true, icon: ArrowLeft, "aria-label": "All Horns", onClick: () => navigate(backTo) })) : (_jsx(Button, { variant: "ghost", size: "sm", icon: ArrowLeft, showLeftIcon: true, onClick: () => navigate(backTo), children: "All Horns" })), _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium text-default-foreground", children: latest.horn.title }), _jsx("span", { "data-save-status": true, className: "truncate text-xs text-muted-foreground", children: status })] }), mobile ? (_jsx("div", { className: "flex items-center gap-1 rounded-full bg-muted p-1", role: "group", "aria-label": "Show", children: ["chat", "preview"].map((each) => (_jsx(Button, { variant: pane === each ? "secondary" : "ghost", size: "xs", "aria-pressed": pane === each, onClick: () => setPane(each), children: each === "chat" ? "Chat" : "Preview" }, each))) })) : null, _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [unsaved ? (_jsx(Button, { variant: "secondary", size: "sm", disabled: saveState === "saving" || pending, onClick: discard, children: mobile ? "Discard" : "Discard changes" })) : null, _jsx(Button, { variant: "primary", size: "sm", disabled: !unsaved || saveState === "saving" || pending, onClick: () => void save(), children: saveState === "saving" ? "Saving…" : mobile ? "Save" : "Save changes" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [showChat ? (_jsxs("aside", { ref: panelRef, "aria-label": "Studio chat", className: "flex min-h-0 shrink-0 flex-col", style: mobile ? { width: "100%" } : { width: panelWidth }, children: [isPresent(saveFailure) ? (_jsx("div", { className: "px-4 pt-2", children: _jsx(ErrorState, { title: "That save did not go through", hint: saveFailure }) })) : null, _jsx(StudioChat, { entries: entries, pending: pending, progress: progress, previewing: shown.id, welcome: welcome ?? "What would you like to change?", reassurance: reassurance ?? "Say it in your own words. Nothing is saved until you choose Save changes.", starters: starters ?? restyleStarters, placeholder: placeholder ?? "Describe a change, in your own words…", onSend: (text) => void send(text), onPreview: (id) => {
429
+ } })), _jsxs("header", { className: "flex h-14 shrink-0 items-center gap-3 px-3", children: [mobile ? (_jsx(Button, { variant: "ghost", size: "sm", iconOnly: true, icon: ArrowLeft, "aria-label": "All Horns", onClick: () => navigate(backTo) })) : (_jsx(Button, { variant: "ghost", size: "sm", icon: ArrowLeft, showLeftIcon: true, onClick: () => navigate(backTo), children: "All Horns" })), _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium text-default-foreground", children: latest.horn.title }), _jsx("span", { "data-save-status": true, className: "truncate text-xs text-muted-foreground", children: status })] }), mobile ? (_jsx("div", { className: "flex items-center gap-1 rounded-full bg-muted p-1", role: "group", "aria-label": "Show", children: ["chat", "preview"].map((each) => (_jsx(Button, { variant: pane === each ? "secondary" : "ghost", size: "xs", "aria-pressed": pane === each, onClick: () => setPane(each), children: each === "chat" ? "Chat" : "Preview" }, each))) })) : null, _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [unsaved ? (_jsx(Button, { variant: "secondary", size: "sm", disabled: saveState === "saving" || pending, onClick: discard, children: mobile ? "Discard" : "Discard changes" })) : null, _jsx(Button, { variant: "primary", size: "sm", disabled: !unsaved || saveState === "saving" || pending, onClick: () => void save(), children: saveState === "saving" ? "Saving…" : mobile ? "Save" : "Save changes" })] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [showChat ? (_jsxs("aside", { ref: panelRef, "aria-label": "Studio chat", className: "flex min-h-0 shrink-0 flex-col", style: mobile ? { width: "100%" } : { width: panelWidth }, children: [isPresent(saveFailure) ? (_jsx("div", { className: "px-4 pt-2", children: _jsx(ErrorState, { title: "That save did not go through", hint: saveFailure }) })) : null, _jsx(StudioChat, { entries: entries, pending: pending, progress: isPresent(progress) ? progress : studio.steps, previewing: shown.id, welcome: welcome ?? "What would you like to change?", reassurance: reassurance ?? "Say it in your own words. Nothing is saved until you choose Save changes.", starters: starters ?? restyleStarters, placeholder: placeholder ?? "Describe a change, in your own words…", onSend: (text) => void send(text), onPreview: (id) => {
448
430
  setViewing(id === latest.id ? undefined : id);
449
431
  if (mobile)
450
432
  setPane("preview");
@@ -456,7 +438,7 @@ export function HornStudio(props) {
456
438
  const target = event.target;
457
439
  const card = target instanceof Element ? target.closest("[data-horn-view]") : undefined;
458
440
  setHovered(card?.getAttribute("data-horn-view") ?? undefined);
459
- }, onMouseLeave: () => setHovered(undefined), children: [editFailure === undefined ? null : (_jsxs("div", { role: "alert", className: "m-3 flex items-start justify-between gap-3 rounded-lg border border-muted-border bg-muted px-3 py-2 text-sm text-default-foreground", children: [_jsx("span", { className: "whitespace-pre-wrap", children: editFailure }), _jsx(Button, { variant: "ghost", size: "xs", onClick: () => setEditFailure(undefined), children: "Dismiss" })] })), planRuns.problem === undefined ? null : (_jsxs("div", { role: "alert", className: "m-3 flex items-start justify-between gap-3 rounded-lg border border-muted-border bg-muted px-3 py-2 text-sm text-default-foreground", children: [_jsx("span", { children: planRuns.problem }), _jsx(Button, { variant: "ghost", size: "xs", onClick: planRuns.dismiss, children: "Dismiss" })] })), _jsx(HornSnapshotView, { snapshot: snapshot.data, mode: "page", theme: appTheme(), planActions: planRuns.actions, writtenView: writtenHornView, catalogCard: catalogCardView, ...(shown.id === latest.id
441
+ }, onMouseLeave: () => setHovered(undefined), children: [editFailure === undefined ? null : (_jsxs("div", { role: "alert", className: "m-3 flex items-start justify-between gap-3 rounded-lg border border-muted-border bg-muted px-3 py-2 text-sm text-default-foreground", children: [_jsx("span", { className: "whitespace-pre-wrap", children: editFailure }), _jsx(Button, { variant: "ghost", size: "xs", onClick: () => setEditFailure(undefined), children: "Dismiss" })] })), planRuns.problem === undefined ? null : (_jsxs("div", { role: "alert", className: "m-3 flex items-start justify-between gap-3 rounded-lg border border-muted-border bg-muted px-3 py-2 text-sm text-default-foreground", children: [_jsx("span", { children: planRuns.problem }), _jsx(Button, { variant: "ghost", size: "xs", onClick: planRuns.dismiss, children: "Dismiss" })] })), _jsx(HornSnapshotView, { snapshot: snapshot.data, mode: "page", theme: appTheme(), planActions: planRuns.actions, writtenView: writtenHornView, planStrip: PlanChanges, catalogCard: catalogCardView, ...(shown.id === latest.id
460
442
  ? {
461
443
  viewControls: (view) => (
462
444
  // Six controls on every card is furniture; they belong to the card being reached
@@ -479,3 +461,13 @@ export function HornStudio(props) {
479
461
  }
480
462
  }, children: "Rename it" })] })] }) })] })) })] }) })) : null] })] }));
481
463
  }
464
+ /** What the reviewer saw, as the turn is told it. */
465
+ function describeReview(seen) {
466
+ const lines = [
467
+ ...seen.problems.map((problem) => `"${problem.viewId}" does not draw: ${problem.message}`),
468
+ ...seen.advice,
469
+ ];
470
+ if (isPresent(seen.unavailable))
471
+ return `Nobody could look at it: ${seen.unavailable}`;
472
+ return lines.length > 0 ? lines.join("\n") : "It draws, and nothing is wrong with how it looks.";
473
+ }
@@ -9,6 +9,7 @@ import { Button, Input } from "@manta-eu/react";
9
9
  import { hornSnapshotSchema, variablesFromSearch } from "@manta-eu/horns/render";
10
10
  import { HornSnapshotView } from "@manta-eu/horns-kit";
11
11
  import { writtenHornView } from "../components/written-horn-view";
12
+ import { PlanChanges } from "../components/plan-changes";
12
13
  import { catalogCardView } from "../components/catalog-card-view";
13
14
  import { PlanRunDialog, usePlanRuns } from "../components/plan-runs";
14
15
  import { sendHornScreenshot } from "../components/rasterize";
@@ -96,5 +97,5 @@ function Viewer({ datastoreId, slug }) {
96
97
  return _jsx(Loading, { label: "Opening the Horn\u2026", rows: 3 });
97
98
  return (_jsxs("div", { "data-horn-viewer": true, className: "flex w-full flex-col gap-4", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx(Link, { to: hornsPath(), className: "text-sm text-muted-foreground hover:text-default-foreground", children: "All Horns" }), _jsx(VariableBar, { horn: horn })] }), _jsxs("div", { className: "flex items-center gap-2", children: [planRuns.problem === undefined ? null : (_jsx("span", { role: "alert", className: "text-xs text-muted-foreground", children: planRuns.problem })), _jsx(UpdatePicture, { datastoreId: datastoreId, slug: slug, snapshot: snapshot.data }), _jsx(ShareLink, {}), _jsx(Button, { variant: "ghost", size: "sm", asChild: true, children: _jsxs(Link, { to: hornPath(slug), children: [_jsx(Pencil, { size: 14, "aria-hidden": "true" }), "Change it"] }) })] })] }), snapshot.isError ? (_jsx(ErrorState, { title: "The Horn could not be drawn", hint: snapshot.error.message })) : !isPresent(snapshot.data) ? (_jsx(Loading, { label: "Drawing the Horn\u2026", rows: 4 })) : (
98
99
  // The Horn draws its own page, title and canvas.
99
- _jsxs("div", { "data-horn-preview": true, className: "w-full overflow-hidden rounded-xl", children: [_jsx(HornSnapshotView, { snapshot: snapshot.data, mode: "page", theme: appTheme(), planActions: planRuns.actions, writtenView: writtenHornView, catalogCard: catalogCardView, explain: explain.open, onVariableChange: urlVariables.set }), _jsx(PlanRunDialog, { runs: planRuns, labels: snapshot.data.labels }), explain.dialog] }))] }));
100
+ _jsxs("div", { "data-horn-preview": true, className: "w-full overflow-hidden rounded-xl", children: [_jsx(HornSnapshotView, { snapshot: snapshot.data, mode: "page", theme: appTheme(), planActions: planRuns.actions, writtenView: writtenHornView, planStrip: PlanChanges, catalogCard: catalogCardView, explain: explain.open, onVariableChange: urlVariables.set }), _jsx(PlanRunDialog, { runs: planRuns, labels: snapshot.data.labels }), explain.dialog] }))] }));
100
101
  }
@@ -15,10 +15,11 @@ import { shapesRealm } from "../lib/shapes/catalog";
15
15
  // The way in: a Horn from nothing. It opens straight into the Studio with an empty document, the way a new
16
16
  // Lovable project opens on its composer: the first thing typed is what it should show, the model names it as
17
17
  // it builds it, and the first save makes its address from that name.
18
+ // Answerable on any datastore: these are offered before anyone knows what is in one.
18
19
  const starterPrompts = [
19
- "Which campaigns are spending the most, and where is the waste?",
20
- "Show me the accounts with the most pipeline movement this month",
21
- "What happened in search last week that I should look at?",
20
+ "What is in here? Show me the main kinds of thing and how much there is of each.",
21
+ "Rank the most important things here and show what makes the top ones stand out.",
22
+ "What in this data is worth a second look, and why?",
22
23
  ];
23
24
  export function NewHornPage() {
24
25
  const { datastoreId } = useActiveDatastore();
@@ -71,6 +72,10 @@ function NewHornAbout({ datastoreId, about }) {
71
72
  function NewHorn({ datastoreId }) {
72
73
  // Made once: the route owns the IRI and the revision on save, and this is a document that has never been
73
74
  // stored. A new render must not make a new one underneath the draft.
74
- const [horn] = useState(() => standaloneHorn({ id: "untitled", title: "Untitled Horn", views: [], realm: shapesRealm(), datastoreId }));
75
- return (_jsx(HornStudio, { datastoreId: datastoreId, stored: horn, backTo: hornsPath(), welcome: "What should this Horn show?", reassurance: "Say it in your own words, and I will build it from this datastore's data.", placeholder: "Describe what you want to see\u2026", starters: starterPrompts }));
75
+ // Its own conversation, not its slug: every unsaved Horn has the same slug and would share one object.
76
+ const [{ horn, conversation }] = useState(() => ({
77
+ horn: standaloneHorn({ id: "untitled", title: "Untitled Horn", views: [], realm: shapesRealm(), datastoreId }),
78
+ conversation: `untitled-${crypto.randomUUID()}`,
79
+ }));
80
+ return (_jsx(HornStudio, { datastoreId: datastoreId, stored: horn, conversation: conversation, backTo: hornsPath(), welcome: "What should this Horn show?", reassurance: "Say it in your own words, and I will build it from this datastore's data.", placeholder: "Describe what you want to see\u2026", starters: starterPrompts }));
76
81
  }
@@ -14,7 +14,7 @@ import { shapesRealm } from "../lib/shapes/catalog";
14
14
  // The work Manta has drafted, and the way to make a Horn out of one. A plan is a named graph, so it has no
15
15
  // page of its own here: the Horn is the page, and this is where you start one.
16
16
  const stateWords = {
17
- draft: { text: "Not run yet", variant: "info" },
17
+ draft: { text: "Not checked yet", variant: "info" },
18
18
  running: { text: "Running", variant: "info" },
19
19
  completed: { text: "Done", variant: "success" },
20
20
  failed: { text: "Did not go through", variant: "warning" },
@@ -5,6 +5,7 @@ import { hornSchema } from "@manta-eu/horns";
5
5
  import { buildHornSnapshot } from "@manta-eu/horns/render";
6
6
  import { HornSnapshotView } from "@manta-eu/horns-kit";
7
7
  import { writtenHornView } from "../components/written-horn-view";
8
+ import { PlanChanges } from "../components/plan-changes";
8
9
  import { catalogCardView } from "../components/catalog-card-view";
9
10
  import { useExplainDialog } from "../components/explain-dialog";
10
11
  import { ErrorState, Loading } from "../components/states";
@@ -51,5 +52,5 @@ export function ReferencePage() {
51
52
  });
52
53
  },
53
54
  });
54
- return (_jsxs("div", { className: "flex flex-col gap-6", children: [_jsx(PageHeader, { title: "Supply Chain 360", description: "The reference Horn from docs/standard.md, drawn against a fixture. No datastore is read." }), drawn.isPending && drawn.data === undefined ? (_jsx(Loading, { label: "Drawing the reference Horn\u2026", rows: 4 })) : drawn.isError ? (_jsx(ErrorState, { title: "The reference Horn could not be drawn", hint: drawn.error.message })) : (_jsxs("div", { "data-horn-preview": true, className: "w-full overflow-hidden rounded-xl", children: [_jsx(HornSnapshotView, { snapshot: drawn.data, mode: "page", theme: appTheme(), writtenView: writtenHornView, catalogCard: catalogCardView, explain: explain.open, onVariableChange: filters.set }), explain.dialog] }))] }));
55
+ return (_jsxs("div", { className: "flex flex-col gap-6", children: [_jsx(PageHeader, { title: "Supply Chain 360", description: "The reference Horn from docs/standard.md, drawn against a fixture. No datastore is read." }), drawn.isPending && drawn.data === undefined ? (_jsx(Loading, { label: "Drawing the reference Horn\u2026", rows: 4 })) : drawn.isError ? (_jsx(ErrorState, { title: "The reference Horn could not be drawn", hint: drawn.error.message })) : (_jsxs("div", { "data-horn-preview": true, className: "w-full overflow-hidden rounded-xl", children: [_jsx(HornSnapshotView, { snapshot: drawn.data, mode: "page", theme: appTheme(), writtenView: writtenHornView, planStrip: PlanChanges, catalogCard: catalogCardView, explain: explain.open, onVariableChange: filters.set }), explain.dialog] }))] }));
55
56
  }
@@ -0,0 +1,2 @@
1
+ import { type PlanStripArgs } from "@manta-eu/horns-kit";
2
+ export declare function PlanChanges({ plan, onView, labels, links, actions }: PlanStripArgs): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Status } from "@manta-eu/react";
3
+ import { DisabledActionTooltip } from "@manta-eu/react/disabled-action-tooltip";
4
+ import { maxShownChanges, planStateText, planSummary, planTitle, planValue, runnable, unreadableChanges, } from "@manta-eu/horns-kit";
5
+ import { isPresent } from "@manta-eu/lib/present";
6
+ import { ArrowRight } from "lucide-react";
7
+ // The kit draws plans with inline styles because it knows nothing of `@manta-eu/react`. A host does, so it
8
+ // draws its own and the Horn's buttons look like every other button in Manta.
9
+ const statusVariant = { quiet: "neutral", busy: "neutral", good: "success", bad: "error" };
10
+ export function PlanChanges({ plan, onView, labels, links, actions }) {
11
+ const live = actions?.status?.[plan.iri];
12
+ const status = live ?? plan;
13
+ const state = planStateText(status, live === undefined);
14
+ const pending = actions?.pending?.has(plan.iri) === true;
15
+ const busy = pending || status.state === "running";
16
+ const ready = runnable(live);
17
+ const near = new Set(onView);
18
+ const sorted = [...plan.changes].sort((a, b) => Number(near.has(b.entity)) - Number(near.has(a.entity)));
19
+ const shown = sorted.slice(0, maxShownChanges);
20
+ const hidden = plan.changes.length - shown.length;
21
+ const unreadable = unreadableChanges(plan);
22
+ const failed = status.state === "failed" || status.state === "timed-out";
23
+ return (_jsxs(Card, { "data-horn-plan": plan.iri, "data-plan-state": status.state, className: "mt-3", children: [_jsxs(CardHeader, { className: "flex flex-row flex-wrap items-start justify-between gap-2", children: [_jsxs("div", { className: "min-w-0", children: [_jsx(CardTitle, { className: "text-sm", children: planTitle(plan, labels) }), _jsx("p", { className: "text-muted-foreground m-0 text-xs", children: planSummary(plan, labels) })] }), _jsx(Status, { "data-plan-status": true, variant: statusVariant[state.tone], label: pending ? "Sending…" : state.text })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [failed && isPresent(status.lastRun?.error) ? (_jsx("p", { role: "alert", className: "text-destructive-foreground m-0 text-sm", children: status.lastRun.error })) : null, shown.length === 0 ? (_jsx("p", { className: "text-muted-foreground m-0 text-sm", children: "Nothing here differs from how things are now." })) : (_jsx("ul", { className: "m-0 flex list-none flex-col gap-2 p-0", children: shown.map((change, index) => {
24
+ const link = links[change.entity];
25
+ const name = labels[change.entity] ?? "Untitled";
26
+ const why = plan.reasons.filter((reason) => reason.entity === change.entity);
27
+ return (_jsxs("li", { "data-plan-change": true, className: "m-0 flex flex-col gap-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-sm", children: [link === undefined ? (_jsx("span", { className: "font-semibold", children: name })) : (_jsx("a", { href: link.href, target: "_blank", rel: "noopener noreferrer", className: "font-semibold underline underline-offset-4", children: name })), _jsx("span", { className: "text-muted-foreground", children: labels[change.property] ?? "A setting" }), _jsx("span", { className: "sr-only", children: "now" }), _jsx("span", { className: "text-muted-foreground line-through", children: planValue(change.from, change, labels) }), _jsx(ArrowRight, { "aria-hidden": true, className: "text-muted-foreground size-3.5" }), _jsx("span", { className: "sr-only", children: "becomes" }), _jsx(Badge, { variant: "info", children: planValue(change.to, change, labels) })] }), why.length > 0 ? (_jsx("p", { className: "text-muted-foreground m-0 text-xs", children: `Why: ${why.map((reason) => reason.text).join("; ")}` })) : null] }, index));
28
+ }) })), hidden > 0 ? (_jsx("p", { className: "text-muted-foreground m-0 text-xs", children: `And ${hidden} more ${hidden === 1 ? "change" : "changes"}.` })) : null, unreadable > 0 ? (_jsx("p", { className: "text-muted-foreground m-0 text-xs", children: `It also touches ${unreadable} more ${unreadable === 1 ? "thing" : "things"} whose before and after could not be read.` })) : null, actions === undefined ? null : (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { size: "sm", variant: "secondary", disabled: busy, onClick: () => actions.onPreview(plan), children: "Check it" }), _jsx(DisabledActionTooltip, { ...(ready ? {} : { reason: "Check it first, so you can see what would change before anything does" }), children: _jsx(Button, { size: "sm", variant: "primary", disabled: busy || !ready, onClick: () => actions.onRun(plan), children: "Make these changes" }) }), _jsx("span", { className: "text-muted-foreground text-xs", children: ready ? "Changes them where they came from" : "A check changes nothing" })] }))] })] }));
29
+ }
@@ -3,7 +3,8 @@ import { createRoot } from "react-dom/client";
3
3
  import { toBlob } from "html-to-image";
4
4
  import { captureDeviceScaleFactor, captureWidth } from "@manta-eu/horns/render";
5
5
  import { HornSnapshotView } from "@manta-eu/horns-kit";
6
- import { makeWrittenHornView } from "./written-horn-view";
6
+ import { drawingAttribute, makeWrittenHornView } from "./written-horn-view";
7
+ import { PlanChanges } from "./plan-changes";
7
8
  import { isRecord } from "@manta-eu/horns";
8
9
  import { problemOf } from "../lib/problem";
9
10
  // A picture of a Horn, taken by the browser that already has it.
@@ -22,19 +23,24 @@ const settleTimeoutMs = 8000;
22
23
  /** No mutation for this long means the views have finished drawing. */
23
24
  const quietMs = 150;
24
25
  /**
25
- * Waits for the off-screen tree to stop changing.
26
+ * Waits for the off-screen tree to draw.
26
27
  *
27
- * A written view draws after a dynamic import and two effects, and nothing in the kit says when it is done, so
28
- * quiet is the signal: no mutation for `quietMs`, and the deadline wins either way. A picture of a view still
29
- * showing its placeholder is worth more than no picture.
28
+ * The gaps between a written view's import and its effects are longer than `quietMs`, so quiet counts only
29
+ * once no view is still drawing. The deadline wins either way.
30
30
  */
31
31
  function settled(node) {
32
+ const stillDrawing = () => node.querySelector(`[${drawingAttribute}]`) !== null;
32
33
  return new Promise((resolve) => {
33
34
  let quiet;
34
- const observer = new MutationObserver(() => {
35
+ const restQuiet = () => {
35
36
  clearTimeout(quiet);
36
- quiet = setTimeout(done, quietMs);
37
- });
37
+ quiet = setTimeout(() => {
38
+ if (stillDrawing())
39
+ return restQuiet();
40
+ done();
41
+ }, quietMs);
42
+ };
43
+ const observer = new MutationObserver(restQuiet);
38
44
  const done = () => {
39
45
  clearTimeout(quiet);
40
46
  clearTimeout(deadline);
@@ -42,7 +48,7 @@ function settled(node) {
42
48
  resolve();
43
49
  };
44
50
  const deadline = setTimeout(done, settleTimeoutMs);
45
- quiet = setTimeout(done, quietMs);
51
+ restQuiet();
46
52
  observer.observe(node, { subtree: true, childList: true, attributes: true, characterData: true });
47
53
  });
48
54
  }
@@ -71,7 +77,7 @@ export async function rasterizeHorn(snapshot) {
71
77
  document.body.append(holder);
72
78
  const root = createRoot(holder);
73
79
  try {
74
- root.render(_jsx(HornSnapshotView, { snapshot: snapshot, mode: "capture", writtenView: writtenView }));
80
+ root.render(_jsx(HornSnapshotView, { snapshot: snapshot, mode: "capture", writtenView: writtenView, planStrip: PlanChanges }));
75
81
  await settled(holder);
76
82
  const drawing = holder.firstElementChild;
77
83
  if (!(drawing instanceof HTMLElement))
@@ -37,6 +37,20 @@ function ChangeCard(props) {
37
37
  const [open, setOpen] = useState(false);
38
38
  return (_jsxs("section", { "data-change-card": true, className: cn("flex flex-col gap-3 rounded-xl border bg-base-background p-3", previewing ? "border-primary" : "border-muted-border"), children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(GitCommitHorizontal, { size: 16, "aria-hidden": "true", className: "mt-0.5 shrink-0 text-muted-foreground" }), _jsx("p", { className: "text-sm font-medium text-default-foreground", children: change.title })] }), open ? (_jsx("ul", { className: "flex list-disc flex-col gap-1 pl-9 text-xs text-muted-foreground", children: change.details.map((detail) => (_jsx("li", { children: detail }, detail))) })) : null, _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(Button, { variant: "secondary", size: "sm", "aria-expanded": open, onClick: () => setOpen((was) => !was), children: "Details" }), _jsx(Button, { variant: previewing ? "tertiary" : "secondary", size: "sm", disabled: previewing, onClick: onPreview, children: previewing ? "Previewing" : "Preview" })] })] }));
39
39
  }
40
+ // `ChatReasoning` is handed the count rather than keeping one.
41
+ function useElapsedSeconds(running) {
42
+ const [seconds, setSeconds] = useState(0);
43
+ useEffect(() => {
44
+ if (!running) {
45
+ setSeconds(0);
46
+ return;
47
+ }
48
+ const startedAt = Date.now();
49
+ const tick = setInterval(() => setSeconds(Math.floor((Date.now() - startedAt) / 1000)), 1000);
50
+ return () => clearInterval(tick);
51
+ }, [running]);
52
+ return seconds;
53
+ }
40
54
  /**
41
55
  * The Studio's conversation as a rail beside the preview: what was asked, what the turn did while it thought,
42
56
  * what it said, and a card for each change that can put that version back in the preview.
@@ -44,6 +58,7 @@ function ChangeCard(props) {
44
58
  export function StudioChat(props) {
45
59
  const { entries, pending, progress, previewing, welcome, reassurance, starters, placeholder, onSend, onPreview } = props;
46
60
  const scroller = useRef(null);
61
+ const elapsed = useElapsedSeconds(pending);
47
62
  // The newest line is the one being read, so the rail follows it down.
48
63
  useEffect(() => {
49
64
  const element = scroller.current;
@@ -59,5 +74,5 @@ export function StudioChat(props) {
59
74
  }
60
75
  const change = entry.change;
61
76
  return (_jsxs("div", { className: "flex flex-col gap-3", children: [isPresent(entry.thinking) ? (_jsx(ChatReasoning, { phase: "done", text: "Finished thinking", seconds: entry.seconds, items: entry.thinking.map(stepItem) })) : null, _jsx(ChatMessage, { role: "assistant", children: entry.content }), isPresent(change) ? (_jsx(ChangeCard, { change: change.summary, previewing: previewing === change.versionId, onPreview: () => onPreview(change.versionId) })) : null, isPresent(entry.advice) ? _jsx(ReviewCard, { advice: entry.advice, onSend: onSend }) : null, entry.unfinished === true ? (_jsx(Button, { size: "sm", variant: "secondary", className: "self-start", onClick: () => onSend("Continue where you left off."), children: "Keep going" })) : null] }, index));
62
- }), pending ? (_jsx(ChatReasoning, { phase: "pending", text: "Thinking", items: (isPresent(progress) ? progress : [{ title: "Sending" }]).map(stepItem) })) : null] }), _jsx("div", { className: "shrink-0 border-t border-muted-border p-3", children: _jsxs(ChatInput, { onSubmit: (value) => onSend(value), children: [_jsx(ChatInputTextarea, { placeholder: placeholder, "aria-label": placeholder }), _jsx(ChatInputSubmit, { icon: ArrowUp, "aria-label": "Send", isLoading: pending, disabled: pending })] }) })] }));
77
+ }), pending ? (_jsx(ChatReasoning, { phase: "pending", text: "Thinking", seconds: elapsed, ...(isPresent(progress) ? { subtitle: progress[progress.length - 1]?.title } : {}), items: (isPresent(progress) ? progress : [{ title: "Sending" }]).map(stepItem) })) : null] }), _jsx("div", { className: "shrink-0 border-t border-muted-border p-3", children: _jsxs(ChatInput, { onSubmit: (value) => onSend(value), children: [_jsx(ChatInputTextarea, { placeholder: placeholder, "aria-label": placeholder }), _jsx(ChatInputSubmit, { icon: ArrowUp, "aria-label": "Send", isLoading: pending, disabled: pending })] }) })] }));
63
78
  }
@@ -6,6 +6,8 @@ import type { HornSnapshotViewProps } from "@manta-eu/horns-kit";
6
6
  * about it — so the Studio cannot fix what it just wrote. A caller that wants to know passes `onError`; the
7
7
  * off-screen draw the picture is taken from passes one, which is how a broken view reaches the next turn.
8
8
  */
9
+ /** On a view that has not drawn yet. The rasterizer waits for these to go. */
10
+ export declare const drawingAttribute = "data-horn-drawing";
9
11
  export declare function makeWrittenHornView(onError?: (problem: {
10
12
  viewId: string;
11
13
  message: string;
@@ -15,8 +15,11 @@ const WrittenView = lazy(async () => {
15
15
  * about it — so the Studio cannot fix what it just wrote. A caller that wants to know passes `onError`; the
16
16
  * off-screen draw the picture is taken from passes one, which is how a broken view reaches the next turn.
17
17
  */
18
+ /** On a view that has not drawn yet. The rasterizer waits for these to go. */
19
+ export const drawingAttribute = "data-horn-drawing";
20
+ const drawing = (_jsx("span", { [drawingAttribute]: "", children: _jsx(Loader, {}) }));
18
21
  export function makeWrittenHornView(onError) {
19
- return (content, view, context) => (_jsx(Suspense, { fallback: _jsx(Loader, {}), children: _jsx(WrittenView, { source: content.source, rows: content.rows, columns: content.columns.map((column) => column.name), labels: context.labels, links: context.links, loadSurface: loadWrittenSurface, hooks: writtenHooks, fallback: _jsx(Loader, {}), renderError: (message) => {
22
+ return (content, view, context) => (_jsx(Suspense, { fallback: drawing, children: _jsx(WrittenView, { source: content.source, rows: content.rows, columns: content.columns.map((column) => column.name), labels: context.labels, links: context.links, loadSurface: loadWrittenSurface, hooks: writtenHooks, fallback: drawing, renderError: (message) => {
20
23
  onError?.({ viewId: view.id, message });
21
24
  // Plain words: the person reading this asked for a view, not a stack trace.
22
25
  return (_jsx("p", { role: "alert", className: "text-destructive-foreground m-0 text-sm", children: message }));
@@ -0,0 +1,32 @@
1
+ import { type HornSnapshotViewProps } from "@manta-eu/horns-kit";
2
+ /**
3
+ * Where the worker answers for a thing. The IRI goes in a query parameter and is encoded: a plan's carries
4
+ * a `#`, which would otherwise end the URL and ask about nothing.
5
+ */
6
+ export declare function hornAboutPath(datastoreId: string, about: string): string;
7
+ /** Where a Horn is turned into rows to draw. */
8
+ export declare function hornSnapshotPath(datastoreId: string): string;
9
+ export type HornAboutProps = {
10
+ datastoreId: string;
11
+ /** The thing to show: an action plan, a rule. Opaque — it is only ever passed on. */
12
+ about: string;
13
+ /** Narrows the Horn. A host with no URL to keep these in can hold them in state. */
14
+ variables?: Record<string, unknown>;
15
+ /** Defaults to `embed`, which draws the views without the page around them. */
16
+ mode?: HornSnapshotViewProps["mode"];
17
+ /** What the viewer may do with the plans behind a view. Without it a plan is shown and never run. */
18
+ planActions?: HornSnapshotViewProps["planActions"];
19
+ explain?: HornSnapshotViewProps["explain"];
20
+ onVariableChange?: HornSnapshotViewProps["onVariableChange"];
21
+ };
22
+ /**
23
+ * The Horn for a thing, drawn wherever a host puts it.
24
+ *
25
+ * A host with an IRI gets something to look at whether or not anyone has made a Horn for it: the worker
26
+ * answers with the kept one, or builds one from the plan or rule itself. So this needs no saved Horn, no
27
+ * route of the host's and no Studio — it is the read-only half, for a plan shown beside its own screen.
28
+ *
29
+ * Written views and catalog cards are bound here because they need `@manta-eu/react`, which the kit has not
30
+ * got. Everything else a host wants to hang on it is a prop.
31
+ */
32
+ export declare function HornAbout(props: HornAboutProps): import("react").JSX.Element;
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { z } from "zod";
4
+ import { isPresent } from "@manta-eu/lib/present";
5
+ import { hornSchema, isRecord } from "@manta-eu/horns";
6
+ import { hornSnapshotSchema } from "@manta-eu/horns/render";
7
+ import { HornSnapshotView } from "@manta-eu/horns-kit";
8
+ import { writtenHornView } from "../components/written-horn-view";
9
+ import { PlanChanges } from "../components/plan-changes";
10
+ import { catalogCardView } from "../components/catalog-card-view";
11
+ import { ErrorState, Loading } from "../components/states";
12
+ import { problemFrom } from "../lib/problem";
13
+ import { appTheme } from "../lib/theme";
14
+ const aboutAnswer = z.object({
15
+ horn: hornSchema,
16
+ kept: z.boolean(),
17
+ slug: z.string().min(1).optional(),
18
+ revision: z.string().min(1).optional(),
19
+ });
20
+ /**
21
+ * Where the worker answers for a thing. The IRI goes in a query parameter and is encoded: a plan's carries
22
+ * a `#`, which would otherwise end the URL and ask about nothing.
23
+ */
24
+ export function hornAboutPath(datastoreId, about) {
25
+ return `/api/d/${encodeURIComponent(datastoreId)}/horns/by/about?iri=${encodeURIComponent(about)}`;
26
+ }
27
+ /** Where a Horn is turned into rows to draw. */
28
+ export function hornSnapshotPath(datastoreId) {
29
+ return `/api/d/${encodeURIComponent(datastoreId)}/horns/snapshot`;
30
+ }
31
+ /**
32
+ * The Horn for a thing, drawn wherever a host puts it.
33
+ *
34
+ * A host with an IRI gets something to look at whether or not anyone has made a Horn for it: the worker
35
+ * answers with the kept one, or builds one from the plan or rule itself. So this needs no saved Horn, no
36
+ * route of the host's and no Studio — it is the read-only half, for a plan shown beside its own screen.
37
+ *
38
+ * Written views and catalog cards are bound here because they need `@manta-eu/react`, which the kit has not
39
+ * got. Everything else a host wants to hang on it is a prop.
40
+ */
41
+ export function HornAbout(props) {
42
+ const { datastoreId, about, variables = {}, mode = "embed", planActions, explain, onVariableChange } = props;
43
+ const seed = useQuery({
44
+ queryKey: ["horn-about", datastoreId, about],
45
+ queryFn: async ({ signal }) => {
46
+ const response = await fetch(hornAboutPath(datastoreId, about), { signal });
47
+ if (!response.ok)
48
+ throw new Error(await problemFrom(response, `The answer was ${response.status}.`));
49
+ return aboutAnswer.parse(await response.json());
50
+ },
51
+ });
52
+ const horn = seed.data?.horn;
53
+ const snapshot = useQuery({
54
+ enabled: isPresent(horn),
55
+ queryKey: ["horn-about-snapshot", datastoreId, about, seed.data?.revision ?? "", variables],
56
+ queryFn: async ({ signal }) => {
57
+ const response = await fetch(hornSnapshotPath(datastoreId), {
58
+ method: "POST",
59
+ headers: { "content-type": "application/json" },
60
+ body: JSON.stringify({ horn, variables }),
61
+ signal,
62
+ });
63
+ if (!response.ok)
64
+ throw new Error(await problemFrom(response, "The Horn could not be drawn."));
65
+ const body = await response.json();
66
+ return hornSnapshotSchema.parse(isRecord(body) ? body["snapshot"] : undefined);
67
+ },
68
+ });
69
+ if (seed.isError)
70
+ return _jsx(ErrorState, { title: "Nothing could be shown for this", hint: seed.error.message });
71
+ if (snapshot.isError)
72
+ return _jsx(ErrorState, { title: "This could not be drawn", hint: snapshot.error.message });
73
+ if (!isPresent(snapshot.data))
74
+ return _jsx(Loading, { label: "Drawing this\u2026", rows: 3 });
75
+ return (_jsx(HornSnapshotView, { snapshot: snapshot.data, mode: mode, theme: appTheme(), writtenView: writtenHornView, planStrip: PlanChanges, catalogCard: catalogCardView, ...(isPresent(planActions) ? { planActions } : {}), ...(isPresent(explain) ? { explain } : {}), ...(isPresent(onVariableChange) ? { onVariableChange } : {}) }));
76
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { AppRoutes, hornsRoutes } from "./app/routes.tsx";
2
2
  export { HornsApp } from "./standalone/app.tsx";
3
+ export { HornAbout, type HornAboutProps } from "./embed/horn-about.tsx";
3
4
  export { AppShell } from "./components/app-shell.tsx";
4
5
  export { signInHref } from "./lib/manta/sign-in.ts";
5
6
  export { useSparqlSelect } from "./lib/manta/sparql-select.ts";
package/dist/index.js CHANGED
@@ -10,6 +10,9 @@ export { AppRoutes, hornsRoutes } from "./app/routes.js";
10
10
  // The session gate, and the only thing that provides the AuthContext every Horns page reads. A host
11
11
  // splicing `hornsRoutes()` into a router of its own mounts this above them, or provides that context itself.
12
12
  export { HornsApp } from "./standalone/app.js";
13
+ // A thing's Horn, drawn inside a host's own screen: it asks the worker for the kept Horn or one built
14
+ // from the plan or rule itself, so a host needs no saved Horn and no route of its own.
15
+ export { HornAbout } from "./embed/horn-about.js";
13
16
  export { AppShell } from "./components/app-shell.js";
14
17
  export { signInHref } from "./lib/manta/sign-in.js";
15
18
  export { useSparqlSelect } from "./lib/manta/sparql-select.js";