mockpit 1.1.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 (62) hide show
  1. package/README.md +13 -6
  2. package/bin/mockpit.js +139 -18
  3. package/dist/mcp/server.js +30 -10
  4. package/dist/server/apiViews.d.ts +129 -25
  5. package/dist/server/apiViews.js +86 -23
  6. package/dist/server/app.d.ts +2 -1
  7. package/dist/server/app.js +1258 -200
  8. package/dist/server/appIcon.d.ts +22 -0
  9. package/dist/server/appIcon.js +43 -0
  10. package/dist/server/appIconPng.d.ts +1 -0
  11. package/dist/server/appIconPng.js +6 -0
  12. package/dist/server/askNudges.d.ts +3 -0
  13. package/dist/server/askNudges.js +40 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +8 -0
  16. package/dist/server/decisions.d.ts +23 -0
  17. package/dist/server/decisions.js +99 -0
  18. package/dist/server/designGuide.js +53 -16
  19. package/dist/server/embeds.d.ts +25 -0
  20. package/dist/server/embeds.js +118 -0
  21. package/dist/server/eventLog.js +2 -1
  22. package/dist/server/feedbackBatch.d.ts +28 -5
  23. package/dist/server/feedbackBatch.js +78 -11
  24. package/dist/server/fills.d.ts +41 -0
  25. package/dist/server/fills.js +328 -0
  26. package/dist/server/handoff.d.ts +6 -0
  27. package/dist/server/handoff.js +38 -0
  28. package/dist/server/index.js +15 -1
  29. package/dist/server/knobs.d.ts +6 -2
  30. package/dist/server/knobs.js +13 -4
  31. package/dist/server/mcpHttp.d.ts +5 -2
  32. package/dist/server/mcpHttp.js +14 -5
  33. package/dist/server/mcpSpec.d.ts +21 -13
  34. package/dist/server/mcpSpec.js +99 -9
  35. package/dist/server/postSurfaces.js +3 -12
  36. package/dist/server/richRender.js +3 -1
  37. package/dist/server/run.js +6 -2
  38. package/dist/server/runApi.d.ts +1 -1
  39. package/dist/server/runApi.js +5 -4
  40. package/dist/server/sqlStore.d.ts +92 -5
  41. package/dist/server/sqlStore.js +188 -30
  42. package/dist/server/surfacePage.d.ts +6 -2
  43. package/dist/server/surfacePage.js +142 -10
  44. package/dist/server/themes.d.ts +2 -0
  45. package/dist/server/themes.js +23 -3
  46. package/dist/server/types.d.ts +95 -10
  47. package/dist/server/types.js +89 -6
  48. package/dist/server/viewerRequest.d.ts +2 -0
  49. package/dist/server/viewerRequest.js +14 -0
  50. package/extensions/mockpit.js +1 -0
  51. package/guide/topics/asks.md +69 -10
  52. package/guide/topics/feedback.md +104 -16
  53. package/guide/topics/html.md +5 -1
  54. package/guide/topics/http.md +2 -1
  55. package/guide/topics/knobs.md +4 -3
  56. package/guide/topics/surfaces.md +32 -16
  57. package/package.json +4 -13
  58. package/skills/launch-video/SKILL.md +1 -1
  59. package/skills/mockpit/SKILL.md +14 -4
  60. package/viewer/dist/index.html +9 -9
  61. package/dist/server/slots.d.ts +0 -14
  62. package/dist/server/slots.js +0 -71
@@ -2,26 +2,33 @@ import { Hono } from "hono";
2
2
  import { bodyLimit } from "hono/body-limit";
3
3
  import { getCookie, setCookie } from "hono/cookie";
4
4
  import { streamSSE } from "hono/streaming";
5
+ import { ICON_SIZES, ICON_SVG, themeColor, webManifest } from "./appIcon.js";
6
+ import { ICON_PNG_BASE64 } from "./appIconPng.js";
5
7
  import { decodeBase64 } from "./base64.js";
6
- import { mockDetailView, mockSummaryView, sessionRowView, surfaceRef } from "./apiViews.js";
8
+ import { mockDetailView, treeNodeView, mockSummaryView, sessionRowView, surfaceRef, } from "./apiViews.js";
9
+ import { askNudges } from "./askNudges.js";
7
10
  import { EventBus } from "./events.js";
8
11
  import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
9
12
  import { buildFeedbackBatches } from "./feedbackBatch.js";
13
+ import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
10
14
  import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
11
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";
12
18
  import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
13
19
  import { diffParts, partsInSurfaces, spliceParts } from "./parts.js";
14
20
  import { postToMarkdown } from "./postMarkdown.js";
15
21
  import { addHook, addSubscription, isPushSubscription, listHooks, notify, removeHook, vapidKeys, } from "./push.js";
16
- import { expandSlots, parseSlotTags } from "./slots.js";
22
+ import { embedsKey, expandHtml, hasEmbedTags, parseEmbedTags, } from "./embeds.js";
17
23
  import { registerMcp } from "./mcpHttp.js";
18
24
  import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
19
25
  import { logMiddleware, writeLog } from "./eventLog.js";
20
26
  import { RUN_API } from "./runApi.js";
21
- 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";
22
28
  import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
23
- import { BUILTIN_ASK_ID, builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, 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";
24
30
  import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
31
+ import { isViewerRequest } from "./viewerRequest.js";
25
32
  const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
26
33
  // A held long-poll (watch, the viewer) re-arms rather than pinning a socket
27
34
  // for good; proxies and serverless hosts drop longer requests anyway.
@@ -161,13 +168,25 @@ const fail = (status, error, extra = {}) => ({
161
168
  status,
162
169
  body: { error, ...extra },
163
170
  });
171
+ const MAX_NOTE = 400;
172
+ const MAX_WIDTHS = 4;
173
+ const MIN_WIDTH = 280;
174
+ const MAX_WIDTH = 2560;
164
175
  const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
165
176
  const MAX_LABEL = 120;
177
+ const MAX_FLOWS = 50;
178
+ const MAX_FLOW_STEPS = 100;
166
179
  const MAX_ASKS = 20;
167
180
  const MAX_OPTIONS = 20;
168
181
  const MAX_DRAFT_COMMENTS = 50;
182
+ const APP_FILES = new Set([
183
+ "/manifest.webmanifest",
184
+ "/icon.svg",
185
+ "/apple-touch-icon.png",
186
+ ...ICON_SIZES.map((s) => `/icon-${s}.png`),
187
+ ]);
169
188
  const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
170
- 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, }) {
171
190
  const app = new Hono();
172
191
  // The scripts topic carries the run API from runApi.ts, its one source.
173
192
  if (topics.scripts)
@@ -185,7 +204,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
185
204
  // warm entry, it can never be served to the wrong caller (renderKey includes it).
186
205
  let lastOrigin = null;
187
206
  app.use("*", (c, next) => {
188
- lastOrigin = new URL(c.req.url).origin;
207
+ lastOrigin = requestOrigin(c.req.raw);
189
208
  c.header("Referrer-Policy", "no-referrer");
190
209
  return next();
191
210
  });
@@ -275,7 +294,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
275
294
  function surfaceDocHeaders(c, immutable) {
276
295
  c.header("X-Content-Type-Options", "nosniff");
277
296
  c.header("Content-Security-Policy", "sandbox allow-scripts");
278
- 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");
279
299
  }
280
300
  function renderCacheHit(key) {
281
301
  const hit = renderCache.get(key);
@@ -319,6 +339,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
319
339
  return withLeading.slice(0, end);
320
340
  };
321
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
+ };
322
355
  // Cached, fail-silent update lookup: being offline or rate-limited must
323
356
  // cost nothing but the absence of the notice. Failures are cached too, so
324
357
  // a dead network doesn't retry on every viewer load.
@@ -331,31 +364,93 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
331
364
  return value;
332
365
  }
333
366
  // --- shared flows (used by both the REST API and the MCP endpoint) ---
367
+ // Sessions with an author=user long-poll held open right now: a waiting
368
+ // `mockpit watch` is a live agent, whatever its agentActiveAt says.
369
+ const listening = new Map();
334
370
  // User comments the agent has not seen yet ride along on its next write, so
335
371
  // agents hear feedback without blocking on the long-poll. The cursor also
336
372
  // advances past the agent's own comments to keep reads cheap.
337
- async function collectFeedback(sessionId) {
373
+ async function collectFeedback(sessionId, base) {
374
+ // Touch before reading the cursor: a taker's handOff checks agentActiveAt,
375
+ // so it can't move this cursor under a read already in flight.
376
+ await store.touchAgent(sessionId);
338
377
  const session = await store.getSession(sessionId);
339
378
  if (!session)
340
379
  return [];
380
+ const handedOff = await takeOrphaned(session);
341
381
  const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq });
342
- if (fresh.length === 0)
343
- return [];
344
- await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
345
- bus.broadcast({ type: "comment-delivered", sessionId, seq: fresh[fresh.length - 1].seq });
382
+ if (fresh.length > 0) {
383
+ await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
384
+ bus.broadcast({ type: "comment-delivered", sessionId, seq: fresh[fresh.length - 1].seq });
385
+ }
346
386
  const feedback = fresh.filter((cm) => cm.author === "user");
