@miadi/stateloom-web 0.1.9 → 0.2.1
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/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +2 -2
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +3 -3
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__d5879c0c._.js → [root-of-the-server]__b1b2bf74._.js} +2 -2
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +4 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/9390437c8b41a90e.js +11 -0
- package/server/.next/static/chunks/a8a37c487ef91a19.js +1 -0
- package/server/.next/static/chunks/c86e96e4d6eeb632.css +2 -0
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/api/file/route.ts +57 -12
- package/server/src/app/globals.css +15 -0
- package/server/src/app/page.tsx +52 -10
- package/server/src/components/BoardBoundary.tsx +46 -0
- package/server/src/components/PropertiesPanel.tsx +10 -0
- package/server/src/components/erd/ErdWorkspace.tsx +32 -3
- package/server/src/components/forms.tsx +129 -0
- package/server/src/components/sequence/SequenceWorkspace.tsx +1024 -0
- package/server/src/components/system/KindIcon.tsx +71 -0
- package/server/src/components/system/SystemStrip.tsx +285 -0
- package/server/src/components/system/SystemWorkspace.tsx +766 -0
- package/server/src/lib/docParam.ts +4 -1
- package/server/src/lib/machineFocus.ts +126 -0
- package/server/src/lib/projectFile.ts +180 -20
- package/server/src/lib/safely.ts +16 -0
- package/server/src/lib/systemLoad.ts +362 -0
- package/server/src/lib/systemParam.ts +110 -0
- package/server/src/types/definition.ts +2 -0
- package/server/stateloom-web.json +2 -2
- package/server/.next/static/chunks/636e7eb4aeda72e0.css +0 -2
- package/server/.next/static/chunks/92628504da59dcc8.js +0 -10
- package/server/.next/static/chunks/aea5b1716c68391c.js +0 -1
- /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_buildManifest.js +0 -0
- /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{6dAVJYqnbe6oEAABQ1yMG → eIqAtQaBkU80e3XWuVwR8}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The designer, when the document is a sequence (`.sqdf.json`, Spec 81).
|
|
5
|
+
*
|
|
6
|
+
* Built as ErdWorkspace is: the document comes through the file API, the hub
|
|
7
|
+
* room is keyed by its resolved path, every edit is one of the protocol's pure
|
|
8
|
+
* functions (the same ones the MCP tools call), written to disk first and then
|
|
9
|
+
* pushed to the room whole. The layout is the same too — one header row, view
|
|
10
|
+
* controls on the board, the panel a bottom sheet behind a dock on a phone and
|
|
11
|
+
* a right column on a desktop.
|
|
12
|
+
*
|
|
13
|
+
* Inside a system (`?system=` in the URL), the Issues tab adds the system's
|
|
14
|
+
* findings for this scenario — the names it uses that no member defines, and
|
|
15
|
+
* the replay of each path against the machines — and the fields that name an
|
|
16
|
+
* event, a state or an entity suggest the ones the members define. When the
|
|
17
|
+
* system's reconcile mode is `auto`, each saved edit also writes the changes
|
|
18
|
+
* the scenario implies into the machines and the data.
|
|
19
|
+
*/
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
21
|
+
import { SequenceCanvas, drawableSequence, type SequenceSelection } from "@miadi/stateloom-canvas";
|
|
22
|
+
import BoardBoundary from "@/components/BoardBoundary";
|
|
23
|
+
import { createBridgeSession, type BridgeSession } from "@miadi/stateloom-react";
|
|
24
|
+
import {
|
|
25
|
+
IDENTITY_VIEWPORT,
|
|
26
|
+
SQD_FRAGMENT_KINDS,
|
|
27
|
+
addFragment,
|
|
28
|
+
addFragmentMessage,
|
|
29
|
+
addMessage,
|
|
30
|
+
addParticipant,
|
|
31
|
+
allMessages,
|
|
32
|
+
moveParticipant,
|
|
33
|
+
renameParticipant,
|
|
34
|
+
checkSystem,
|
|
35
|
+
clampScale,
|
|
36
|
+
emptySequence,
|
|
37
|
+
fitToBoxes,
|
|
38
|
+
isSequenceDefinition,
|
|
39
|
+
moveMessage,
|
|
40
|
+
parseFocus,
|
|
41
|
+
parseMessageRef,
|
|
42
|
+
reconcileModeOf,
|
|
43
|
+
removeFragment,
|
|
44
|
+
removeMessage,
|
|
45
|
+
removeParticipant,
|
|
46
|
+
renderMermaidSequence,
|
|
47
|
+
sequenceLayout,
|
|
48
|
+
summarizeReconcile,
|
|
49
|
+
updateFragment,
|
|
50
|
+
updateMessage,
|
|
51
|
+
updateParticipant,
|
|
52
|
+
updateSqdSettings,
|
|
53
|
+
validateSequence,
|
|
54
|
+
zoomAt,
|
|
55
|
+
type SequenceDefinition,
|
|
56
|
+
type SqdFragmentKind,
|
|
57
|
+
type SqdValidationError,
|
|
58
|
+
type SqdMessage,
|
|
59
|
+
type StateMachineDefinition,
|
|
60
|
+
type Viewport,
|
|
61
|
+
} from "@miadi/stateloom-protocol";
|
|
62
|
+
import DocSwitcher from "@/components/DocSwitcher";
|
|
63
|
+
import IssueIcon from "@/components/IssueIcon";
|
|
64
|
+
import { NotesField } from "@/components/erd/ErdWorkspace";
|
|
65
|
+
import { FieldForm, panelButton as button, panelHeading as heading, type FormValues } from "@/components/forms";
|
|
66
|
+
import KindIcon from "@/components/system/KindIcon";
|
|
67
|
+
import { docQuery, useRequestedDoc } from "@/lib/docParam";
|
|
68
|
+
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
69
|
+
import { useSheetDrag } from "@/lib/useSheetDrag";
|
|
70
|
+
import { safely } from "@/lib/safely";
|
|
71
|
+
import { stripNote, useFocusRequest, useUrlParam, writeFocus } from "@/lib/systemParam";
|
|
72
|
+
import { memberOfDoc, notifySystemChanged, reconcileNow, systemVocabulary, useSystem } from "@/lib/systemLoad";
|
|
73
|
+
|
|
74
|
+
type Tab = "participants" | "messages" | "notes" | "issues";
|
|
75
|
+
type Edit = (def: SequenceDefinition) => SequenceDefinition;
|
|
76
|
+
|
|
77
|
+
const KINDS = ["", "actor", "service", "object"] as const;
|
|
78
|
+
|
|
79
|
+
const str = (v: string | boolean | undefined): string => (typeof v === "string" ? v.trim() : "");
|
|
80
|
+
const list = (v: string | boolean | undefined): string[] =>
|
|
81
|
+
str(v)
|
|
82
|
+
.split(",")
|
|
83
|
+
.map((s) => s.trim())
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
|
|
86
|
+
/** A message as the form hands it back: empty strings clear a field, `false` clears a flag. */
|
|
87
|
+
function messageFrom(v: FormValues): SqdMessage {
|
|
88
|
+
return {
|
|
89
|
+
from: str(v.from),
|
|
90
|
+
to: str(v.to),
|
|
91
|
+
label: str(v.label),
|
|
92
|
+
event: str(v.event),
|
|
93
|
+
machine: str(v.machine),
|
|
94
|
+
carries: str(v.carries),
|
|
95
|
+
state: str(v.state),
|
|
96
|
+
description: str(v.description),
|
|
97
|
+
optional: v.optional ? true : undefined,
|
|
98
|
+
reply: v.reply ? true : undefined,
|
|
99
|
+
} as SqdMessage;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default function SequenceWorkspace() {
|
|
103
|
+
const requested = useRequestedDoc();
|
|
104
|
+
const systemPath = useUrlParam("system");
|
|
105
|
+
const { system } = useSystem(systemPath);
|
|
106
|
+
const focusRequest = useFocusRequest();
|
|
107
|
+
const [docPath, setDocPath] = useState("");
|
|
108
|
+
const [def, setDef] = useState<SequenceDefinition | null>(null);
|
|
109
|
+
const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
|
|
110
|
+
const [fitKey, setFitKey] = useState<number | undefined>(undefined);
|
|
111
|
+
const [selection, setSelection] = useState<SequenceSelection | null>(null);
|
|
112
|
+
const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
|
|
113
|
+
const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
|
|
114
|
+
const [statusQuiet, setStatusQuiet] = useState(false);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const timer = setTimeout(() => setStatusQuiet(true), 4000);
|
|
117
|
+
return () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
setStatusQuiet(false);
|
|
120
|
+
};
|
|
121
|
+
}, [status]);
|
|
122
|
+
const [tab, setTab] = useState<Tab>("messages");
|
|
123
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
124
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
125
|
+
const [panelHidden, setPanelHidden] = useState(false);
|
|
126
|
+
const boardRef = useRef<HTMLDivElement | null>(null);
|
|
127
|
+
const sheet = useSheetDrag({ onClose: () => setSheetOpen(false), restVh: 62, tallVh: 92 });
|
|
128
|
+
const [peers, setPeers] = useState(0);
|
|
129
|
+
const sessionRef = useRef<BridgeSession | null>(null);
|
|
130
|
+
const firstView = useRef(true);
|
|
131
|
+
const appliedNonce = useRef(-1);
|
|
132
|
+
|
|
133
|
+
// Load the document, then join its room.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
let session: BridgeSession | null = null;
|
|
137
|
+
(async () => {
|
|
138
|
+
const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
|
|
139
|
+
if (cancelled) return;
|
|
140
|
+
if (body.error) return setStatus(body.error, "warn");
|
|
141
|
+
const path: string = body.path ?? "";
|
|
142
|
+
let loaded: SequenceDefinition | null = null;
|
|
143
|
+
try {
|
|
144
|
+
const parsed = body.content ? JSON.parse(body.content) : null;
|
|
145
|
+
if (isSequenceDefinition(parsed)) loaded = parsed;
|
|
146
|
+
} catch {
|
|
147
|
+
// Falls through to the message below.
|
|
148
|
+
}
|
|
149
|
+
if (body.exists && !loaded) return setStatus(`${path} is not a readable sequence definition`, "warn");
|
|
150
|
+
const name = (path.split("/").pop() ?? "Untitled").replace(/\.sqdf\.json$/i, "");
|
|
151
|
+
setDocPath(path);
|
|
152
|
+
setDef(loaded ?? emptySequence("Default", name));
|
|
153
|
+
setStatus(body.exists ? "loaded" : "new document — the first edit writes it");
|
|
154
|
+
|
|
155
|
+
const { bridgeUrl } = await loadRuntimeConfig();
|
|
156
|
+
if (cancelled || !bridgeUrl) return;
|
|
157
|
+
session = createBridgeSession({
|
|
158
|
+
url: bridgeUrl,
|
|
159
|
+
role: "web",
|
|
160
|
+
docId: path,
|
|
161
|
+
name: "web-designer",
|
|
162
|
+
onFull: (e) => {
|
|
163
|
+
const incoming: unknown = e.def;
|
|
164
|
+
if (!isSequenceDefinition(incoming)) return;
|
|
165
|
+
setDef(incoming);
|
|
166
|
+
setStatus("updated live");
|
|
167
|
+
},
|
|
168
|
+
// A sequence has no PatchOp vocabulary.
|
|
169
|
+
onPatch: () => {},
|
|
170
|
+
onPresence: (list) => setPeers(list.length),
|
|
171
|
+
});
|
|
172
|
+
sessionRef.current = session;
|
|
173
|
+
session.connect();
|
|
174
|
+
})().catch((e) => !cancelled && setStatus(String(e), "warn"));
|
|
175
|
+
return () => {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
sessionRef.current = null;
|
|
178
|
+
session?.disconnect();
|
|
179
|
+
};
|
|
180
|
+
}, [requested, setStatus]);
|
|
181
|
+
|
|
182
|
+
// What is drawn and listed: the document read defensively (a malformed file
|
|
183
|
+
// shows what it can). Edits still go through the protocol on `def` itself.
|
|
184
|
+
const view = useMemo(() => (def ? drawableSequence(def) : null), [def]);
|
|
185
|
+
const layout = useMemo(() => (view ? sequenceLayout(view) : null), [view]);
|
|
186
|
+
// A malformed document is reported as one issue, not a crash.
|
|
187
|
+
const problems = useMemo<SqdValidationError[]>(() => {
|
|
188
|
+
if (!def) return [];
|
|
189
|
+
let failure = "";
|
|
190
|
+
const found = safely<SqdValidationError[] | null>(() => validateSequence(def), null, (m) => (failure = m));
|
|
191
|
+
return found ?? [{ ruleId: "S000", message: `The document could not be checked (${failure}): a field is not the shape Spec 81 gives it` }];
|
|
192
|
+
}, [def]);
|
|
193
|
+
|
|
194
|
+
// The system, when this sequence is read as one of its members. The check
|
|
195
|
+
// runs on the definition in hand, so it follows an edit before the reload.
|
|
196
|
+
const member = memberOfDoc(system, docPath);
|
|
197
|
+
const systemCheck = useMemo(() => {
|
|
198
|
+
if (!system?.def || !member || !def) return null;
|
|
199
|
+
return safely(
|
|
200
|
+
() => checkSystem(system.def, system.members.map((m) => (m.path === member ? { ...m, def, error: undefined } : m))),
|
|
201
|
+
null,
|
|
202
|
+
);
|
|
203
|
+
}, [system, member, def]);
|
|
204
|
+
const memberIssues = useMemo(
|
|
205
|
+
() => (systemCheck?.issues ?? []).filter((i) => i.member === member && !/^S0/.test(i.ruleId)),
|
|
206
|
+
[systemCheck, member],
|
|
207
|
+
);
|
|
208
|
+
const replay = systemCheck?.replays.find((r) => r.member === member)?.report ?? null;
|
|
209
|
+
const vocabulary = useMemo(() => systemVocabulary(system), [system]);
|
|
210
|
+
|
|
211
|
+
// What the canvas outlines: checks name messages and participants as focus
|
|
212
|
+
// strings, except a duplicate participant, which S002 names bare.
|
|
213
|
+
const errorElements = useMemo(() => {
|
|
214
|
+
const asFocus = (e: string | undefined): string =>
|
|
215
|
+
!e ? "" : /^(message|participant):/.test(e) ? e : view?.participants.some((p) => p.name === e) ? `participant:${e}` : "";
|
|
216
|
+
return [
|
|
217
|
+
...problems.map((p) => asFocus(p.element)),
|
|
218
|
+
...memberIssues.filter((i) => i.severity === "error").map((i) => asFocus(i.element)),
|
|
219
|
+
].filter(Boolean);
|
|
220
|
+
}, [problems, memberIssues, view]);
|
|
221
|
+
const warningElements = useMemo(
|
|
222
|
+
() => memberIssues.filter((i) => i.severity === "warning").map((i) => i.element ?? ""),
|
|
223
|
+
[memberIssues],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const boardSize = (): { width: number; height: number } => {
|
|
227
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
228
|
+
return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** Centre an area of the drawing at a size its words can be read at. */
|
|
232
|
+
const centreOn = useCallback(
|
|
233
|
+
(box: { x: number; y: number; width: number; height: number }) => {
|
|
234
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
235
|
+
if (!rect?.width) return;
|
|
236
|
+
const fit = fitToBoxes([box], rect.width, rect.height, { padding: 24, maxScale: 1.1 }).scale;
|
|
237
|
+
const scale = clampScale(Math.max(fit, 0.7));
|
|
238
|
+
setViewport({
|
|
239
|
+
scale,
|
|
240
|
+
x: rect.width / 2 - (box.x + box.width / 2) * scale,
|
|
241
|
+
y: rect.height / 2 - (box.y + box.height / 2) * scale,
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
[],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const showSelection = useCallback(
|
|
248
|
+
(target: SequenceSelection) => {
|
|
249
|
+
if (!layout) return;
|
|
250
|
+
if (target.kind === "message") {
|
|
251
|
+
const row = layout.rows.find((r) => r.ref === target.id);
|
|
252
|
+
if (!row) return;
|
|
253
|
+
const left = row.self ? row.fromX : Math.min(row.fromX, row.toX);
|
|
254
|
+
const width = row.self ? 220 : Math.abs(row.toX - row.fromX);
|
|
255
|
+
centreOn({ x: left - 10, y: row.y - 24, width: width + 20, height: 40 });
|
|
256
|
+
} else {
|
|
257
|
+
const p = layout.participants.find((b) => b.name === target.id);
|
|
258
|
+
if (p) centreOn({ x: p.left - 20, y: p.top, width: p.width + 40, height: Math.min(260, layout.height - p.top) });
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
[layout, centreOn],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const select = useCallback((target: SequenceSelection | null) => {
|
|
265
|
+
setSelection(target);
|
|
266
|
+
if (target) writeFocus(`${target.kind}:${target.id}`);
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
// A focus from the URL, the strip or an agent: select it and bring it to the middle.
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (!def || !layout || appliedNonce.current === focusRequest.nonce) return;
|
|
272
|
+
appliedNonce.current = focusRequest.nonce;
|
|
273
|
+
const f = focusRequest.focus ? parseFocus(focusRequest.focus) : null;
|
|
274
|
+
if (!f) return;
|
|
275
|
+
let target: SequenceSelection | null = null;
|
|
276
|
+
if (f.kind === "message" && parseMessageRef(view ?? def, f.name)) target = { kind: "message", id: f.name };
|
|
277
|
+
if (f.kind === "participant" && (view ?? def).participants.some((p) => p.name === f.name)) target = { kind: "participant", id: f.name };
|
|
278
|
+
if (!target) return setStatus(`${focusRequest.focus} is not in this sequence`, "warn");
|
|
279
|
+
firstView.current = false;
|
|
280
|
+
setSelection(target);
|
|
281
|
+
showSelection(target);
|
|
282
|
+
}, [def, view, layout, focusRequest, showSelection, setStatus]);
|
|
283
|
+
|
|
284
|
+
// The first view: the whole drawing when it can be read that way, else its
|
|
285
|
+
// top-left corner at a readable size (a phone showing seven lifelines at a
|
|
286
|
+
// quarter of their size shows nothing).
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!layout || !firstView.current) return;
|
|
289
|
+
const { width, height } = boardSize();
|
|
290
|
+
if (!width) return;
|
|
291
|
+
firstView.current = false;
|
|
292
|
+
const fit = Math.min((width - 32) / layout.width, (height - 32) / layout.height);
|
|
293
|
+
if (fit >= 0.5 || layout.participants.length === 0) setFitKey((k) => (k ?? 0) + 1);
|
|
294
|
+
else setViewport({ x: 8, y: 8, scale: 0.6 });
|
|
295
|
+
}, [layout]);
|
|
296
|
+
|
|
297
|
+
const apply = useCallback(
|
|
298
|
+
async (edit: Edit) => {
|
|
299
|
+
if (!def || !docPath) return;
|
|
300
|
+
let next: SequenceDefinition;
|
|
301
|
+
try {
|
|
302
|
+
next = edit(def);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
return setStatus(e instanceof Error ? e.message : String(e), "warn");
|
|
305
|
+
}
|
|
306
|
+
setDef(next);
|
|
307
|
+
try {
|
|
308
|
+
const r = await fetch(`/api/file${docQuery(docPath)}`, {
|
|
309
|
+
method: "PUT",
|
|
310
|
+
headers: { "content-type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ content: JSON.stringify(next, null, 2) + "\n" }),
|
|
312
|
+
});
|
|
313
|
+
if (!r.ok) throw new Error((await r.json())?.error ?? `HTTP ${r.status}`);
|
|
314
|
+
sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
|
|
315
|
+
setStatus("saved");
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`, "warn");
|
|
318
|
+
}
|
|
319
|
+
if (!system?.def || !member) return;
|
|
320
|
+
// Inside a system in `auto` mode, what the scenario now implies is written
|
|
321
|
+
// into the machines and the data straight away.
|
|
322
|
+
// It reads every member fresh from disk first, and builds on an agent's
|
|
323
|
+
// concurrent edit rather than writing over it.
|
|
324
|
+
if (reconcileModeOf(system.def) === "auto") {
|
|
325
|
+
const { result, failed, conflicts } = await reconcileNow(system.path);
|
|
326
|
+
if (result && result.count > 0) {
|
|
327
|
+
stripNote(
|
|
328
|
+
failed.length
|
|
329
|
+
? `auto: not written — ${failed[0]}`
|
|
330
|
+
: conflicts.length
|
|
331
|
+
? `auto: ${conflicts.join(", ")} kept changing on disk; not written — save again`
|
|
332
|
+
: summarizeReconcile(result, "auto"),
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
notifySystemChanged();
|
|
338
|
+
},
|
|
339
|
+
[def, docPath, setStatus, system, member],
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const zoomBy = (factor: number): void => {
|
|
343
|
+
const { width, height } = boardSize();
|
|
344
|
+
setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const openTab = (next: Tab): void => {
|
|
348
|
+
if (sheetOpen && tab === next) sheet.close();
|
|
349
|
+
else setSheetOpen(true);
|
|
350
|
+
setTab(next);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const copyMermaid = (): void => {
|
|
354
|
+
if (def) navigator.clipboard?.writeText(renderMermaidSequence(def)).then(() => setStatus("mermaid copied"));
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/** Select an element named by a check, and show it. */
|
|
358
|
+
const goTo = (element: string | undefined): void => {
|
|
359
|
+
const f = element ? parseFocus(element) : null;
|
|
360
|
+
if (!f || (f.kind !== "message" && f.kind !== "participant")) return;
|
|
361
|
+
const target: SequenceSelection = { kind: f.kind, id: f.name };
|
|
362
|
+
select(target);
|
|
363
|
+
showSelection(target);
|
|
364
|
+
sheet.close();
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const sd = view ?? emptySequence("", "");
|
|
368
|
+
const messages = allMessages(sd);
|
|
369
|
+
const selectedMessage =
|
|
370
|
+
def && selection?.kind === "message" ? parseMessageRef(sd, selection.id) : null;
|
|
371
|
+
const selectedParticipant =
|
|
372
|
+
def && selection?.kind === "participant" ? sd.participants.find((p) => p.name === selection.id) ?? null : null;
|
|
373
|
+
const names = sd.participants.map((p) => p.name);
|
|
374
|
+
const fileName = docPath.split("/").pop() || "…";
|
|
375
|
+
const problemCount = problems.length + memberIssues.filter((i) => i.severity === "error").length;
|
|
376
|
+
const warningCount = memberIssues.filter((i) => i.severity === "warning").length;
|
|
377
|
+
const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
|
|
378
|
+
{ id: "participants", label: "Participants", icon: <KindIcon kind="actor" /> },
|
|
379
|
+
{ id: "messages", label: "Messages", icon: <KindIcon kind="sequence" /> },
|
|
380
|
+
{ id: "notes", label: "Notes", icon: "✎" },
|
|
381
|
+
{ id: "issues", label: "Issues", icon: <IssueIcon />, badge: problemCount + warningCount || undefined },
|
|
382
|
+
];
|
|
383
|
+
const messageFields = (m?: SqdMessage) => [
|
|
384
|
+
{ name: "from", label: "from", options: names, initial: m?.from ?? names[0] ?? "" },
|
|
385
|
+
{ name: "to", label: "to", options: names, initial: m?.to ?? names[1] ?? names[0] ?? "" },
|
|
386
|
+
{ name: "label", label: "label", placeholder: "what is said or done", initial: m?.label ?? "", wide: true },
|
|
387
|
+
{ name: "event", label: "event it fires", suggest: vocabulary.events, initial: m?.event ?? "" },
|
|
388
|
+
{ name: "state", label: "state after", suggest: vocabulary.states, initial: m?.state ?? "" },
|
|
389
|
+
{ name: "carries", label: "entity it carries", suggest: vocabulary.entities, initial: m?.carries ?? "" },
|
|
390
|
+
{ name: "machine", label: "machine", suggest: vocabulary.machines, initial: m?.machine ?? "" },
|
|
391
|
+
{ name: "optional", label: "optional", checkbox: true, initial: !!m?.optional },
|
|
392
|
+
{ name: "reply", label: "reply (dashed)", checkbox: true, initial: !!m?.reply },
|
|
393
|
+
];
|
|
394
|
+
const participantKind = (p: { actor?: string; service?: string; object?: string }) =>
|
|
395
|
+
p.actor ? "actor" : p.object ? "object" : p.service ? "service" : "";
|
|
396
|
+
const kindPatch = (kind: string, value: string) => ({
|
|
397
|
+
actor: kind === "actor" ? value : "",
|
|
398
|
+
service: kind === "service" ? value : "",
|
|
399
|
+
object: kind === "object" ? value : "",
|
|
400
|
+
});
|
|
401
|
+
const where = [
|
|
402
|
+
"main: at the end",
|
|
403
|
+
...(selectedMessage && selectedMessage.fragment === null ? [`main: after ${selectedMessage.index + 1}`] : []),
|
|
404
|
+
...(def?.fragments ?? []).map((f, i) => `fragment ${i + 1}: ${f.kind} · ${f.label}`),
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
return (
|
|
408
|
+
<div
|
|
409
|
+
className="app-shell dock-offset flex flex-col overflow-hidden bg-gray-950 text-gray-200"
|
|
410
|
+
onClick={() => menuOpen && setMenuOpen(false)}
|
|
411
|
+
>
|
|
412
|
+
<header className="relative z-40 flex items-center gap-2 border-b border-gray-800 bg-gray-900 px-2 py-1.5 text-xs md:px-3 md:py-2">
|
|
413
|
+
<span className="flex items-center gap-1 font-semibold text-blue-300">
|
|
414
|
+
<KindIcon kind="sequence" size={14} />
|
|
415
|
+
SEQ
|
|
416
|
+
</span>
|
|
417
|
+
<DocSwitcher />
|
|
418
|
+
<span className="min-w-0 flex-1 truncate text-gray-400" title={docPath}>
|
|
419
|
+
{def?.settings.name ?? fileName}
|
|
420
|
+
<span className="hidden text-gray-600 lg:inline"> · {fileName}</span>
|
|
421
|
+
</span>
|
|
422
|
+
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
|
423
|
+
<button className={button} onClick={copyMermaid}>
|
|
424
|
+
Copy mermaid
|
|
425
|
+
</button>
|
|
426
|
+
<button
|
|
427
|
+
className={button}
|
|
428
|
+
onClick={() => setPanelHidden((h) => !h)}
|
|
429
|
+
aria-pressed={!panelHidden}
|
|
430
|
+
title={panelHidden ? "Show the panel" : "Hide the panel — the board takes the full width"}
|
|
431
|
+
>
|
|
432
|
+
{panelHidden ? "⟨ Panel" : "Panel ⟩"}
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
<div className="relative shrink-0 md:hidden">
|
|
436
|
+
<button
|
|
437
|
+
className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm leading-none text-gray-200"
|
|
438
|
+
aria-label="More actions"
|
|
439
|
+
aria-expanded={menuOpen}
|
|
440
|
+
onClick={(e) => {
|
|
441
|
+
e.stopPropagation();
|
|
442
|
+
setMenuOpen((o) => !o);
|
|
443
|
+
}}
|
|
444
|
+
>
|
|
445
|
+
⋯
|
|
446
|
+
</button>
|
|
447
|
+
{menuOpen && (
|
|
448
|
+
<div className="absolute right-0 top-full z-50 mt-1 w-56 overflow-hidden rounded-lg border border-gray-700 bg-gray-900 py-1 shadow-xl">
|
|
449
|
+
<button
|
|
450
|
+
className="block w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-gray-800"
|
|
451
|
+
onClick={() => {
|
|
452
|
+
setMenuOpen(false);
|
|
453
|
+
copyMermaid();
|
|
454
|
+
}}
|
|
455
|
+
>
|
|
456
|
+
Copy as mermaid
|
|
457
|
+
</button>
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
</header>
|
|
462
|
+
|
|
463
|
+
<div className="relative flex min-h-0 flex-1">
|
|
464
|
+
<div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden">
|
|
465
|
+
{def && layout && (
|
|
466
|
+
<BoardBoundary what="sequence" resetKey={def}>
|
|
467
|
+
<SequenceCanvas
|
|
468
|
+
definition={view!}
|
|
469
|
+
layout={layout}
|
|
470
|
+
viewport={viewport}
|
|
471
|
+
onViewportChange={setViewport}
|
|
472
|
+
selection={selection}
|
|
473
|
+
errorElements={errorElements}
|
|
474
|
+
warningElements={warningElements}
|
|
475
|
+
fitKey={fitKey}
|
|
476
|
+
onSelect={(kind, id) => select({ kind, id })}
|
|
477
|
+
onClearSelection={() => setSelection(null)}
|
|
478
|
+
emptyHint="No participants yet — add them in the panel, or let an agent write the scenario."
|
|
479
|
+
/>
|
|
480
|
+
</BoardBoundary>
|
|
481
|
+
)}
|
|
482
|
+
|
|
483
|
+
<div
|
|
484
|
+
className={`pointer-events-none absolute left-2 top-2 flex max-w-[75%] items-center gap-1.5 rounded bg-gray-950/70 px-1.5 py-0.5 text-[11px] ${
|
|
485
|
+
status.tone === "warn" ? "text-amber-300" : "text-gray-500"
|
|
486
|
+
}`}
|
|
487
|
+
role="status"
|
|
488
|
+
>
|
|
489
|
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${status.tone === "warn" ? "bg-amber-400" : "bg-emerald-500"}`} />
|
|
490
|
+
<span className={`truncate transition-opacity duration-500 ${statusQuiet && status.tone === "ok" ? "opacity-0" : "opacity-100"}`}>
|
|
491
|
+
{status.text}
|
|
492
|
+
{peers > 1 ? ` · ${peers} in the room` : ""}
|
|
493
|
+
</span>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<div className="absolute bottom-3 right-3 flex flex-col overflow-hidden rounded-lg border border-gray-700 bg-gray-900/90 shadow-lg backdrop-blur">
|
|
497
|
+
{(
|
|
498
|
+
[
|
|
499
|
+
["+", "Zoom in", () => zoomBy(1.25)],
|
|
500
|
+
["−", "Zoom out", () => zoomBy(0.8)],
|
|
501
|
+
["⛶", "Fit the whole diagram", () => setFitKey((k) => (k ?? 0) + 1)],
|
|
502
|
+
] as const
|
|
503
|
+
).map(([glyph, title, run]) => (
|
|
504
|
+
<button
|
|
505
|
+
key={title}
|
|
506
|
+
title={title}
|
|
507
|
+
aria-label={title}
|
|
508
|
+
onClick={run}
|
|
509
|
+
className="h-10 w-10 border-b border-gray-800 text-base leading-none text-gray-200 last:border-b-0 hover:bg-gray-800 md:h-8 md:w-8 md:text-sm"
|
|
510
|
+
>
|
|
511
|
+
{glyph}
|
|
512
|
+
</button>
|
|
513
|
+
))}
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
{selection && !sheetOpen && (
|
|
517
|
+
<button
|
|
518
|
+
className="absolute bottom-3 left-1/2 max-w-[60%] -translate-x-1/2 truncate rounded-full border border-blue-500/60 bg-gray-900/95 px-4 py-2 text-sm text-blue-200 shadow-lg md:hidden"
|
|
519
|
+
onClick={() => {
|
|
520
|
+
setTab(selection.kind === "message" ? "messages" : "participants");
|
|
521
|
+
setSheetOpen(true);
|
|
522
|
+
}}
|
|
523
|
+
>
|
|
524
|
+
Edit {selection.kind === "message" ? `${selection.id}. ${selectedMessage?.message.label ?? ""}` : selection.id}
|
|
525
|
+
</button>
|
|
526
|
+
)}
|
|
527
|
+
</div>
|
|
528
|
+
|
|
529
|
+
{sheetOpen && <div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={sheet.close} aria-hidden="true" />}
|
|
530
|
+
|
|
531
|
+
{def && (
|
|
532
|
+
<aside
|
|
533
|
+
style={sheet.sheetStyle}
|
|
534
|
+
className={`safe-x fixed inset-x-0 bottom-0 z-30 flex h-[var(--sheet-h)] flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-[transform,height] duration-200 ease-out ${
|
|
535
|
+
sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
|
|
536
|
+
} md:pointer-events-auto md:static md:z-auto md:h-auto md:w-80 md:translate-y-0 md:rounded-none md:border-l md:border-t-0 md:shadow-none md:transition-none ${
|
|
537
|
+
panelHidden ? "md:hidden" : ""
|
|
538
|
+
}`}
|
|
539
|
+
>
|
|
540
|
+
<div className="md:hidden" {...sheet.zoneProps}>
|
|
541
|
+
<div className="flex justify-center pb-1 pt-2">
|
|
542
|
+
<span className="h-1.5 w-12 rounded-full bg-gray-600" />
|
|
543
|
+
</div>
|
|
544
|
+
<div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
|
|
545
|
+
<h2 className="text-sm font-semibold text-gray-300">{tabs.find((t) => t.id === tab)?.label}</h2>
|
|
546
|
+
<button onClick={sheet.close} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
|
|
547
|
+
✕
|
|
548
|
+
</button>
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
{/* Desktop: the panel's own tabs. */}
|
|
553
|
+
<div className="hidden border-b border-gray-800 md:flex">
|
|
554
|
+
{tabs.map((t) => (
|
|
555
|
+
<button
|
|
556
|
+
key={t.id}
|
|
557
|
+
onClick={() => setTab(t.id)}
|
|
558
|
+
className={`relative flex-1 px-1 py-2 text-center text-xs transition-colors ${
|
|
559
|
+
tab === t.id ? "border-b-2 border-blue-400 bg-gray-800/50 text-blue-400" : "text-gray-500 hover:text-gray-300"
|
|
560
|
+
}`}
|
|
561
|
+
>
|
|
562
|
+
{t.label}
|
|
563
|
+
{t.badge !== undefined && (
|
|
564
|
+
<span className="absolute right-1 top-1 min-w-[14px] rounded-full bg-red-900 px-1 text-[9px] text-red-100">{t.badge}</span>
|
|
565
|
+
)}
|
|
566
|
+
</button>
|
|
567
|
+
))}
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
<div className="touch-targets dock-offset flex-1 overflow-y-auto overscroll-contain p-3">
|
|
571
|
+
<BoardBoundary what="panel" resetKey={def}>
|
|
572
|
+
{/* ── Participants ─────────────────────────────────────────── */}
|
|
573
|
+
<section className={tab === "participants" ? "" : "hidden"}>
|
|
574
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
575
|
+
Participants
|
|
576
|
+
</div>
|
|
577
|
+
<div className="flex flex-wrap gap-1">
|
|
578
|
+
{sd.participants.map((p) => (
|
|
579
|
+
<button
|
|
580
|
+
key={p.name}
|
|
581
|
+
className={`${button} ${selection?.kind === "participant" && selection.id === p.name ? "border-blue-500 text-blue-300" : ""}`}
|
|
582
|
+
onClick={() => {
|
|
583
|
+
select({ kind: "participant", id: p.name });
|
|
584
|
+
showSelection({ kind: "participant", id: p.name });
|
|
585
|
+
}}
|
|
586
|
+
title={participantKind(p) || "participant"}
|
|
587
|
+
>
|
|
588
|
+
{p.name}
|
|
589
|
+
{participantKind(p) && <span className="text-gray-500"> · {participantKind(p)}</span>}
|
|
590
|
+
</button>
|
|
591
|
+
))}
|
|
592
|
+
</div>
|
|
593
|
+
<FieldForm
|
|
594
|
+
id="add-participant"
|
|
595
|
+
reset
|
|
596
|
+
required={["name"]}
|
|
597
|
+
fields={[
|
|
598
|
+
{ name: "name", placeholder: "new participant" },
|
|
599
|
+
{ name: "kind", options: KINDS, label: "kind" },
|
|
600
|
+
{ name: "value", placeholder: "actor / service / object", suggest: [...vocabulary.actors, ...vocabulary.objects] },
|
|
601
|
+
{ name: "holds", placeholder: "holds: Entity, Entity", suggest: vocabulary.entities },
|
|
602
|
+
]}
|
|
603
|
+
submit="+ Participant"
|
|
604
|
+
onSubmit={(v) => {
|
|
605
|
+
const name = str(v.name);
|
|
606
|
+
const kind = str(v.kind);
|
|
607
|
+
apply((d) =>
|
|
608
|
+
addParticipant(d, {
|
|
609
|
+
name,
|
|
610
|
+
...(kind ? { [kind]: str(v.value) || name } : {}),
|
|
611
|
+
holds: list(v.holds),
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
select({ kind: "participant", id: name });
|
|
615
|
+
}}
|
|
616
|
+
/>
|
|
617
|
+
|
|
618
|
+
{selectedParticipant && (
|
|
619
|
+
<>
|
|
620
|
+
<div className={`${heading} flex items-center justify-between gap-2`}>
|
|
621
|
+
<span className="truncate text-sm normal-case tracking-normal text-gray-200">{selectedParticipant.name}</span>
|
|
622
|
+
<button className={`${button} normal-case tracking-normal`} onClick={() => showSelection({ kind: "participant", id: selectedParticipant.name })}>
|
|
623
|
+
◎ Show on board
|
|
624
|
+
</button>
|
|
625
|
+
</div>
|
|
626
|
+
{/* Where it stands: participants are the columns, left to right. */}
|
|
627
|
+
{(() => {
|
|
628
|
+
const at = sd.participants.findIndex((p) => p.name === selectedParticipant.name);
|
|
629
|
+
const last = sd.participants.length - 1;
|
|
630
|
+
const move = (to: number) => apply((d) => moveParticipant(d, selectedParticipant.name, to));
|
|
631
|
+
return (
|
|
632
|
+
<div className="mb-1 flex items-center gap-1 text-[11px] text-gray-400">
|
|
633
|
+
<button className={`${button} disabled:opacity-40`} disabled={at <= 0} onClick={() => move(at - 1)} aria-label="Move one column left">
|
|
634
|
+
← Left
|
|
635
|
+
</button>
|
|
636
|
+
<span className="flex-1 text-center">
|
|
637
|
+
column {at + 1} of {last + 1}
|
|
638
|
+
</span>
|
|
639
|
+
<button className={`${button} disabled:opacity-40`} disabled={at >= last} onClick={() => move(at + 1)} aria-label="Move one column right">
|
|
640
|
+
Right →
|
|
641
|
+
</button>
|
|
642
|
+
</div>
|
|
643
|
+
);
|
|
644
|
+
})()}
|
|
645
|
+
<FieldForm
|
|
646
|
+
key={`rename:${selectedParticipant.name}`}
|
|
647
|
+
id="rename-participant"
|
|
648
|
+
required={["to"]}
|
|
649
|
+
fields={[{ name: "to", label: "new name", initial: selectedParticipant.name }]}
|
|
650
|
+
submit="Rename (every message follows)"
|
|
651
|
+
onSubmit={(v) => {
|
|
652
|
+
const to = str(v.to);
|
|
653
|
+
if (to === selectedParticipant.name) return;
|
|
654
|
+
apply((d) => renameParticipant(d, selectedParticipant.name, to));
|
|
655
|
+
select({ kind: "participant", id: to });
|
|
656
|
+
}}
|
|
657
|
+
/>
|
|
658
|
+
<FieldForm
|
|
659
|
+
key={`p:${JSON.stringify(selectedParticipant)}`}
|
|
660
|
+
id="edit-participant"
|
|
661
|
+
fields={[
|
|
662
|
+
{ name: "kind", options: KINDS, label: "kind", initial: participantKind(selectedParticipant) },
|
|
663
|
+
{
|
|
664
|
+
name: "value",
|
|
665
|
+
placeholder: "actor / service / object",
|
|
666
|
+
suggest: [...vocabulary.actors, ...vocabulary.objects],
|
|
667
|
+
initial: selectedParticipant.actor ?? selectedParticipant.object ?? selectedParticipant.service ?? "",
|
|
668
|
+
},
|
|
669
|
+
{ name: "holds", placeholder: "holds: Entity, Entity", suggest: vocabulary.entities, initial: (selectedParticipant.holds ?? []).join(", "), wide: true },
|
|
670
|
+
{ name: "description", placeholder: "description", initial: selectedParticipant.description ?? "", wide: true },
|
|
671
|
+
]}
|
|
672
|
+
submit="Save participant"
|
|
673
|
+
onSubmit={(v) =>
|
|
674
|
+
apply((d) =>
|
|
675
|
+
updateParticipant(d, selectedParticipant.name, {
|
|
676
|
+
...kindPatch(str(v.kind), str(v.value) || selectedParticipant.name),
|
|
677
|
+
holds: list(v.holds),
|
|
678
|
+
description: str(v.description),
|
|
679
|
+
}),
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
/>
|
|
683
|
+
<button
|
|
684
|
+
className={`${button} mt-2 text-red-300`}
|
|
685
|
+
onClick={() => {
|
|
686
|
+
apply((d) => removeParticipant(d, selectedParticipant.name));
|
|
687
|
+
setSelection(null);
|
|
688
|
+
writeFocus(null);
|
|
689
|
+
}}
|
|
690
|
+
title="Also removes every message it sends or receives"
|
|
691
|
+
>
|
|
692
|
+
Delete {selectedParticipant.name} and its messages
|
|
693
|
+
</button>
|
|
694
|
+
</>
|
|
695
|
+
)}
|
|
696
|
+
</section>
|
|
697
|
+
|
|
698
|
+
{/* ── Messages ─────────────────────────────────────────────── */}
|
|
699
|
+
<section className={tab === "messages" ? "" : "hidden"}>
|
|
700
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
701
|
+
Messages
|
|
702
|
+
</div>
|
|
703
|
+
{messages.length === 0 && <div className="py-0.5 text-[11px] text-gray-600">None yet.</div>}
|
|
704
|
+
<div className="flex flex-col">
|
|
705
|
+
{messages.map(({ ref, message: m, fragment }, i) => {
|
|
706
|
+
const startsFragment = fragment !== null && (i === 0 || messages[i - 1].fragment !== fragment);
|
|
707
|
+
const f = fragment !== null ? sd.fragments?.[fragment] : undefined;
|
|
708
|
+
const isSelected = selection?.kind === "message" && selection.id === ref;
|
|
709
|
+
return (
|
|
710
|
+
<div key={ref}>
|
|
711
|
+
{startsFragment && f && (
|
|
712
|
+
<div className="mt-2 border-t border-gray-800 pt-1 text-[11px] text-indigo-300">
|
|
713
|
+
<span className="font-semibold">{f.kind}</span> · after {f.after} · {f.label}
|
|
714
|
+
</div>
|
|
715
|
+
)}
|
|
716
|
+
<button
|
|
717
|
+
className={`block w-full truncate rounded px-1 py-0.5 text-left text-[11px] ${
|
|
718
|
+
isSelected ? "bg-blue-950/60 text-blue-200" : "text-gray-300 hover:bg-gray-800"
|
|
719
|
+
}`}
|
|
720
|
+
onClick={() => {
|
|
721
|
+
select({ kind: "message", id: ref });
|
|
722
|
+
showSelection({ kind: "message", id: ref });
|
|
723
|
+
}}
|
|
724
|
+
title={`${m.from} → ${m.to}: ${m.label}`}
|
|
725
|
+
>
|
|
726
|
+
<span className="text-gray-500">{ref}.</span> {m.from} → {m.to}: {m.label}
|
|
727
|
+
{m.event ? <span className="text-blue-300"> [{m.event}]</span> : null}
|
|
728
|
+
{m.state ? <span className="text-emerald-400"> → {m.state}</span> : null}
|
|
729
|
+
{m.optional ? <span className="text-gray-500"> opt</span> : null}
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
);
|
|
733
|
+
})}
|
|
734
|
+
</div>
|
|
735
|
+
|
|
736
|
+
{selectedMessage && (
|
|
737
|
+
<>
|
|
738
|
+
<div className={`${heading} flex items-center justify-between gap-2`}>
|
|
739
|
+
<span className="truncate text-sm normal-case tracking-normal text-gray-200">Message {selection!.id}</span>
|
|
740
|
+
<span className="flex gap-1 normal-case tracking-normal">
|
|
741
|
+
{selectedMessage.fragment === null && (
|
|
742
|
+
<>
|
|
743
|
+
<button
|
|
744
|
+
className={button}
|
|
745
|
+
aria-label="Move up"
|
|
746
|
+
title="Move up"
|
|
747
|
+
disabled={selectedMessage.index === 0}
|
|
748
|
+
onClick={() => {
|
|
749
|
+
apply((d) => moveMessage(d, selectedMessage.index, selectedMessage.index - 1));
|
|
750
|
+
select({ kind: "message", id: String(selectedMessage.index) });
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
↑
|
|
754
|
+
</button>
|
|
755
|
+
<button
|
|
756
|
+
className={button}
|
|
757
|
+
aria-label="Move down"
|
|
758
|
+
title="Move down"
|
|
759
|
+
disabled={selectedMessage.index >= sd.messages.length - 1}
|
|
760
|
+
onClick={() => {
|
|
761
|
+
apply((d) => moveMessage(d, selectedMessage.index, selectedMessage.index + 1));
|
|
762
|
+
select({ kind: "message", id: String(selectedMessage.index + 2) });
|
|
763
|
+
}}
|
|
764
|
+
>
|
|
765
|
+
↓
|
|
766
|
+
</button>
|
|
767
|
+
</>
|
|
768
|
+
)}
|
|
769
|
+
<button className={button} onClick={() => showSelection(selection!)}>
|
|
770
|
+
◎ Show
|
|
771
|
+
</button>
|
|
772
|
+
</span>
|
|
773
|
+
</div>
|
|
774
|
+
<FieldForm
|
|
775
|
+
key={`m:${selection!.id}:${JSON.stringify(selectedMessage.message)}`}
|
|
776
|
+
id="edit-message"
|
|
777
|
+
required={["label"]}
|
|
778
|
+
fields={[
|
|
779
|
+
...messageFields(selectedMessage.message),
|
|
780
|
+
{ name: "description", placeholder: "description", initial: selectedMessage.message.description ?? "", wide: true },
|
|
781
|
+
]}
|
|
782
|
+
submit="Save message"
|
|
783
|
+
onSubmit={(v) =>
|
|
784
|
+
apply((d) => updateMessage(d, selectedMessage.index, messageFrom(v), selectedMessage.fragment))
|
|
785
|
+
}
|
|
786
|
+
/>
|
|
787
|
+
<button
|
|
788
|
+
className={`${button} mt-2 text-red-300`}
|
|
789
|
+
onClick={() => {
|
|
790
|
+
apply((d) => removeMessage(d, selectedMessage.index, selectedMessage.fragment));
|
|
791
|
+
setSelection(null);
|
|
792
|
+
writeFocus(null);
|
|
793
|
+
}}
|
|
794
|
+
>
|
|
795
|
+
Delete message {selection!.id}
|
|
796
|
+
</button>
|
|
797
|
+
</>
|
|
798
|
+
)}
|
|
799
|
+
|
|
800
|
+
<div className={heading}>Add a message</div>
|
|
801
|
+
<FieldForm
|
|
802
|
+
key={`add:${where.join("|")}:${names.join("|")}`}
|
|
803
|
+
id="add-message"
|
|
804
|
+
reset
|
|
805
|
+
required={["label"]}
|
|
806
|
+
fields={[{ name: "where", options: where, label: "where", wide: true }, ...messageFields()]}
|
|
807
|
+
submit="+ Message"
|
|
808
|
+
onSubmit={(v) => {
|
|
809
|
+
const at = str(v.where);
|
|
810
|
+
const message = messageFrom(v);
|
|
811
|
+
const frag = /^fragment (\d+):/.exec(at);
|
|
812
|
+
const after = /^main: after (\d+)$/.exec(at);
|
|
813
|
+
if (frag) apply((d) => addFragmentMessage(d, Number(frag[1]) - 1, message));
|
|
814
|
+
else if (after) apply((d) => addMessage(d, message, Number(after[1])));
|
|
815
|
+
else apply((d) => addMessage(d, message));
|
|
816
|
+
}}
|
|
817
|
+
/>
|
|
818
|
+
|
|
819
|
+
<div className={heading}>Fragments</div>
|
|
820
|
+
{(sd.fragments ?? []).length === 0 && <div className="py-0.5 text-[11px] text-gray-600">None.</div>}
|
|
821
|
+
{(sd.fragments ?? []).map((f, i) => (
|
|
822
|
+
<div key={`${i}:${JSON.stringify({ ...f, messages: undefined })}`} className="mb-2 rounded border border-gray-800 p-1.5">
|
|
823
|
+
<div className="flex items-center justify-between text-[11px] text-indigo-300">
|
|
824
|
+
<span>
|
|
825
|
+
{i + 1}. <span className="font-semibold">{f.kind}</span> · after {f.after} · {f.messages.length} message(s)
|
|
826
|
+
</span>
|
|
827
|
+
<button className="text-gray-500 hover:text-red-400" aria-label={`Remove fragment ${i + 1}`} onClick={() => apply((d) => removeFragment(d, i))}>
|
|
828
|
+
✕
|
|
829
|
+
</button>
|
|
830
|
+
</div>
|
|
831
|
+
<FieldForm
|
|
832
|
+
id={`edit-fragment-${i}`}
|
|
833
|
+
fields={[
|
|
834
|
+
{ name: "kind", options: SQD_FRAGMENT_KINDS, initial: f.kind },
|
|
835
|
+
{ name: "after", placeholder: "after message", initial: String(f.after) },
|
|
836
|
+
{ name: "label", placeholder: "the condition, in words", initial: f.label, wide: true },
|
|
837
|
+
]}
|
|
838
|
+
submit="Save fragment"
|
|
839
|
+
onSubmit={(v) =>
|
|
840
|
+
apply((d) => updateFragment(d, i, { kind: str(v.kind) as SqdFragmentKind, after: Number(str(v.after)), label: str(v.label) }))
|
|
841
|
+
}
|
|
842
|
+
/>
|
|
843
|
+
</div>
|
|
844
|
+
))}
|
|
845
|
+
<FieldForm
|
|
846
|
+
key={`add-fragment:${sd.messages.length}:${selectedMessage?.index ?? ""}`}
|
|
847
|
+
id="add-fragment"
|
|
848
|
+
reset
|
|
849
|
+
required={["label"]}
|
|
850
|
+
fields={[
|
|
851
|
+
{ name: "kind", options: SQD_FRAGMENT_KINDS },
|
|
852
|
+
{
|
|
853
|
+
name: "after",
|
|
854
|
+
placeholder: "after message",
|
|
855
|
+
initial: String(selectedMessage && selectedMessage.fragment === null ? selectedMessage.index + 1 : sd.messages.length),
|
|
856
|
+
},
|
|
857
|
+
{ name: "label", placeholder: "the condition, in words", wide: true },
|
|
858
|
+
{ name: "from", options: names, label: "first message from" },
|
|
859
|
+
{ name: "to", options: names, label: "to", initial: names[1] ?? names[0] ?? "" },
|
|
860
|
+
{ name: "first", placeholder: "first message label (optional)", wide: true },
|
|
861
|
+
]}
|
|
862
|
+
submit="+ Fragment"
|
|
863
|
+
onSubmit={(v) =>
|
|
864
|
+
apply((d) =>
|
|
865
|
+
addFragment(d, {
|
|
866
|
+
kind: str(v.kind) as SqdFragmentKind,
|
|
867
|
+
after: Number(str(v.after)),
|
|
868
|
+
label: str(v.label),
|
|
869
|
+
messages: str(v.first) ? [{ from: str(v.from), to: str(v.to), label: str(v.first) }] : [],
|
|
870
|
+
}),
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
/>
|
|
874
|
+
</section>
|
|
875
|
+
|
|
876
|
+
{/* ── Notes ────────────────────────────────────────────────── */}
|
|
877
|
+
<section className={tab === "notes" ? "" : "hidden"}>
|
|
878
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
879
|
+
Notes
|
|
880
|
+
</div>
|
|
881
|
+
<p className="mb-1 text-[11px] text-gray-600">
|
|
882
|
+
Saved in the document, for whoever opens it next — you, or an agent.
|
|
883
|
+
</p>
|
|
884
|
+
<NotesField
|
|
885
|
+
key="sequence"
|
|
886
|
+
label="On this sequence"
|
|
887
|
+
value={def.settings.notes ?? ""}
|
|
888
|
+
placeholder="What was discussed, what is still open…"
|
|
889
|
+
onSave={(notes) => apply((d) => updateSqdSettings(d, { notes }))}
|
|
890
|
+
/>
|
|
891
|
+
{selectedParticipant && (
|
|
892
|
+
<NotesField
|
|
893
|
+
key={`participant:${selectedParticipant.name}`}
|
|
894
|
+
label={`On ${selectedParticipant.name}`}
|
|
895
|
+
value={selectedParticipant.notes ?? ""}
|
|
896
|
+
placeholder={`A question or a decision about ${selectedParticipant.name}…`}
|
|
897
|
+
onSave={(notes) => apply((d) => updateParticipant(d, selectedParticipant.name, { notes }))}
|
|
898
|
+
/>
|
|
899
|
+
)}
|
|
900
|
+
{selectedMessage && (
|
|
901
|
+
<NotesField
|
|
902
|
+
key={`message:${selection!.id}`}
|
|
903
|
+
label={`On message ${selection!.id}`}
|
|
904
|
+
value={selectedMessage.message.notes ?? ""}
|
|
905
|
+
placeholder="A question or a decision about this message…"
|
|
906
|
+
onSave={(notes) => apply((d) => updateMessage(d, selectedMessage.index, { notes }, selectedMessage.fragment))}
|
|
907
|
+
/>
|
|
908
|
+
)}
|
|
909
|
+
{!selection && <p className="mt-2 text-[11px] text-gray-600">Select a participant or a message to leave a note on it.</p>}
|
|
910
|
+
{[...sd.participants.filter((p) => p.notes?.trim()), ...messages.filter((m) => m.message.notes?.trim())].length > 0 && (
|
|
911
|
+
<>
|
|
912
|
+
<div className={heading}>With notes</div>
|
|
913
|
+
<div className="flex flex-wrap gap-1">
|
|
914
|
+
{sd.participants
|
|
915
|
+
.filter((p) => p.notes?.trim())
|
|
916
|
+
.map((p) => (
|
|
917
|
+
<button key={`p${p.name}`} className={button} onClick={() => goTo(`participant:${p.name}`)}>
|
|
918
|
+
{p.name}
|
|
919
|
+
</button>
|
|
920
|
+
))}
|
|
921
|
+
{messages
|
|
922
|
+
.filter((m) => m.message.notes?.trim())
|
|
923
|
+
.map((m) => (
|
|
924
|
+
<button key={`m${m.ref}`} className={button} onClick={() => goTo(`message:${m.ref}`)}>
|
|
925
|
+
message {m.ref}
|
|
926
|
+
</button>
|
|
927
|
+
))}
|
|
928
|
+
</div>
|
|
929
|
+
</>
|
|
930
|
+
)}
|
|
931
|
+
</section>
|
|
932
|
+
|
|
933
|
+
{/* ── Issues ───────────────────────────────────────────────── */}
|
|
934
|
+
<section className={tab === "issues" ? "" : "hidden"}>
|
|
935
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
936
|
+
Issues
|
|
937
|
+
</div>
|
|
938
|
+
{problems.map((p, i) => (
|
|
939
|
+
<button key={`p${i}`} className="block w-full py-0.5 text-left text-[11px] text-red-300" onClick={() => goTo(p.element)}>
|
|
940
|
+
[{p.ruleId}] {p.message}
|
|
941
|
+
</button>
|
|
942
|
+
))}
|
|
943
|
+
{memberIssues.map((p, i) => (
|
|
944
|
+
<button
|
|
945
|
+
key={`s${i}`}
|
|
946
|
+
className={`block w-full py-0.5 text-left text-[11px] ${p.severity === "error" ? "text-red-300" : "text-amber-300"}`}
|
|
947
|
+
onClick={() => goTo(p.element)}
|
|
948
|
+
>
|
|
949
|
+
[{p.ruleId}] {p.message}
|
|
950
|
+
</button>
|
|
951
|
+
))}
|
|
952
|
+
{problems.length === 0 && memberIssues.length === 0 && (
|
|
953
|
+
<div className="py-0.5 text-[11px] text-green-400">
|
|
954
|
+
{systemCheck ? "Valid, and every name it uses resolves in the system." : "The document is valid."}
|
|
955
|
+
</div>
|
|
956
|
+
)}
|
|
957
|
+
{!systemPath && (
|
|
958
|
+
<p className="mt-2 text-[11px] text-gray-600">
|
|
959
|
+
Open this sequence from its system to check it against the machines and the data.
|
|
960
|
+
</p>
|
|
961
|
+
)}
|
|
962
|
+
{systemPath && !member && system && (
|
|
963
|
+
<p className="mt-2 text-[11px] text-amber-300">This document is not a member of the open system.</p>
|
|
964
|
+
)}
|
|
965
|
+
{replay && (
|
|
966
|
+
<>
|
|
967
|
+
<div className={heading}>Replay against {replay.machines.join(", ") || "no machine"}</div>
|
|
968
|
+
{replay.paths.map((path) => (
|
|
969
|
+
<div key={path.name} className="mb-2">
|
|
970
|
+
<div className={`text-[11px] font-semibold ${path.accepted ? "text-green-400" : "text-amber-300"}`}>
|
|
971
|
+
{path.name} — {path.accepted ? "accepted" : "stops"}
|
|
972
|
+
</div>
|
|
973
|
+
{path.steps
|
|
974
|
+
.filter((s) => s.result !== "data")
|
|
975
|
+
.map((s) => (
|
|
976
|
+
<button
|
|
977
|
+
key={`${path.name}:${s.ref}`}
|
|
978
|
+
className={`block w-full truncate py-0.5 pl-2 text-left font-mono text-[10.5px] ${
|
|
979
|
+
s.result === "moved" || s.result === "stayed" ? "text-gray-400" : "text-amber-300"
|
|
980
|
+
}`}
|
|
981
|
+
title={s.note ?? s.result}
|
|
982
|
+
onClick={() => goTo(`message:${s.ref}`)}
|
|
983
|
+
>
|
|
984
|
+
{s.ref}. {s.event ?? ""} {s.from.join("|") || "·"} → {s.to.join("|") || "·"} {s.result}
|
|
985
|
+
</button>
|
|
986
|
+
))}
|
|
987
|
+
</div>
|
|
988
|
+
))}
|
|
989
|
+
</>
|
|
990
|
+
)}
|
|
991
|
+
</section>
|
|
992
|
+
</BoardBoundary>
|
|
993
|
+
</div>
|
|
994
|
+
</aside>
|
|
995
|
+
)}
|
|
996
|
+
</div>
|
|
997
|
+
|
|
998
|
+
<nav className="dock-safe safe-x fixed inset-x-0 bottom-0 z-40 flex border-t border-gray-800 bg-gray-900/95 backdrop-blur-md md:hidden">
|
|
999
|
+
{tabs.map((t) => {
|
|
1000
|
+
const current = sheetOpen && tab === t.id;
|
|
1001
|
+
return (
|
|
1002
|
+
<button
|
|
1003
|
+
key={t.id}
|
|
1004
|
+
onClick={() => openTab(t.id)}
|
|
1005
|
+
aria-pressed={current}
|
|
1006
|
+
className={`relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
|
|
1007
|
+
current ? "text-blue-400" : "text-gray-500"
|
|
1008
|
+
}`}
|
|
1009
|
+
>
|
|
1010
|
+
<span className="flex h-4 items-center text-base leading-none">{t.icon}</span>
|
|
1011
|
+
<span>{t.label}</span>
|
|
1012
|
+
{t.badge !== undefined && (
|
|
1013
|
+
<span className="absolute right-1/2 top-1 min-w-[16px] translate-x-5 rounded-full border border-red-900/80 bg-red-950/80 px-1 text-[10px] leading-4 text-red-200">
|
|
1014
|
+
{t.badge}
|
|
1015
|
+
</span>
|
|
1016
|
+
)}
|
|
1017
|
+
{current && <span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-blue-400" />}
|
|
1018
|
+
</button>
|
|
1019
|
+
);
|
|
1020
|
+
})}
|
|
1021
|
+
</nav>
|
|
1022
|
+
</div>
|
|
1023
|
+
);
|
|
1024
|
+
}
|