mockpit 1.2.0 → 1.3.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 (51) hide show
  1. package/README.md +13 -8
  2. package/bin/mockpit.js +116 -16
  3. package/dist/mcp/server.js +10 -7
  4. package/dist/server/apiViews.d.ts +118 -17
  5. package/dist/server/apiViews.js +66 -8
  6. package/dist/server/app.d.ts +2 -1
  7. package/dist/server/app.js +917 -144
  8. package/dist/server/decisions.d.ts +23 -0
  9. package/dist/server/decisions.js +99 -0
  10. package/dist/server/designGuide.js +45 -10
  11. package/dist/server/embeds.d.ts +25 -0
  12. package/dist/server/embeds.js +118 -0
  13. package/dist/server/eventLog.js +2 -1
  14. package/dist/server/feedbackBatch.d.ts +12 -1
  15. package/dist/server/feedbackBatch.js +20 -3
  16. package/dist/server/fills.d.ts +41 -0
  17. package/dist/server/fills.js +328 -0
  18. package/dist/server/index.js +15 -1
  19. package/dist/server/knobs.d.ts +6 -2
  20. package/dist/server/knobs.js +11 -1
  21. package/dist/server/mcpHttp.d.ts +3 -0
  22. package/dist/server/mcpHttp.js +3 -0
  23. package/dist/server/mcpSpec.d.ts +13 -13
  24. package/dist/server/mcpSpec.js +73 -8
  25. package/dist/server/postSurfaces.js +3 -12
  26. package/dist/server/richRender.js +3 -1
  27. package/dist/server/run.js +4 -0
  28. package/dist/server/runApi.d.ts +1 -1
  29. package/dist/server/runApi.js +4 -3
  30. package/dist/server/sqlStore.d.ts +89 -4
  31. package/dist/server/sqlStore.js +149 -23
  32. package/dist/server/surfacePage.d.ts +6 -2
  33. package/dist/server/surfacePage.js +142 -10
  34. package/dist/server/themes.d.ts +2 -0
  35. package/dist/server/themes.js +23 -3
  36. package/dist/server/types.d.ts +73 -11
  37. package/dist/server/types.js +42 -2
  38. package/dist/server/viewerRequest.d.ts +2 -0
  39. package/dist/server/viewerRequest.js +14 -0
  40. package/guide/topics/asks.md +50 -3
  41. package/guide/topics/feedback.md +89 -11
  42. package/guide/topics/html.md +5 -1
  43. package/guide/topics/http.md +2 -1
  44. package/guide/topics/knobs.md +2 -1
  45. package/guide/topics/surfaces.md +24 -8
  46. package/package.json +4 -13
  47. package/skills/launch-video/SKILL.md +1 -1
  48. package/skills/mockpit/SKILL.md +12 -4
  49. package/viewer/dist/index.html +8 -8
  50. package/dist/server/slots.d.ts +0 -14
  51. package/dist/server/slots.js +0 -71
@@ -5,7 +5,7 @@ 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, 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";
@@ -13,19 +13,22 @@ import { buildFeedbackBatches } from "./feedbackBatch.js";
13
13
  import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
14
14
  import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
15
15
  import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
16
+ import { chosenFills, decisionsFor, settledValues } from "./decisions.js";
17
+ import { composeFills, slotsInSurfaces, spliceFills } from "./fills.js";
16
18
  import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
17
19
  import { diffParts, partsInSurfaces, spliceParts } from "./parts.js";
18
20
  import { postToMarkdown } from "./postMarkdown.js";
19
21
  import { addHook, addSubscription, isPushSubscription, listHooks, notify, removeHook, vapidKeys, } from "./push.js";
20
- import { expandSlots, parseSlotTags } from "./slots.js";
22
+ import { embedsKey, expandHtml, hasEmbedTags, parseEmbedTags, } from "./embeds.js";
21
23
  import { registerMcp } from "./mcpHttp.js";
22
24
  import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
23
25
  import { logMiddleware, writeLog } from "./eventLog.js";
24
26
  import { RUN_API } from "./runApi.js";
25
- import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
27
+ import { escapeHtml, renderHtmlPage, renderMermaidPage, knobAttributes, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
26
28
  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";
29
+ import { askAnswered, builtinAsk, builtinAskIds, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, inUseVersion, isLive, optionState, newId, openAsks, openCount, OTHER_ID, REACTIONS, projectFromCwd, postAtVersion, versionAt, reservedAgent, sameScreen, slugify, surfacesByteLength, } from "./types.js";
28
30
  import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
31
+ import { isViewerRequest } from "./viewerRequest.js";
29
32
  const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
30
33
  // A held long-poll (watch, the viewer) re-arms rather than pinning a socket
31
34
  // for good; proxies and serverless hosts drop longer requests anyway.
@@ -165,8 +168,14 @@ const fail = (status, error, extra = {}) => ({
165
168
  status,
166
169
  body: { error, ...extra },
167
170
  });
171
+ const MAX_NOTE = 400;
172
+ const MAX_WIDTHS = 4;
173
+ const MIN_WIDTH = 280;
174
+ const MAX_WIDTH = 2560;
168
175
  const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
169
176
  const MAX_LABEL = 120;
177
+ const MAX_FLOWS = 50;
178
+ const MAX_FLOW_STEPS = 100;
170
179
  const MAX_ASKS = 20;
171
180
  const MAX_OPTIONS = 20;
172
181
  const MAX_DRAFT_COMMENTS = 50;
@@ -177,7 +186,7 @@ const APP_FILES = new Set([
177
186
  ...ICON_SIZES.map((s) => `/icon-${s}.png`),
178
187
  ]);
179
188
  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, }) {
189
+ export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, renderRev, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
181
190
  const app = new Hono();
182
191
  // The scripts topic carries the run API from runApi.ts, its one source.
183
192
  if (topics.scripts)
@@ -195,7 +204,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
195
204
  // warm entry, it can never be served to the wrong caller (renderKey includes it).
196
205
  let lastOrigin = null;
197
206
  app.use("*", (c, next) => {
198
- lastOrigin = new URL(c.req.url).origin;
207
+ lastOrigin = requestOrigin(c.req.raw);
199
208
  c.header("Referrer-Policy", "no-referrer");
200
209
  return next();
201
210
  });
@@ -285,7 +294,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
285
294
  function surfaceDocHeaders(c, immutable) {
286
295
  c.header("X-Content-Type-Options", "nosniff");
287
296
  c.header("Content-Security-Policy", "sandbox allow-scripts");
288
- c.header("Cache-Control", immutable ? "public, max-age=31536000, immutable" : "private, no-cache");
297
+ const current = !renderRev || c.req.query("r") === renderRev;
298
+ c.header("Cache-Control", immutable && current ? "public, max-age=31536000, immutable" : "private, no-cache");
289
299
  }
290
300
  function renderCacheHit(key) {
291
301
  const hit = renderCache.get(key);
@@ -329,6 +339,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
329
339
  return withLeading.slice(0, end);
330
340
  };
331
341
  const requestBasePath = (request) => normalizeBasePath(typeof basePath === "function" ? basePath(request) : basePath);
342
+ // Behind a TLS-terminating proxy (tailscale serve, a tunnel) the request
343
+ // reaches us as http while the browser is on https. A surface bakes this
344
+ // origin into its script URLs and CSP, and an http script inside an https
345
+ // page is blocked as mixed content: the frame then never runs its bridge, so
346
+ // live knobs and part reports silently stop. Trusting the header is safe
347
+ // here: it only shapes the caller's own response.
348
+ const requestOrigin = (request) => {
349
+ const url = new URL(request.url);
350
+ const proto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
351
+ if (proto === "https" || proto === "http")
352
+ url.protocol = `${proto}:`;
353
+ return url.origin;
354
+ };
332
355
  // Cached, fail-silent update lookup: being offline or rate-limited must
333
356
  // cost nothing but the absence of the notice. Failures are cached too, so
334
357
  // a dead network doesn't retry on every viewer load.
@@ -588,7 +611,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
588
611
  variants: posts.map(variantLabel),
589
612
  });
590
613
  }
591
- const live = candidates.filter((p) => p.status !== "archived");
614
+ const live = candidates.filter((p) => isLive(p.status));
592
615
  if (live.length === 1 && !variant)
593
616
  return live[0];
594
617
  return fail(400, `${mock.slug} has several variants; pass ${variant ? "state" : "variant"}`, {
@@ -683,57 +706,126 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
683
706
  }
684
707
  return null;
685
708
  }
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) {
709
+ // The component post an embed shows: the named variant of its first state,
710
+ // else the accepted one there, else "default", else the first live one.
711
+ async function embedTarget(project, slug, variant) {
704
712
  const mock = await store.findMock(project, slug);
705
713
  if (!mock)
706
714
  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))