347
- return feedback.length > 0 ? await buildFeedbackBatches(store, feedback) : [];
387
+ return feedbackWithHandoffs(feedback, handedOff, base);
388
+ }
389
+ async function feedbackWithHandoffs(own, handedOff, base) {
390
+ const mine = own.length > 0 ? await buildFeedbackBatches(store, own, base) : [];
391
+ if (handedOff.length === 0)
392
+ return mine;
393
+ const theirs = await buildFeedbackBatches(store, handedOff, base);
394
+ return [...mine, ...theirs.map((b) => ({ ...b, handoff: HANDOFF_NOTE }))];
395
+ }
396
+ // The user comments still owed to sessions `taker` supersedes (handoff.ts),
397
+ // taken for `taker`: each owner's cursor moves past them in one conditional
398
+ // write, so delivery stays exactly-once across sessions.
399
+ async function takeOrphaned(taker) {
400
+ if (!taker.project)
401
+ return [];
402
+ const now = Date.now();
403
+ const taken = [];
404
+ for (const owner of await store.listSessions(taker.project)) {
405
+ if (!supersedes(taker, owner, listening.has(owner.id)))
406
+ continue;
407
+ const owed = await store.listComments({ sessionId: owner.id, afterSeq: owner.agentSeq });
408
+ // Seq order is time order, so the ready comments are a prefix; stop at the
409
+ // first one still inside its grace and leave the rest owed.
410
+ let upto = owner.agentSeq;
411
+ const take = [];
412
+ for (const c of owed) {
413
+ if (c.author === "user" && !handoffReady(c, owner, taker, now))
414
+ break;
415
+ upto = c.seq;
416
+ if (c.author === "user")
417
+ take.push(c);
418
+ }
419
+ if (take.length === 0)
420
+ continue;
421
+ if (!(await store.handOff(owner.id, owner, upto)))
422
+ continue;
423
+ bus.broadcast({ type: "comment-delivered", sessionId: owner.id, seq: upto });
424
+ taken.push(...take);
425
+ }
426
+ return taken;
348
427
  }
349
428
  // Per-comment delivery state for the viewer: `delivered` once the session's
350
- // agentSeq has passed the comment. Computed, never stored.
429
+ // agentSeq has passed the comment. Computed, never stored. `ownerGone` marks
430
+ // an undelivered user comment whose session a newer one already supersedes,
431
+ // so the Thread can say the next agent here will get it.
351
432
  async function withDelivered(comments) {
352
- const cursors = new Map();
433
+ const sessions = new Map();
353
434
  for (const c of comments) {
354
- if (!cursors.has(c.sessionId)) {
355
- cursors.set(c.sessionId, (await store.getSession(c.sessionId))?.agentSeq ?? 0);
435
+ if (!sessions.has(c.sessionId))
436
+ sessions.set(c.sessionId, await store.getSession(c.sessionId));
437
+ }
438
+ const gone = new Map();
439
+ const ownerGone = async (owner) => {
440
+ if (!gone.has(owner.id)) {
441
+ const peers = owner.project ? await store.listSessions(owner.project) : [];
442
+ gone.set(owner.id, peers.some((t) => supersedes(t, owner, listening.has(owner.id))));
356
443
  }
444
+ return gone.get(owner.id);
445
+ };
446
+ const out = [];
447
+ for (const c of comments) {
448
+ const owner = sessions.get(c.sessionId);
449
+ const delivered = c.seq <= (owner?.agentSeq ?? 0);
450
+ const waiting = !delivered && !!owner && c.author === "user" && (await ownerGone(owner));
451
+ out.push({ ...c, delivered, ...(waiting ? { ownerGone: true } : {}) });
357
452
  }
358
- return comments.map((c) => ({ ...c, delivered: c.seq <= (cursors.get(c.sessionId) ?? 0) }));
453
+ return out;
359
454
  }
360
455
  // The workspace's light/dark choice. Older workspaces stored a theme id under
361
456
  // `theme`; that registry is gone, so they start from the default mode.
@@ -516,7 +611,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
516
611
  variants: posts.map(variantLabel),
517
612
  });
518
613
  }
519
- const live = candidates.filter((p) => p.status !== "archived");
614
+ const live = candidates.filter((p) => isLive(p.status));
520
615
  if (live.length === 1 && !variant)
521
616
  return live[0];
522
617
  return fail(400, `${mock.slug} has several variants; pass ${variant ? "state" : "variant"}`, {
@@ -611,57 +706,126 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
611
706
  }
612
707
  return null;
613
708
  }
614
- // A page's slot list, with each missing version pinned to the referenced
615
- // component's current one (snapshot semantics).
616
- async function pageSlots(project, surfaces) {
617
- const html = surfaces.find((s) => s.kind === "html");
618
- if (!html || html.kind !== "html")
619
- return [];
620
- const slots = [];
621
- for (const tag of parseSlotTags(html.html)) {
622
- let version = tag.version;
623
- if (version == null)
624
- version = (await slotTarget(project, tag.slug, tag.variant))?.version ?? null;
625
- if (version != null)
626
- slots.push({ slug: tag.slug, variant: tag.variant, version });
627
- }
628
- return slots;
629
- }
630
- // A slot names a component mock by slug; it renders that mock's first state.
631
- 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) {
632
712
  const mock = await store.findMock(project, slug);
633
713
  if (!mock)
634
714
  return null;
635
- return store.findPost(mock.id, mock.states[0] ?? null, variant);
636
- }
637
- // Inline each `<mockpit-slot>` with the referenced variant version's first
638
- // html surface body. The stored `slots` list (resolved at publish) wins over
639
- // a bare tag, so a page keeps rendering the versions it was composed from.
640
- async function expandPageHtml(project, post, html) {
641
- const pinned = new Map(post.slots.map((s) => [`${s.slug}::${s.variant}`, s.version]));
642
- const bodies = new Map();
643
- for (const tag of parseSlotTags(html)) {
644
- const key = `${tag.slug}::${tag.variant}`;
645
- const version = tag.version ?? pinned.get(key) ?? null;
646
- const cacheKey = `${key}::${version}`;
647
- if (bodies.has(cacheKey))
648
- continue;
649
- const target = await slotTarget(project, tag.slug, tag.variant);
650
- if (!target) {
651
- 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))
652
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 });
653
793
  }
654
- const surfaces = version == null || version === target.version
655
- ? target.surfaces
656
- : (target.history.find((h) => h.version === version)?.surfaces ?? null);
657
- const body = surfaces?.find((s) => s.kind === "html");
658
- bodies.set(cacheKey, body && body.kind === "html" ? body.html : null);
794
+ out.set(post.id, { key: embedsKey(composed), list });
659
795
  }
660
- return expandSlots(html, ({ slug, variant, version }) => {
661
- const key = `${slug}::${variant}`;
662
- const pinnedVersion = version ?? pinned.get(key) ?? null;
663
- return bodies.get(`${key}::${pinnedVersion}`) ?? null;
664
- });
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;
665
829
  }
666
830
  // Knobs with a handful of discrete choices are usually decisions dressed as
667
831
  // controls: the user can't compare options they only see one at a time.
@@ -669,8 +833,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
669
833
  const out = [];
670
834
  for (const [path, config] of Object.entries(knobs)) {
671
835
  const n = discreteChoices(config);
672
- if (n !== null && n <= 3) {
673
- out.push(`knob "${path}" has ${n} discrete options: if each option needs its own render, ask instead (ask with options bound to variants); keep a knob when one render plus a control shows it`);
836
+ if (n !== null && n >= 2 && n <= 3) {
837
+ out.push(`knob "${path}": a 2–3 option knob is a choice. Ask with options that \`set\` it.`);
674
838
  }
675
839
  }
676
840
  return out;
@@ -680,50 +844,102 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
680
844
  // chat instead. The nudge carries a ready ask: mock-wide when every state has
681
845
  // the same variant names, else for this state.
682
846
  function unboundVariants(mock, posts, state) {
683
- 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);
684
848
  const names = live(state);
685
849
  if (names.length < 2)
686
850
  return null;
851
+ const applies = (a) => a.scope !== "part" && !(a.scope === "state" && a.state !== state);
687
852
  const bound = mock.asks.some((a) => {
688
- if (a.scope === "part" || (a.scope === "state" && a.state !== state))
853
+ if (!applies(a))
689
854
  return false;
690
855
  const variants = new Set(a.options.map((o) => o.variant));
691
856
  return names.every((n) => variants.has(n));
692
857
  });
693
858
  if (bound)
694
859
  return null;
860
+ const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
695
861
  const key = (list) => [...list].sort().join("\n");
696
862
  const states = mock.states.length ? mock.states : [null];
697
863
  const wide = states.every((s) => key(live(s)) === key(names));
864
+ // An ask that already picks between some of these variants is the question:
865
+ // a second, generic "Which one?" would make the user choose the look twice.
866
+ // A mock-wide one only fits while every state has the same variants; else a
867
+ // variant only some states have would leave the others no real choice.
868
+ const existing = mock.asks.find((a) => applies(a) && a.options.some((o) => o.variant) && (a.scope === "state" || wide));
869
+ if (existing) {
870
+ const have = new Set(existing.options.map((o) => o.variant));
871
+ const missing = names.filter((n) => !have.has(n));
872
+ const ask = {
873
+ id: existing.id,
874
+ text: existing.text,
875
+ scope: existing.scope,
876
+ ...(existing.state ? { state: existing.state } : {}),
877
+ ...(existing.multi ? { multi: true } : {}),
878
+ options: [
879
+ ...existing.options.map((o) => ({
880
+ id: o.id,
881
+ label: o.label,
882
+ ...(o.variant ? { variant: o.variant } : {}),
883
+ ...(o.set ? { set: o.set } : {}),
884
+ })),
885
+ ...missing.map((v) => ({ label: v, variant: v })),
886
+ ],
887
+ };
888
+ return {
889
+ nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing ask "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
890
+ ask,
891
+ };
892
+ }
698
893
  const options = names.map((v) => ({ label: v, variant: v }));
894
+ // Not the built-in's reserved id: a stored ask under it would collide with the
895
+ // built-in the viewer synthesizes once a new variant goes unbound. Nor any id
896
+ // already taken, which sending this would silently replace.
897
+ const base = wide ? "look" : `look-${slugify(state ?? "")}`;
898
+ let id = base;
899
+ for (let n = 2; mock.asks.some((a) => a.id === id); n++)
900
+ id = `${base}-${n}`;
699
901
  const ask = wide
700
- ? { id: BUILTIN_ASK_ID, text: "Which one?", scope: "mock", options }
701
- : {
702
- id: `${BUILTIN_ASK_ID}-${slugify(state ?? "")}`,
703
- text: "Which one?",
704
- scope: "state",
705
- state,
706
- options,
707
- };
708
- const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
902
+ ? { id, text: "Which one?", scope: "mock", options }
903
+ : { id, text: "Which one?", scope: "state", state, options };
709
904
  return {
710
- nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk (or your own question with options bound to the variants) so the user can choose in one Send; never ask in chat`,
905
+ nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk. Never ask in chat.`,
711
906
  ask,
712
907
  };
713
908
  }
