@alexkroman1/aai-cli 6.3.1 → 6.5.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.
@@ -0,0 +1,214 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * "You do not need this page" — the HTTP API, on the page.
4
+ *
5
+ * A workflow app's whole surface is `GET|POST /workflows/*`, and the page is one
6
+ * caller of it. That is the most useful thing about the shape and the least
7
+ * discoverable: nothing in a form suggests that the same work is three `curl`
8
+ * calls, that a run id is the entire handle (no session, no cookie), or that a
9
+ * transcript can be collected days later from another machine. So the recipes are
10
+ * rendered where somebody is already looking, rather than left in a README they
11
+ * would have to know exists.
12
+ *
13
+ * ## It links to the LIVE listing
14
+ *
15
+ * `GET /workflows` serves each workflow's name, description and input schema — the
16
+ * same JSON `<WorkflowFields>` renders this form from. So the link is not
17
+ * documentation about the API, it is the API answering for itself, on this
18
+ * deployment, at this version. A reader who wants the schema gets the real one;
19
+ * a reader whose agent is behind `AAI_WORKFLOW_API_TOKEN` gets a 401, which is
20
+ * also the truth.
21
+ *
22
+ * ## The three recipes are the three flows
23
+ *
24
+ * The first two differ only in who names the upload — which is the whole of what
25
+ * lets one of them start before the bytes are in — and the third does none of that
26
+ * work at all. Showing them side by side is the clearest statement of the trade
27
+ * available, and cheaper than the prose that would otherwise have to make it.
28
+ *
29
+ * They are also KEPT HONEST by being runnable: every one of these was executed
30
+ * against a real dev server, which is how two bugs in the streaming path were
31
+ * found (a missing wake after the upload, and a poll that slept on a stale view).
32
+ */
33
+
34
+ /** @jsxImportSource react */
35
+
36
+ import type { ReactNode } from "react";
37
+
38
+ /** The API root, relative to wherever this page is served from. */
39
+ const API = "workflows";
40
+
41
+ /**
42
+ * One shell recipe, with a heading and a note.
43
+ *
44
+ * Rendered in a `<pre>` rather than assembled from styled spans: it exists to be
45
+ * SELECTED and pasted, and any markup inside the block is markup a copy picks up.
46
+ */
47
+ function Recipe({
48
+ title,
49
+ note,
50
+ script,
51
+ }: {
52
+ title: string;
53
+ note: string;
54
+ script: string;
55
+ }): ReactNode {
56
+ return (
57
+ <section className="flex flex-col gap-2">
58
+ <h4 className="text-xs font-medium uppercase tracking-[1.2px]">{title}</h4>
59
+ <p className="text-xs opacity-70">{note}</p>
60
+ <pre className="overflow-x-auto rounded-md border p-3 text-xs leading-relaxed">{script}</pre>
61
+ </section>
62
+ );
63
+ }
64
+
65
+ /** Store the whole file, then start a run on its id. */
66
+ const CLASSIC = `# 1. store the recording. Answers 201 once the LAST byte is in, which is why
67
+ # this shape cannot start the run any earlier.
68
+ ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.wav" \
69
+ -H 'content-type: audio/wav' --data-binary @standup.wav | jq -r .id)
70
+
71
+ # 2. run it. \`wait\` holds the request open for up to 60s; drop it to get
72
+ # { runId } straight back and poll instead.
73
+ curl -s -X POST "$AGENT/workflows/runs" \
74
+ -H 'content-type: application/json' \
75
+ -d "{\\"workflow\\":\\"transcribe\\",\\"wait\\":60000,
76
+ \\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r '.run.output.transcript'`;
77
+
78
+ /** Start the run first, then stream the file to the id it is watching. */
79
+ const STREAMING = `# No splitting, no ffmpeg — the file goes in ONE request. The only difference
80
+ # from the recipe above is that YOU pick the upload id, so it is already valid
81
+ # when the run starts and the run reads the bytes as they land.
82
+
83
+ # 1. pick an id and start the run on it. Nothing has been uploaded yet.
84
+ ID=$(openssl rand -hex 16)
85
+ RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
86
+ -H 'content-type: application/json' \
87
+ -d "{\\"workflow\\":\\"transcribeStream\\",
88
+ \\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
89
+
90
+ # 2. PUT the whole file. The upload record exists from the first byte with
91
+ # complete:false, and its size grows — which is what the run polls.
92
+ curl -s -X PUT "$AGENT/workflows/uploads/$ID?name=standup.wav" \
93
+ -H 'content-type: audio/wav' --data-binary @standup.wav | jq -c '{size, complete}'
94
+
95
+ # 3. wake it. The run sleeps between polls, so without this it notices the file
96
+ # is finished up to one poll interval late — every time.
97
+ curl -s -X POST "$AGENT/workflows/runs/$RUN/wake" > /dev/null
98
+
99
+ # 4. collect it whenever — a run id is the whole handle.
100
+ curl -s "$AGENT/workflows/runs/$RUN?wait=60000" | jq -r '.run.output.transcript'
101
+
102
+ # While it runs, from any other shell:
103
+ # curl -s "$AGENT/workflows/uploads/$ID/info" # how much has arrived
104
+ # curl -sN "$AGENT/workflows/runs/$RUN/stream" # what the run is saying`;
105
+
106
+ /** Hand the whole thing to the async API. */
107
+ const BATCH = `# The same two requests as the first recipe — only the workflow name differs.
108
+ # No cutting happens anywhere: the run uploads your file to the async API,
109
+ # polls the job, and reads the text. It also accepts mp3 and m4a, which the
110
+ # two sync flows refuse.
111
+ ID=$(curl -s -X POST "$AGENT/workflows/uploads?name=standup.m4a" \
112
+ -H 'content-type: audio/mp4' --data-binary @standup.m4a | jq -r .id)
113
+
114
+ # No \`wait\` here: an async job takes minutes, well past the 60s ceiling a
115
+ # synchronous read can hold. Start it, then follow the run.
116
+ RUN=$(curl -s -X POST "$AGENT/workflows/runs" \
117
+ -H 'content-type: application/json' \
118
+ -d "{\\"workflow\\":\\"transcribeBatch\\",
119
+ \\"input\\":{\\"recording\\":\\"$ID\\"}}" | jq -r .runId)
120
+
121
+ curl -sN "$AGENT/workflows/runs/$RUN/events" # status, as it changes
122
+ curl -s "$AGENT/workflows/runs/$RUN" | jq -r '.output.transcript // .status'`;
123
+
124
+ /** Every route this app answers, and what each is for. */
125
+ const ROUTES: readonly { route: string; does: string }[] = [
126
+ { route: "GET /workflows", does: "the three workflows and their input schemas" },
127
+ { route: "POST /workflows/runs", does: "start a run · body names workflow and input" },
128
+ { route: "GET /workflows/runs", does: "runs so far · filter by workflow, key, limit" },
129
+ { route: "GET /workflows/runs/:id", does: "one run · add wait=<ms> to block on it" },
130
+ { route: "GET /workflows/runs/:id/events", does: "SSE, status transitions" },
131
+ { route: "GET /workflows/runs/:id/stream", does: "SSE, what the run has written" },
132
+ { route: "POST /workflows/runs/:id/wake", does: "end a pending sleep early" },
133
+ { route: "DELETE /workflows/runs/:id", does: "cancel it" },
134
+ { route: "POST /workflows/uploads", does: "store a file, id minted by the store" },
135
+ {
136
+ route: "PUT /workflows/uploads/:id",
137
+ does: "store a file under YOUR id, readable as it arrives",
138
+ },
139
+ {
140
+ route: "POST /workflows/uploads/:id/parts",
141
+ does: "declare an upload its parts fill in · ?total=<bytes>",
142
+ },
143
+ {
144
+ route: "PUT /workflows/uploads/:id/parts",
145
+ does: "one window of it · ?offset=<byte>, sent concurrently",
146
+ },
147
+ { route: "GET /workflows/uploads/:id", does: "read the bytes back · Range honoured" },
148
+ { route: "GET /workflows/uploads/:id/info", does: "name, bytes stored so far, and complete" },
149
+ ];
150
+
151
+ /**
152
+ * The whole API, collapsed by default.
153
+ *
154
+ * `<details>` rather than a state hook: the browser owns disclosure, it is
155
+ * keyboard-accessible and findable by in-page search without anyone wiring either,
156
+ * and a page that renders a transcript should not re-render because somebody
157
+ * expanded a help panel.
158
+ */
159
+ export function ApiHelp(): ReactNode {
160
+ return (
161
+ <details className="rounded-md border">
162
+ <summary className="cursor-pointer p-4 text-sm font-medium">
163
+ Use the API without this page
164
+ </summary>
165
+ <div className="flex flex-col gap-6 border-t p-4">
166
+ <p className="text-sm opacity-70">
167
+ This page is one caller. Everything it does is plain HTTP on this agent's own origin,
168
+ unauthenticated unless the deployment sets{" "}
169
+ <code className="text-xs">AAI_WORKFLOW_API_TOKEN</code> (then every route wants{" "}
170
+ <code className="text-xs">Authorization: Bearer …</code>). A run id is the whole handle —
171
+ no session, no cookie — so a transcript can be collected from another machine, days later.
172
+ </p>
173
+ <p className="text-sm">
174
+ {/* The API answering for itself: same JSON this form was rendered from. */}
175
+ <a className="underline" href={API} target="_blank" rel="noreferrer">
176
+ {`GET ${API}`}
177
+ </a>{" "}
178
+ <span className="opacity-70">
179
+ — the live listing, including each workflow's input schema. Set{" "}
180
+ <code className="text-xs">AGENT</code> to this page's origin for the recipes below.
181
+ </span>
182
+ </p>
183
+
184
+ <Recipe
185
+ title="Store it, then transcribe"
186
+ note="Sync API. One request per phase, and the upload has to finish before there is a run."
187
+ script={CLASSIC}
188
+ />
189
+ <Recipe
190
+ title="Transcribe while it uploads"
191
+ note="Sync API, one upload request, no splitting. The run starts on an id you chose and reads the bytes as they land."
192
+ script={STREAMING}
193
+ />
194
+ <Recipe
195
+ title="Let the provider do it"
196
+ note="Async API. No cutting and no seams, it accepts compressed audio, and the wait belongs to the provider's queue."
197
+ script={BATCH}
198
+ />
199
+
200
+ <section className="flex flex-col gap-2">
201
+ <h4 className="text-xs font-medium uppercase tracking-[1.2px]">Every route</h4>
202
+ <ul className="flex flex-col gap-1">
203
+ {ROUTES.map((entry) => (
204
+ <li key={entry.route} className="flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-3">
205
+ <code className="shrink-0 sm:w-72">{entry.route}</code>
206
+ <span className="opacity-70">{entry.does}</span>
207
+ </li>
208
+ ))}
209
+ </ul>
210
+ </section>
211
+ </div>
212
+ </details>
213
+ );
214
+ }
@@ -33,6 +33,62 @@
33
33
  * never travel in one; this page contains no upload code because the SDK owns
