mockpit 1.2.0 → 2.0.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 (59) hide show
  1. package/README.md +69 -48
  2. package/bin/demoData.js +3 -3
  3. package/bin/mockpit.js +426 -51
  4. package/dist/mcp/server.js +45 -7
  5. package/dist/server/apiViews.d.ts +407 -21
  6. package/dist/server/apiViews.js +110 -11
  7. package/dist/server/app.d.ts +5 -1
  8. package/dist/server/app.js +1454 -192
  9. package/dist/server/codeWorker.js +2 -1
  10. package/dist/server/decisions.d.ts +23 -0
  11. package/dist/server/decisions.js +100 -0
  12. package/dist/server/designGuide.d.ts +6 -2
  13. package/dist/server/designGuide.js +96 -27
  14. package/dist/server/embeds.d.ts +26 -0
  15. package/dist/server/embeds.js +144 -0
  16. package/dist/server/eventLog.js +2 -1
  17. package/dist/server/feedbackBatch.d.ts +15 -4
  18. package/dist/server/feedbackBatch.js +31 -7
  19. package/dist/server/fills.d.ts +41 -0
  20. package/dist/server/fills.js +328 -0
  21. package/dist/server/guidance.d.ts +23 -0
  22. package/dist/server/guidance.js +71 -0
  23. package/dist/server/index.js +25 -2
  24. package/dist/server/knobs.d.ts +6 -2
  25. package/dist/server/knobs.js +11 -1
  26. package/dist/server/mcpHttp.d.ts +6 -0
  27. package/dist/server/mcpHttp.js +35 -0
  28. package/dist/server/mcpSpec.d.ts +14 -13
  29. package/dist/server/mcpSpec.js +133 -18
  30. package/dist/server/postSurfaces.js +6 -12
  31. package/dist/server/richRender.js +3 -1
  32. package/dist/server/run.js +10 -1
  33. package/dist/server/runApi.d.ts +3 -3
  34. package/dist/server/runApi.js +18 -13
  35. package/dist/server/sqlStore.d.ts +97 -5
  36. package/dist/server/sqlStore.js +367 -27
  37. package/dist/server/surfacePage.d.ts +6 -2
  38. package/dist/server/surfacePage.js +142 -10
  39. package/dist/server/themes.d.ts +2 -0
  40. package/dist/server/themes.js +23 -3
  41. package/dist/server/types.d.ts +107 -16
  42. package/dist/server/types.js +73 -10
  43. package/dist/server/viewerRequest.d.ts +2 -0
  44. package/dist/server/viewerRequest.js +14 -0
  45. package/extensions/mockpit.js +179 -16
  46. package/guide/packs/design-craft.md +35 -0
  47. package/guide/topics/asks.md +82 -31
  48. package/guide/topics/feedback.md +151 -43
  49. package/guide/topics/html.md +8 -10
  50. package/guide/topics/http.md +8 -4
  51. package/guide/topics/knobs.md +4 -3
  52. package/guide/topics/scripts.md +2 -2
  53. package/guide/topics/surfaces.md +45 -11
  54. package/package.json +3 -15
  55. package/skills/launch-video/SKILL.md +2 -2
  56. package/skills/mockpit/SKILL.md +23 -7
  57. package/viewer/dist/index.html +9 -8
  58. package/dist/server/slots.d.ts +0 -14
  59. package/dist/server/slots.js +0 -71
@@ -5,27 +5,31 @@ import { streamSSE } from "hono/streaming";
5
5
  import { ICON_SIZES, ICON_SVG, themeColor, webManifest } from "./appIcon.js";
6
6
  import { ICON_PNG_BASE64 } from "./appIconPng.js";
7
7
  import { decodeBase64 } from "./base64.js";
8
- import { mockDetailView, mockSummaryView, sessionRowView, surfaceRef } from "./apiViews.js";
8
+ import { mockDetailView, treeNodeView, mockSummaryView, placeOf, sessionRowView, surfaceRef, } from "./apiViews.js";
9
9
  import { askNudges } from "./askNudges.js";
10
10
  import { EventBus } from "./events.js";
11
11
  import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
12
12
  import { buildFeedbackBatches } from "./feedbackBatch.js";
13
+ import { guidanceSections, MAX_RULES_CHARS, parseRulesFile, RULES_SETTING, USER_RULES_SETTING, } from "./guidance.js";
13
14
  import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
14
15
  import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
15
16
  import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
17
+ import { chosenFills, decisionsFor, settledValues } from "./decisions.js";
18
+ import { composeFills, slotsInSurfaces, spliceFills } from "./fills.js";
16
19
  import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
17
20
  import { diffParts, partsInSurfaces, spliceParts } from "./parts.js";
18
21
  import { postToMarkdown } from "./postMarkdown.js";
19
22
  import { addHook, addSubscription, isPushSubscription, listHooks, notify, removeHook, vapidKeys, } from "./push.js";
20
- import { expandSlots, parseSlotTags } from "./slots.js";
23
+ import { embedsKey, expandHtml, hasEmbedTags, parseEmbedTags, } from "./embeds.js";
21
24
  import { registerMcp } from "./mcpHttp.js";
22
25
  import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
23
26
  import { logMiddleware, writeLog } from "./eventLog.js";
24
27
  import { RUN_API } from "./runApi.js";
25
- import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
28
+ import { escapeHtml, renderHtmlPage, renderMermaidPage, knobAttributes, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
26
29
  import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
27
- import { builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, REACTIONS, projectFromCwd, reservedAgent, slugify, surfacesByteLength, } from "./types.js";
30
+ import { askAnswered, builtinAsk, builtinAskIds, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, inUseVersion, isLive, isMockKind, momentOf, NORMAL_SITUATION, optionState, newId, openAsks, openCount, OTHER_ID, REACTIONS, projectFromCwd, AREA_ORDER_SETTING, stripNul, postAtVersion, versionAt, variantStatus, reservedAgent, sameScreen, situationKey, slugify, surfacesByteLength, } from "./types.js";
28
31
  import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
32
+ import { isViewerRequest } from "./viewerRequest.js";
29
33
  const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
30
34
  // A held long-poll (watch, the viewer) re-arms rather than pinning a socket
31
35
  // for good; proxies and serverless hosts drop longer requests anyway.
@@ -165,11 +169,34 @@ const fail = (status, error, extra = {}) => ({
165
169
  status,
166
170
  body: { error, ...extra },
167
171
  });
172
+ const MAX_NOTE = 400;
173
+ const MAX_SOURCE = 1024;
174
+ const MAX_WIDTHS = 4;
175
+ const MIN_WIDTH = 280;
176
+ const MAX_WIDTH = 2560;
168
177
  const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
169
178
  const MAX_LABEL = 120;
179
+ const MAX_AREAS = 50;
180
+ const MAX_FLOWS = 50;
181
+ const MAX_FLOW_STEPS = 100;
170
182
  const MAX_ASKS = 20;
171
183
  const MAX_OPTIONS = 20;
172
184
  const MAX_DRAFT_COMMENTS = 50;
185
+ // A title that names a step of the agent's process instead of what the mock
186
+ // is: it reads as noise on Home, where titles are seen out of context.
187
+ const PROCESS_TITLE = /^\s*(round\s*\d+|flow\s*\d+\s*[·:\-–—]|v\d+\b|prototype\b)/i;
188
+ // Once, on a variant's first version: a canvas page runs live in every
189
+ // overview card, and a browser holds only about 16 WebGL contexts.
190
+ const posterNudges = (surfaces) => surfaces.some((s) => s.kind === "html" && !s.poster && /<canvas[\s>]/i.test(s.html))
191
+ ? [
192
+ "this page draws on a <canvas> and has no poster, so Studio and Home run it live; send a still of it as poster (upload the image, or CLI --poster shot.png) with each version",
193
+ ]
194
+ : [];
195
+ const titleNudges = (title) => title && PROCESS_TITLE.test(title)
196
+ ? [
197
+ `title "${title}" names your process; title the mock by what it is ("Hold menu"), as states are named by what the user sees`,
198
+ ]
199
+ : [];
173
200
  const APP_FILES = new Set([
174
201
  "/manifest.webmanifest",
175
202
  "/icon.svg",
@@ -177,7 +204,7 @@ const APP_FILES = new Set([
177
204
  ...ICON_SIZES.map((s) => `/icon-${s}.png`),
178
205
  ]);
179
206
  const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
180
- export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
207
+ export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {}, userRules, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, renderRev, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
181
208
  const app = new Hono();
182
209
  // The scripts topic carries the run API from runApi.ts, its one source.
183
210
  if (topics.scripts)
@@ -195,7 +222,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
195
222
  // warm entry, it can never be served to the wrong caller (renderKey includes it).
196
223
  let lastOrigin = null;
197
224
  app.use("*", (c, next) => {
198
- lastOrigin = new URL(c.req.url).origin;
225
+ lastOrigin = requestOrigin(c.req.raw);
199
226
  c.header("Referrer-Policy", "no-referrer");
200
227
  return next();
201
228
  });
@@ -285,7 +312,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
285
312
  function surfaceDocHeaders(c, immutable) {
286
313
  c.header("X-Content-Type-Options", "nosniff");
287
314
  c.header("Content-Security-Policy", "sandbox allow-scripts");
288
- c.header("Cache-Control", immutable ? "public, max-age=31536000, immutable" : "private, no-cache");
315
+ const current = !renderRev || c.req.query("r") === renderRev;
316
+ c.header("Cache-Control", immutable && current ? "public, max-age=31536000, immutable" : "private, no-cache");
289
317
  }
290
318
  function renderCacheHit(key) {
291
319
  const hit = renderCache.get(key);
@@ -329,6 +357,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
329
357
  return withLeading.slice(0, end);
330
358
  };
331
359
  const requestBasePath = (request) => normalizeBasePath(typeof basePath === "function" ? basePath(request) : basePath);
360
+ // Behind a TLS-terminating proxy (tailscale serve, a tunnel) the request
361
+ // reaches us as http while the browser is on https. A surface bakes this
362
+ // origin into its script URLs and CSP, and an http script inside an https
363
+ // page is blocked as mixed content: the frame then never runs its bridge, so
364
+ // live knobs and part reports silently stop. Trusting the header is safe
365
+ // here: it only shapes the caller's own response.
366
+ const requestOrigin = (request) => {
367
+ const url = new URL(request.url);
368
+ const proto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
369
+ if (proto === "https" || proto === "http")
370
+ url.protocol = `${proto}:`;
371
+ return url.origin;
372
+ };
332
373
  // Cached, fail-silent update lookup: being offline or rate-limited must
333
374
  // cost nothing but the absence of the notice. Failures are cached too, so
334
375
  // a dead network doesn't retry on every viewer load.
@@ -511,13 +552,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
511
552
  async function briefFor(project) {
512
553
  try {
513
554
  const name = project ?? (await store.listProjects())[0]?.name ?? DEFAULT_PROJECT;
514
- return renderBriefGuide(await designFor(name));
555
+ return renderBriefGuide(await designFor(name), await guidanceFor(name));
515
556
  }
516
557
  catch (err) {
517
558
  console.warn("[mockpit] project design unavailable for the brief", err);
518
559
  return renderBriefGuide(null);
519
560
  }
520
561
  }
562
+ // The user's file wins over the stored copy when the host can read it: the
563
+ // copy exists for hosts that can't.
564
+ async function userRulesText() {
565
+ const file = userRules ? await userRules().catch(() => null) : null;
566
+ return file ?? (await store.getSetting(USER_RULES_SETTING));
567
+ }
568
+ async function guidanceFor(project) {
569
+ return guidanceSections({
570
+ packs,
571
+ user: await userRulesText(),
572
+ project: await store.getSetting(`${RULES_SETTING}:${project}`),
573
+ });
574
+ }
575
+ // A rules file as sent by `mockpit rules`: stored whole, "" clears it. The
576
+ // answer says what the brief will make of it, so a mistyped pack name shows
577
+ // at once rather than as a quietly missing layer.
578
+ async function saveRules(key, body) {
579
+ if (!body || typeof body !== "object" || typeof body.text !== "string") {
580
+ return fail(400, 'provide "text", the rules file ("" clears it)');
581
+ }
582
+ const text = stripNul(body.text).trim();
583
+ if (text.length > MAX_RULES_CHARS) {
584
+ return fail(400, `a rules file is at most ${MAX_RULES_CHARS} characters`);
585
+ }
586
+ await store.setSetting(key, text);
587
+ const parsed = parseRulesFile(text);
588
+ const unknown = parsed.packs.filter((p) => !(p in packs));
589
+ return ok({ packs: parsed.packs, unknown, rules: parsed.body.length > 0, cleared: !text });
590
+ }
521
591
  // Push + webhooks. Detached on purpose: a dead push endpoint must never fail
522
592
  // (or slow) the write that triggered it.
523
593
  function fireNotify(payload) {
@@ -588,7 +658,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
588
658
  variants: posts.map(variantLabel),
589
659
  });
590
660
  }
591
- const live = candidates.filter((p) => p.status !== "archived");
661
+ const live = candidates.filter((p) => isLive(p.status));
592
662
  if (live.length === 1 && !variant)
593
663
  return live[0];
594
664
  return fail(400, `${mock.slug} has several variants; pass ${variant ? "state" : "variant"}`, {
@@ -675,6 +745,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
675
745
  code: failure.code,
676
746
  issues: failure.issues,
677
747
  });
748
+ // A poster is a still of the html beside it. Carried over unchanged onto
749
+ // changed html (a parts or fills splice, a fork with edits) it would show an
750
+ // old page, so it goes unless the write brings one; `poster` names the still
751
+ // of the page this write produces.
752
+ async function withPoster(surfaces, before, poster) {
753
+ const given = poster === undefined ? undefined : str(poster, MAX_LABEL);
754
+ if (poster !== undefined && !given) {
755
+ return fail(400, '"poster" must be the asset id of an uploaded image');
756
+ }
757
+ const pagesBefore = (before ?? []).filter((s) => s.kind === "html");
758
+ let ordinal = 0;
759
+ const out = surfaces.map((s) => {
760
+ if (s.kind !== "html")
761
+ return s;
762
+ const old = (s.id !== undefined && pagesBefore.find((b) => b.id === s.id)) || pagesBefore[ordinal];
763
+ let next = s;
764
+ if (s.poster && old?.kind === "html" && old.html !== s.html && old.poster === s.poster) {
765
+ const { poster: _stale, ...rest } = s;
766
+ next = rest;
767
+ }
768
+ if (ordinal === 0 && given)
769
+ next = { ...next, poster: given };
770
+ ordinal++;
771
+ return next;
772
+ });
773
+ if (given && ordinal === 0)
774
+ return fail(400, '"poster" needs an html surface to be the still of');
775
+ for (const s of out) {
776
+ if (s.kind !== "html" || !s.poster)
777
+ continue;
778
+ const asset = await store.getAsset(s.poster);
779
+ if (!asset)
780
+ return fail(404, `poster "${s.poster}" is not an uploaded asset; upload the image first`);
781
+ if (!asset.contentType.startsWith("image/")) {
782
+ return fail(400, `poster "${s.poster}" is ${asset.contentType}, not an image`);
783
+ }
784
+ }
785
+ return out;
786
+ }
678
787
  function checkSurfaces(surfaces) {
679
788
  if (surfaces.length === 0)
680
789
  return fail(400, "a variant needs at least one surface");
@@ -683,57 +792,138 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
683
792
  }
684
793
  return null;
685
794
  }