714
909
  // The answer to every write: what was published, the parts found per state,
715
- // what moved since the previous version, and any feedback waiting.
910
+ // what moved since the previous version, and any feedback waiting — for the
911
+ // session that wrote, which on a new version may not be the variant's own.
716
912
  async function writeResult(mock, post, ctx, extra = {}) {
717
913
  const posts = await store.listPosts({ mockId: mock.id });
718
914
  const detail = mockDetailView(mock, posts);
719
915
  const unbound = unboundVariants(mock, posts, post.state);
720
- 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
+ ];
721
937
  let partChanges;
722
938
  if (extra.previous) {
723
939
  partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
724
940
  }
725
941
  const warnings = await iconWarnings(mock.project, post.surfaces);
726
- const feedback = await collectFeedback(post.sessionId);
942
+ const feedback = await collectFeedback(extra.writer?.id ?? post.sessionId, ctx.base);
727
943
  return ok({
728
944
  mock: {
729
945
  id: mock.id,
@@ -744,13 +960,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
744
960
  sessionId: post.sessionId,
745
961
  url: mockUrl(ctx.base, mock, post),
746
962
  parts: detail.parts,
963
+ ...(slots.length ? { slots } : {}),
747
964
  ...(extra.applied ? { applied: extra.applied } : {}),
748
965
  ...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
749
966
  ? { partChanges }
750
967
  : {}),
751
968
  ...(nudges.length ? { nudges } : {}),
752
969
  ...(unbound ? { suggestedAsk: unbound.ask } : {}),
970
+ ...(slotAsks.length ? { suggestedSlotAsks: slotAsks } : {}),
753
971
  ...(warnings.length ? { warnings } : {}),
972
+ ...(extra.flow ? { flow: extra.flow } : {}),
754
973
  feedback,
755
974
  }, extra.status ?? 200);
756
975
  }
@@ -764,26 +983,46 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
764
983
  by,
765
984
  });
766
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
+ }
767
994
  }
768
995
  // The one write: publish one variant of one state of a mock. An existing
769
996
  // (mock, state, variant) becomes a new version; anything else is created.
770
- // Exactly one body: `html`, the full ordered `surfaces` list, or `parts`
771
- // 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.
772
999
  async function publishFlow(body, ctx) {
773
1000
  if (!body || typeof body !== "object")
774
1001
  return fail(400, "invalid JSON body");
775
1002
  const slugIn = str(body.mock, MAX_TITLE);
776
1003
  if (!slugIn)
777
1004
  return fail(400, 'provide "mock": the mock slug, e.g. "writer"');
778
- const given = ["html", "surfaces", "parts"].filter((k) => body[k] !== undefined);
779
- if (given.length === 0)
780
- 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
+ }
781
1017
  if (given.length > 1) {
782
- 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 ")}`);
783
1019
  }
784
- const partEdits = partEditsArg(body);
1020
+ const partEdits = editsArg(body, "parts");
785
1021
  if (isResult(partEdits))
786
1022
  return partEdits;
1023
+ const fillEdits = editsArg(body, "fills");
1024
+ if (isResult(fillEdits))
1025
+ return fillEdits;
787
1026
  const knobs = checkKnobs(body.knobs);
788
1027
  if (!knobs.ok)
789
1028
  return fail(400, knobs.error);
@@ -798,6 +1037,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
798
1037
  const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
799
1038
  const prompt = str(body.prompt, MAX_COMMENT_TEXT);
800
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;
801
1044
  // An existing mock is addressable by id before a session exists.
802
1045
  let mock = null;
803
1046
  if (typeof body.mock === "string")
@@ -832,13 +1075,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
832
1075
  const inState = posts.filter((p) => p.state === state);
833
1076
  let variant = variantIn;
834
1077
  if (!variant) {
835
- const live = inState.filter((p) => p.status !== "archived");
1078
+ const live = inState.filter((p) => isLive(p.status));
836
1079
  if (inState.length === 0)
837
1080
  variant = DEFAULT_VARIANT;
838
1081
  else if (inState.length === 1)
839
1082
  variant = inState[0].variant;
840
1083
  else if (live.length === 1)
841
1084
  variant = live[0].variant;
1085
+ else if (flowOnly)
1086
+ variant = (live[0] ?? inState[0]).variant;
842
1087
  else {
843
1088
  return fail(400, `${slug} has several variants; pass variant`, {
844
1089
  variants: inState.map(variantLabel),
@@ -849,9 +1094,53 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
849
1094
  const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
850
1095
  variants: posts.map(variantLabel),
851
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
+ }
852
1139
  // What `parts` and kept surface ids refer to: the variant's current
853
1140
  // version, or the one `from` names.
854
1141
  const base = () => {
1142
+ if (fork)
1143
+ return fork.surfaces;
855
1144
  if (!target)
856
1145
  return missing();
857
1146
  if (from === undefined || from === target.version)
@@ -861,11 +1150,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
861
1150
  };
862
1151
  let surfaces;
863
1152
  let applied;
864
- if (partEdits) {
1153
+ if (fork && given.length === 0) {
1154
+ surfaces = fork.surfaces;
1155
+ }
1156
+ else if (partEdits || fillEdits) {
865
1157
  const current = base();
866
1158
  if (isResult(current))
867
1159
  return current;
868
- const result = spliceParts(current, partEdits);
1160
+ const result = partEdits
1161
+ ? spliceParts(current, partEdits)
1162
+ : spliceFills(current, fillEdits);
869
1163
  if (!result.ok)
870
1164
  return fail(400, result.error);
871
1165
  surfaces = result.surfaces;
@@ -881,6 +1175,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
881
1175
  if (bad)
882
1176
  return bad;
883
1177
  const nudges = knobNudges({ ...knobs.value, ...variantKnobs.value });
1178
+ const embeds = await publishEmbeds(project, slug, surfaces);
1179
+ if (isResult(embeds))
1180
+ return embeds;
884
1181
  if (!target) {
885
1182
  // A new variant: carried by the session the agent is writing from.
886
1183
  if (!session) {
@@ -900,11 +1197,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
900
1197
  kind: kind ?? "component",
901
1198
  states: state === null ? [] : [state],
902
1199
  knobs: knobs.value,
1200
+ ...(widths ? { widths } : {}),
903
1201
  sessionId: session.id,
904
1202
  });
905
1203
  bus.broadcast({ type: "mock-created", id: mock.id, project });
906
1204
  }
907
- const slots = mock.kind === "page" || kind === "page" ? await pageSlots(project, surfaces) : [];
908
1205
  const created = await store.createPost({
909
1206
  sessionId: session.id,
910
1207
  mock: mock.id,
@@ -912,59 +1209,176 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
912
1209
  variant,
913
1210
  title: title ?? mock.title,
914
1211
  surfaces,
915
- ...(Object.keys(variantKnobs.value).length ? { knobs: variantKnobs.value } : {}),
916
- 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 } : {}),
917
1220
  from,
918
1221
  prompt,
919
1222
  author,
920
1223
  });
921
1224
  if (!created)
922
1225
  return fail(404, "session not found");
923
- 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;
924
1235
  announcePost(mock, created, true);
925
1236
  if (prompt && ctx.request)
926
1237
  notifyPublish(mock, created, prompt, ctx);
927
- return writeResult(mock, created, ctx, { nudges, status: 201 });
1238
+ return writeResult(mock, created, ctx, { nudges, status: 201, flow });
928
1239
  }
929
1240
  if (!mock)
930
1241
  return missing();
931
1242
  // A new version is written by whoever writes it; without an explicit
932
1243
  // session the variant's own session carries it.
933
1244
  session ??= await store.getSession(target.sessionId);
934
- const slots = mock.kind === "page" ? await pageSlots(project, surfaces) : undefined;
935
1245
  const updated = await store.updatePost(target.id, {
936
1246
  surfaces,
937
1247
  title,
938
1248
  ...(body.variantKnobs !== undefined ? { knobs: variantKnobs.value } : {}),
939
- slots,
1249
+ ...(note !== undefined ? { note } : {}),
1250
+ embeds,
940
1251
  from,
941
1252
  prompt,
942
1253
  author,
943
1254
  });
944
1255
  if (!updated)
945
1256
  return fail(404, "variant not found");
946
- 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 });
947
1259
  announcePost(mock, updated, false);
948
1260
  if (prompt && ctx.request)
