mockpit 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -45
- package/bin/demoData.js +3 -3
- package/bin/mockpit.js +327 -52
- package/dist/mcp/server.js +35 -0
- package/dist/server/apiViews.d.ts +308 -23
- package/dist/server/apiViews.js +60 -19
- package/dist/server/app.d.ts +4 -1
- package/dist/server/app.js +608 -119
- package/dist/server/codeWorker.js +2 -1
- package/dist/server/decisions.d.ts +5 -5
- package/dist/server/decisions.js +10 -9
- package/dist/server/designGuide.d.ts +6 -2
- package/dist/server/designGuide.js +77 -43
- package/dist/server/embeds.d.ts +1 -0
- package/dist/server/embeds.js +30 -4
- package/dist/server/feedbackBatch.d.ts +6 -6
- package/dist/server/feedbackBatch.js +18 -11
- package/dist/server/guidance.d.ts +23 -0
- package/dist/server/guidance.js +71 -0
- package/dist/server/index.js +10 -1
- package/dist/server/mcpHttp.d.ts +3 -0
- package/dist/server/mcpHttp.js +32 -0
- package/dist/server/mcpSpec.d.ts +2 -1
- package/dist/server/mcpSpec.js +74 -24
- package/dist/server/postSurfaces.js +3 -0
- package/dist/server/run.js +6 -1
- package/dist/server/runApi.d.ts +3 -3
- package/dist/server/runApi.js +16 -12
- package/dist/server/sqlStore.d.ts +10 -3
- package/dist/server/sqlStore.js +230 -16
- package/dist/server/types.d.ts +46 -17
- package/dist/server/types.js +34 -11
- package/extensions/mockpit.js +179 -16
- package/guide/packs/design-craft.md +35 -0
- package/guide/topics/asks.md +60 -56
- package/guide/topics/feedback.md +104 -74
- package/guide/topics/html.md +5 -11
- package/guide/topics/http.md +6 -3
- package/guide/topics/knobs.md +2 -2
- package/guide/topics/scripts.md +2 -2
- package/guide/topics/surfaces.md +28 -10
- package/package.json +1 -4
- package/skills/launch-video/SKILL.md +1 -1
- package/skills/mockpit/SKILL.md +18 -10
- package/viewer/dist/index.html +8 -7
package/dist/server/app.js
CHANGED
|
@@ -5,11 +5,12 @@ import { streamSSE } from "hono/streaming";
|
|
|
5
5
|
import { ICON_SIZES, ICON_SVG, themeColor, webManifest } from "./appIcon.js";
|
|
6
6
|
import { ICON_PNG_BASE64 } from "./appIconPng.js";
|
|
7
7
|
import { decodeBase64 } from "./base64.js";
|
|
8
|
-
import { mockDetailView, treeNodeView, mockSummaryView, sessionRowView, surfaceRef, } from "./apiViews.js";
|
|
8
|
+
import { mockDetailView, treeNodeView, mockSummaryView, placeOf, sessionRowView, surfaceRef, } from "./apiViews.js";
|
|
9
9
|
import { askNudges } from "./askNudges.js";
|
|
10
10
|
import { EventBus } from "./events.js";
|
|
11
11
|
import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
|
|
12
12
|
import { buildFeedbackBatches } from "./feedbackBatch.js";
|
|
13
|
+
import { guidanceSections, MAX_RULES_CHARS, parseRulesFile, RULES_SETTING, USER_RULES_SETTING, } from "./guidance.js";
|
|
13
14
|
import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
|
|
14
15
|
import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
|
|
15
16
|
import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
|
|
@@ -26,7 +27,7 @@ import { logMiddleware, writeLog } from "./eventLog.js";
|
|
|
26
27
|
import { RUN_API } from "./runApi.js";
|
|
27
28
|
import { escapeHtml, renderHtmlPage, renderMermaidPage, knobAttributes, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
|
|
28
29
|
import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
|
|
29
|
-
import { askAnswered, builtinAsk, builtinAskIds, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, inUseVersion, isLive, optionState, newId, openAsks, openCount, OTHER_ID, REACTIONS, projectFromCwd, postAtVersion, versionAt, reservedAgent, sameScreen, slugify, surfacesByteLength, } from "./types.js";
|
|
30
|
+
import { askAnswered, builtinAsk, builtinAskIds, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, inUseVersion, isLive, isMockKind, momentOf, NORMAL_SITUATION, optionState, newId, openAsks, openCount, OTHER_ID, REACTIONS, projectFromCwd, AREA_ORDER_SETTING, stripNul, postAtVersion, versionAt, variantStatus, reservedAgent, sameScreen, situationKey, slugify, surfacesByteLength, } from "./types.js";
|
|
30
31
|
import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
|
|
31
32
|
import { isViewerRequest } from "./viewerRequest.js";
|
|
32
33
|
const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
|
|
@@ -169,16 +170,33 @@ const fail = (status, error, extra = {}) => ({
|
|
|
169
170
|
body: { error, ...extra },
|
|
170
171
|
});
|
|
171
172
|
const MAX_NOTE = 400;
|
|
173
|
+
const MAX_SOURCE = 1024;
|
|
172
174
|
const MAX_WIDTHS = 4;
|
|
173
175
|
const MIN_WIDTH = 280;
|
|
174
176
|
const MAX_WIDTH = 2560;
|
|
175
177
|
const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
|
|
176
178
|
const MAX_LABEL = 120;
|
|
179
|
+
const MAX_AREAS = 50;
|
|
177
180
|
const MAX_FLOWS = 50;
|
|
178
181
|
const MAX_FLOW_STEPS = 100;
|
|
179
182
|
const MAX_ASKS = 20;
|
|
180
183
|
const MAX_OPTIONS = 20;
|
|
181
184
|
const MAX_DRAFT_COMMENTS = 50;
|
|
185
|
+
// A title that names a step of the agent's process instead of what the mock
|
|
186
|
+
// is: it reads as noise on Home, where titles are seen out of context.
|
|
187
|
+
const PROCESS_TITLE = /^\s*(round\s*\d+|flow\s*\d+\s*[·:\-–—]|v\d+\b|prototype\b)/i;
|
|
188
|
+
// Once, on a variant's first version: a canvas page runs live in every
|
|
189
|
+
// overview card, and a browser holds only about 16 WebGL contexts.
|
|
190
|
+
const posterNudges = (surfaces) => surfaces.some((s) => s.kind === "html" && !s.poster && /<canvas[\s>]/i.test(s.html))
|
|
191
|
+
? [
|
|
192
|
+
"this page draws on a <canvas> and has no poster, so Studio and Home run it live; send a still of it as poster (upload the image, or CLI --poster shot.png) with each version",
|
|
193
|
+
]
|
|
194
|
+
: [];
|
|
195
|
+
const titleNudges = (title) => title && PROCESS_TITLE.test(title)
|
|
196
|
+
? [
|
|
197
|
+
`title "${title}" names your process; title the mock by what it is ("Hold menu"), as states are named by what the user sees`,
|
|
198
|
+
]
|
|
199
|
+
: [];
|
|
182
200
|
const APP_FILES = new Set([
|
|
183
201
|
"/manifest.webmanifest",
|
|
184
202
|
"/icon.svg",
|
|
@@ -186,7 +204,7 @@ const APP_FILES = new Set([
|
|
|
186
204
|
...ICON_SIZES.map((s) => `/icon-${s}.png`),
|
|
187
205
|
]);
|
|
188
206
|
const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
|
|
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, }) {
|
|
207
|
+
export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {}, userRules, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, renderRev, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
|
|
190
208
|
const app = new Hono();
|
|
191
209
|
// The scripts topic carries the run API from runApi.ts, its one source.
|
|
192
210
|
if (topics.scripts)
|
|
@@ -534,13 +552,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
534
552
|
async function briefFor(project) {
|
|
535
553
|
try {
|
|
536
554
|
const name = project ?? (await store.listProjects())[0]?.name ?? DEFAULT_PROJECT;
|
|
537
|
-
return renderBriefGuide(await designFor(name));
|
|
555
|
+
return renderBriefGuide(await designFor(name), await guidanceFor(name));
|
|
538
556
|
}
|
|
539
557
|
catch (err) {
|
|
540
558
|
console.warn("[mockpit] project design unavailable for the brief", err);
|
|
541
559
|
return renderBriefGuide(null);
|
|
542
560
|
}
|
|
543
561
|
}
|
|
562
|
+
// The user's file wins over the stored copy when the host can read it: the
|
|
563
|
+
// copy exists for hosts that can't.
|
|
564
|
+
async function userRulesText() {
|
|
565
|
+
const file = userRules ? await userRules().catch(() => null) : null;
|
|
566
|
+
return file ?? (await store.getSetting(USER_RULES_SETTING));
|
|
567
|
+
}
|
|
568
|
+
async function guidanceFor(project) {
|
|
569
|
+
return guidanceSections({
|
|
570
|
+
packs,
|
|
571
|
+
user: await userRulesText(),
|
|
572
|
+
project: await store.getSetting(`${RULES_SETTING}:${project}`),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
// A rules file as sent by `mockpit rules`: stored whole, "" clears it. The
|
|
576
|
+
// answer says what the brief will make of it, so a mistyped pack name shows
|
|
577
|
+
// at once rather than as a quietly missing layer.
|
|
578
|
+
async function saveRules(key, body) {
|
|
579
|
+
if (!body || typeof body !== "object" || typeof body.text !== "string") {
|
|
580
|
+
return fail(400, 'provide "text", the rules file ("" clears it)');
|
|
581
|
+
}
|
|
582
|
+
const text = stripNul(body.text).trim();
|
|
583
|
+
if (text.length > MAX_RULES_CHARS) {
|
|
584
|
+
return fail(400, `a rules file is at most ${MAX_RULES_CHARS} characters`);
|
|
585
|
+
}
|
|
586
|
+
await store.setSetting(key, text);
|
|
587
|
+
const parsed = parseRulesFile(text);
|
|
588
|
+
const unknown = parsed.packs.filter((p) => !(p in packs));
|
|
589
|
+
return ok({ packs: parsed.packs, unknown, rules: parsed.body.length > 0, cleared: !text });
|
|
590
|
+
}
|
|
544
591
|
// Push + webhooks. Detached on purpose: a dead push endpoint must never fail
|
|
545
592
|
// (or slow) the write that triggered it.
|
|
546
593
|
function fireNotify(payload) {
|
|
@@ -698,6 +745,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
698
745
|
code: failure.code,
|
|
699
746
|
issues: failure.issues,
|
|
700
747
|
});
|
|
748
|
+
// A poster is a still of the html beside it. Carried over unchanged onto
|
|
749
|
+
// changed html (a parts or fills splice, a fork with edits) it would show an
|
|
750
|
+
// old page, so it goes unless the write brings one; `poster` names the still
|
|
751
|
+
// of the page this write produces.
|
|
752
|
+
async function withPoster(surfaces, before, poster) {
|
|
753
|
+
const given = poster === undefined ? undefined : str(poster, MAX_LABEL);
|
|
754
|
+
if (poster !== undefined && !given) {
|
|
755
|
+
return fail(400, '"poster" must be the asset id of an uploaded image');
|
|
756
|
+
}
|
|
757
|
+
const pagesBefore = (before ?? []).filter((s) => s.kind === "html");
|
|
758
|
+
let ordinal = 0;
|
|
759
|
+
const out = surfaces.map((s) => {
|
|
760
|
+
if (s.kind !== "html")
|
|
761
|
+
return s;
|
|
762
|
+
const old = (s.id !== undefined && pagesBefore.find((b) => b.id === s.id)) || pagesBefore[ordinal];
|
|
763
|
+
let next = s;
|
|
764
|
+
if (s.poster && old?.kind === "html" && old.html !== s.html && old.poster === s.poster) {
|
|
765
|
+
const { poster: _stale, ...rest } = s;
|
|
766
|
+
next = rest;
|
|
767
|
+
}
|
|
768
|
+
if (ordinal === 0 && given)
|
|
769
|
+
next = { ...next, poster: given };
|
|
770
|
+
ordinal++;
|
|
771
|
+
return next;
|
|
772
|
+
});
|
|
773
|
+
if (given && ordinal === 0)
|
|
774
|
+
return fail(400, '"poster" needs an html surface to be the still of');
|
|
775
|
+
for (const s of out) {
|
|
776
|
+
if (s.kind !== "html" || !s.poster)
|
|
777
|
+
continue;
|
|
778
|
+
const asset = await store.getAsset(s.poster);
|
|
779
|
+
if (!asset)
|
|
780
|
+
return fail(404, `poster "${s.poster}" is not an uploaded asset; upload the image first`);
|
|
781
|
+
if (!asset.contentType.startsWith("image/")) {
|
|
782
|
+
return fail(400, `poster "${s.poster}" is ${asset.contentType}, not an image`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return out;
|
|
786
|
+
}
|
|
701
787
|
function checkSurfaces(surfaces) {
|
|
702
788
|
if (surfaces.length === 0)
|
|
703
789
|
return fail(400, "a variant needs at least one surface");
|
|
@@ -772,6 +858,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
772
858
|
}
|
|
773
859
|
return used;
|
|
774
860
|
}
|
|
861
|
+
// Being embedded is what makes a mock a component, so a sketch another mock
|
|
862
|
+
// embeds becomes one; a declared page stays a page.
|
|
863
|
+
async function promoteEmbedded(project, embeds) {
|
|
864
|
+
for (const slug of new Set(embeds.map((e) => e.slug))) {
|
|
865
|
+
const m = await store.findMock(project, slug);
|
|
866
|
+
if (m?.kind !== "sketch")
|
|
867
|
+
continue;
|
|
868
|
+
const updated = await store.updateMock(m.id, { kind: "component" });
|
|
869
|
+
if (updated)
|
|
870
|
+
bus.broadcast({ type: "mock-updated", id: updated.id, project });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
775
873
|
// What the variants of a mock show through their embeds now: per post, the
|
|
776
874
|
// resolved list (nested included) with each component's versions, and the
|
|
777
875
|
// key that changes when any of them does.
|
|
@@ -862,7 +960,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
862
960
|
const states = mock.states.length ? mock.states : [null];
|
|
863
961
|
const wide = states.every((s) => key(live(s)) === key(names));
|
|
864
962
|
// An ask that already picks between some of these variants is the question:
|
|
865
|
-
// a second, generic "Which
|
|
963
|
+
// a second, generic "Which variant?" would make the user choose twice.
|
|
866
964
|
// A mock-wide one only fits while every state has the same variants; else a
|
|
867
965
|
// variant only some states have would leave the others no real choice.
|
|
868
966
|
const existing = mock.asks.find((a) => applies(a) && a.options.some((o) => o.variant) && (a.scope === "state" || wide));
|
|
@@ -886,7 +984,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
886
984
|
],
|
|
887
985
|
};
|
|
888
986
|
return {
|
|
889
|
-
nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing
|
|
987
|
+
nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing question "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
|
|
890
988
|
ask,
|
|
891
989
|
};
|
|
892
990
|
}
|
|
@@ -894,15 +992,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
894
992
|
// Not the built-in's reserved id: a stored ask under it would collide with the
|
|
895
993
|
// built-in the viewer synthesizes once a new variant goes unbound. Nor any id
|
|
896
994
|
// already taken, which sending this would silently replace.
|
|
897
|
-
const base = wide ? "
|
|
995
|
+
const base = wide ? "choice" : `choice-${slugify(state ?? "")}`;
|
|
898
996
|
let id = base;
|
|
899
997
|
for (let n = 2; mock.asks.some((a) => a.id === id); n++)
|
|
900
998
|
id = `${base}-${n}`;
|
|
901
999
|
const ask = wide
|
|
902
|
-
? { id, text: "Which
|
|
903
|
-
: { id, text: "Which
|
|
1000
|
+
? { id, text: "Which variant?", scope: "mock", options }
|
|
1001
|
+
: { id, text: "Which variant?", scope: "state", state, options };
|
|
904
1002
|
return {
|
|
905
|
-
nudge: `${where} has ${names.length} variants and no
|
|
1003
|
+
nudge: `${where} has ${names.length} variants and no question binds them: call ask with suggestedAsk. Never ask in chat.`,
|
|
906
1004
|
ask,
|
|
907
1005
|
};
|
|
908
1006
|
}
|
|
@@ -913,7 +1011,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
913
1011
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
914
1012
|
const detail = mockDetailView(mock, posts);
|
|
915
1013
|
const unbound = unboundVariants(mock, posts, post.state);
|
|
916
|
-
// A
|
|
1014
|
+
// A part with fills that no ask sets is a decision nobody was asked; hand over the ask.
|
|
917
1015
|
const slots = slotsInSurfaces(post.surfaces);
|
|
918
1016
|
const setPaths = new Set(mock.asks.flatMap((a) => a.options.flatMap((o) => Object.keys(o.set ?? {}))));
|
|
919
1017
|
const slotAsks = slots
|
|
@@ -930,7 +1028,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
930
1028
|
...(unbound ? [unbound.nudge] : []),
|
|
931
1029
|
...(slotAsks.length
|
|
932
1030
|
? [
|
|
933
|
-
`${slotAsks.map((a) => `
|
|
1031
|
+
`${slotAsks.map((a) => `part "${a.part}"`).join(", ")} ${slotAsks.length === 1 ? "has" : "have"} fills and no question sets ${slotAsks.length === 1 ? "it" : "them"}: send suggestedFillAsks with ask (reword the text in the user's terms)`,
|
|
934
1032
|
]
|
|
935
1033
|
: []),
|
|
936
1034
|
];
|
|
@@ -947,6 +1045,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
947
1045
|
slug: mock.slug,
|
|
948
1046
|
title: mock.title,
|
|
949
1047
|
kind: mock.kind,
|
|
1048
|
+
...placeOf(mock),
|
|
950
1049
|
states: mock.states,
|
|
951
1050
|
},
|
|
952
1051
|
post: {
|
|
@@ -954,20 +1053,20 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
954
1053
|
state: post.state,
|
|
955
1054
|
variant: post.variant,
|
|
956
1055
|
version: post.version,
|
|
957
|
-
status: post.status,
|
|
1056
|
+
status: variantStatus(post.status),
|
|
958
1057
|
surfaces: post.surfaces.map(surfaceRef),
|
|
959
1058
|
},
|
|
960
1059
|
sessionId: post.sessionId,
|
|
961
1060
|
url: mockUrl(ctx.base, mock, post),
|
|
962
1061
|
parts: detail.parts,
|
|
963
|
-
...(slots.length ? { slots } : {}),
|
|
1062
|
+
...(slots.length ? { fills: slots } : {}),
|
|
964
1063
|
...(extra.applied ? { applied: extra.applied } : {}),
|
|
965
1064
|
...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
|
|
966
1065
|
? { partChanges }
|
|
967
1066
|
: {}),
|
|
968
1067
|
...(nudges.length ? { nudges } : {}),
|
|
969
1068
|
...(unbound ? { suggestedAsk: unbound.ask } : {}),
|
|
970
|
-
...(slotAsks.length ? {
|
|
1069
|
+
...(slotAsks.length ? { suggestedFillAsks: slotAsks } : {}),
|
|
971
1070
|
...(warnings.length ? { warnings } : {}),
|
|
972
1071
|
...(extra.flow ? { flow: extra.flow } : {}),
|
|
973
1072
|
feedback,
|
|
@@ -1031,7 +1130,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1031
1130
|
return fail(400, variantKnobs.error);
|
|
1032
1131
|
const stateIn = stateArg(body.state);
|
|
1033
1132
|
const variantIn = str(body.variant, MAX_LABEL);
|
|
1034
|
-
const kind = body.kind
|
|
1133
|
+
const kind = isMockKind(body.kind) ? body.kind : undefined;
|
|
1035
1134
|
const title = str(body.title, MAX_TITLE);
|
|
1036
1135
|
const fromN = Number(body.from);
|
|
1037
1136
|
const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
|
|
@@ -1041,6 +1140,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1041
1140
|
const widths = widthsArg(body.widths);
|
|
1042
1141
|
if (isResult(widths))
|
|
1043
1142
|
return widths;
|
|
1143
|
+
const area = labelArg(body, "area", MAX_LABEL);
|
|
1144
|
+
if (isResult(area))
|
|
1145
|
+
return area;
|
|
1146
|
+
const source = labelArg(body, "source", MAX_SOURCE);
|
|
1147
|
+
if (isResult(source))
|
|
1148
|
+
return source;
|
|
1044
1149
|
// An existing mock is addressable by id before a session exists.
|
|
1045
1150
|
let mock = null;
|
|
1046
1151
|
if (typeof body.mock === "string")
|
|
@@ -1072,6 +1177,32 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1072
1177
|
}
|
|
1073
1178
|
state = stateIn;
|
|
1074
1179
|
}
|
|
1180
|
+
const situationIn = str(body.situation, MAX_LABEL);
|
|
1181
|
+
// A situation is another render of one state ("Couldn't save"); "Normal"
|
|
1182
|
+
// names the state's own render.
|
|
1183
|
+
let situation;
|
|
1184
|
+
if (situationIn && situationIn.toLowerCase() !== NORMAL_SITUATION.toLowerCase()) {
|
|
1185
|
+
if (state === null) {
|
|
1186
|
+
return fail(400, '"situation" needs "state": the state it is another render of');
|
|
1187
|
+
}
|
|
1188
|
+
if (!mock || !mock.states.includes(state)) {
|
|
1189
|
+
return fail(404, `${slug} has no state "${state}" yet: publish its normal render first, then its situations`, mock ? { states: mock.states } : {});
|
|
1190
|
+
}
|
|
1191
|
+
const moment = momentOf(mock.situations, state);
|
|
1192
|
+
// The key feedback names it by, passed back with its own situation.
|
|
1193
|
+
if (moment !== state && moment !== null && situationKey(moment, situationIn) === state) {
|
|
1194
|
+
state = moment;
|
|
1195
|
+
}
|
|
1196
|
+
else if (moment !== state) {
|
|
1197
|
+
return fail(400, `"${state}" is a situation; give "situation" the state it belongs to`);
|
|
1198
|
+
}
|
|
1199
|
+
const key = situationKey(state, situationIn);
|
|
1200
|
+
if (mock.states.includes(key) && !(mock.situations[state] ?? []).includes(situationIn)) {
|
|
1201
|
+
return fail(409, `${slug} already has a state named "${key}"; name the situation otherwise`);
|
|
1202
|
+
}
|
|
1203
|
+
situation = { of: state, name: situationIn };
|
|
1204
|
+
state = key;
|
|
1205
|
+
}
|
|
1075
1206
|
const inState = posts.filter((p) => p.state === state);
|
|
1076
1207
|
let variant = variantIn;
|
|
1077
1208
|
if (!variant) {
|
|
@@ -1094,7 +1225,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1094
1225
|
const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
|
|
1095
1226
|
variants: posts.map(variantLabel),
|
|
1096
1227
|
});
|
|
1097
|
-
|
|
1228
|
+
// A flow steps through states; a situation's step is its state's.
|
|
1229
|
+
const screen = { mock: slug, state: situation ? situation.of : state };
|
|
1098
1230
|
if (flowIn) {
|
|
1099
1231
|
const full = await flowFull(project, flowIn.name, screen, flowIn.step);
|
|
1100
1232
|
if (full)
|
|
@@ -1171,13 +1303,23 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1171
1303
|
return parsed;
|
|
1172
1304
|
surfaces = parsed;
|
|
1173
1305
|
}
|
|
1306
|
+
const before = base();
|
|
1307
|
+
const postered = await withPoster(surfaces, isResult(before) ? null : before, body.poster);
|
|
1308
|
+
if (isResult(postered))
|
|
1309
|
+
return postered;
|
|
1310
|
+
surfaces = postered;
|
|
1174
1311
|
const bad = checkSurfaces(surfaces);
|
|
1175
1312
|
if (bad)
|
|
1176
1313
|
return bad;
|
|
1177
|
-
const nudges =
|
|
1314
|
+
const nudges = [
|
|
1315
|
+
...knobNudges({ ...knobs.value, ...variantKnobs.value }),
|
|
1316
|
+
...titleNudges(title),
|
|
1317
|
+
...(target ? [] : posterNudges(surfaces)),
|
|
1318
|
+
];
|
|
1178
1319
|
const embeds = await publishEmbeds(project, slug, surfaces);
|
|
1179
1320
|
if (isResult(embeds))
|
|
1180
1321
|
return embeds;
|
|
1322
|
+
await promoteEmbedded(project, embeds);
|
|
1181
1323
|
if (!target) {
|
|
1182
1324
|
// A new variant: carried by the session the agent is writing from.
|
|
1183
1325
|
if (!session) {
|
|
@@ -1194,7 +1336,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1194
1336
|
project,
|
|
1195
1337
|
slug,
|
|
1196
1338
|
title: title ?? titleFromSlug(slug),
|
|
1197
|
-
kind: kind ?? "
|
|
1339
|
+
kind: kind ?? "sketch",
|
|
1340
|
+
...(area === undefined ? {} : { area }),
|
|
1341
|
+
...(source === undefined ? {} : { source }),
|
|
1198
1342
|
states: state === null ? [] : [state],
|
|
1199
1343
|
knobs: knobs.value,
|
|
1200
1344
|
...(widths ? { widths } : {}),
|
|
@@ -1225,10 +1369,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1225
1369
|
return fail(404, "session not found");
|
|
1226
1370
|
mock = await updateMockAfterWrite(mock, {
|
|
1227
1371
|
state,
|
|
1372
|
+
situation,
|
|
1228
1373
|
kind,
|
|
1229
1374
|
title,
|
|
1230
1375
|
knobs: knobs.value,
|
|
1231
1376
|
widths,
|
|
1377
|
+
area,
|
|
1378
|
+
source,
|
|
1232
1379
|
session,
|
|
1233
1380
|
});
|
|
1234
1381
|
const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
|
|
@@ -1255,7 +1402,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1255
1402
|
if (!updated)
|
|
1256
1403
|
return fail(404, "variant not found");
|
|
1257
1404
|
const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
|
|
1258
|
-
mock = await updateMockAfterWrite(mock, {
|
|
1405
|
+
mock = await updateMockAfterWrite(mock, {
|
|
1406
|
+
state,
|
|
1407
|
+
situation,
|
|
1408
|
+
kind,
|
|
1409
|
+
knobs: knobs.value,
|
|
1410
|
+
widths,
|
|
1411
|
+
area,
|
|
1412
|
+
source,
|
|
1413
|
+
session,
|
|
1414
|
+
});
|
|
1259
1415
|
announcePost(mock, updated, false);
|
|
1260
1416
|
if (prompt && ctx.request)
|
|
1261
1417
|
notifyPublish(mock, updated, prompt, ctx);
|
|
@@ -1342,6 +1498,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1342
1498
|
return fail(400, bad);
|
|
1343
1499
|
return version === undefined ? { variant } : { variant, version };
|
|
1344
1500
|
}
|
|
1501
|
+
// An optional free-text field of the mock (`area`, `source`): absent keeps
|
|
1502
|
+
// it, "" or null clears it.
|
|
1503
|
+
function labelArg(body, key, max) {
|
|
1504
|
+
const v = body?.[key];
|
|
1505
|
+
if (v === undefined)
|
|
1506
|
+
return undefined;
|
|
1507
|
+
if (v === null)
|
|
1508
|
+
return null;
|
|
1509
|
+
if (typeof v !== "string")
|
|
1510
|
+
return fail(400, `"${key}" must be a string; "" clears it`);
|
|
1511
|
+
return str(v, max) ?? null;
|
|
1512
|
+
}
|
|
1345
1513
|
// `widths`: the device widths the mock is judged at, in the agent's order;
|
|
1346
1514
|
// [] hands the choice back to the viewer.
|
|
1347
1515
|
function widthsArg(v) {
|
|
@@ -1383,14 +1551,34 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1383
1551
|
// A write may add a state, retitle the mock, declare knobs, and makes its
|
|
1384
1552
|
// session the one a reply is delivered to.
|
|
1385
1553
|
async function updateMockAfterWrite(mock, w) {
|
|
1386
|
-
|
|
1554
|
+
let states;
|
|
1555
|
+
let situations;
|
|
1556
|
+
if (w.state !== null && !mock.states.includes(w.state)) {
|
|
1557
|
+
if (w.situation) {
|
|
1558
|
+
// After its state and that state's other situations, so the order of
|
|
1559
|
+
// states reads moment by moment.
|
|
1560
|
+
const { of, name } = w.situation;
|
|
1561
|
+
const own = mock.situations[of] ?? [];
|
|
1562
|
+
const last = [of, ...own.map((n) => situationKey(of, n))]
|
|
1563
|
+
.map((st) => mock.states.indexOf(st))
|
|
1564
|
+
.reduce((a, b) => Math.max(a, b), -1);
|
|
1565
|
+
states = [...mock.states.slice(0, last + 1), w.state, ...mock.states.slice(last + 1)];
|
|
1566
|
+
situations = { ...mock.situations, [of]: [...own, name] };
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
states = [...mock.states, w.state];
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1387
1572
|
const knobs = Object.keys(w.knobs).length ? { ...mock.knobs, ...w.knobs } : undefined;
|
|
1388
1573
|
const updated = await store.updateMock(mock.id, {
|
|
1389
1574
|
...(states ? { states } : {}),
|
|
1575
|
+
...(situations ? { situations } : {}),
|
|
1390
1576
|
...(w.kind ? { kind: w.kind } : {}),
|
|
1391
1577
|
...(w.title ? { title: w.title } : {}),
|
|
1392
1578
|
...(knobs ? { knobs } : {}),
|
|
1393
1579
|
...(w.widths ? { widths: w.widths } : {}),
|
|
1580
|
+
...(w.area === undefined ? {} : { area: w.area }),
|
|
1581
|
+
...(w.source === undefined ? {} : { source: w.source }),
|
|
1394
1582
|
...(w.session ? { sessionId: w.session.id } : {}),
|
|
1395
1583
|
});
|
|
1396
1584
|
if (!updated)
|
|
@@ -1441,35 +1629,41 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1441
1629
|
const pending = [];
|
|
1442
1630
|
for (const m of mocks) {
|
|
1443
1631
|
const posts = await store.listPosts({ mockId: m.id });
|
|
1444
|
-
const
|
|
1445
|
-
const
|
|
1632
|
+
const views = await embedViews(m, posts);
|
|
1633
|
+
const embeds = new Set();
|
|
1446
1634
|
for (const p of posts) {
|
|
1447
1635
|
if (!isLive(p.status))
|
|
1448
1636
|
continue;
|
|
1449
1637
|
for (const x of p.surfaces) {
|
|
1450
1638
|
if (x.kind === "html")
|
|
1451
1639
|
for (const t of parseEmbedTags(x.html))
|
|
1452
|
-
|
|
1640
|
+
embeds.add(t.slug);
|
|
1453
1641
|
}
|
|
1454
1642
|
}
|
|
1455
1643
|
nodes.push(treeNodeView(m, posts, {
|
|
1456
1644
|
settled: settledValues(await replyPayloads(m.id), m, posts),
|
|
1457
|
-
|
|
1458
|
-
|
|
1645
|
+
embeds: [...embeds],
|
|
1646
|
+
embedViews: views,
|
|
1459
1647
|
}));
|
|
1460
1648
|
pending.push(pendingOf(m, posts));
|
|
1461
1649
|
}
|
|
1462
1650
|
const bySlug = new Map(nodes.map((n) => [n.slug, n]));
|
|
1463
1651
|
for (const n of nodes) {
|
|
1464
|
-
n.
|
|
1465
|
-
for (const s of n.
|
|
1466
|
-
bySlug.get(s).
|
|
1652
|
+
n.embeds = n.embeds.filter((s) => bySlug.has(s));
|
|
1653
|
+
for (const s of n.embeds)
|
|
1654
|
+
bySlug.get(s).embeddedIn.push(n.slug);
|
|
1467
1655
|
}
|
|
1468
|
-
return ok({
|
|
1656
|
+
return ok({
|
|
1657
|
+
project,
|
|
1658
|
+
tree: nodes,
|
|
1659
|
+
flows: await flowsView(project, mocks),
|
|
1660
|
+
areaOrder: await areaOrderOf(project),
|
|
1661
|
+
pending,
|
|
1662
|
+
});
|
|
1469
1663
|
}
|
|
1470
1664
|
async function replyPayloads(mockId) {
|
|
1471
1665
|
return (await store.listComments({ mockId }))
|
|
1472
|
-
.filter((c) => c.kind === "reply" && c.payload)
|
|
1666
|
+
.filter((c) => c.kind === "reply" && c.payload && !c.payload.edited)
|
|
1473
1667
|
.map((c) => c.payload);
|
|
1474
1668
|
}
|
|
1475
1669
|
async function getMockFlow(ref, query) {
|
|
@@ -1513,9 +1707,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1513
1707
|
continue;
|
|
1514
1708
|
const post = postAtVersion(found, inUseVersion(found)) ?? found;
|
|
1515
1709
|
const html = post.surfaces.find((s) => s.kind === "html");
|
|
1516
|
-
// With
|
|
1710
|
+
// With fills, the state as decided: only the chosen fills, by every
|
|
1517
1711
|
// value sent so far (defaults before any reply). The authored markup with
|
|
1518
|
-
// every fill stays available as `
|
|
1712
|
+
// every fill stays available as `allFills`.
|
|
1519
1713
|
const slots = slotsInSurfaces(post.surfaces);
|
|
1520
1714
|
const raw = html && html.kind === "html" ? html.html : "";
|
|
1521
1715
|
const composed = slots.length ? composeFills(raw, chosenFills(slots, settled)) : raw;
|
|
@@ -1526,9 +1720,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1526
1720
|
version: post.version,
|
|
1527
1721
|
// The agent's newer versions wait until the user puts one in use.
|
|
1528
1722
|
...(found.version !== post.version ? { latest: found.version } : {}),
|
|
1529
|
-
status: post.status,
|
|
1723
|
+
status: variantStatus(post.status),
|
|
1530
1724
|
html: composed,
|
|
1531
|
-
...(slots.length ? {
|
|
1725
|
+
...(slots.length ? { allFills: raw } : {}),
|
|
1532
1726
|
markdown: postToMarkdown(post, {
|
|
1533
1727
|
postUrl: `${ctx.base}/s/${post.id}`,
|
|
1534
1728
|
assetBase: ctx.base,
|
|
@@ -1601,6 +1795,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1601
1795
|
const options = [];
|
|
1602
1796
|
// Seeded with the viewer's write-in, so a label "Other" slugs to "other-2".
|
|
1603
1797
|
const optionIds = new Set([OTHER_ID]);
|
|
1798
|
+
// The agent's own pick, shown as a quiet mark on its option: one per ask.
|
|
1799
|
+
const picks = raw.options.filter((o) => o && typeof o === "object" && o.recommended === true).length;
|
|
1800
|
+
if (picks > 1)
|
|
1801
|
+
return `ask "${text}" recommends ${picks} options; recommend one`;
|
|
1604
1802
|
for (const o of raw.options) {
|
|
1605
1803
|
const opt = typeof o === "string" ? { label: o } : o;
|
|
1606
1804
|
const label = str(opt?.label, 200);
|
|
@@ -1624,14 +1822,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1624
1822
|
return checked.error;
|
|
1625
1823
|
set = checked.value;
|
|
1626
1824
|
}
|
|
1825
|
+
const pick = opt.recommended === true ? { recommended: true } : {};
|
|
1627
1826
|
if (opt.version !== undefined) {
|
|
1628
1827
|
const pinned = versionOption(opt.version, label, variant, { scope, state }, mock, posts);
|
|
1629
1828
|
if (typeof pinned === "string")
|
|
1630
1829
|
return pinned;
|
|
1631
|
-
options.push({ id, label, ...pinned, ...(set ? { set } : {}) });
|
|
1830
|
+
options.push({ id, label, ...pinned, ...(set ? { set } : {}), ...pick });
|
|
1632
1831
|
continue;
|
|
1633
1832
|
}
|
|
1634
|
-
options.push({
|
|
1833
|
+
options.push({
|
|
1834
|
+
id,
|
|
1835
|
+
label,
|
|
1836
|
+
...(variant ? { variant } : {}),
|
|
1837
|
+
...(set ? { set } : {}),
|
|
1838
|
+
...pick,
|
|
1839
|
+
});
|
|
1635
1840
|
}
|
|
1636
1841
|
let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
|
|
1637
1842
|
if (!str(raw.id, 64))
|
|
@@ -1719,7 +1924,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1719
1924
|
return fail(400, ask);
|
|
1720
1925
|
const at = asks.findIndex((a) => a.id === ask.id);
|
|
1721
1926
|
// The user closed this one as not worth answering; asking it again is
|
|
1722
|
-
// exactly the noise they
|
|
1927
|
+
// exactly the noise they closed away. Only their Reopen revives it.
|
|
1723
1928
|
if (at >= 0 && asks[at].dismissed) {
|
|
1724
1929
|
skipped.push(ask.id);
|
|
1725
1930
|
continue;
|
|
@@ -1731,7 +1936,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1731
1936
|
taken.add(ask.id);
|
|
1732
1937
|
added.push(ask);
|
|
1733
1938
|
}
|
|
1734
|
-
const skippedNudges = skipped.map((id) => `ask "${id}": the user dismissed it when they
|
|
1939
|
+
const skippedNudges = skipped.map((id) => `ask "${id}": the user dismissed it when they closed ${mock.slug}; not asked again. Don't re-ask it.`);
|
|
1735
1940
|
if (!added.length) {
|
|
1736
1941
|
return ok({
|
|
1737
1942
|
mock: mock.slug,
|
|
@@ -1749,12 +1954,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1749
1954
|
if (typeof body.session === "string" && (await store.getSession(body.session))) {
|
|
1750
1955
|
sessionId = body.session;
|
|
1751
1956
|
}
|
|
1752
|
-
// A new question on a
|
|
1957
|
+
// A new question on a closed mock brings it back to the open list, or it
|
|
1753
1958
|
// would wait where the user no longer looks.
|
|
1754
1959
|
const updated = await store.updateMock(mock.id, {
|
|
1755
1960
|
asks,
|
|
1756
1961
|
sessionId,
|
|
1757
|
-
...(mock.
|
|
1962
|
+
...(mock.closedAt ? { closedAt: null } : {}),
|
|
1758
1963
|
});
|
|
1759
1964
|
if (!updated)
|
|
1760
1965
|
return fail(404, "mock not found");
|
|
@@ -1810,15 +2015,33 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1810
2015
|
if (reaction !== undefined && !REACTIONS.includes(reaction))
|
|
1811
2016
|
return `reaction must be one of ${REACTIONS.join(", ")}`;
|
|
1812
2017
|
const text = str(c?.text, MAX_COMMENT_TEXT);
|
|
1813
|
-
|
|
2018
|
+
const borrowIn = c?.borrow;
|
|
2019
|
+
if (!text && !reaction && !borrowIn)
|
|
1814
2020
|
return "every comment needs text";
|
|
1815
2021
|
const state = stateArg(c.state) ?? null;
|
|
1816
2022
|
if (state !== null && !mock.states.includes(state))
|
|
1817
2023
|
return `${mock.slug} has no state "${state}"`;
|
|
1818
2024
|
const part = str(c.part, 200) ?? null;
|
|
1819
2025
|
const variant = str(c.variant, 200);
|
|
1820
|
-
|
|
2026
|
+
const post = variant ? posts.find((p) => p.variant === variant && p.state === state) : null;
|
|
2027
|
+
if (variant && !post)
|
|
1821
2028
|
return `${mock.slug} has no variant "${variant}"${state ? ` in "${state}"` : ""}`;
|
|
2029
|
+
const version = c.version;
|
|
2030
|
+
if (version !== undefined &&
|
|
2031
|
+
(!post || !Number.isInteger(version) || version < 1 || version > post.version))
|
|
2032
|
+
return `${mock.slug} has no version ${String(version)}${variant ? ` of "${variant}"` : ""}`;
|
|
2033
|
+
let borrow;
|
|
2034
|
+
if (borrowIn !== undefined) {
|
|
2035
|
+
const from = str(borrowIn?.from, 240);
|
|
2036
|
+
const at = from ? /^(.+)@(\d+)$/.exec(from) : null;
|
|
2037
|
+
const source = at && posts.find((p) => p.variant === at[1] && p.state === state);
|
|
2038
|
+
if (!at || !source || Number(at[2]) < 1 || Number(at[2]) > source.version)
|
|
2039
|
+
return `borrow.from must be variant@version of "${mock.slug}"${state ? ` in "${state}"` : ""}`;
|
|
2040
|
+
if (!post || version === undefined)
|
|
2041
|
+
return "a borrow names the version it goes into";
|
|
2042
|
+
const borrowed = str(borrowIn?.part, 200);
|
|
2043
|
+
borrow = { from: at[0], ...(borrowed ? { part: borrowed } : {}) };
|
|
2044
|
+
}
|
|
1822
2045
|
const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
|
|
1823
2046
|
const anchor = a ? sanitizePartAnchor(a) : undefined;
|
|
1824
2047
|
const next = {
|
|
@@ -1828,13 +2051,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1828
2051
|
...(anchor ? { anchor } : {}),
|
|
1829
2052
|
...(reaction ? { reaction: reaction } : {}),
|
|
1830
2053
|
...(variant ? { variant } : {}),
|
|
2054
|
+
...(version !== undefined ? { version: version } : {}),
|
|
2055
|
+
...(borrow ? { borrow } : {}),
|
|
1831
2056
|
};
|
|
1832
2057
|
// One reaction per target: a later tap on the same part replaces the earlier one.
|
|
1833
2058
|
if (reaction) {
|
|
1834
2059
|
const same = out.findIndex((o) => o.reaction &&
|
|
1835
2060
|
o.part === part &&
|
|
1836
2061
|
o.state === state &&
|
|
1837
|
-
(o.variant ?? null) === (variant ?? null)
|
|
2062
|
+
(o.variant ?? null) === (variant ?? null) &&
|
|
2063
|
+
(o.version ?? null) === (version ?? null));
|
|
1838
2064
|
if (same >= 0)
|
|
1839
2065
|
out.splice(same, 1);
|
|
1840
2066
|
}
|
|
@@ -1941,18 +2167,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1941
2167
|
}
|
|
1942
2168
|
return out;
|
|
1943
2169
|
}
|
|
1944
|
-
function
|
|
2170
|
+
function sanitizeBorrowed(raw, posts) {
|
|
1945
2171
|
if (raw === undefined || raw === null)
|
|
1946
2172
|
return {};
|
|
1947
2173
|
if (typeof raw !== "object" || Array.isArray(raw))
|
|
1948
|
-
return '"
|
|
2174
|
+
return '"borrowed" must be an object';
|
|
1949
2175
|
const variants = new Set(posts.map((p) => p.variant));
|
|
1950
2176
|
const out = {};
|
|
1951
2177
|
for (const [part, variant] of Object.entries(raw)) {
|
|
1952
2178
|
if (!part || part.length > 200)
|
|
1953
|
-
return "
|
|
2179
|
+
return "borrowed keys are part names";
|
|
1954
2180
|
if (typeof variant !== "string" || !variants.has(variant)) {
|
|
1955
|
-
return `
|
|
2181
|
+
return `borrowed "${part}" names a variant that does not exist`;
|
|
1956
2182
|
}
|
|
1957
2183
|
out[part] = variant;
|
|
1958
2184
|
}
|
|
@@ -1965,9 +2191,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1965
2191
|
const answers = sanitizeAnswers(pick("answers"), mock, posts);
|
|
1966
2192
|
if (typeof answers === "string")
|
|
1967
2193
|
return answers;
|
|
1968
|
-
const
|
|
1969
|
-
if (typeof
|
|
1970
|
-
return
|
|
2194
|
+
const borrowed = sanitizeBorrowed(pick("borrowed"), posts);
|
|
2195
|
+
if (typeof borrowed === "string")
|
|
2196
|
+
return borrowed;
|
|
1971
2197
|
const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
|
|
1972
2198
|
if (!tuned.ok)
|
|
1973
2199
|
return tuned.error;
|
|
@@ -2002,7 +2228,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2002
2228
|
return {
|
|
2003
2229
|
version,
|
|
2004
2230
|
answers,
|
|
2005
|
-
|
|
2231
|
+
borrowed,
|
|
2006
2232
|
tuned: tuned.value,
|
|
2007
2233
|
comments,
|
|
2008
2234
|
others,
|
|
@@ -2047,28 +2273,26 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2047
2273
|
bus.broadcast({ type: "draft-updated", mockId: mock.id });
|
|
2048
2274
|
return ok({ draft: null });
|
|
2049
2275
|
}
|
|
2050
|
-
function
|
|
2276
|
+
function sanitizeVerdict(raw, mock, posts) {
|
|
2051
2277
|
if (raw === undefined || raw === null)
|
|
2052
2278
|
return undefined;
|
|
2053
2279
|
const kind = raw.kind;
|
|
2054
|
-
if (kind !== "
|
|
2055
|
-
return '
|
|
2056
|
-
}
|
|
2280
|
+
if (kind !== "use" && kind !== "revise")
|
|
2281
|
+
return 'verdict.kind must be "use" or "revise"';
|
|
2057
2282
|
const chosen = chooseVariant(mock, posts, stateArg(raw.state), str(raw.variant, MAX_LABEL));
|
|
2058
2283
|
if (isResult(chosen))
|
|
2059
2284
|
return chosen.body.error;
|
|
2060
2285
|
return { kind, state: chosen.state, variant: chosen.variant };
|
|
2061
2286
|
}
|
|
2062
|
-
// Which variants a reply
|
|
2063
|
-
// the chosen variant(s) in every state the ask covers and archives
|
|
2064
|
-
// siblings there; a part-scoped pick is a
|
|
2065
|
-
// variant
|
|
2066
|
-
// use at the version the reply was made on (`version`, a mock version), not
|
|
2287
|
+
// Which variants a reply puts in use and archives. A variant-bound answer puts
|
|
2288
|
+
// the chosen variant(s) in use in every state the ask covers and archives
|
|
2289
|
+
// their siblings there; a part-scoped pick is a borrow, not a verdict. The
|
|
2290
|
+
// built-in variant questions flip the same way, per state. Each variant is put
|
|
2291
|
+
// in use at the version the reply was made on (`version`, a mock version), not
|
|
2067
2292
|
// whatever the agent published since — or, for a version option, at its version.
|
|
2068
|
-
function replyFlips(mock, posts, answers,
|
|
2293
|
+
function replyFlips(mock, posts, answers, verdict, version) {
|
|
2069
2294
|
const accept = new Map();
|
|
2070
2295
|
const archive = new Set();
|
|
2071
|
-
const drop = new Set();
|
|
2072
2296
|
const settle = (inState, chosen, pins) => {
|
|
2073
2297
|
if (!inState.some((p) => chosen.has(p.variant)))
|
|
2074
2298
|
return;
|
|
@@ -2080,7 +2304,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2080
2304
|
}
|
|
2081
2305
|
const pin = pins?.get(p.variant);
|
|
2082
2306
|
// A version option outranks a looser pick of the same variant (the
|
|
2083
|
-
// built-in Which
|
|
2307
|
+
// built-in Which variant?, or the agent's own variant question) in the
|
|
2308
|
+
// same reply.
|
|
2084
2309
|
if (pin === undefined && accept.has(p.id))
|
|
2085
2310
|
continue;
|
|
2086
2311
|
// A pinned version history retention has since dropped falls back to
|
|
@@ -2109,21 +2334,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2109
2334
|
for (const state of states)
|
|
2110
2335
|
settle(posts.filter((p) => p.state === state), chosen);
|
|
2111
2336
|
}
|
|
2112
|
-
if (
|
|
2113
|
-
settle(posts.filter((p) => p.state ===
|
|
2114
|
-
}
|
|
2115
|
-
else if (decision?.kind === "drop") {
|
|
2116
|
-
const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
|
|
2117
|
-
if (post)
|
|
2118
|
-
drop.add(post.id);
|
|
2119
|
-
}
|
|
2120
|
-
for (const id of accept.keys()) {
|
|
2121
|
-
archive.delete(id);
|
|
2122
|
-
drop.delete(id);
|
|
2337
|
+
if (verdict?.kind === "use") {
|
|
2338
|
+
settle(posts.filter((p) => p.state === verdict.state), new Set([verdict.variant]));
|
|
2123
2339
|
}
|
|
2124
|
-
for (const id of
|
|
2340
|
+
for (const id of accept.keys())
|
|
2125
2341
|
archive.delete(id);
|
|
2126
|
-
return { accept, archive
|
|
2342
|
+
return { accept, archive };
|
|
2127
2343
|
}
|
|
2128
2344
|
// An ask as a reply leaves it. A pick or a write-in replaces whatever was
|
|
2129
2345
|
// answered before; a note alone keeps that answer and adds to it.
|
|
@@ -2170,13 +2386,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2170
2386
|
if (typeof draft === "string")
|
|
2171
2387
|
return fail(400, draft);
|
|
2172
2388
|
const text = str(body?.text, MAX_COMMENT_TEXT);
|
|
2173
|
-
const
|
|
2174
|
-
if (typeof
|
|
2175
|
-
return fail(400,
|
|
2389
|
+
const verdict = sanitizeVerdict(body?.verdict, mock, posts);
|
|
2390
|
+
if (typeof verdict === "string")
|
|
2391
|
+
return fail(400, verdict);
|
|
2176
2392
|
const empty = !text &&
|
|
2177
|
-
!
|
|
2393
|
+
!verdict &&
|
|
2178
2394
|
!Object.keys(draft.answers).length &&
|
|
2179
|
-
!Object.keys(draft.
|
|
2395
|
+
!Object.keys(draft.borrowed).length &&
|
|
2180
2396
|
!Object.keys(draft.tuned).length &&
|
|
2181
2397
|
!draft.comments.length &&
|
|
2182
2398
|
!Object.keys(draft.others ?? {}).length &&
|
|
@@ -2191,7 +2407,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2191
2407
|
mockId: mock.id,
|
|
2192
2408
|
version: draft.version,
|
|
2193
2409
|
answers: draft.answers,
|
|
2194
|
-
|
|
2410
|
+
borrowed: draft.borrowed,
|
|
2195
2411
|
tuned: draft.tuned,
|
|
2196
2412
|
comments: draft.comments,
|
|
2197
2413
|
...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
|
|
@@ -2201,11 +2417,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2201
2417
|
...(text ? { text } : {}),
|
|
2202
2418
|
};
|
|
2203
2419
|
const asks = mock.asks.map((a) => sentAsk(a, draft));
|
|
2204
|
-
const flips = replyFlips(mock, posts, draft.answers,
|
|
2205
|
-
if (
|
|
2206
|
-
const target = posts.find((p) => p.state ===
|
|
2207
|
-
const pinned =
|
|
2208
|
-
payload.
|
|
2420
|
+
const flips = replyFlips(mock, posts, draft.answers, verdict, draft.version);
|
|
2421
|
+
if (verdict) {
|
|
2422
|
+
const target = posts.find((p) => p.state === verdict.state && p.variant === verdict.variant);
|
|
2423
|
+
const pinned = verdict.kind === "use" && target ? flips.accept.get(target.id) : undefined;
|
|
2424
|
+
payload.verdict = pinned === undefined ? verdict : { ...verdict, version: pinned };
|
|
2209
2425
|
}
|
|
2210
2426
|
const comment = await store.commitReply({
|
|
2211
2427
|
mockId: mock.id,
|
|
@@ -2215,7 +2431,6 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2215
2431
|
asks,
|
|
2216
2432
|
accept: [...flips.accept].map(([id, version]) => ({ id, version })),
|
|
2217
2433
|
archive: [...flips.archive],
|
|
2218
|
-
drop: [...flips.drop],
|
|
2219
2434
|
});
|
|
2220
2435
|
if (!comment)
|
|
2221
2436
|
return fail(409, `${mock.slug} has no agent session to reply to`);
|
|
@@ -2225,7 +2440,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2225
2440
|
void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
|
|
2226
2441
|
const after = await store.listPosts({ mockId: mock.id });
|
|
2227
2442
|
for (const p of after) {
|
|
2228
|
-
if (flips.accept.has(p.id) || flips.archive.has(p.id)
|
|
2443
|
+
if (flips.accept.has(p.id) || flips.archive.has(p.id)) {
|
|
2229
2444
|
bus.broadcast({
|
|
2230
2445
|
type: "post-updated",
|
|
2231
2446
|
id: p.id,
|
|
@@ -2240,29 +2455,26 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2240
2455
|
event: "decision",
|
|
2241
2456
|
project: mock.project,
|
|
2242
2457
|
slug: mock.slug,
|
|
2243
|
-
variant:
|
|
2458
|
+
variant: verdict?.variant ?? "",
|
|
2244
2459
|
version: draft.version,
|
|
2245
|
-
text: text ||
|
|
2460
|
+
text: text || verdict?.kind || "reply",
|
|
2246
2461
|
url: mockUrl(ctx.base, mock),
|
|
2247
2462
|
});
|
|
2248
2463
|
const [delivered] = await withDelivered([comment]);
|
|
2249
2464
|
return ok({
|
|
2250
2465
|
reply: delivered,
|
|
2251
|
-
|
|
2466
|
+
inUse: after
|
|
2252
2467
|
.filter((p) => flips.accept.has(p.id))
|
|
2253
2468
|
.map((p) => ({ state: p.state, variant: p.variant, version: inUseVersion(p) })),
|
|
2254
2469
|
archived: after
|
|
2255
2470
|
.filter((p) => flips.archive.has(p.id))
|
|
2256
2471
|
.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
2472
|
}, 201);
|
|
2261
2473
|
}
|
|
2262
|
-
// "Use this": the user puts one exact version of a variant in use. It
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2265
|
-
//
|
|
2474
|
+
// "Use this": the user puts one exact version of a variant in use. It
|
|
2475
|
+
// archives the variant's live siblings in that state and reaches the agent
|
|
2476
|
+
// once, as a reply carrying the verdict, through the same cursor as a Send;
|
|
2477
|
+
// the user's unsent draft is left for their next Send.
|
|
2266
2478
|
async function useFlow(ref, body, ctx) {
|
|
2267
2479
|
if (!ctx.viewer)
|
|
2268
2480
|
return viewerOnly();
|
|
@@ -2299,10 +2511,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2299
2511
|
mockId: mock.id,
|
|
2300
2512
|
version,
|
|
2301
2513
|
answers: {},
|
|
2302
|
-
|
|
2514
|
+
borrowed: {},
|
|
2303
2515
|
tuned: {},
|
|
2304
2516
|
comments: [],
|
|
2305
|
-
|
|
2517
|
+
verdict: { kind: "use", ...inUse },
|
|
2306
2518
|
},
|
|
2307
2519
|
asks: mock.asks,
|
|
2308
2520
|
accept: [{ id: chosen.id, version }],
|
|
@@ -2330,7 +2542,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2330
2542
|
slug: mock.slug,
|
|
2331
2543
|
variant: chosen.variant,
|
|
2332
2544
|
version,
|
|
2333
|
-
text: "
|
|
2545
|
+
text: "use",
|
|
2334
2546
|
url: mockUrl(ctx.base, mock),
|
|
2335
2547
|
});
|
|
2336
2548
|
const [delivered] = await withDelivered([comment]);
|
|
@@ -2394,15 +2606,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2394
2606
|
}
|
|
2395
2607
|
// The user closes a mock from the project list: its open asks are dismissed
|
|
2396
2608
|
// unanswered, an unsent draft goes with them, and the agent hears it once,
|
|
2397
|
-
// as a
|
|
2398
|
-
async function
|
|
2609
|
+
// as a close through the same cursor as a Send.
|
|
2610
|
+
async function closeFlow(ref, ctx) {
|
|
2399
2611
|
if (!ctx.viewer)
|
|
2400
2612
|
return viewerOnly();
|
|
2401
2613
|
const mock = await resolveMock(ref);
|
|
2402
2614
|
if (isResult(mock))
|
|
2403
2615
|
return mock;
|
|
2404
2616
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
2405
|
-
if (mock.
|
|
2617
|
+
if (mock.closedAt)
|
|
2406
2618
|
return ok({ mock: mockSummaryView(mock, posts), dismissed: [] });
|
|
2407
2619
|
const dismissed = [...openAsks(mock).map((a) => a.id), ...builtinAskIds(mock, posts, mock.id)];
|
|
2408
2620
|
const asks = mock.asks.map((a) => (askAnswered(a) ? a : { ...a, dismissed: true }));
|
|
@@ -2419,22 +2631,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2419
2631
|
mockId: mock.id,
|
|
2420
2632
|
version: Math.max(1, ...posts.map((p) => p.version)),
|
|
2421
2633
|
answers: {},
|
|
2422
|
-
|
|
2634
|
+
borrowed: {},
|
|
2423
2635
|
tuned: {},
|
|
2424
2636
|
comments: [],
|
|
2425
|
-
|
|
2637
|
+
closed: { dismissed },
|
|
2426
2638
|
},
|
|
2427
2639
|
asks,
|
|
2428
2640
|
accept: [],
|
|
2429
2641
|
archive: [],
|
|
2430
|
-
|
|
2642
|
+
closedAt: at,
|
|
2431
2643
|
})
|
|
2432
2644
|
: null;
|
|
2433
2645
|
if (comment)
|
|
2434
2646
|
announceComment(comment);
|
|
2435
2647
|
else {
|
|
2436
2648
|
// No agent to tell: the mock still leaves the open list.
|
|
2437
|
-
await store.updateMock(mock.id, { asks,
|
|
2649
|
+
await store.updateMock(mock.id, { asks, closedAt: at });
|
|
2438
2650
|
await store.putDraft(mock.id, null);
|
|
2439
2651
|
}
|
|
2440
2652
|
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
@@ -2442,7 +2654,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2442
2654
|
const after = (await store.getMock(mock.id)) ?? mock;
|
|
2443
2655
|
return ok({ mock: mockSummaryView(after, posts), dismissed });
|
|
2444
2656
|
}
|
|
2445
|
-
// Back from
|
|
2657
|
+
// Back from Closed: the asks it dismissed are open again. The agent was
|
|
2446
2658
|
// told they were dismissed; answers given now reach it as a normal Send.
|
|
2447
2659
|
async function reopenFlow(ref, ctx) {
|
|
2448
2660
|
if (!ctx.viewer)
|
|
@@ -2451,10 +2663,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2451
2663
|
if (isResult(mock))
|
|
2452
2664
|
return mock;
|
|
2453
2665
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
2454
|
-
if (!mock.
|
|
2666
|
+
if (!mock.closedAt)
|
|
2455
2667
|
return ok({ mock: mockSummaryView(mock, posts) });
|
|
2456
2668
|
const asks = mock.asks.map(({ dismissed: _, ...a }) => a);
|
|
2457
|
-
const updated = await store.updateMock(mock.id, { asks,
|
|
2669
|
+
const updated = await store.updateMock(mock.id, { asks, closedAt: null });
|
|
2458
2670
|
if (!updated)
|
|
2459
2671
|
return fail(404, "mock not found");
|
|
2460
2672
|
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
@@ -2479,7 +2691,44 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2479
2691
|
version: updated.version,
|
|
2480
2692
|
by: "user",
|
|
2481
2693
|
});
|
|
2482
|
-
return ok({
|
|
2694
|
+
return ok({
|
|
2695
|
+
state: updated.state,
|
|
2696
|
+
variant: updated.variant,
|
|
2697
|
+
status: variantStatus(updated.status),
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
// The user archives a variant they're done with, short of choosing another:
|
|
2701
|
+
// Studio's × on its last version. Restore is the way back. The variant in
|
|
2702
|
+
// use stays, because archiving it would leave the state with nothing chosen
|
|
2703
|
+
// and no reply saying why.
|
|
2704
|
+
async function archiveFlow(ref, body, ctx) {
|
|
2705
|
+
if (!ctx.viewer)
|
|
2706
|
+
return viewerOnly();
|
|
2707
|
+
const mock = await resolveMock(ref);
|
|
2708
|
+
if (isResult(mock))
|
|
2709
|
+
return mock;
|
|
2710
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2711
|
+
const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
|
|
2712
|
+
if (isResult(chosen))
|
|
2713
|
+
return chosen;
|
|
2714
|
+
if (variantStatus(chosen.status) === "inUse")
|
|
2715
|
+
return fail(409, `${chosen.variant} is in use`);
|
|
2716
|
+
const updated = await store.setPostStatus(chosen.id, "archived");
|
|
2717
|
+
if (!updated)
|
|
2718
|
+
return fail(404, "variant not found");
|
|
2719
|
+
bus.broadcast({
|
|
2720
|
+
type: "post-updated",
|
|
2721
|
+
id: updated.id,
|
|
2722
|
+
mockId: mock.id,
|
|
2723
|
+
sessionId: updated.sessionId,
|
|
2724
|
+
version: updated.version,
|
|
2725
|
+
by: "user",
|
|
2726
|
+
});
|
|
2727
|
+
return ok({
|
|
2728
|
+
state: updated.state,
|
|
2729
|
+
variant: updated.variant,
|
|
2730
|
+
status: variantStatus(updated.status),
|
|
2731
|
+
});
|
|
2483
2732
|
}
|
|
2484
2733
|
// The user stars (or unstars) a variant: a favourite to come back to, which
|
|
2485
2734
|
// decides nothing. Viewer-only; the agent reads it as `starred`.
|
|
@@ -2557,6 +2806,216 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2557
2806
|
bus.broadcast({ type: "mock-deleted", id: mock.id, project: mock.project });
|
|
2558
2807
|
return ok({ ok: true });
|
|
2559
2808
|
}
|
|
2809
|
+
// A write's feedback for the session that made it, when it named one: an
|
|
2810
|
+
// edit needs no session, so without one there is no cursor to read from.
|
|
2811
|
+
const feedbackFor = async (session, base) => typeof session === "string" && session ? collectFeedback(session, base) : [];
|
|
2812
|
+
// `edit`: a mock's own fields, without a new version. A new slug rewrites
|
|
2813
|
+
// every reference to the old one in the project (store.renameMock), so pages
|
|
2814
|
+
// keep showing it and flows keep walking it.
|
|
2815
|
+
async function editMockFlow(ref, body, ctx) {
|
|
2816
|
+
if (!body || typeof body !== "object")
|
|
2817
|
+
return fail(400, "invalid JSON body");
|
|
2818
|
+
const given = ["slug", "title", "kind", "area", "source"].filter((k) => body[k] !== undefined);
|
|
2819
|
+
if (given.length === 0) {
|
|
2820
|
+
return fail(400, 'provide one of "slug", "title", "kind", "area" or "source"');
|
|
2821
|
+
}
|
|
2822
|
+
const mock = await resolveMock(ref, body.project, body.session);
|
|
2823
|
+
if (isResult(mock))
|
|
2824
|
+
return mock;
|
|
2825
|
+
const slugIn = body.slug === undefined ? undefined : str(body.slug, MAX_TITLE);
|
|
2826
|
+
if (body.slug !== undefined && !slugIn)
|
|
2827
|
+
return fail(400, '"slug" must name the mock, e.g. "onni"');
|
|
2828
|
+
const slug = slugIn === undefined ? undefined : slugify(slugIn);
|
|
2829
|
+
const title = str(body.title, MAX_TITLE);
|
|
2830
|
+
if (body.title !== undefined && !title)
|
|
2831
|
+
return fail(400, '"title" must be non-empty text');
|
|
2832
|
+
if (body.kind !== undefined && !isMockKind(body.kind)) {
|
|
2833
|
+
return fail(400, '"kind" must be page, component or sketch');
|
|
2834
|
+
}
|
|
2835
|
+
const kind = body.kind;
|
|
2836
|
+
const area = labelArg(body, "area", MAX_LABEL);
|
|
2837
|
+
if (isResult(area))
|
|
2838
|
+
return area;
|
|
2839
|
+
const source = labelArg(body, "source", MAX_SOURCE);
|
|
2840
|
+
if (isResult(source))
|
|
2841
|
+
return source;
|
|
2842
|
+
const renaming = slug !== undefined && slug !== mock.slug;
|
|
2843
|
+
if (renaming && (await store.findMock(mock.project, slug))) {
|
|
2844
|
+
return fail(409, `${mock.project} already has a mock "${slug}"; pick another slug`);
|
|
2845
|
+
}
|
|
2846
|
+
// Being embedded is what makes a mock a component: a sketch that is still
|
|
2847
|
+
// embedded would be promoted back on its embedder's next publish.
|
|
2848
|
+
if (kind === "sketch" && mock.kind !== "sketch") {
|
|
2849
|
+
const embedders = await embeddersOf(mock.project, mock.slug);
|
|
2850
|
+
if (embedders.length) {
|
|
2851
|
+
return fail(409, `${mock.slug} is embedded in ${embedders.map((m) => m.slug).join(", ")}; being embedded makes it a component. Take the embeds out first`, { embeddedIn: embedders.map((m) => m.slug) });
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
let next = mock;
|
|
2855
|
+
// The rename goes first: it is the part that can still lose a race for the slug.
|
|
2856
|
+
if (renaming) {
|
|
2857
|
+
const renamed = await store.renameMock(mock.id, slug);
|
|
2858
|
+
if (!renamed)
|
|
2859
|
+
return fail(409, `${mock.project} already has a mock "${slug}"`);
|
|
2860
|
+
next = renamed;
|
|
2861
|
+
clearRenderCache();
|
|
2862
|
+
}
|
|
2863
|
+
if (title !== undefined || kind !== undefined || area !== undefined || source !== undefined) {
|
|
2864
|
+
next =
|
|
2865
|
+
(await store.updateMock(mock.id, {
|
|
2866
|
+
...(title === undefined ? {} : { title }),
|
|
2867
|
+
...(kind === undefined ? {} : { kind }),
|
|
2868
|
+
...(area === undefined ? {} : { area }),
|
|
2869
|
+
...(source === undefined ? {} : { source }),
|
|
2870
|
+
})) ?? next;
|
|
2871
|
+
}
|
|
2872
|
+
bus.broadcast({ type: "mock-updated", id: next.id, project: next.project });
|
|
2873
|
+
if (renaming)
|
|
2874
|
+
await announceEmbedders(next);
|
|
2875
|
+
if (ctx.viewer)
|
|
2876
|
+
await tellAgentEdited(mock, next);
|
|
2877
|
+
// Flows are declared apart from kind, so a page that stops being one stays
|
|
2878
|
+
// in them until the agent takes it out.
|
|
2879
|
+
const nudges = [...titleNudges(title)];
|
|
2880
|
+
if (kind !== undefined && kind !== "page" && mock.kind === "page") {
|
|
2881
|
+
for (const f of await store.listFlows(next.project)) {
|
|
2882
|
+
if (!f.steps.some((x) => x.mock === next.slug))
|
|
2883
|
+
continue;
|
|
2884
|
+
nudges.push(`${next.slug} is still a step of flow "${f.name}"; publish it with flow "${f.name}" and step 0 to take it out`);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
return ok({
|
|
2888
|
+
mock: {
|
|
2889
|
+
id: next.id,
|
|
2890
|
+
project: next.project,
|
|
2891
|
+
slug: next.slug,
|
|
2892
|
+
title: next.title,
|
|
2893
|
+
kind: next.kind,
|
|
2894
|
+
...placeOf(next),
|
|
2895
|
+
states: next.states,
|
|
2896
|
+
},
|
|
2897
|
+
...(renaming ? { renamed: { from: mock.slug, to: next.slug } } : {}),
|
|
2898
|
+
url: mockUrl(ctx.base, next),
|
|
2899
|
+
...(nudges.length ? { nudges } : {}),
|
|
2900
|
+
feedback: await feedbackFor(body.session, ctx.base),
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
// The user moved a mock from Home (another kind or area): the agent hears it
|
|
2904
|
+
// once, through the same cursor as a Send, on the session driving the mock.
|
|
2905
|
+
// Nothing to tell when no agent has written it.
|
|
2906
|
+
async function tellAgentEdited(before, after) {
|
|
2907
|
+
const edited = {
|
|
2908
|
+
...(after.kind !== before.kind ? { kind: after.kind } : {}),
|
|
2909
|
+
...(after.area !== before.area ? { area: after.area } : {}),
|
|
2910
|
+
};
|
|
2911
|
+
if (Object.keys(edited).length === 0)
|
|
2912
|
+
return;
|
|
2913
|
+
const posts = await store.listPosts({ mockId: after.id });
|
|
2914
|
+
const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
2915
|
+
const sessionId = after.sessionId ?? latest?.sessionId;
|
|
2916
|
+
if (!sessionId)
|
|
2917
|
+
return;
|
|
2918
|
+
const comment = await store.commitReply({
|
|
2919
|
+
mockId: after.id,
|
|
2920
|
+
sessionId,
|
|
2921
|
+
text: "",
|
|
2922
|
+
payload: {
|
|
2923
|
+
mockId: after.id,
|
|
2924
|
+
version: Math.max(1, ...posts.map((p) => p.version)),
|
|
2925
|
+
answers: {},
|
|
2926
|
+
borrowed: {},
|
|
2927
|
+
tuned: {},
|
|
2928
|
+
comments: [],
|
|
2929
|
+
edited,
|
|
2930
|
+
},
|
|
2931
|
+
asks: after.asks,
|
|
2932
|
+
accept: [],
|
|
2933
|
+
archive: [],
|
|
2934
|
+
keepDraft: true,
|
|
2935
|
+
});
|
|
2936
|
+
if (comment)
|
|
2937
|
+
announceComment(comment);
|
|
2938
|
+
}
|
|
2939
|
+
const areaOrderOf = async (project) => {
|
|
2940
|
+
const raw = await store.getSetting(`${AREA_ORDER_SETTING}:${project}`);
|
|
2941
|
+
if (!raw)
|
|
2942
|
+
return [];
|
|
2943
|
+
try {
|
|
2944
|
+
const v = JSON.parse(raw);
|
|
2945
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
2946
|
+
}
|
|
2947
|
+
catch {
|
|
2948
|
+
return [];
|
|
2949
|
+
}
|
|
2950
|
+
};
|
|
2951
|
+
// `areaOrder`: the areas in the order Home lists them; [] (or "") hands the
|
|
2952
|
+
// order back to the alphabet.
|
|
2953
|
+
function areaOrderArg(v) {
|
|
2954
|
+
const list = typeof v === "string" ? v.split(",") : v;
|
|
2955
|
+
if (!Array.isArray(list) || list.some((x) => typeof x !== "string")) {
|
|
2956
|
+
return fail(400, '"areaOrder" must be a list of area names, e.g. ["Shell", "Food"]');
|
|
2957
|
+
}
|
|
2958
|
+
const names = [...new Set(list.map((x) => stripNul(x).trim()).filter(Boolean))];
|
|
2959
|
+
if (names.length > MAX_AREAS)
|
|
2960
|
+
return fail(400, `"areaOrder" takes at most ${MAX_AREAS} areas`);
|
|
2961
|
+
if (names.some((x) => x.length > MAX_LABEL)) {
|
|
2962
|
+
return fail(400, `an area name is at most ${MAX_LABEL} characters`);
|
|
2963
|
+
}
|
|
2964
|
+
return names;
|
|
2965
|
+
}
|
|
2966
|
+
// `edit` on a project: a new name for it, carrying its mocks, sessions,
|
|
2967
|
+
// flows and settings (refused onto a project that already has mocks), and
|
|
2968
|
+
// the order Home lists its areas in.
|
|
2969
|
+
async function editProjectFlow(name, body, ctx) {
|
|
2970
|
+
if (!body || typeof body !== "object")
|
|
2971
|
+
return fail(400, "invalid JSON body");
|
|
2972
|
+
const from = str(name, MAX_TITLE);
|
|
2973
|
+
if (!from)
|
|
2974
|
+
return fail(400, "provide the project to edit");
|
|
2975
|
+
if (body.name === undefined && body.areaOrder === undefined) {
|
|
2976
|
+
return fail(400, 'provide "name" (the project\'s new name) or "areaOrder"');
|
|
2977
|
+
}
|
|
2978
|
+
const to = body.name === undefined ? undefined : str(body.name, MAX_TITLE);
|
|
2979
|
+
if (body.name !== undefined && !to)
|
|
2980
|
+
return fail(400, '"name" must be the project\'s new name');
|
|
2981
|
+
if (to === from)
|
|
2982
|
+
return fail(400, `${from} already has that name`);
|
|
2983
|
+
const order = body.areaOrder === undefined ? undefined : areaOrderArg(body.areaOrder);
|
|
2984
|
+
if (isResult(order))
|
|
2985
|
+
return order;
|
|
2986
|
+
const mocks = await store.listMocks(from);
|
|
2987
|
+
if (mocks.length === 0)
|
|
2988
|
+
return fail(404, `no project "${from}" with mocks`);
|
|
2989
|
+
if (to !== undefined) {
|
|
2990
|
+
if (!(await store.renameProject(from, to))) {
|
|
2991
|
+
return fail(409, `${to} already has mocks; pick another name`);
|
|
2992
|
+
}
|
|
2993
|
+
clearRenderCache();
|
|
2994
|
+
}
|
|
2995
|
+
const project = to ?? from;
|
|
2996
|
+
if (order !== undefined) {
|
|
2997
|
+
await store.setSetting(`${AREA_ORDER_SETTING}:${project}`, JSON.stringify(order));
|
|
2998
|
+
}
|
|
2999
|
+
for (const m of mocks)
|
|
3000
|
+
bus.broadcast({ type: "mock-updated", id: m.id, project });
|
|
3001
|
+
const named = new Set(mocks.flatMap((m) => (m.area ? [m.area] : [])));
|
|
3002
|
+
const unnamed = (order ?? []).filter((a) => !named.has(a));
|
|
3003
|
+
return ok({
|
|
3004
|
+
project,
|
|
3005
|
+
...(to === undefined ? {} : { renamed: { from, to } }),
|
|
3006
|
+
...(order === undefined ? {} : { areaOrder: order }),
|
|
3007
|
+
mocks: mocks.length,
|
|
3008
|
+
url: `${ctx.base}/project/${encodeURIComponent(project)}`,
|
|
3009
|
+
...(unnamed.length
|
|
3010
|
+
? {
|
|
3011
|
+
nudges: [
|
|
3012
|
+
`no mock in ${project} has area ${unnamed.map((a) => `"${a}"`).join(", ")} yet; Home lists it once one does`,
|
|
3013
|
+
],
|
|
3014
|
+
}
|
|
3015
|
+
: {}),
|
|
3016
|
+
feedback: await feedbackFor(body.session, ctx.base),
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
2560
3019
|
// --- comments ---
|
|
2561
3020
|
function numberInRange(value, min, max) {
|
|
2562
3021
|
const n = Number(value);
|
|
@@ -2883,7 +3342,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2883
3342
|
const pendingOf = (mock, posts) => ({
|
|
2884
3343
|
mock: mock.slug,
|
|
2885
3344
|
// Closed by the user: nothing there is waiting on them, or on you.
|
|
2886
|
-
...(mock.
|
|
3345
|
+
...(mock.closedAt ? { closed: true } : {}),
|
|
2887
3346
|
viewerOpen: viewing.has(mock.id),
|
|
2888
3347
|
draft: mock.draft
|
|
2889
3348
|
? {
|
|
@@ -3195,6 +3654,17 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3195
3654
|
const flag = (c, key) => c.req.query(key) === "1";
|
|
3196
3655
|
// --- sessions ---
|
|
3197
3656
|
app.get("/api/sessions", async (c) => {
|
|
3657
|
+
// A watcher started with every conversation finds that conversation's
|
|
3658
|
+
// session without creating one, and never mistakes a sibling's in the same
|
|
3659
|
+
// repo for its own.
|
|
3660
|
+
const key = c.req.query("key");
|
|
3661
|
+
if (key !== undefined) {
|
|
3662
|
+
const session = key ? await store.getSessionByKey(key) : null;
|
|
3663
|
+
if (!session)
|
|
3664
|
+
return c.json([]);
|
|
3665
|
+
const posts = await store.listPosts({ sessionId: session.id });
|
|
3666
|
+
return c.json([sessionRowView(session, posts.length)]);
|
|
3667
|
+
}
|
|
3198
3668
|
const [sessions, counts] = await Promise.all([
|
|
3199
3669
|
store.listSessions(),
|
|
3200
3670
|
store.countPostsBySession(),
|
|
@@ -3280,6 +3750,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3280
3750
|
variant: c.req.query("variant"),
|
|
3281
3751
|
}, flowCtx(c)));
|
|
3282
3752
|
});
|
|
3753
|
+
app.patch("/api/mocks/:id", async (c) => {
|
|
3754
|
+
const body = await jsonBody(c);
|
|
3755
|
+
// A slug names its project in the body or, as on the reads, in the query.
|
|
3756
|
+
const project = c.req.query("project");
|
|
3757
|
+
const withProject = body && typeof body === "object" && body.project === undefined && project
|
|
3758
|
+
? { ...body, project }
|
|
3759
|
+
: body;
|
|
3760
|
+
return send(c, await editMockFlow(c.req.param("id"), withProject, flowCtx(c)));
|
|
3761
|
+
});
|
|
3283
3762
|
app.delete("/api/mocks/:id", async (c) => send(c, await removeMockFlow(c.req.param("id"), { project: c.req.query("project") })));
|
|
3284
3763
|
app.post("/api/mocks/:id/asks", async (c) => send(c, await askFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3285
3764
|
app.get("/api/mocks/:id/draft", async (c) => send(c, await getDraftFlow(c.req.param("id"), flowCtx(c))));
|
|
@@ -3288,9 +3767,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3288
3767
|
app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3289
3768
|
app.post("/api/mocks/:id/use", async (c) => send(c, await useFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3290
3769
|
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/
|
|
3770
|
+
app.post("/api/mocks/:id/close", async (c) => send(c, await closeFlow(c.req.param("id"), flowCtx(c))));
|
|
3292
3771
|
app.post("/api/mocks/:id/reopen", async (c) => send(c, await reopenFlow(c.req.param("id"), flowCtx(c))));
|
|
3293
3772
|
app.post("/api/mocks/:id/star", async (c) => send(c, await starFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3773
|
+
app.post("/api/mocks/:id/archive", async (c) => send(c, await archiveFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3294
3774
|
app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
|
|
3295
3775
|
app.post("/api/mocks/:id/variants/:postId/restore", async (c) => send(c, await restoreVersionFlow(c.req.param("id"), c.req.param("postId"), await jsonBody(c), flowCtx(c))));
|
|
3296
3776
|
app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
@@ -3421,7 +3901,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3421
3901
|
// The project's design system, imported by `mockpit init` and injected into
|
|
3422
3902
|
// every html surface of the project (see renderHtmlPage).
|
|
3423
3903
|
app.get("/api/projects/:name/tree", async (c) => send(c, await treeFlow({ project: c.req.param("name") })));
|
|
3904
|
+
app.patch("/api/projects/:name", async (c) => send(c, await editProjectFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
|
|
3424
3905
|
app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
|
|
3906
|
+
// A project's rules file (.mockpit/rules.md, sent by `mockpit rules` and
|
|
3907
|
+
// `mockpit init`) and the user's own (`mockpit rules --mine`).
|
|
3908
|
+
app.get("/api/projects/:name/rules", async (c) => c.json({ text: (await store.getSetting(`${RULES_SETTING}:${c.req.param("name")}`)) ?? "" }));
|
|
3909
|
+
app.put("/api/projects/:name/rules", async (c) => send(c, await saveRules(`${RULES_SETTING}:${c.req.param("name")}`, await jsonBody(c))));
|
|
3910
|
+
app.get("/api/rules", async (c) => c.json({ text: (await userRulesText()) ?? "" }));
|
|
3911
|
+
app.put("/api/rules", async (c) => send(c, await saveRules(USER_RULES_SETTING, await jsonBody(c))));
|
|
3425
3912
|
app.put("/api/projects/:name/design", async (c) => {
|
|
3426
3913
|
const body = await c.req.json().catch(() => null);
|
|
3427
3914
|
if (!body || typeof body !== "object")
|
|
@@ -3737,9 +4224,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3737
4224
|
// The runtime's module cache makes every later call cheap, so there's no memo
|
|
3738
4225
|
// here to keep in sync. On the Worker the module is already inside the
|
|
3739
4226
|
// deployed bundle — the import defers evaluating it, not fetching it — so this
|
|
3740
|
-
// needs no network at runtime.
|
|
3741
|
-
//
|
|
3742
|
-
//
|
|
4227
|
+
// needs no network at runtime. Check it on real workerd (`npm run dev:cloud`):
|
|
4228
|
+
// a dynamic import resolving differently there is exactly the way this
|
|
4229
|
+
// optimization could break in production and nowhere else.
|
|
3743
4230
|
const { renderCode, renderDiff, renderMarkdown, renderTerminal } = await import("./richRender.js");
|
|
3744
4231
|
const rendered = surface.kind === "markdown"
|
|
3745
4232
|
? await renderMarkdown(surface, { theme: themeId, mode })
|
|
@@ -4160,6 +4647,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
4160
4647
|
exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
|
|
4161
4648
|
say: (ref, body, ctx) => sayFlow(ref, body, ctx),
|
|
4162
4649
|
feedback: (query, ctx) => feedbackFlow(query, ctx),
|
|
4650
|
+
edit: (ref, body, ctx) => editMockFlow(ref, body, ctx),
|
|
4651
|
+
editProject: (name, body, ctx) => editProjectFlow(name, body, ctx),
|
|
4163
4652
|
};
|
|
4164
4653
|
const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
|
|
4165
4654
|
// --- run: an agent script over the same flows (server/run.ts) ---
|