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.
- package/README.md +13 -6
- package/bin/mockpit.js +139 -18
- package/dist/mcp/server.js +30 -10
- package/dist/server/apiViews.d.ts +129 -25
- package/dist/server/apiViews.js +86 -23
- package/dist/server/app.d.ts +2 -1
- package/dist/server/app.js +1258 -200
- package/dist/server/appIcon.d.ts +22 -0
- package/dist/server/appIcon.js +43 -0
- package/dist/server/appIconPng.d.ts +1 -0
- package/dist/server/appIconPng.js +6 -0
- package/dist/server/askNudges.d.ts +3 -0
- package/dist/server/askNudges.js +40 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +8 -0
- package/dist/server/decisions.d.ts +23 -0
- package/dist/server/decisions.js +99 -0
- package/dist/server/designGuide.js +53 -16
- 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 +28 -5
- package/dist/server/feedbackBatch.js +78 -11
- package/dist/server/fills.d.ts +41 -0
- package/dist/server/fills.js +328 -0
- package/dist/server/handoff.d.ts +6 -0
- package/dist/server/handoff.js +38 -0
- package/dist/server/index.js +15 -1
- package/dist/server/knobs.d.ts +6 -2
- package/dist/server/knobs.js +13 -4
- package/dist/server/mcpHttp.d.ts +5 -2
- package/dist/server/mcpHttp.js +14 -5
- package/dist/server/mcpSpec.d.ts +21 -13
- package/dist/server/mcpSpec.js +99 -9
- package/dist/server/postSurfaces.js +3 -12
- package/dist/server/richRender.js +3 -1
- package/dist/server/run.js +6 -2
- package/dist/server/runApi.d.ts +1 -1
- package/dist/server/runApi.js +5 -4
- package/dist/server/sqlStore.d.ts +92 -5
- package/dist/server/sqlStore.js +188 -30
- 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 +95 -10
- package/dist/server/types.js +89 -6
- package/dist/server/viewerRequest.d.ts +2 -0
- package/dist/server/viewerRequest.js +14 -0
- package/extensions/mockpit.js +1 -0
- package/guide/topics/asks.md +69 -10
- package/guide/topics/feedback.md +104 -16
- package/guide/topics/html.md +5 -1
- package/guide/topics/http.md +2 -1
- package/guide/topics/knobs.md +4 -3
- package/guide/topics/surfaces.md +32 -16
- package/package.json +4 -13
- package/skills/launch-video/SKILL.md +1 -1
- package/skills/mockpit/SKILL.md +14 -4
- package/viewer/dist/index.html +9 -9
- package/dist/server/slots.d.ts +0 -14
- package/dist/server/slots.js +0 -71
package/dist/server/app.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
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
|
|
433
|
+
const sessions = new Map();
|
|
353
434
|
for (const c of comments) {
|
|
354
|
-
if (!
|
|
355
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
615
|
-
//
|
|
616
|
-
async function
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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}"
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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 "
|
|
1018
|
+
return fail(400, `pass one of "html", "surfaces", "parts" or "fills", not ${given.join(" and ")}`);
|
|
783
1019
|
}
|
|
784
|
-
const partEdits =
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
|
916
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
955
|
-
|
|
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
|
|
958
|
-
|
|
959
|
-
|
|
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(
|
|
1375
|
+
const entries = Object.entries(edits);
|
|
962
1376
|
if (entries.length === 0)
|
|
963
|
-
return fail(400,
|
|
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,
|
|
1379
|
+
return fail(400, `each "${key}" value must be the html string for that ${key.slice(0, -1)}`);
|
|
966
1380
|
}
|
|
967
|
-
return
|
|
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
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
1510
|
+
if (!found)
|
|
1053
1511
|
continue;
|
|
1054
|
-
if (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:
|
|
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
|
-
...[...
|
|
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:
|
|
1079
|
-
at:
|
|
1080
|
-
from:
|
|
1081
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
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
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
2000
|
-
//
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
|
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: `${
|
|
2202
|
-
//
|
|
2203
|
-
//
|
|
2204
|
-
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),
|
|
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
|
-
|
|
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
|
-
//
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
2720
|
-
const html = args.
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
2886
|
-
//
|
|
2887
|
-
|
|
2888
|
-
|
|
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({
|
|
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 =
|
|
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) ---
|