34
34
  * that.
35
35
  *
36
+ * ## Two modes, and the toggle is the template's subject
37
+ *
38
+ * The desk offers both flows the agent declares, and the page is where the
39
+ * difference is legible: pick "while it uploads" and there is a run to watch
40
+ * before any bytes are in, pick "after it uploads" and there is not. They share
41
+ * everything else — one `<Form>`, one picker, one progress log, one transcript —
42
+ * because they take the same input and return the same shape, and the only thing
43
+ * the page chooses is which HOOK submits it.
44
+ *
45
+ * `useWorkflowStream` is the streaming half: it mints the upload id, starts the run
46
+ * on it, sends the file, and wakes the run when the bytes land. `useWorkflowSubmit`
47
+ * is the classic half and is unchanged.
48
+ *
49
+ * Streaming is the DEFAULT because it is faster on any real recording. The classic
50
+ * path stays selectable because it is the shape to read first.
51
+ *
52
+ * ## The third control is about the UPLOAD, not the flow
53
+ *
54
+ * "Split the file across connections" (`parallel`) is orthogonal to the three modes
55
+ * and applies to all of them, which is why it is a checkbox beside the radios
56
+ * rather than a fourth option. A single request moves a file at one connection's
57
+ * throughput, which over any distance is a fraction of the link — so the SDK cuts
58
+ * the file into megabyte-aligned parts and sends four at once. Nothing about the
59
+ * workflow changes: the agent reassembles them, `readUpload` reads the same
60
+ * windows, and the streaming flow still watches the file grow (what it polls is the
61
+ * CONTIGUOUS prefix, which is honest whether one connection or four are filling
62
+ * it).
63
+ *
64
+ * It is selectable rather than always-on for the reason the modes are: this is the
65
+ * template where a reader runs both over the same recording and sees what each
66
+ * costs. It also degrades on its own — a small file, or an agent deployed before
67
+ * the `/parts` routes existed, sends the single request instead — so leaving it on
68
+ * is safe.
69
+ *
70
+ * ## The transcript ARRIVES, rather than appearing at the end
71
+ *
72
+ * A run's `output` exists only when its last segment does, so a page with only
73
+ * that shows a status line for the whole fan-out and then everything at once — on
74
+ * a 97-minute recording, minutes of it. Each segment is emitted the moment it
75
+ * lands (`emit(TRANSCRIPT_STREAM, …)` in `workflows/transcribe.ts`) and
76
+ * `useWorkflowProgress` reads that stream, so the panel renders the transcript
77
+ * growing.
78
+ *
79
+ * Three things make it honest rather than decorative:
80
+ *
81
+ * - **The page stitches with the RUN's own function.** `stitchChunks` is
82
+ * `workflows/stitch.ts`, imported by both, so the live text and the stored one
83
+ * cannot drift into two different transcripts of one recording.
84
+ * - **It is a SEPARATE stream from the progress log.** `report()`'s lines go to
85
+ * the default one, which `<WorkflowProgress>` renders verbatim; objects in
86
+ * there would come out as `[object Object]` between the sentences.
87
+ * - **The finished run wins.** Once `output` exists the panel renders that
88
+ * instead — it is the authoritative text, counted and measured, and a live
89
+ * transcript that stayed on screen beside it would be a second answer with no
90
+ * way to tell which was current.
91
+ *
36
92
  * ## Two waits, two bars
