@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,382 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Talking with Mia from the canvas (Spec 84).
|
|
5
|
+
*
|
|
6
|
+
* The Mia episode companion's phone page, in a corner of the designer: the person
|
|
7
|
+
* presses Record and speaks about what is on the canvas; the take is sent to the
|
|
8
|
+
* episode as it is recorded (a backup part every 10 s) and whole on Stop, with what the
|
|
9
|
+
* canvas showed — the drawing, the element in focus, the marks waiting on an agent. The
|
|
10
|
+
* session listening in that episode replies; a reply with steps plays as a tour on this
|
|
11
|
+
* stage, each step's words in Mia's voice while that step's drawing is open and its
|
|
12
|
+
* elements are lit.
|
|
13
|
+
*
|
|
14
|
+
* The phone page's rules hold here:
|
|
15
|
+
* - nothing plays while the person records or a take is being sent; a reply that arrives
|
|
16
|
+
* then waits, and plays once the take is sent;
|
|
17
|
+
* - a reply that arrives while another plays waits for it to end;
|
|
18
|
+
* - a reply already in the thread when the page opened is shown, not played;
|
|
19
|
+
* - once the person presses ▶ or ■, the waiting sequence is over: each reply plays when
|
|
20
|
+
* they choose it.
|
|
21
|
+
*
|
|
22
|
+
* Everything reaches the service through `/api/companion/` (this designer's proxy to
|
|
23
|
+
* `STATELOOM_COMPANION_URL`); a reply is never posted from here.
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
26
|
+
import {
|
|
27
|
+
browserVoice,
|
|
28
|
+
createCompanionThread,
|
|
29
|
+
createTakeRecorder,
|
|
30
|
+
replyTour,
|
|
31
|
+
takeContext,
|
|
32
|
+
type CompanionReply,
|
|
33
|
+
type CompanionThread,
|
|
34
|
+
type RecorderSnapshot,
|
|
35
|
+
type TakeRecorder,
|
|
36
|
+
type ThreadSnapshot,
|
|
37
|
+
} from "@miadi/stateloom-react";
|
|
38
|
+
import { CompanionDock } from "@miadi/stateloom-canvas";
|
|
39
|
+
import { marksWaitingOn, readMarksFile } from "@miadi/stateloom-protocol";
|
|
40
|
+
import { requestedDoc } from "@/lib/docParam";
|
|
41
|
+
import { urlParam } from "@/lib/systemParam";
|
|
42
|
+
import type { StageValue } from "./StageProvider";
|
|
43
|
+
|
|
44
|
+
const BASE = "/api/companion/";
|
|
45
|
+
const WHO = "Mia";
|
|
46
|
+
const AUTOPLAY_KEY = "stateloom.companion.autoplay";
|
|
47
|
+
const EPISODE_KEY = "stateloom.companion.episode";
|
|
48
|
+
const EPISODE_NAME = /^\d{4}-\d{2}-\d{2}-episode-\d+-[a-z0-9-]+$/;
|
|
49
|
+
|
|
50
|
+
/** The episode a chronicle drawing belongs to: `<root>/<episode>/diagrams/<file>`. */
|
|
51
|
+
export function episodeOf(doc: string | null | undefined): string | null {
|
|
52
|
+
const m = doc?.match(/(?:^|\/)(\d{4}-\d{2}-\d{2}-episode-\d+-[a-z0-9-]+)\/diagrams\/[^/]+$/);
|
|
53
|
+
return m ? m[1] : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readLocal(key: string): string | null {
|
|
57
|
+
try {
|
|
58
|
+
return window.localStorage.getItem(key);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeLocal(key: string, value: string): void {
|
|
65
|
+
try {
|
|
66
|
+
window.localStorage.setItem(key, value);
|
|
67
|
+
} catch {
|
|
68
|
+
// Private mode: the choice holds for this page only.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const when = (iso: string) => {
|
|
73
|
+
const d = new Date(iso);
|
|
74
|
+
return Number.isNaN(d.getTime()) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const busy = (s: RecorderSnapshot["state"]) => s === "starting" || s === "recording" || s === "sending";
|
|
78
|
+
|
|
79
|
+
const IDLE: RecorderSnapshot = { state: "idle", seconds: 0, held: 0, message: "", error: false, stored: null };
|
|
80
|
+
const EMPTY: ThreadSnapshot = { episode: null, items: [], presence: null, waiting: null, error: null };
|
|
81
|
+
|
|
82
|
+
export default function CompanionLayer({ stage, defaultEpisode }: { stage: StageValue; defaultEpisode: string | null }) {
|
|
83
|
+
// The dock opens closed on every visit: the board comes first, the conversation is one tap away.
|
|
84
|
+
const [open, setOpenState] = useState(false);
|
|
85
|
+
const [autoplay, setAutoplayState] = useState(() => readLocal(AUTOPLAY_KEY) !== "0");
|
|
86
|
+
const [episode, setEpisodeState] = useState<string | null>(() => {
|
|
87
|
+
const asked = urlParam("episode");
|
|
88
|
+
// The episode this browser last spoke in comes before a server's default, as on the phone page.
|
|
89
|
+
const remembered = readLocal(EPISODE_KEY);
|
|
90
|
+
return (
|
|
91
|
+
(asked && EPISODE_NAME.test(asked) ? asked : null) ??
|
|
92
|
+
episodeOf(requestedDoc()) ??
|
|
93
|
+
(remembered && EPISODE_NAME.test(remembered) ? remembered : null) ??
|
|
94
|
+
defaultEpisode
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
const [episodes, setEpisodes] = useState<{ path: string; number: number }[]>([]);
|
|
98
|
+
const [rec, setRec] = useState<RecorderSnapshot>(IDLE);
|
|
99
|
+
const [thread, setThread] = useState<ThreadSnapshot>(EMPTY);
|
|
100
|
+
const [playing, setPlayingState] = useState<string | null>(null);
|
|
101
|
+
const [unheard, setUnheard] = useState<string[]>([]);
|
|
102
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
103
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
104
|
+
const [now, setNow] = useState(() => Date.now());
|
|
105
|
+
|
|
106
|
+
// What the long-lived recorder and thread read when they call back.
|
|
107
|
+
const stageRef = useRef(stage);
|
|
108
|
+
const episodeRef = useRef(episode);
|
|
109
|
+
const autoplayRef = useRef(autoplay);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
stageRef.current = stage;
|
|
112
|
+
episodeRef.current = episode;
|
|
113
|
+
autoplayRef.current = autoplay;
|
|
114
|
+
});
|
|
115
|
+
const recRef = useRef<RecorderSnapshot>(IDLE);
|
|
116
|
+
const playingRef = useRef<string | null>(null);
|
|
117
|
+
const queue = useRef<CompanionReply[]>([]);
|
|
118
|
+
/** The reply stopped because the person pressed Record: its sequence goes on after the take. */
|
|
119
|
+
const heldForTake = useRef(false);
|
|
120
|
+
const waitingMarks = useRef<string[]>([]);
|
|
121
|
+
const recorder = useRef<TakeRecorder | null>(null);
|
|
122
|
+
const threadRef = useRef<CompanionThread | null>(null);
|
|
123
|
+
|
|
124
|
+
// Her voice: the reply's own recordings; a step without one is read, never spoken in
|
|
125
|
+
// a voice that is not hers.
|
|
126
|
+
const voice = useMemo(() => browserVoice({ recordings: true, speech: false }), []);
|
|
127
|
+
|
|
128
|
+
const setPlaying = (id: string | null) => {
|
|
129
|
+
playingRef.current = id;
|
|
130
|
+
setPlayingState(id);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** The latest `play`, for the calls that outlive a render (a queued reply, a callback). */
|
|
134
|
+
const playRef = useRef<(reply: CompanionReply, from?: number) => Promise<void>>(async () => {});
|
|
135
|
+
const play = useCallback(
|
|
136
|
+
async (reply: CompanionReply, from = 0) => {
|
|
137
|
+
setPlaying(reply.id);
|
|
138
|
+
setUnheard((u) => u.filter((id) => id !== reply.id));
|
|
139
|
+
// A reply that walks the diagram needs the board. On a phone the dock folds to its
|
|
140
|
+
// button; on a wider screen it stays beside the board and follows the walk step by step.
|
|
141
|
+
if (reply.steps?.length && window.matchMedia("(max-width: 767px)").matches) setOpenState(false);
|
|
142
|
+
const more = queue.current.length;
|
|
143
|
+
setNotice(more ? `${more} more of ${WHO}'s replies wait after this one.` : null);
|
|
144
|
+
const done = await stageRef.current.present(replyTour(reply, BASE, WHO), { voice, origin: WHO, from });
|
|
145
|
+
// Another reply took the stage from this one: that play owns what follows.
|
|
146
|
+
if (playingRef.current !== reply.id) return;
|
|
147
|
+
setPlaying(null);
|
|
148
|
+
if (heldForTake.current) return;
|
|
149
|
+
if (!done) {
|
|
150
|
+
// Stopped, or the stage was given to something else: the sequence is over.
|
|
151
|
+
queue.current = [];
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const next = queue.current.shift();
|
|
155
|
+
if (next && !busy(recRef.current.state)) void playRef.current(next);
|
|
156
|
+
else if (next) queue.current.unshift(next);
|
|
157
|
+
},
|
|
158
|
+
[voice],
|
|
159
|
+
);
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
playRef.current = play;
|
|
162
|
+
}, [play]);
|
|
163
|
+
|
|
164
|
+
const arrived = useCallback((reply: CompanionReply) => {
|
|
165
|
+
const id = reply.id;
|
|
166
|
+
if (!autoplayRef.current) {
|
|
167
|
+
setUnheard((u) => (u.includes(id) ? u : [...u, id]));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (busy(recRef.current.state) || playingRef.current) {
|
|
171
|
+
setUnheard((u) => (u.includes(id) ? u : [...u, id]));
|
|
172
|
+
if (!queue.current.some((r) => r.id === id)) queue.current.push(reply);
|
|
173
|
+
setNotice(
|
|
174
|
+
busy(recRef.current.state)
|
|
175
|
+
? `${WHO} replied at ${when(reply.at)}. Her voice waits until you have sent your take.`
|
|
176
|
+
: `${WHO} replied again at ${when(reply.at)}. It plays when this one ends.`,
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
void playRef.current(reply);
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
// The recorder and the conversation live as long as the page.
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const t = createCompanionThread({ base: BASE, onChange: setThread, onReply: arrived });
|
|
186
|
+
const r = createTakeRecorder({
|
|
187
|
+
base: BASE,
|
|
188
|
+
episode: () => episodeRef.current,
|
|
189
|
+
context: () => {
|
|
190
|
+
// A take spoken while a step is on the board carries that step: what was lit and said.
|
|
191
|
+
const on = stageRef.current.shown;
|
|
192
|
+
const step = on
|
|
193
|
+
? {
|
|
194
|
+
...(on.title || on.step.title ? { title: [on.title, on.step.title].filter(Boolean).join(" · ") } : {}),
|
|
195
|
+
say: on.step.say.slice(0, 400),
|
|
196
|
+
...(on.index !== null && on.count !== null ? { index: on.index, count: on.count } : {}),
|
|
197
|
+
...(on.origin ? { by: on.origin } : {}),
|
|
198
|
+
}
|
|
199
|
+
: null;
|
|
200
|
+
return takeContext(stageRef.current.lastReport(), {
|
|
201
|
+
...(waitingMarks.current.length ? { marks: waitingMarks.current } : {}),
|
|
202
|
+
...(on?.step.spotlight?.length ? { spotlight: on.step.spotlight } : {}),
|
|
203
|
+
...(step ? { step } : {}),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
onChange: (s) => {
|
|
207
|
+
const was = recRef.current.state;
|
|
208
|
+
recRef.current = s;
|
|
209
|
+
setRec(s);
|
|
210
|
+
// Once a take is sent (or the microphone closed), what waited for it plays in order.
|
|
211
|
+
if (!busy(was) || busy(s.state)) return;
|
|
212
|
+
heldForTake.current = false;
|
|
213
|
+
if (!autoplayRef.current || playingRef.current) return;
|
|
214
|
+
const next = queue.current.shift();
|
|
215
|
+
if (next) void playRef.current(next);
|
|
216
|
+
else setNotice(null);
|
|
217
|
+
},
|
|
218
|
+
onStored: (take) => t.waitFor(take),
|
|
219
|
+
});
|
|
220
|
+
threadRef.current = t;
|
|
221
|
+
recorder.current = r;
|
|
222
|
+
recRef.current = r.getSnapshot();
|
|
223
|
+
setRec(r.getSnapshot());
|
|
224
|
+
return () => {
|
|
225
|
+
r.destroy();
|
|
226
|
+
t.destroy();
|
|
227
|
+
threadRef.current = null;
|
|
228
|
+
recorder.current = null;
|
|
229
|
+
};
|
|
230
|
+
}, [arrived]);
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
threadRef.current?.setEpisode(episode);
|
|
234
|
+
}, [episode]);
|
|
235
|
+
|
|
236
|
+
// The service's own default episode, when nothing else named one; and whether it answers.
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
let live = true;
|
|
239
|
+
fetch(`${BASE}health`, { cache: "no-store" })
|
|
240
|
+
.then((r) => r.json())
|
|
241
|
+
.then((a: { success?: boolean; error?: string; defaultEpisode?: string | null }) => {
|
|
242
|
+
if (!live) return;
|
|
243
|
+
if (!a.success) return setProblem(`The companion does not answer: ${a.error ?? "no reason given"}.`);
|
|
244
|
+
setProblem(null);
|
|
245
|
+
if (a.defaultEpisode) setEpisodeState((e) => e ?? a.defaultEpisode ?? null);
|
|
246
|
+
})
|
|
247
|
+
.catch((e) => live && setProblem(`The companion does not answer: ${e instanceof Error ? e.message : String(e)}.`));
|
|
248
|
+
return () => {
|
|
249
|
+
live = false;
|
|
250
|
+
};
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
// The episodes to choose from, read when the dock first opens.
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!open || episodes.length) return;
|
|
256
|
+
let live = true;
|
|
257
|
+
fetch(`${BASE}episodes`, { cache: "no-store" })
|
|
258
|
+
.then((r) => r.json())
|
|
259
|
+
.then((a: { success?: boolean; episodes?: { path: string; number: number }[] }) => {
|
|
260
|
+
if (live && a.success && a.episodes) setEpisodes(a.episodes);
|
|
261
|
+
})
|
|
262
|
+
.catch(() => {});
|
|
263
|
+
return () => {
|
|
264
|
+
live = false;
|
|
265
|
+
};
|
|
266
|
+
}, [open, episodes.length]);
|
|
267
|
+
|
|
268
|
+
// The marks on the open drawing that wait on an agent travel with the take.
|
|
269
|
+
const marksDoc = useRef<string | null>(null);
|
|
270
|
+
const refreshMarks = useCallback(async (force = false) => {
|
|
271
|
+
const doc = stageRef.current.lastReport()?.doc ?? null;
|
|
272
|
+
if (!force && doc === marksDoc.current) return;
|
|
273
|
+
marksDoc.current = doc;
|
|
274
|
+
if (!doc) {
|
|
275
|
+
waitingMarks.current = [];
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const r = await fetch(`/api/marks?doc=${encodeURIComponent(doc)}`, { cache: "no-store" });
|
|
280
|
+
const body = (await r.json()) as { marks?: unknown };
|
|
281
|
+
waitingMarks.current = marksWaitingOn(readMarksFile(body, doc).marks, "agent").map((m) => m.id);
|
|
282
|
+
} catch {
|
|
283
|
+
waitingMarks.current = [];
|
|
284
|
+
}
|
|
285
|
+
}, []);
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
void refreshMarks(true);
|
|
288
|
+
const off = stageRef.current.onMarks(() => void refreshMarks(true));
|
|
289
|
+
const t = setInterval(() => void refreshMarks(), 3000);
|
|
290
|
+
return () => {
|
|
291
|
+
off();
|
|
292
|
+
clearInterval(t);
|
|
293
|
+
};
|
|
294
|
+
}, [refreshMarks]);
|
|
295
|
+
|
|
296
|
+
// The clock on a take waiting for its reply.
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (!thread.waiting) return;
|
|
299
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
300
|
+
return () => clearInterval(t);
|
|
301
|
+
}, [thread.waiting]);
|
|
302
|
+
|
|
303
|
+
const onRecord = useCallback(() => {
|
|
304
|
+
const r = recorder.current;
|
|
305
|
+
if (!r) return;
|
|
306
|
+
if (!busy(recRef.current.state) && recRef.current.state !== "retry" && playingRef.current) {
|
|
307
|
+
// Her voice and his microphone do not share the room: she stops, and what waited
|
|
308
|
+
// after her plays once his take is sent.
|
|
309
|
+
heldForTake.current = true;
|
|
310
|
+
stageRef.current.stop();
|
|
311
|
+
}
|
|
312
|
+
void r.press();
|
|
313
|
+
}, []);
|
|
314
|
+
|
|
315
|
+
const onPlay = useCallback((id: string, from?: number) => {
|
|
316
|
+
const reply = threadRef.current?.getSnapshot().items.find((i): i is CompanionReply => i.kind === "reply" && i.id === id);
|
|
317
|
+
if (!reply) return;
|
|
318
|
+
if (busy(recRef.current.state)) {
|
|
319
|
+
setNotice(`Send your take first. Then press ▶ to hear ${WHO}.`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
queue.current = [];
|
|
323
|
+
heldForTake.current = false;
|
|
324
|
+
void playRef.current(reply, from);
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
const onStop = useCallback(() => {
|
|
328
|
+
queue.current = [];
|
|
329
|
+
heldForTake.current = false;
|
|
330
|
+
stageRef.current.stop();
|
|
331
|
+
}, []);
|
|
332
|
+
|
|
333
|
+
const setOpen = (v: boolean) => {
|
|
334
|
+
setOpenState(v);
|
|
335
|
+
if (v) void refreshMarks(true);
|
|
336
|
+
};
|
|
337
|
+
const setAutoplay = (v: boolean) => {
|
|
338
|
+
setAutoplayState(v);
|
|
339
|
+
writeLocal(AUTOPLAY_KEY, v ? "1" : "0");
|
|
340
|
+
if (!v) queue.current = [];
|
|
341
|
+
};
|
|
342
|
+
const onEpisode = (path: string) => {
|
|
343
|
+
if (!EPISODE_NAME.test(path) || path === episode) return;
|
|
344
|
+
queue.current = [];
|
|
345
|
+
setUnheard([]);
|
|
346
|
+
setNotice(null);
|
|
347
|
+
setEpisodeState(path);
|
|
348
|
+
writeLocal(EPISODE_KEY, path);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const waiting = thread.waiting ? { take: thread.waiting.take, seconds: Math.max(0, Math.floor((now - thread.waiting.since) / 1000)) } : null;
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<div className="stage-talk-host">
|
|
355
|
+
<CompanionDock
|
|
356
|
+
open={open}
|
|
357
|
+
onOpenChange={setOpen}
|
|
358
|
+
who={WHO}
|
|
359
|
+
episode={episode}
|
|
360
|
+
episodes={episodes}
|
|
361
|
+
onEpisode={onEpisode}
|
|
362
|
+
presence={thread.presence}
|
|
363
|
+
recorder={rec}
|
|
364
|
+
onRecord={onRecord}
|
|
365
|
+
items={thread.items}
|
|
366
|
+
unheard={unheard}
|
|
367
|
+
playing={playing}
|
|
368
|
+
playingStep={playing && stage.shown?.origin === WHO ? stage.shown.index : null}
|
|
369
|
+
onPlay={onPlay}
|
|
370
|
+
onStop={onStop}
|
|
371
|
+
onFeedback={(id, value) => {
|
|
372
|
+
threadRef.current?.feedback(id, value).catch((e: unknown) => setProblem(`Your thumb was not kept: ${e instanceof Error ? e.message : String(e)}.`));
|
|
373
|
+
}}
|
|
374
|
+
autoplay={autoplay}
|
|
375
|
+
onAutoplay={setAutoplay}
|
|
376
|
+
waiting={waiting}
|
|
377
|
+
notice={notice}
|
|
378
|
+
error={problem ?? thread.error}
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The stage on the state designer's board (Spec 83): the level on the board,
|
|
5
|
+
* its states and arrows as boxes, from the designer's store. A step that
|
|
6
|
+
* lights a state on another level drills there first — the same focus an
|
|
7
|
+
* agent's `show` gives.
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo } from "react";
|
|
10
|
+
import { childStatesAt } from "@miadi/stateloom-canvas";
|
|
11
|
+
import { machineElementBoxes, parseFocus } from "@miadi/stateloom-protocol";
|
|
12
|
+
import { useDesignerStore } from "@/store/useDesignerStore";
|
|
13
|
+
import { useUrlParam, FOCUS_REQUEST_EVENT } from "@/lib/systemParam";
|
|
14
|
+
import StageLayer from "./StageLayer";
|
|
15
|
+
|
|
16
|
+
export default function MachineStageLayer() {
|
|
17
|
+
const definition = useDesignerStore((s) => s.definition);
|
|
18
|
+
const positions = useDesignerStore((s) => s.layout.positions);
|
|
19
|
+
const navigationPath = useDesignerStore((s) => s.navigationPath);
|
|
20
|
+
const viewport = useDesignerStore((s) => s.viewport);
|
|
21
|
+
const setViewport = useDesignerStore((s) => s.setViewport);
|
|
22
|
+
const docPath = useDesignerStore((s) => s.docPath);
|
|
23
|
+
const focus = useUrlParam("focus");
|
|
24
|
+
// The board is the page's element around the canvas; read when it is needed.
|
|
25
|
+
const boardRef = useMemo(() => ({ get current() { return document.getElementById("machine-board"); } }), []);
|
|
26
|
+
|
|
27
|
+
const path = navigationPath.slice(1);
|
|
28
|
+
const boxes = useMemo(() => {
|
|
29
|
+
if (!definition?.state) return [];
|
|
30
|
+
return machineElementBoxes(childStatesAt(definition.state, path), positions);
|
|
31
|
+
// `path` is derived from navigationPath, which is the dependency.
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
+
}, [definition, positions, navigationPath]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<StageLayer
|
|
37
|
+
docPath={docPath}
|
|
38
|
+
kind="machine"
|
|
39
|
+
viewport={viewport}
|
|
40
|
+
setViewport={setViewport}
|
|
41
|
+
boxes={boxes}
|
|
42
|
+
boardRef={boardRef}
|
|
43
|
+
focus={focus}
|
|
44
|
+
level={navigationPath[navigationPath.length - 1]}
|
|
45
|
+
onReveal={(focuses) => {
|
|
46
|
+
// Drill to the level of the first lit state: the focus machinery knows how.
|
|
47
|
+
const state = focuses.map(parseFocus).find((f) => f?.kind === "state");
|
|
48
|
+
if (!state) return;
|
|
49
|
+
const url = new URL(window.location.href);
|
|
50
|
+
url.searchParams.set("focus", `state:${state.name}`);
|
|
51
|
+
window.history.replaceState(window.history.state, "", url);
|
|
52
|
+
window.dispatchEvent(new Event(FOCUS_REQUEST_EVENT));
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|