720
- continue;
721
- const target = await slotTarget(project, tag.slug, tag.variant);
722
- if (!target) {
723
- bodies.set(cacheKey, null);
715
+ const state = mock.states[0] ?? null;
716
+ if (variant)
717
+ return store.findPost(mock.id, state, variant);
718
+ const inState = (await store.listPosts({ mockId: mock.id })).filter((p) => p.state === state);
719
+ return (inState.find((p) => p.status === "accepted") ??
720
+ inState.find((p) => p.variant === DEFAULT_VARIANT && isLive(p.status)) ??
721
+ inState.find((p) => isLive(p.status)) ??
722
+ inState[0] ??
723
+ null);
724
+ }
725
+ // Resolves tags to bodies: the tag's version if it pins one, else a peek
726
+ // (`overrides`, slug → version) the viewer asked for, else the latest. The
727
+ // body shows the component as decided: its slots composed to the fills its
728
+ // settled values chose, its other settled values on the wrapper.
729
+ function embedResolver(project, overrides = {}) {
730
+ return async (tag) => {
731
+ const post = await embedTarget(project, tag.slug, tag.variant);
732
+ if (!post)
733
+ return null;
734
+ const version = tag.version ?? overrides[tag.slug] ?? inUseVersion(post);
735
+ const surfaces = version === post.version
736
+ ? post.surfaces
737
+ : post.history.find((h) => h.version === version)?.surfaces;
738
+ const body = surfaces?.find((x) => x.kind === "html");
739
+ if (!surfaces || !body || body.kind !== "html")
740
+ return null;
741
+ const mock = await store.getMock(post.mock);
742
+ const settled = mock
743
+ ? settledValues(await replyPayloads(mock.id), mock, await store.listPosts({ mockId: mock.id }))
744
+ : {};
745
+ const slots = slotsInSurfaces(surfaces);
746
+ const fills = new Set(slots.map((sl) => sl.part));
747
+ const rest = Object.fromEntries(Object.entries(settled).filter(([p]) => !fills.has(p)));
748
+ return {
749
+ variant: post.variant,
750
+ version,
751
+ html: slots.length ? composeFills(body.html, chosenFills(slots, settled)) : body.html,
752
+ attrs: knobAttributes(rest).attrs,
753
+ ...(Object.keys(settled).length ? { stamp: JSON.stringify(settled) } : {}),
754
+ };
755
+ };
756
+ }
757
+ // A surface's html with its embeds expanded, and what they resolved to.
758
+ async function composedHtml(project, slug, html, overrides) {
759
+ if (!hasEmbedTags(html))
760
+ return { html, used: [], stamps: [], cycle: null };
761
+ return expandHtml(html, embedResolver(project, overrides), [slug]);
762
+ }
763
+ // The component versions a publish renders with, recorded on the version;
764
+ // an embed chain that loops back to this mock is refused.
765
+ async function publishEmbeds(project, slug, surfaces) {
766
+ const html = surfaces.find((x) => x.kind === "html");
767
+ if (!html || html.kind !== "html")
768
+ return [];
769
+ const { used, cycle } = await composedHtml(project, slug, html.html);
770
+ if (cycle) {
771
+ return fail(400, `embeds loop back on themselves: ${cycle.join(" → ")}`, { cycle });
772
+ }
773
+ return used;
774
+ }
775
+ // What the variants of a mock show through their embeds now: per post, the
776
+ // resolved list (nested included) with each component's versions, and the
777
+ // key that changes when any of them does.
778
+ async function embedViews(mock, posts) {
779
+ const out = new Map();
780
+ for (const post of posts) {
781
+ const html = post.surfaces.find((x) => x.kind === "html");
782
+ if (!html || html.kind !== "html" || !hasEmbedTags(html.html))
724
783
  continue;
784
+ const composed = await composedHtml(mock.project, mock.slug, html.html);
785
+ const { used } = composed;
786
+ const list = [];
787
+ for (const e of used) {
788
+ const target = await embedTarget(mock.project, e.slug, e.variant);
789
+ const versions = target
790
+ ? [target.version, ...target.history.map((h) => h.version)].sort((a, b) => b - a)
791
+ : [e.version];
792
+ list.push({ ...e, latest: target?.version ?? e.version, versions });
725
793
  }
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);
794
+ out.set(post.id, { key: embedsKey(composed), list });
731
795
  }
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
- });
796
+ return out;
797
+ }
798
+ // The mocks whose html embeds this slug, directly or through other
799
+ // components: they render differently once it changes.
800
+ async function embeddersOf(project, slug) {
801
+ const mocks = await store.listMocks(project);
802
+ const tagsOf = new Map();
803
+ for (const m of mocks) {
804
+ const slugs = new Set();
805
+ for (const p of await store.listPosts({ mockId: m.id })) {
806
+ for (const x of p.surfaces) {
807
+ if (x.kind === "html")
808
+ for (const t of parseEmbedTags(x.html))
809
+ slugs.add(t.slug);
810
+ }
811
+ }
812
+ tagsOf.set(m.id, slugs);
813
+ }
814
+ const reached = new Set([slug]);
815
+ const out = [];
816
+ for (let grew = true; grew;) {
817
+ grew = false;
818
+ for (const m of mocks) {
819
+ if (reached.has(m.slug))
820
+ continue;
821
+ if ([...(tagsOf.get(m.id) ?? [])].some((s) => reached.has(s))) {
822
+ reached.add(m.slug);
823
+ out.push(m);
824
+ grew = true;
825
+ }
826
+ }
827
+ }
828
+ return out;
737
829
  }
738
830
  // Knobs with a handful of discrete choices are usually decisions dressed as
739
831
  // controls: the user can't compare options they only see one at a time.
@@ -752,7 +844,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
752
844
  // chat instead. The nudge carries a ready ask: mock-wide when every state has
753
845
  // the same variant names, else for this state.
754
846
  function unboundVariants(mock, posts, state) {
755
- const live = (s) => posts.filter((p) => p.state === s && p.status !== "archived").map((p) => p.variant);
847
+ const live = (s) => posts.filter((p) => p.state === s && isLive(p.status)).map((p) => p.variant);
756
848
  const names = live(state);
757
849
  if (names.length < 2)
758
850
  return null;
@@ -821,7 +913,27 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
821
913
  const posts = await store.listPosts({ mockId: mock.id });
822
914
  const detail = mockDetailView(mock, posts);
823
915
  const unbound = unboundVariants(mock, posts, post.state);
824
- const nudges = [...(extra.nudges ?? []), ...(unbound ? [unbound.nudge] : [])];
916
+ // A slot no ask sets is a decision nobody was asked; hand over the ask.
917
+ const slots = slotsInSurfaces(post.surfaces);
918
+ const setPaths = new Set(mock.asks.flatMap((a) => a.options.flatMap((o) => Object.keys(o.set ?? {}))));
919
+ const slotAsks = slots
920
+ .filter((sl) => !setPaths.has(sl.part))
921
+ .map((sl) => ({
922
+ id: sl.part,
923
+ text: `Which ${sl.label ?? sl.part}?`,
924
+ scope: "part",
925
+ part: sl.part,
926
+ options: sl.fills.map((f) => ({ label: f.label ?? f.name, set: { [sl.part]: f.name } })),
927
+ }));
928
+ const nudges = [
929
+ ...(extra.nudges ?? []),
930
+ ...(unbound ? [unbound.nudge] : []),
931
+ ...(slotAsks.length
932
+ ? [
933
+ `${slotAsks.map((a) => `slot "${a.part}"`).join(", ")} ${slotAsks.length === 1 ? "has" : "have"} fills and no ask sets ${slotAsks.length === 1 ? "it" : "them"}: send suggestedSlotAsks with ask (reword the text in the user's terms)`,
934
+ ]
935
+ : []),
936
+ ];
825
937
  let partChanges;
826
938
  if (extra.previous) {
827
939
  partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
@@ -848,13 +960,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
848
960
  sessionId: post.sessionId,
849
961
  url: mockUrl(ctx.base, mock, post),
850
962
  parts: detail.parts,
963
+ ...(slots.length ? { slots } : {}),
851
964
  ...(extra.applied ? { applied: extra.applied } : {}),
852
965
  ...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
853
966
  ? { partChanges }
854
967
  : {}),
855
968
  ...(nudges.length ? { nudges } : {}),
856
969
  ...(unbound ? { suggestedAsk: unbound.ask } : {}),
970
+ ...(slotAsks.length ? { suggestedSlotAsks: slotAsks } : {}),
857
971
  ...(warnings.length ? { warnings } : {}),
972
+ ...(extra.flow ? { flow: extra.flow } : {}),
858
973
  feedback,
859
974
  }, extra.status ?? 200);
860
975
  }
@@ -868,26 +983,46 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
868
983
  by,
869
984
  });
870
985
  warmPost(post, mock);
986
+ void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
987
+ }
988
+ // A component's new version changes every mock that shows it: their views
989
+ // carry a new embeds key, so open viewers refetch and reload those frames.
990
+ async function announceEmbedders(mock) {
991
+ for (const m of await embeddersOf(mock.project, mock.slug)) {
992
+ bus.broadcast({ type: "mock-updated", id: m.id, project: m.project });
993
+ }
871
994
  }
872
995
  // The one write: publish one variant of one state of a mock. An existing
873
996
  // (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.
