@manta-eu/horns-client 0.1.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/dist/app/coverage-run.d.ts +5 -0
  3. package/dist/app/coverage-run.js +221 -0
  4. package/dist/app/home-page.d.ts +1 -0
  5. package/dist/app/home-page.js +11 -0
  6. package/dist/app/horn-page.d.ts +1 -0
  7. package/dist/app/horn-page.js +38 -0
  8. package/dist/app/horn-studio.d.ts +30 -0
  9. package/dist/app/horn-studio.js +428 -0
  10. package/dist/app/horn-view-page.d.ts +1 -0
  11. package/dist/app/horn-view-page.js +96 -0
  12. package/dist/app/horns-page.d.ts +1 -0
  13. package/dist/app/horns-page.js +43 -0
  14. package/dist/app/new-horn-page.d.ts +1 -0
  15. package/dist/app/new-horn-page.js +34 -0
  16. package/dist/app/paths.d.ts +13 -0
  17. package/dist/app/paths.js +32 -0
  18. package/dist/app/routes.d.ts +12 -0
  19. package/dist/app/routes.js +32 -0
  20. package/dist/app/rule-page.d.ts +1 -0
  21. package/dist/app/rule-page.js +84 -0
  22. package/dist/app/rules-page.d.ts +1 -0
  23. package/dist/app/rules-page.js +74 -0
  24. package/dist/app/saved-horn.d.ts +7 -0
  25. package/dist/app/saved-horn.js +26 -0
  26. package/dist/app/session-gate.d.ts +6 -0
  27. package/dist/app/session-gate.js +19 -0
  28. package/dist/app/shape-entity-page.d.ts +1 -0
  29. package/dist/app/shape-entity-page.js +68 -0
  30. package/dist/app/shape-view-studio.d.ts +14 -0
  31. package/dist/app/shape-view-studio.js +163 -0
  32. package/dist/app/shape-zoo-page.d.ts +1 -0
  33. package/dist/app/shape-zoo-page.js +58 -0
  34. package/dist/app/shapes-page.d.ts +1 -0
  35. package/dist/app/shapes-page.js +75 -0
  36. package/dist/app/showcase-page.d.ts +2 -0
  37. package/dist/app/showcase-page.js +99 -0
  38. package/dist/app/variable-bar.d.ts +6 -0
  39. package/dist/app/variable-bar.js +42 -0
  40. package/dist/client.d.ts +10 -0
  41. package/dist/client.js +22 -0
  42. package/dist/components/app-shell.d.ts +1 -0
  43. package/dist/components/app-shell.js +18 -0
  44. package/dist/components/catalog-card-body.d.ts +8 -0
  45. package/dist/components/catalog-card-body.js +29 -0
  46. package/dist/components/catalog-card-view.d.ts +2 -0
  47. package/dist/components/catalog-card-view.js +13 -0
  48. package/dist/components/chat-column.d.ts +22 -0
  49. package/dist/components/chat-column.js +20 -0
  50. package/dist/components/page-header.d.ts +15 -0
  51. package/dist/components/page-header.js +11 -0
  52. package/dist/components/plan-runs.d.ts +28 -0
  53. package/dist/components/plan-runs.js +90 -0
  54. package/dist/components/rasterize.d.ts +34 -0
  55. package/dist/components/rasterize.js +115 -0
  56. package/dist/components/read-the-code.d.ts +14 -0
  57. package/dist/components/read-the-code.js +43 -0
  58. package/dist/components/snapshot-view.d.ts +12 -0
  59. package/dist/components/snapshot-view.js +25 -0
  60. package/dist/components/states.d.ts +23 -0
  61. package/dist/components/states.js +22 -0
  62. package/dist/components/studio-chat.d.ts +47 -0
  63. package/dist/components/studio-chat.js +63 -0
  64. package/dist/components/written-horn-view.d.ts +15 -0
  65. package/dist/components/written-horn-view.js +25 -0
  66. package/dist/index.d.ts +11 -0
  67. package/dist/index.js +16 -0
  68. package/dist/lib/horn-review.d.ts +27 -0
  69. package/dist/lib/horn-review.js +59 -0
  70. package/dist/lib/manta/__tests__/sdk-realm.d.ts +2 -0
  71. package/dist/lib/manta/__tests__/sdk-realm.js +17 -0
  72. package/dist/lib/manta/ldo.d.ts +6 -0
  73. package/dist/lib/manta/ldo.js +14 -0
  74. package/dist/lib/manta/providers.d.ts +10 -0
  75. package/dist/lib/manta/providers.js +48 -0
  76. package/dist/lib/manta/session.d.ts +47 -0
  77. package/dist/lib/manta/session.js +204 -0
  78. package/dist/lib/manta/sparql-select.d.ts +2 -0
  79. package/dist/lib/manta/sparql-select.js +19 -0
  80. package/dist/lib/plan-runs.d.ts +14 -0
  81. package/dist/lib/plan-runs.js +26 -0
  82. package/dist/lib/problem.d.ts +8 -0
  83. package/dist/lib/problem.js +16 -0
  84. package/dist/lib/shapes/catalog.d.ts +14 -0
  85. package/dist/lib/shapes/catalog.js +25 -0
  86. package/dist/lib/shapes/ldkit-source.d.ts +14 -0
  87. package/dist/lib/shapes/ldkit-source.js +26 -0
  88. package/dist/lib/studio-progress.d.ts +7 -0
  89. package/dist/lib/studio-progress.js +36 -0
  90. package/dist/lib/telemetry/sentry.d.ts +1 -0
  91. package/dist/lib/telemetry/sentry.js +17 -0
  92. package/dist/lib/theme.d.ts +2 -0
  93. package/dist/lib/theme.js +7 -0
  94. package/dist/lib/time-ago.d.ts +5 -0
  95. package/dist/lib/time-ago.js +35 -0
  96. package/dist/lib/written-surface.d.ts +9 -0
  97. package/dist/lib/written-surface.js +10 -0
  98. package/dist/render/horn-client.d.ts +1 -0
  99. package/dist/render/horn-client.js +10 -0
  100. package/dist/styles.css +2 -0
  101. package/package.json +77 -0
