@miadi/stateloom-web 0.2.6 → 0.3.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/app-path-routes-manifest.json +2 -0
- package/server/.next/build-manifest.json +4 -4
- package/server/.next/prerender-manifest.json +3 -3
- package/server/.next/routes-manifest.json +16 -1
- package/server/.next/server/app/_global-error/page/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/build-manifest.json +2 -2
- 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/companion/[...path]/route/app-paths-manifest.json +3 -0
- package/server/.next/server/app/api/companion/[...path]/route/build-manifest.json +11 -0
- package/server/.next/server/app/api/companion/[...path]/route/server-reference-manifest.json +4 -0
- package/server/.next/server/app/api/companion/[...path]/route.js +7 -0
- package/server/.next/server/app/api/companion/[...path]/route.js.map +5 -0
- package/server/.next/server/app/api/companion/[...path]/route.js.nft.json +1 -0
- package/server/.next/server/app/api/companion/[...path]/route_client-reference-manifest.js +2 -0
- package/server/.next/server/app/api/docs/route.js +1 -1
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/marks/route/app-paths-manifest.json +3 -0
- package/server/.next/server/app/api/marks/route/build-manifest.json +11 -0
- package/server/.next/server/app/api/marks/route/server-reference-manifest.json +4 -0
- package/server/.next/server/app/api/marks/route.js +8 -0
- package/server/.next/server/app/api/marks/route.js.map +5 -0
- package/server/.next/server/app/api/marks/route.js.nft.json +1 -0
- package/server/.next/server/app/api/marks/route_client-reference-manifest.js +2 -0
- package/server/.next/server/app/api/rispec/route.js +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js +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/build-manifest.json +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/app-paths-manifest.json +2 -0
- 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]__8b8844cb._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__8d9e281e._.js +3 -0
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__aedef6d1._.js +3 -0
- package/server/.next/server/chunks/bridge-protocol_dist_index_179d02a5.js +1 -1
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__cf0b4d7f._.js → [root-of-the-server]__9e661f75._.js} +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +7 -4
- package/server/.next/server/chunks/web__next-internal_server_app_api_companion_[___path]_route_actions_cfae35e7.js +3 -0
- package/server/.next/server/chunks/web__next-internal_server_app_api_marks_route_actions_6f18e4f5.js +3 -0
- package/server/.next/server/middleware-build-manifest.js +2 -2
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/server/server-reference-manifest.js +1 -1
- package/server/.next/server/server-reference-manifest.json +1 -1
- package/server/.next/static/chunks/0d24f3e3dbdef25e.css +2 -0
- package/server/.next/static/chunks/{5e1795a806bd6c51.js → d54b3ced5d4f7ae5.js} +1 -1
- package/server/.next/static/chunks/ec729c54327cc597.js +14 -0
- package/server/.next/static/chunks/{turbopack-34fc7de1568e2a7f.js → turbopack-b3cae9131ae5086f.js} +1 -1
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/api/companion/[...path]/route.ts +112 -0
- package/server/src/app/api/config/route.ts +6 -0
- package/server/src/app/api/marks/route.ts +146 -0
- package/server/src/app/globals.css +94 -0
- package/server/src/app/page.tsx +13 -6
- package/server/src/components/erd/ErdWorkspace.tsx +7 -1
- package/server/src/components/sequence/SequenceWorkspace.tsx +10 -0
- package/server/src/components/stage/CompanionLayer.tsx +382 -0
- package/server/src/components/stage/MachineStageLayer.tsx +56 -0
- package/server/src/components/stage/StageLayer.tsx +338 -0
- package/server/src/components/stage/StageProvider.tsx +257 -0
- package/server/src/lib/projectFile.ts +4 -2
- package/server/src/lib/runtimeConfig.ts +10 -1
- package/server/stateloom-web.json +2 -2
- package/server/.next/static/chunks/4237fd3bb3fa88e5.js +0 -11
- package/server/.next/static/chunks/83083561effbd9ca.css +0 -2
- /package/server/.next/static/{bwSt2D1R7aQAKa8P4ErpF → pbn7-8jVWdRn8FLy4fHnU}/_buildManifest.js +0 -0
- /package/server/.next/static/{bwSt2D1R7aQAKa8P4ErpF → pbn7-8jVWdRn8FLy4fHnU}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{bwSt2D1R7aQAKa8P4ErpF → pbn7-8jVWdRn8FLy4fHnU}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The stage on one board (Spec 83): mounted by each workspace beside its
|
|
5
|
+
* canvas, told the board's viewport and where its elements are.
|
|
6
|
+
*
|
|
7
|
+
* - What the step on stage lights in this drawing is drawn here, and the view
|
|
8
|
+
* fits it (after the workspace has centred the step's focus, so the wider
|
|
9
|
+
* view wins).
|
|
10
|
+
* - This drawing's marks: pinned on the board, listed in a panel, read and
|
|
11
|
+
* answered in a thread. ✎ turns marking on — a tap marks an element, a drag
|
|
12
|
+
* marks an area and what is inside it — and the composer asks what the agent
|
|
13
|
+
* should know. Answer on the caption opens the composer on what is lit.
|
|
14
|
+
* - What the person is looking at goes back to the agents through the stage.
|
|
15
|
+
*
|
|
16
|
+
* The sidecar is written through /api/marks; the hub is told after.
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
19
|
+
import { MarkComposer, MarkIcon, MarksIcon, MarkThread, MarksPanel, StageOverlay, type MarkDraft } from "@miadi/stateloom-canvas";
|
|
20
|
+
import {
|
|
21
|
+
boxesOf,
|
|
22
|
+
fitToBoxes,
|
|
23
|
+
formatReview,
|
|
24
|
+
screenToWorld,
|
|
25
|
+
type DocKind,
|
|
26
|
+
type ElementBox,
|
|
27
|
+
type Mark,
|
|
28
|
+
type MarkKind,
|
|
29
|
+
type Viewport,
|
|
30
|
+
} from "@miadi/stateloom-protocol";
|
|
31
|
+
import { useStageContext } from "./StageProvider";
|
|
32
|
+
import { urlParam } from "@/lib/systemParam";
|
|
33
|
+
|
|
34
|
+
export interface StageLayerProps {
|
|
35
|
+
/** The drawing this board shows, absolute. Null until the workspace has read it. */
|
|
36
|
+
docPath: string | null;
|
|
37
|
+
kind: DocKind;
|
|
38
|
+
viewport: Viewport;
|
|
39
|
+
setViewport: (vp: Viewport) => void;
|
|
40
|
+
boxes: readonly ElementBox[];
|
|
41
|
+
boardRef: RefObject<HTMLElement | null>;
|
|
42
|
+
/** The element in hand (`focus=`). */
|
|
43
|
+
focus?: string | null;
|
|
44
|
+
/** The machine level on the board: the state whose children are drawn. */
|
|
45
|
+
level?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The step lights something this board cannot see yet — a state on another
|
|
48
|
+
* level, a sequence shown as a list. The workspace brings it on.
|
|
49
|
+
*/
|
|
50
|
+
onReveal?: (focuses: readonly string[]) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const quietButton =
|
|
54
|
+
"flex h-9 min-w-9 items-center justify-center gap-1 rounded-lg border border-gray-700 bg-gray-900/90 px-2 text-sm text-gray-200 shadow-lg backdrop-blur hover:bg-gray-800";
|
|
55
|
+
|
|
56
|
+
async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[]; change: { action: "add" | "reply" | "resolve" | "reopen" | "remove"; id: string } }> {
|
|
57
|
+
const r = await fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "content-type": "application/json" },
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
});
|
|
62
|
+
const data = await r.json().catch(() => ({}));
|
|
63
|
+
if (!r.ok) throw new Error(data?.error ?? `the marks could not be written (${r.status})`);
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal }: StageLayerProps) {
|
|
68
|
+
const stage = useStageContext();
|
|
69
|
+
const [marks, setMarks] = useState<Mark[]>([]);
|
|
70
|
+
const [marking, setMarking] = useState(false);
|
|
71
|
+
const [draft, setDraft] = useState<(MarkDraft & { listen?: boolean }) | null>(null);
|
|
72
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
73
|
+
const [panel, setPanel] = useState(false);
|
|
74
|
+
const [busy, setBusy] = useState(false);
|
|
75
|
+
const [error, setError] = useState<string | null>(null);
|
|
76
|
+
const [copied, setCopied] = useState(false);
|
|
77
|
+
|
|
78
|
+
// ── This drawing's marks ─────────────────────────────────────────────
|
|
79
|
+
const load = useCallback(() => {
|
|
80
|
+
if (!docPath) return;
|
|
81
|
+
fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, { cache: "no-store" })
|
|
82
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
83
|
+
.then((data) => data && Array.isArray(data.marks) && setMarks(data.marks))
|
|
84
|
+
.catch(() => {});
|
|
85
|
+
}, [docPath]);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
setMarks([]);
|
|
88
|
+
setSelected(null);
|
|
89
|
+
setDraft(null);
|
|
90
|
+
load();
|
|
91
|
+
window.addEventListener("focus", load);
|
|
92
|
+
return () => window.removeEventListener("focus", load);
|
|
93
|
+
}, [load]);
|
|
94
|
+
useEffect(
|
|
95
|
+
() =>
|
|
96
|
+
stage?.onMarks((e) => {
|
|
97
|
+
if (docPath && e.docId === docPath && Array.isArray(e.marks)) setMarks(e.marks);
|
|
98
|
+
}),
|
|
99
|
+
[stage, docPath],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const write = async (body: unknown): Promise<boolean> => {
|
|
103
|
+
if (!docPath) return false;
|
|
104
|
+
setBusy(true);
|
|
105
|
+
setError(null);
|
|
106
|
+
try {
|
|
107
|
+
const r = await postMarks(docPath, body);
|
|
108
|
+
setMarks(r.marks);
|
|
109
|
+
stage?.sendMarks({ docId: docPath, marks: r.marks, change: r.change });
|
|
110
|
+
return true;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
113
|
+
return false;
|
|
114
|
+
} finally {
|
|
115
|
+
setBusy(false);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── What the step lights here ────────────────────────────────────────
|
|
120
|
+
const lit = stage?.lit ?? null;
|
|
121
|
+
const litHere = lit && (!lit.doc || lit.doc === docPath) ? lit : null;
|
|
122
|
+
const spotlight = litHere?.spotlight;
|
|
123
|
+
const region = litHere?.region ?? null;
|
|
124
|
+
const fitted = useRef(-1);
|
|
125
|
+
const revealed = useRef(-1);
|
|
126
|
+
const refit = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
127
|
+
const litBoxes = useMemo(() => (spotlight?.length ? boxesOf(boxes, spotlight) : []), [boxes, spotlight]);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!litHere || fitted.current === litHere.nonce) return;
|
|
130
|
+
const targets = [...litBoxes, ...(region ? [region] : [])];
|
|
131
|
+
if (!targets.length) {
|
|
132
|
+
// Lit, but not drawn on this board as it stands: ask the workspace once.
|
|
133
|
+
if (spotlight?.length && revealed.current !== litHere.nonce) {
|
|
134
|
+
revealed.current = litHere.nonce;
|
|
135
|
+
onReveal?.(spotlight);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
fitted.current = litHere.nonce;
|
|
140
|
+
const fit = (): void => {
|
|
141
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
142
|
+
if (!rect?.width) return;
|
|
143
|
+
// The caption covers the bottom of the board: fit into what it leaves.
|
|
144
|
+
const caption = document.querySelector(".stage-caption-host .slc-stage-caption")?.getBoundingClientRect();
|
|
145
|
+
const covered = caption && caption.top < rect.bottom ? Math.max(0, rect.bottom - caption.top + 8) : 0;
|
|
146
|
+
const height = Math.max(rect.height * 0.4, rect.height - covered);
|
|
147
|
+
setViewport(fitToBoxes(targets, rect.width, height, { padding: 48, maxScale: 1 }));
|
|
148
|
+
};
|
|
149
|
+
fit();
|
|
150
|
+
// Again after the workspace's own fit or centring has landed. Not cleared
|
|
151
|
+
// when the board's boxes move under it (a live update arrives just after a
|
|
152
|
+
// drawing opens) — only when the layer goes.
|
|
153
|
+
if (refit.current) clearTimeout(refit.current);
|
|
154
|
+
refit.current = setTimeout(fit, 520);
|
|
155
|
+
}, [litHere, litBoxes, region, spotlight, boardRef, setViewport, onReveal]);
|
|
156
|
+
useEffect(() => () => {
|
|
157
|
+
if (refit.current) clearTimeout(refit.current);
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
// Answer on the caption: a composer on what is lit (or the element in hand).
|
|
161
|
+
const answered = useRef(stage?.answerNonce ?? 0);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!stage || stage.answerNonce === answered.current) return;
|
|
164
|
+
answered.current = stage.answerNonce;
|
|
165
|
+
const elements = spotlight?.length ? [...spotlight] : litHere?.focus ? [litHere.focus] : focus ? [focus] : [];
|
|
166
|
+
setPanel(false);
|
|
167
|
+
setSelected(null);
|
|
168
|
+
setDraft({ elements, region: region ?? undefined, listen: !!stage.listener });
|
|
169
|
+
}, [stage, spotlight, litHere, focus, region]);
|
|
170
|
+
|
|
171
|
+
// ── What the person is looking at ────────────────────────────────────
|
|
172
|
+
const report = stage?.report;
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!report || !docPath) return;
|
|
175
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
176
|
+
const a = screenToWorld(viewport, { x: 0, y: 0 });
|
|
177
|
+
const b = rect ? screenToWorld(viewport, { x: rect.width, y: rect.height }) : a;
|
|
178
|
+
report({
|
|
179
|
+
doc: docPath,
|
|
180
|
+
kind,
|
|
181
|
+
system: urlParam("system") ?? undefined,
|
|
182
|
+
focus,
|
|
183
|
+
...(rect?.width ? { visible: { x: Math.round(a.x), y: Math.round(a.y), width: Math.round(b.x - a.x), height: Math.round(b.y - a.y) } } : {}),
|
|
184
|
+
});
|
|
185
|
+
}, [report, docPath, kind, focus, viewport, boardRef]);
|
|
186
|
+
|
|
187
|
+
const copy = (): void => {
|
|
188
|
+
if (!docPath) return;
|
|
189
|
+
const text = formatReview(stage?.lastReport() ?? null, [{ doc: docPath, marks: marks.filter((m) => m.status !== "resolved") }]);
|
|
190
|
+
navigator.clipboard?.writeText(text).then(() => {
|
|
191
|
+
setCopied(true);
|
|
192
|
+
setTimeout(() => setCopied(false), 2000);
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (!docPath) return null;
|
|
197
|
+
const waiting = marks.filter((m) => m.status === "open" && m.author.role === "person").length;
|
|
198
|
+
const questions = marks.filter((m) => m.status === "open" && m.author.role === "agent").length;
|
|
199
|
+
const selectedMark = marks.find((m) => m.id === selected) ?? null;
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<>
|
|
203
|
+
<StageOverlay
|
|
204
|
+
viewport={viewport}
|
|
205
|
+
boxes={boxes}
|
|
206
|
+
spotlight={spotlight}
|
|
207
|
+
region={region}
|
|
208
|
+
marks={marks}
|
|
209
|
+
selectedMark={selected}
|
|
210
|
+
onSelectMark={(id) => {
|
|
211
|
+
setDraft(null);
|
|
212
|
+
setPanel(false);
|
|
213
|
+
setSelected((s) => (s === id ? null : id));
|
|
214
|
+
}}
|
|
215
|
+
marking={marking}
|
|
216
|
+
onDraft={(d) => {
|
|
217
|
+
setMarking(false);
|
|
218
|
+
setSelected(null);
|
|
219
|
+
setPanel(false);
|
|
220
|
+
setDraft(d);
|
|
221
|
+
}}
|
|
222
|
+
draft={draft}
|
|
223
|
+
/>
|
|
224
|
+
|
|
225
|
+
<div className="stage-board-controls">
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
className={`${quietButton} ${marking ? "!border-amber-400 !text-amber-300" : ""}`}
|
|
229
|
+
aria-pressed={marking}
|
|
230
|
+
onClick={() => {
|
|
231
|
+
setDraft(null);
|
|
232
|
+
setMarking((m) => !m);
|
|
233
|
+
}}
|
|
234
|
+
title={marking ? "Marking: tap an element, or drag around several. Tap ✎ again to stop." : "Mark something for the agent — tap an element or drag around several"}
|
|
235
|
+
>
|
|
236
|
+
<MarkIcon />
|
|
237
|
+
{marking && <span className="text-xs">marking</span>}
|
|
238
|
+
</button>
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
className={quietButton}
|
|
242
|
+
aria-expanded={panel}
|
|
243
|
+
onClick={() => {
|
|
244
|
+
setDraft(null);
|
|
245
|
+
setSelected(null);
|
|
246
|
+
setPanel((p) => !p);
|
|
247
|
+
}}
|
|
248
|
+
title="Marks on this drawing"
|
|
249
|
+
>
|
|
250
|
+
<MarksIcon />
|
|
251
|
+
{waiting + questions > 0 && <span className="text-xs text-amber-300">{waiting + questions}</span>}
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{marking && !draft && (
|
|
256
|
+
<div className="stage-board-hint" role="status">
|
|
257
|
+
Tap an element, or drag around several, to say something about it.
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{(draft || selectedMark || panel) && (
|
|
262
|
+
<div className="stage-board-card">
|
|
263
|
+
{draft ? (
|
|
264
|
+
<MarkComposer
|
|
265
|
+
key={`${draft.elements.join(",")}|${draft.region ? JSON.stringify(draft.region) : ""}`}
|
|
266
|
+
elements={draft.elements}
|
|
267
|
+
region={draft.region}
|
|
268
|
+
author={stage?.author ?? ""}
|
|
269
|
+
onAuthorChange={stage?.setAuthor}
|
|
270
|
+
listener={stage?.listener}
|
|
271
|
+
listenAtOnce={!!draft.listen}
|
|
272
|
+
busy={busy}
|
|
273
|
+
error={error}
|
|
274
|
+
onCancel={() => {
|
|
275
|
+
setDraft(null);
|
|
276
|
+
setError(null);
|
|
277
|
+
}}
|
|
278
|
+
onSubmit={async ({ text, kind: markKind, author }: { text: string; kind: MarkKind; author: string }) => {
|
|
279
|
+
const name = author || "someone";
|
|
280
|
+
const ok = await write({
|
|
281
|
+
action: "add",
|
|
282
|
+
mark: { text, kind: markKind, elements: draft.elements, region: draft.region, level, author: { name } },
|
|
283
|
+
});
|
|
284
|
+
if (ok) setDraft(null);
|
|
285
|
+
}}
|
|
286
|
+
/>
|
|
287
|
+
) : selectedMark ? (
|
|
288
|
+
<MarkThread
|
|
289
|
+
mark={selectedMark}
|
|
290
|
+
number={marks.indexOf(selectedMark) + 1}
|
|
291
|
+
listener={stage?.listener}
|
|
292
|
+
busy={busy}
|
|
293
|
+
error={error}
|
|
294
|
+
onClose={() => setSelected(null)}
|
|
295
|
+
onReply={(text) => void write({ action: "reply", id: selectedMark.id, text, author: { name: stage?.author || "someone" } })}
|
|
296
|
+
onResolve={() => void write({ action: "resolve", id: selectedMark.id })}
|
|
297
|
+
onReopen={() => void write({ action: "reopen", id: selectedMark.id })}
|
|
298
|
+
onRemove={async () => {
|
|
299
|
+
if (await write({ action: "remove", id: selectedMark.id })) setSelected(null);
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
302
|
+
) : (
|
|
303
|
+
<MarksPanel
|
|
304
|
+
marks={marks}
|
|
305
|
+
selected={selected}
|
|
306
|
+
onSelect={(id) => {
|
|
307
|
+
setPanel(false);
|
|
308
|
+
setSelected(id);
|
|
309
|
+
}}
|
|
310
|
+
onCopy={copy}
|
|
311
|
+
copied={copied}
|
|
312
|
+
onClose={() => setPanel(false)}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
</>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* For a board that is not drawn right now (the sequence's list view): when the
|
|
323
|
+
* step on stage lights something in this drawing — a spotlight or an area, not
|
|
324
|
+
* a focus, which the list shows by itself — ask for the drawing.
|
|
325
|
+
*/
|
|
326
|
+
export function StageReveal({ docPath, onReveal }: { docPath: string | null; onReveal: () => void }) {
|
|
327
|
+
const stage = useStageContext();
|
|
328
|
+
const lit = stage?.lit ?? null;
|
|
329
|
+
const done = useRef(-1);
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!lit || done.current === lit.nonce) return;
|
|
332
|
+
if (lit.doc && lit.doc !== docPath) return;
|
|
333
|
+
if (!lit.spotlight?.length && !lit.region) return;
|
|
334
|
+
done.current = lit.nonce;
|
|
335
|
+
onReveal();
|
|
336
|
+
}, [lit, docPath, onReveal]);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The page's stage (Spec 83): one connection to the stage this designer
|
|
5
|
+
* presents on, whichever workspace is open.
|
|
6
|
+
*
|
|
7
|
+
* It joins `?stage=` (else the server's `STATELOOM_STAGE`, else `default`) and
|
|
8
|
+
* does what a shown step asks of the page as a whole: open the step's drawing
|
|
9
|
+
* (inside its system, when it names one) and focus its element — the same
|
|
10
|
+
* `openInSystem` an agent's `show` uses. What is lit inside the drawing — the
|
|
11
|
+
* spotlight, the area — is handed to whichever `StageLayer` the open
|
|
12
|
+
* workspace mounts, because only it knows where its elements are.
|
|
13
|
+
*
|
|
14
|
+
* It also holds what every layer shares: the caption and its controls, the
|
|
15
|
+
* voice the person may turn on, the ears a mark may be spoken into, the name
|
|
16
|
+
* marks are signed with, and the line back to the agents — what the person is
|
|
17
|
+
* looking at, and that a drawing's marks changed.
|
|
18
|
+
*/
|
|
19
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
20
|
+
import { browserDictation, browserVoice, useStage, type StageShown, type UseStage } from "@miadi/stateloom-react";
|
|
21
|
+
import { StageCaption, type ListenerLike } from "@miadi/stateloom-canvas";
|
|
22
|
+
import { DEFAULT_STAGE, isStageName, type Box, type MarksEnvelope, type StageContext } from "@miadi/stateloom-protocol";
|
|
23
|
+
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
24
|
+
import { requestedDoc } from "@/lib/docParam";
|
|
25
|
+
import { FOCUS_REQUEST_EVENT, openInSystem, urlParam } from "@/lib/systemParam";
|
|
26
|
+
import CompanionLayer from "./CompanionLayer";
|
|
27
|
+
|
|
28
|
+
/** What the step on stage lights inside its drawing. `doc` absent: whatever drawing is open. */
|
|
29
|
+
export interface Lit {
|
|
30
|
+
doc?: string;
|
|
31
|
+
focus?: string;
|
|
32
|
+
spotlight?: string[];
|
|
33
|
+
region?: Box;
|
|
34
|
+
nonce: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a layer reports about the board it sits on. */
|
|
38
|
+
export type BoardReport = Omit<StageContext, "stage" | "at" | "tour" | "who">;
|
|
39
|
+
|
|
40
|
+
export interface StageValue {
|
|
41
|
+
stage: string;
|
|
42
|
+
connected: boolean;
|
|
43
|
+
lit: Lit | null;
|
|
44
|
+
author: string;
|
|
45
|
+
setAuthor: (name: string) => void;
|
|
46
|
+
listener: ListenerLike | null;
|
|
47
|
+
/** A layer asks to mark what is lit: moves when the person presses Answer on the caption. */
|
|
48
|
+
answerNonce: number;
|
|
49
|
+
report: (board: BoardReport) => void;
|
|
50
|
+
lastReport: () => StageContext | null;
|
|
51
|
+
sendMarks: (env: Omit<MarksEnvelope, "origin" | "stage">) => void;
|
|
52
|
+
onMarks: (cb: (e: MarksEnvelope) => void) => () => void;
|
|
53
|
+
/** What is on stage now: a live step, or a tour's step with its place. */
|
|
54
|
+
shown: StageShown | null;
|
|
55
|
+
/** Play a tour from this page in its own voice — a companion's reply (Spec 84). */
|
|
56
|
+
present: UseStage["present"];
|
|
57
|
+
pause: () => void;
|
|
58
|
+
stop: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const StageCtx = createContext<StageValue | null>(null);
|
|
62
|
+
|
|
63
|
+
export function useStageContext(): StageValue | null {
|
|
64
|
+
return useContext(StageCtx);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const VOICE_KEY = "stateloom.stage.voice";
|
|
68
|
+
const AUTHOR_KEY = "stateloom.author";
|
|
69
|
+
|
|
70
|
+
function readLocal(key: string): string {
|
|
71
|
+
try {
|
|
72
|
+
return window.localStorage.getItem(key) ?? "";
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeLocal(key: string, value: string): void {
|
|
79
|
+
try {
|
|
80
|
+
window.localStorage.setItem(key, value);
|
|
81
|
+
} catch {
|
|
82
|
+
// Private mode: the choice holds for this page only.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Ask the open workspace to select and centre `focus` on the drawing already open. */
|
|
87
|
+
function requestFocus(focus: string): void {
|
|
88
|
+
const url = new URL(window.location.href);
|
|
89
|
+
url.searchParams.set("focus", focus);
|
|
90
|
+
window.history.replaceState(window.history.state, "", url);
|
|
91
|
+
window.dispatchEvent(new Event(FOCUS_REQUEST_EVENT));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default function StageProvider({ children }: { children: ReactNode }) {
|
|
95
|
+
const [config, setConfig] = useState<{ bridgeUrl: string | null; stage: string; companion: boolean; companionEpisode: string | null }>({
|
|
96
|
+
bridgeUrl: null,
|
|
97
|
+
stage: DEFAULT_STAGE,
|
|
98
|
+
companion: false,
|
|
99
|
+
companionEpisode: null,
|
|
100
|
+
});
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
let live = true;
|
|
103
|
+
loadRuntimeConfig().then((c) => {
|
|
104
|
+
if (!live) return;
|
|
105
|
+
const asked = urlParam("stage");
|
|
106
|
+
const stage = isStageName(asked) ? asked : isStageName(c.stage) ? c.stage : DEFAULT_STAGE;
|
|
107
|
+
setConfig({ bridgeUrl: c.bridgeUrl, stage, companion: c.companion, companionEpisode: c.companionEpisode });
|
|
108
|
+
});
|
|
109
|
+
return () => {
|
|
110
|
+
live = false;
|
|
111
|
+
};
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Read once from this browser. The provider mounts only after the page has
|
|
115
|
+
// read its URL (never in the server's render), so there is nothing to hydrate.
|
|
116
|
+
const [voiceOn, setVoiceOn] = useState(() => typeof window !== "undefined" && readLocal(VOICE_KEY) === "1");
|
|
117
|
+
const [author, setAuthorState] = useState(() => (typeof window !== "undefined" ? readLocal(AUTHOR_KEY) : ""));
|
|
118
|
+
const voice = useMemo(() => (voiceOn ? browserVoice() : null), [voiceOn]);
|
|
119
|
+
const listener = useMemo<ListenerLike | null>(() => (typeof window === "undefined" ? null : browserDictation()), []);
|
|
120
|
+
|
|
121
|
+
const [lit, setLit] = useState<Lit | null>(null);
|
|
122
|
+
const marksListeners = useRef(new Set<(e: MarksEnvelope) => void>());
|
|
123
|
+
|
|
124
|
+
const show = useCallback((shown: StageShown | null) => {
|
|
125
|
+
if (!shown) {
|
|
126
|
+
setLit(null);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const { step } = shown;
|
|
130
|
+
const open = requestedDoc();
|
|
131
|
+
// A page opened on a drawing keeps it when it only catches up with what the stage held:
|
|
132
|
+
// the caption says what waits, and ▶ opens it. A step sent now always opens its drawing.
|
|
133
|
+
if (shown.caught && urlParam("doc")) {
|
|
134
|
+
setLit({ doc: step.doc, focus: step.focus, spotlight: step.spotlight, region: step.region, nonce: shown.nonce });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (step.doc && step.doc !== open) {
|
|
138
|
+
openInSystem({ doc: step.doc, system: step.system ?? undefined, focus: step.focus ?? null });
|
|
139
|
+
} else if (step.focus) {
|
|
140
|
+
if (step.system && urlParam("system") !== step.system && open) openInSystem({ doc: open, system: step.system, focus: step.focus });
|
|
141
|
+
else requestFocus(step.focus);
|
|
142
|
+
}
|
|
143
|
+
setLit({ doc: step.doc, focus: step.focus, spotlight: step.spotlight, region: step.region, nonce: shown.nonce });
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const stage = useStage({
|
|
147
|
+
url: config.bridgeUrl ?? "",
|
|
148
|
+
stage: config.stage,
|
|
149
|
+
name: "web-stage",
|
|
150
|
+
voice,
|
|
151
|
+
enabled: !!config.bridgeUrl,
|
|
152
|
+
onShow: show,
|
|
153
|
+
onMarks: (e) => {
|
|
154
|
+
for (const cb of [...marksListeners.current]) cb(e);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// What the person is looking at, told to the agents a moment after it settles.
|
|
159
|
+
const last = useRef<StageContext | null>(null);
|
|
160
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
161
|
+
const sendContext = stage.sendContext;
|
|
162
|
+
const report = useCallback(
|
|
163
|
+
(board: BoardReport) => {
|
|
164
|
+
const ctx: StageContext = { ...board, stage: config.stage, who: author || undefined, at: new Date().toISOString() };
|
|
165
|
+
last.current = ctx;
|
|
166
|
+
if (timer.current) clearTimeout(timer.current);
|
|
167
|
+
timer.current = setTimeout(() => sendContext({ ...board, who: author || undefined }), 400);
|
|
168
|
+
},
|
|
169
|
+
[config.stage, author, sendContext],
|
|
170
|
+
);
|
|
171
|
+
useEffect(() => () => {
|
|
172
|
+
if (timer.current) clearTimeout(timer.current);
|
|
173
|
+
}, []);
|
|
174
|
+
// A tour moving is news too: say it again with the tour's place.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (last.current) sendContext({ ...last.current, who: author || undefined });
|
|
177
|
+
}, [stage.index, stage.playing, stage.tour, sendContext, author]);
|
|
178
|
+
|
|
179
|
+
const [answerNonce, setAnswerNonce] = useState(0);
|
|
180
|
+
|
|
181
|
+
// How tall the caption stands, for what must sit above it on a phone (the Talk dock).
|
|
182
|
+
const captionUp = !!stage.shown;
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const root = document.documentElement;
|
|
185
|
+
const caption = captionUp ? document.querySelector<HTMLElement>(".stage-caption-host .slc-stage-caption") : null;
|
|
186
|
+
if (!caption || typeof ResizeObserver === "undefined") {
|
|
187
|
+
root.style.removeProperty("--stage-caption-h");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const measure = () => root.style.setProperty("--stage-caption-h", `${Math.ceil(caption.getBoundingClientRect().height) + 12}px`);
|
|
191
|
+
const watch = new ResizeObserver(measure);
|
|
192
|
+
watch.observe(caption);
|
|
193
|
+
measure();
|
|
194
|
+
return () => {
|
|
195
|
+
watch.disconnect();
|
|
196
|
+
root.style.removeProperty("--stage-caption-h");
|
|
197
|
+
};
|
|
198
|
+
}, [captionUp]);
|
|
199
|
+
|
|
200
|
+
const value = useMemo<StageValue>(
|
|
201
|
+
() => ({
|
|
202
|
+
stage: config.stage,
|
|
203
|
+
connected: stage.status === "connected",
|
|
204
|
+
lit,
|
|
205
|
+
author,
|
|
206
|
+
setAuthor: (name: string) => {
|
|
207
|
+
setAuthorState(name);
|
|
208
|
+
writeLocal(AUTHOR_KEY, name);
|
|
209
|
+
},
|
|
210
|
+
listener,
|
|
211
|
+
answerNonce,
|
|
212
|
+
report,
|
|
213
|
+
lastReport: () => last.current,
|
|
214
|
+
sendMarks: stage.sendMarks,
|
|
215
|
+
onMarks: (cb) => {
|
|
216
|
+
marksListeners.current.add(cb);
|
|
217
|
+
return () => {
|
|
218
|
+
marksListeners.current.delete(cb);
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
shown: stage.shown,
|
|
222
|
+
present: stage.present,
|
|
223
|
+
pause: stage.pause,
|
|
224
|
+
stop: stage.stop,
|
|
225
|
+
}),
|
|
226
|
+
[config.stage, stage.status, stage.shown, stage.sendMarks, stage.present, stage.pause, stage.stop, lit, author, listener, answerNonce, report],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<StageCtx.Provider value={value}>
|
|
231
|
+
{children}
|
|
232
|
+
{stage.shown && (
|
|
233
|
+
<div className="stage-caption-host">
|
|
234
|
+
<StageCaption
|
|
235
|
+
shown={stage.shown}
|
|
236
|
+
playing={stage.playing}
|
|
237
|
+
speaking={stage.speaking}
|
|
238
|
+
voice={stage.ownVoice ? undefined : voiceOn}
|
|
239
|
+
onToggleVoice={() => {
|
|
240
|
+
const next = !voiceOn;
|
|
241
|
+
setVoiceOn(next);
|
|
242
|
+
writeLocal(VOICE_KEY, next ? "1" : "0");
|
|
243
|
+
}}
|
|
244
|
+
onPlay={stage.play}
|
|
245
|
+
onPause={stage.pause}
|
|
246
|
+
onNext={stage.next}
|
|
247
|
+
onPrev={stage.prev}
|
|
248
|
+
onGoto={stage.goto}
|
|
249
|
+
onClose={stage.dismiss}
|
|
250
|
+
onAnswer={() => setAnswerNonce((n) => n + 1)}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
{config.companion && <CompanionLayer stage={value} defaultEpisode={config.companionEpisode} />}
|
|
255
|
+
</StageCtx.Provider>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { closeSync, constants, lstatSync, openSync, readdirSync, readlinkSync, realpathSync, statSync, writeSync } from "fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
|
|
3
|
-
import { envAlias } from "@miadi/stateloom-protocol";
|
|
3
|
+
import { envAlias, isLoomSidecar } from "@miadi/stateloom-protocol";
|
|
4
4
|
|
|
5
5
|
export function getProjectFilePath(): string {
|
|
6
6
|
return resolve(envAlias("PROJECT_FILE") ?? "./statemachine.smdf.json");
|
|
@@ -262,6 +262,8 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
|
|
|
262
262
|
continue;
|
|
263
263
|
}
|
|
264
264
|
if (!entry.endsWith(".json") || !real.endsWith(".json")) continue;
|
|
265
|
+
// A drawing's marks and a tour are the loom's, but they are not drawings (Spec 83).
|
|
266
|
+
if (isLoomSidecar(entry)) continue;
|
|
265
267
|
if (docs.length >= limit) {
|
|
266
268
|
truncated = true;
|
|
267
269
|
return;
|
|
@@ -298,7 +300,7 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
|
|
|
298
300
|
continue; // no diagrams/ folder: nothing of this episode is admitted
|
|
299
301
|
}
|
|
300
302
|
for (const file of files) {
|
|
301
|
-
if (file.startsWith(".") || !file.endsWith(".json")) continue;
|
|
303
|
+
if (file.startsWith(".") || !file.endsWith(".json") || isLoomSidecar(file)) continue;
|
|
302
304
|
const full = join(dir, file);
|
|
303
305
|
let real: string;
|
|
304
306
|
let stat;
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
export interface RuntimeConfig {
|
|
18
18
|
bridgeUrl: string | null;
|
|
19
19
|
projectFile: string | null;
|
|
20
|
+
/** The stage agents present on (Spec 83), when the server names one. */
|
|
21
|
+
stage: string | null;
|
|
22
|
+
/** A companion answers behind `/api/companion` (Spec 84). */
|
|
23
|
+
companion: boolean;
|
|
24
|
+
/** The episode the Talk dock speaks in when the page names none. */
|
|
25
|
+
companionEpisode: string | null;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
/**
|
|
@@ -47,12 +53,15 @@ export function loadRuntimeConfig(): Promise<RuntimeConfig> {
|
|
|
47
53
|
// build — it just has nothing to add.
|
|
48
54
|
bridgeUrl: body.bridgeUrl || fallback,
|
|
49
55
|
projectFile: body.projectFile ?? null,
|
|
56
|
+
stage: body.stage ?? null,
|
|
57
|
+
companion: body.companion === true,
|
|
58
|
+
companionEpisode: body.companionEpisode ?? null,
|
|
50
59
|
};
|
|
51
60
|
}
|
|
52
61
|
} catch {
|
|
53
62
|
// Older server, or none reachable: fall back to whatever the build knew.
|
|
54
63
|
}
|
|
55
|
-
return { bridgeUrl: fallback, projectFile: null };
|
|
64
|
+
return { bridgeUrl: fallback, projectFile: null, stage: null, companion: false, companionEpisode: null };
|
|
56
65
|
})();
|
|
57
66
|
return pending;
|
|
58
67
|
}
|