997
+ // Exactly one body: `html`, the full ordered `surfaces` list, or `parts` or
998
+ // `fills` spliced into the variant's latest (or `from`) version.
876
999
  async function publishFlow(body, ctx) {
877
1000
  if (!body || typeof body !== "object")
878
1001
  return fail(400, "invalid JSON body");
879
1002
  const slugIn = str(body.mock, MAX_TITLE);
880
1003
  if (!slugIn)
881
1004
  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"');
1005
+ const given = ["html", "surfaces", "parts", "fills"].filter((k) => body[k] !== undefined);
1006
+ const forkIn = forkArg(body.fork);
1007
+ if (isResult(forkIn))
1008
+ return forkIn;
1009
+ const flowIn = flowArg(body);
1010
+ if (isResult(flowIn))
1011
+ return flowIn;
1012
+ // `flow` alone places an existing screen in a flow without a new version.
1013
+ const flowOnly = given.length === 0 && !forkIn && !!flowIn;
1014
+ if (given.length === 0 && !forkIn && !flowIn) {
1015
+ return fail(400, 'provide "html", "surfaces", "parts" or "fills" (or "fork" alone)');
1016
+ }
885
1017
  if (given.length > 1) {
886
- return fail(400, `pass one of "html", "surfaces" or "parts", not ${given.join(" and ")}`);
1018
+ return fail(400, `pass one of "html", "surfaces", "parts" or "fills", not ${given.join(" and ")}`);
887
1019
  }
888
- const partEdits = partEditsArg(body);
1020
+ const partEdits = editsArg(body, "parts");
889
1021
  if (isResult(partEdits))
890
1022
  return partEdits;
1023
+ const fillEdits = editsArg(body, "fills");
1024
+ if (isResult(fillEdits))
1025
+ return fillEdits;
891
1026
  const knobs = checkKnobs(body.knobs);
892
1027
  if (!knobs.ok)
893
1028
  return fail(400, knobs.error);
@@ -902,6 +1037,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
902
1037
  const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
903
1038
  const prompt = str(body.prompt, MAX_COMMENT_TEXT);
904
1039
  const author = str(body.author, MAX_TITLE);
1040
+ const note = body.note === undefined ? undefined : (str(body.note, MAX_NOTE) ?? "");
1041
+ const widths = widthsArg(body.widths);
1042
+ if (isResult(widths))
1043
+ return widths;
905
1044
  // An existing mock is addressable by id before a session exists.
906
1045
  let mock = null;
907
1046
  if (typeof body.mock === "string")
@@ -936,13 +1075,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
936
1075
  const inState = posts.filter((p) => p.state === state);
937
1076
  let variant = variantIn;
938
1077
  if (!variant) {
939
- const live = inState.filter((p) => p.status !== "archived");
1078
+ const live = inState.filter((p) => isLive(p.status));
940
1079
  if (inState.length === 0)
941
1080
  variant = DEFAULT_VARIANT;
942
1081
  else if (inState.length === 1)
943
1082
  variant = inState[0].variant;
944
1083
  else if (live.length === 1)
945
1084
  variant = live[0].variant;
1085
+ else if (flowOnly)
1086
+ variant = (live[0] ?? inState[0]).variant;
946
1087
  else {
947
1088
  return fail(400, `${slug} has several variants; pass variant`, {
948
1089
  variants: inState.map(variantLabel),
@@ -953,9 +1094,53 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
953
1094
  const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
954
1095
  variants: posts.map(variantLabel),
955
1096
  });
1097
+ const screen = { mock: slug, state };
1098
+ if (flowIn) {
1099
+ const full = await flowFull(project, flowIn.name, screen, flowIn.step);
1100
+ if (full)
1101
+ return full;
1102
+ }
1103
+ if (flowOnly) {
1104
+ if (!mock || inState.length === 0) {
1105
+ return fail(404, `${slug} has no ${state === null ? "design" : `state "${state}"`} yet: publish it with "flow" to place it`);
1106
+ }
1107
+ if (!target)
1108
+ return missing();
1109
+ const flow = await placeFlow(project, flowIn.name, screen, flowIn.step);
1110
+ bus.broadcast({ type: "mock-updated", id: mock.id, project });
1111
+ return writeResult(mock, target, ctx, { writer: session, flow });
1112
+ }
1113
+ // A fork starts a new variant from a sibling's version: that version's
1114
+ // surfaces are what `parts`, `fills` and kept surface ids refer to.
1115
+ let fork = null;
1116
+ if (forkIn) {
1117
+ if (target) {
1118
+ return fail(409, `${variant} already exists; fork names a new variant to start`);
1119
+ }
1120
+ const source = inState.find((p) => p.variant === forkIn.variant);
1121
+ if (!source) {
1122
+ return fail(404, `no variant "${forkIn.variant}" to fork in this state`, {
1123
+ variants: inState.map(variantLabel),
1124
+ });
1125
+ }
1126
+ const version = forkIn.version ?? source.version;
1127
+ const surfacesAt = version === source.version
1128
+ ? source.surfaces
1129
+ : source.history.find((h) => h.version === version)?.surfaces;
1130
+ if (!surfacesAt)
1131
+ return fail(404, `${source.variant} has no version ${version}`);
1132
+ fork = {
1133
+ ref: { variant: source.variant, version },
1134
+ surfaces: surfacesAt,
1135
+ ...(source.knobs ? { knobs: source.knobs } : {}),
1136
+ ...(source.note ? { note: source.note } : {}),
1137
+ };
1138
+ }
956
1139
  // What `parts` and kept surface ids refer to: the variant's current
957
1140
  // version, or the one `from` names.
958
1141
  const base = () => {
1142
+ if (fork)
1143
+ return fork.surfaces;
959
1144
  if (!target)
960
1145
  return missing();
961
1146
  if (from === undefined || from === target.version)
@@ -965,11 +1150,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
965
1150
  };
966
1151
  let surfaces;
967
1152
  let applied;
968
- if (partEdits) {
1153
+ if (fork && given.length === 0) {
1154
+ surfaces = fork.surfaces;
1155
+ }
1156
+ else if (partEdits || fillEdits) {
969
1157
  const current = base();
970
1158
  if (isResult(current))
971
1159
  return current;
972
- const result = spliceParts(current, partEdits);
1160
+ const result = partEdits
1161
+ ? spliceParts(current, partEdits)
1162
+ : spliceFills(current, fillEdits);
973
1163
  if (!result.ok)
974
1164
  return fail(400, result.error);
975
1165
  surfaces = result.surfaces;
@@ -985,6 +1175,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
985
1175
  if (bad)
986
1176
  return bad;
987
1177
  const nudges = knobNudges({ ...knobs.value, ...variantKnobs.value });
1178
+ const embeds = await publishEmbeds(project, slug, surfaces);
1179
+ if (isResult(embeds))
1180
+ return embeds;
988
1181
  if (!target) {
989
1182
  // A new variant: carried by the session the agent is writing from.
990
1183
  if (!session) {
@@ -1004,11 +1197,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1004
1197
  kind: kind ?? "component",
1005
1198
  states: state === null ? [] : [state],
1006
1199
  knobs: knobs.value,
1200
+ ...(widths ? { widths } : {}),
1007
1201
  sessionId: session.id,
1008
1202
  });
1009
1203
  bus.broadcast({ type: "mock-created", id: mock.id, project });
1010
1204
  }
1011
- const slots = mock.kind === "page" || kind === "page" ? await pageSlots(project, surfaces) : [];
1012
1205
  const created = await store.createPost({
1013
1206
  sessionId: session.id,
1014
1207
  mock: mock.id,
@@ -1016,38 +1209,53 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1016
1209
  variant,
1017
1210
  title: title ?? mock.title,
1018
1211
  surfaces,
1019
- ...(Object.keys(variantKnobs.value).length ? { knobs: variantKnobs.value } : {}),
1020
- slots,
1212
+ ...(Object.keys(variantKnobs.value).length
1213
+ ? { knobs: variantKnobs.value }
1214
+ : fork?.knobs
1215
+ ? { knobs: fork.knobs }
1216
+ : {}),
1217
+ ...(note ? { note } : note === undefined && fork?.note ? { note: fork.note } : {}),
1218
+ embeds,
1219
+ ...(fork ? { forkedFrom: fork.ref } : {}),
1021
1220
  from,
1022
1221
  prompt,
1023
1222
  author,
1024
1223
  });
1025
1224
  if (!created)
1026
1225
  return fail(404, "session not found");
1027
- mock = await updateMockAfterWrite(mock, { state, kind, title, knobs: knobs.value, session });
1226
+ mock = await updateMockAfterWrite(mock, {
1227
+ state,
1228
+ kind,
1229
+ title,
1230
+ knobs: knobs.value,
1231
+ widths,
1232
+ session,
1233
+ });
1234
+ const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
1028
1235
  announcePost(mock, created, true);
1029
1236
  if (prompt && ctx.request)
1030
1237
  notifyPublish(mock, created, prompt, ctx);
1031
- return writeResult(mock, created, ctx, { nudges, status: 201 });
1238
+ return writeResult(mock, created, ctx, { nudges, status: 201, flow });
1032
1239
  }
1033
1240
  if (!mock)
1034
1241
  return missing();
1035
1242
  // A new version is written by whoever writes it; without an explicit
1036
1243
  // session the variant's own session carries it.
1037
1244
  session ??= await store.getSession(target.sessionId);
1038
- const slots = mock.kind === "page" ? await pageSlots(project, surfaces) : undefined;
1039
1245
  const updated = await store.updatePost(target.id, {
1040
1246
  surfaces,
1041
1247
  title,
1042
1248
  ...(body.variantKnobs !== undefined ? { knobs: variantKnobs.value } : {}),
1043
- slots,
1249
+ ...(note !== undefined ? { note } : {}),
1250
+ embeds,
1044
1251
  from,
1045
1252
  prompt,
1046
1253
  author,
1047
1254
  });
1048
1255
  if (!updated)
1049
1256
  return fail(404, "variant not found");
1050
- mock = await updateMockAfterWrite(mock, { state, kind, knobs: knobs.value, session });
1257
+ const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
1258
+ mock = await updateMockAfterWrite(mock, { state, kind, knobs: knobs.value, widths, session });
1051
1259
  announcePost(mock, updated, false);
1052
1260
  if (prompt && ctx.request)
1053
1261
  notifyPublish(mock, updated, prompt, ctx);
@@ -1056,24 +1264,121 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1056
1264
  applied,
1057
1265
  nudges,
1058
1266
  writer: session,
1267
+ flow,
1059
1268
  });
1060
1269
  }