686
- // A page's slot list, with each missing version pinned to the referenced
687
- // component's current one (snapshot semantics).
688
- async function pageSlots(project, surfaces) {
689
- const html = surfaces.find((s) => s.kind === "html");
690
- if (!html || html.kind !== "html")
691
- return [];
692
- const slots = [];
693
- for (const tag of parseSlotTags(html.html)) {
694
- let version = tag.version;
695
- if (version == null)
696
- version = (await slotTarget(project, tag.slug, tag.variant))?.version ?? null;
697
- if (version != null)
698
- slots.push({ slug: tag.slug, variant: tag.variant, version });
699
- }
700
- return slots;
701
- }
702
- // A slot names a component mock by slug; it renders that mock's first state.
703
- async function slotTarget(project, slug, variant) {
795
+ // The component post an embed shows: the named variant of its first state,
796
+ // else the accepted one there, else "default", else the first live one.
797
+ async function embedTarget(project, slug, variant) {
704
798
  const mock = await store.findMock(project, slug);
705
799
  if (!mock)
706
800
  return null;
707
- return store.findPost(mock.id, mock.states[0] ?? null, variant);
708
- }
709
- // Inline each `<mockpit-slot>` with the referenced variant version's first
710
- // html surface body. The stored `slots` list (resolved at publish) wins over
711
- // a bare tag, so a page keeps rendering the versions it was composed from.
712
- async function expandPageHtml(project, post, html) {
713
- const pinned = new Map(post.slots.map((s) => [`${s.slug}::${s.variant}`, s.version]));
714
- const bodies = new Map();
715
- for (const tag of parseSlotTags(html)) {
716
- const key = `${tag.slug}::${tag.variant}`;
717
- const version = tag.version ?? pinned.get(key) ?? null;
718
- const cacheKey = `${key}::${version}`;
719
- if (bodies.has(cacheKey))
801
+ const state = mock.states[0] ?? null;
802
+ if (variant)
803
+ return store.findPost(mock.id, state, variant);
804
+ const inState = (await store.listPosts({ mockId: mock.id })).filter((p) => p.state === state);
805
+ return (inState.find((p) => p.status === "accepted") ??
806
+ inState.find((p) => p.variant === DEFAULT_VARIANT && isLive(p.status)) ??
807
+ inState.find((p) => isLive(p.status)) ??
808
+ inState[0] ??
809
+ null);
810
+ }
811
+ // Resolves tags to bodies: the tag's version if it pins one, else a peek
812
+ // (`overrides`, slug → version) the viewer asked for, else the latest. The
813
+ // body shows the component as decided: its slots composed to the fills its
814
+ // settled values chose, its other settled values on the wrapper.
815
+ function embedResolver(project, overrides = {}) {
816
+ return async (tag) => {
817
+ const post = await embedTarget(project, tag.slug, tag.variant);
818
+ if (!post)
819
+ return null;
820
+ const version = tag.version ?? overrides[tag.slug] ?? inUseVersion(post);
821
+ const surfaces = version === post.version
822
+ ? post.surfaces
823
+ : post.history.find((h) => h.version === version)?.surfaces;
824
+ const body = surfaces?.find((x) => x.kind === "html");
825
+ if (!surfaces || !body || body.kind !== "html")
826
+ return null;
827
+ const mock = await store.getMock(post.mock);
828
+ const settled = mock
829
+ ? settledValues(await replyPayloads(mock.id), mock, await store.listPosts({ mockId: mock.id }))
830
+ : {};
831
+ const slots = slotsInSurfaces(surfaces);
832
+ const fills = new Set(slots.map((sl) => sl.part));
833
+ const rest = Object.fromEntries(Object.entries(settled).filter(([p]) => !fills.has(p)));
834
+ return {
835
+ variant: post.variant,
836
+ version,
837
+ html: slots.length ? composeFills(body.html, chosenFills(slots, settled)) : body.html,
838
+ attrs: knobAttributes(rest).attrs,
839
+ ...(Object.keys(settled).length ? { stamp: JSON.stringify(settled) } : {}),
840
+ };
841
+ };
842
+ }
843
+ // A surface's html with its embeds expanded, and what they resolved to.
844
+ async function composedHtml(project, slug, html, overrides) {
845
+ if (!hasEmbedTags(html))
846
+ return { html, used: [], stamps: [], cycle: null };
847
+ return expandHtml(html, embedResolver(project, overrides), [slug]);
848
+ }
849
+ // The component versions a publish renders with, recorded on the version;
850
+ // an embed chain that loops back to this mock is refused.
851
+ async function publishEmbeds(project, slug, surfaces) {
852
+ const html = surfaces.find((x) => x.kind === "html");
853
+ if (!html || html.kind !== "html")
854
+ return [];
855
+ const { used, cycle } = await composedHtml(project, slug, html.html);
856
+ if (cycle) {
857
+ return fail(400, `embeds loop back on themselves: ${cycle.join(" → ")}`, { cycle });
858
+ }
859
+ return used;
860
+ }
861
+ // Being embedded is what makes a mock a component, so a sketch another mock
862
+ // embeds becomes one; a declared page stays a page.
863
+ async function promoteEmbedded(project, embeds) {
864
+ for (const slug of new Set(embeds.map((e) => e.slug))) {
865
+ const m = await store.findMock(project, slug);
866
+ if (m?.kind !== "sketch")
720
867
  continue;
721
- const target = await slotTarget(project, tag.slug, tag.variant);
722
- if (!target) {
723
- bodies.set(cacheKey, null);
868
+ const updated = await store.updateMock(m.id, { kind: "component" });
869
+ if (updated)
870
+ bus.broadcast({ type: "mock-updated", id: updated.id, project });
871
+ }
872
+ }
873
+ // What the variants of a mock show through their embeds now: per post, the
874
+ // resolved list (nested included) with each component's versions, and the
875
+ // key that changes when any of them does.
876
+ async function embedViews(mock, posts) {
877
+ const out = new Map();
878
+ for (const post of posts) {
879
+ const html = post.surfaces.find((x) => x.kind === "html");
880
+ if (!html || html.kind !== "html" || !hasEmbedTags(html.html))
724
881
  continue;
882
+ const composed = await composedHtml(mock.project, mock.slug, html.html);
883
+ const { used } = composed;
884
+ const list = [];
885
+ for (const e of used) {
886
+ const target = await embedTarget(mock.project, e.slug, e.variant);
887
+ const versions = target
888
+ ? [target.version, ...target.history.map((h) => h.version)].sort((a, b) => b - a)
889
+ : [e.version];
890
+ list.push({ ...e, latest: target?.version ?? e.version, versions });
725
891
  }
726
- const surfaces = version == null || version === target.version
727
- ? target.surfaces
728
- : (target.history.find((h) => h.version === version)?.surfaces ?? null);
729
- const body = surfaces?.find((s) => s.kind === "html");
730
- bodies.set(cacheKey, body && body.kind === "html" ? body.html : null);
892
+ out.set(post.id, { key: embedsKey(composed), list });
731
893
  }
732
- return expandSlots(html, ({ slug, variant, version }) => {
733
- const key = `${slug}::${variant}`;
734
- const pinnedVersion = version ?? pinned.get(key) ?? null;
735
- return bodies.get(`${key}::${pinnedVersion}`) ?? null;
736
- });
894
+ return out;
895
+ }
896
+ // The mocks whose html embeds this slug, directly or through other
897
+ // components: they render differently once it changes.
898
+ async function embeddersOf(project, slug) {
899
+ const mocks = await store.listMocks(project);
900
+ const tagsOf = new Map();
901
+ for (const m of mocks) {
902
+ const slugs = new Set();
903
+ for (const p of await store.listPosts({ mockId: m.id })) {
904
+ for (const x of p.surfaces) {
905
+ if (x.kind === "html")
906
+ for (const t of parseEmbedTags(x.html))
907
+ slugs.add(t.slug);
908
+ }
909
+ }
910
+ tagsOf.set(m.id, slugs);
911
+ }
912
+ const reached = new Set([slug]);
913
+ const out = [];
914
+ for (let grew = true; grew;) {
915
+ grew = false;
916
+ for (const m of mocks) {
917
+ if (reached.has(m.slug))
918
+ continue;
919
+ if ([...(tagsOf.get(m.id) ?? [])].some((s) => reached.has(s))) {
920
+ reached.add(m.slug);
921
+ out.push(m);
922
+ grew = true;
923
+ }
924
+ }
925
+ }
926
+ return out;
737
927
  }
738
928
  // Knobs with a handful of discrete choices are usually decisions dressed as
739
929
  // controls: the user can't compare options they only see one at a time.
@@ -752,7 +942,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
752
942
  // chat instead. The nudge carries a ready ask: mock-wide when every state has
753
943
  // the same variant names, else for this state.
754
944
  function unboundVariants(mock, posts, state) {
755
- const live = (s) => posts.filter((p) => p.state === s && p.status !== "archived").map((p) => p.variant);
945
+ const live = (s) => posts.filter((p) => p.state === s && isLive(p.status)).map((p) => p.variant);
756
946
  const names = live(state);
757
947
  if (names.length < 2)
758
948
  return null;
@@ -770,7 +960,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
770
960
  const states = mock.states.length ? mock.states : [null];
771
961
  const wide = states.every((s) => key(live(s)) === key(names));
772
962
  // An ask that already picks between some of these variants is the question:
773
- // a second, generic "Which one?" would make the user choose the look twice.
963
+ // a second, generic "Which variant?" would make the user choose twice.
774
964
  // A mock-wide one only fits while every state has the same variants; else a
775
965
  // variant only some states have would leave the others no real choice.
776
966
  const existing = mock.asks.find((a) => applies(a) && a.options.some((o) => o.variant) && (a.scope === "state" || wide));
@@ -794,7 +984,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
794
984
  ],
795
985
  };
796
986
  return {
797
- nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing ask "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
987
+ nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing question "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
798
988
  ask,
799
989
  };
800
990
  }
@@ -802,15 +992,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
802
992
  // Not the built-in's reserved id: a stored ask under it would collide with the
803
993
  // built-in the viewer synthesizes once a new variant goes unbound. Nor any id
804
994
  // already taken, which sending this would silently replace.
805
- const base = wide ? "look" : `look-${slugify(state ?? "")}`;
995
+ const base = wide ? "choice" : `choice-${slugify(state ?? "")}`;
806
996
  let id = base;
807
997
  for (let n = 2; mock.asks.some((a) => a.id === id); n++)
808
998
  id = `${base}-${n}`;
809
999
  const ask = wide
810
- ? { id, text: "Which one?", scope: "mock", options }
811
- : { id, text: "Which one?", scope: "state", state, options };
1000
+ ? { id, text: "Which variant?", scope: "mock", options }
1001
+ : { id, text: "Which variant?", scope: "state", state, options };
812
1002
  return {
813
- nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk. Never ask in chat.`,
1003
+ nudge: `${where} has ${names.length} variants and no question binds them: call ask with suggestedAsk. Never ask in chat.`,
814
1004
  ask,
815
1005
  };
816
1006
  }
@@ -821,7 +1011,27 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
821
1011
  const posts = await store.listPosts({ mockId: mock.id });
822
1012
  const detail = mockDetailView(mock, posts);
823
1013
  const unbound = unboundVariants(mock, posts, post.state);
824
- const nudges = [...(extra.nudges ?? []), ...(unbound ? [unbound.nudge] : [])];
1014
+ // A part with fills that no ask sets is a decision nobody was asked; hand over the ask.
1015
+ const slots = slotsInSurfaces(post.surfaces);
1016
+ const setPaths = new Set(mock.asks.flatMap((a) => a.options.flatMap((o) => Object.keys(o.set ?? {}))));
1017
+ const slotAsks = slots
1018
+ .filter((sl) => !setPaths.has(sl.part))
1019
+ .map((sl) => ({
1020
+ id: sl.part,
1021
+ text: `Which ${sl.label ?? sl.part}?`,
1022
+ scope: "part",
1023
+ part: sl.part,
1024
+ options: sl.fills.map((f) => ({ label: f.label ?? f.name, set: { [sl.part]: f.name } })),
1025
+ }));
1026
+ const nudges = [
1027
+ ...(extra.nudges ?? []),
1028
+ ...(unbound ? [unbound.nudge] : []),
1029
+ ...(slotAsks.length
1030
+ ? [
1031
+ `${slotAsks.map((a) => `part "${a.part}"`).join(", ")} ${slotAsks.length === 1 ? "has" : "have"} fills and no question sets ${slotAsks.length === 1 ? "it" : "them"}: send suggestedFillAsks with ask (reword the text in the user's terms)`,
1032
+ ]
1033
+ : []),
1034
+ ];
825
1035
  let partChanges;
826
1036
  if (extra.previous) {
827
1037
  partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
@@ -835,6 +1045,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
835
1045
  slug: mock.slug,
836
1046
  title: mock.title,
837
1047
  kind: mock.kind,
1048
+ ...placeOf(mock),
838
1049
  states: mock.states,
839
1050
  },
840
1051
  post: {
@@ -842,19 +1053,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
842
1053
  state: post.state,
843
1054
  variant: post.variant,
844
1055
  version: post.version,
845
- status: post.status,
1056
+ status: variantStatus(post.status),
846
1057
  surfaces: post.surfaces.map(surfaceRef),
847
1058
  },
848
1059
  sessionId: post.sessionId,
849
1060
  url: mockUrl(ctx.base, mock, post),
850
1061
  parts: detail.parts,
1062
+ ...(slots.length ? { fills: slots } : {}),
851
1063
  ...(extra.applied ? { applied: extra.applied } : {}),
852
1064
  ...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
853
1065
  ? { partChanges }
854
1066
  : {}),
855
1067
  ...(nudges.length ? { nudges } : {}),
856
1068
  ...(unbound ? { suggestedAsk: unbound.ask } : {}),
1069
+ ...(slotAsks.length ? { suggestedFillAsks: slotAsks } : {}),
857
1070
  ...(warnings.length ? { warnings } : {}),
1071
+ ...(extra.flow ? { flow: extra.flow } : {}),
858
1072
  feedback,
859
1073
  }, extra.status ?? 200);
860
1074
  }
@@ -868,26 +1082,46 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
868
1082
  by,
869
1083
  });
870
1084
  warmPost(post, mock);
1085
+ void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
1086
+ }
1087
+ // A component's new version changes every mock that shows it: their views
1088
+ // carry a new embeds key, so open viewers refetch and reload those frames.
1089
+ async function announceEmbedders(mock) {
1090
+ for (const m of await embeddersOf(mock.project, mock.slug)) {
1091
+ bus.broadcast({ type: "mock-updated", id: m.id, project: m.project });
1092
+ }
871
1093
  }
872
1094
  // The one write: publish one variant of one state of a mock. An existing
873
1095
  // (mock, state, variant) becomes a new version; anything else is created.
