@manta-eu/horns-client 0.1.4 → 0.2.1

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
  }
@@ -0,0 +1,20 @@
1
+ import type { LucideIcon } from "lucide-react";
2
+ export type HornsNavItem = {
3
+ to: string;
4
+ label: string;
5
+ icon: LucideIcon;
6
+ /** Exact for a page that is one page, a prefix where the page has children. */
7
+ isCurrent: (pathname: string) => boolean;
8
+ };
9
+ export type HornsNavSection = {
10
+ label: string;
11
+ items: HornsNavItem[];
12
+ };
13
+ /**
14
+ * Where a person can go inside Horns, in one list.
15
+ *
16
+ * `AppShell` draws it as its sidebar, and a host that supplies a `shell` of its own draws it wherever its
17
+ * own chrome puts navigation — `manta/agents` renders it beside the page, the way Data Explorer does. Two
18
+ * renderings of one list, so a page added here cannot go missing from the other.
19
+ */
20
+ export declare function hornsNavSections(): HornsNavSection[];
@@ -0,0 +1,34 @@
1
+ import { Boxes, Columns3, LayoutGrid, ListChecks, Sigma } from "lucide-react";
2
+ import { hornsPath, plansPath, rulesPath, shapesPath, showcasePath } from "./paths";
3
+ const exact = (path) => (pathname) => pathname === path;
4
+ const under = (path) => (pathname) => pathname.startsWith(path);
5
+ /**
6
+ * Where a person can go inside Horns, in one list.
7
+ *
8
+ * `AppShell` draws it as its sidebar, and a host that supplies a `shell` of its own draws it wherever its
9
+ * own chrome puts navigation — `manta/agents` renders it beside the page, the way Data Explorer does. Two
10
+ * renderings of one list, so a page added here cannot go missing from the other.
11
+ */
12
+ export function hornsNavSections() {
13
+ return [
14
+ {
15
+ label: "Horns",
16
+ items: [
17
+ { to: showcasePath(), label: "Showcase", icon: LayoutGrid, isCurrent: exact(showcasePath()) },
18
+ { to: hornsPath(), label: "Horns", icon: Columns3, isCurrent: exact(hornsPath()) },
19
+ ],
20
+ },
21
+ // The tools behind a Horn: for the people who build them, kept apart so it is not the first thing
22
+ // everyone else is offered.
23
+ {
24
+ label: "For builders",
25
+ items: [
26
+ { to: shapesPath(), label: "Data shapes", icon: Boxes, isCurrent: under(shapesPath()) },
27
+ // The other half of what the datastore knows: not what a thing is, but what Manta works out.
28
+ { to: rulesPath(), label: "Rules", icon: Sigma, isCurrent: under(rulesPath()) },
29
+ // What those rules drafted, and where a Horn is started from one.
30
+ { to: plansPath(), label: "Action plans", icon: ListChecks, isCurrent: under(plansPath()) },
31
+ ],
32
+ },
33
+ ];
34
+ }
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  /**
2
3
  * Horns' routes as `<Route>` children, for a host that splices them into its own `<Routes>`. A nested
3
4
  * `<Routes>` would rebase every path on its parent's match, so a host mounting Horns under a prefix would
@@ -5,8 +6,15 @@
5
6
  *
6
7
  * `catchAll` is for the host that gives Horns the whole URL space, as `mountHorns` does. A host with routes
7
8
  * of its own leaves it off and keeps its own 404.
9
+ *
10
+ * `shell` is the layout the listed pages draw inside, defaulting to Horns' own. A host that has a shell
11
+ * already passes it, and a Horn then reads as one of that host's pages rather than swapping the chrome for
12
+ * Horns'. It is a layout route's element, so it renders an `<Outlet />`; the Studio and a saved Horn ignore
13
+ * it, because each is a whole screen with nothing beside it.
8
14
  */
9
- export declare function hornsRoutes({ catchAll }?: {
15
+ export type HornsRoutesOptions = {
10
16
  catchAll?: boolean;
11
- }): import("react").JSX.Element;
17
+ shell?: ReactNode;
18
+ };
19
+ export declare function hornsRoutes({ catchAll, shell }?: HornsRoutesOptions): import("react").JSX.Element;
12
20
  export declare function AppRoutes(): import("react").JSX.Element;
@@ -15,19 +15,11 @@ import { ShapeZooPage } from "./shape-zoo-page";
15
15
  import { ShapesPage } from "./shapes-page";