1270
+ // `flow` names a flow; `step` is the 1-based place of this screen in it
1271
+ // (moving it if it is there already), 0 takes the screen out, and without
1272
+ // `step` a screen not yet in the flow joins at its end.
1273
+ function flowArg(body) {
1274
+ const stepIn = body.step;
1275
+ if (body.flow === undefined || body.flow === null) {
1276
+ return stepIn === undefined || stepIn === null
1277
+ ? undefined
1278
+ : fail(400, '"step" needs "flow": the flow\'s name, e.g. "Morning"');
1279
+ }
1280
+ const name = str(body.flow, MAX_LABEL);
1281
+ if (!name)
1282
+ return fail(400, '"flow" must be the flow\'s name, e.g. "Morning"');
1283
+ if (stepIn === undefined || stepIn === null)
1284
+ return { name };
1285
+ const step = Number(stepIn);
1286
+ if (!Number.isInteger(step) || step < 0) {
1287
+ return fail(400, '"step" must be the 1-based place in the flow, or 0 to take it out');
1288
+ }
1289
+ return { name, step };
1290
+ }
1291
+ // Checked before anything is written, so a full flow leaves no new version behind.
1292
+ async function flowFull(project, name, screen, step) {
1293
+ if (step === 0)
1294
+ return null;
1295
+ const flows = await store.listFlows(project);
1296
+ const flow = flows.find((f) => f.name === name);
1297
+ if (!flow && flows.length >= MAX_FLOWS) {
1298
+ return fail(400, `${project} already has ${MAX_FLOWS} flows; reuse one`);
1299
+ }
1300
+ if (flow &&
1301
+ flow.steps.length >= MAX_FLOW_STEPS &&
1302
+ !flow.steps.some((x) => sameScreen(x, screen))) {
1303
+ return fail(400, `flow "${name}" already has ${MAX_FLOW_STEPS} steps`);
1304
+ }
1305
+ return null;
1306
+ }
1307
+ async function placeFlow(project, name, screen, step) {
1308
+ let at = step;
1309
+ if (at === undefined) {
1310
+ const flow = (await store.listFlows(project)).find((f) => f.name === name);
1311
+ const i = flow ? flow.steps.findIndex((x) => sameScreen(x, screen)) : -1;
1312
+ at = i >= 0 ? i + 1 : MAX_FLOW_STEPS + 1;
1313
+ }
1314
+ const flow = await store.placeFlowStep(project, name, screen, at);
1315
+ return { name, steps: flow?.steps ?? [] };
1316
+ }
1317
+ // The project's flows as `read` returns them: only steps whose screen exists.
1318
+ async function flowsView(project, mocks) {
1319
+ const bySlug = new Map(mocks.map((m) => [m.slug, m]));
1320
+ const exists = (x) => {
1321
+ const m = bySlug.get(x.mock);
1322
+ return !!m && (x.state === null ? m.states.length === 0 : m.states.includes(x.state));
1323
+ };
1324
+ return (await store.listFlows(project))
1325
+ .map((f) => ({ name: f.name, steps: f.steps.filter(exists) }))
1326
+ .filter((f) => f.steps.length > 0);
1327
+ }
1328
+ // `fork`: "variant" or "variant@N", the sibling version a new variant starts
1329
+ // from.
1330
+ function forkArg(v) {
1331
+ if (v === undefined || v === null || v === "")
1332
+ return undefined;
1333
+ const bad = '"fork" must be "<variant>" or "<variant>@<version>", e.g. "quiet@2"';
1334
+ if (typeof v !== "string")
1335
+ return fail(400, bad);
1336
+ const m = /^(.+?)(?:@(\d+))?$/.exec(v.trim());
1337
+ const variant = m && str(m[1], MAX_LABEL);
1338
+ if (!variant)
1339
+ return fail(400, bad);
1340
+ const version = m[2] === undefined ? undefined : Number(m[2]);
1341
+ if (version !== undefined && version < 1)
1342
+ return fail(400, bad);
1343
+ return version === undefined ? { variant } : { variant, version };
1344
+ }
1345
+ // `widths`: the device widths the mock is judged at, in the agent's order;
1346
+ // [] hands the choice back to the viewer.
1347
+ function widthsArg(v) {
1348
+ if (v === undefined)
1349
+ return undefined;
1350
+ const bad = `"widths" must be up to ${MAX_WIDTHS} integer px widths between ${MIN_WIDTH} and ${MAX_WIDTH}, e.g. [390, 1280]`;
1351
+ if (!Array.isArray(v) || v.length > MAX_WIDTHS)
1352
+ return fail(400, bad);
1353
+ const out = [];
1354
+ for (const w of v) {
1355
+ if (!Number.isInteger(w) || w < MIN_WIDTH || w > MAX_WIDTH)
1356
+ return fail(400, bad);
1357
+ if (!out.includes(w))
1358
+ out.push(w);
1359
+ }
1360
+ return out;
1361
+ }
1061
1362
  // `parts`: new outer html per part ("name" or "name#key"), spliced into the
1062
1363
  // variant's html so a tweak costs the tweak rather than the whole document.
1063
- function partEditsArg(body) {
1064
- if (body?.parts === undefined)
1364
+ // `fills`: the same per slot alternative ("part#fill"), replaced or added.
1365
+ function editsArg(body, key) {
1366
+ if (body?.[key] === undefined)
1065
1367
  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>" }');
1368
+ const edits = body[key];
1369
+ const shape = key === "parts"
1370
+ ? '{ "<part name>": "<html for that part>" }'
1371
+ : '{ "<part>#<fill>": "<html for that fill>" }';
1372
+ if (!edits || typeof edits !== "object" || Array.isArray(edits)) {
1373
+ return fail(400, `"${key}" must be an object: ${shape}`);
1069
1374
  }
1070
- const entries = Object.entries(parts);
1375
+ const entries = Object.entries(edits);
1071
1376
  if (entries.length === 0)
1072
- return fail(400, '"parts" is empty; name at least one part');
1377
+ return fail(400, `"${key}" is empty; name at least one ${key.slice(0, -1)}`);
1073
1378
  if (entries.some(([, html]) => typeof html !== "string")) {
1074
- return fail(400, 'each "parts" value must be the html string for that part');
1379
+ return fail(400, `each "${key}" value must be the html string for that ${key.slice(0, -1)}`);
1075
1380
  }
1076
- return parts;
1381
+ return edits;
1077
1382
  }
1078
1383
  // A write may add a state, retitle the mock, declare knobs, and makes its
1079
1384
  // session the one a reply is delivered to.
@@ -1085,6 +1390,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1085
1390
  ...(w.kind ? { kind: w.kind } : {}),
1086
1391
  ...(w.title ? { title: w.title } : {}),
1087
1392
  ...(knobs ? { knobs } : {}),
1393
+ ...(w.widths ? { widths: w.widths } : {}),
1088
1394
  ...(w.session ? { sessionId: w.session.id } : {}),
1089
1395
  });
1090
1396
  if (!updated)
@@ -1115,17 +1421,56 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1115
1421
  }
1116
1422
  const open = rows.reduce((n, r) => n + r.open, 0);
1117
1423
  return ok({
1118
- ...(project ? { project } : {}),
1424
+ ...(project ? { project, flows: await flowsView(project, mocks) } : {}),
1119
1425
  mocks: rows,
1120
1426
  open,
1121
1427
  openMocks: rows.filter((r) => r.open > 0).length,
1122
1428
  pending,
1123
1429
  });
1124
1430
  }
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 ?? {};
1431
+ // The project as a tree: every mock with its variants per state (status,
1432
+ // lineage, what its embeds resolve to) and the components it embeds and is
1433
+ // embedded in, and the declared flows. What the viewer's Home and an agent's
1434
+ // `read --tree` lay out.
1435
+ async function treeFlow(query) {
1436
+ const project = str(query.project, MAX_TITLE);
1437
+ if (!project)
1438
+ return fail(400, 'provide "project"');
1439
+ const mocks = await store.listMocks(project);
1440
+ const nodes = [];
1441
+ const pending = [];
1442
+ for (const m of mocks) {
1443
+ const posts = await store.listPosts({ mockId: m.id });
1444
+ const embeds = await embedViews(m, posts);
1445
+ const uses = new Set();
1446
+ for (const p of posts) {
1447
+ if (!isLive(p.status))
1448
+ continue;
1449
+ for (const x of p.surfaces) {
1450
+ if (x.kind === "html")
1451
+ for (const t of parseEmbedTags(x.html))
1452
+ uses.add(t.slug);
1453
+ }
1454
+ }
1455
+ nodes.push(treeNodeView(m, posts, {
1456
+ settled: settledValues(await replyPayloads(m.id), m, posts),
1457
+ uses: [...uses],
1458
+ embeds,
1459
+ }));
1460
+ pending.push(pendingOf(m, posts));
1461
+ }
1462
+ const bySlug = new Map(nodes.map((n) => [n.slug, n]));
1463
+ for (const n of nodes) {
1464
+ n.uses = n.uses.filter((s) => bySlug.has(s));
1465
+ for (const s of n.uses)
1466
+ bySlug.get(s).usedIn.push(n.slug);
1467
+ }
1468
+ return ok({ project, tree: nodes, flows: await flowsView(project, mocks), pending });
1469
+ }
1470
+ async function replyPayloads(mockId) {
1471
+ return (await store.listComments({ mockId }))
1472
+ .filter((c) => c.kind === "reply" && c.payload)
1473
+ .map((c) => c.payload);
1129
1474
  }
1130
1475
  async function getMockFlow(ref, query) {
1131
1476
  const mock = await resolveMock(ref, query.project, query.session);
@@ -1136,7 +1481,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1136
1481
  ...mockDetailView(mock, posts, {
1137
1482
  body: query.body,
1138
1483
  history: query.history,
1139
- tuned: await lastTuned(mock.id),
1484
+ settled: settledValues(await replyPayloads(mock.id), mock, posts),
1485
+ embeds: await embedViews(mock, posts),
1140
1486
  }),
1141
1487
  pending: pendingOf(mock, posts),
1142
1488
  });
@@ -1151,31 +1497,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1151
1497
  const states = (mock.states.length ? mock.states : [null]).filter((s) => wantedState === undefined || s === wantedState);
1152
1498
  if (states.length === 0)
1153
1499
  return fail(404, `${mock.slug} has no state "${wantedState}"`);
1500
+ const replies = await replyPayloads(mock.id);
1501
+ const lastReply = replies[replies.length - 1] ?? null;
1502
+ const settled = settledValues(replies, mock, posts);
1154
1503
  const entries = [];