949
1261
  notifyPublish(mock, updated, prompt, ctx);
950
- return writeResult(mock, updated, ctx, { previous: target.surfaces, applied, nudges });
1262
+ return writeResult(mock, updated, ctx, {
1263
+ previous: target.surfaces,
1264
+ applied,
1265
+ nudges,
1266
+ writer: session,
1267
+ flow,
1268
+ });
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;
951
1361
  }
952
1362
  // `parts`: new outer html per part ("name" or "name#key"), spliced into the
953
1363
  // variant's html so a tweak costs the tweak rather than the whole document.
954
- function partEditsArg(body) {
955
- 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)
956
1367
  return undefined;
957
- const parts = body.parts;
958
- if (!parts || typeof parts !== "object" || Array.isArray(parts)) {
959
- 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}`);
960
1374
  }
961
- const entries = Object.entries(parts);
1375
+ const entries = Object.entries(edits);
962
1376
  if (entries.length === 0)
963
- 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)}`);
964
1378
  if (entries.some(([, html]) => typeof html !== "string")) {
965
- 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)}`);
966
1380
  }
967
- return parts;
1381
+ return edits;
968
1382
  }
969
1383
  // A write may add a state, retitle the mock, declare knobs, and makes its
970
1384
  // session the one a reply is delivered to.
@@ -976,6 +1390,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
976
1390
  ...(w.kind ? { kind: w.kind } : {}),
977
1391
  ...(w.title ? { title: w.title } : {}),
978
1392
  ...(knobs ? { knobs } : {}),
1393
+ ...(w.widths ? { widths: w.widths } : {}),
979
1394
  ...(w.session ? { sessionId: w.session.id } : {}),
980
1395
  });
981
1396
  if (!updated)
@@ -1006,17 +1421,56 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1006
1421
  }
1007
1422
  const open = rows.reduce((n, r) => n + r.open, 0);
1008
1423
  return ok({
1009
- ...(project ? { project } : {}),
1424
+ ...(project ? { project, flows: await flowsView(project, mocks) } : {}),
1010
1425
  mocks: rows,
1011
1426
  open,
1012
1427
  openMocks: rows.filter((r) => r.open > 0).length,
1013
1428
  pending,
1014
1429
  });
1015
1430
  }
1016
- // The tuned values of the mock's latest reply: what the user last sent.
1017
- async function lastTuned(mockId) {
1018
- const replies = (await store.listComments({ mockId })).filter((c) => c.kind === "reply");
1019
- 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);
1020
1474
  }
1021
1475
  async function getMockFlow(ref, query) {
1022
1476
  const mock = await resolveMock(ref, query.project, query.session);
@@ -1027,7 +1481,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1027
1481
  ...mockDetailView(mock, posts, {
1028
1482
  body: query.body,
1029
1483
  history: query.history,
1030
- tuned: await lastTuned(mock.id),
1484
+ settled: settledValues(await replyPayloads(mock.id), mock, posts),
1485
+ embeds: await embedViews(mock, posts),
1031
1486
  }),
1032
1487
  pending: pendingOf(mock, posts),
1033
1488
  });
@@ -1042,31 +1497,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1042
1497
  const states = (mock.states.length ? mock.states : [null]).filter((s) => wantedState === undefined || s === wantedState);
1043
1498
  if (states.length === 0)
1044
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);
1045
1503
  const entries = [];
1046
1504
  for (const state of states) {
1047
1505
  const inState = posts.filter((p) => p.state === state);
1048
- const post = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
1506
+ const found = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
1049
1507
  inState.find((p) => p.status === "accepted") ||
1050
1508
  inState.find((p) => p.status === "open") ||
1051
1509
  inState[0];
1052
- if (!post)
1510
+ if (!found)
1053
1511
  continue;
1054
- if (wantedVariant && post.variant !== wantedVariant)
1512
+ if (wantedVariant && found.variant !== wantedVariant)
1055
1513
  continue;
1514
+ const post = postAtVersion(found, inUseVersion(found)) ?? found;
1056
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;
1057
1522
  entries.push({
1058
1523
  state,
1059
1524
  variant: post.variant,
1060
1525
  postId: post.id,
1061
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 } : {}),
1062
1529
  status: post.status,
1063
- html: html && html.kind === "html" ? html.html : "",
1530
+ html: composed,
1531
+ ...(slots.length ? { source: raw } : {}),
1064
1532
  markdown: postToMarkdown(post, {
1065
1533
  postUrl: `${ctx.base}/s/${post.id}`,
1066
1534
  assetBase: ctx.base,
1067
1535
  }),
1068
1536
  history: [
1069
- ...[...post.history]
1537
+ ...[...found.history]
1538
+ .filter((h) => !(found.hiddenVersions ?? []).includes(h.version))
1070
1539
  .sort((a, b) => a.version - b.version)
1071
1540
  .map((h) => ({
1072
1541
  version: h.version,
@@ -1075,10 +1544,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1075
1544
  prompt: h.prompt ?? "",
1076
1545
  })),
1077
1546
  {
1078
- version: post.version,
1079
- at: post.updatedAt,
1080
- from: post.from ?? null,
1081
- prompt: post.prompt ?? "",
1547
+ version: found.version,
1548
+ at: found.updatedAt,
1549
+ from: found.from ?? null,
1550
+ prompt: found.prompt ?? "",
1082
1551
  },
1083
1552
  ],
1084
1553
  screenshotUrl: screenshots ? `${ctx.base}/s/${post.id}.png?v=${post.version}` : null,
@@ -1089,7 +1558,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1089
1558
  variants: posts.map(variantLabel),
1090
1559
  });
1091
1560
  }
1092
- const replies = (await store.listComments({ mockId: mock.id })).filter((c) => c.kind === "reply");
1561
+ const decisions = lastReply ? decisionsFor(mock, posts, lastReply) : null;
1093
1562
  return ok({
1094
1563
  project: mock.project,
1095
1564
  mock: mock.slug,
@@ -1097,7 +1566,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1097
1566
  kind: mock.kind,
1098
1567
  states: entries,
1099
1568
  knobs: mock.knobs,
1100
- reply: replies[replies.length - 1]?.payload ?? null,
1569
+ settled,
1570
+ reply: lastReply,
1571
+ ...(decisions ? { decisions } : {}),
1101
1572
  });
1102
1573
  }
1103
1574
  // --- asks ---
@@ -1153,12 +1624,25 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1153
1624
  return checked.error;
1154
1625
  set = checked.value;
1155
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
+ }
1156
1634
  options.push({ id, label, ...(variant ? { variant } : {}), ...(set ? { set } : {}) });
1157
1635
  }
1158
1636
  let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
1159
1637
  if (!str(raw.id, 64))
1160
1638
  for (let n = 2; taken.has(id); n++)
1161
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
+ }
1162
1646
  return {
1163
1647
  id,
1164
1648
  text,
@@ -1167,9 +1651,49 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1167
1651
  ...(part ? { part } : {}),
1168
1652
  options,
1169
1653
  ...(raw.multi === true ? { multi: true } : {}),
1654
+ ...(after ? { after } : {}),
1170
1655
  at: new Date().toISOString(),
1171
1656
  };
1172
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
+ }
1173
1697
  // Ask the user structured questions. An ask whose id matches an existing one
1174
1698
  // replaces it (and reopens it); anything else is appended.
1175
1699
  async function askFlow(ref, body, ctx) {
@@ -1188,11 +1712,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1188
1712
  const asks = [...mock.asks];
1189
1713
  const taken = new Set(asks.map((a) => a.id));
1190
1714
  const added = [];
1715
+ const skipped = [];
1191
1716
  for (const entry of raw) {
1192
1717
  const ask = sanitizeAsk(entry, mock, posts, taken);
1193
1718
  if (typeof ask === "string")
1194
1719
  return fail(400, ask);
1195
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
+ }
1196
1727
  if (at >= 0)
1197
1728
  asks[at] = ask;
1198
1729
  else
@@ -1200,11 +1731,31 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1200
1731
  taken.add(ask.id);
1201
1732
  added.push(ask);
1202
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
+ }
1203
1748
  let sessionId = mock.sessionId;
1204
1749
  if (typeof body.session === "string" && (await store.getSession(body.session))) {
1205
1750
  sessionId = body.session;
1206
1751
  }
1207
- 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
+ });
1208
1759
  if (!updated)
1209
1760
  return fail(404, "mock not found");
1210
1761
  mock = updated;
@@ -1231,19 +1782,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1231
1782
  text: added.map((a) => a.text).join(" · "),
1232
1783
  url: mockUrl(ctx.base, mock),
1233
1784
  });
1234
- const feedback = session ? await collectFeedback(session.id) : [];
1785
+ const feedback = session ? await collectFeedback(session.id, ctx.base) : [];
1786
+ const nudges = [...skippedNudges, ...askNudges(added, mock.asks)];
1235
1787
  return ok({
1236
1788
  mock: mock.slug,
1237
1789
  mockId: mock.id,
1238
1790
  project: mock.project,
1239
1791
  asks: added,
1792
+ ...(skipped.length ? { skipped } : {}),
1240
1793
  open: openCount(mock, posts),
1241
1794
  url: mockUrl(ctx.base, mock),
1795
+ ...(nudges.length ? { nudges } : {}),
1242
1796
  feedback,
1243
1797
  });
1244
1798
  }
1245
1799
  // --- drafts and replies (the user's side; viewer origin only) ---
1246
- function sanitizePartComments(raw, mock) {
1800
+ function sanitizePartComments(raw, mock, posts) {
1247
1801
  if (raw === undefined || raw === null)
1248
1802
  return [];
1249
1803
  if (!Array.isArray(raw))
@@ -1252,16 +1806,39 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1252
1806
  return `at most ${MAX_DRAFT_COMMENTS} comments`;
1253
1807
  const out = [];
1254
1808
  for (const c of raw) {
1809
+ const reaction = c?.reaction;
1810
+ if (reaction !== undefined && !REACTIONS.includes(reaction))
1811
+ return `reaction must be one of ${REACTIONS.join(", ")}`;
1255
1812
  const text = str(c?.text, MAX_COMMENT_TEXT);
1256
- if (!text)
1813
+ if (!text && !reaction)
1257
1814
  return "every comment needs text";
1258
1815
  const state = stateArg(c.state) ?? null;
1259
1816
  if (state !== null && !mock.states.includes(state))
1260
1817
  return `${mock.slug} has no state "${state}"`;
1261
1818
  const part = str(c.part, 200) ?? null;
1819
+ const variant = str(c.variant, 200);
1820
+ if (variant && !posts.some((p) => p.variant === variant && p.state === state))
1821
+ return `${mock.slug} has no variant "${variant}"${state ? ` in "${state}"` : ""}`;
1262
1822
  const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
1263
1823
  const anchor = a ? sanitizePartAnchor(a) : undefined;
1264
- out.push({ part, state, text, ...(anchor ? { anchor } : {}) });
1824
+ const next = {
1825
+ part,
1826
+ state,
1827
+ text: text ?? "",
1828
+ ...(anchor ? { anchor } : {}),
1829
+ ...(reaction ? { reaction: reaction } : {}),
1830
+ ...(variant ? { variant } : {}),
1831
+ };
1832
+ // One reaction per target: a later tap on the same part replaces the earlier one.
1833
+ if (reaction) {
1834
+ const same = out.findIndex((o) => o.reaction &&
1835
+ o.part === part &&
1836
+ o.state === state &&
1837
+ (o.variant ?? null) === (variant ?? null));
1838
+ if (same >= 0)
1839
+ out.splice(same, 1);
1840
+ }
1841
+ out.push(next);
1265
1842
  }
1266
1843
  return out;
1267
1844
  }
@@ -1274,6 +1851,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1274
1851
  : undefined;
1275
1852
  const quote = str(a.quote, 200);
1276
1853
  const selector = str(a.selector, 500);
1854
+ const width = typeof a.width === "number" && Number.isFinite(a.width) && a.width >= 1 && a.width <= 10000
1855
+ ? Math.round(a.width)
1856
+ : undefined;
1277
1857
  if (!offset && !box && !quote && !selector)
1278
1858
  return undefined;
1279
1859
  return {
@@ -1281,6 +1861,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1281
1861
  ...(quote ? { quote } : {}),
1282
1862
  ...(selector ? { selector } : {}),
1283
1863
  ...(box ? { box } : {}),
1864
+ ...(width ? { width } : {}),
1284
1865
  };
1285
1866
  }
1286
1867
  // A stored ask, else the viewer's built-in variant ask under its reserved id.
@@ -1329,6 +1910,37 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1329
1910
  }
1330
1911
  return out;
1331
1912
  }
1913
+ // askId → attached image ids: known asks, ids of stored raster images only,
1914
+ // deduped and capped, empty lists dropped.
1915
+ async function sanitizeAskImages(raw, mock, posts, key) {
1916
+ if (raw === undefined || raw === null)
1917
+ return {};
1918
+ if (typeof raw !== "object" || Array.isArray(raw))
1919
+ return `"${key}" must be an object`;
1920
+ const out = {};
1921
+ for (const [askId, value] of Object.entries(raw)) {
1922
+ if (!askById(mock, posts, askId))
1923
+ return `${mock.slug} has no ask "${askId}"`;
1924
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string")) {
1925
+ return `${key} "${askId}" must be a list of asset ids`;
1926
+ }
1927
+ const ids = [...new Set(value)];
1928
+ if (ids.length > MAX_ASK_IMAGES) {
1929
+ return `${key} "${askId}" takes at most ${MAX_ASK_IMAGES} images`;
1930
+ }
1931
+ for (const id of ids) {
1932
+ const asset = await store.getAsset(id);
1933
+ if (!asset)
1934
+ return `asset "${id}" not found`;
1935
+ if (!ATTACHABLE_IMAGE_TYPES.has(asset.contentType)) {
1936
+ return `asset "${id}" is not an attachable image`;
1937
+ }
1938
+ }
1939
+ if (ids.length)
1940
+ out[askId] = ids;
1941
+ }
1942
+ return out;
1943
+ }
1332
1944
  function sanitizeMix(raw, posts) {
1333
1945
  if (raw === undefined || raw === null)
1334
1946
  return {};
@@ -1348,7 +1960,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1348
1960
  }
1349
1961
  // A draft, validated against the mock as it is now: unknown asks, options,
1350
1962
  // variants, knob paths or out-of-range values are refused, never stored.
1351
- function sanitizeDraft(raw, mock, posts, base) {
1963
+ async function sanitizeDraft(raw, mock, posts, base) {
1352
1964
  const pick = (key) => (raw?.[key] !== undefined ? raw[key] : base?.[key]);
1353
1965
  const answers = sanitizeAnswers(pick("answers"), mock, posts);
1354
1966
  if (typeof answers === "string")
@@ -1359,7 +1971,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1359
1971
  const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
1360
1972
  if (!tuned.ok)
1361
1973
  return tuned.error;
1362
- const comments = sanitizePartComments(pick("comments"), mock);
1974
+ const comments = sanitizePartComments(pick("comments"), mock, posts);
1363
1975
  if (typeof comments === "string")
1364
1976
  return comments;
1365
1977
  const others = sanitizeAskTexts(pick("others"), mock, posts, "others");
@@ -1368,6 +1980,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1368
1980
  const notes = sanitizeAskTexts(pick("notes"), mock, posts, "notes");
1369
1981
  if (typeof notes === "string")
1370
1982
  return notes;
1983
+ const otherImages = await sanitizeAskImages(pick("otherImages"), mock, posts, "otherImages");
1984
+ if (typeof otherImages === "string")
1985
+ return otherImages;
1986
+ const noteImages = await sanitizeAskImages(pick("noteImages"), mock, posts, "noteImages");
1987
+ if (typeof noteImages === "string")
1988
+ return noteImages;
1989
+ for (const askId of Object.keys(otherImages))
1990
+ others[askId] ??= "";
1991
+ for (const askId of Object.keys(noteImages))
1992
+ notes[askId] ??= "";
1371
1993
  for (const askId of Object.keys(others)) {
1372
1994
  const ask = askById(mock, posts, askId);
1373
1995
  if (!ask?.multi && answers[askId] !== undefined)
@@ -1385,6 +2007,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1385
2007
  comments,
1386
2008
  others,
1387
2009
  notes,
2010
+ ...(Object.keys(otherImages).length ? { otherImages } : {}),
2011
+ ...(Object.keys(noteImages).length ? { noteImages } : {}),
1388
2012
  updatedAt: new Date().toISOString(),
1389
2013
  };
1390
2014
  }
@@ -1406,7 +2030,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1406
2030
  if (isResult(mock))
1407
2031
  return mock;
1408
2032
  const posts = await store.listPosts({ mockId: mock.id });
1409
- const draft = sanitizeDraft(body, mock, posts, null);
2033
+ const draft = await sanitizeDraft(body, mock, posts, null);
1410
2034
  if (typeof draft === "string")
1411
2035
  return fail(400, draft);
1412
2036
  await store.putDraft(mock.id, draft);
@@ -1438,18 +2062,30 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1438
2062
  // Which variants a reply accepts and archives. A variant-bound answer accepts
1439
2063
  // the chosen variant(s) in every state the ask covers and archives their
1440
2064
  // siblings there; a part-scoped pick is a mix, not a verdict. The built-in
1441
- // variant asks flip the same way, per state.
1442
- function replyFlips(mock, posts, answers, decision) {
1443
- 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();
1444
2070
  const archive = new Set();
1445
- const settle = (inState, chosen) => {
2071
+ const drop = new Set();
2072
+ const settle = (inState, chosen, pins) => {
1446
2073
  if (!inState.some((p) => chosen.has(p.variant)))
1447
2074
  return;
1448
2075
  for (const p of inState) {
1449
- if (chosen.has(p.variant))
1450
- accept.add(p.id);
1451
- else if (p.status !== "archived")
1452
- 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));
1453
2089
  }
1454
2090
  };
1455
2091
  const builtins = Object.keys(answers)
@@ -1460,7 +2096,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1460
2096
  if (answer === undefined || ask.scope === "part")
1461
2097
  continue;
1462
2098
  const ids = Array.isArray(answer) ? answer : [answer];
1463
- 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));
1464
2106
  if (chosen.size === 0)
1465
2107
  continue;
1466
2108
  const states = ask.scope === "state" ? [ask.state ?? null] : mock.states.length ? mock.states : [null];
@@ -1473,11 +2115,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1473
2115
  else if (decision?.kind === "drop") {
1474
2116
  const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
1475
2117
  if (post)
1476
- archive.add(post.id);
2118
+ drop.add(post.id);
1477
2119
  }
1478
- for (const id of accept)
2120
+ for (const id of accept.keys()) {
1479
2121
  archive.delete(id);
1480
- return { accept, archive };
2122
+ drop.delete(id);
2123
+ }
2124
+ for (const id of drop)
2125
+ archive.delete(id);
2126
+ return { accept, archive, drop };
1481
2127
  }
1482
2128
  // An ask as a reply leaves it. A pick or a write-in replaces whatever was
1483
2129
  // answered before; a note alone keeps that answer and adds to it.
@@ -1488,16 +2134,26 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1488
2134
  if (answer === undefined && other === undefined && note === undefined)
1489
2135
  return a;
1490
2136
  const next = { ...a };
2137
+ delete next.dismissed;
1491
2138
  if (answer !== undefined || other !== undefined) {
1492
2139
  delete next.answer;
1493
2140
  delete next.other;
2141
+ delete next.otherImages;
1494
2142
  if (answer !== undefined)
1495
2143
  next.answer = answer;
1496
2144
  if (other !== undefined)
1497
2145
  next.other = other;
2146
+ const images = draft.otherImages?.[a.id];
2147
+ if (other !== undefined && images)
2148
+ next.otherImages = images;
1498
2149
  }
1499
- if (note !== undefined)
2150
+ if (note !== undefined) {
1500
2151
  next.note = note;
2152
+ delete next.noteImages;
2153
+ const images = draft.noteImages?.[a.id];
2154
+ if (images)
2155
+ next.noteImages = images;
2156
+ }
1501
2157
  return next;
1502
2158
  }
1503
2159
  // Send: the user's one batched answer becomes one kind:"reply" comment on the
@@ -1510,7 +2166,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1510
2166
  if (isResult(mock))
1511
2167
  return mock;
1512
2168
  const posts = await store.listPosts({ mockId: mock.id });
1513
- const draft = sanitizeDraft(body ?? {}, mock, posts, mock.draft);
2169
+ const draft = await sanitizeDraft(body ?? {}, mock, posts, mock.draft);
1514
2170
  if (typeof draft === "string")
1515
2171
  return fail(400, draft);
1516
2172
  const text = str(body?.text, MAX_COMMENT_TEXT);
@@ -1540,28 +2196,36 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1540
2196
  comments: draft.comments,
1541
2197
  ...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
1542
2198
  ...(Object.keys(draft.notes ?? {}).length ? { notes: draft.notes } : {}),
2199
+ ...(draft.otherImages ? { otherImages: draft.otherImages } : {}),
2200
+ ...(draft.noteImages ? { noteImages: draft.noteImages } : {}),
1543
2201
  ...(text ? { text } : {}),
1544
- ...(decision ? { decision } : {}),
1545
2202
  };
1546
2203
  const asks = mock.asks.map((a) => sentAsk(a, draft));
1547
- 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
+ }
1548
2210
  const comment = await store.commitReply({
1549
2211
  mockId: mock.id,
1550
2212
  sessionId,
1551
2213
  text: text ?? "",
1552
2214
  payload,
1553
2215
  asks,
1554
- accept: [...flips.accept],
2216
+ accept: [...flips.accept].map(([id, version]) => ({ id, version })),
1555
2217
  archive: [...flips.archive],
2218
+ drop: [...flips.drop],
1556
2219
  });
1557
2220
  if (!comment)
1558
2221
  return fail(409, `${mock.slug} has no agent session to reply to`);
1559
2222
  announceComment(comment);
1560
2223
  bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
1561
2224
  bus.broadcast({ type: "draft-updated", mockId: mock.id });
2225
+ void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
1562
2226
  const after = await store.listPosts({ mockId: mock.id });
1563
2227
  for (const p of after) {
1564
- 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)) {
1565
2229
  bus.broadcast({
1566
2230
  type: "post-updated",
1567
2231
  id: p.id,
@@ -1586,12 +2250,216 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1586
2250
  reply: delivered,
1587
2251
  accepted: after
1588
2252
  .filter((p) => flips.accept.has(p.id))
1589
- .map((p) => ({ state: p.state, variant: p.variant })),
2253
+ .map((p) => ({ state: p.state, variant: p.variant, version: inUseVersion(p) })),
1590
2254
  archived: after
1591
2255
  .filter((p) => flips.archive.has(p.id))
1592
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 })),
1593
2341
  }, 201);
1594
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
+ }
1595
2463
  async function restoreFlow(ref, body) {
1596
2464
  const mock = await resolveMock(ref, body?.project);
1597
2465
  if (isResult(mock))
@@ -1613,6 +2481,34 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1613
2481
  });
1614
2482
  return ok({ state: updated.state, variant: updated.variant, status: updated.status });
1615
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
+ }
1616
2512
  // D8 "restore as vN": the user brings an older version back as the newest
1617
2513
  // one. Distinct from restoreFlow, which un-archives a variant. Viewer-only
1618
2514
  // because the version is authored by the user; an agent restoring publishes.
@@ -1865,7 +2761,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1865
2761
  }
1866
2762
  // The agent's plain-text message in a mock's thread. Stored as text and
1867
2763
  // rendered as a text node, never as HTML. A write, so feedback rides along.
1868
- async function sayFlow(ref, body) {
2764
+ async function sayFlow(ref, body, ctx) {
1869
2765
  if (!body || typeof body !== "object")
1870
2766
  return fail(400, "invalid JSON body");
1871
2767
  const text = typeof body.message === "string" ? str(body.message, MAX_COMMENT_TEXT) : undefined;
@@ -1889,10 +2785,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1889
2785
  if (!comment)
1890
2786
  return fail(404, "session not found");
1891
2787
  announceComment(comment);
1892
- return ok({ feedback: await collectFeedback(comment.sessionId) }, 201);
2788
+ return ok({ feedback: await collectFeedback(comment.sessionId, ctx.base) }, 201);
1893
2789
  }
1894
2790
  // Long-poll: resolves as soon as a matching comment lands, or at timeout.
1895
2791
  async function waitForComments(q, signal) {
2792
+ // The agent listening for its feedback: it is alive, and it may take
2793
+ // replies owed to a session it superseded (handoff.ts).
2794
+ const listener = q.agent && q.author === "user" ? q.sessionId : undefined;
2795
+ if (listener)
2796
+ await store.touchAgent(listener);
1896
2797
  // An author=user session wait with no explicit cursor resumes from the
1897
2798
  // session's agentSeq — "where the agent left off" lives server-side so the
1898
2799
  // CLI, both MCP transports, and piggyback share one exactly-once stream.
@@ -1900,12 +2801,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1900
2801
  if (afterSeq === undefined && q.author === "user" && q.sessionId) {
1901
2802
  afterSeq = (await store.getSession(q.sessionId))?.agentSeq;
1902
2803
  }
2804
+ const takeOrphanedFor = async (id) => {
2805
+ const taker = id ? await store.getSession(id) : null;
2806
+ return taker ? await takeOrphaned(taker) : [];
2807
+ };
2808
+ let handedOff = await takeOrphanedFor(listener);
1903
2809
  const query = { sessionId: q.sessionId, mockId: q.mockId, postId: q.postId, afterSeq };
1904
2810
  const matches = (list) => q.author ? list.filter((cm) => cm.author === q.author) : list;
1905
2811
  const wait = Math.min(Math.max(q.waitSeconds, 0), MAX_WAIT_SECONDS);
1906
2812
  let all = await store.listComments(query);
1907
2813
  let comments = matches(all);
1908
- if (comments.length === 0 && wait > 0) {
2814
+ if (comments.length === 0 && handedOff.length === 0 && wait > 0) {
2815
+ if (listener)
2816
+ listening.set(listener, (listening.get(listener) ?? 0) + 1);
1909
2817
  await new Promise((resolve) => {
1910
2818
  const timer = setTimeout(done, wait * 1000);
1911
2819
  const unsubscribe = bus.subscribe((event) => {
@@ -1934,10 +2842,20 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1934
2842
  resolve();
1935
2843
  }
1936
2844
  });
2845
+ if (listener) {
2846
+ const n = (listening.get(listener) ?? 1) - 1;
2847
+ if (n > 0)
2848
+ listening.set(listener, n);
2849
+ else
2850
+ listening.delete(listener);
2851
+ }
1937
2852
  // A caller that gave up can't receive this batch, so delivering it would
1938
2853
  // advance the cursor past feedback no agent ever saw.
1939
2854
  if (signal?.aborted)
1940
- return { comments: [], lastSeq: afterSeq ?? 0 };
2855
+ return { comments: [], lastSeq: afterSeq ?? 0, handedOff: [] };
2856
+ if (listener)
2857
+ await store.touchAgent(listener);
2858
+ handedOff = await takeOrphanedFor(listener);
1941
2859
  all = await store.listComments(query);
1942
2860
  comments = matches(all);
1943
2861
  }
@@ -1950,13 +2868,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1950
2868
  await store.markAgentSeen(q.sessionId, lastSeq);
1951
2869
  bus.broadcast({ type: "comment-delivered", sessionId: q.sessionId, seq: lastSeq });
1952
2870
  }
1953
- return { comments, lastSeq };
2871
+ return { comments, lastSeq, handedOff };
1954
2872
  }
1955
2873
  // The watch long-poll's answer: comments plus the batched feedback built
1956
2874
  // from them.
1957
- async function listenFlow(q, signal) {
1958
- const result = await waitForComments({ ...q, agent: true }, signal);
1959
- const feedback = await buildFeedbackBatches(store, result.comments);
2875
+ async function listenFlow(q, base, signal) {
2876
+ const { handedOff, ...result } = await waitForComments({ ...q, agent: true }, signal);
2877
+ const feedback = await feedbackWithHandoffs(result.comments, handedOff, base);
1960
2878
  return ok({ ...result, feedback });
1961
2879
  }
1962
2880
  // What the user is doing right now, per mock: derived from the SSE registry
@@ -1964,6 +2882,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1964
2882
  // tells the agent something ("2 of 3 answered", or nobody is looking).
1965
2883
  const pendingOf = (mock, posts) => ({
1966
2884
  mock: mock.slug,
2885
+ // Closed by the user: nothing there is waiting on them, or on you.
2886
+ ...(mock.resolvedAt ? { resolved: true } : {}),
1967
2887
  viewerOpen: viewing.has(mock.id),
1968
2888
  draft: mock.draft
1969
2889
  ? {
@@ -1976,14 +2896,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1976
2896
  });
1977
2897
  // The feedback verb: never blocks. Takes what the session has not heard yet
1978
2898
  // (the same cursor as piggyback and watch) plus `pending` for the project.
1979
- async function feedbackFlow(query) {
2899
+ async function feedbackFlow(query, ctx) {
1980
2900
  if (typeof query.session !== "string" || !query.session) {
1981
2901
  return fail(400, 'provide "session": the session id a write returned');
1982
2902
  }
1983
2903
  const session = await store.getSession(query.session);
1984
2904
  if (!session)
1985
2905
  return fail(404, `session "${query.session}" not found`);
1986
- const { comments } = await waitForComments({
2906
+ const { comments, handedOff } = await waitForComments({
1987
2907
  sessionId: session.id,
1988
2908
  author: "user",
1989
2909
  waitSeconds: 0,
@@ -1991,13 +2911,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1991
2911
  });
1992
2912
  const project = str(query.project, MAX_TITLE) ?? session.project ?? undefined;
1993
2913
  return ok({
1994
- feedback: await buildFeedbackBatches(store, comments),
2914
+ feedback: await feedbackWithHandoffs(comments, handedOff, ctx.base),
1995
2915
  pending: await Promise.all((await store.listMocks(project)).map(async (m) => pendingOf(m, await store.listPosts({ mockId: m.id })))),
1996
2916
  });
1997
2917
  }
1998
2918
  // Store an uploaded blob. An explicit session is validated and a missing one
1999
- // is auto-created so an upload can precede the first publish. The asset's
2000
- // data is dropped from the result (it's bytes).
2919
+ // is auto-created so an upload can precede the first publish. An image the
2920
+ // user attaches names its mock instead, and belongs to the session that mock
2921
+ // replies to. The asset's data is dropped from the result (it's bytes).
2001
2922
  async function uploadAsset(input) {
2002
2923
  if (input.data.byteLength === 0)
2003
2924
  return { error: "empty upload", status: 400 };
@@ -2005,6 +2926,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2005
2926
  return { error: `asset exceeds ${MAX_ASSET_BYTES} bytes`, status: 413 };
2006
2927
  }
2007
2928
  let sessionId = input.session;
2929
+ if (input.mock !== undefined) {
2930
+ if (!ATTACHABLE_IMAGE_TYPES.has(input.contentType)) {
2931
+ return { error: "attach a png, jpeg, webp or gif image", status: 415 };
2932
+ }
2933
+ const mock = await store.getMock(input.mock);
2934
+ if (!mock)
2935
+ return { error: `mock "${input.mock}" not found`, status: 404 };
2936
+ const posts = await store.listPosts({ mockId: mock.id });
2937
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2938
+ sessionId = mock.sessionId ?? latest?.sessionId;
2939
+ if (!sessionId)
2940
+ return { error: `${mock.slug} has no agent session`, status: 409 };
2941
+ }
2008
2942
  if (sessionId && !(await store.getSession(sessionId))) {
2009
2943
  return { error: `session "${sessionId}" not found`, status: 404 };
2010
2944
  }
@@ -2062,6 +2996,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2062
2996
  return next();
2063
2997
  if (path.startsWith(STATIC_ASSET_PREFIX))
2064
2998
  return next();
2999
+ // The installable-app files carry no workspace data, and a browser may
3000
+ // fetch them without the cookie; the manifest adds the key only for a
3001
+ // caller that already holds it.
3002
+ if (APP_FILES.has(path))
3003
+ return next();
2065
3004
  const key = c.req.query("key");
2066
3005
  if (key === authToken) {
2067
3006
  setCookie(c, "mockpit_key", authToken, {
@@ -2103,7 +3042,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2103
3042
  maxSize: MAX_ASSET_BYTES,
2104
3043
  onError: (c) => c.json({ error: `asset exceeds ${MAX_ASSET_BYTES} bytes` }, 413),
2105
3044
  });
2106
- 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));
2107
3046
  const injectHead = (text, head) => {
2108
3047
  const headClose = text.lastIndexOf("</head>");
2109
3048
  return headClose >= 0
@@ -2132,15 +3071,31 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2132
3071
  ? `window.__MOCKPIT_PUBLIC_READ__=${JSON.stringify(publicRead)};`
2133
3072
  : "",
2134
3073
  screenshots ? "window.__MOCKPIT_SCREENSHOTS__=true;" : "",
3074
+ renderRev ? `window.__MOCKPIT_RENDER_REV__=${JSON.stringify(renderRev)};` : "",
2135
3075
  ].join("");
2136
3076
  return injectHead(text, `<script>${config}</script>`);
2137
3077
  };
3078
+ // The tags that make the viewer installable to a home screen. `use-credentials`
3079
+ // sends the cookie with the manifest fetch, which browsers otherwise omit.
3080
+ const withAppHead = (request, mode) => (text) => {
3081
+ const base = escapeHtml(requestBasePath(request));
3082
+ return injectHead(text, [
3083
+ `<link rel="manifest" href="${base}/manifest.webmanifest" crossorigin="use-credentials">`,
3084
+ `<link rel="icon" href="${base}/icon.svg" type="image/svg+xml">`,
3085
+ `<link rel="apple-touch-icon" href="${base}/apple-touch-icon.png">`,
3086
+ `<meta name="theme-color" content="${themeColor(mode)}">`,
3087
+ `<meta name="mobile-web-app-capable" content="yes">`,
3088
+ `<meta name="apple-mobile-web-app-capable" content="yes">`,
3089
+ `<meta name="apple-mobile-web-app-title" content="mockpit">`,
3090
+ `<meta name="apple-mobile-web-app-status-bar-style" content="default">`,
3091
+ ].join("\n"));
3092
+ };
2138
3093
  // --- viewer pages ---
2139
3094
  // Link previews for a mock page: the first open variant's first surface,
2140
3095
  // with every pixel-affecting input pinned in the image URL so the Worker can
2141
3096
  // cache it at the edge.
2142
3097
  const mockPreviewHead = (mock, post, request, mode, rendererGeneration) => {
2143
- const origin = new URL(request.url).origin;
3098
+ const origin = requestOrigin(request);
2144
3099
  const publicBasePath = requestBasePath(request);
2145
3100
  const canonical = `${origin}${publicBasePath}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