37
93
  *
38
94
  * A recording is the one input big enough that STORING it is itself a wait, and
@@ -58,14 +114,24 @@ import {
58
114
  page,
59
115
  SubmitButton,
60
116
  UploadProgressBar,
117
+ useWorkflowProgress,
61
118
  useWorkflowRuns,
119
+ useWorkflowStream,
62
120
  useWorkflowSubmit,
63
121
  WorkflowFields,
64
122
  WorkflowProgress,
65
123
  type WorkflowRun,
66
124
  } from "@alexkroman1/aai-ui";
67
- import { useEffect, useState } from "react";
125
+ import { useEffect, useMemo, useState } from "react";
68
126
  import type { transcribe } from "./agent.ts";
127
+ import { ApiHelp } from "./api-help.tsx";
128
+ import {
129
+ clock,
130
+ countWords,
131
+ stitchChunks,
132
+ TRANSCRIPT_STREAM,
133
+ type TranscriptChunk,
134
+ } from "./workflows/stitch.ts";
69
135
 
70
136
  /**
71
137
  * What a finished run reports.
@@ -76,15 +142,70 @@ import type { transcribe } from "./agent.ts";
76
142
  */
77
143
  type Transcript = WorkflowOutputOf<typeof transcribe>;
78
144
 
79
- /** The workflow this page drives. Matches the key in `workflowApp({ workflows })`. */
80
- const WORKFLOW = "transcribe";
145
+ /**
146
+ * The three workflows this page drives, keyed by the mode that picks one.
147
+ *
148
+ * The STRINGS matter: a page starts a run by name, so a rename in `agent.ts` is a
149
+ * runtime 400 rather than a compile error. `agent.test.ts` pins all three.
150
+ */
151
+ const WORKFLOWS = {
152
+ streaming: "transcribeStream",
153
+ classic: "transcribe",
154
+ batch: "transcribeBatch",
155
+ } as const;
156
+
157
+ /** Which flow the form submits through. */
158
+ type Mode = keyof typeof WORKFLOWS;
159
+
160
+ /**
161
+ * What each mode is called, and what picking it changes.
162
+ *
163
+ * The notes are the template's actual subject, so they say what the trade IS rather
164
+ * than which is "best" — the answer depends on the file and the link, and the whole
165
+ * reason all three ship is that a reader can run them over the same recording.
166
+ */
167
+ const MODES: readonly { mode: Mode; label: string; note: string }[] = [
168
+ {
169
+ mode: "streaming",
170
+ label: "While it uploads",
171
+ note: "Sync API. The run starts first and transcribes each segment as its bytes land, so progress is visible while the file is still moving.",
172
+ },
173
+ {
174
+ mode: "classic",
175
+ label: "After it uploads",
176
+ note: "Sync API. Store the whole recording, then fan out over it. The simplest shape, and the quickest on a fast link.",
177
+ },
178
+ {
179
+ mode: "batch",
180
+ label: "Let the provider do it",
181
+ note: "Async API. One job, no cutting, no seams — and it accepts MP3 and M4A, which the two above refuse.",
182
+ },
183
+ ];
81
184
 
