@deepwatch/dsh-live 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/lib/capture.d.ts +155 -0
- package/lib/capture.js +301 -0
- package/lib/client/components.d.ts +55 -0
- package/lib/client/components.js +62 -0
- package/lib/client/index.d.ts +14 -0
- package/lib/client/index.js +22 -0
- package/lib/client/live-mode.d.ts +38 -0
- package/lib/client/live-mode.js +72 -0
- package/lib/client.js +951 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/session.d.ts +216 -0
- package/lib/session.js +280 -0
- package/lib/sources-catalogue.d.ts +66 -0
- package/lib/sources-catalogue.js +110 -0
- package/lib/synthetic-source.d.ts +58 -0
- package/lib/synthetic-source.js +96 -0
- package/lib/triggers.d.ts +114 -0
- package/lib/triggers.js +138 -0
- package/package.json +97 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@deepwatch/dsh-live",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
|
+
//#region ../brand/lib/identity.js
|
|
9
|
+
/** Every status the product renders, and the tone it is permitted. */
|
|
10
|
+
const STATUS_TONE = {
|
|
11
|
+
VERIFIED: "success",
|
|
12
|
+
FAILED: "error",
|
|
13
|
+
UNVERIFIED: "caution",
|
|
14
|
+
INCONCLUSIVE: "caution",
|
|
15
|
+
STALE: "caution",
|
|
16
|
+
BLOCKED: "caution",
|
|
17
|
+
queued: "neutral",
|
|
18
|
+
running: "active",
|
|
19
|
+
completed: "info",
|
|
20
|
+
failed: "error",
|
|
21
|
+
cancelled: "neutral",
|
|
22
|
+
current: "neutral",
|
|
23
|
+
gap: "caution",
|
|
24
|
+
expired: "caution",
|
|
25
|
+
unavailable: "caution"
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The tone one status is allowed.
|
|
29
|
+
*
|
|
30
|
+
* Anything unrecognized is `neutral`, never `success`. A new status added
|
|
31
|
+
* elsewhere and not registered here renders as unremarkable rather than
|
|
32
|
+
* accidentally as a win.
|
|
33
|
+
*/
|
|
34
|
+
function toneFor$1(status) {
|
|
35
|
+
return STATUS_TONE[status] ?? "neutral";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The semantic token a tone maps to.
|
|
39
|
+
*
|
|
40
|
+
* Feature packages ask for a tone and get a CSS variable. They never write a
|
|
41
|
+
* hex value, which is what stops the palette from being re-invented slightly
|
|
42
|
+
* differently in every panel — and what makes a theme change one edit.
|
|
43
|
+
*/
|
|
44
|
+
function tokenFor(tone) {
|
|
45
|
+
return `var(--watch-tone-${tone})`;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region ../brand/lib/mark.js
|
|
49
|
+
/**
|
|
50
|
+
* The Watch mark, inlined.
|
|
51
|
+
*
|
|
52
|
+
* Generated by `scripts/brand-assets.mjs` from `assets/watch-orca-master.png`.
|
|
53
|
+
* Do not edit by hand: the master is the brand source of truth and this is a
|
|
54
|
+
* mechanical derivation of it at 64px, which covers a 32px slot at 2x.
|
|
55
|
+
*
|
|
56
|
+
* @module @deepwatch/dsh-client-brand/mark
|
|
57
|
+
*/
|
|
58
|
+
/** The orca, as a transparent PNG data URI. */
|
|
59
|
+
const WATCH_MARK_PNG = "data:image/png;base64,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";
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region ../workspace/lib/client/surface.js
|
|
62
|
+
const MODE_KICKER = {
|
|
63
|
+
Watch: "Trust layer",
|
|
64
|
+
Live: "Observation",
|
|
65
|
+
Memory: "Knowledge",
|
|
66
|
+
Library: "Evidence library",
|
|
67
|
+
Compare: "Change analysis"
|
|
68
|
+
};
|
|
69
|
+
/** Stable global class names; their rules live with the product theme. */
|
|
70
|
+
const C = {
|
|
71
|
+
root: "watch-mode-root",
|
|
72
|
+
hero: "watch-mode-hero",
|
|
73
|
+
markFrame: "watch-mode-mark-frame",
|
|
74
|
+
mark: "watch-mode-mark",
|
|
75
|
+
heroCopy: "watch-mode-hero-copy",
|
|
76
|
+
eyebrow: "watch-mode-eyebrow",
|
|
77
|
+
title: "watch-mode-title",
|
|
78
|
+
lead: "watch-mode-lead",
|
|
79
|
+
localBadge: "watch-mode-local-badge",
|
|
80
|
+
body: "watch-mode-body",
|
|
81
|
+
empty: "watch-empty",
|
|
82
|
+
emptyCopy: "watch-empty-copy",
|
|
83
|
+
sectionLabel: "watch-section-label",
|
|
84
|
+
emptyShows: "watch-empty-shows",
|
|
85
|
+
emptyWhy: "watch-empty-why",
|
|
86
|
+
nextBlock: "watch-next-block",
|
|
87
|
+
nextList: "watch-next-list",
|
|
88
|
+
panel: "watch-panel",
|
|
89
|
+
panelHeading: "watch-panel-heading",
|
|
90
|
+
facts: "watch-facts",
|
|
91
|
+
factRow: "watch-fact-row",
|
|
92
|
+
factKey: "watch-fact-key",
|
|
93
|
+
factValue: "watch-fact-value",
|
|
94
|
+
note: "watch-note",
|
|
95
|
+
noteMark: "watch-note-mark",
|
|
96
|
+
unavailable: "watch-unavailable",
|
|
97
|
+
unavailableHead: "watch-unavailable-head",
|
|
98
|
+
unavailableBadge: "watch-unavailable-badge",
|
|
99
|
+
unavailableBecause: "watch-unavailable-because",
|
|
100
|
+
requirements: "watch-requirements"
|
|
101
|
+
};
|
|
102
|
+
/** The frame: a title, one sentence of what this is, then the body. */
|
|
103
|
+
function ModeSurface({ title, lead, children }) {
|
|
104
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
105
|
+
className: C.root,
|
|
106
|
+
"data-watch-mode": title.toLowerCase(),
|
|
107
|
+
children: [(0, react_jsx_runtime.jsxs)("header", {
|
|
108
|
+
className: C.hero,
|
|
109
|
+
children: [
|
|
110
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
111
|
+
className: C.markFrame,
|
|
112
|
+
"aria-hidden": "true",
|
|
113
|
+
children: (0, react_jsx_runtime.jsx)("img", {
|
|
114
|
+
className: C.mark,
|
|
115
|
+
src: WATCH_MARK_PNG,
|
|
116
|
+
alt: ""
|
|
117
|
+
})
|
|
118
|
+
}),
|
|
119
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
120
|
+
className: C.heroCopy,
|
|
121
|
+
children: [
|
|
122
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
123
|
+
className: C.eyebrow,
|
|
124
|
+
children: `DEEPWATCH / ${MODE_KICKER[title] ?? "Evidence workspace"}`
|
|
125
|
+
}),
|
|
126
|
+
(0, react_jsx_runtime.jsx)("h2", {
|
|
127
|
+
className: C.title,
|
|
128
|
+
children: title
|
|
129
|
+
}),
|
|
130
|
+
(0, react_jsx_runtime.jsx)("p", {
|
|
131
|
+
className: C.lead,
|
|
132
|
+
children: lead
|
|
133
|
+
})
|
|
134
|
+
]
|
|
135
|
+
}),
|
|
136
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
137
|
+
className: C.localBadge,
|
|
138
|
+
children: "Local-first"
|
|
139
|
+
})
|
|
140
|
+
]
|
|
141
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
142
|
+
className: C.body,
|
|
143
|
+
children
|
|
144
|
+
})]
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* What this surface would show, why it shows nothing, and what to do.
|
|
149
|
+
*
|
|
150
|
+
* All three parts are required. "No data" on its own teaches people the product
|
|
151
|
+
* is broken; naming the reason and the next action is the difference between an
|
|
152
|
+
* empty state and a dead end.
|
|
153
|
+
*/
|
|
154
|
+
function EmptyState({ shows, why, next }) {
|
|
155
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
156
|
+
className: C.empty,
|
|
157
|
+
"data-watch-empty-state": "",
|
|
158
|
+
children: [(0, react_jsx_runtime.jsxs)("div", {
|
|
159
|
+
className: C.emptyCopy,
|
|
160
|
+
children: [
|
|
161
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
162
|
+
className: C.sectionLabel,
|
|
163
|
+
children: "What appears here"
|
|
164
|
+
}),
|
|
165
|
+
(0, react_jsx_runtime.jsx)("p", {
|
|
166
|
+
className: C.emptyShows,
|
|
167
|
+
children: shows
|
|
168
|
+
}),
|
|
169
|
+
(0, react_jsx_runtime.jsx)("p", {
|
|
170
|
+
className: C.emptyWhy,
|
|
171
|
+
children: why
|
|
172
|
+
})
|
|
173
|
+
]
|
|
174
|
+
}), next.length === 0 ? null : (0, react_jsx_runtime.jsxs)("div", {
|
|
175
|
+
className: C.nextBlock,
|
|
176
|
+
children: [(0, react_jsx_runtime.jsx)("span", {
|
|
177
|
+
className: C.sectionLabel,
|
|
178
|
+
children: "Start here"
|
|
179
|
+
}), (0, react_jsx_runtime.jsx)("ol", {
|
|
180
|
+
className: C.nextList,
|
|
181
|
+
children: next.map((step) => (0, react_jsx_runtime.jsx)("li", { children: step }, step))
|
|
182
|
+
})]
|
|
183
|
+
})]
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** A titled block of content. */
|
|
187
|
+
function Panel({ heading, children }) {
|
|
188
|
+
return (0, react_jsx_runtime.jsxs)("section", {
|
|
189
|
+
className: C.panel,
|
|
190
|
+
"data-watch-panel": "",
|
|
191
|
+
children: [heading === void 0 ? null : (0, react_jsx_runtime.jsx)("h3", {
|
|
192
|
+
className: C.panelHeading,
|
|
193
|
+
children: heading
|
|
194
|
+
}), children]
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** A key/value grid. Long values wrap rather than forcing the page sideways. */
|
|
198
|
+
function Facts({ rows }) {
|
|
199
|
+
return (0, react_jsx_runtime.jsx)("dl", {
|
|
200
|
+
className: C.facts,
|
|
201
|
+
children: rows.map(([label, value]) => (0, react_jsx_runtime.jsxs)("div", {
|
|
202
|
+
className: C.factRow,
|
|
203
|
+
children: [(0, react_jsx_runtime.jsx)("dt", {
|
|
204
|
+
className: C.factKey,
|
|
205
|
+
children: label
|
|
206
|
+
}), (0, react_jsx_runtime.jsx)("dd", {
|
|
207
|
+
className: C.factValue,
|
|
208
|
+
children: value
|
|
209
|
+
})]
|
|
210
|
+
}, label))
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A capability this build cannot reach, said out loud.
|
|
215
|
+
*
|
|
216
|
+
* Distinct from `EmptyState`: empty means nothing has happened yet, unavailable
|
|
217
|
+
* means the surface could not show it even if something had. Conflating the two
|
|
218
|
+
* is how a person concludes a working feature is broken, or a missing one is
|
|
219
|
+
* merely quiet.
|
|
220
|
+
*/
|
|
221
|
+
function Unavailable({ what, because, wouldNeed }) {
|
|
222
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
223
|
+
className: C.unavailable,
|
|
224
|
+
"data-watch-unavailable": "",
|
|
225
|
+
children: [
|
|
226
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
227
|
+
className: C.unavailableHead,
|
|
228
|
+
children: [(0, react_jsx_runtime.jsx)("h3", { children: what }), (0, react_jsx_runtime.jsx)("span", {
|
|
229
|
+
className: C.unavailableBadge,
|
|
230
|
+
children: "Not available in this build"
|
|
231
|
+
})]
|
|
232
|
+
}),
|
|
233
|
+
(0, react_jsx_runtime.jsx)("p", {
|
|
234
|
+
className: C.unavailableBecause,
|
|
235
|
+
children: because
|
|
236
|
+
}),
|
|
237
|
+
wouldNeed.length === 0 ? null : (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)("h4", {
|
|
238
|
+
className: C.panelHeading,
|
|
239
|
+
children: "What it would take"
|
|
240
|
+
}), (0, react_jsx_runtime.jsx)("ul", {
|
|
241
|
+
className: C.requirements,
|
|
242
|
+
children: wouldNeed.map((item) => (0, react_jsx_runtime.jsx)("li", { children: item }, item))
|
|
243
|
+
})] })
|
|
244
|
+
]
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/sources-catalogue.ts
|
|
249
|
+
const SOURCES = [
|
|
250
|
+
{
|
|
251
|
+
id: "screen",
|
|
252
|
+
name: "Screen",
|
|
253
|
+
what: "The whole display, as a continuous observation.",
|
|
254
|
+
asks: "Asks for screen-capture permission the first time you start it.",
|
|
255
|
+
runtime: "shell",
|
|
256
|
+
needsOsPermission: true,
|
|
257
|
+
canAct: false
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "window",
|
|
261
|
+
name: "Window",
|
|
262
|
+
what: "One application window rather than the whole display.",
|
|
263
|
+
asks: "Asks for screen-capture permission the first time you start it.",
|
|
264
|
+
runtime: "shell",
|
|
265
|
+
needsOsPermission: true,
|
|
266
|
+
canAct: false
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "camera",
|
|
270
|
+
name: "Camera",
|
|
271
|
+
what: "Live visual input from an attached device.",
|
|
272
|
+
asks: "Asks for camera permission the first time you start it.",
|
|
273
|
+
runtime: "device",
|
|
274
|
+
needsOsPermission: true,
|
|
275
|
+
canAct: false
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: "microphone",
|
|
279
|
+
name: "Microphone",
|
|
280
|
+
what: "Live audio, with timings a citation can point at.",
|
|
281
|
+
asks: "Asks for microphone permission the first time you start it.",
|
|
282
|
+
runtime: "device",
|
|
283
|
+
needsOsPermission: true,
|
|
284
|
+
canAct: false
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "browser-observer",
|
|
288
|
+
name: "Browser Observer",
|
|
289
|
+
what: "Watches a page and records what it showed. Takes no action.",
|
|
290
|
+
asks: "No OS permission. Needs the browser runtime enabled.",
|
|
291
|
+
runtime: "process",
|
|
292
|
+
needsOsPermission: false,
|
|
293
|
+
canAct: false
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "browser-operator",
|
|
297
|
+
name: "Browser Operator",
|
|
298
|
+
what: "Acts on a page and returns a receipt for what it did. A separate capability from observing.",
|
|
299
|
+
asks: "No OS permission. Needs the browser runtime enabled, and every side effect carries an idempotency key.",
|
|
300
|
+
runtime: "process",
|
|
301
|
+
needsOsPermission: false,
|
|
302
|
+
canAct: true
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: "synthetic",
|
|
306
|
+
name: "Synthetic source",
|
|
307
|
+
what: "A task-owned page this workspace generates, for exercising capture without touching anything of yours.",
|
|
308
|
+
asks: "No permission. It observes only content this workspace created.",
|
|
309
|
+
runtime: "fixture",
|
|
310
|
+
needsOsPermission: false,
|
|
311
|
+
canAct: false
|
|
312
|
+
}
|
|
313
|
+
];
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/client/live-mode.tsx
|
|
316
|
+
/** Words for a state, so it is never only a colour. */
|
|
317
|
+
const STATE_WORDS = {
|
|
318
|
+
idle: "Not started",
|
|
319
|
+
requesting_permission: "Waiting for your permission",
|
|
320
|
+
starting: "Starting",
|
|
321
|
+
active: "Observing",
|
|
322
|
+
paused: "Paused",
|
|
323
|
+
stopping: "Stopping",
|
|
324
|
+
stopped: "Stopped",
|
|
325
|
+
cancelled: "Cancelled",
|
|
326
|
+
denied: "Permission refused — nothing was captured",
|
|
327
|
+
unavailable: "Source unavailable",
|
|
328
|
+
timed_out: "The source did not start in time",
|
|
329
|
+
failed: "Failed"
|
|
330
|
+
};
|
|
331
|
+
const PERMISSION_WORDS = {
|
|
332
|
+
not_requested: "Not requested",
|
|
333
|
+
requested: "Requested",
|
|
334
|
+
granted: "Granted",
|
|
335
|
+
denied: "Refused"
|
|
336
|
+
};
|
|
337
|
+
function toneFor(state) {
|
|
338
|
+
if (state === "active") return "var(--watch-tone-active)";
|
|
339
|
+
if ([
|
|
340
|
+
"denied",
|
|
341
|
+
"failed",
|
|
342
|
+
"timed_out",
|
|
343
|
+
"unavailable"
|
|
344
|
+
].includes(state)) return "var(--watch-tone-error)";
|
|
345
|
+
if ([
|
|
346
|
+
"paused",
|
|
347
|
+
"starting",
|
|
348
|
+
"requesting_permission",
|
|
349
|
+
"stopping"
|
|
350
|
+
].includes(state)) return "var(--watch-tone-caution)";
|
|
351
|
+
return "var(--watch-tone-neutral)";
|
|
352
|
+
}
|
|
353
|
+
/** The Live mode: what could be observed, and what is being observed. */
|
|
354
|
+
function LiveModeView({ session = null, receipts = [] } = {}) {
|
|
355
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ModeSurface, {
|
|
356
|
+
title: "Live",
|
|
357
|
+
lead: "A continuous observation over a source, with its clock, its gaps and its freshness. Opening this page starts nothing and asks for nothing.",
|
|
358
|
+
children: [
|
|
359
|
+
session === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
|
|
360
|
+
shows: "An active session: which source is bound, its permission state, the observations arriving with their timestamps, and how it ended.",
|
|
361
|
+
why: "No live session is running, and no source has been started.",
|
|
362
|
+
next: ["Choose a source below and start it — each asks for its own permission at that moment, not before.", "Bind ASR or Visual Perception in Settings → Role Bindings if you want a source interpreted rather than only recorded."]
|
|
363
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Panel, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
364
|
+
style: {
|
|
365
|
+
display: "flex",
|
|
366
|
+
alignItems: "baseline",
|
|
367
|
+
gap: "10px",
|
|
368
|
+
flexWrap: "wrap"
|
|
369
|
+
},
|
|
370
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
|
|
371
|
+
style: {
|
|
372
|
+
fontSize: "15px",
|
|
373
|
+
color: toneFor(session.state)
|
|
374
|
+
},
|
|
375
|
+
children: STATE_WORDS[session.state]
|
|
376
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
377
|
+
style: {
|
|
378
|
+
fontSize: "12px",
|
|
379
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
380
|
+
},
|
|
381
|
+
children: session.reason === "" ? session.sourceId : session.reason
|
|
382
|
+
})]
|
|
383
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
384
|
+
style: { marginTop: "12px" },
|
|
385
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Facts, { rows: [
|
|
386
|
+
["Session", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
387
|
+
"data-watch-ltr": true,
|
|
388
|
+
children: session.sessionId
|
|
389
|
+
}, "s")],
|
|
390
|
+
["Source", session.sourceId],
|
|
391
|
+
["Permission", PERMISSION_WORDS[session.permission]],
|
|
392
|
+
["Run", session.runId ?? "Not associated with a run"],
|
|
393
|
+
["Started", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
394
|
+
"data-watch-ltr": true,
|
|
395
|
+
children: session.startedAt ?? "—"
|
|
396
|
+
}, "t")],
|
|
397
|
+
["Observations", String(session.observations.length)]
|
|
398
|
+
] })
|
|
399
|
+
})] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Panel, {
|
|
400
|
+
heading: `Observations (${String(session.observations.length)})`,
|
|
401
|
+
children: session.observations.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
402
|
+
style: {
|
|
403
|
+
fontSize: "13px",
|
|
404
|
+
margin: 0,
|
|
405
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
406
|
+
},
|
|
407
|
+
children: "Nothing observed yet. An observation without a timestamp could not be cited, so none is recorded until the clock is running."
|
|
408
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
|
|
409
|
+
style: {
|
|
410
|
+
margin: 0,
|
|
411
|
+
paddingInlineStart: "20px",
|
|
412
|
+
fontSize: "12.5px",
|
|
413
|
+
lineHeight: 1.7
|
|
414
|
+
},
|
|
415
|
+
children: session.observations.slice(-25).map((observation) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
|
|
416
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
417
|
+
"data-watch-ltr": true,
|
|
418
|
+
style: { color: "var(--dsw-alias-label-tertiary)" },
|
|
419
|
+
children: `+${String(observation.offsetMs)}ms`
|
|
420
|
+
}),
|
|
421
|
+
" ",
|
|
422
|
+
observation.text
|
|
423
|
+
] }, observation.observationId))
|
|
424
|
+
})
|
|
425
|
+
})] }),
|
|
426
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Panel, {
|
|
427
|
+
heading: "Sources",
|
|
428
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Facts, { rows: SOURCES.map((source) => [source.name, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
|
|
429
|
+
source.what,
|
|
430
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("br", {}),
|
|
431
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
432
|
+
style: { color: "var(--dsw-alias-label-tertiary)" },
|
|
433
|
+
children: source.asks
|
|
434
|
+
}),
|
|
435
|
+
source.canAct ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("br", {}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
436
|
+
style: { color: "var(--watch-tone-caution)" },
|
|
437
|
+
children: "This one can act on the world, not only record it."
|
|
438
|
+
})] }) : null
|
|
439
|
+
] }, source.id)]) })
|
|
440
|
+
}),
|
|
441
|
+
receipts.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Panel, {
|
|
442
|
+
heading: `Finished sessions (${String(receipts.length)})`,
|
|
443
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Facts, { rows: receipts.slice(0, 10).map((receipt) => [receipt.sessionId, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
|
|
444
|
+
STATE_WORDS[receipt.finalState],
|
|
445
|
+
` · ${String(receipt.observationCount)} observation(s)`,
|
|
446
|
+
receipt.reason === "" ? "" : ` · ${receipt.reason}`
|
|
447
|
+
] }, receipt.sessionId)]) })
|
|
448
|
+
}),
|
|
449
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Unavailable, {
|
|
450
|
+
what: "Sources this machine cannot provide",
|
|
451
|
+
because: "Every source above keeps its real adapter and its real permission boundary, and each is exercised deterministically. A source is only offered as startable where its adapter can actually run here — a control that fails when pressed teaches people the product is broken rather than that a capability is absent.",
|
|
452
|
+
wouldNeed: [
|
|
453
|
+
"The hardware or OS support the adapter names, on the machine running Watch.",
|
|
454
|
+
"The relevant OS permission, granted at first use rather than on load.",
|
|
455
|
+
"A role bound for interpretation if the stream is to be transcribed or described."
|
|
456
|
+
]
|
|
457
|
+
})
|
|
458
|
+
]
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/session.ts
|
|
463
|
+
/** The default bound. Roughly an hour of dense observation. */
|
|
464
|
+
const DEFAULT_LIMITS = { maxEvents: 2e3 };
|
|
465
|
+
/** Begin a session, before anything has been observed. */
|
|
466
|
+
function startSession(input) {
|
|
467
|
+
return {
|
|
468
|
+
sessionId: input.sessionId,
|
|
469
|
+
target: input.target,
|
|
470
|
+
kind: input.kind,
|
|
471
|
+
status: "starting",
|
|
472
|
+
connection: "connecting",
|
|
473
|
+
cursor: "",
|
|
474
|
+
startedAtMs: input.startedAtMs,
|
|
475
|
+
events: [],
|
|
476
|
+
gaps: [],
|
|
477
|
+
pinned: [],
|
|
478
|
+
clocks: {
|
|
479
|
+
wallMs: null,
|
|
480
|
+
mediaMs: null,
|
|
481
|
+
sessionMs: 0,
|
|
482
|
+
latencyMs: null
|
|
483
|
+
},
|
|
484
|
+
trimmed: 0,
|
|
485
|
+
needsSnapshot: false,
|
|
486
|
+
lastError: null
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** Recompute the clocks from the newest event. */
|
|
490
|
+
function clocksFrom(state, events, nowMs) {
|
|
491
|
+
const newest = events[events.length - 1];
|
|
492
|
+
if (newest === void 0) return {
|
|
493
|
+
wallMs: null,
|
|
494
|
+
mediaMs: null,
|
|
495
|
+
sessionMs: nowMs - state.startedAtMs,
|
|
496
|
+
latencyMs: null
|
|
497
|
+
};
|
|
498
|
+
return {
|
|
499
|
+
wallMs: newest.at,
|
|
500
|
+
mediaMs: newest.mediaMs,
|
|
501
|
+
sessionMs: nowMs - state.startedAtMs,
|
|
502
|
+
latencyMs: Math.max(0, nowMs - newest.at)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Trim the buffer to its bound.
|
|
507
|
+
*
|
|
508
|
+
* Gaps and pinned events survive. The trim count is carried so the surface can
|
|
509
|
+
* say "1,204 earlier events dropped" rather than presenting a partial history
|
|
510
|
+
* as a complete one.
|
|
511
|
+
*/
|
|
512
|
+
function bound(events, pinned, limits) {
|
|
513
|
+
if (events.length <= limits.maxEvents) return {
|
|
514
|
+
events,
|
|
515
|
+
dropped: 0
|
|
516
|
+
};
|
|
517
|
+
const pinnedEvidence = new Set(pinned.flatMap((moment) => moment.evidenceIds));
|
|
518
|
+
const keepAlways = (event) => event.kind === "gap" || event.evidenceIds.some((id) => pinnedEvidence.has(id));
|
|
519
|
+
const protectedEvents = events.filter(keepAlways);
|
|
520
|
+
const ordinary = events.filter((event) => !keepAlways(event));
|
|
521
|
+
const room = Math.max(0, limits.maxEvents - protectedEvents.length);
|
|
522
|
+
const kept = ordinary.slice(Math.max(0, ordinary.length - room));
|
|
523
|
+
const keptIds = new Set([...protectedEvents, ...kept].map((event) => event.seq));
|
|
524
|
+
return {
|
|
525
|
+
events: events.filter((event) => keptIds.has(event.seq)),
|
|
526
|
+
dropped: ordinary.length - kept.length
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/** Mint the gap a cursor break implies, with the range it actually covers. */
|
|
530
|
+
function cursorBreakGap(state, delta) {
|
|
531
|
+
const last = state.events[state.events.length - 1];
|
|
532
|
+
const next = delta.events[0];
|
|
533
|
+
if (last === void 0 || next === void 0) return null;
|
|
534
|
+
return {
|
|
535
|
+
seq: last.seq + .5,
|
|
536
|
+
cursor: state.cursor,
|
|
537
|
+
kind: "gap",
|
|
538
|
+
at: last.at,
|
|
539
|
+
mediaMs: last.mediaMs,
|
|
540
|
+
text: "observation gap — the stream did not continue from the last cursor",
|
|
541
|
+
range: last.mediaMs === null || next.mediaMs === null ? null : {
|
|
542
|
+
startMs: last.mediaMs,
|
|
543
|
+
endMs: next.mediaMs
|
|
544
|
+
},
|
|
545
|
+
evidenceIds: []
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Apply one delta or snapshot to the session.
|
|
550
|
+
*
|
|
551
|
+
* The three cases, in the order they are checked:
|
|
552
|
+
*
|
|
553
|
+
* 1. **A snapshot** replaces the event view outright and clears
|
|
554
|
+
* `needsSnapshot`. This is the only thing that re-establishes continuity.
|
|
555
|
+
* 2. **A delta that continues from the held cursor** appends. This is the
|
|
556
|
+
* ordinary case.
|
|
557
|
+
* 3. **A delta that does not** is a break. A gap event is minted at the seam,
|
|
558
|
+
* `needsSnapshot` is set, and the delta's events are *not* appended —
|
|
559
|
+
* appending them would splice two discontinuous stretches into something
|
|
560
|
+
* that reads as continuous.
|
|
561
|
+
*
|
|
562
|
+
* Re-applying the same delta is a no-op, which is what makes a retry safe.
|
|
563
|
+
*/
|
|
564
|
+
function applyDelta(state, delta, nowMs, limits = DEFAULT_LIMITS) {
|
|
565
|
+
const engineGaps = delta.gaps ?? [];
|
|
566
|
+
if (delta.isSnapshot) {
|
|
567
|
+
const { events, dropped } = bound(delta.events, state.pinned, limits);
|
|
568
|
+
return {
|
|
569
|
+
...state,
|
|
570
|
+
status: delta.status,
|
|
571
|
+
connection: delta.status === "observing" ? "live" : state.connection,
|
|
572
|
+
cursor: delta.nextCursor,
|
|
573
|
+
events,
|
|
574
|
+
gaps: mergeGaps(state.gaps, [...engineGaps, ...rangesOf(delta.events)]),
|
|
575
|
+
clocks: clocksFrom(state, events, nowMs),
|
|
576
|
+
trimmed: state.trimmed + dropped,
|
|
577
|
+
needsSnapshot: false,
|
|
578
|
+
lastError: null
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (delta.nextCursor === state.cursor) return state;
|
|
582
|
+
if (delta.fromCursor !== state.cursor) {
|
|
583
|
+
const gap = cursorBreakGap(state, delta);
|
|
584
|
+
const events = gap === null ? state.events : [...state.events, gap];
|
|
585
|
+
return {
|
|
586
|
+
...state,
|
|
587
|
+
connection: "reconnecting",
|
|
588
|
+
events,
|
|
589
|
+
gaps: mergeGaps(state.gaps, gap?.range === null || gap?.range === void 0 ? [] : [gap.range]),
|
|
590
|
+
clocks: clocksFrom(state, events, nowMs),
|
|
591
|
+
needsSnapshot: true,
|
|
592
|
+
lastError: `cursor ${delta.fromCursor} does not continue from ` + (state.cursor === "" ? "(start)" : state.cursor)
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
const { events, dropped } = bound([...state.events, ...delta.events], state.pinned, limits);
|
|
596
|
+
return {
|
|
597
|
+
...state,
|
|
598
|
+
status: delta.status,
|
|
599
|
+
connection: delta.status === "observing" ? "live" : state.connection,
|
|
600
|
+
cursor: delta.nextCursor,
|
|
601
|
+
events,
|
|
602
|
+
gaps: mergeGaps(state.gaps, [...engineGaps, ...rangesOf(delta.events)]),
|
|
603
|
+
clocks: clocksFrom(state, events, nowMs),
|
|
604
|
+
trimmed: state.trimmed + dropped,
|
|
605
|
+
lastError: null
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/** Ranges of the gap events in a batch. */
|
|
609
|
+
function rangesOf(events) {
|
|
610
|
+
return events.filter((event) => event.kind === "gap" && event.range !== null).map((event) => event.range);
|
|
611
|
+
}
|
|
612
|
+
/** Union of two gap lists, deduplicated and ordered. */
|
|
613
|
+
function mergeGaps(existing, incoming) {
|
|
614
|
+
const seen = /* @__PURE__ */ new Map();
|
|
615
|
+
for (const range of [...existing, ...incoming]) seen.set(`${String(range.startMs)}:${String(range.endMs)}`, range);
|
|
616
|
+
return [...seen.values()].sort((left, right) => left.startMs - right.startMs);
|
|
617
|
+
}
|
|
618
|
+
/** Record that the channel dropped. */
|
|
619
|
+
function connectionLost(state, reason) {
|
|
620
|
+
return {
|
|
621
|
+
...state,
|
|
622
|
+
connection: "lost",
|
|
623
|
+
needsSnapshot: true,
|
|
624
|
+
lastError: reason
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/** Record that a reconnect attempt is under way. */
|
|
628
|
+
function reconnecting(state) {
|
|
629
|
+
return {
|
|
630
|
+
...state,
|
|
631
|
+
connection: "reconnecting"
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Pin a moment.
|
|
636
|
+
*
|
|
637
|
+
* Pinning protects the events it references from being trimmed. That is the
|
|
638
|
+
* whole reason pins take evidence ids: a pin that pointed only at a timestamp
|
|
639
|
+
* would survive a trim while the thing it pointed at did not.
|
|
640
|
+
*/
|
|
641
|
+
function pinMoment(state, moment) {
|
|
642
|
+
if (state.pinned.some((existing) => existing.momentId === moment.momentId)) return state;
|
|
643
|
+
return {
|
|
644
|
+
...state,
|
|
645
|
+
pinned: [...state.pinned, moment]
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/** Stop the session, keeping or discarding what it observed. */
|
|
649
|
+
function finish(state, finalize) {
|
|
650
|
+
return {
|
|
651
|
+
...state,
|
|
652
|
+
status: finalize ? "finalized" : "discarded",
|
|
653
|
+
connection: "stopped"
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
/** Freeze a finished session into its replay record. */
|
|
657
|
+
function toReplay(state) {
|
|
658
|
+
return {
|
|
659
|
+
sessionId: state.sessionId,
|
|
660
|
+
target: state.target,
|
|
661
|
+
kind: state.kind,
|
|
662
|
+
status: state.status,
|
|
663
|
+
events: state.events,
|
|
664
|
+
gaps: state.gaps,
|
|
665
|
+
pinned: state.pinned,
|
|
666
|
+
trimmed: state.trimmed
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Digest of a replay, so "reopening it shows the same thing" is assertable.
|
|
671
|
+
*
|
|
672
|
+
* FNV-1a over event identity and gap ranges — the same construction the
|
|
673
|
+
* trajectory projection uses, and for the same reason.
|
|
674
|
+
*/
|
|
675
|
+
function replayDigest(replay) {
|
|
676
|
+
let hash = 2166136261;
|
|
677
|
+
const feed = (text) => {
|
|
678
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
679
|
+
hash ^= text.charCodeAt(index);
|
|
680
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
feed(`${replay.sessionId}|${replay.status}|${String(replay.trimmed)}`);
|
|
684
|
+
for (const event of replay.events) feed(`|${String(event.seq)}:${event.kind}:${event.cursor}`);
|
|
685
|
+
for (const gap of replay.gaps) feed(`|gap:${String(gap.startMs)}-${String(gap.endMs)}`);
|
|
686
|
+
return hash.toString(16).padStart(8, "0");
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* One line describing the session's honesty, for the surface header.
|
|
690
|
+
*
|
|
691
|
+
* Always says the gap count and the trim count, including when both are zero.
|
|
692
|
+
* A message that appears only when something is wrong is a message people
|
|
693
|
+
* learn to not look for.
|
|
694
|
+
*/
|
|
695
|
+
function describeContinuity(state) {
|
|
696
|
+
const parts = [state.gaps.length === 0 ? "no gaps" : `${String(state.gaps.length)} gap(s)`, state.trimmed === 0 ? "nothing dropped" : `${String(state.trimmed)} earlier event(s) dropped`];
|
|
697
|
+
if (state.needsSnapshot) parts.push("view not continuous — resnapshot pending");
|
|
698
|
+
return parts.join(" · ");
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/client/components.tsx
|
|
702
|
+
/** Connection states, mapped onto the brand's status vocabulary. */
|
|
703
|
+
const CONNECTION_STATUS = {
|
|
704
|
+
connecting: "queued",
|
|
705
|
+
live: "running",
|
|
706
|
+
reconnecting: "gap",
|
|
707
|
+
lost: "unavailable",
|
|
708
|
+
stopped: "completed"
|
|
709
|
+
};
|
|
710
|
+
/** A millisecond count as a clock reading. */
|
|
711
|
+
function clock(ms) {
|
|
712
|
+
if (ms === null) return "—";
|
|
713
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
714
|
+
const seconds = String(total % 60).padStart(2, "0");
|
|
715
|
+
const minutes = String(Math.floor(total / 60) % 60).padStart(2, "0");
|
|
716
|
+
return `${String(Math.floor(total / 3600)).padStart(2, "0")}:${minutes}:${seconds}`;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Session, media and wall clocks, side by side.
|
|
720
|
+
*
|
|
721
|
+
* Three readings rather than one, because they answer three questions and
|
|
722
|
+
* disagree constantly during a live observation. A single "time" field would
|
|
723
|
+
* be right about one of them and quietly wrong about the other two.
|
|
724
|
+
*/
|
|
725
|
+
function LiveHeader({ state }) {
|
|
726
|
+
const status = CONNECTION_STATUS[state.connection] ?? "unavailable";
|
|
727
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
728
|
+
"data-watch-live-header": "",
|
|
729
|
+
"data-watch-live-session": state.sessionId,
|
|
730
|
+
children: [
|
|
731
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
732
|
+
"data-watch-field": "target",
|
|
733
|
+
dir: "ltr",
|
|
734
|
+
children: state.target
|
|
735
|
+
}),
|
|
736
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
737
|
+
"data-watch-field": "kind",
|
|
738
|
+
children: state.kind
|
|
739
|
+
}),
|
|
740
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
741
|
+
"data-watch-field": "status",
|
|
742
|
+
children: state.status
|
|
743
|
+
}),
|
|
744
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
745
|
+
"data-watch-field": "connection",
|
|
746
|
+
style: { color: tokenFor(toneFor$1(status)) },
|
|
747
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
748
|
+
"aria-hidden": "true",
|
|
749
|
+
children: state.connection === "live" ? "●" : "▲"
|
|
750
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: state.connection })]
|
|
751
|
+
}),
|
|
752
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
753
|
+
"data-watch-field": "session-clock",
|
|
754
|
+
dir: "ltr",
|
|
755
|
+
children: `session ${clock(state.clocks.sessionMs)}`
|
|
756
|
+
}),
|
|
757
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
758
|
+
"data-watch-field": "media-clock",
|
|
759
|
+
dir: "ltr",
|
|
760
|
+
children: `media ${clock(state.clocks.mediaMs)}`
|
|
761
|
+
}),
|
|
762
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
763
|
+
"data-watch-field": "wall-clock",
|
|
764
|
+
dir: "ltr",
|
|
765
|
+
children: state.clocks.wallMs === null ? "wall —" : `wall ${new Date(state.clocks.wallMs).toISOString()}`
|
|
766
|
+
}),
|
|
767
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
768
|
+
"data-watch-field": "latency",
|
|
769
|
+
children: state.clocks.latencyMs === null ? "latency —" : `latency ${String(state.clocks.latencyMs)}ms`
|
|
770
|
+
}),
|
|
771
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
772
|
+
"data-watch-field": "continuity",
|
|
773
|
+
children: describeContinuity(state)
|
|
774
|
+
})
|
|
775
|
+
]
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* One observed event.
|
|
780
|
+
*
|
|
781
|
+
* A gap draws as a gap — dashed, glyphed and labelled — and is never
|
|
782
|
+
* collapsed into the events around it.
|
|
783
|
+
*/
|
|
784
|
+
function LiveEventRow({ event, onSelect }) {
|
|
785
|
+
const isGap = event.kind === "gap";
|
|
786
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
787
|
+
"data-watch-live-event": event.kind,
|
|
788
|
+
"data-watch-seq": String(event.seq),
|
|
789
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
790
|
+
type: "button",
|
|
791
|
+
onClick: () => {
|
|
792
|
+
onSelect(event);
|
|
793
|
+
},
|
|
794
|
+
style: {
|
|
795
|
+
font: "inherit",
|
|
796
|
+
color: "inherit",
|
|
797
|
+
cursor: "pointer",
|
|
798
|
+
background: isGap ? "var(--watch-wash-caution)" : "none",
|
|
799
|
+
border: isGap ? "1px dashed var(--watch-tone-caution)" : "1px solid transparent",
|
|
800
|
+
textAlign: "start",
|
|
801
|
+
width: "100%"
|
|
802
|
+
},
|
|
803
|
+
children: [
|
|
804
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
805
|
+
dir: "ltr",
|
|
806
|
+
style: { fontVariantNumeric: "tabular-nums" },
|
|
807
|
+
children: clock(event.mediaMs)
|
|
808
|
+
}),
|
|
809
|
+
isGap && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
810
|
+
"aria-hidden": "true",
|
|
811
|
+
children: " ⌇ "
|
|
812
|
+
}),
|
|
813
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
814
|
+
dir: "auto",
|
|
815
|
+
children: event.text
|
|
816
|
+
})
|
|
817
|
+
]
|
|
818
|
+
})
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* The Live mode body.
|
|
823
|
+
*
|
|
824
|
+
* `Resnapshot needed` is rendered as a banner rather than a toast. A transient
|
|
825
|
+
* notification for "your view of this is not continuous" is a notification
|
|
826
|
+
* that will be missed exactly when it matters.
|
|
827
|
+
*/
|
|
828
|
+
function LiveSurface(props) {
|
|
829
|
+
const { state } = props;
|
|
830
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
831
|
+
"data-watch-live": "",
|
|
832
|
+
"aria-label": "Live observation",
|
|
833
|
+
children: [
|
|
834
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveHeader, { state }),
|
|
835
|
+
state.needsSnapshot && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
836
|
+
role: "alert",
|
|
837
|
+
"data-watch-live-resnapshot": "",
|
|
838
|
+
style: {
|
|
839
|
+
borderInlineStart: "3px solid var(--watch-tone-caution)",
|
|
840
|
+
paddingInlineStart: "8px"
|
|
841
|
+
},
|
|
842
|
+
children: [
|
|
843
|
+
"This view is not continuous. ",
|
|
844
|
+
state.lastError ?? "The stream did not continue from the last cursor.",
|
|
845
|
+
" A fresh snapshot is needed before anything here can be read as unbroken."
|
|
846
|
+
]
|
|
847
|
+
}),
|
|
848
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
849
|
+
"data-watch-live-controls": "",
|
|
850
|
+
children: [
|
|
851
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
852
|
+
type: "button",
|
|
853
|
+
"data-watch-action": "start",
|
|
854
|
+
onClick: props.onStart,
|
|
855
|
+
children: "Start"
|
|
856
|
+
}),
|
|
857
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
858
|
+
type: "button",
|
|
859
|
+
"data-watch-action": "pin",
|
|
860
|
+
onClick: props.onPin,
|
|
861
|
+
children: "Pin moment"
|
|
862
|
+
}),
|
|
863
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
864
|
+
type: "button",
|
|
865
|
+
"data-watch-action": "finalize",
|
|
866
|
+
onClick: () => {
|
|
867
|
+
props.onStop(true);
|
|
868
|
+
},
|
|
869
|
+
children: "Stop and keep"
|
|
870
|
+
}),
|
|
871
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
872
|
+
type: "button",
|
|
873
|
+
"data-watch-action": "discard",
|
|
874
|
+
onClick: () => {
|
|
875
|
+
props.onStop(false);
|
|
876
|
+
},
|
|
877
|
+
children: "Stop and discard"
|
|
878
|
+
})
|
|
879
|
+
]
|
|
880
|
+
}),
|
|
881
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
882
|
+
"data-watch-live-events": "",
|
|
883
|
+
style: {
|
|
884
|
+
listStyle: "none",
|
|
885
|
+
margin: 0,
|
|
886
|
+
padding: 0
|
|
887
|
+
},
|
|
888
|
+
children: state.events.map((event) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveEventRow, {
|
|
889
|
+
event,
|
|
890
|
+
onSelect: props.onSelect
|
|
891
|
+
}, String(event.seq)))
|
|
892
|
+
}),
|
|
893
|
+
state.pinned.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
894
|
+
"data-watch-live-pinned": "",
|
|
895
|
+
"aria-label": "Pinned moments",
|
|
896
|
+
children: state.pinned.map((moment) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
897
|
+
"data-watch-pinned": moment.momentId,
|
|
898
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
899
|
+
dir: "ltr",
|
|
900
|
+
children: clock(moment.atMediaMs)
|
|
901
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
902
|
+
dir: "auto",
|
|
903
|
+
children: ` ${moment.note}`
|
|
904
|
+
})]
|
|
905
|
+
}, moment.momentId))
|
|
906
|
+
}),
|
|
907
|
+
state.trimmed > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
908
|
+
"data-watch-live-trimmed": String(state.trimmed),
|
|
909
|
+
children: [`${String(state.trimmed)} earlier event(s) are no longer held in this view. `, "Gaps and pinned moments were kept."]
|
|
910
|
+
})
|
|
911
|
+
]
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/client/index.tsx
|
|
916
|
+
/** Services this half needs before it can register anything. */
|
|
917
|
+
const inject = ["slots"];
|
|
918
|
+
/** Register the Live mode body. */
|
|
919
|
+
function apply(ctx) {
|
|
920
|
+
const slots = ctx.slots;
|
|
921
|
+
slots.inject("conversation.view", () => {
|
|
922
|
+
slots.register({
|
|
923
|
+
name: "conversation.view",
|
|
924
|
+
id: "live",
|
|
925
|
+
label: "Live",
|
|
926
|
+
order: 30
|
|
927
|
+
}, LiveModeView);
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
exports.DEFAULT_LIMITS = DEFAULT_LIMITS;
|
|
932
|
+
exports.LiveEventRow = LiveEventRow;
|
|
933
|
+
exports.LiveHeader = LiveHeader;
|
|
934
|
+
exports.LiveModeView = LiveModeView;
|
|
935
|
+
exports.LiveSurface = LiveSurface;
|
|
936
|
+
exports.apply = apply;
|
|
937
|
+
exports.applyDelta = applyDelta;
|
|
938
|
+
exports.connectionLost = connectionLost;
|
|
939
|
+
exports.describeContinuity = describeContinuity;
|
|
940
|
+
exports.finish = finish;
|
|
941
|
+
exports.inject = inject;
|
|
942
|
+
exports.pinMoment = pinMoment;
|
|
943
|
+
exports.reconnecting = reconnecting;
|
|
944
|
+
exports.replayDigest = replayDigest;
|
|
945
|
+
exports.startSession = startSession;
|
|
946
|
+
exports.toReplay = toReplay;
|
|
947
|
+
return module.exports;
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
//# sourceMappingURL=client.js.map
|