2146
3101
  const imageUrl = new URL(`${origin}${publicBasePath}/s/${post.id}.png`);
@@ -2175,13 +3130,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2175
3130
  // sandboxed surface documents at /s/:id are *meant* to be framed and carry
2176
3131
  // their own `sandbox` CSP header instead, so they never pass through here.
2177
3132
  c.header("Content-Security-Policy", "frame-ancestors 'self'");
2178
- const html = 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));
2179
3134
  if (!opts.preview)
2180
3135
  return html;
2181
3136
  const { mock, post } = opts.preview;
2182
3137
  return injectHead(html, mockPreviewHead(mock, post, c.req.raw, await workspaceMode(), version ?? "dev"));
2183
3138
  };
2184
3139
  app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
3140
+ // --- the installable app: manifest and icons ---
3141
+ app.get("/manifest.webmanifest", async (c) => {
3142
+ // A tokened workspace bakes the key into start_url for a caller that holds
3143
+ // it (the viewer links the manifest with credentials), so a home-screen
3144
+ // launch, which may not share the browser's cookies, signs itself in.
3145
+ const keyed = !!authToken && isAuthenticated(c);
3146
+ const manifest = webManifest(await workspaceMode(), keyed ? `./?key=${encodeURIComponent(authToken)}` : "./");
3147
+ c.header("Content-Type", "application/manifest+json; charset=utf-8");
3148
+ c.header("X-Content-Type-Options", "nosniff");
3149
+ c.header("Cache-Control", keyed ? "private, no-store" : "no-cache");
3150
+ return c.body(JSON.stringify(manifest));
3151
+ });
3152
+ app.get("/icon.svg", (c) => {
3153
+ c.header("Content-Type", "image/svg+xml");
3154
+ // Our own art, but SVG is a document: opened top-level it runs at this origin.
3155
+ c.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
3156
+ c.header("X-Content-Type-Options", "nosniff");
3157
+ c.header("Cache-Control", "public, max-age=86400");
3158
+ return c.body(ICON_SVG);
3159
+ });
3160
+ const iconPng = (size) => (c) => {
3161
+ c.header("Content-Type", "image/png");
3162
+ c.header("X-Content-Type-Options", "nosniff");
3163
+ c.header("Cache-Control", "public, max-age=86400");
3164
+ return c.body(decodeBase64(ICON_PNG_BASE64[size]).buffer);
3165
+ };
3166
+ for (const size of ICON_SIZES)
3167
+ app.get(`/icon-${size}.png`, iconPng(size));
3168
+ app.get("/apple-touch-icon.png", iconPng(180));
2185
3169
  // The engine reads the project/mock out of the URL itself; these render the