82
185
  /** Most past runs the history list shows. */
83
186
  const HISTORY_LIMIT = 10;
84
187
 
85
188
  function TranscriptionDesk() {
86
- const { submit, run, upload, pending, error, reset } = useWorkflowSubmit<Transcript>(WORKFLOW);
87
- const history = useWorkflowRuns<Transcript>(WORKFLOW, { limit: HISTORY_LIMIT });
189
+ const [mode, setMode] = useState<Mode>("streaming");
190
+ // Whether the browser cuts the recording up and sends the pieces at once. One
191
+ // piece of state for all three hooks, because it describes the UPLOAD and every
192
+ // mode has one — see the module doc.
193
+ const [parallel, setParallel] = useState(true);
194
+ // ALL THREE hooks are called every render, because a hook may not be conditional —
195
+ // and that costs nothing here: none of them does anything until its `submit` is
196
+ // called, and `useWorkflowRun` underneath them holds no id until then either.
197
+ const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming, { parallel });
198
+ const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic, { parallel });
199
+ const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch, { parallel });
200
+ // The batch flow uploads the same way the classic one does — the id comes from the
201
+ // store — so it is the SAME hook against a different workflow. Only the streaming
202
+ // mode needs the other one, because only it needs the id before the bytes.
203
+ const active = mode === "streaming" ? streamed : mode === "batch" ? batched : stored;
204
+ const { submit, run, upload, pending, error, reset } = active;
205
+ // History is per WORKFLOW, so the list follows the mode: two flows that produce
206
+ // the same output are still two different things to have run, and merging them
207
+ // would put a run under a heading that cannot explain it.
208
+ const history = useWorkflowRuns<Transcript>(WORKFLOWS[mode], { limit: HISTORY_LIMIT });
88
209
  // Which past run the reader is looking at, if any. Its own state rather than