16
16
  import { ShowcasePage } from "./showcase-page";
17
17
  import { ReferencePage } from "./reference-page";
18
- /**
19
- * Horns' routes as `<Route>` children, for a host that splices them into its own `<Routes>`. A nested
20
- * `<Routes>` would rebase every path on its parent's match, so a host mounting Horns under a prefix would
21
- * push `d/:datastoreId/...` a segment too deep; spliced in, the paths are the ones `paths.ts` writes.
22
- *
23
- * `catchAll` is for the host that gives Horns the whole URL space, as `mountHorns` does. A host with routes
24
- * of its own leaves it off and keeps its own 404.
25
- */
26
- export function hornsRoutes({ catchAll = false } = {}) {
18
+ export function hornsRoutes({ catchAll = false, shell } = {}) {
27
19
  return (
28
20
  // Here rather than at the mount: a host splicing these into its own router gets it too, and a Horn page
29
21
  // reads its variables through nuqs on every render — without an adapter that throws rather than degrades.
30
- _jsxs(Route, { element: _jsx(NuqsAdapter, { children: _jsx(Outlet, {}) }), children: [_jsx(Route, { path: "horns/new", element: _jsx(NewHornPage, {}) }), _jsx(Route, { path: "horns/:slug", element: _jsx(HornPage, {}) }), _jsxs(Route, { element: _jsx(AppShell, {}), children: [_jsx(Route, { path: "horns", element: _jsx(HornsPage, {}) }), _jsx(Route, { path: "horns/:slug/view", element: _jsx(HornViewPage, {}) }), _jsx(Route, { path: "horns/showcase", element: _jsx(ShowcasePage, {}) }), _jsx(Route, { path: "horns/plans", element: _jsx(PlansPage, {}) }), _jsx(Route, { path: "horns/rules", element: _jsx(RulesPage, {}) }), _jsx(Route, { path: "horns/rules/:rule", element: _jsx(RulePage, {}) }), _jsx(Route, { path: "horns/shapes", element: _jsx(ShapesPage, {}) }), _jsx(Route, { path: "horns/shapes/:schema", element: _jsx(ShapeZooPage, {}) }), _jsx(Route, { path: "horns/shapes/:schema/entity", element: _jsx(ShapeEntityPage, {}) }), import.meta.env.DEV ? _jsx(Route, { path: "horns/reference", element: _jsx(ReferencePage, {}) }) : null, catchAll ? _jsx(Route, { index: true, element: _jsx(HomePage, {}) }) : null] }), catchAll ? _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/", replace: true }) }) : null] }));
22
+ _jsxs(Route, { element: _jsx(NuqsAdapter, { children: _jsx(Outlet, {}) }), children: [_jsx(Route, { path: "horns/new", element: _jsx(NewHornPage, {}) }), _jsx(Route, { path: "horns/:slug", element: _jsx(HornPage, {}) }), _jsxs(Route, { element: shell ?? _jsx(AppShell, {}), children: [_jsx(Route, { path: "horns", element: _jsx(HornsPage, {}) }), _jsx(Route, { path: "horns/:slug/view", element: _jsx(HornViewPage, {}) }), _jsx(Route, { path: "horns/showcase", element: _jsx(ShowcasePage, {}) }), _jsx(Route, { path: "horns/plans", element: _jsx(PlansPage, {}) }), _jsx(Route, { path: "horns/rules", element: _jsx(RulesPage, {}) }), _jsx(Route, { path: "horns/rules/:rule", element: _jsx(RulePage, {}) }), _jsx(Route, { path: "horns/shapes", element: _jsx(ShapesPage, {}) }), _jsx(Route, { path: "horns/shapes/:schema", element: _jsx(ShapeZooPage, {}) }), _jsx(Route, { path: "horns/shapes/:schema/entity", element: _jsx(ShapeEntityPage, {}) }), import.meta.env.DEV ? _jsx(Route, { path: "horns/reference", element: _jsx(ReferencePage, {}) }) : null, catchAll ? _jsx(Route, { index: true, element: _jsx(HomePage, {}) }) : null] }), catchAll ? _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/", replace: true }) }) : null] }));
31
23
  }
32
24
  export function AppRoutes() {
33
25
  return _jsx(Routes, { children: hornsRoutes({ catchAll: true }) });
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { isPresent } from "@manta-eu/lib/present";
3
3
  import { Link, Outlet, useLocation } from "react-router";
4
- import { Boxes, Columns3, Database, LayoutGrid, ListChecks, Sigma } from "lucide-react";
4
+ import { Database } from "lucide-react";
5
5
  import { MainContent, MantaLogo, NavUser, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, } from "@manta-eu/react";
6
- import { hornsPath, plansPath, rulesPath, shapesPath, showcasePath } from "../app/paths";
6
+ import { hornsNavSections } from "../app/nav";
7
7
  import { useActiveDatastore } from "@manta-eu/react/providers";
8
8
  import { useAuthContext } from "@manta-eu/react/providers/AuthContext";
9
9
  function NavLink({ to, icon, label, active }) {
@@ -15,5 +15,5 @@ export function AppShell() {
15
15
  const { user, datastore, datastores = [] } = useAuthContext();
16
16
  const current = datastores.find((candidate) => candidate.id === datastoreId) ?? datastore;
17
17
  const inDatastore = isPresent(datastoreId) && isPresent(current);
18
- return (_jsxs(SidebarProvider, { children: [_jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsxs(Link, { to: "/", className: "flex items-center gap-2 px-2 py-1.5", "aria-label": "Manta Horns", children: [_jsx(MantaLogo, { size: 24 }), _jsx("span", { className: "font-semibold group-data-[collapsible=icon]:hidden", children: "Horns" })] }) }), _jsx(SidebarContent, { children: _jsxs("nav", { "aria-label": "Datastore", className: "flex flex-col gap-2", children: [isPresent(datastoreId) && inDatastore ? (_jsxs(_Fragment, { children: [_jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: current.name }), _jsx(SidebarGroupContent, { children: _jsxs(SidebarMenu, { children: [_jsx(NavLink, { to: showcasePath(), icon: _jsx(LayoutGrid, { "aria-hidden": "true" }), label: "Showcase", active: pathname === showcasePath() }), _jsx(NavLink, { to: hornsPath(), icon: _jsx(Columns3, { "aria-hidden": "true" }), label: "Horns", active: pathname === hornsPath() })] }) })] }), _jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: "For builders" }), _jsx(SidebarGroupContent, { children: _jsxs(SidebarMenu, { children: [_jsx(NavLink, { to: shapesPath(), icon: _jsx(Boxes, { "aria-hidden": "true" }), label: "Data shapes", active: pathname.startsWith(shapesPath()) }), _jsx(NavLink, { to: rulesPath(), icon: _jsx(Sigma, { "aria-hidden": "true" }), label: "Rules", active: pathname.startsWith(rulesPath()) }), _jsx(NavLink, { to: plansPath(), icon: _jsx(ListChecks, { "aria-hidden": "true" }), label: "Action plans", active: pathname.startsWith(plansPath()) })] }) })] })] })) : null, _jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: "All datastores" }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: _jsx(NavLink, { to: "/", icon: _jsx(Database, { "aria-hidden": "true" }), label: `Datastores (${datastores.length})`, active: pathname === "/" }) }) })] })] }) }), _jsx(SidebarFooter, { children: _jsx(NavUser, { name: user?.name ?? user?.email ?? "Signed in", currentDatastore: { name: current?.name ?? "No datastore" }, onNavigateToAccount: () => window.location.assign("/"), onLogout: () => window.location.assign("/logout") }) })] }), _jsx(MainContent, { className: "pb-8", children: _jsxs("div", { className: "flex w-full flex-col gap-4 px-4 sm:px-6", children: [_jsx(SidebarTrigger, { "aria-label": "Toggle Sidebar" }), _jsx(Outlet, {})] }) })] }));
18
+ return (_jsxs(SidebarProvider, { children: [_jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsxs(Link, { to: "/", className: "flex items-center gap-2 px-2 py-1.5", "aria-label": "Manta Horns", children: [_jsx(MantaLogo, { size: 24 }), _jsx("span", { className: "font-semibold group-data-[collapsible=icon]:hidden", children: "Horns" })] }) }), _jsx(SidebarContent, { children: _jsxs("nav", { "aria-label": "Datastore", className: "flex flex-col gap-2", children: [isPresent(datastoreId) && inDatastore ? (_jsx(_Fragment, { children: hornsNavSections().map((section, index) => (_jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: index === 0 ? current.name : section.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: section.items.map(({ to, label, icon: Icon, isCurrent }) => (_jsx(NavLink, { to: to, icon: _jsx(Icon, { "aria-hidden": "true" }), label: label, active: isCurrent(pathname) }, to))) }) })] }, section.label))) })) : null, _jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: "All datastores" }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: _jsx(NavLink, { to: "/", icon: _jsx(Database, { "aria-hidden": "true" }), label: `Datastores (${datastores.length})`, active: pathname === "/" }) }) })] })] }) }), _jsx(SidebarFooter, { children: _jsx(NavUser, { name: user?.name ?? user?.email ?? "Signed in", currentDatastore: { name: current?.name ?? "No datastore" }, onNavigateToAccount: () => window.location.assign("/"), onLogout: () => window.location.assign("/logout") }) })] }), _jsx(MainContent, { className: "pb-8", children: _jsxs("div", { className: "flex w-full flex-col gap-4 px-4 sm:px-6", children: [_jsx(SidebarTrigger, { "aria-label": "Toggle Sidebar" }), _jsx(Outlet, {})] }) })] }));
19
19
  }