2186
3170
  // same shell as "/".
2187
3171
  app.get("/project/:name", async (c) => c.html(await configuredViewerHtml(c, { title: c.req.param("name") })));
@@ -2190,7 +3174,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2190
3174
  if (!mock)
2191
3175
  return c.html(await configuredViewerHtml(c, { title: c.req.param("name") }));
2192
3176
  const posts = await store.listPosts({ mockId: mock.id });
2193
- const post = posts.find((p) => p.status !== "archived") ?? posts[0];
3177
+ const post = posts.find((p) => isLive(p.status)) ?? posts[0];
2194
3178
  return c.html(await configuredViewerHtml(c, {
2195
3179
  title: mock.title,
2196
3180
  ...(post ? { preview: { mock, post } } : {}),
@@ -2198,10 +3182,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2198
3182
  });
2199
3183
  // --- REST plumbing for the shared flows ---
2200
3184
  const flowCtx = (c, strict = true) => ({
2201
- base: `${new URL(c.req.url).origin}${requestBasePath(c.req.raw)}`,
2202
- // The browser sets Fetch Metadata on same-origin requests; only the trusted
2203
- // viewer may act as the user (drafts, replies, user-authored comments).
2204
- 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),
2205
3189
  strict,
2206
3190
  request: c.req.raw,
2207
3191
  signal: c.req.raw.signal,
@@ -2229,8 +3213,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2229
3213
  const cwd = str(body.cwd, 4096);
2230
3214
  const key = str(body.key, MAX_TITLE);
2231
3215
  const existing = key ? await store.getSessionByKey(key) : null;
2232
- if (existing)
2233
- return c.json(existing, 200);
3216
+ if (existing) {
3217
+ // A resumed conversation is that agent back, not a new one superseding it.
3218
+ await store.touchAgent(existing.id);
3219
+ return c.json((await store.getSession(existing.id)) ?? existing, 200);
3220
+ }
2234
3221
  const session = await store.createSession({
2235
3222
  agent: str(body.agent, MAX_TITLE) ?? "agent",
2236
3223
  title: str(body.title, MAX_TITLE),
@@ -2299,10 +3286,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2299
3286
  app.put("/api/mocks/:id/draft", async (c) => send(c, await putDraftFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2300
3287
  app.delete("/api/mocks/:id/draft", async (c) => send(c, await deleteDraftFlow(c.req.param("id"), flowCtx(c))));
2301
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))));
2302
3294
  app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
2303
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))));
2304
- app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c))));
2305
- app.get("/api/feedback", async (c) => send(c, await feedbackFlow({ session: c.req.query("session"), project: c.req.query("project") })));
3296
+ app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
3297
+ app.get("/api/feedback", async (c) => send(c, await feedbackFlow({ session: c.req.query("session"), project: c.req.query("project") }, flowCtx(c))));
2306
3298
  // --- comments ---
