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