874
- // Exactly one body: `html`, the full ordered `surfaces` list, or `parts`
875
- // spliced into the variant's latest (or `from`) version.
1096
+ // Exactly one body: `html`, the full ordered `surfaces` list, or `parts` or
1097
+ // `fills` spliced into the variant's latest (or `from`) version.
876
1098
  async function publishFlow(body, ctx) {
877
1099
  if (!body || typeof body !== "object")
878
1100
  return fail(400, "invalid JSON body");
879
1101
  const slugIn = str(body.mock, MAX_TITLE);
880
1102
  if (!slugIn)
881
1103
  return fail(400, 'provide "mock": the mock slug, e.g. "writer"');
882
- const given = ["html", "surfaces", "parts"].filter((k) => body[k] !== undefined);
883
- if (given.length === 0)
884
- return fail(400, 'provide "html", "surfaces" or "parts"');
1104
+ const given = ["html", "surfaces", "parts", "fills"].filter((k) => body[k] !== undefined);
1105
+ const forkIn = forkArg(body.fork);
1106
+ if (isResult(forkIn))
1107
+ return forkIn;
1108
+ const flowIn = flowArg(body);
1109
+ if (isResult(flowIn))
1110
+ return flowIn;
1111
+ // `flow` alone places an existing screen in a flow without a new version.
1112
+ const flowOnly = given.length === 0 && !forkIn && !!flowIn;
1113
+ if (given.length === 0 && !forkIn && !flowIn) {
1114
+ return fail(400, 'provide "html", "surfaces", "parts" or "fills" (or "fork" alone)');
1115
+ }
885
1116
  if (given.length > 1) {
886
- return fail(400, `pass one of "html", "surfaces" or "parts", not ${given.join(" and ")}`);
1117
+ return fail(400, `pass one of "html", "surfaces", "parts" or "fills", not ${given.join(" and ")}`);
887
1118
  }
888
- const partEdits = partEditsArg(body);
1119
+ const partEdits = editsArg(body, "parts");
889
1120
  if (isResult(partEdits))
890
1121
  return partEdits;
1122
+ const fillEdits = editsArg(body, "fills");
1123
+ if (isResult(fillEdits))
1124
+ return fillEdits;
891
1125
  const knobs = checkKnobs(body.knobs);
892
1126
  if (!knobs.ok)
893
1127
  return fail(400, knobs.error);
@@ -896,12 +1130,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
896
1130
  return fail(400, variantKnobs.error);
897
1131
  const stateIn = stateArg(body.state);
898
1132
  const variantIn = str(body.variant, MAX_LABEL);
899
- const kind = body.kind === "page" || body.kind === "component" ? body.kind : undefined;
1133
+ const kind = isMockKind(body.kind) ? body.kind : undefined;
900
1134
  const title = str(body.title, MAX_TITLE);
901
1135
  const fromN = Number(body.from);
902
1136
  const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
903
1137
  const prompt = str(body.prompt, MAX_COMMENT_TEXT);
904
1138
  const author = str(body.author, MAX_TITLE);
1139
+ const note = body.note === undefined ? undefined : (str(body.note, MAX_NOTE) ?? "");
1140
+ const widths = widthsArg(body.widths);
1141
+ if (isResult(widths))
1142
+ return widths;
1143
+ const area = labelArg(body, "area", MAX_LABEL);
1144
+ if (isResult(area))
1145
+ return area;
1146
+ const source = labelArg(body, "source", MAX_SOURCE);
1147
+ if (isResult(source))
1148
+ return source;
905
1149
  // An existing mock is addressable by id before a session exists.
906
1150
  let mock = null;
907
1151
  if (typeof body.mock === "string")
@@ -933,16 +1177,44 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
933
1177
  }
934
1178
  state = stateIn;
935
1179
  }
1180
+ const situationIn = str(body.situation, MAX_LABEL);
1181
+ // A situation is another render of one state ("Couldn't save"); "Normal"
1182
+ // names the state's own render.
1183
+ let situation;
1184
+ if (situationIn && situationIn.toLowerCase() !== NORMAL_SITUATION.toLowerCase()) {
1185
+ if (state === null) {
1186
+ return fail(400, '"situation" needs "state": the state it is another render of');
1187
+ }
1188
+ if (!mock || !mock.states.includes(state)) {
1189
+ return fail(404, `${slug} has no state "${state}" yet: publish its normal render first, then its situations`, mock ? { states: mock.states } : {});
1190
+ }
1191
+ const moment = momentOf(mock.situations, state);
1192
+ // The key feedback names it by, passed back with its own situation.
1193
+ if (moment !== state && moment !== null && situationKey(moment, situationIn) === state) {
1194
+ state = moment;
1195
+ }
1196
+ else if (moment !== state) {
1197
+ return fail(400, `"${state}" is a situation; give "situation" the state it belongs to`);
1198
+ }
1199
+ const key = situationKey(state, situationIn);
1200
+ if (mock.states.includes(key) && !(mock.situations[state] ?? []).includes(situationIn)) {
1201
+ return fail(409, `${slug} already has a state named "${key}"; name the situation otherwise`);
1202
+ }
1203
+ situation = { of: state, name: situationIn };
1204
+ state = key;
1205
+ }
936
1206
  const inState = posts.filter((p) => p.state === state);
937
1207
  let variant = variantIn;
938
1208
  if (!variant) {
939
- const live = inState.filter((p) => p.status !== "archived");
1209
+ const live = inState.filter((p) => isLive(p.status));
940
1210
  if (inState.length === 0)
941
1211
  variant = DEFAULT_VARIANT;
942
1212
  else if (inState.length === 1)
943
1213
  variant = inState[0].variant;
944
1214
  else if (live.length === 1)
945
1215
  variant = live[0].variant;
1216
+ else if (flowOnly)
1217
+ variant = (live[0] ?? inState[0]).variant;
946
1218
  else {
947
1219
  return fail(400, `${slug} has several variants; pass variant`, {
948
1220
  variants: inState.map(variantLabel),
@@ -953,9 +1225,54 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
953
1225
  const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
954
1226
  variants: posts.map(variantLabel),
955
1227
  });
1228
+ // A flow steps through states; a situation's step is its state's.
1229
+ const screen = { mock: slug, state: situation ? situation.of : state };
1230
+ if (flowIn) {
1231
+ const full = await flowFull(project, flowIn.name, screen, flowIn.step);
1232
+ if (full)
1233
+ return full;
1234
+ }
1235
+ if (flowOnly) {
1236
+ if (!mock || inState.length === 0) {
1237
+ return fail(404, `${slug} has no ${state === null ? "design" : `state "${state}"`} yet: publish it with "flow" to place it`);
1238
+ }
1239
+ if (!target)
1240
+ return missing();
1241
+ const flow = await placeFlow(project, flowIn.name, screen, flowIn.step);
1242
+ bus.broadcast({ type: "mock-updated", id: mock.id, project });
1243
+ return writeResult(mock, target, ctx, { writer: session, flow });
1244
+ }
1245
+ // A fork starts a new variant from a sibling's version: that version's
1246
+ // surfaces are what `parts`, `fills` and kept surface ids refer to.
1247
+ let fork = null;
1248
+ if (forkIn) {
1249
+ if (target) {
1250
+ return fail(409, `${variant} already exists; fork names a new variant to start`);
1251
+ }
1252
+ const source = inState.find((p) => p.variant === forkIn.variant);
1253
+ if (!source) {
1254
+ return fail(404, `no variant "${forkIn.variant}" to fork in this state`, {
1255
+ variants: inState.map(variantLabel),
1256
+ });
1257
+ }
1258
+ const version = forkIn.version ?? source.version;
1259
+ const surfacesAt = version === source.version
1260
+ ? source.surfaces
1261
+ : source.history.find((h) => h.version === version)?.surfaces;
1262
+ if (!surfacesAt)
1263
+ return fail(404, `${source.variant} has no version ${version}`);
1264
+ fork = {
1265
+ ref: { variant: source.variant, version },
1266
+ surfaces: surfacesAt,
1267
+ ...(source.knobs ? { knobs: source.knobs } : {}),
1268
+ ...(source.note ? { note: source.note } : {}),
1269
+ };
1270
+ }
956
1271
  // What `parts` and kept surface ids refer to: the variant's current
957
1272
  // version, or the one `from` names.
958
1273
  const base = () => {
1274
+ if (fork)
1275
+ return fork.surfaces;
959
1276
  if (!target)
960
1277
  return missing();
961
1278
  if (from === undefined || from === target.version)
@@ -965,11 +1282,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
965
1282
  };
966
1283
  let surfaces;
967
1284
  let applied;
968
- if (partEdits) {
1285
+ if (fork && given.length === 0) {
1286
+ surfaces = fork.surfaces;
1287
+ }
1288
+ else if (partEdits || fillEdits) {
969
1289
  const current = base();
970
1290
  if (isResult(current))
971
1291
  return current;
972
- const result = spliceParts(current, partEdits);
1292
+ const result = partEdits
1293
+ ? spliceParts(current, partEdits)
1294
+ : spliceFills(current, fillEdits);
973
1295
  if (!result.ok)
974
1296
  return fail(400, result.error);
975
1297
  surfaces = result.surfaces;
@@ -981,10 +1303,23 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
981
1303
  return parsed;
982
1304
  surfaces = parsed;
983
1305
  }
1306
+ const before = base();
1307
+ const postered = await withPoster(surfaces, isResult(before) ? null : before, body.poster);
1308
+ if (isResult(postered))
1309
+ return postered;
1310
+ surfaces = postered;
984
1311
  const bad = checkSurfaces(surfaces);
985
1312
  if (bad)
986
1313
  return bad;
987
- const nudges = knobNudges({ ...knobs.value, ...variantKnobs.value });
1314
+ const nudges = [
1315
+ ...knobNudges({ ...knobs.value, ...variantKnobs.value }),
1316
+ ...titleNudges(title),
1317
+ ...(target ? [] : posterNudges(surfaces)),
1318
+ ];
1319
+ const embeds = await publishEmbeds(project, slug, surfaces);
1320
+ if (isResult(embeds))
1321
+ return embeds;
1322
+ await promoteEmbedded(project, embeds);
988
1323
  if (!target) {
989
1324
  // A new variant: carried by the session the agent is writing from.
990
1325
  if (!session) {
@@ -1001,14 +1336,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1001
1336
  project,
1002
1337
  slug,
1003
1338
  title: title ?? titleFromSlug(slug),
1004
- kind: kind ?? "component",
1339
+ kind: kind ?? "sketch",
1340
+ ...(area === undefined ? {} : { area }),
1341
+ ...(source === undefined ? {} : { source }),
1005
1342
  states: state === null ? [] : [state],
1006
1343
  knobs: knobs.value,
1344
+ ...(widths ? { widths } : {}),
1007
1345
  sessionId: session.id,
1008
1346
  });
1009
1347
  bus.broadcast({ type: "mock-created", id: mock.id, project });
1010
1348
  }
1011
- const slots = mock.kind === "page" || kind === "page" ? await pageSlots(project, surfaces) : [];
1012
1349
  const created = await store.createPost({
1013
1350
  sessionId: session.id,
1014
1351
  mock: mock.id,
@@ -1016,38 +1353,65 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1016
1353
  variant,
1017
1354
  title: title ?? mock.title,
1018
1355
  surfaces,
1019
- ...(Object.keys(variantKnobs.value).length ? { knobs: variantKnobs.value } : {}),
1020
- slots,
1356
+ ...(Object.keys(variantKnobs.value).length
1357
+ ? { knobs: variantKnobs.value }
1358
+ : fork?.knobs
1359
+ ? { knobs: fork.knobs }
1360
+ : {}),
1361
+ ...(note ? { note } : note === undefined && fork?.note ? { note: fork.note } : {}),
1362
+ embeds,
1363
+ ...(fork ? { forkedFrom: fork.ref } : {}),
1021
1364
  from,
1022
1365
  prompt,
1023
1366
  author,
1024
1367
  });
1025
1368
  if (!created)
1026
1369
  return fail(404, "session not found");
1027
- mock = await updateMockAfterWrite(mock, { state, kind, title, knobs: knobs.value, session });
1370
+ mock = await updateMockAfterWrite(mock, {
1371
+ state,
1372
+ situation,
1373
+ kind,
1374
+ title,
1375
+ knobs: knobs.value,
1376
+ widths,
1377
+ area,
1378
+ source,
1379
+ session,
1380
+ });
1381
+ const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
1028
1382
  announcePost(mock, created, true);
1029
1383
  if (prompt && ctx.request)
1030
1384
  notifyPublish(mock, created, prompt, ctx);
1031
- return writeResult(mock, created, ctx, { nudges, status: 201 });
1385
+ return writeResult(mock, created, ctx, { nudges, status: 201, flow });
1032
1386
  }
1033
1387
  if (!mock)
1034
1388
  return missing();
1035
1389
  // A new version is written by whoever writes it; without an explicit
1036
1390
  // session the variant's own session carries it.
1037
1391
  session ??= await store.getSession(target.sessionId);
1038
- const slots = mock.kind === "page" ? await pageSlots(project, surfaces) : undefined;
1039
1392
  const updated = await store.updatePost(target.id, {
1040
1393
  surfaces,
1041
1394
  title,
1042
1395
  ...(body.variantKnobs !== undefined ? { knobs: variantKnobs.value } : {}),
1043
- slots,
1396
+ ...(note !== undefined ? { note } : {}),
1397
+ embeds,
1044
1398
  from,
1045
1399
  prompt,
1046
1400
  author,
1047
1401
  });
1048
1402
  if (!updated)
1049
1403
  return fail(404, "variant not found");
1050
- mock = await updateMockAfterWrite(mock, { state, kind, knobs: knobs.value, session });
1404
+ const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
1405
+ mock = await updateMockAfterWrite(mock, {
1406
+ state,
1407
+ situation,
1408
+ kind,
1409
+ knobs: knobs.value,
1410
+ widths,
1411
+ area,
1412
+ source,
1413
+ session,
1414
+ });
1051
1415
  announcePost(mock, updated, false);
1052
1416
  if (prompt && ctx.request)
1053
1417
  notifyPublish(mock, updated, prompt, ctx);
@@ -1056,35 +1420,165 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1056
1420
  applied,
1057
1421
  nudges,
1058
1422
  writer: session,
1423
+ flow,
1059
1424
  });
1060
1425
  }