1155
1504
  for (const state of states) {
1156
1505
  const inState = posts.filter((p) => p.state === state);
1157
- const post = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
1506
+ const found = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
1158
1507
  inState.find((p) => p.status === "accepted") ||
1159
1508
  inState.find((p) => p.status === "open") ||
1160
1509
  inState[0];
1161
- if (!post)
1510
+ if (!found)
1162
1511
  continue;
1163
- if (wantedVariant && post.variant !== wantedVariant)
1512
+ if (wantedVariant && found.variant !== wantedVariant)
1164
1513
  continue;
1514
+ const post = postAtVersion(found, inUseVersion(found)) ?? found;
1165
1515
  const html = post.surfaces.find((s) => s.kind === "html");
1516
+ // With slots, the screen as decided: only the chosen fills, by every
1517
+ // value sent so far (defaults before any reply). The authored markup with
1518
+ // every fill stays available as `source`.
1519
+ const slots = slotsInSurfaces(post.surfaces);
1520
+ const raw = html && html.kind === "html" ? html.html : "";
1521
+ const composed = slots.length ? composeFills(raw, chosenFills(slots, settled)) : raw;
1166
1522
  entries.push({
1167
1523
  state,
1168
1524
  variant: post.variant,
1169
1525
  postId: post.id,
1170
1526
  version: post.version,
1527
+ // The agent's newer versions wait until the user puts one in use.
1528
+ ...(found.version !== post.version ? { latest: found.version } : {}),
1171
1529
  status: post.status,
1172
- html: html && html.kind === "html" ? html.html : "",
1530
+ html: composed,
1531
+ ...(slots.length ? { source: raw } : {}),
1173
1532
  markdown: postToMarkdown(post, {
1174
1533
  postUrl: `${ctx.base}/s/${post.id}`,
1175
1534
  assetBase: ctx.base,
1176
1535
  }),
1177
1536
  history: [
1178
- ...[...post.history]
1537
+ ...[...found.history]
1538
+ .filter((h) => !(found.hiddenVersions ?? []).includes(h.version))
1179
1539
  .sort((a, b) => a.version - b.version)
1180
1540
  .map((h) => ({
1181
1541
  version: h.version,
@@ -1184,10 +1544,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1184
1544
  prompt: h.prompt ?? "",
1185
1545
  })),
1186
1546
  {
1187
- version: post.version,
1188
- at: post.updatedAt,
1189
- from: post.from ?? null,
1190
- prompt: post.prompt ?? "",
1547
+ version: found.version,
1548
+ at: found.updatedAt,
1549
+ from: found.from ?? null,
1550
+ prompt: found.prompt ?? "",
1191
1551
  },
1192
1552
  ],
1193
1553
  screenshotUrl: screenshots ? `${ctx.base}/s/${post.id}.png?v=${post.version}` : null,
@@ -1198,7 +1558,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1198
1558
  variants: posts.map(variantLabel),
1199
1559
  });
1200
1560
  }
1201
- const replies = (await store.listComments({ mockId: mock.id })).filter((c) => c.kind === "reply");
1561
+ const decisions = lastReply ? decisionsFor(mock, posts, lastReply) : null;
1202
1562
  return ok({
1203
1563
  project: mock.project,
1204
1564
  mock: mock.slug,
@@ -1206,7 +1566,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1206
1566
  kind: mock.kind,
1207
1567
  states: entries,
1208
1568
  knobs: mock.knobs,
1209
- reply: replies[replies.length - 1]?.payload ?? null,
1569
+ settled,
1570
+ reply: lastReply,
1571
+ ...(decisions ? { decisions } : {}),
1210
1572
  });
1211
1573
  }
1212
1574
  // --- asks ---
@@ -1262,12 +1624,25 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1262
1624
  return checked.error;
1263
1625
  set = checked.value;
1264
1626
  }
1627
+ if (opt.version !== undefined) {
1628
+ const pinned = versionOption(opt.version, label, variant, { scope, state }, mock, posts);
1629
+ if (typeof pinned === "string")
1630
+ return pinned;
1631
+ options.push({ id, label, ...pinned, ...(set ? { set } : {}) });
1632
+ continue;
1633
+ }
1265
1634
  options.push({ id, label, ...(variant ? { variant } : {}), ...(set ? { set } : {}) });
1266
1635
  }
1267
1636
  let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
1268
1637
  if (!str(raw.id, 64))
1269
1638
  for (let n = 2; taken.has(id); n++)
1270
1639
  id = `${slugify(text).slice(0, 36)}-${n}`;
1640
+ const after = raw.after === undefined ? undefined : str(raw.after, 64);
1641
+ if (raw.after !== undefined) {
1642
+ if (!after || after === id || !taken.has(after)) {
1643
+ return `ask "${text}": after must name an earlier ask's id (asked before it or earlier in this call)`;
1644
+ }
1645
+ }
1271
1646
  return {
1272
1647
  id,
1273
1648
  text,
@@ -1276,9 +1651,49 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1276
1651
  ...(part ? { part } : {}),
1277
1652
  options,
1278
1653
  ...(raw.multi === true ? { multi: true } : {}),
1654
+ ...(after ? { after } : {}),
1279
1655
  at: new Date().toISOString(),
1280
1656
  };
1281
1657
  }
1658
+ // A version option names one visible version of one variant in the ask's
1659
+ // state (`optionState`); the variant may be left out when that state has
1660
+ // only one, or one accepted.
1661
+ function versionOption(raw, label, variant, ask, mock, posts) {
1662
+ const version = Number(raw);
1663
+ if (!Number.isInteger(version) || version < 1) {
1664
+ return `option "${label}": version must be a positive integer`;
1665
+ }
1666
+ if (ask.scope === "part") {
1667
+ return `option "${label}" names a version, which puts it in use: ask with scope "state" or "mock", not "part"`;
1668
+ }
1669
+ if (ask.state === undefined && mock.states.length > 1) {
1670
+ return `option "${label}" names a version: give the ask a state (${mock.slug} has ${mock.states.map((x) => `"${x}"`).join(", ")})`;
1671
+ }
1672
+ const state = optionState(ask, mock);
1673
+ const inState = posts.filter((p) => p.state === state);
1674
+ const accepted = inState.filter((p) => p.status === "accepted");
1675
+ const post = variant
1676
+ ? inState.find((p) => p.variant === variant)
1677
+ : inState.length === 1
1678
+ ? inState[0]
1679
+ : accepted.length === 1
1680
+ ? accepted[0]
1681
+ : undefined;
1682
+ const where = state === null ? mock.slug : `"${state}"`;
1683
+ if (!post) {
1684
+ return variant
1685
+ ? `option "${label}" names variant "${variant}", which ${where} does not have`
1686
+ : `option "${label}" names version ${version} but ${where} has several variants (${inState.map((p) => p.variant).join(", ")}): name one`;
1687
+ }
1688
+ const hidden = new Set(post.hiddenVersions ?? []);
1689
+ const visible = [post.version, ...post.history.map((h) => h.version)]
1690
+ .filter((v) => !hidden.has(v))
1691
+ .sort((a, b) => a - b);
1692
+ if (!visible.includes(version)) {
1693
+ return `option "${label}" names ${post.variant} v${version}${hidden.has(version) ? ", which is hidden" : ""}; ${post.variant} has ${visible.map((v) => `v${v}`).join(", ")}`;
1694
+ }
1695
+ return { variant: post.variant, version };
1696
+ }
1282
1697
  // Ask the user structured questions. An ask whose id matches an existing one
1283
1698
  // replaces it (and reopens it); anything else is appended.
1284
1699
  async function askFlow(ref, body, ctx) {
@@ -1297,11 +1712,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1297
1712
  const asks = [...mock.asks];
1298
1713
  const taken = new Set(asks.map((a) => a.id));
1299
1714
  const added = [];
1715
+ const skipped = [];
1300
1716
  for (const entry of raw) {
1301
1717
  const ask = sanitizeAsk(entry, mock, posts, taken);
1302
1718
  if (typeof ask === "string")
1303
1719
  return fail(400, ask);
1304
1720
  const at = asks.findIndex((a) => a.id === ask.id);
1721
+ // The user closed this one as not worth answering; asking it again is
1722
+ // exactly the noise they resolved away. Only their Reopen revives it.
1723
+ if (at >= 0 && asks[at].dismissed) {
1724
+ skipped.push(ask.id);
1725
+ continue;
1726
+ }
1305
1727
  if (at >= 0)
1306
1728
  asks[at] = ask;
1307
1729
  else
@@ -1309,11 +1731,31 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1309
1731
  taken.add(ask.id);
1310
1732
  added.push(ask);
1311
1733
  }
1734
+ const skippedNudges = skipped.map((id) => `ask "${id}": the user dismissed it when they resolved ${mock.slug}; not asked again. Don't re-ask it.`);
1735
+ if (!added.length) {
1736
+ return ok({
1737
+ mock: mock.slug,
1738
+ mockId: mock.id,
1739
+ project: mock.project,
1740
+ asks: [],
1741
+ skipped,
1742
+ open: openCount(mock, posts),
1743
+ url: mockUrl(ctx.base, mock),
1744
+ nudges: skippedNudges,
1745
+ feedback: mock.sessionId ? await collectFeedback(mock.sessionId, ctx.base) : [],
1746
+ });
1747
+ }
1312
1748
  let sessionId = mock.sessionId;
1313
1749
  if (typeof body.session === "string" && (await store.getSession(body.session))) {
1314
1750
  sessionId = body.session;
1315
1751
  }
1316
- const updated = await store.updateMock(mock.id, { asks, sessionId });
1752
+ // A new question on a resolved mock brings it back to the open list, or it
1753
+ // would wait where the user no longer looks.
1754
+ const updated = await store.updateMock(mock.id, {
1755
+ asks,
1756
+ sessionId,
1757
+ ...(mock.resolvedAt ? { resolvedAt: null } : {}),
1758
+ });
1317
1759
  if (!updated)
1318
1760
  return fail(404, "mock not found");
1319
1761
  mock = updated;
@@ -1341,12 +1783,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1341
1783
  url: mockUrl(ctx.base, mock),
1342
1784
  });
1343
1785
  const feedback = session ? await collectFeedback(session.id, ctx.base) : [];
