@alexkroman1/aai-cli 6.6.0 → 6.7.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.
@@ -13,8 +13,8 @@
13
13
  "publish:agent": "aai publish"
14
14
  },
15
15
  "dependencies": {
16
- "@alexkroman1/aai": "^6.6.0",
17
- "@alexkroman1/aai-ui": "^6.6.0",
16
+ "@alexkroman1/aai": "^6.7.0",
17
+ "@alexkroman1/aai-ui": "^6.7.0",
18
18
  "@workflow/world-postgres": "4.3.3",
19
19
  "react": "^19.2.8",
20
20
  "react-dom": "^19.2.8",
@@ -23,7 +23,7 @@
23
23
  "zod": "^4.4.3"
24
24
  },
25
25
  "devDependencies": {
26
- "@alexkroman1/aai-cli": "^6.6.0",
26
+ "@alexkroman1/aai-cli": "^6.7.0",
27
27
  "@tailwindcss/vite": "^4.3.3",
28
28
  "@types/node": "^26.2.0",
29
29
  "@types/react": "^19.2.18",
@@ -106,6 +106,22 @@
106
106
  * whose schema has an object or array property writes those fields itself, in
107
107
  * the same `<Form>` — every field in `@alexkroman1/aai-ui` is a plain named
108
108
  * control, so declared and hand-written ones mix freely.
109
+ *
110
+ * ## Two waits, ONE number
111
+ *
112
+ * The two bars describe the two stretches separately, and neither answers the
113
+ * question a reader comparing the three modes is actually asking: how long from
114
+ * pressing Transcribe to having a transcript. Nothing on the server can answer it
115
+ * either — `output.elapsedMs` is the RUN's own wall clock, so it starts after the
116
+ * bytes are stored in two of the three modes and misses the whole upload, which is
117
+ * most of the wait on a long file over a slow link. Only the browser holds both
118
+ * ends, so `useTotalLatency` is a stopwatch here: started by the submit, ticking
119
+ * across the upload and the run alike, and frozen the moment the run settles.
120
+ *
121
+ * `<TotalLatency>` also prints the SPLIT once the run reports its own elapsed —
122
+ * before the run and inside it — because the two numbers on screen otherwise
123
+ * disagree with no way to see why, and their difference is exactly what picking a
124
+ * mode or unchecking `parallel` moves.
109
125
  */
110
126
 
111
127
  import "@alexkroman1/aai-ui/styles.css";
@@ -124,7 +140,7 @@ import {
124
140
  WorkflowProgress,
125
141
  type WorkflowRun,
126
142
  } from "@alexkroman1/aai-ui";
127
- import { useEffect, useMemo, useState } from "react";
143
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
128
144
  import type { transcribe } from "./agent.ts";
129
145
  import { ApiHelp } from "./api-help.tsx";
130
146
  import {
@@ -187,6 +203,132 @@ const MODES: readonly { mode: Mode; label: string; note: string }[] = [
187
203
  /** Most past runs the history list shows. */
188
204
  const HISTORY_LIMIT = 10;
189
205
 
206
+ /**
207
+ * How often the running stopwatch re-renders.
208
+ *
209
+ * Under a second, so the displayed seconds turn over promptly rather than up to a
210
+ * second late; nothing reads this value, since the elapsed time is measured from
211
+ * the clock at render (see {@link useTotalLatency}).
212
+ */
213
+ const STOPWATCH_TICK_MS = 250;
214
+
215
+ /** What {@link useTotalLatency} reports. */
216
+ type TotalLatency = {
217
+ /**
218
+ * Milliseconds since the submit — ticking while the submission is in flight,
219
+ * frozen at the finish, and undefined before the first one.
220
+ */
221
+ elapsedMs: number | undefined;
222
+ /** Whether the clock is still running, which is what makes the label honest. */
223
+ running: boolean;
224
+ /** Start (or restart) the clock. Called from the form's own submit handler. */
225
+ start: () => void;
226
+ /** Drop it, for a panel that no longer describes the submission it timed. */
227
+ clear: () => void;
228
+ };
229
+
230
+ /**
231
+ * Wall clock from the submit to the finish, across both waits.
232
+ *
233
+ * `inFlight` is the submission's own `pending` — true from `submit()` until the run
234
+ * reaches a terminal status — so the clock covers the upload, the run, and the
235
+ * gap between them, which is the whole of what a reader waits for and is the one
236
+ * measurement no server-side number can make.
237
+ *
238
+ * Two details it would be easy to get wrong:
239
+ *
240
+ * - **The interval re-renders; it does not accumulate.** The elapsed time is read
241
+ * from the clock at render, so a tick the tab throttled or dropped cannot make
242
+ * the number lag behind real time.
243
+ * - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
244
+ * correction (NTP, a laptop waking up) cannot make a transcription look
245
+ * instant — or negative.
246
+ */
247
+ function useTotalLatency(inFlight: boolean): TotalLatency {
248
+ const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
249
+ const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
250
+ // Re-render trigger only — see the doc above.
251
+ const [, tick] = useState(0);
252
+ // Whether `inFlight` has been seen true since the last `start()`. Without it,
253
+ // a start that lands one render before the submission reports itself in flight
254
+ // would freeze the clock at zero instead of running it.
255
+ const began = useRef(false);
256
+
257
+ useEffect(() => {
258
+ if (startedAt === undefined || frozenMs !== undefined) return;
259
+ if (inFlight) {
260
+ began.current = true;
261
+ const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
262
+ return () => clearInterval(id);
263
+ }
264
+ // Measured here rather than at render, so the frozen number is the one at the
265
+ // moment the run settled rather than whenever this page next drew.
266
+ if (began.current) setFrozenMs(performance.now() - startedAt);
267
+ }, [startedAt, frozenMs, inFlight]);
268
+
269
+ const start = useCallback(() => {
270
+ began.current = false;
271
+ setFrozenMs(undefined);
272
+ setStartedAt(performance.now());
273
+ }, []);
274
+
275
+ const clear = useCallback(() => {
276
+ began.current = false;
277
+ setStartedAt(undefined);
278
+ setFrozenMs(undefined);
279
+ }, []);
280
+
281
+ return {
282
+ elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
283
+ running: startedAt !== undefined && frozenMs === undefined,
284
+ start,
285
+ clear,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * The one number the two bars cannot give: click to transcript.
291
+ *
292
+ * Rendered above the run panel rather than inside it, because the stretch it
293
+ * covers starts before there IS a run — in two of the three modes the run does
294
+ * not exist until the upload finishes, so a clock living in the panel would
295
+ * appear only after the wait it is supposed to be timing.
296
+ *
297
+ * `runMs` is the run's own elapsed, once it reports one. The remainder is
298
+ * everything the run could not see: storing the file (or, in streaming mode,
299
+ * minting the upload id), the `POST` that starts the run, and the poll that
300
+ * notices it finished. Clamped at zero, because the two numbers come from two
301
+ * different clocks on two different machines and a few milliseconds the wrong way
302
+ * would otherwise print a negative.
303
+ */
304
+ function TotalLatency({
305
+ elapsedMs,
306
+ running,
307
+ runMs,
308
+ }: {
309
+ elapsedMs: number | undefined;
310
+ running: boolean;
311
+ runMs: number | undefined;
312
+ }) {
313
+ if (elapsedMs === undefined) return null;
314
+ const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
315
+ return (
316
+ <section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
317
+ <h2 className="text-sm font-medium uppercase tracking-[1.2px]">
318
+ {running ? "Elapsed" : "Total latency"}
319
+ </h2>
320
+ <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
321
+ <span className="text-sm tabular-nums">{duration(elapsedMs)}</span>
322
+ {runMs !== undefined && outside !== undefined && (
323
+ <span className="text-xs tabular-nums opacity-60">
324
+ {duration(outside)} before the run · {duration(runMs)} inside it
325
+ </span>
326
+ )}
327
+ </span>
328
+ </section>
329
+ );
330
+ }
331
+
190
332
  function TranscriptionDesk() {
191
333
  const [mode, setMode] = useState<Mode>("streaming");
192
334
  // Whether the browser cuts the recording up and sends the pieces at once. One
@@ -211,6 +353,8 @@ function TranscriptionDesk() {
211
353
  // Which past run the reader is looking at, if any. Its own state rather than
212
354
  // a route, because a workflow app is one page and a run id is not a place.
213
355
  const [openId, setOpenId] = useState<string | undefined>(undefined);
356
+ // Click to transcript, measured here because only the browser sees both ends.
357
+ const total = useTotalLatency(pending);
214
358
 
215
359
  // The list is read once and re-read on demand (see `useWorkflowRuns`), and
216
360
  // this is the "on demand": the moment the run this page started settles, the
@@ -233,14 +377,34 @@ function TranscriptionDesk() {
233
377
  </p>
234
378
  </header>
235
379
 
236
- <ModePicker mode={mode} onPick={setMode} disabled={pending} />
380
+ {/* The clock goes with the mode: switching swaps `active` for another hook's
381
+ run, and a total measured over a different submission would be a number
382
+ for something the panel below is no longer showing. */}
383
+ <ModePicker
384
+ mode={mode}
385
+ onPick={(next) => {
386
+ setMode(next);
387
+ total.clear();
388
+ }}
389
+ disabled={pending}
390
+ />
237
391
 
238
392
  <UploadPicker parallel={parallel} onPick={setParallel} disabled={pending} />
239
393
 
240
394
  {/* No mapping: the collected values already match the input schema. All three
241
395
  workflows declare `recording` as an upload, so the same picker serves every
242
396
  mode — how the bytes travel is not a question to ask a person. */}
243
- <Form onSubmit={(values) => submit(values)} error={error}>
397
+ {/* The clock starts HERE, which is as close to the press as a page can get:
398
+ `<Form>` calls this once the browser's own validation has passed and it has
399
+ read the controls, and an upload field contributes its `File` unread — so
400
+ what separates this line from the click is a microtask, not the file. */}
401
+ <Form
402
+ onSubmit={(values) => {
403
+ total.start();
404
+ return submit(values);
405
+ }}
406
+ error={error}
407
+ >
244
408
  {/* The NAME, so the schema is fetched here rather than by this page. */}
245
409
  <WorkflowFields workflow={WORKFLOWS[mode]} />
246
410
  {/* Unguarded on purpose: it renders nothing until there are bytes in
@@ -249,7 +413,21 @@ function TranscriptionDesk() {
249
413
  <SubmitButton pending={pending}>Transcribe</SubmitButton>
250
414
  </Form>
251
415
 
252
- {run && <RunPanel run={run} onClear={reset} />}
416
+ <TotalLatency
417
+ elapsedMs={total.elapsedMs}
418
+ running={total.running}
419
+ runMs={run?.status === "completed" ? run.output.elapsedMs : undefined}
420
+ />
421
+
422
+ {run && (
423
+ <RunPanel
424
+ run={run}
425
+ onClear={() => {
426
+ reset();
427
+ total.clear();
428
+ }}
429
+ />
430
+ )}
253
431
 
254
432
  <History
255
433
  runs={history.runs}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "6.6.0",
3
+ "version": "6.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "aai": "bin.mjs"
@@ -44,8 +44,8 @@
44
44
  "p-timeout": "^7.0.1",
45
45
  "vite": "^8.2.1",
46
46
  "zod": "^4.4.3",
47
- "@alexkroman1/aai": "6.6.0",
48
- "@alexkroman1/aai-ui": "6.6.0"
47
+ "@alexkroman1/aai": "6.7.0",
48
+ "@alexkroman1/aai-ui": "6.7.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "playwright": "^1.62.1",