2307
3299
  app.post("/api/comments", async (c) => send(c, await commentFlow(await jsonBody(c), flowCtx(c))));
2308
3300
  app.delete("/api/comments/:id", async (c) => {
@@ -2367,8 +3359,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2367
3359
  query.agent = isAgentRead;
2368
3360
  const respond = async (signal) => {
2369
3361
  if (isAgentRead)
2370
- return send(c, await listenFlow(query, signal));
2371
- const result = await waitForComments(query, signal);
3362
+ return send(c, await listenFlow(query, flowCtx(c).base, signal));
3363
+ const { handedOff: _none, ...result } = await waitForComments(query, signal);
2372
3364
  return c.json({ ...result, comments: await withDelivered(result.comments) });
2373
3365
  };
2374
3366
  if (waitSeconds <= 0)
@@ -2428,6 +3420,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2428
3420
  });
2429
3421
  // The project's design system, imported by `mockpit init` and injected into
2430
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") })));
2431
3424
  app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
2432
3425
  app.put("/api/projects/:name/design", async (c) => {
2433
3426
  const body = await c.req.json().catch(() => null);
@@ -2706,7 +3699,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2706
3699
  // agent publishing over one origin must never be served to a viewer on another.
2707
3700
  // `knobs` is the canonical (validated, path-sorted) JSON of `?k=`, so two
2708
3701
  // spellings of the same values share an entry.
2709
- 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 ?? ""}`;
2710
3703
  // The document itself. Shared by the GET below and the publish-time pre-warm,
2711
3704
  // so both produce byte-identical output for one key.
2712
3705
  async function buildSurfaceDoc(args) {
@@ -2714,12 +3707,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2714
3707
  const theme = themeById(DEFAULT_THEME_ID);
2715
3708
  const themeId = theme.id;
2716
3709
  if (surface.kind === "html") {
2717
- // A page mock composes published components by reference; the tags
2718
- // are expanded here, server-side, so the whole page is still ONE
2719
- // sandboxed document rather than nested frames.
2720
- const html = args.mock?.kind === "page"
2721
- ? await expandPageHtml(args.mock.project, args.post, surface.html)
2722
- : 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;
2723
3714
  return renderHtmlPage({
2724
3715
  title: args.title,
2725
3716
  html,
@@ -2782,8 +3773,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2782
3773
  if (!isSandboxedSurfaceKind(surface.kind))
2783
3774
  continue;
2784
3775
  warmed++;
3776
+ const composed = surface.kind === "html" && hasEmbedTags(surface.html)
3777
+ ? await composedHtml(mock.project, mock.slug, surface.html)
3778
+ : null;
2785
3779
  for (const mode of ["light", "dark"]) {
2786
- 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
+ });
2787
3788
  if (renderCache.has(key))
2788
3789
  continue;
2789
3790
  await cachedRender(key, () => buildSurfaceDoc({
@@ -2795,6 +3796,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2795
3796
  mode,
2796
3797
  origin,
2797
3798
  design,
3799
+ composed: composed?.html,
2798
3800
  }));
2799
3801
  }
2800
3802
  }
@@ -2808,7 +3810,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2808
3810
  const modeParam = c.req.query("mode");
2809
3811
  const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
2810
3812
  const kParam = c.req.query("k");
2811
- 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);
2812
3818
  // Cache-first. A hit needs nothing from the post row, and the row is the
2813
3819
  // expensive part of this route (surfaces + every retained version). Only a
2814
3820
  // version-pinned request can take this path: without `ver` the current
@@ -2816,13 +3822,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2816
3822
  // request never does: its values are checked against the mock's knobs,
2817
3823
  // which can change under a pinned version.
2818
3824
  const pinned = Number(ver);
2819
- 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) {
2820
3828
  const hit = renderCacheHit(renderKey({
2821
3829
  postId: c.req.param("id"),
2822
3830
  idx: Number(surfaceParam),
2823
3831
  version: pinned,
2824
3832
  mode,
2825
3833
  origin,
3834
+ embeds: eParam,
2826
3835
  }));
2827
3836
  if (hit !== undefined) {
2828
3837
  surfaceDocHeaders(c, true);
@@ -2878,16 +3887,59 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2878
3887
  knobsKey = JSON.stringify(knobs);
2879
3888
  }
2880
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;
2881
3914
  // Every input is in the URL (the palette is fixed, the mode is a query
2882
- // param), so a version-pinned document never changes.
2883
- 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));
2884
3918
  // Cache the finished document under the same key the pre-warm uses; the
2885
- // resolved `version` makes it immutable, so a hit is always correct.
2886
- // A page's slots are pinned at publish, so they are a function of
2887
- // (id, version) too and need no separate key component.
2888
- 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
+ });
2889
3930
  const design = mock ? await designFor(mock.project) : null;
2890
- 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
+ }));
2891
3943
  return c.html(doc);
2892
3944
  };
2893
3945
  app.get("/s/:id", renderPostPage);
@@ -2935,15 +3987,20 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2935
3987
  kind: isAssetKind(kindQ) ? kindQ : undefined,
2936
3988
  session: c.req.query("session"),
2937
3989
  agent: c.req.query("agent"),
3990
+ mock: c.req.query("mock"),
2938
3991
  };
2939
3992
  }
2940
3993
  catch (err) {
2941
3994
  return c.json({ error: err instanceof Error ? err.message : "invalid upload" }, 400);
2942
3995
  }
3996
+ // An attachment on a mock is the user's, so only the viewer may make one.
3997
+ if (body.mock !== undefined && !flowCtx(c).viewer) {
3998
+ return c.json({ error: "only the viewer can attach images to a mock" }, 403);
3999
+ }
2943
4000
  const result = await uploadAsset(body);
2944
4001
  if ("error" in result)
2945
4002
  return c.json({ error: result.error }, result.status);
2946
- const origin = new URL(c.req.url).origin;
4003
+ const origin = requestOrigin(c.req.raw);
2947
4004
  const publicBasePath = requestBasePath(c.req.raw);
2948
4005
  const assetPath = encodeURIComponent(result.asset.id);
2949
4006
  return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201);
@@ -3097,11 +4154,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3097
4154
  const mcpFlows = {
3098
4155
  publish: (body, ctx) => publishFlow(body, ctx),
3099
4156
  list: (query) => listMocksFlow(query),
4157
+ tree: (query) => treeFlow(query),
3100
4158
  get: (ref, query) => getMockFlow(ref, query),
3101
4159
  ask: (ref, body, ctx) => askFlow(ref, body, ctx),
3102
4160
  exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
3103
- say: (ref, body) => sayFlow(ref, body),
3104
- feedback: (query) => feedbackFlow(query),
4161
+ say: (ref, body, ctx) => sayFlow(ref, body, ctx),
4162
+ feedback: (query, ctx) => feedbackFlow(query, ctx),
3105
4163
  };
3106
4164
  const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
3107
4165
  // --- run: an agent script over the same flows (server/run.ts) ---