1344
- const nudges = askNudges(added, mock.asks);
1786
+ const nudges = [...skippedNudges, ...askNudges(added, mock.asks)];
1345
1787
  return ok({
1346
1788
  mock: mock.slug,
1347
1789
  mockId: mock.id,
1348
1790
  project: mock.project,
1349
1791
  asks: added,
1792
+ ...(skipped.length ? { skipped } : {}),
1350
1793
  open: openCount(mock, posts),
1351
1794
  url: mockUrl(ctx.base, mock),
1352
1795
  ...(nudges.length ? { nudges } : {}),
@@ -1619,18 +2062,30 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1619
2062
  // Which variants a reply accepts and archives. A variant-bound answer accepts
1620
2063
  // the chosen variant(s) in every state the ask covers and archives their
1621
2064
  // 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();
2065
+ // variant asks flip the same way, per state. Each accepted variant is put in
2066
+ // use at the version the reply was made on (`version`, a mock version), not
2067
+ // whatever the agent published since — or, for a version option, at its version.
2068
+ function replyFlips(mock, posts, answers, decision, version) {
2069
+ const accept = new Map();
1625
2070
  const archive = new Set();
1626
- const settle = (inState, chosen) => {
2071
+ const drop = new Set();
2072
+ const settle = (inState, chosen, pins) => {
1627
2073
  if (!inState.some((p) => chosen.has(p.variant)))
1628
2074
  return;
1629
2075
  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);
2076
+ if (!chosen.has(p.variant)) {
2077
+ if (isLive(p.status))
2078
+ archive.add(p.id);
2079
+ continue;
2080
+ }
2081
+ const pin = pins?.get(p.variant);
2082
+ // A version option outranks a looser pick of the same variant (the
2083
+ // built-in Which one?, a Look) in the same reply.
2084
+ if (pin === undefined && accept.has(p.id))
2085
+ continue;
2086
+ // A pinned version history retention has since dropped falls back to
2087
+ // what the stage showed, rather than pinning one nothing can render.
2088
+ accept.set(p.id, pin !== undefined && postAtVersion(p, pin) ? pin : versionAt(p, version));
1634
2089
  }
1635
2090
  };
1636
2091
  const builtins = Object.keys(answers)
@@ -1641,7 +2096,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1641
2096
  if (answer === undefined || ask.scope === "part")
1642
2097
  continue;
1643
2098
  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));
2099
+ const picked = ask.options.filter((o) => ids.includes(o.id) && o.variant);
2100
+ const pinned = picked.filter((o) => o.version !== undefined);
2101
+ if (pinned.length) {
2102
+ const state = optionState(ask, mock);
2103
+ settle(posts.filter((p) => p.state === state), new Set(pinned.map((o) => o.variant)), new Map(pinned.map((o) => [o.variant, o.version])));
2104
+ }
2105
+ const chosen = new Set(picked.filter((o) => o.version === undefined).map((o) => o.variant));
1645
2106
  if (chosen.size === 0)
1646
2107
  continue;
1647
2108
  const states = ask.scope === "state" ? [ask.state ?? null] : mock.states.length ? mock.states : [null];
@@ -1654,11 +2115,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1654
2115
  else if (decision?.kind === "drop") {
1655
2116
  const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
1656
2117
  if (post)
1657
- archive.add(post.id);
2118
+ drop.add(post.id);
2119
+ }
2120
+ for (const id of accept.keys()) {
2121
+ archive.delete(id);
2122
+ drop.delete(id);
1658
2123
  }
1659
- for (const id of accept)
2124
+ for (const id of drop)
1660
2125
  archive.delete(id);
1661
- return { accept, archive };
2126
+ return { accept, archive, drop };
1662
2127
  }
1663
2128
  // An ask as a reply leaves it. A pick or a write-in replaces whatever was
1664
2129
  // answered before; a note alone keeps that answer and adds to it.
@@ -1669,6 +2134,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1669
2134
  if (answer === undefined && other === undefined && note === undefined)
1670
2135
  return a;
1671
2136
  const next = { ...a };
2137
+ delete next.dismissed;
1672
2138
  if (answer !== undefined || other !== undefined) {
1673
2139
  delete next.answer;
1674
2140
  delete next.other;
@@ -1733,27 +2199,33 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1733
2199
  ...(draft.otherImages ? { otherImages: draft.otherImages } : {}),
1734
2200
  ...(draft.noteImages ? { noteImages: draft.noteImages } : {}),
1735
2201
  ...(text ? { text } : {}),
1736
- ...(decision ? { decision } : {}),
1737
2202
  };
1738
2203
  const asks = mock.asks.map((a) => sentAsk(a, draft));
1739
- const flips = replyFlips(mock, posts, draft.answers, decision);
2204
+ const flips = replyFlips(mock, posts, draft.answers, decision, draft.version);
2205
+ if (decision) {
2206
+ const target = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
2207
+ const pinned = decision.kind === "accept" && target ? flips.accept.get(target.id) : undefined;
2208
+ payload.decision = pinned === undefined ? decision : { ...decision, version: pinned };
2209
+ }
1740
2210
  const comment = await store.commitReply({
1741
2211
  mockId: mock.id,
1742
2212
  sessionId,
1743
2213
  text: text ?? "",
1744
2214
  payload,
1745
2215
  asks,
1746
- accept: [...flips.accept],
2216
+ accept: [...flips.accept].map(([id, version]) => ({ id, version })),
1747
2217
  archive: [...flips.archive],
2218
+ drop: [...flips.drop],
1748
2219
  });
1749
2220
  if (!comment)
1750
2221
  return fail(409, `${mock.slug} has no agent session to reply to`);
1751
2222
  announceComment(comment);
1752
2223
  bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
1753
2224
  bus.broadcast({ type: "draft-updated", mockId: mock.id });
2225
+ void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
1754
2226
  const after = await store.listPosts({ mockId: mock.id });