89
210
  // a route, because a workflow app is one page and a run id is not a place.
90
211
  const [openId, setOpenId] = useState<string | undefined>(undefined);
@@ -110,10 +231,16 @@ function TranscriptionDesk() {
110
231
  </p>
111
232
  </header>
112
233
 
113
- {/* No mapping: the collected values already match the input schema. */}
234
+ <ModePicker mode={mode} onPick={setMode} disabled={pending} />
235
+
236
+ <UploadPicker parallel={parallel} onPick={setParallel} disabled={pending} />
237
+
238
+ {/* No mapping: the collected values already match the input schema. All three
239
+ workflows declare `recording` as an upload, so the same picker serves every
240
+ mode — how the bytes travel is not a question to ask a person. */}
114
241
  <Form onSubmit={(values) => submit(values)} error={error}>
115
242
  {/* The NAME, so the schema is fetched here rather than by this page. */}
116
- <WorkflowFields workflow={WORKFLOW} />
243
+ <WorkflowFields workflow={WORKFLOWS[mode]} />
117
244
  {/* Unguarded on purpose: it renders nothing until there are bytes in
118
245
  flight, and nothing again once they have landed. */}
119
246
  <UploadProgressBar upload={upload} />
@@ -128,10 +255,101 @@ function TranscriptionDesk() {
128
255
  openId={openId}
129
256
  onOpen={(runId) => setOpenId((current) => (current === runId ? undefined : runId))}
130
257
  />
258
+
259
+ {/* The most useful thing about a workflow app is the least discoverable:
260
+ this page is one caller of an ordinary HTTP API. See `api-help.tsx`. */}
261
+ <ApiHelp />
131
262
  </main>
132
263
  );
133
264
  }