1426
+ // `flow` names a flow; `step` is the 1-based place of this screen in it
1427
+ // (moving it if it is there already), 0 takes the screen out, and without
1428
+ // `step` a screen not yet in the flow joins at its end.
1429
+ function flowArg(body) {
1430
+ const stepIn = body.step;
1431
+ if (body.flow === undefined || body.flow === null) {
1432
+ return stepIn === undefined || stepIn === null
1433
+ ? undefined
1434
+ : fail(400, '"step" needs "flow": the flow\'s name, e.g. "Morning"');
1435
+ }
1436
+ const name = str(body.flow, MAX_LABEL);
1437
+ if (!name)
1438
+ return fail(400, '"flow" must be the flow\'s name, e.g. "Morning"');
1439
+ if (stepIn === undefined || stepIn === null)
1440
+ return { name };
1441
+ const step = Number(stepIn);
1442
+ if (!Number.isInteger(step) || step < 0) {
1443
+ return fail(400, '"step" must be the 1-based place in the flow, or 0 to take it out');
1444
+ }
1445
+ return { name, step };
1446
+ }
1447
+ // Checked before anything is written, so a full flow leaves no new version behind.
1448
+ async function flowFull(project, name, screen, step) {
1449
+ if (step === 0)
1450
+ return null;
1451
+ const flows = await store.listFlows(project);
1452
+ const flow = flows.find((f) => f.name === name);
1453
+ if (!flow && flows.length >= MAX_FLOWS) {
1454
+ return fail(400, `${project} already has ${MAX_FLOWS} flows; reuse one`);
1455
+ }
1456
+ if (flow &&
1457
+ flow.steps.length >= MAX_FLOW_STEPS &&
1458
+ !flow.steps.some((x) => sameScreen(x, screen))) {
1459
+ return fail(400, `flow "${name}" already has ${MAX_FLOW_STEPS} steps`);
1460
+ }
1461
+ return null;
1462
+ }
1463
+ async function placeFlow(project, name, screen, step) {
1464
+ let at = step;
1465
+ if (at === undefined) {
1466
+ const flow = (await store.listFlows(project)).find((f) => f.name === name);
1467
+ const i = flow ? flow.steps.findIndex((x) => sameScreen(x, screen)) : -1;
1468
+ at = i >= 0 ? i + 1 : MAX_FLOW_STEPS + 1;
1469
+ }
1470
+ const flow = await store.placeFlowStep(project, name, screen, at);
1471
+ return { name, steps: flow?.steps ?? [] };
1472
+ }
1473
+ // The project's flows as `read` returns them: only steps whose screen exists.
1474
+ async function flowsView(project, mocks) {
1475
+ const bySlug = new Map(mocks.map((m) => [m.slug, m]));
1476
+ const exists = (x) => {
1477
+ const m = bySlug.get(x.mock);
1478
+ return !!m && (x.state === null ? m.states.length === 0 : m.states.includes(x.state));
1479
+ };
1480
+ return (await store.listFlows(project))
1481
+ .map((f) => ({ name: f.name, steps: f.steps.filter(exists) }))
1482
+ .filter((f) => f.steps.length > 0);
1483
+ }
1484
+ // `fork`: "variant" or "variant@N", the sibling version a new variant starts
1485
+ // from.
1486
+ function forkArg(v) {
1487
+ if (v === undefined || v === null || v === "")
1488
+ return undefined;
1489
+ const bad = '"fork" must be "<variant>" or "<variant>@<version>", e.g. "quiet@2"';
1490
+ if (typeof v !== "string")
1491
+ return fail(400, bad);
1492
+ const m = /^(.+?)(?:@(\d+))?$/.exec(v.trim());
1493
+ const variant = m && str(m[1], MAX_LABEL);
1494
+ if (!variant)
1495
+ return fail(400, bad);
1496
+ const version = m[2] === undefined ? undefined : Number(m[2]);
1497
+ if (version !== undefined && version < 1)
1498
+ return fail(400, bad);
1499
+ return version === undefined ? { variant } : { variant, version };
1500
+ }
1501
+ // An optional free-text field of the mock (`area`, `source`): absent keeps
1502
+ // it, "" or null clears it.
1503
+ function labelArg(body, key, max) {
1504
+ const v = body?.[key];
1505
+ if (v === undefined)
1506
+ return undefined;
1507
+ if (v === null)
1508
+ return null;
1509
+ if (typeof v !== "string")
1510
+ return fail(400, `"${key}" must be a string; "" clears it`);
1511
+ return str(v, max) ?? null;
1512
+ }
1513
+ // `widths`: the device widths the mock is judged at, in the agent's order;
1514
+ // [] hands the choice back to the viewer.
1515
+ function widthsArg(v) {
1516
+ if (v === undefined)
1517
+ return undefined;
1518
+ const bad = `"widths" must be up to ${MAX_WIDTHS} integer px widths between ${MIN_WIDTH} and ${MAX_WIDTH}, e.g. [390, 1280]`;
1519
+ if (!Array.isArray(v) || v.length > MAX_WIDTHS)
1520
+ return fail(400, bad);
1521
+ const out = [];
1522
+ for (const w of v) {
1523
+ if (!Number.isInteger(w) || w < MIN_WIDTH || w > MAX_WIDTH)
1524
+ return fail(400, bad);
1525
+ if (!out.includes(w))
1526
+ out.push(w);
1527
+ }
1528
+ return out;
1529
+ }
1061
1530
  // `parts`: new outer html per part ("name" or "name#key"), spliced into the
1062
1531
  // variant's html so a tweak costs the tweak rather than the whole document.
1063
- function partEditsArg(body) {
1064
- if (body?.parts === undefined)
1532
+ // `fills`: the same per slot alternative ("part#fill"), replaced or added.
1533
+ function editsArg(body, key) {
1534
+ if (body?.[key] === undefined)
1065
1535
  return undefined;
1066
- const parts = body.parts;
1067
- if (!parts || typeof parts !== "object" || Array.isArray(parts)) {
1068
- return fail(400, '"parts" must be an object: { "<part name>": "<html for that part>" }');
1536
+ const edits = body[key];
1537
+ const shape = key === "parts"
1538
+ ? '{ "<part name>": "<html for that part>" }'
1539
+ : '{ "<part>#<fill>": "<html for that fill>" }';
1540
+ if (!edits || typeof edits !== "object" || Array.isArray(edits)) {
1541
+ return fail(400, `"${key}" must be an object: ${shape}`);
1069
1542
  }
1070
- const entries = Object.entries(parts);
1543
+ const entries = Object.entries(edits);
1071
1544
  if (entries.length === 0)
1072
- return fail(400, '"parts" is empty; name at least one part');
1545
+ return fail(400, `"${key}" is empty; name at least one ${key.slice(0, -1)}`);
1073
1546
  if (entries.some(([, html]) => typeof html !== "string")) {
1074
- return fail(400, 'each "parts" value must be the html string for that part');
1547
+ return fail(400, `each "${key}" value must be the html string for that ${key.slice(0, -1)}`);
1075
1548
  }
1076
- return parts;
1549
+ return edits;
1077
1550
  }
1078
1551
  // A write may add a state, retitle the mock, declare knobs, and makes its
1079
1552
  // session the one a reply is delivered to.
1080
1553
  async function updateMockAfterWrite(mock, w) {
1081
- const states = w.state !== null && !mock.states.includes(w.state) ? [...mock.states, w.state] : undefined;
1554
+ let states;
1555
+ let situations;
1556
+ if (w.state !== null && !mock.states.includes(w.state)) {
1557
+ if (w.situation) {
1558
+ // After its state and that state's other situations, so the order of
1559
+ // states reads moment by moment.
1560
+ const { of, name } = w.situation;
1561
+ const own = mock.situations[of] ?? [];
1562
+ const last = [of, ...own.map((n) => situationKey(of, n))]
1563
+ .map((st) => mock.states.indexOf(st))
1564
+ .reduce((a, b) => Math.max(a, b), -1);
1565
+ states = [...mock.states.slice(0, last + 1), w.state, ...mock.states.slice(last + 1)];
1566
+ situations = { ...mock.situations, [of]: [...own, name] };
1567
+ }
1568
+ else {
1569
+ states = [...mock.states, w.state];
1570
+ }
1571
+ }
1082
1572
  const knobs = Object.keys(w.knobs).length ? { ...mock.knobs, ...w.knobs } : undefined;
1083
1573
  const updated = await store.updateMock(mock.id, {
1084
1574
  ...(states ? { states } : {}),
1575
+ ...(situations ? { situations } : {}),
1085
1576
  ...(w.kind ? { kind: w.kind } : {}),
1086
1577
  ...(w.title ? { title: w.title } : {}),
1087
1578
  ...(knobs ? { knobs } : {}),
1579
+ ...(w.widths ? { widths: w.widths } : {}),
1580
+ ...(w.area === undefined ? {} : { area: w.area }),
1581
+ ...(w.source === undefined ? {} : { source: w.source }),
1088
1582
  ...(w.session ? { sessionId: w.session.id } : {}),
1089
1583
  });
1090
1584
  if (!updated)
@@ -1115,17 +1609,62 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1115
1609
  }
1116
1610
  const open = rows.reduce((n, r) => n + r.open, 0);
1117
1611
  return ok({
1118
- ...(project ? { project } : {}),
1612
+ ...(project ? { project, flows: await flowsView(project, mocks) } : {}),
1119
1613
  mocks: rows,
1120
1614
  open,
1121
1615
  openMocks: rows.filter((r) => r.open > 0).length,
1122
1616
  pending,
1123
1617
  });
1124
1618
  }
1125
- // The tuned values of the mock's latest reply: what the user last sent.
1126
- async function lastTuned(mockId) {
1127
- const replies = (await store.listComments({ mockId })).filter((c) => c.kind === "reply");
1128
- return replies[replies.length - 1]?.payload?.tuned ?? {};
1619
+ // The project as a tree: every mock with its variants per state (status,
1620
+ // lineage, what its embeds resolve to) and the components it embeds and is
1621
+ // embedded in, and the declared flows. What the viewer's Home and an agent's
1622
+ // `read --tree` lay out.
1623
+ async function treeFlow(query) {
1624
+ const project = str(query.project, MAX_TITLE);
1625
+ if (!project)
1626
+ return fail(400, 'provide "project"');
1627
+ const mocks = await store.listMocks(project);
1628
+ const nodes = [];
1629
+ const pending = [];
1630
+ for (const m of mocks) {
1631
+ const posts = await store.listPosts({ mockId: m.id });
1632
+ const views = await embedViews(m, posts);
1633
+ const embeds = new Set();
1634
+ for (const p of posts) {
1635
+ if (!isLive(p.status))
1636
+ continue;
1637
+ for (const x of p.surfaces) {
1638
+ if (x.kind === "html")
1639
+ for (const t of parseEmbedTags(x.html))
1640
+ embeds.add(t.slug);
1641
+ }
1642
+ }
1643
+ nodes.push(treeNodeView(m, posts, {
1644
+ settled: settledValues(await replyPayloads(m.id), m, posts),
1645
+ embeds: [...embeds],
1646
+ embedViews: views,
1647
+ }));
1648
+ pending.push(pendingOf(m, posts));
1649
+ }
1650
+ const bySlug = new Map(nodes.map((n) => [n.slug, n]));
1651
+ for (const n of nodes) {
1652
+ n.embeds = n.embeds.filter((s) => bySlug.has(s));
1653
+ for (const s of n.embeds)
1654
+ bySlug.get(s).embeddedIn.push(n.slug);
1655
+ }
1656
+ return ok({
1657
+ project,
1658
+ tree: nodes,
1659
+ flows: await flowsView(project, mocks),
1660
+ areaOrder: await areaOrderOf(project),
1661
+ pending,
1662
+ });
1663
+ }
1664
+ async function replyPayloads(mockId) {
1665
+ return (await store.listComments({ mockId }))
1666
+ .filter((c) => c.kind === "reply" && c.payload && !c.payload.edited)
1667
+ .map((c) => c.payload);
1129
1668
  }
1130
1669
  async function getMockFlow(ref, query) {
1131
1670
  const mock = await resolveMock(ref, query.project, query.session);
@@ -1136,7 +1675,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1136
1675
  ...mockDetailView(mock, posts, {
1137
1676
  body: query.body,
1138
1677
  history: query.history,
1139
- tuned: await lastTuned(mock.id),
1678
+ settled: settledValues(await replyPayloads(mock.id), mock, posts),
1679
+ embeds: await embedViews(mock, posts),
1140
1680
  }),
1141
1681
  pending: pendingOf(mock, posts),
1142
1682
  });
@@ -1151,31 +1691,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1151
1691
  const states = (mock.states.length ? mock.states : [null]).filter((s) => wantedState === undefined || s === wantedState);
1152
1692
  if (states.length === 0)
1153
1693
  return fail(404, `${mock.slug} has no state "${wantedState}"`);
1694
+ const replies = await replyPayloads(mock.id);
1695
+ const lastReply = replies[replies.length - 1] ?? null;
1696
+ const settled = settledValues(replies, mock, posts);
1154
1697
  const entries = [];
1155
1698
  for (const state of states) {
1156
1699
  const inState = posts.filter((p) => p.state === state);
1157
- const post = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
1700
+ const found = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
1158
1701
  inState.find((p) => p.status === "accepted") ||
1159
1702
  inState.find((p) => p.status === "open") ||
1160
1703
  inState[0];
1161
- if (!post)
1704
+ if (!found)
1162
1705
  continue;
1163
- if (wantedVariant && post.variant !== wantedVariant)
1706
+ if (wantedVariant && found.variant !== wantedVariant)
1164
1707
  continue;
1708
+ const post = postAtVersion(found, inUseVersion(found)) ?? found;
1165
1709
  const html = post.surfaces.find((s) => s.kind === "html");
1710
+ // With fills, the state as decided: only the chosen fills, by every
1711
+ // value sent so far (defaults before any reply). The authored markup with
1712
+ // every fill stays available as `allFills`.
1713
+ const slots = slotsInSurfaces(post.surfaces);
1714
+ const raw = html && html.kind === "html" ? html.html : "";
1715
+ const composed = slots.length ? composeFills(raw, chosenFills(slots, settled)) : raw;
1166
1716
  entries.push({
1167
1717
  state,
1168
1718
  variant: post.variant,
1169
1719
  postId: post.id,
1170
1720
  version: post.version,
1171
- status: post.status,
1172
- html: html && html.kind === "html" ? html.html : "",
1721
+ // The agent's newer versions wait until the user puts one in use.
1722
+ ...(found.version !== post.version ? { latest: found.version } : {}),
1723
+ status: variantStatus(post.status),
1724
+ html: composed,
1725
+ ...(slots.length ? { allFills: raw } : {}),
1173
1726
  markdown: postToMarkdown(post, {
1174
1727
  postUrl: `${ctx.base}/s/${post.id}`,
1175
1728
  assetBase: ctx.base,
1176
1729
  }),
1177
1730
  history: [
1178
- ...[...post.history]
1731
+ ...[...found.history]
1732
+ .filter((h) => !(found.hiddenVersions ?? []).includes(h.version))
1179
1733
  .sort((a, b) => a.version - b.version)
1180
1734
  .map((h) => ({
1181
1735
  version: h.version,
@@ -1184,10 +1738,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1184
1738
  prompt: h.prompt ?? "",
1185
1739
  })),
1186
1740
  {
1187
- version: post.version,
1188
- at: post.updatedAt,
1189
- from: post.from ?? null,
1190
- prompt: post.prompt ?? "",
1741
+ version: found.version,
1742
+ at: found.updatedAt,
1743
+ from: found.from ?? null,
1744
+ prompt: found.prompt ?? "",
1191
1745
  },
1192
1746
  ],
1193
1747
  screenshotUrl: screenshots ? `${ctx.base}/s/${post.id}.png?v=${post.version}` : null,
@@ -1198,7 +1752,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1198
1752
  variants: posts.map(variantLabel),
1199
1753
  });
1200
1754
  }
1201
- const replies = (await store.listComments({ mockId: mock.id })).filter((c) => c.kind === "reply");
1755
+ const decisions = lastReply ? decisionsFor(mock, posts, lastReply) : null;
1202
1756
  return ok({
1203
1757
  project: mock.project,
1204
1758
  mock: mock.slug,
@@ -1206,7 +1760,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1206
1760
  kind: mock.kind,
1207
1761
  states: entries,
1208
1762
  knobs: mock.knobs,
1209
- reply: replies[replies.length - 1]?.payload ?? null,
1763
+ settled,
1764
+ reply: lastReply,
1765
+ ...(decisions ? { decisions } : {}),
1210
1766
  });
1211
1767
  }
1212
1768
  // --- asks ---
@@ -1239,6 +1795,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1239
1795
  const options = [];
1240
1796
  // Seeded with the viewer's write-in, so a label "Other" slugs to "other-2".
1241
1797
  const optionIds = new Set([OTHER_ID]);
1798
+ // The agent's own pick, shown as a quiet mark on its option: one per ask.
1799
+ const picks = raw.options.filter((o) => o && typeof o === "object" && o.recommended === true).length;
1800
+ if (picks > 1)
1801
+ return `ask "${text}" recommends ${picks} options; recommend one`;
1242
1802
  for (const o of raw.options) {
1243
1803
  const opt = typeof o === "string" ? { label: o } : o;
1244
1804
  const label = str(opt?.label, 200);
@@ -1262,12 +1822,32 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1262
1822
  return checked.error;
1263
1823
  set = checked.value;
1264
1824
  }
1265
- options.push({ id, label, ...(variant ? { variant } : {}), ...(set ? { set } : {}) });
1825
+ const pick = opt.recommended === true ? { recommended: true } : {};
1826
+ if (opt.version !== undefined) {
1827
+ const pinned = versionOption(opt.version, label, variant, { scope, state }, mock, posts);
1828
+ if (typeof pinned === "string")
1829
+ return pinned;
1830
+ options.push({ id, label, ...pinned, ...(set ? { set } : {}), ...pick });
1831
+ continue;
1832
+ }
1833
+ options.push({
1834
+ id,
1835
+ label,
1836
+ ...(variant ? { variant } : {}),
1837
+ ...(set ? { set } : {}),
1838
+ ...pick,
1839
+ });
1266
1840
  }