1755
2227
  for (const p of after) {
1756
- if (flips.accept.has(p.id) || flips.archive.has(p.id)) {
2228
+ if (flips.accept.has(p.id) || flips.archive.has(p.id) || flips.drop.has(p.id)) {
1757
2229
  bus.broadcast({
1758
2230
  type: "post-updated",
1759
2231
  id: p.id,
@@ -1778,12 +2250,216 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1778
2250
  reply: delivered,
1779
2251
  accepted: after
1780
2252
  .filter((p) => flips.accept.has(p.id))
1781
- .map((p) => ({ state: p.state, variant: p.variant })),
2253
+ .map((p) => ({ state: p.state, variant: p.variant, version: inUseVersion(p) })),
1782
2254
  archived: after
1783
2255
  .filter((p) => flips.archive.has(p.id))
1784
2256
  .map((p) => ({ state: p.state, variant: p.variant })),
2257
+ dropped: after
2258
+ .filter((p) => flips.drop.has(p.id))
2259
+ .map((p) => ({ state: p.state, variant: p.variant })),
2260
+ }, 201);
2261
+ }
2262
+ // "Use this": the user puts one exact version of a variant in use. It is an
2263
+ // accept, so it archives the variant's live siblings in that state and
2264
+ // reaches the agent once, as a reply carrying the decision, through the same
2265
+ // cursor as a Send; the user's unsent draft is left for their next Send.
2266
+ async function useFlow(ref, body, ctx) {
2267
+ if (!ctx.viewer)
2268
+ return viewerOnly();
2269
+ const mock = await resolveMock(ref);
2270
+ if (isResult(mock))
2271
+ return mock;
2272
+ const posts = await store.listPosts({ mockId: mock.id });
2273
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2274
+ if (isResult(chosen))
2275
+ return chosen;
2276
+ const version = Number(body?.version);
2277
+ if (!Number.isInteger(version) || version < 1) {
2278
+ return fail(400, '"version" must be a positive integer');
2279
+ }
2280
+ if (!postAtVersion(chosen, version)) {
2281
+ return fail(404, `${chosen.variant} has no version ${version}`, {
2282
+ versions: [chosen.version, ...chosen.history.map((h) => h.version)],
2283
+ });
2284
+ }
2285
+ const inUse = { state: chosen.state, variant: chosen.variant, version };
2286
+ if (chosen.status === "accepted" && chosen.acceptedVersion === version) {
2287
+ return ok({ reply: null, inUse, archived: [] });
2288
+ }
2289
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2290
+ const sessionId = mock.sessionId ?? latest?.sessionId;
2291
+ if (!sessionId)
2292
+ return fail(409, `${mock.slug} has no agent session to reply to`);
2293
+ const archive = posts.filter((p) => p.state === chosen.state && p.id !== chosen.id && isLive(p.status));
2294
+ const comment = await store.commitReply({
2295
+ mockId: mock.id,
2296
+ sessionId,
2297
+ text: "",
2298
+ payload: {
2299
+ mockId: mock.id,
2300
+ version,
2301
+ answers: {},
2302
+ mix: {},
2303
+ tuned: {},
2304
+ comments: [],
2305
+ decision: { kind: "accept", ...inUse },
2306
+ },
2307
+ asks: mock.asks,
2308
+ accept: [{ id: chosen.id, version }],
2309
+ archive: archive.map((p) => p.id),
2310
+ keepDraft: true,
2311
+ });
2312
+ if (!comment)
2313
+ return fail(409, `${mock.slug} has no agent session to reply to`);
2314
+ announceComment(comment);
2315
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2316
+ void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
2317
+ for (const p of [chosen, ...archive]) {
2318
+ bus.broadcast({
2319
+ type: "post-updated",
2320
+ id: p.id,
2321
+ mockId: mock.id,
2322
+ sessionId: p.sessionId,
2323
+ version: p.version,
2324
+ by: "user",
2325
+ });
2326
+ }
2327
+ fireNotify({
2328
+ event: "decision",
2329
+ project: mock.project,
2330
+ slug: mock.slug,
2331
+ variant: chosen.variant,
2332
+ version,
2333
+ text: "accept",
2334
+ url: mockUrl(ctx.base, mock),
2335
+ });
2336
+ const [delivered] = await withDelivered([comment]);
2337
+ return ok({
2338
+ reply: delivered,
2339
+ inUse,
2340
+ archived: archive.map((p) => ({ state: p.state, variant: p.variant })),
1785
2341
  }, 201);
1786
2342
  }
2343
+ // The user hides one version of a variant from the history, or brings it
2344
+ // back. Hidden, never removed: replies and embeds may point at it, so it still
2345
+ // renders by URL. The version in use can't be hidden, nor a state's last.
2346
+ async function hideFlow(ref, body, ctx) {
2347
+ if (!ctx.viewer)
2348
+ return viewerOnly();
2349
+ const mock = await resolveMock(ref);
2350
+ if (isResult(mock))
2351
+ return mock;
2352
+ if (typeof body?.hidden !== "boolean")
2353
+ return fail(400, '"hidden" must be true or false');
2354
+ const posts = await store.listPosts({ mockId: mock.id });
2355
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2356
+ if (isResult(chosen))
2357
+ return chosen;
2358
+ const version = Number(body?.version);
2359
+ if (!Number.isInteger(version) || version < 1) {
2360
+ return fail(400, '"version" must be a positive integer');
2361
+ }
2362
+ if (!postAtVersion(chosen, version)) {
2363
+ return fail(404, `${chosen.variant} has no version ${version}`);
2364
+ }
2365
+ if (body.hidden) {
2366
+ if (chosen.status === "accepted" && inUseVersion(chosen) === version) {
2367
+ return fail(409, `v${version} of ${chosen.variant} is in use; use another version first`);
2368
+ }
2369
+ const visible = posts
2370
+ .filter((p) => p.state === chosen.state)
2371
+ .flatMap((p) => [p.version, ...p.history.map((h) => h.version)].map((v) => [p, v]))
2372
+ .filter(([p, v]) => !(p.hiddenVersions ?? []).includes(v));
2373
+ if (visible.length === 1 && visible[0][0].id === chosen.id && visible[0][1] === version) {
2374
+ return fail(409, "the last visible version can't be hidden");
2375
+ }
2376
+ }
2377
+ const updated = await store.setVersionHidden(chosen.id, version, body.hidden);
2378
+ if (!updated)
2379
+ return fail(404, "variant not found");
2380
+ bus.broadcast({
2381
+ type: "post-updated",
2382
+ id: updated.id,
2383
+ mockId: mock.id,
2384
+ sessionId: updated.sessionId,
2385
+ version: updated.version,
2386
+ by: "user",
2387
+ });
2388
+ return ok({
2389
+ state: updated.state,
2390
+ variant: updated.variant,
2391
+ version,
2392
+ hidden: body.hidden,
2393
+ });
2394
+ }
2395
+ // The user closes a mock from the project list: its open asks are dismissed
2396
+ // unanswered, an unsent draft goes with them, and the agent hears it once,
2397
+ // as a resolution through the same cursor as a Send.
2398
+ async function resolveFlow(ref, ctx) {
2399
+ if (!ctx.viewer)
2400
+ return viewerOnly();
2401
+ const mock = await resolveMock(ref);
2402
+ if (isResult(mock))
2403
+ return mock;
2404
+ const posts = await store.listPosts({ mockId: mock.id });
2405
+ if (mock.resolvedAt)
2406
+ return ok({ mock: mockSummaryView(mock, posts), dismissed: [] });
2407
+ const dismissed = [...openAsks(mock).map((a) => a.id), ...builtinAskIds(mock, posts, mock.id)];
2408
+ const asks = mock.asks.map((a) => (askAnswered(a) ? a : { ...a, dismissed: true }));
2409
+ const at = new Date().toISOString();
2410
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2411
+ const sessionId = mock.sessionId ?? latest?.sessionId;
2412
+ const session = sessionId ? await store.getSession(sessionId) : null;
2413
+ const comment = session
2414
+ ? await store.commitReply({
2415
+ mockId: mock.id,
2416
+ sessionId: session.id,
2417
+ text: "",
2418
+ payload: {
2419
+ mockId: mock.id,
2420
+ version: Math.max(1, ...posts.map((p) => p.version)),
2421
+ answers: {},
2422
+ mix: {},
2423
+ tuned: {},
2424
+ comments: [],
2425
+ resolved: { dismissed },
2426
+ },
2427
+ asks,
2428
+ accept: [],
2429
+ archive: [],
2430
+ resolvedAt: at,
2431
+ })
2432
+ : null;
2433
+ if (comment)
2434
+ announceComment(comment);
2435
+ else {
2436
+ // No agent to tell: the mock still leaves the open list.
2437
+ await store.updateMock(mock.id, { asks, resolvedAt: at });
2438
+ await store.putDraft(mock.id, null);
2439
+ }
2440
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2441
+ bus.broadcast({ type: "draft-updated", mockId: mock.id });
2442
+ const after = (await store.getMock(mock.id)) ?? mock;
2443
+ return ok({ mock: mockSummaryView(after, posts), dismissed });
2444
+ }
2445
+ // Back from Resolved: the asks it dismissed are open again. The agent was
2446
+ // told they were dismissed; answers given now reach it as a normal Send.
2447
+ async function reopenFlow(ref, ctx) {
2448
+ if (!ctx.viewer)
2449
+ return viewerOnly();
2450
+ const mock = await resolveMock(ref);
2451
+ if (isResult(mock))
2452
+ return mock;
2453
+ const posts = await store.listPosts({ mockId: mock.id });
2454
+ if (!mock.resolvedAt)
2455
+ return ok({ mock: mockSummaryView(mock, posts) });
2456
+ const asks = mock.asks.map(({ dismissed: _, ...a }) => a);
2457
+ const updated = await store.updateMock(mock.id, { asks, resolvedAt: null });
2458
+ if (!updated)
2459
+ return fail(404, "mock not found");
2460
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2461
+ return ok({ mock: mockSummaryView(updated, posts) });
2462
+ }
1787
2463
  async function restoreFlow(ref, body) {
1788
2464
  const mock = await resolveMock(ref, body?.project);
1789
2465
  if (isResult(mock))
@@ -1805,6 +2481,34 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1805
2481
  });
1806
2482
  return ok({ state: updated.state, variant: updated.variant, status: updated.status });
1807
2483
  }
2484
+ // The user stars (or unstars) a variant: a favourite to come back to, which
2485
+ // decides nothing. Viewer-only; the agent reads it as `starred`.
2486
+ async function starFlow(ref, body, ctx) {
2487
+ if (!ctx.viewer)
2488
+ return viewerOnly();
2489
+ const mock = await resolveMock(ref);
2490
+ if (isResult(mock))
2491
+ return mock;
2492
+ if (typeof body?.starred !== "boolean")
2493
+ return fail(400, '"starred" must be true or false');
2494
+ const posts = await store.listPosts({ mockId: mock.id });
2495
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
2496
+ if (isResult(chosen))
2497
+ return chosen;
2498
+ const updated = await store.setPostStarred(chosen.id, body.starred);
2499
+ if (!updated)
2500
+ return fail(404, "variant not found");
2501
+ bus.broadcast({
2502
+ type: "post-updated",
2503
+ id: updated.id,
2504
+ mockId: mock.id,
2505
+ sessionId: updated.sessionId,
2506
+ version: updated.version,
2507
+ by: "user",
2508
+ });
2509
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
2510
+ return ok({ state: updated.state, variant: updated.variant, starred: !!updated.starred });
2511
+ }
1808
2512
  // D8 "restore as vN": the user brings an older version back as the newest
1809
2513
  // one. Distinct from restoreFlow, which un-archives a variant. Viewer-only
1810
2514
  // because the version is authored by the user; an agent restoring publishes.
@@ -2178,6 +2882,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2178
2882
  // tells the agent something ("2 of 3 answered", or nobody is looking).
2179
2883
  const pendingOf = (mock, posts) => ({
2180
2884
  mock: mock.slug,
2885
+ // Closed by the user: nothing there is waiting on them, or on you.
2886
+ ...(mock.resolvedAt ? { resolved: true } : {}),
2181
2887
  viewerOpen: viewing.has(mock.id),
2182
2888
  draft: mock.draft
2183
2889
  ? {
@@ -2336,7 +3042,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2336
3042
  maxSize: MAX_ASSET_BYTES,
2337
3043
  onError: (c) => c.json({ error: `asset exceeds ${MAX_ASSET_BYTES} bytes` }, 413),
2338
3044
  });
2339
- const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN, new URL(c.req.url).origin);
3045
+ const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN, requestOrigin(c.req.raw));
2340
3046
  const injectHead = (text, head) => {
2341
3047
  const headClose = text.lastIndexOf("</head>");
2342
3048
  return headClose >= 0
@@ -2365,6 +3071,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2365
3071
  ? `window.__MOCKPIT_PUBLIC_READ__=${JSON.stringify(publicRead)};`
2366
3072
  : "",
2367
3073
  screenshots ? "window.__MOCKPIT_SCREENSHOTS__=true;" : "",
3074
+ renderRev ? `window.__MOCKPIT_RENDER_REV__=${JSON.stringify(renderRev)};` : "",
2368
3075
  ].join("");
2369
3076
  return injectHead(text, `<script>${config}</script>`);
2370
3077
  };
@@ -2388,7 +3095,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2388
3095
  // with every pixel-affecting input pinned in the image URL so the Worker can
2389
3096
  // cache it at the edge.