134
265
 
266
+ /**
267
+ * Which flow submits, as two radios.
268
+ *
269
+ * Radios rather than a toggle or a select, because the choice has a REASON per
270
+ * option and a radio group is the one control with room to show it — the note
271
+ * under each label is what makes this a decision rather than a switch somebody
272
+ * flips to see what happens.
273
+ *
274
+ * Disabled while a submission is in flight: the two hooks hold separate run state,
275
+ * so switching mid-run would swap the panel for the other hook's (empty) one and
276
+ * read as the run having vanished.
277
+ */
278
+ function ModePicker({
279
+ mode,
280
+ onPick,
281
+ disabled,
282
+ }: {
283
+ mode: Mode;
284
+ onPick: (next: Mode) => void;
285
+ disabled: boolean;
286
+ }) {
287
+ return (
288
+ <fieldset className="flex flex-col gap-3" disabled={disabled}>
289
+ <legend className="text-sm font-medium uppercase tracking-[1.2px]">Transcribe</legend>
290
+ {MODES.map((option) => (
291
+ <label key={option.mode} className="flex items-start gap-3 text-sm">
292
+ <input
293
+ type="radio"
294
+ name="mode"
295
+ className="mt-1"
296
+ value={option.mode}
297
+ checked={mode === option.mode}
298
+ onChange={() => onPick(option.mode)}
299
+ />
300
+ <span className="flex flex-col gap-0.5">
301
+ <span>{option.label}</span>
302
+ <span className="text-xs opacity-70">{option.note}</span>
303
+ </span>
304
+ </label>
305
+ ))}
306
+ </fieldset>
307
+ );
308
+ }
309
+
310
+ /**
311
+ * How the recording travels, as one checkbox.
312
+ *
313
+ * Beside the mode radios rather than among them because it answers a different
314
+ * question — those pick the WORKFLOW, this picks how its input gets there — and
315
+ * every mode is uploading a file either way.
316
+ *
317
+ * Disabled mid-submission for the same reason the radios are: the bytes are
318
+ * already moving, and a control that looks live while changing nothing is worse
319
+ * than one that is plainly unavailable.
320
+ */
321
+ function UploadPicker({
322
+ parallel,
323
+ onPick,
324
+ disabled,
325
+ }: {
326
+ parallel: boolean;
327
+ onPick: (next: boolean) => void;
328
+ disabled: boolean;
329
+ }) {
330
+ return (
331
+ <fieldset className="flex flex-col gap-3" disabled={disabled}>
332
+ <legend className="text-sm font-medium uppercase tracking-[1.2px]">Upload</legend>
333
+ <label className="flex items-start gap-3 text-sm">
334
+ <input
335
+ type="checkbox"
336
+ className="mt-1"
337
+ name="parallel"
338
+ checked={parallel}
339
+ onChange={(event) => onPick(event.target.checked)}
340
+ />
341
+ <span className="flex flex-col gap-0.5">
342
+ <span>Split the file across connections</span>
343
+ <span className="text-xs opacity-70">
344
+ Sends the recording as several parts at once instead of in one request, which is most of
345
+ the wait on a long file. Falls back to the single request on a small one.
346
+ </span>
347
+ </span>
348
+ </label>
349
+ </fieldset>
350
+ );
351
+ }
352
+
135
353
  /**
136
354
  * Every recent run, newest first, with its transcript one click away.
137
355
  *
@@ -214,13 +432,19 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
214
432
  finished run up in the panel below shows how it got there. */}