1267
1841
  let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
1268
1842
  if (!str(raw.id, 64))
1269
1843
  for (let n = 2; taken.has(id); n++)
1270
1844
  id = `${slugify(text).slice(0, 36)}-${n}`;
1845
+ const after = raw.after === undefined ? undefined : str(raw.after, 64);
1846
+ if (raw.after !== undefined) {
1847
+ if (!after || after === id || !taken.has(after)) {
1848
+ return `ask "${text}": after must name an earlier ask's id (asked before it or earlier in this call)`;
1849
+ }
1850
+ }
1271
1851
  return {
1272
1852
  id,
1273
1853
  text,
@@ -1276,9 +1856,49 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1276
1856
  ...(part ? { part } : {}),
1277
1857
  options,
1278
1858
  ...(raw.multi === true ? { multi: true } : {}),
1859
+ ...(after ? { after } : {}),
1279
1860
  at: new Date().toISOString(),
1280
1861
  };
1281
1862
  }
1863
+ // A version option names one visible version of one variant in the ask's
1864
+ // state (`optionState`); the variant may be left out when that state has
1865
+ // only one, or one accepted.
1866
+ function versionOption(raw, label, variant, ask, mock, posts) {
1867
+ const version = Number(raw);
1868
+ if (!Number.isInteger(version) || version < 1) {
1869
+ return `option "${label}": version must be a positive integer`;
1870
+ }
1871
+ if (ask.scope === "part") {
1872
+ return `option "${label}" names a version, which puts it in use: ask with scope "state" or "mock", not "part"`;
1873
+ }
1874
+ if (ask.state === undefined && mock.states.length > 1) {
1875
+ return `option "${label}" names a version: give the ask a state (${mock.slug} has ${mock.states.map((x) => `"${x}"`).join(", ")})`;
1876
+ }
1877
+ const state = optionState(ask, mock);
1878
+ const inState = posts.filter((p) => p.state === state);
1879
+ const accepted = inState.filter((p) => p.status === "accepted");
1880
+ const post = variant
1881
+ ? inState.find((p) => p.variant === variant)
1882
+ : inState.length === 1
1883
+ ? inState[0]
1884
+ : accepted.length === 1
1885
+ ? accepted[0]
1886
+ : undefined;
1887
+ const where = state === null ? mock.slug : `"${state}"`;
1888
+ if (!post) {
1889
+ return variant
1890
+ ? `option "${label}" names variant "${variant}", which ${where} does not have`
1891
+ : `option "${label}" names version ${version} but ${where} has several variants (${inState.map((p) => p.variant).join(", ")}): name one`;
1892
+ }
1893
+ const hidden = new Set(post.hiddenVersions ?? []);
1894
+ const visible = [post.version, ...post.history.map((h) => h.version)]
1895
+ .filter((v) => !hidden.has(v))
1896
+ .sort((a, b) => a - b);
1897
+ if (!visible.includes(version)) {
1898
+ return `option "${label}" names ${post.variant} v${version}${hidden.has(version) ? ", which is hidden" : ""}; ${post.variant} has ${visible.map((v) => `v${v}`).join(", ")}`;
1899
+ }
1900
+ return { variant: post.variant, version };
1901
+ }
1282
1902
  // Ask the user structured questions. An ask whose id matches an existing one
1283
1903
  // replaces it (and reopens it); anything else is appended.
1284
1904
  async function askFlow(ref, body, ctx) {
@@ -1297,11 +1917,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1297
1917
  const asks = [...mock.asks];
1298
1918
  const taken = new Set(asks.map((a) => a.id));
1299
1919
  const added = [];
1920
+ const skipped = [];
1300
1921
  for (const entry of raw) {
1301
1922
  const ask = sanitizeAsk(entry, mock, posts, taken);
1302
1923
  if (typeof ask === "string")
1303
1924
  return fail(400, ask);
1304
1925
  const at = asks.findIndex((a) => a.id === ask.id);
1926
+ // The user closed this one as not worth answering; asking it again is
1927
+ // exactly the noise they closed away. Only their Reopen revives it.
1928
+ if (at >= 0 && asks[at].dismissed) {
1929
+ skipped.push(ask.id);
1930
+ continue;
1931
+ }
1305
1932
  if (at >= 0)
1306
1933
  asks[at] = ask;
1307
1934
  else
@@ -1309,11 +1936,31 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1309
1936
  taken.add(ask.id);
1310
1937
  added.push(ask);
1311
1938
  }
1939
+ const skippedNudges = skipped.map((id) => `ask "${id}": the user dismissed it when they closed ${mock.slug}; not asked again. Don't re-ask it.`);
1940
+ if (!added.length) {
1941
+ return ok({
1942
+ mock: mock.slug,
1943
+ mockId: mock.id,
1944
+ project: mock.project,
1945
+ asks: [],
1946
+ skipped,
1947
+ open: openCount(mock, posts),
1948
+ url: mockUrl(ctx.base, mock),
1949
+ nudges: skippedNudges,
1950
+ feedback: mock.sessionId ? await collectFeedback(mock.sessionId, ctx.base) : [],
1951
+ });
1952
+ }
1312
1953
  let sessionId = mock.sessionId;
1313
1954
  if (typeof body.session === "string" && (await store.getSession(body.session))) {
1314
1955
  sessionId = body.session;
1315
1956
  }
1316
- const updated = await store.updateMock(mock.id, { asks, sessionId });
1957
+ // A new question on a closed mock brings it back to the open list, or it
1958
+ // would wait where the user no longer looks.
1959
+ const updated = await store.updateMock(mock.id, {
1960
+ asks,
1961
+ sessionId,
1962
+ ...(mock.closedAt ? { closedAt: null } : {}),
1963
+ });
1317
1964
  if (!updated)
1318
1965
  return fail(404, "mock not found");
1319
1966
  mock = updated;
@@ -1341,12 +1988,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1341
1988
  url: mockUrl(ctx.base, mock),
1342
1989
  });
1343
1990
  const feedback = session ? await collectFeedback(session.id, ctx.base) : [];
1344
- const nudges = askNudges(added, mock.asks);
1991
+ const nudges = [...skippedNudges, ...askNudges(added, mock.asks)];
1345
1992
  return ok({
1346
1993
  mock: mock.slug,
1347
1994
  mockId: mock.id,
1348
1995
  project: mock.project,
1349
1996
  asks: added,
1997
+ ...(skipped.length ? { skipped } : {}),
1350
1998
  open: openCount(mock, posts),
1351
1999
  url: mockUrl(ctx.base, mock),
1352
2000
  ...(nudges.length ? { nudges } : {}),
@@ -1367,15 +2015,33 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1367
2015
  if (reaction !== undefined && !REACTIONS.includes(reaction))
1368
2016
  return `reaction must be one of ${REACTIONS.join(", ")}`;
1369
2017
  const text = str(c?.text, MAX_COMMENT_TEXT);
1370
- if (!text && !reaction)
2018
+ const borrowIn = c?.borrow;
2019
+ if (!text && !reaction && !borrowIn)
1371
2020
  return "every comment needs text";
1372
2021
  const state = stateArg(c.state) ?? null;
1373
2022
  if (state !== null && !mock.states.includes(state))
1374
2023
  return `${mock.slug} has no state "${state}"`;
1375
2024
  const part = str(c.part, 200) ?? null;
1376
2025
  const variant = str(c.variant, 200);
1377
- if (variant && !posts.some((p) => p.variant === variant && p.state === state))
2026
+ const post = variant ? posts.find((p) => p.variant === variant && p.state === state) : null;
2027
+ if (variant && !post)
1378
2028
  return `${mock.slug} has no variant "${variant}"${state ? ` in "${state}"` : ""}`;
2029
+ const version = c.version;
2030
+ if (version !== undefined &&
2031
+ (!post || !Number.isInteger(version) || version < 1 || version > post.version))
2032
+ return `${mock.slug} has no version ${String(version)}${variant ? ` of "${variant}"` : ""}`;
2033
+ let borrow;
2034
+ if (borrowIn !== undefined) {
2035
+ const from = str(borrowIn?.from, 240);
2036
+ const at = from ? /^(.+)@(\d+)$/.exec(from) : null;
2037
+ const source = at && posts.find((p) => p.variant === at[1] && p.state === state);
2038
+ if (!at || !source || Number(at[2]) < 1 || Number(at[2]) > source.version)
2039
+ return `borrow.from must be variant@version of "${mock.slug}"${state ? ` in "${state}"` : ""}`;
2040
+ if (!post || version === undefined)
2041
+ return "a borrow names the version it goes into";
2042
+ const borrowed = str(borrowIn?.part, 200);
2043
+ borrow = { from: at[0], ...(borrowed ? { part: borrowed } : {}) };
2044
+ }
1379
2045
  const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
1380
2046
  const anchor = a ? sanitizePartAnchor(a) : undefined;
1381
2047
  const next = {
@@ -1385,13 +2051,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1385
2051
  ...(anchor ? { anchor } : {}),
1386
2052
  ...(reaction ? { reaction: reaction } : {}),
1387
2053
  ...(variant ? { variant } : {}),
2054
+ ...(version !== undefined ? { version: version } : {}),
2055
+ ...(borrow ? { borrow } : {}),
1388
2056
  };
1389
2057
  // One reaction per target: a later tap on the same part replaces the earlier one.
1390
2058
  if (reaction) {
1391
2059
  const same = out.findIndex((o) => o.reaction &&
1392
2060
  o.part === part &&
1393
2061
  o.state === state &&
1394
- (o.variant ?? null) === (variant ?? null));
2062
+ (o.variant ?? null) === (variant ?? null) &&
2063
+ (o.version ?? null) === (version ?? null));
1395
2064
  if (same >= 0)
1396
2065
  out.splice(same, 1);
1397
2066
  }
@@ -1498,18 +2167,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1498
2167
  }
1499
2168
  return out;
1500
2169
  }