2390
3097
  const mockPreviewHead = (mock, post, request, mode, rendererGeneration) => {
2391
- const origin = new URL(request.url).origin;
3098
+ const origin = requestOrigin(request);
2392
3099
  const publicBasePath = requestBasePath(request);
2393
3100
  const canonical = `${origin}${publicBasePath}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
2394
3101
  const imageUrl = new URL(`${origin}${publicBasePath}/s/${post.id}.png`);
@@ -2423,7 +3130,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2423
3130
  // sandboxed surface documents at /s/:id are *meant* to be framed and carry
2424
3131
  // their own `sandbox` CSP header instead, so they never pass through here.
2425
3132
  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));
3133
+ const html = withAppHead(c.req.raw, await workspaceMode())(withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, c), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title));
2427
3134
  if (!opts.preview)
2428
3135
  return html;
2429
3136
  const { mock, post } = opts.preview;
@@ -2467,7 +3174,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2467
3174
  if (!mock)
2468
3175
  return c.html(await configuredViewerHtml(c, { title: c.req.param("name") }));
2469
3176
  const posts = await store.listPosts({ mockId: mock.id });
2470
- const post = posts.find((p) => p.status !== "archived") ?? posts[0];
3177
+ const post = posts.find((p) => isLive(p.status)) ?? posts[0];
2471
3178
  return c.html(await configuredViewerHtml(c, {
2472
3179
  title: mock.title,
2473
3180
  ...(post ? { preview: { mock, post } } : {}),
@@ -2475,10 +3182,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2475
3182
  });
2476
3183
  // --- REST plumbing for the shared flows ---
2477
3184
  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",
3185
+ base: `${requestOrigin(c.req.raw)}${requestBasePath(c.req.raw)}`,
3186
+ // Only the trusted viewer may act as the user (drafts, replies,
3187
+ // user-authored comments); server/viewerRequest.ts says how it is known.
3188
+ viewer: isViewerRequest(c.req.raw),
2482
3189
  strict,
2483
3190
  request: c.req.raw,
2484
3191
  signal: c.req.raw.signal,
@@ -2579,6 +3286,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2579
3286
  app.put("/api/mocks/:id/draft", async (c) => send(c, await putDraftFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2580
3287
  app.delete("/api/mocks/:id/draft", async (c) => send(c, await deleteDraftFlow(c.req.param("id"), flowCtx(c))));
2581
3288
  app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3289
+ app.post("/api/mocks/:id/use", async (c) => send(c, await useFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3290
+ app.post("/api/mocks/:id/hide", async (c) => send(c, await hideFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3291
+ app.post("/api/mocks/:id/resolve", async (c) => send(c, await resolveFlow(c.req.param("id"), flowCtx(c))));
3292
+ app.post("/api/mocks/:id/reopen", async (c) => send(c, await reopenFlow(c.req.param("id"), flowCtx(c))));
3293
+ app.post("/api/mocks/:id/star", async (c) => send(c, await starFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2582
3294
  app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
2583
3295
  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
3296
  app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
@@ -2708,6 +3420,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2708
3420
  });
2709
3421
  // The project's design system, imported by `mockpit init` and injected into
2710
3422
  // every html surface of the project (see renderHtmlPage).
3423
+ app.get("/api/projects/:name/tree", async (c) => send(c, await treeFlow({ project: c.req.param("name") })));
2711
3424
  app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
2712
3425
  app.put("/api/projects/:name/design", async (c) => {
2713
3426
  const body = await c.req.json().catch(() => null);
@@ -2986,7 +3699,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2986
3699
  // agent publishing over one origin must never be served to a viewer on another.
2987
3700
  // `knobs` is the canonical (validated, path-sorted) JSON of `?k=`, so two
2988
3701
  // 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 ?? ""}`;
3702
+ const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}:${o.embeds ?? ""}`;
2990
3703
  // The document itself. Shared by the GET below and the publish-time pre-warm,
2991
3704
  // so both produce byte-identical output for one key.
2992
3705
  async function buildSurfaceDoc(args) {
@@ -2994,12 +3707,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2994
3707
  const theme = themeById(DEFAULT_THEME_ID);
2995
3708
  const themeId = theme.id;
2996
3709
  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;
3710
+ // Embedded components are expanded server-side (the caller passes the
3711
+ // expansion), so the whole page is still ONE sandboxed document rather
3712
+ // than nested frames.
3713
+ const html = args.composed ?? surface.html;
3003
3714
  return renderHtmlPage({
3004
3715
  title: args.title,
3005
3716
  html,
@@ -3062,8 +3773,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3062
3773
  if (!isSandboxedSurfaceKind(surface.kind))
3063
3774
  continue;
3064
3775
  warmed++;
3776
+ const composed = surface.kind === "html" && hasEmbedTags(surface.html)
3777
+ ? await composedHtml(mock.project, mock.slug, surface.html)
3778
+ : null;
3065
3779
  for (const mode of ["light", "dark"]) {
3066
- const key = renderKey({ postId: post.id, idx, version: post.version, mode, origin });
3780
+ const key = renderKey({
3781
+ postId: post.id,
3782
+ idx,
3783
+ version: post.version,
3784
+ mode,
3785
+ origin,
3786
+ embeds: composed ? embedsKey(composed) : undefined,
3787
+ });
3067
3788
  if (renderCache.has(key))
3068
3789
  continue;
3069
3790
  await cachedRender(key, () => buildSurfaceDoc({
@@ -3075,6 +3796,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3075
3796
  mode,
3076
3797
  origin,
3077
3798
  design,
3799
+ composed: composed?.html,
3078
3800
  }));
3079
3801
  }
3080
3802
  }
@@ -3088,7 +3810,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3088
3810
  const modeParam = c.req.query("mode");
3089
3811
  const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
3090
3812
  const kParam = c.req.query("k");
3091
- const origin = new URL(c.req.url).origin;
3813
+ // `e`: the embeds key the caller saw (cache identity); `embed`: a peek at
3814
+ // older component versions, JSON {slug: version}.
3815
+ const eParam = c.req.query("e");
3816
+ const embedParam = c.req.query("embed");
3817
+ const origin = requestOrigin(c.req.raw);
3092
3818
  // Cache-first. A hit needs nothing from the post row, and the row is the
3093
3819
  // expensive part of this route (surfaces + every retained version). Only a
3094
3820
  // version-pinned request can take this path: without `ver` the current
@@ -3096,13 +3822,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3096
3822
  // request never does: its values are checked against the mock's knobs,
3097
3823
  // which can change under a pinned version.
3098
3824
  const pinned = Number(ver);
3099
- if (Number.isInteger(pinned) && pinned > 0 && kParam === undefined) {
3825
+ // A document with embeds is stored only under the key its resolved embeds
3826
+ // give, so a hit here was rendered with exactly the `e` this URL names.
3827
+ if (Number.isInteger(pinned) && pinned > 0 && kParam === undefined && !embedParam) {
3100
3828
  const hit = renderCacheHit(renderKey({
3101
3829
  postId: c.req.param("id"),
3102
3830
  idx: Number(surfaceParam),
3103
3831
  version: pinned,
3104
3832
  mode,
3105
3833
  origin,
3834
+ embeds: eParam,
3106
3835
  }));
3107
3836
  if (hit !== undefined) {
3108
3837
  surfaceDocHeaders(c, true);
@@ -3158,16 +3887,59 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3158
3887
  knobsKey = JSON.stringify(knobs);
3159
3888
  }
3160
3889
  }
3890
+ let composed = null;
3891
+ if (surface.kind === "html" && hasEmbedTags(surface.html) && mock) {
3892
+ let overrides;
3893
+ if (embedParam !== undefined) {
3894
+ let raw;
3895
+ try {
3896
+ raw = JSON.parse(embedParam);
3897
+ }
3898
+ catch {
3899
+ raw = null;
3900
+ }
3901
+ const ok = raw !== null &&
3902
+ typeof raw === "object" &&
3903
+ !Array.isArray(raw) &&
3904
+ Object.values(raw).every((v) => Number.isInteger(v) && v > 0);
3905
+ if (!ok || embedParam.length > MAX_KNOB_QUERY) {
3906
+ surfaceDocHeaders(c, false);
3907
+ return c.text("embed must be URL-encoded JSON of {slug: version}", 400);
3908
+ }
3909
+ overrides = raw;
3910
+ }
3911
+ composed = await composedHtml(mock.project, mock.slug, surface.html, overrides);
3912
+ }
3913
+ const eKey = composed ? embedsKey(composed) : undefined;
3161
3914
  // 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);
3915
+ // param), so a version-pinned document never changes, unless it follows a
3916
+ // component's latest version and the URL does not name that state (`e`).
3917
+ surfaceDocHeaders(c, ver != null && (eKey === undefined || eKey === eParam));
3164
3918
  // 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 });
3919
+ // resolved `version` and embeds key make it immutable, so a hit is always
3920
+ // correct.
3921
+ const cacheKey = renderKey({
3922
+ postId: post.id,
3923
+ idx,
3924
+ version,
3925
+ mode,
3926
+ origin,
3927
+ knobs: knobsKey,
3928
+ embeds: eKey,
3929
+ });
3169
3930
  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 }));
3931
+ const doc = await cachedRender(cacheKey, async () => buildSurfaceDoc({
3932
+ post,
3933
+ mock,
3934
+ surface,
3935
+ title,
3936
+ version,
3937
+ mode,
3938
+ origin,
3939
+ design,
3940
+ knobs,
3941
+ composed: composed?.html,
3942
+ }));
3171
3943
  return c.html(doc);
3172
3944
  };
3173
3945
  app.get("/s/:id", renderPostPage);
@@ -3228,7 +4000,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3228
4000
  const result = await uploadAsset(body);
3229
4001
  if ("error" in result)
3230
4002
  return c.json({ error: result.error }, result.status);
3231
- const origin = new URL(c.req.url).origin;
4003
+ const origin = requestOrigin(c.req.raw);
3232
4004
  const publicBasePath = requestBasePath(c.req.raw);
3233
4005
  const assetPath = encodeURIComponent(result.asset.id);
3234
4006
  return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201);
@@ -3382,6 +4154,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3382
4154
  const mcpFlows = {
3383
4155
  publish: (body, ctx) => publishFlow(body, ctx),
3384
4156
  list: (query) => listMocksFlow(query),
4157
+ tree: (query) => treeFlow(query),
3385
4158
  get: (ref, query) => getMockFlow(ref, query),
3386
4159
  ask: (ref, body, ctx) => askFlow(ref, body, ctx),
3387
4160
  exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),