@@ -0,0 +1,428 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { isPresent } from "@manta-eu/lib/present";
5
+ import { useNavigate } from "react-router";
6
+ import { ArrowLeft, ChevronLeft, ChevronRight, Database, Eye, Maximize2, Minimize2, Pencil, RefreshCw, Trash2, } from "lucide-react";
7
+ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, cn, useIsMobile, useResizablePanel, } from "@manta-eu/react";
8
+ import { gridColumns, hornSchema, maxBindingLimit } from "@manta-eu/horns";
9
+ import { hornSnapshotSchema } from "@manta-eu/horns/render";
10
+ import { describeHornChange, minViewWidth, visualEditOps } from "@manta-eu/horns/studio";
11
+ import { HornSnapshotView } from "@manta-eu/horns-kit";
12
+ import { writtenHornView } from "../components/written-horn-view";
13
+ import { catalogCardView } from "../components/catalog-card-view";
14
+ import { ReadTheCode, writtenReviewStates } from "../components/read-the-code";
15
+ import { PlanRunDialog, usePlanRuns } from "../components/plan-runs";
16
+ import { sendHornScreenshot } from "../components/rasterize";
17
+ import { reviewHorn } from "../lib/horn-review";
18
+ import { StudioChat } from "../components/studio-chat";
19
+ import { ErrorState, Loading } from "../components/states";
20
+ import { isRecord } from "@manta-eu/horns";
21
+ import { readNdjson } from "@manta-eu/horns-server/shared";
22
+ import { problemOf } from "../lib/problem";
23
+ import { studioProgressLine } from "../lib/studio-progress";
24
+ import { appTheme } from "../lib/theme";
25
+ const restyleStarters = [
26
+ "Make it dark and give it a calmer accent",
27
+ "Turn the biggest chart into a donut",
28
+ "Tighter cards, and a KPI row across the top",
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
+ function failureOf(body, status) {
41
+ return problemOf(body, `The Studio could not answer (${status}).`);
42
+ }
43
+ /** How much a card grows or shrinks by: a third of the twelve columns, so the page keeps its rhythm. */
44
+ const widthStep = 4;
45
+ const kebab = (name) => name
46
+ .trim()
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, "-")
49
+ .replace(/^-+|-+$/g, "")
50
+ .slice(0, 60);
51
+ /**
52
+ * The Studio as a whole screen, the way Lovable lays out a project: the conversation down the left, the Horn in
53
+ * a frame on the right, and saving in the bar across the top.
54
+ *
55
+ * The draft lives here and nowhere else. Each turn that changes it adds a version, the chat keeps a card for
56
+ * each, and the preview shows whichever one is picked — the latest unless someone went back to look. Saving is
57
+ * a separate, deliberate act, which is what makes the conversation free to be wrong.
58
+ */
59
+ export function HornStudio(props) {
60
+ const { datastoreId, stored, revision, backTo, welcome, reassurance, placeholder, starters, variables, toolbar } = props;
61
+ const navigate = useNavigate();
62
+ const mobile = useIsMobile();
63
+ const [pane, setPane] = useState("preview");
64
+ const [versions, setVersions] = useState([{ id: 0, horn: stored }]);
65
+ const nextId = useRef(1);
66
+ const [viewing, setViewing] = useState();
67
+ const [entries, setEntries] = useState([]);
68
+ const [pending, setPending] = useState(false);
69
+ const [progress, setProgress] = useState([]);
70
+ const [slug, setSlug] = useState(props.slug);
71
+ const [baseRevision, setBaseRevision] = useState(revision);
72
+ const [savedId, setSavedId] = useState(isPresent(revision) ? 0 : undefined);
73
+ const [hovered, setHovered] = useState();
74
+ const [renaming, setRenaming] = useState();
75
+ const [rebinding, setRebinding] = useState();
76
+ const [editFailure, setEditFailure] = useState();
77
+ const [saveState, setSaveState] = useState("idle");
78
+ const [saveFailure, setSaveFailure] = useState();
79
+ const { panelRef, panelWidth, handleResizeStart } = useResizablePanel({
80
+ initialWidth: 440,
81
+ minWidth: 340,
82
+ maxWidthRatio: 0.5,
83
+ resizeFrom: "right",
84
+ });
85
+ const latest = versions[versions.length - 1] ?? { id: 0, horn: stored };
86
+ const shown = versions.find((version) => version.id === viewing) ?? latest;
87
+ // Hashing is async, so the states are held rather than derived: a card shows what was last worked out for
88
+ // the draft it is drawing.
89
+ const [reviewStates, setReviewStates] = useState({});
90
+ useEffect(() => {
91
+ let live = true;
92
+ void writtenReviewStates(shown.horn).then((states) => {
93
+ if (live)
94
+ setReviewStates(states);
95
+ });
96
+ return () => {
97
+ live = false;
98
+ };
99
+ }, [shown.horn]);
100
+ // A new Horn with nothing in it yet has nothing to save; one that was saved and then discarded does.
101
+ const unsaved = latest.id !== savedId && (versions.length > 1 || isPresent(savedId));
102
+ // Nothing to draw is not something to wait for.
103
+ const empty = !isPresent(shown.horn.views);
104
+ const snapshot = useQuery({
105
+ enabled: !empty,
106
+ // The version id stands for the document: versions are never edited, only added. The variables are part of
107
+ // the key because a link that sets one is a different Horn to draw.
108
+ queryKey: ["horn-draft", datastoreId, props.slug ?? "new", shown.id, variables ?? {}],
109
+ queryFn: async ({ signal }) => {
110
+ const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/horns/snapshot`, {
111
+ method: "POST",
112
+ headers: { "content-type": "application/json" },
113
+ body: JSON.stringify({ horn: shown.horn, ...(variables ? { variables } : {}) }),
114
+ signal,
115
+ });
116
+ if (!response.ok)
117
+ throw new Error(`The draft could not be drawn (${response.status}).`);
118
+ const body = await response.json();
119
+ return hornSnapshotSchema.parse(isRecord(body) ? body["snapshot"] : undefined);
120
+ },
121
+ });
122
+ const planRuns = usePlanRuns({
123
+ datastoreId,
124
+ realm: shown.horn.realm,
125
+ onChanged: () => void snapshot.refetch(),
126
+ });
127
+ async function send(asked) {
128
+ const conversation = [...entries, { kind: "user", content: asked }];
129
+ setEntries(conversation);
130
+ setPending(true);
131
+ setProgress([]);
132
+ setPane("chat");
133
+ const started = Date.now();
134
+ const thinking = [];
135
+ const base = latest;
136
+ const fail = (content) => setEntries([...conversation, { kind: "failure", content }]);
137
+ try {
138
+ const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/studio`, {
139
+ method: "POST",
140
+ headers: { "content-type": "application/json" },
141
+ body: JSON.stringify({
142
+ horn: base.horn,
143
+ // Failures are the app's, not the conversation's; the model is shown only what was said.
144
+ messages: conversation.flatMap((entry) => entry.kind === "failure" ? [] : [{ role: entry.kind, content: entry.content }]),
145
+ }),
146
+ });
147
+ if (!response.ok) {
148
+ fail(failureOf(await response.json().catch(() => undefined), response.status));
149
+ return;
150
+ }
151
+ const body = await readNdjson(response, (line) => {
152
+ const said = line["type"] === "progress" ? studioProgressLine(line) : undefined;
153
+ if (!isPresent(said))
154
+ return;
155
+ thinking.push(said);
156
+ setProgress([...thinking]);
157
+ });
158
+ if (body?.["type"] === "error") {
159
+ fail(problemOf(body, "The Studio failed."));
160
+ return;
161
+ }
162
+ const reply = readReply(body);
163
+ if (!isPresent(reply)) {
164
+ fail("The Studio answered with something that is not a Horn.");
165
+ return;
166
+ }
167
+ const summary = describeHornChange(base.horn, reply.horn);
168
+ const changed = isPresent(summary.details);
169
+ const version = { id: nextId.current, horn: reply.horn };
170
+ if (changed) {
171
+ nextId.current += 1;
172
+ setVersions((was) => [...was, version]);
173
+ setViewing(undefined);
174
+ setPane("preview");
175
+ }
176
+ // A second model looks at what was drawn. The browser is the only thing that can draw a Horn, so the
177
+ // picture is taken here; the advice is the reviewer's, never a verdict, and a review that fails is a
178
+ // turn without one.
179
+ let review = { advice: [], problems: [] };
180
+ if (changed) {
181
+ thinking.push({ title: "Looking at what it drew" });
182
+ setProgress([...thinking]);
183
+ review = await reviewHorn({ datastoreId, horn: reply.horn, asked, variables });
184
+ for (const problem of review.problems) {
185
+ thinking.push({ title: `“${problem.viewId}” would not draw`, detail: problem.message });
186
+ }
187
+ if (isPresent(review.advice)) {
188
+ const count = review.advice.length;
189
+ thinking.push({ title: `Found ${count === 1 ? "one thing" : `${count} things`} to fix` });
190
+ }
191
+ else if (isPresent(review.unavailable)) {
192
+ // Said out loud: a reviewer nobody configured looks exactly like a reviewer that liked it.
193
+ thinking.push({ title: "Nobody could look at it", detail: review.unavailable });
194
+ }
195
+ else if (!isPresent(review.problems)) {
196
+ thinking.push({ title: "Looked at it and found nothing to fix" });
197
+ }
198
+ }
199
+ const said = [
200
+ // A view that would not draw is not an opinion; it goes first, and it is a fact the Studio can act on.
201
+ ...review.problems.map((problem) => `“${problem.viewId}” does not draw: ${problem.message}`),
202
+ ...review.advice,
203
+ ];
204
+ setEntries([
205
+ ...conversation,
206
+ {
207
+ kind: "assistant",
208
+ content: isPresent(reply.reply) ? reply.reply : summary.title,
209
+ thinking,
210
+ seconds: Math.max(1, Math.round((Date.now() - started) / 1000)),
211
+ ...(changed ? { change: { summary, versionId: version.id } } : {}),
212
+ ...(isPresent(said) ? { advice: said } : {}),
213
+ // A turn that ran out of room is one click from going on, not a message to retype.
214
+ ...(reply.status === "step-limit" ? { unfinished: true } : {}),
215
+ },
216
+ ]);
217
+ }
218
+ catch (caught) {
219
+ fail(caught instanceof Error ? caught.message : String(caught));
220
+ }
221
+ finally {
222
+ setPending(false);
223
+ setProgress([]);
224
+ }
225
+ }
226
+ /**
227
+ * A change made by hand rather than by asking. It is the same patch a turn would propose, checked by the same
228
+ * gate on the worker, and it lands as a version like any other — so going back, saving and asking for the next
229
+ * change all work on it without knowing where it came from.
230
+ */
231
+ async function edit(change, said) {
232
+ const base = latest;
233
+ const ops = visualEditOps(base.horn, change);
234
+ if (!ops.ok) {
235
+ setEditFailure(ops.message);
236
+ return;
237
+ }
238
+ setEditFailure(undefined);
239
+ try {
240
+ const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/horns/edit`, {
241
+ method: "POST",
242
+ headers: { "content-type": "application/json" },
243
+ body: JSON.stringify({ horn: base.horn, ops: ops.ops }),
244
+ });
245
+ const body = await response.json().catch(() => undefined);
246
+ if (!response.ok) {
247
+ setEditFailure(failureOf(body, response.status));
248
+ return;
249
+ }
250
+ const changed = hornSchema.parse(isRecord(body) ? body["horn"] : undefined);
251
+ const summary = describeHornChange(base.horn, changed);
252
+ const version = { id: nextId.current, horn: changed };
253
+ nextId.current += 1;
254
+ setVersions((was) => [...was, version]);
255
+ setViewing(undefined);
256
+ setEntries((was) => [
257
+ ...was,
258
+ {
259
+ kind: "assistant",
260
+ content: said,
261
+ thinking: [],
262
+ seconds: 0,
263
+ ...(isPresent(summary.details) ? { change: { summary, versionId: version.id } } : {}),
264
+ },
265
+ ]);
266
+ }
267
+ catch (caught) {
268
+ setEditFailure(caught instanceof Error ? caught.message : String(caught));
269
+ }
270
+ }
271
+ /**
272
+ * The query a view runs, as written in the document. A view that reads through a lens, or through a pinned
273
+ * library query, is not a query anybody typed — so it is not one to retype here.
274
+ */
275
+ function openBinding(viewId, title) {
276
+ const view = latest.horn.views.find((candidate) => candidate.id === viewId);
277
+ if (!view || "ref" in view) {
278
+ setEditFailure("This view comes from the library. Change it there, not here.");
279
+ return;
280
+ }
281
+ if (view.binding.kind !== "select") {
282
+ setEditFailure("This view reads a shape rather than a query somebody wrote, so there is nothing to type here.");
283
+ return;
284
+ }
285
+ setEditFailure(undefined);
286
+ setRebinding({ viewId, title, sparql: view.binding.sparql, limit: String(view.binding.limit) });
287
+ }
288
+ async function saveBinding() {
289
+ const asked = rebinding;
290
+ setRebinding(undefined);
291
+ if (!asked)
292
+ return;
293
+ const view = latest.horn.views.find((candidate) => candidate.id === asked.viewId);
294
+ if (!view || "ref" in view || view.binding.kind !== "select")
295
+ return;
296
+ const asked_limit = Number(asked.limit);
297
+ const binding = {
298
+ ...view.binding,
299
+ sparql: asked.sparql.trim(),
300
+ limit: Number.isFinite(asked_limit)
301
+ ? Math.min(Math.max(Math.round(asked_limit), 1), maxBindingLimit)
302
+ : view.binding.limit,
303
+ };
304
+ await edit({ kind: "rebind", viewId: asked.viewId, binding }, `Changed the query behind "${asked.title}".`);
305
+ }
306
+ /** A card is widened or narrowed by a third of the page, which is what the grid divides into evenly. */
307
+ async function resize(viewId, title, by) {
308
+ const placed = latest.horn.layout.items.find((item) => item.viewId === viewId);
309
+ if (!placed)
310
+ return;
311
+ const wanted = placed.w + by;
312
+ if (wanted === placed.w || wanted < minViewWidth || wanted > gridColumns)
313
+ return;
314
+ await edit({ kind: "resize", viewId, w: wanted }, `Made "${title}" ${by > 0 ? "wider" : "narrower"}.`);
315
+ }
316
+ // Going back is a new version, not a rewind: the history stays, and the next turn builds on what is shown.
317
+ function restore() {
318
+ const target = shown;
319
+ if (target.id === latest.id)
320
+ return;
321
+ const version = { id: nextId.current, horn: target.horn };
322
+ nextId.current += 1;
323
+ setVersions((was) => [...was, version]);
324
+ setViewing(undefined);
325
+ const summary = describeHornChange(latest.horn, target.horn);
326
+ setEntries((was) => [
327
+ ...was,
328
+ {
329
+ kind: "assistant",
330
+ content: "Restored the Horn to that earlier version.",
331
+ thinking: [],
332
+ seconds: 0,
333
+ ...(isPresent(summary.details) ? { change: { summary, versionId: version.id } } : {}),
334
+ },
335
+ ]);
336
+ }
337
+ // The Horn the page loaded, and a conversation that never happened.
338
+ function discard() {
339
+ setVersions([{ id: 0, horn: stored }]);
340
+ setViewing(undefined);
341
+ setEntries([]);
342
+ setSaveState("idle");
343
+ setSaveFailure(undefined);
344
+ }
345
+ async function save() {
346
+ const saving = latest;
347
+ const name = slug ?? kebab(saving.horn.title);
348
+ if (!isPresent(name)) {
349
+ setSaveState("failed");
350
+ setSaveFailure("Give the Horn a title before saving it: its address is made from the title.");
351
+ return;
352
+ }
353
+ setSaveState("saving");
354
+ setSaveFailure(undefined);
355
+ try {
356
+ const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/horns/${encodeURIComponent(name)}`, {
357
+ method: "PUT",
358
+ headers: { "content-type": "application/json" },
359
+ body: JSON.stringify({ horn: saving.horn, ...(isPresent(baseRevision) ? { baseRevision } : {}) }),
360
+ });
361
+ const body = await response.json().catch(() => undefined);
362
+ if (!response.ok) {
363
+ setSaveState("failed");
364
+ setSaveFailure(failureOf(body, response.status));
365
+ return;
366
+ }
367
+ setBaseRevision(isRecord(body) && typeof body["revision"] === "string" ? body["revision"] : baseRevision);
368
+ setSlug(name);
369
+ setSavedId(saving.id);
370
+ setSaveState("idle");
371
+ // The showcase leads with a picture of the Horn, and this browser has it drawn already. A saved Horn is
372
+ // saved whether or not the picture was taken, so this cannot fail the save and is never waited on.
373
+ if (isPresent(snapshot.data)) {
374
+ void sendHornScreenshot({ datastoreId, slug: name, snapshot: snapshot.data });
375
+ }
376
+ }
377
+ catch (caught) {
378
+ setSaveState("failed");
379
+ setSaveFailure(caught instanceof Error ? caught.message : String(caught));
380
+ }
381
+ }
382
+ // Where things stand, as a sentence: a badge says less, and an address like "/spend-watch" says nothing.
383
+ const status = saveState === "saving"
384
+ ? "Saving…"
385
+ : unsaved
386
+ ? "Unsaved changes"
387
+ : versions.length > 1
388
+ ? "All changes saved"
389
+ : isPresent(savedId)
390
+ ? "No changes yet"
391
+ : "Not saved yet";
392
+ const showChat = !mobile || pane === "chat";
393
+ const showPreview = !mobile || pane === "preview";
394
+ return (_jsxs("div", { "data-horn-studio": true, className: "flex h-svh w-full flex-col bg-sidebar", children: [_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) => {
395
+ setViewing(id === latest.id ? undefined : id);
396
+ if (mobile)
397
+ setPane("preview");
398
+ } })] })) : null, !mobile ? (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize the chat", onMouseDown: handleResizeStart, className: "w-1 shrink-0 cursor-col-resize rounded-full hover:bg-muted" })) : null, showPreview ? (_jsx("main", { className: cn("flex min-h-0 min-w-0 flex-1 flex-col pr-2 pb-2", mobile ? "pl-2" : "pl-1"), children: _jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-muted-border bg-base-background", children: [_jsxs("div", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-muted-border px-3", children: [_jsxs("span", { className: "flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs text-default-foreground", children: [_jsx(Eye, { size: 12, "aria-hidden": "true" }), "Preview"] }), shown.id !== latest.id ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "truncate text-xs text-muted-foreground", children: "You are looking at an earlier version" }), _jsx(Button, { variant: "primary", size: "xs", onClick: restore, children: "Restore this version" }), _jsx(Button, { variant: "ghost", size: "xs", onClick: () => setViewing(undefined), children: "Back to latest" })] })) : null, _jsxs("div", { className: "ml-auto flex min-w-0 items-center gap-2", children: [toolbar, _jsx(Button, { variant: "ghost", size: "xs", icon: RefreshCw, showLeftIcon: true, disabled: empty || snapshot.isFetching, onClick: () => void snapshot.refetch(), children: snapshot.isFetching && !empty ? "Refreshing…" : "Refresh data" })] })] }), _jsx("div", { className: cn("min-h-0 flex-1 overflow-y-auto", snapshot.isFetching && "opacity-80"), children: empty ? (_jsxs("div", { className: "flex h-full flex-col items-center justify-center gap-2 p-8 text-center", children: [_jsx(Eye, { size: 24, "aria-hidden": "true", className: "text-muted-foreground" }), _jsx("p", { className: "text-base font-medium text-default-foreground", children: "Your Horn will appear here" }), _jsx("p", { className: "max-w-sm text-sm text-muted-foreground", children: pending
399
+ ? "It is being built now. You can watch the steps on the left."
400
+ : "Tell me on the left what it should show, and it takes shape here as I build it." })] })) : snapshot.isPending ? (_jsx("div", { className: "p-6", children: _jsx(Loading, { label: "Drawing the Horn\u2026", rows: 4 }) })) : snapshot.isError ? (_jsx("div", { className: "p-6", children: _jsx(ErrorState, { title: "The Horn could not be drawn", hint: snapshot.error.message }) })) : (
401
+ // The Horn draws its own page, canvas and title included, at the width it is given.
402
+ _jsxs("div", { "data-horn-preview": true, className: "w-full", onMouseOver: (event) => {
403
+ const target = event.target;
404
+ const card = target instanceof Element ? target.closest("[data-horn-view]") : undefined;
405
+ setHovered(card?.getAttribute("data-horn-view") ?? undefined);
406
+ }, 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
407
+ ? {
408
+ viewControls: (view) => (
409
+ // Six controls on every card is furniture; they belong to the card being reached
410
+ // for. Focus keeps them reachable without a pointer.
411
+ _jsxs("span", { onMouseEnter: () => setHovered(view.id), onMouseLeave: () => setHovered(undefined), className: cn("flex items-center gap-1 transition-opacity focus-within:opacity-100", hovered === view.id ? "opacity-100" : "opacity-0"), children: [(() => {
412
+ const written = shown.horn.views.find((candidate) => candidate.id === view.id);
413
+ const state = reviewStates[view.id];
414
+ if (written === undefined || "ref" in written)
415
+ return null;
416
+ if (written.render.kind !== "tsx" || state === undefined)
417
+ return null;
418
+ return (_jsx(ReadTheCode, { viewId: view.id, title: view.title, source: written.render.source, state: state, onKeep: (change) => void edit(change, `Read and kept the code for "${view.title}".`) }));
419
+ })(), _jsx(Button, { variant: "ghost", size: "xs", "aria-label": `Move ${view.title} earlier`, onClick: () => void edit({ kind: "reorder", viewId: view.id, direction: "earlier" }, `Moved "${view.title}" earlier.`), children: _jsx(ChevronLeft, { size: 12, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "xs", "aria-label": `Move ${view.title} later`, onClick: () => void edit({ kind: "reorder", viewId: view.id, direction: "later" }, `Moved "${view.title}" later.`), children: _jsx(ChevronRight, { size: 12, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "xs", "aria-label": `Make ${view.title} narrower`, onClick: () => void resize(view.id, view.title, -widthStep), children: _jsx(Minimize2, { size: 12, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "xs", "aria-label": `Make ${view.title} wider`, onClick: () => void resize(view.id, view.title, widthStep), children: _jsx(Maximize2, { size: 12, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "xs", "aria-label": `Edit the query behind ${view.title}`, onClick: () => openBinding(view.id, view.title), children: _jsx(Database, { size: 12, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "xs", "aria-label": `Rename ${view.title}`, onClick: () => setRenaming({ viewId: view.id, title: view.title }), children: _jsx(Pencil, { size: 12, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "xs", "aria-label": `Remove ${view.title}`, onClick: () => void edit({ kind: "remove", viewId: view.id }, `Took out "${view.title}".`), children: _jsx(Trash2, { size: 12, "aria-hidden": "true" }) })] })),
420
+ }
421
+ : {}) }), _jsx(PlanRunDialog, { runs: planRuns, labels: snapshot.data.labels }), _jsx(Dialog, { open: rebinding !== undefined, onOpenChange: (open) => (open ? undefined : setRebinding(undefined)), children: _jsxs(DialogContent, { className: "sm:max-w-3xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { text: "The query behind this view" }), _jsx(DialogDescription, { children: "What the datastore is asked for, and how many rows come back. The columns it returns are what the chart draws, so a query that stops returning one is refused with the reason." })] }), _jsxs("label", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: ["Query", _jsx("textarea", { "data-query-editor": true, "aria-label": "Query", spellCheck: false, rows: 12, className: "w-full rounded-md border border-default-border bg-card p-3 font-mono text-xs text-default-foreground", value: rebinding?.sparql ?? "", onChange: (event) => setRebinding((was) => (was ? { ...was, sparql: event.target.value } : was)) })] }), _jsxs("label", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: ["Most rows to draw", _jsx(Input, { "aria-label": "Most rows to draw", className: "h-8 w-28", inputMode: "numeric", value: rebinding?.limit ?? "", onChange: (event) => setRebinding((was) => (was ? { ...was, limit: event.target.value } : was)) })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", onClick: () => setRebinding(undefined), children: "Cancel" }), _jsx(Button, { variant: "primary", onClick: () => void saveBinding(), children: "Run it" })] })] }) }), _jsx(Dialog, { open: renaming !== undefined, onOpenChange: (open) => (open ? undefined : setRenaming(undefined)), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { text: "What should this be called?" }), _jsx(DialogDescription, { children: "The name is what the card says above the chart. Nothing else about the view changes." })] }), _jsx(Input, { "aria-label": "View name", value: renaming?.title ?? "", onChange: (event) => setRenaming((was) => (was ? { ...was, title: event.target.value } : was)) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", onClick: () => setRenaming(undefined), children: "Cancel" }), _jsx(Button, { variant: "primary", onClick: () => {
422
+ const asked = renaming;
423
+ setRenaming(undefined);
424
+ if (asked && isPresent(asked.title.trim())) {
425
+ void edit({ kind: "rename", viewId: asked.viewId, title: asked.title.trim() }, `Renamed it to "${asked.title.trim()}".`);
426
+ }
427
+ }, children: "Rename it" })] })] }) })] })) })] }) })) : null] })] }));
428
+ }
@@ -0,0 +1 @@
1
+ export declare function HornViewPage(): import("react").JSX.Element;
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { isPresent } from "@manta-eu/lib/present";
5
+ import { Link, useParams, useSearchParams } from "react-router";
6
+ import { Camera, Link2, Pencil } from "lucide-react";
7
+ import { Button, Input } from "@manta-eu/react";
8
+ import { hornSnapshotSchema, variablesFromSearch } from "@manta-eu/horns/render";
9
+ import { HornSnapshotView } from "@manta-eu/horns-kit";
10
+ import { writtenHornView } from "../components/written-horn-view";
11
+ import { catalogCardView } from "../components/catalog-card-view";
12
+ import { PlanRunDialog, usePlanRuns } from "../components/plan-runs";
13
+ import { sendHornScreenshot } from "../components/rasterize";
14
+ import { DatastoreMissing, ErrorState, Loading } from "../components/states";
15
+ import { hornPath, hornsPath } from "./paths";
16
+ import { useSavedHorn } from "./saved-horn";
17
+ import { VariableBar } from "./variable-bar";
18
+ import { isRecord } from "@manta-eu/horns";
19
+ import { MantaSparqlProvider } from "../lib/manta/providers";
20
+ import { useMantaSession } from "../lib/manta/session";
21
+ import { appTheme } from "../lib/theme";
22
+ // A saved Horn as the thing itself: what a shared link opens. No chat, nothing to save — the URL is the state,
23
+ // so the link somebody is sent draws exactly the Horn they were looking at, filters and all. The plans behind a
24
+ // view are still offered here, because a person who can open the Horn is a person who can act on it.
25
+ export function HornViewPage() {
26
+ const { datastoreId, slug } = useParams();
27
+ const { datastores } = useMantaSession();
28
+ const datastore = datastores.find((candidate) => candidate.id === datastoreId);
29
+ if (!isPresent(datastore))
30
+ return _jsx(DatastoreMissing, {});
31
+ return (_jsx(MantaSparqlProvider, { datastoreId: datastore.id, children: _jsx(Viewer, { datastoreId: datastore.id, slug: slug ?? "" }) }));
32
+ }
33
+ function ShareLink() {
34
+ const [shown, setShown] = useState();
35
+ const share = () => {
36
+ const here = window.location.href;
37
+ setShown(here);
38
+ // The clipboard is a convenience; the link is on screen either way, which is what makes it shareable.
39
+ void navigator.clipboard?.writeText(here).catch(() => undefined);
40
+ };
41
+ return (_jsxs("span", { className: "flex items-center gap-2", children: [shown === undefined ? null : (_jsx(Input, { "data-share-url": true, readOnly: true, "aria-label": "Link to this Horn", className: "h-8 w-72", value: shown, onFocus: (event) => event.currentTarget.select() })), _jsxs(Button, { variant: "secondary", size: "sm", onClick: share, children: [_jsx(Link2, { size: 14, "aria-hidden": "true" }), shown === undefined ? "Share" : "Copied"] })] }));
42
+ }
43
+ /**
44
+ * Take the picture the showcase shows. The browser draws the Horn already, so this is the same drawing read
45
+ * back as a PNG — nothing is rendered anywhere else and no other browser is started to do it.
46
+ */
47
+ function UpdatePicture({ datastoreId, slug, snapshot }) {
48
+ const [state, setState] = useState("idle");
49
+ const [problem, setProblem] = useState();
50
+ if (!isPresent(snapshot))
51
+ return null;
52
+ const take = async () => {
53
+ setState("working");
54
+ setProblem(undefined);
55
+ const kept = await sendHornScreenshot({ datastoreId, slug, snapshot });
56
+ setState(kept.ok ? "kept" : "idle");
57
+ if (!kept.ok)
58
+ setProblem(kept.error);
59
+ };
60
+ return (_jsxs(_Fragment, { children: [problem === undefined ? null : (_jsx("span", { role: "alert", className: "text-xs text-muted-foreground", children: problem })), _jsxs(Button, { variant: "secondary", size: "sm", disabled: state === "working", onClick: () => void take(), children: [_jsx(Camera, { size: 14, "aria-hidden": "true" }), state === "working" ? "Taking…" : state === "kept" ? "Picture updated" : "Update picture"] })] }));
61
+ }
62
+ function Viewer({ datastoreId, slug }) {
63
+ const [search] = useSearchParams();
64
+ const saved = useSavedHorn(datastoreId, slug);
65
+ const horn = saved.data?.horn;
66
+ const variables = horn ? variablesFromSearch(horn, search.toString()) : {};
67
+ const snapshot = useQuery({
68
+ enabled: isPresent(horn),
69
+ queryKey: ["horn-view", datastoreId, slug, saved.data?.revision ?? "", variables],
70
+ queryFn: async ({ signal }) => {
71
+ const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/horns/snapshot`, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify({ horn, variables }),
75
+ signal,
76
+ });
77
+ if (!response.ok)
78
+ throw new Error(`The Horn could not be drawn (${response.status}).`);
79
+ const body = await response.json();
80
+ return hornSnapshotSchema.parse(isRecord(body) ? body["snapshot"] : undefined);
81
+ },
82
+ });
83
+ const planRuns = usePlanRuns({
84
+ datastoreId,
85
+ realm: horn?.realm ?? "hiremanta-com",
86
+ onChanged: () => void snapshot.refetch(),
87
+ });
88
+ if (saved.isError) {
89
+ return (_jsx(ErrorState, { title: "That Horn could not be opened", hint: saved.error instanceof Error ? saved.error.message : "It may have been removed." }));
90
+ }
91
+ if (!isPresent(horn))
92
+ return _jsx(Loading, { label: "Opening the Horn\u2026", rows: 3 });
93
+ 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(datastoreId), 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(datastoreId, 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 })) : (
94
+ // The Horn draws its own page, title and canvas.
95
+ _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 }), _jsx(PlanRunDialog, { runs: planRuns, labels: snapshot.data.labels })] }))] }));
96
+ }
@@ -0,0 +1 @@
1
+ export declare function HornsPage(): import("react").JSX.Element;
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isPresent } from "@manta-eu/lib/present";
3
+ import { Link, useParams } from "react-router";
4
+ import { ChevronRight, Columns3, Plus } from "lucide-react";
5
+ import { Button } from "@manta-eu/react";
6
+ import { PageHeader } from "../components/page-header";
7
+ import { DatastoreMissing, EmptyState, ErrorState, Loading } from "../components/states";
8
+ import { hornViewPath, newHornPath } from "./paths";
9
+ import { useQuery } from "@tanstack/react-query";
10
+ import { isRecord } from "@manta-eu/horns";
11
+ import { MantaSparqlProvider } from "../lib/manta/providers";
12
+ import { useMantaSession } from "../lib/manta/session";
13
+ export function HornsPage() {
14
+ const { datastoreId } = useParams();
15
+ const { datastores } = useMantaSession();
16
+ const datastore = datastores.find((candidate) => candidate.id === datastoreId);
17
+ if (!isPresent(datastore))
18
+ return _jsx(DatastoreMissing, {});
19
+ return (_jsx(MantaSparqlProvider, { datastoreId: datastore.id, children: _jsx(HornsIn, { datastoreId: datastore.id }) }));
20
+ }
21
+ function HornsIn({ datastoreId }) {
22
+ const horns = useQuery({
23
+ queryKey: ["horns", datastoreId],
24
+ enabled: isPresent(datastoreId),
25
+ queryFn: async ({ signal }) => {
26
+ const response = await fetch(`/api/d/${encodeURIComponent(datastoreId)}/horns`, { signal });
27
+ if (!response.ok)
28
+ throw new Error(`The datastore answered ${response.status}.`);
29
+ const body = await response.json();
30
+ const listed = isRecord(body) && Array.isArray(body["horns"]) ? body["horns"] : [];
31
+ return listed.flatMap((entry) => isRecord(entry) && typeof entry["slug"] === "string"
32
+ ? [
33
+ {
34
+ slug: entry["slug"],
35
+ title: typeof entry["title"] === "string" ? entry["title"] : entry["slug"],
36
+ number: typeof entry["number"] === "number" ? entry["number"] : 1,
37
+ },
38
+ ]
39
+ : []);
40
+ },
41
+ });
42
+ return (_jsxs("div", { className: "flex w-full flex-col gap-6", children: [_jsx(PageHeader, { title: "Horns", description: "Dashboards and apps made from your data. Open one to look at it or change it, or describe a new one.", actions: _jsx(Button, { variant: "primary", asChild: true, children: _jsxs(Link, { to: newHornPath(datastoreId), children: [_jsx(Plus, { size: 16, "aria-hidden": "true" }), "New Horn"] }) }) }), horns.isPending ? (_jsx(Loading, { label: "Reading this datastore's Horns\u2026" })) : horns.isError ? (_jsx(ErrorState, { title: "Your Horns could not be loaded", hint: "Try again in a moment. If it keeps happening, the data service may be down." })) : !isPresent(horns.data) ? (_jsx(EmptyState, { title: "No Horns yet", hint: "Describe what you want to see, in your own words, and it is built for you from this datastore's data.", icon: _jsx(Columns3, { size: 20 }), action: _jsx(Button, { variant: "primary", size: "sm", asChild: true, children: _jsxs(Link, { to: newHornPath(datastoreId), children: [_jsx(Plus, { size: 14, "aria-hidden": "true" }), "Make your first Horn"] }) }) })) : (_jsx("ul", { "data-horns": true, className: "grid gap-3 sm:grid-cols-2 md:grid-cols-3", children: horns.data.map((horn) => (_jsx("li", { children: _jsxs(Link, { to: hornViewPath(datastoreId, horn.slug), className: "flex items-center gap-3 rounded-lg border border-default-border bg-card px-4 py-3 transition-colors hover:bg-card/70", children: [_jsx("span", { className: "grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(Columns3, { size: 16 }) }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate text-sm font-medium text-default-foreground", children: horn.title }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: horn.number === 1 ? "Saved once" : `Saved ${horn.number} times` })] }), _jsx(ChevronRight, { size: 16, "aria-hidden": "true", className: "shrink-0 text-muted-foreground" })] }) }, horn.slug))) }))] }));
43
+ }
@@ -0,0 +1 @@
1
+ export declare function NewHornPage(): import("react").JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { isPresent } from "@manta-eu/lib/present";
4
+ import { useParams } from "react-router";
5
+ import { standaloneHorn } from "@manta-eu/horns/shapes";
6
+ import { DatastoreMissing } from "../components/states";
7
+ import { hornsPath } from "./paths";
8
+ import { HornStudio } from "./horn-studio";
9
+ import { MantaSparqlProvider } from "../lib/manta/providers";
10
+ import { useMantaSession } from "../lib/manta/session";
11
+ import { shapesRealm } from "../lib/shapes/catalog";
12
+ // The way in: a Horn from nothing. It opens straight into the Studio with an empty document, the way a new
13
+ // Lovable project opens on its composer: the first thing typed is what it should show, the model names it as
14
+ // it builds it, and the first save makes its address from that name.
15
+ const starterPrompts = [
16
+ "Which campaigns are spending the most, and where is the waste?",
17
+ "Show me the accounts with the most pipeline movement this month",
18
+ "What happened in search last week that I should look at?",
19
+ ];
20
+ export function NewHornPage() {
21
+ const { datastoreId } = useParams();
22
+ const { datastores } = useMantaSession();
23
+ const datastore = datastores.find((candidate) => candidate.id === datastoreId);
24
+ if (!isPresent(datastore)) {
25
+ return (_jsx("main", { className: "flex h-svh w-full items-center justify-center bg-sidebar p-6", children: _jsx(DatastoreMissing, {}) }));
26
+ }
27
+ return (_jsx(MantaSparqlProvider, { datastoreId: datastore.id, children: _jsx(NewHorn, { datastoreId: datastore.id }) }));
28
+ }
29
+ function NewHorn({ datastoreId }) {
30
+ // Made once: the route owns the IRI and the revision on save, and this is a document that has never been
31
+ // stored. A new render must not make a new one underneath the draft.
32
+ const [horn] = useState(() => standaloneHorn({ id: "untitled", title: "Untitled Horn", views: [], realm: shapesRealm(), datastoreId }));
33
+ return (_jsx(HornStudio, { datastoreId: datastoreId, stored: horn, backTo: hornsPath(datastoreId), 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 }));
34
+ }