1501
- function sanitizeMix(raw, posts) {
2170
+ function sanitizeBorrowed(raw, posts) {
1502
2171
  if (raw === undefined || raw === null)
1503
2172
  return {};
1504
2173
  if (typeof raw !== "object" || Array.isArray(raw))
1505
- return '"mix" must be an object';
2174
+ return '"borrowed" must be an object';
1506
2175
  const variants = new Set(posts.map((p) => p.variant));
1507
2176
  const out = {};
1508
2177
  for (const [part, variant] of Object.entries(raw)) {
1509
2178
  if (!part || part.length > 200)
1510
- return "mix keys are part names";
2179
+ return "borrowed keys are part names";
1511
2180
  if (typeof variant !== "string" || !variants.has(variant)) {
1512
- return `mix "${part}" names a variant that does not exist`;
2181
+ return `borrowed "${part}" names a variant that does not exist`;
1513
2182
  }
1514
2183
  out[part] = variant;
1515
2184
  }
@@ -1522,9 +2191,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1522
2191
  const answers = sanitizeAnswers(pick("answers"), mock, posts);
1523
2192
  if (typeof answers === "string")
1524
2193
  return answers;
1525
- const mix = sanitizeMix(pick("mix"), posts);
1526
- if (typeof mix === "string")
1527
- return mix;
2194
+ const borrowed = sanitizeBorrowed(pick("borrowed"), posts);
2195
+ if (typeof borrowed === "string")
2196
+ return borrowed;
1528
2197
  const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
1529
2198
  if (!tuned.ok)
1530
2199
  return tuned.error;
@@ -1559,7 +2228,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1559
2228
  return {
1560
2229
  version,
1561
2230
  answers,
1562
- mix,
2231
+ borrowed,
1563
2232
  tuned: tuned.value,
1564
2233
  comments,
1565
2234
  others,
@@ -1604,33 +2273,44 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1604
2273
  bus.broadcast({ type: "draft-updated", mockId: mock.id });
1605
2274
  return ok({ draft: null });
1606
2275
  }
1607
- function sanitizeDecision(raw, mock, posts) {
2276
+ function sanitizeVerdict(raw, mock, posts) {
1608
2277
  if (raw === undefined || raw === null)
1609
2278
  return undefined;
1610
2279
  const kind = raw.kind;
1611
- if (kind !== "accept" && kind !== "revise" && kind !== "drop") {
1612
- return 'decision.kind must be "accept", "revise" or "drop"';
1613
- }
2280
+ if (kind !== "use" && kind !== "revise")
2281
+ return 'verdict.kind must be "use" or "revise"';
1614
2282
  const chosen = chooseVariant(mock, posts, stateArg(raw.state), str(raw.variant, MAX_LABEL));
1615
2283
  if (isResult(chosen))
1616
2284
  return chosen.body.error;
1617
2285
  return { kind, state: chosen.state, variant: chosen.variant };
1618
2286
  }
1619
- // Which variants a reply accepts and archives. A variant-bound answer accepts
1620
- // the chosen variant(s) in every state the ask covers and archives their
1621
- // siblings there; a part-scoped pick is a mix, not a verdict. The built-in
1622
- // variant asks flip the same way, per state.
1623
- function replyFlips(mock, posts, answers, decision) {
1624
- const accept = new Set();
2287
+ // Which variants a reply puts in use and archives. A variant-bound answer puts
2288
+ // the chosen variant(s) in use in every state the ask covers and archives
2289
+ // their siblings there; a part-scoped pick is a borrow, not a verdict. The
2290
+ // built-in variant questions flip the same way, per state. Each variant is put
2291
+ // in use at the version the reply was made on (`version`, a mock version), not
2292
+ // whatever the agent published since — or, for a version option, at its version.
2293
+ function replyFlips(mock, posts, answers, verdict, version) {
2294
+ const accept = new Map();
1625
2295
  const archive = new Set();
1626
- const settle = (inState, chosen) => {
2296
+ const settle = (inState, chosen, pins) => {
1627
2297
  if (!inState.some((p) => chosen.has(p.variant)))
1628
2298
  return;
1629
2299
  for (const p of inState) {
1630
- if (chosen.has(p.variant))
1631
- accept.add(p.id);
1632
- else if (p.status !== "archived")
1633
- archive.add(p.id);
2300
+ if (!chosen.has(p.variant)) {
2301
+ if (isLive(p.status))
2302
+ archive.add(p.id);
2303
+ continue;
2304
+ }
2305
+ const pin = pins?.get(p.variant);
2306
+ // A version option outranks a looser pick of the same variant (the
2307
+ // built-in Which variant?, or the agent's own variant question) in the
2308
+ // same reply.
2309
+ if (pin === undefined && accept.has(p.id))
2310
+ continue;
2311
+ // A pinned version history retention has since dropped falls back to
2312
+ // what the stage showed, rather than pinning one nothing can render.
2313
+ accept.set(p.id, pin !== undefined && postAtVersion(p, pin) ? pin : versionAt(p, version));
1634
2314
  }
1635
2315
  };
1636
2316
  const builtins = Object.keys(answers)
@@ -1641,22 +2321,23 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1641
2321
  if (answer === undefined || ask.scope === "part")
1642
2322
  continue;
1643
2323
  const ids = Array.isArray(answer) ? answer : [answer];
1644
- const chosen = new Set(ask.options.filter((o) => ids.includes(o.id) && o.variant).map((o) => o.variant));
2324
+ const picked = ask.options.filter((o) => ids.includes(o.id) && o.variant);
2325
+ const pinned = picked.filter((o) => o.version !== undefined);
2326
+ if (pinned.length) {
2327
+ const state = optionState(ask, mock);
2328
+ settle(posts.filter((p) => p.state === state), new Set(pinned.map((o) => o.variant)), new Map(pinned.map((o) => [o.variant, o.version])));
2329
+ }
2330
+ const chosen = new Set(picked.filter((o) => o.version === undefined).map((o) => o.variant));
1645
2331
  if (chosen.size === 0)
1646
2332
  continue;
1647
2333
  const states = ask.scope === "state" ? [ask.state ?? null] : mock.states.length ? mock.states : [null];
1648
2334
  for (const state of states)
1649
2335
  settle(posts.filter((p) => p.state === state), chosen);
1650
2336
  }
1651
- if (decision?.kind === "accept") {
1652
- settle(posts.filter((p) => p.state === decision.state), new Set([decision.variant]));
1653
- }
1654
- else if (decision?.kind === "drop") {
1655
- const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
1656
- if (post)
1657
- archive.add(post.id);
2337
+ if (verdict?.kind === "use") {
2338
+ settle(posts.filter((p) => p.state === verdict.state), new Set([verdict.variant]));
1658
2339
  }
1659
- for (const id of accept)
2340
+ for (const id of accept.keys())
1660
2341
  archive.delete(id);
1661
2342
  return { accept, archive };
1662
2343
  }
@@ -1669,6 +2350,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1669
2350
  if (answer === undefined && other === undefined && note === undefined)
1670
2351
  return a;
1671
2352
  const next = { ...a };
2353
+ delete next.dismissed;
1672
2354
  if (answer !== undefined || other !== undefined) {
1673
2355
  delete next.answer;
1674
2356
  delete next.other;
@@ -1704,13 +2386,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1704
2386
  if (typeof draft === "string")
1705
2387
  return fail(400, draft);
1706
2388
  const text = str(body?.text, MAX_COMMENT_TEXT);
1707
- const decision = sanitizeDecision(body?.decision, mock, posts);
1708
- if (typeof decision === "string")
1709
- return fail(400, decision);
2389
+ const verdict = sanitizeVerdict(body?.verdict, mock, posts);
2390
+ if (typeof verdict === "string")
2391
+ return fail(400, verdict);
1710
2392
  const empty = !text &&
1711
- !decision &&
2393
+ !verdict &&
1712
2394
  !Object.keys(draft.answers).length &&
1713
- !Object.keys(draft.mix).length &&
2395
+ !Object.keys(draft.borrowed).length &&
1714
2396
  !Object.keys(draft.tuned).length &&
1715
2397
  !draft.comments.length &&
1716
2398
  !Object.keys(draft.others ?? {}).length &&
@@ -1725,7 +2407,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1725
2407
  mockId: mock.id,
1726
2408
  version: draft.version,
1727
2409
  answers: draft.answers,
1728
- mix: draft.mix,
2410
+ borrowed: draft.borrowed,
1729
2411
  tuned: draft.tuned,
1730
2412
  comments: draft.comments,
1731
2413
  ...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
@@ -1733,17 +2415,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1733
2415
  ...(draft.otherImages ? { otherImages: draft.otherImages } : {}),
1734
2416
  ...(draft.noteImages ? { noteImages: draft.noteImages } : {}),
1735
2417
  ...(text ? { text } : {}),
1736
- ...(decision ? { decision } : {}),
1737
2418
  };
1738
2419
  const asks = mock.asks.map((a) => sentAsk(a, draft));
1739
- const flips = replyFlips(mock, posts, draft.answers, decision);
2420
+ const flips = replyFlips(mock, posts, draft.answers, verdict, draft.version);
2421
+ if (verdict) {
2422
+ const target = posts.find((p) => p.state === verdict.state && p.variant === verdict.variant);
2423
+ const pinned = verdict.kind === "use" && target ? flips.accept.get(target.id) : undefined;
2424
+ payload.verdict = pinned === undefined ? verdict : { ...verdict, version: pinned };
2425
+ }
1740
2426
  const comment = await store.commitReply({
1741
2427
  mockId: mock.id,
1742
2428
  sessionId,
1743
2429
  text: text ?? "",
1744
2430
  payload,
1745
2431
  asks,
1746
- accept: [...flips.accept],
2432
+ accept: [...flips.accept].map(([id, version]) => ({ id, version })),
1747
2433
  archive: [...flips.archive],
1748
2434
  });
1749
2435
  if (!comment)
@@ -1751,6 +2437,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1751
2437
  announceComment(comment);
1752
2438
  bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
1753
2439
  bus.broadcast({ type: "draft-updated", mockId: mock.id });
2440
+ void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
1754
2441
  const after = await store.listPosts({ mockId: mock.id });
1755
2442
  for (const p of after) {
1756
2443
  if (flips.accept.has(p.id) || flips.archive.has(p.id)) {
@@ -1768,22 +2455,223 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1768
2455
  event: "decision",
1769
2456
  project: mock.project,
1770
2457
  slug: mock.slug,
1771
- variant: decision?.variant ?? "",
2458
+ variant: verdict?.variant ?? "",
1772
2459
  version: draft.version,
1773
- text: text || decision?.kind || "reply",
2460
+ text: text || verdict?.kind || "reply",
1774
2461
  url: mockUrl(ctx.base, mock),
1775
2462
  });
1776
2463
  const [delivered] = await withDelivered([comment]);
1777
2464
  return ok({
1778
2465
  reply: delivered,
1779
- accepted: after
2466
+ inUse: after
1780
2467
  .filter((p) => flips.accept.has(p.id))
1781
- .map((p) => ({ state: p.state, variant: p.variant })),
2468
+ .map((p) => ({ state: p.state, variant: p.variant, version: inUseVersion(p) })),
1782
2469
  archived: after
1783
2470
  .filter((p) => flips.archive.has(p.id))
1784
2471
  .map((p) => ({ state: p.state, variant: p.variant })),
1785
2472
  }, 201);
1786
2473
  }
2474
+ // "Use this": the user puts one exact version of a variant in use. It
2475
+ // archives the variant's live siblings in that state and reaches the agent
2476
+ // once, as a reply carrying the verdict, through the same cursor as a Send;
2477
+ // the user's unsent draft is left for their next Send.
2478
+ async function useFlow(ref, body, ctx) {
2479
+ if (!ctx.viewer)
2480
+ return viewerOnly();
2481
+ const mock = await resolveMock(ref);
2482
+ if (isResult(mock))
2483
+ return mock;
2484
+ const posts = await store.listPosts({ mockId: mock.id });
2485
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2486
+ if (isResult(chosen))
2487
+ return chosen;
2488
+ const version = Number(body?.version);
2489
+ if (!Number.isInteger(version) || version < 1) {
2490
+ return fail(400, '"version" must be a positive integer');
2491
+ }
2492
+ if (!postAtVersion(chosen, version)) {
2493
+ return fail(404, `${chosen.variant} has no version ${version}`, {
2494
+ versions: [chosen.version, ...chosen.history.map((h) => h.version)],
2495
+ });
2496
+ }
2497
+ const inUse = { state: chosen.state, variant: chosen.variant, version };
2498
+ if (chosen.status === "accepted" && chosen.acceptedVersion === version) {
2499
+ return ok({ reply: null, inUse, archived: [] });
2500
+ }
2501
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2502
+ const sessionId = mock.sessionId ?? latest?.sessionId;
2503
+ if (!sessionId)
2504
+ return fail(409, `${mock.slug} has no agent session to reply to`);
2505
+ const archive = posts.filter((p) => p.state === chosen.state && p.id !== chosen.id && isLive(p.status));
2506
+ const comment = await store.commitReply({
2507
+ mockId: mock.id,
2508
+ sessionId,
2509
+ text: "",
2510
+ payload: {
2511
+ mockId: mock.id,
2512
+ version,
2513
+ answers: {},
2514
+ borrowed: {},
2515
+ tuned: {},
2516
+ comments: [],
2517
+ verdict: { kind: "use", ...inUse },
2518
+ },
2519
+ asks: mock.asks,
2520
+ accept: [{ id: chosen.id, version }],
2521
+ archive: archive.map((p) => p.id),
2522
+ keepDraft: true,
2523
+ });
2524
+ if (!comment)
2525
+ return fail(409, `${mock.slug} has no agent session to reply to`);
2526
+ announceComment(comment);
2527
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2528
+ void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
2529
+ for (const p of [chosen, ...archive]) {
2530
+ bus.broadcast({
2531
+ type: "post-updated",
2532
+ id: p.id,
2533
+ mockId: mock.id,
2534
+ sessionId: p.sessionId,
2535
+ version: p.version,
2536
+ by: "user",
2537
+ });
2538
+ }
2539
+ fireNotify({
2540
+ event: "decision",
2541
+ project: mock.project,
2542
+ slug: mock.slug,
2543
+ variant: chosen.variant,
2544
+ version,
2545
+ text: "use",
2546
+ url: mockUrl(ctx.base, mock),
2547
+ });
2548
+ const [delivered] = await withDelivered([comment]);
2549
+ return ok({
2550
+ reply: delivered,
2551
+ inUse,
2552
+ archived: archive.map((p) => ({ state: p.state, variant: p.variant })),
2553
+ }, 201);
2554
+ }
2555
+ // The user hides one version of a variant from the history, or brings it
2556
+ // back. Hidden, never removed: replies and embeds may point at it, so it still
2557
+ // renders by URL. The version in use can't be hidden, nor a state's last.
2558
+ async function hideFlow(ref, body, ctx) {
2559
+ if (!ctx.viewer)
2560
+ return viewerOnly();
2561
+ const mock = await resolveMock(ref);
2562
+ if (isResult(mock))
2563
+ return mock;
2564
+ if (typeof body?.hidden !== "boolean")
2565
+ return fail(400, '"hidden" must be true or false');
2566
+ const posts = await store.listPosts({ mockId: mock.id });
2567
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2568
+ if (isResult(chosen))
2569
+ return chosen;
2570
+ const version = Number(body?.version);
2571
+ if (!Number.isInteger(version) || version < 1) {
2572
+ return fail(400, '"version" must be a positive integer');
2573
+ }
2574
+ if (!postAtVersion(chosen, version)) {
2575
+ return fail(404, `${chosen.variant} has no version ${version}`);
2576
+ }
2577
+ if (body.hidden) {
2578
+ if (chosen.status === "accepted" && inUseVersion(chosen) === version) {
2579
+ return fail(409, `v${version} of ${chosen.variant} is in use; use another version first`);
2580
+ }
2581
+ const visible = posts
2582
+ .filter((p) => p.state === chosen.state)
2583
+ .flatMap((p) => [p.version, ...p.history.map((h) => h.version)].map((v) => [p, v]))
2584
+ .filter(([p, v]) => !(p.hiddenVersions ?? []).includes(v));
2585
+ if (visible.length === 1 && visible[0][0].id === chosen.id && visible[0][1] === version) {
2586
+ return fail(409, "the last visible version can't be hidden");
2587
+ }
2588
+ }
2589
+ const updated = await store.setVersionHidden(chosen.id, version, body.hidden);
2590
+ if (!updated)
2591
+ return fail(404, "variant not found");
2592
+ bus.broadcast({
2593
+ type: "post-updated",
2594
+ id: updated.id,
2595
+ mockId: mock.id,
2596
+ sessionId: updated.sessionId,
2597
+ version: updated.version,
2598
+ by: "user",
2599
+ });
2600
+ return ok({
2601
+ state: updated.state,
2602
+ variant: updated.variant,
2603
+ version,
2604
+ hidden: body.hidden,
2605
+ });
2606
+ }
2607
+ // The user closes a mock from the project list: its open asks are dismissed
2608
+ // unanswered, an unsent draft goes with them, and the agent hears it once,
2609
+ // as a close through the same cursor as a Send.
2610
+ async function closeFlow(ref, ctx) {
2611
+ if (!ctx.viewer)
2612
+ return viewerOnly();
2613
+ const mock = await resolveMock(ref);
2614
+ if (isResult(mock))
2615
+ return mock;
2616
+ const posts = await store.listPosts({ mockId: mock.id });
2617
+ if (mock.closedAt)
2618
+ return ok({ mock: mockSummaryView(mock, posts), dismissed: [] });
2619
+ const dismissed = [...openAsks(mock).map((a) => a.id), ...builtinAskIds(mock, posts, mock.id)];
2620
+ const asks = mock.asks.map((a) => (askAnswered(a) ? a : { ...a, dismissed: true }));
2621
+ const at = new Date().toISOString();
2622
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2623
+ const sessionId = mock.sessionId ?? latest?.sessionId;
2624
+ const session = sessionId ? await store.getSession(sessionId) : null;
2625
+ const comment = session
2626
+ ? await store.commitReply({
2627
+ mockId: mock.id,
2628
+ sessionId: session.id,
2629
+ text: "",
2630
+ payload: {
2631
+ mockId: mock.id,
2632
+ version: Math.max(1, ...posts.map((p) => p.version)),
2633
+ answers: {},
2634
+ borrowed: {},
2635
+ tuned: {},
2636
+ comments: [],
2637
+ closed: { dismissed },
2638
+ },
2639
+ asks,
2640
+ accept: [],
2641
+ archive: [],
2642
+ closedAt: at,
2643
+ })
2644
+ : null;
2645
+ if (comment)
2646
+ announceComment(comment);
2647
+ else {
2648
+ // No agent to tell: the mock still leaves the open list.
2649
+ await store.updateMock(mock.id, { asks, closedAt: at });
2650
+ await store.putDraft(mock.id, null);
2651
+ }
2652
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2653
+ bus.broadcast({ type: "draft-updated", mockId: mock.id });
2654
+ const after = (await store.getMock(mock.id)) ?? mock;
2655
+ return ok({ mock: mockSummaryView(after, posts), dismissed });
2656
+ }
2657
+ // Back from Closed: the asks it dismissed are open again. The agent was
2658
+ // told they were dismissed; answers given now reach it as a normal Send.
2659
+ async function reopenFlow(ref, ctx) {
2660
+ if (!ctx.viewer)
2661
+ return viewerOnly();
2662
+ const mock = await resolveMock(ref);
2663
+ if (isResult(mock))
2664
+ return mock;
2665
+ const posts = await store.listPosts({ mockId: mock.id });
2666
+ if (!mock.closedAt)
2667
+ return ok({ mock: mockSummaryView(mock, posts) });
2668
+ const asks = mock.asks.map(({ dismissed: _, ...a }) => a);
2669
+ const updated = await store.updateMock(mock.id, { asks, closedAt: null });
2670
+ if (!updated)
2671
+ return fail(404, "mock not found");
2672
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2673
+ return ok({ mock: mockSummaryView(updated, posts) });
2674
+ }
1787
2675
  async function restoreFlow(ref, body) {
1788
2676
  const mock = await resolveMock(ref, body?.project);
1789
2677
  if (isResult(mock))
@@ -1803,7 +2691,72 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1803
2691
  version: updated.version,
1804
2692
  by: "user",
1805
2693
  });
1806
- return ok({ state: updated.state, variant: updated.variant, status: updated.status });
2694
+ return ok({
2695
+ state: updated.state,
2696
+ variant: updated.variant,
2697
+ status: variantStatus(updated.status),
2698
+ });
2699
+ }
2700
+ // The user archives a variant they're done with, short of choosing another:
2701
+ // Studio's × on its last version. Restore is the way back. The variant in
2702
+ // use stays, because archiving it would leave the state with nothing chosen
2703
+ // and no reply saying why.
2704
+ async function archiveFlow(ref, body, ctx) {
2705
+ if (!ctx.viewer)
2706
+ return viewerOnly();
2707
+ const mock = await resolveMock(ref);
2708
+ if (isResult(mock))
2709
+ return mock;
2710
+ const posts = await store.listPosts({ mockId: mock.id });
2711
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2712
+ if (isResult(chosen))
2713
+ return chosen;
2714
+ if (variantStatus(chosen.status) === "inUse")
2715
+ return fail(409, `${chosen.variant} is in use`);
2716
+ const updated = await store.setPostStatus(chosen.id, "archived");
2717
+ if (!updated)
2718
+ return fail(404, "variant not found");
2719
+ bus.broadcast({
2720
+ type: "post-updated",
2721
+ id: updated.id,
2722
+ mockId: mock.id,
2723
+ sessionId: updated.sessionId,
2724
+ version: updated.version,
2725
+ by: "user",
2726
+ });
2727
+ return ok({
2728
+ state: updated.state,
2729
+ variant: updated.variant,
2730
+ status: variantStatus(updated.status),
2731
+ });
2732
+ }
2733
+ // The user stars (or unstars) a variant: a favourite to come back to, which
2734
+ // decides nothing. Viewer-only; the agent reads it as `starred`.
2735
+ async function starFlow(ref, body, ctx) {
2736
+ if (!ctx.viewer)
2737
+ return viewerOnly();
2738
+ const mock = await resolveMock(ref);
2739
+ if (isResult(mock))
2740
+ return mock;
2741
+ if (typeof body?.starred !== "boolean")
2742
+ return fail(400, '"starred" must be true or false');
2743
+ const posts = await store.listPosts({ mockId: mock.id });
2744
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2745
+ if (isResult(chosen))
2746
+ return chosen;
2747
+ const updated = await store.setPostStarred(chosen.id, body.starred);
2748
+ if (!updated)
2749
+ return fail(404, "variant not found");
2750
+ bus.broadcast({
2751
+ type: "post-updated",
2752
+ id: updated.id,
2753
+ mockId: mock.id,
2754
+ sessionId: updated.sessionId,
2755
+ version: updated.version,
2756
+ by: "user",
2757
+ });
2758
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2759
+ return ok({ state: updated.state, variant: updated.variant, starred: !!updated.starred });
1807
2760
  }
1808
2761
  // D8 "restore as vN": the user brings an older version back as the newest
1809
2762
  // one. Distinct from restoreFlow, which un-archives a variant. Viewer-only
@@ -1853,6 +2806,216 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1853
2806
  bus.broadcast({ type: "mock-deleted", id: mock.id, project: mock.project });
1854
2807
  return ok({ ok: true });
1855
2808
  }
2809
+ // A write's feedback for the session that made it, when it named one: an
2810
+ // edit needs no session, so without one there is no cursor to read from.
2811
+ const feedbackFor = async (session, base) => typeof session === "string" && session ? collectFeedback(session, base) : [];
2812
+ // `edit`: a mock's own fields, without a new version. A new slug rewrites
2813
+ // every reference to the old one in the project (store.renameMock), so pages
2814
+ // keep showing it and flows keep walking it.
2815
+ async function editMockFlow(ref, body, ctx) {
2816
+ if (!body || typeof body !== "object")
2817
+ return fail(400, "invalid JSON body");
2818
+ const given = ["slug", "title", "kind", "area", "source"].filter((k) => body[k] !== undefined);
2819
+ if (given.length === 0) {
2820
+ return fail(400, 'provide one of "slug", "title", "kind", "area" or "source"');
2821
+ }
2822
+ const mock = await resolveMock(ref, body.project, body.session);
2823
+ if (isResult(mock))
2824
+ return mock;
2825
+ const slugIn = body.slug === undefined ? undefined : str(body.slug, MAX_TITLE);
2826
+ if (body.slug !== undefined && !slugIn)
2827
+ return fail(400, '"slug" must name the mock, e.g. "onni"');
2828
+ const slug = slugIn === undefined ? undefined : slugify(slugIn);
2829
+ const title = str(body.title, MAX_TITLE);
2830
+ if (body.title !== undefined && !title)
2831
+ return fail(400, '"title" must be non-empty text');
2832
+ if (body.kind !== undefined && !isMockKind(body.kind)) {
2833
+ return fail(400, '"kind" must be page, component or sketch');
2834
+ }
2835
+ const kind = body.kind;
2836
+ const area = labelArg(body, "area", MAX_LABEL);
2837
+ if (isResult(area))
2838
+ return area;
2839
+ const source = labelArg(body, "source", MAX_SOURCE);
2840
+ if (isResult(source))
2841
+ return source;
2842
+ const renaming = slug !== undefined && slug !== mock.slug;
2843
+ if (renaming && (await store.findMock(mock.project, slug))) {
2844
+ return fail(409, `${mock.project} already has a mock "${slug}"; pick another slug`);
2845
+ }
2846
+ // Being embedded is what makes a mock a component: a sketch that is still
2847
+ // embedded would be promoted back on its embedder's next publish.
2848
+ if (kind === "sketch" && mock.kind !== "sketch") {
2849
+ const embedders = await embeddersOf(mock.project, mock.slug);
2850
+ if (embedders.length) {
2851
+ return fail(409, `${mock.slug} is embedded in ${embedders.map((m) => m.slug).join(", ")}; being embedded makes it a component. Take the embeds out first`, { embeddedIn: embedders.map((m) => m.slug) });
2852
+ }
2853
+ }
2854
+ let next = mock;
2855
+ // The rename goes first: it is the part that can still lose a race for the slug.
2856
+ if (renaming) {
2857
+ const renamed = await store.renameMock(mock.id, slug);
2858
+ if (!renamed)
2859
+ return fail(409, `${mock.project} already has a mock "${slug}"`);
2860
+ next = renamed;
2861
+ clearRenderCache();
2862
+ }
2863
+ if (title !== undefined || kind !== undefined || area !== undefined || source !== undefined) {
2864
+ next =
2865
+ (await store.updateMock(mock.id, {
2866
+ ...(title === undefined ? {} : { title }),
2867
+ ...(kind === undefined ? {} : { kind }),
2868
+ ...(area === undefined ? {} : { area }),
2869
+ ...(source === undefined ? {} : { source }),
2870
+ })) ?? next;
2871
+ }
2872
+ bus.broadcast({ type: "mock-updated", id: next.id, project: next.project });
2873
+ if (renaming)
2874
+ await announceEmbedders(next);
2875
+ if (ctx.viewer)
2876
+ await tellAgentEdited(mock, next);
2877
+ // Flows are declared apart from kind, so a page that stops being one stays
2878
+ // in them until the agent takes it out.
2879
+ const nudges = [...titleNudges(title)];
2880
+ if (kind !== undefined && kind !== "page" && mock.kind === "page") {
2881
+ for (const f of await store.listFlows(next.project)) {
2882
+ if (!f.steps.some((x) => x.mock === next.slug))
2883
+ continue;
2884
+ nudges.push(`${next.slug} is still a step of flow "${f.name}"; publish it with flow "${f.name}" and step 0 to take it out`);
2885
+ }
2886
+ }
2887
+ return ok({
2888
+ mock: {
2889
+ id: next.id,
2890
+ project: next.project,
2891
+ slug: next.slug,
2892
+ title: next.title,
2893
+ kind: next.kind,
2894
+ ...placeOf(next),
2895
+ states: next.states,
2896
+ },
2897
+ ...(renaming ? { renamed: { from: mock.slug, to: next.slug } } : {}),
2898
+ url: mockUrl(ctx.base, next),
2899
+ ...(nudges.length ? { nudges } : {}),
2900
+ feedback: await feedbackFor(body.session, ctx.base),
2901
+ });
2902
+ }
2903
+ // The user moved a mock from Home (another kind or area): the agent hears it
2904
+ // once, through the same cursor as a Send, on the session driving the mock.
2905
+ // Nothing to tell when no agent has written it.
2906
+ async function tellAgentEdited(before, after) {
2907
+ const edited = {
2908
+ ...(after.kind !== before.kind ? { kind: after.kind } : {}),
2909
+ ...(after.area !== before.area ? { area: after.area } : {}),
2910
+ };
2911
+ if (Object.keys(edited).length === 0)
2912
+ return;
2913
+ const posts = await store.listPosts({ mockId: after.id });
2914
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2915
+ const sessionId = after.sessionId ?? latest?.sessionId;
2916
+ if (!sessionId)
2917
+ return;
2918
+ const comment = await store.commitReply({
2919
+ mockId: after.id,
2920
+ sessionId,
2921
+ text: "",
2922
+ payload: {
2923
+ mockId: after.id,
2924
+ version: Math.max(1, ...posts.map((p) => p.version)),
2925
+ answers: {},
2926
+ borrowed: {},
2927
+ tuned: {},
2928
+ comments: [],
2929
+ edited,
2930
+ },
2931
+ asks: after.asks,
2932
+ accept: [],
2933
+ archive: [],
2934
+ keepDraft: true,
2935
+ });
2936
+ if (comment)
2937
+ announceComment(comment);
2938
+ }
2939
+ const areaOrderOf = async (project) => {
2940
+ const raw = await store.getSetting(`${AREA_ORDER_SETTING}:${project}`);
2941
+ if (!raw)
2942
+ return [];
2943
+ try {
2944
+ const v = JSON.parse(raw);
2945
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
2946
+ }
2947
+ catch {
2948
+ return [];
2949
+ }
2950
+ };
2951
+ // `areaOrder`: the areas in the order Home lists them; [] (or "") hands the
2952
+ // order back to the alphabet.
2953
+ function areaOrderArg(v) {
2954
+ const list = typeof v === "string" ? v.split(",") : v;
2955
+ if (!Array.isArray(list) || list.some((x) => typeof x !== "string")) {
2956
+ return fail(400, '"areaOrder" must be a list of area names, e.g. ["Shell", "Food"]');
2957
+ }
2958
+ const names = [...new Set(list.map((x) => stripNul(x).trim()).filter(Boolean))];
2959
+ if (names.length > MAX_AREAS)
2960
+ return fail(400, `"areaOrder" takes at most ${MAX_AREAS} areas`);
2961
+ if (names.some((x) => x.length > MAX_LABEL)) {
2962
+ return fail(400, `an area name is at most ${MAX_LABEL} characters`);
2963
+ }
2964
+ return names;
2965
+ }
2966
+ // `edit` on a project: a new name for it, carrying its mocks, sessions,
2967
+ // flows and settings (refused onto a project that already has mocks), and
2968
+ // the order Home lists its areas in.
2969
+ async function editProjectFlow(name, body, ctx) {
2970
+ if (!body || typeof body !== "object")
2971
+ return fail(400, "invalid JSON body");
2972
+ const from = str(name, MAX_TITLE);
2973
+ if (!from)
2974
+ return fail(400, "provide the project to edit");
2975
+ if (body.name === undefined && body.areaOrder === undefined) {
2976
+ return fail(400, 'provide "name" (the project\'s new name) or "areaOrder"');
2977
+ }
2978
+ const to = body.name === undefined ? undefined : str(body.name, MAX_TITLE);
2979
+ if (body.name !== undefined && !to)
2980
+ return fail(400, '"name" must be the project\'s new name');
2981
+ if (to === from)
2982
+ return fail(400, `${from} already has that name`);
2983
+ const order = body.areaOrder === undefined ? undefined : areaOrderArg(body.areaOrder);
2984
+ if (isResult(order))
2985
+ return order;
2986
+ const mocks = await store.listMocks(from);
2987
+ if (mocks.length === 0)
2988
+ return fail(404, `no project "${from}" with mocks`);
2989
+ if (to !== undefined) {
2990
+ if (!(await store.renameProject(from, to))) {
2991
+ return fail(409, `${to} already has mocks; pick another name`);
2992
+ }
2993
+ clearRenderCache();
2994
+ }
2995
+ const project = to ?? from;
2996
+ if (order !== undefined) {
2997
+ await store.setSetting(`${AREA_ORDER_SETTING}:${project}`, JSON.stringify(order));
2998
+ }
2999
+ for (const m of mocks)
3000
+ bus.broadcast({ type: "mock-updated", id: m.id, project });
3001
+ const named = new Set(mocks.flatMap((m) => (m.area ? [m.area] : [])));
3002
+ const unnamed = (order ?? []).filter((a) => !named.has(a));
3003
+ return ok({
3004
+ project,
3005
+ ...(to === undefined ? {} : { renamed: { from, to } }),
3006
+ ...(order === undefined ? {} : { areaOrder: order }),
3007
+ mocks: mocks.length,
3008
+ url: `${ctx.base}/project/${encodeURIComponent(project)}`,
3009
+ ...(unnamed.length
3010
+ ? {
3011
+ nudges: [
3012
+ `no mock in ${project} has area ${unnamed.map((a) => `"${a}"`).join(", ")} yet; Home lists it once one does`,
3013
+ ],
3014
+ }
3015
+ : {}),
3016
+ feedback: await feedbackFor(body.session, ctx.base),
3017
+ });
3018
+ }
1856
3019
  // --- comments ---
1857
3020
  function numberInRange(value, min, max) {
1858
3021
  const n = Number(value);
@@ -2178,6 +3341,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2178
3341
  // tells the agent something ("2 of 3 answered", or nobody is looking).
2179
3342
  const pendingOf = (mock, posts) => ({
2180
3343
  mock: mock.slug,
3344
+ // Closed by the user: nothing there is waiting on them, or on you.
3345
+ ...(mock.closedAt ? { closed: true } : {}),
2181
3346
  viewerOpen: viewing.has(mock.id),
2182
3347
  draft: mock.draft
2183
3348
  ? {
@@ -2336,7 +3501,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2336
3501
  maxSize: MAX_ASSET_BYTES,
2337
3502
  onError: (c) => c.json({ error: `asset exceeds ${MAX_ASSET_BYTES} bytes` }, 413),
2338
3503
  });
2339
- const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN, new URL(c.req.url).origin);
3504
+ const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN, requestOrigin(c.req.raw));
2340
3505
  const injectHead = (text, head) => {
2341
3506
  const headClose = text.lastIndexOf("</head>");
2342
3507
  return headClose >= 0
@@ -2365,6 +3530,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2365
3530
  ? `window.__MOCKPIT_PUBLIC_READ__=${JSON.stringify(publicRead)};`
2366
3531
  : "",
2367
3532
  screenshots ? "window.__MOCKPIT_SCREENSHOTS__=true;" : "",
3533
+ renderRev ? `window.__MOCKPIT_RENDER_REV__=${JSON.stringify(renderRev)};` : "",
2368
3534
  ].join("");
2369
3535
  return injectHead(text, `<script>${config}</script>`);
2370
3536
  };