@@ -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,56 @@
1
+ import { type Horn } from "@manta-eu/horns";
2
+ import { type HornSnapshotViewProps } from "@manta-eu/horns-kit";
3
+ /**
4
+ * Where the worker answers for a thing. The IRI goes in a query parameter and is encoded: a plan's carries
5
+ * a `#`, which would otherwise end the URL and ask about nothing.
6
+ */
7
+ export declare function hornAboutPath(datastoreId: string, about: string): string;
8
+ /** Where a Horn is turned into rows to draw. */
9
+ export declare function hornSnapshotPath(datastoreId: string): string;
10
+ /** Where every Horn about a thing is listed. */
11
+ export declare function hornsAboutPath(datastoreId: string, about: string, limit?: number): string;
12
+ /** What the drawing needs, apart from which Horn it is. */
13
+ export type HornDrawProps = {
14
+ mode?: HornSnapshotViewProps["mode"];
15
+ planActions?: HornSnapshotViewProps["planActions"];
16
+ explain?: HornSnapshotViewProps["explain"];
17
+ onVariableChange?: HornSnapshotViewProps["onVariableChange"];
18
+ };
19
+ /**
20
+ * One Horn, turned into rows by the worker and drawn.
21
+ *
22
+ * Written views and catalog cards are bound here because they need `@manta-eu/react`, which the kit has
23
+ * not got. Everything else a host wants to hang on it is a prop.
24
+ */
25
+ export type HornDrawnProps = HornDrawProps & {
26
+ datastoreId: string;
27
+ horn: Horn;
28
+ /** What a redraw keys on: the revision where there is one, or the thing the Horn was built for. */
29
+ cacheKey: string;
30
+ variables?: Record<string, unknown>;
31
+ };
32
+ export declare function HornDrawn(props: HornDrawnProps): import("react").JSX.Element;
33
+ export type HornAboutProps = {
34
+ datastoreId: string;
35
+ /** The thing to show: an action plan, a rule. Opaque — it is only ever passed on. */
36
+ about: string;
37
+ /** Narrows the Horn. A host with no URL to keep these in can hold them in state. */
38
+ variables?: Record<string, unknown>;
39
+ /** Defaults to `embed`, which draws the views without the page around them. */
40
+ mode?: HornSnapshotViewProps["mode"];
41
+ /** What the viewer may do with the plans behind a view. Without it a plan is shown and never run. */
42
+ planActions?: HornSnapshotViewProps["planActions"];
43
+ explain?: HornSnapshotViewProps["explain"];
44
+ onVariableChange?: HornSnapshotViewProps["onVariableChange"];
45
+ };
46
+ /**
47
+ * The Horn for a thing, drawn wherever a host puts it.
48
+ *
49
+ * A host with an IRI gets something to look at whether or not anyone has made a Horn for it: the worker
50
+ * answers with the kept one, or builds one from the plan or rule itself. So this needs no saved Horn, no
51
+ * route of the host's and no Studio — it is the read-only half, for a plan shown beside its own screen.
52
+ *
53
+ * Written views and catalog cards are bound here because they need `@manta-eu/react`, which the kit has not
54
+ * got. Everything else a host wants to hang on it is a prop.
55
+ */
56
+ export declare function HornAbout(props: HornAboutProps): import("react").JSX.Element;