215
433
  <WorkflowProgress runId={run.runId} />
216
434
 
435
+ {/* While it runs, the transcript so far. Unguarded on the run's status
436
+ beyond this: the component renders nothing until a segment has landed,
437
+ and stops the moment there is an `output` to render instead. */}
438
+ {!isTerminal(run) && <LiveTranscript runId={run.runId} />}
439
+
217
440
  {/* Discriminated on `status`, so `output` and `error` are reachable
218
441
  without a cast — the reason a snapshot is a union rather than a flat
219
442
  object with optional fields. */}
220
443
  {run.status === "completed" && (
221
444
  <>
222
445
  <p className="text-xs opacity-60">
223
- {run.output.segments} segments · {Math.round(run.output.durationMs / 1000)}s ·{" "}
446
+ {run.output.segments} {run.output.segments === 1 ? "segment" : "segments"} ·{" "}
447
+ {duration(run.output.durationMs)} of audio · took {duration(run.output.elapsedMs)} ·{" "}
224
448
  {run.output.words} words
225
449
  </p>
226
450
  <pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
@@ -231,6 +455,66 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
231
455
  );
232
456
  }
233
457
 
458
+ /**
459
+ * The transcript as it arrives, stitched from the segments that have landed.
460
+ *
461
+ * The other half of `<WorkflowProgress>` above it: that one renders what the run
462
+ * SAYS about itself, this one renders what it has produced. Both are the same
463
+ * mechanism — a run's output stream — separated by the namespace, which is what
464
+ * lets this one be typed.
465
+ *
466
+ * It renders NOTHING until a segment lands, so a page can mount it unguarded:
467
+ * before the first chunk there is nothing to say that the progress log is not
468
+ * already saying better.
469
+ *
470
+ * The count is derived from the stitched text rather than summed per chunk,
471
+ * because the seams overlap — adding up the segments would over-count every one
472
+ * of them by a couple of seconds' worth of words.
473
+ */
474
+ function LiveTranscript({ runId }: { runId: string }) {
475
+ const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
476
+ namespace: TRANSCRIPT_STREAM,
477
+ });
478
+ // Memoized on the ARRAY, which the hook appends to per read: stitching is a
479
+ // seam search per segment, and a fan-out re-renders this panel on every
480
+ // progress poll whether or not anything arrived.
481
+ const transcript = useMemo(() => stitchChunks(progress), [progress]);
482
+ if (progress.length === 0) return null;
483
+
484
+ // The furthest point reached, not the count: segments land out of order, so
485
+ // "6 segments" says nothing about how much of the recording is covered.
486
+ const covered = Math.max(...progress.map((chunk) => chunk.endMs));
487
+ return (
488
+ <div className="flex flex-col gap-2">
489
+ <p className="text-xs opacity-60">
490
+ {countWords(transcript)} words so far · through {clock(covered)}
491
+ </p>
492
+ <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
493
+ </div>
494
+ );
495
+ }
496
+
497
+ /**
498
+ * A duration a person can read.
499
+ *
500
+ * `${Math.round(ms / 1000)}s` was what this printed, and an hour-long recording came
501
+ * out as `3746s` — which a reader asked whether they should parse as 37.46 seconds.
502
+ * A raw second count stops being readable at about ninety of them, and the recordings
503
+ * this desk is FOR are the ones past that.
504
+ *
505
+ * The hours component is omitted when it is zero rather than padded to `0:02:26`, so
506
+ * a two-minute clip reads as `2:26` and only a long one grows a field.
507
+ */
508
+ function duration(ms: number): string {
509
+ const total = Math.max(0, Math.round(ms / 1000));
510
+ const seconds = String(total % 60).padStart(2, "0");
511
+ const minutes = Math.floor(total / 60) % 60;
512
+ const hours = Math.floor(total / 3600);
513
+ return hours > 0
514
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
515
+ : `${minutes}:${seconds}`;
516
+ }
517
+
234
518
  /**
235
519
  * One line describing where a run has got to.
236
520
  *