@@ -2388,7 +3554,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2388
3554
  // with every pixel-affecting input pinned in the image URL so the Worker can
2389
3555
  // cache it at the edge.
2390
3556
  const mockPreviewHead = (mock, post, request, mode, rendererGeneration) => {
2391
- const origin = new URL(request.url).origin;
3557
+ const origin = requestOrigin(request);
2392
3558
  const publicBasePath = requestBasePath(request);
2393
3559
  const canonical = `${origin}${publicBasePath}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
2394
3560
  const imageUrl = new URL(`${origin}${publicBasePath}/s/${post.id}.png`);
@@ -2423,7 +3589,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2423
3589
  // sandboxed surface documents at /s/:id are *meant* to be framed and carry
2424
3590
  // their own `sandbox` CSP header instead, so they never pass through here.
2425
3591
  c.header("Content-Security-Policy", "frame-ancestors 'self'");
2426
- const html = withAppHead(c.req.raw, await workspaceMode())(withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, { req: { url: c.req.url } }), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title));
3592
+ const html = withAppHead(c.req.raw, await workspaceMode())(withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, c), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title));
2427
3593
  if (!opts.preview)
2428
3594
  return html;
2429
3595
  const { mock, post } = opts.preview;
@@ -2467,7 +3633,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2467
3633
  if (!mock)
2468
3634
  return c.html(await configuredViewerHtml(c, { title: c.req.param("name") }));
2469
3635
  const posts = await store.listPosts({ mockId: mock.id });
2470
- const post = posts.find((p) => p.status !== "archived") ?? posts[0];
3636
+ const post = posts.find((p) => isLive(p.status)) ?? posts[0];
2471
3637
  return c.html(await configuredViewerHtml(c, {
2472
3638
  title: mock.title,
2473
3639
  ...(post ? { preview: { mock, post } } : {}),
@@ -2475,10 +3641,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2475
3641
  });
2476
3642
  // --- REST plumbing for the shared flows ---
2477
3643
  const flowCtx = (c, strict = true) => ({
2478
- base: `${new URL(c.req.url).origin}${requestBasePath(c.req.raw)}`,
2479
- // The browser sets Fetch Metadata on same-origin requests; only the trusted
2480
- // viewer may act as the user (drafts, replies, user-authored comments).
2481
- viewer: c.req.header("sec-fetch-site") === "same-origin",
3644
+ base: `${requestOrigin(c.req.raw)}${requestBasePath(c.req.raw)}`,
3645
+ // Only the trusted viewer may act as the user (drafts, replies,
3646
+ // user-authored comments); server/viewerRequest.ts says how it is known.
3647
+ viewer: isViewerRequest(c.req.raw),
2482
3648
  strict,
2483
3649
  request: c.req.raw,
2484
3650
  signal: c.req.raw.signal,
@@ -2488,6 +3654,17 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2488
3654
  const flag = (c, key) => c.req.query(key) === "1";
2489
3655
  // --- sessions ---
2490
3656
  app.get("/api/sessions", async (c) => {
3657
+ // A watcher started with every conversation finds that conversation's
3658
+ // session without creating one, and never mistakes a sibling's in the same
3659
+ // repo for its own.
3660
+ const key = c.req.query("key");
3661
+ if (key !== undefined) {
3662
+ const session = key ? await store.getSessionByKey(key) : null;
3663
+ if (!session)
3664
+ return c.json([]);
3665
+ const posts = await store.listPosts({ sessionId: session.id });
3666
+ return c.json([sessionRowView(session, posts.length)]);
3667
+ }
2491
3668
  const [sessions, counts] = await Promise.all([
2492
3669
  store.listSessions(),
2493
3670
  store.countPostsBySession(),
@@ -2573,12 +3750,27 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2573
3750
  variant: c.req.query("variant"),
2574
3751
  }, flowCtx(c)));
2575
3752
  });
3753
+ app.patch("/api/mocks/:id", async (c) => {
3754
+ const body = await jsonBody(c);
3755
+ // A slug names its project in the body or, as on the reads, in the query.
3756
+ const project = c.req.query("project");
3757
+ const withProject = body && typeof body === "object" && body.project === undefined && project
3758
+ ? { ...body, project }
3759
+ : body;
3760
+ return send(c, await editMockFlow(c.req.param("id"), withProject, flowCtx(c)));
3761
+ });
2576
3762
  app.delete("/api/mocks/:id", async (c) => send(c, await removeMockFlow(c.req.param("id"), { project: c.req.query("project") })));
2577
3763
  app.post("/api/mocks/:id/asks", async (c) => send(c, await askFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2578
3764
  app.get("/api/mocks/:id/draft", async (c) => send(c, await getDraftFlow(c.req.param("id"), flowCtx(c))));
2579
3765
  app.put("/api/mocks/:id/draft", async (c) => send(c, await putDraftFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2580
3766
  app.delete("/api/mocks/:id/draft", async (c) => send(c, await deleteDraftFlow(c.req.param("id"), flowCtx(c))));
2581
3767
  app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3768
+ app.post("/api/mocks/:id/use", async (c) => send(c, await useFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3769
+ app.post("/api/mocks/:id/hide", async (c) => send(c, await hideFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3770
+ app.post("/api/mocks/:id/close", async (c) => send(c, await closeFlow(c.req.param("id"), flowCtx(c))));
3771
+ app.post("/api/mocks/:id/reopen", async (c) => send(c, await reopenFlow(c.req.param("id"), flowCtx(c))));
3772
+ app.post("/api/mocks/:id/star", async (c) => send(c, await starFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3773
+ app.post("/api/mocks/:id/archive", async (c) => send(c, await archiveFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2582
3774
  app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
2583
3775
  app.post("/api/mocks/:id/variants/:postId/restore", async (c) => send(c, await restoreVersionFlow(c.req.param("id"), c.req.param("postId"), await jsonBody(c), flowCtx(c))));
2584
3776
  app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
@@ -2708,7 +3900,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2708
3900
  });
2709
3901
  // The project's design system, imported by `mockpit init` and injected into
2710
3902
  // every html surface of the project (see renderHtmlPage).
3903
+ app.get("/api/projects/:name/tree", async (c) => send(c, await treeFlow({ project: c.req.param("name") })));
3904
+ app.patch("/api/projects/:name", async (c) => send(c, await editProjectFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
2711
3905
  app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
3906
+ // A project's rules file (.mockpit/rules.md, sent by `mockpit rules` and
3907
+ // `mockpit init`) and the user's own (`mockpit rules --mine`).
3908
+ app.get("/api/projects/:name/rules", async (c) => c.json({ text: (await store.getSetting(`${RULES_SETTING}:${c.req.param("name")}`)) ?? "" }));
3909
+ app.put("/api/projects/:name/rules", async (c) => send(c, await saveRules(`${RULES_SETTING}:${c.req.param("name")}`, await jsonBody(c))));
3910
+ app.get("/api/rules", async (c) => c.json({ text: (await userRulesText()) ?? "" }));
3911
+ app.put("/api/rules", async (c) => send(c, await saveRules(USER_RULES_SETTING, await jsonBody(c))));
2712
3912
  app.put("/api/projects/:name/design", async (c) => {
2713
3913
  const body = await c.req.json().catch(() => null);
2714
3914
  if (!body || typeof body !== "object")
@@ -2986,7 +4186,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2986
4186
  // agent publishing over one origin must never be served to a viewer on another.
2987
4187
  // `knobs` is the canonical (validated, path-sorted) JSON of `?k=`, so two
2988
4188
  // spellings of the same values share an entry.
2989
- const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}`;
4189
+ const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}:${o.embeds ?? ""}`;
2990
4190
  // The document itself. Shared by the GET below and the publish-time pre-warm,
2991
4191
  // so both produce byte-identical output for one key.
2992
4192
  async function buildSurfaceDoc(args) {
@@ -2994,12 +4194,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2994
4194
  const theme = themeById(DEFAULT_THEME_ID);
2995
4195
  const themeId = theme.id;
2996
4196
  if (surface.kind === "html") {
2997
- // A page mock composes published components by reference; the tags
2998
- // are expanded here, server-side, so the whole page is still ONE
2999
- // sandboxed document rather than nested frames.
3000
- const html = args.mock?.kind === "page"
3001
- ? await expandPageHtml(args.mock.project, args.post, surface.html)
3002
- : surface.html;
4197
+ // Embedded components are expanded server-side (the caller passes the
4198
+ // expansion), so the whole page is still ONE sandboxed document rather
4199
+ // than nested frames.
4200
+ const html = args.composed ?? surface.html;
3003
4201
  return renderHtmlPage({
3004
4202
  title: args.title,
3005
4203
  html,
@@ -3026,9 +4224,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3026
4224
  // The runtime's module cache makes every later call cheap, so there's no memo
3027
4225
  // here to keep in sync. On the Worker the module is already inside the
3028
4226
  // deployed bundle — the import defers evaluating it, not fetching it — so this
3029
- // needs no network at runtime. test/workerIntegration covers that on real
3030
- // workerd, because a dynamic import resolving differently there is exactly the
3031
- // way this optimization could break in production and nowhere else.
4227
+ // needs no network at runtime. Check it on real workerd (`npm run dev:cloud`):
4228
+ // a dynamic import resolving differently there is exactly the way this
4229
+ // optimization could break in production and nowhere else.
3032
4230
  const { renderCode, renderDiff, renderMarkdown, renderTerminal } = await import("./richRender.js");
3033
4231
  const rendered = surface.kind === "markdown"
3034
4232
  ? await renderMarkdown(surface, { theme: themeId, mode })
@@ -3062,8 +4260,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3062
4260
  if (!isSandboxedSurfaceKind(surface.kind))
3063
4261
  continue;
3064
4262
  warmed++;
4263
+ const composed = surface.kind === "html" && hasEmbedTags(surface.html)
4264
+ ? await composedHtml(mock.project, mock.slug, surface.html)
4265
+ : null;
3065
4266
  for (const mode of ["light", "dark"]) {
3066
- const key = renderKey({ postId: post.id, idx, version: post.version, mode, origin });
4267
+ const key = renderKey({
4268
+ postId: post.id,
4269
+ idx,
4270
+ version: post.version,
4271
+ mode,
4272
+ origin,
4273
+ embeds: composed ? embedsKey(composed) : undefined,
4274
+ });
3067
4275
  if (renderCache.has(key))
3068
4276
  continue;
3069
4277
  await cachedRender(key, () => buildSurfaceDoc({
@@ -3075,6 +4283,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3075
4283
  mode,
3076
4284
  origin,
3077
4285
  design,
4286
+ composed: composed?.html,
3078
4287
  }));
3079
4288
  }
3080
4289
  }
@@ -3088,7 +4297,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3088
4297
  const modeParam = c.req.query("mode");
3089
4298
  const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
3090
4299
  const kParam = c.req.query("k");
3091
- const origin = new URL(c.req.url).origin;
4300
+ // `e`: the embeds key the caller saw (cache identity); `embed`: a peek at
4301
+ // older component versions, JSON {slug: version}.
4302
+ const eParam = c.req.query("e");
4303
+ const embedParam = c.req.query("embed");
4304
+ const origin = requestOrigin(c.req.raw);
3092
4305
  // Cache-first. A hit needs nothing from the post row, and the row is the
3093
4306
  // expensive part of this route (surfaces + every retained version). Only a
3094
4307
  // version-pinned request can take this path: without `ver` the current
@@ -3096,13 +4309,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3096
4309
  // request never does: its values are checked against the mock's knobs,
3097
4310
  // which can change under a pinned version.
3098
4311
  const pinned = Number(ver);
3099
- if (Number.isInteger(pinned) && pinned > 0 && kParam === undefined) {
4312
+ // A document with embeds is stored only under the key its resolved embeds
4313
+ // give, so a hit here was rendered with exactly the `e` this URL names.
4314
+ if (Number.isInteger(pinned) && pinned > 0 && kParam === undefined && !embedParam) {
3100
4315
  const hit = renderCacheHit(renderKey({
3101
4316
  postId: c.req.param("id"),
3102
4317
  idx: Number(surfaceParam),
3103
4318
  version: pinned,
3104
4319
  mode,
3105
4320
  origin,
4321
+ embeds: eParam,
3106
4322
  }));
3107
4323
  if (hit !== undefined) {
3108
4324
  surfaceDocHeaders(c, true);
@@ -3158,16 +4374,59 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3158
4374
  knobsKey = JSON.stringify(knobs);
3159
4375
  }
3160
4376
  }
4377
+ let composed = null;
4378
+ if (surface.kind === "html" && hasEmbedTags(surface.html) && mock) {
4379
+ let overrides;
4380
+ if (embedParam !== undefined) {
4381
+ let raw;
4382
+ try {
4383
+ raw = JSON.parse(embedParam);
4384
+ }
4385
+ catch {
4386
+ raw = null;
4387
+ }
4388
+ const ok = raw !== null &&
4389
+ typeof raw === "object" &&
4390
+ !Array.isArray(raw) &&
4391
+ Object.values(raw).every((v) => Number.isInteger(v) && v > 0);
4392
+ if (!ok || embedParam.length > MAX_KNOB_QUERY) {
4393
+ surfaceDocHeaders(c, false);
4394
+ return c.text("embed must be URL-encoded JSON of {slug: version}", 400);
4395
+ }
4396
+ overrides = raw;
4397
+ }
4398
+ composed = await composedHtml(mock.project, mock.slug, surface.html, overrides);
4399
+ }
4400
+ const eKey = composed ? embedsKey(composed) : undefined;
3161
4401
  // Every input is in the URL (the palette is fixed, the mode is a query
3162
- // param), so a version-pinned document never changes.
3163
- surfaceDocHeaders(c, ver != null);
4402
+ // param), so a version-pinned document never changes, unless it follows a
4403
+ // component's latest version and the URL does not name that state (`e`).
4404
+ surfaceDocHeaders(c, ver != null && (eKey === undefined || eKey === eParam));
3164
4405
  // Cache the finished document under the same key the pre-warm uses; the
3165
- // resolved `version` makes it immutable, so a hit is always correct.
3166
- // A page's slots are pinned at publish, so they are a function of
3167
- // (id, version) too and need no separate key component.
3168
- const cacheKey = renderKey({ postId: post.id, idx, version, mode, origin, knobs: knobsKey });
4406
+ // resolved `version` and embeds key make it immutable, so a hit is always
4407
+ // correct.
4408
+ const cacheKey = renderKey({
4409
+ postId: post.id,
4410
+ idx,
4411
+ version,
4412
+ mode,
4413
+ origin,
4414
+ knobs: knobsKey,
4415
+ embeds: eKey,
4416
+ });
3169
4417
  const design = mock ? await designFor(mock.project) : null;
3170
- const doc = await cachedRender(cacheKey, async () => buildSurfaceDoc({ post, mock, surface, title, version, mode, origin, design, knobs }));
4418
+ const doc = await cachedRender(cacheKey, async () => buildSurfaceDoc({
4419
+ post,
4420
+ mock,
4421
+ surface,
4422
+ title,
4423
+ version,
4424
+ mode,
4425
+ origin,
4426
+ design,
4427
+ knobs,
4428
+ composed: composed?.html,
4429
+ }));
3171
4430
  return c.html(doc);
3172
4431
  };
3173
4432
  app.get("/s/:id", renderPostPage);
@@ -3228,7 +4487,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3228
4487
  const result = await uploadAsset(body);
3229
4488
  if ("error" in result)
3230
4489
  return c.json({ error: result.error }, result.status);
3231
- const origin = new URL(c.req.url).origin;
4490
+ const origin = requestOrigin(c.req.raw);
3232
4491
  const publicBasePath = requestBasePath(c.req.raw);
3233
4492
  const assetPath = encodeURIComponent(result.asset.id);
3234
4493
  return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201);
@@ -3382,11 +4641,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3382
4641
  const mcpFlows = {
3383
4642
  publish: (body, ctx) => publishFlow(body, ctx),
3384
4643
  list: (query) => listMocksFlow(query),
4644
+ tree: (query) => treeFlow(query),
3385
4645
  get: (ref, query) => getMockFlow(ref, query),
3386
4646
  ask: (ref, body, ctx) => askFlow(ref, body, ctx),
3387
4647
  exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
3388
4648
  say: (ref, body, ctx) => sayFlow(ref, body, ctx),
3389
4649
  feedback: (query, ctx) => feedbackFlow(query, ctx),
4650
+ edit: (ref, body, ctx) => editMockFlow(ref, body, ctx),
4651
+ editProject: (name, body, ctx) => editProjectFlow(name, body, ctx),
3390
4652
  };
3391
4653
  const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
3392
4654
  // --- run: an agent script over the same flows (server/run.ts) ---