Found 2026-10-02 while the user ran a real 64-minute recording through the two-engine pipeline (PR #39, ADR-034).
Gap./uploads/{id} shows only queued / transcribing / done / failed. A 1-hour upload with reconciliation takes minutes to tens of minutes, and during that time the page looks identical whether the job is working, hung on a dead engine, or the process has crashed. The user asked for this directly.
What the page cannot say today
which phase is running: transcribing with engine A, with engine B, reconciling (and how many of N disputes are adjudicated), or saving
how long the job has run, and when it last made progress
whether the process is still alive. FailInterruptedUploads fixes a crash only on the next start, so after a crash the row says transcribing until someone restarts the service
queue position, since the worker is serialized (a second upload waits behind the first)
Suggested shape (not decided)
Store a phase and progress_at heartbeat on uploaded_transcripts (new migration, RLS unchanged). The worker writes the phase on each transition; the reconciler writes adjudicated/total.
The existing HTMX status poll renders phase, elapsed time and the heartbeat age. A heartbeat older than a stated threshold shows "no progress for N min, may be stuck" instead of a spinner.
The ASR calls are single blocking HTTP requests, so inside an engine call there is no progress to report. The honest display there is elapsed time against an estimate (about 12x realtime for kb-whisper, measured in docs/research/audio-length-ceiling-2026-10-01.md), not a percentage.
Open question: whether a heartbeat from a goroutine independent of the engine call is needed, so "alive" is distinguishable from "engine call hung". The job already has a timeout (60 min default).
Test notes. The stuck-versus-working display is control-like, so it needs a test that holds a fake engine open past the threshold and asserts the page changes, not only that a phase string renders.
Related: #37 (deploy plumbing), #38 (unmeasured limits). Not built in PR #39.
Found 2026-10-02 while the user ran a real 64-minute recording through the two-engine pipeline (PR #39, ADR-034).
**Gap.** `/uploads/{id}` shows only `queued` / `transcribing` / `done` / `failed`. A 1-hour upload with reconciliation takes minutes to tens of minutes, and during that time the page looks identical whether the job is working, hung on a dead engine, or the process has crashed. The user asked for this directly.
**What the page cannot say today**
- which phase is running: transcribing with engine A, with engine B, reconciling (and how many of N disputes are adjudicated), or saving
- how long the job has run, and when it last made progress
- whether the process is still alive. `FailInterruptedUploads` fixes a crash only on the next start, so after a crash the row says `transcribing` until someone restarts the service
- queue position, since the worker is serialized (a second upload waits behind the first)
**Suggested shape (not decided)**
- Store a `phase` and `progress_at` heartbeat on `uploaded_transcripts` (new migration, RLS unchanged). The worker writes the phase on each transition; the reconciler writes `adjudicated/total`.
- The existing HTMX status poll renders phase, elapsed time and the heartbeat age. A heartbeat older than a stated threshold shows "no progress for N min, may be stuck" instead of a spinner.
- The ASR calls are single blocking HTTP requests, so inside an engine call there is no progress to report. The honest display there is elapsed time against an estimate (about 12x realtime for kb-whisper, measured in `docs/research/audio-length-ceiling-2026-10-01.md`), not a percentage.
- Open question: whether a heartbeat from a goroutine independent of the engine call is needed, so "alive" is distinguishable from "engine call hung". The job already has a timeout (60 min default).
**Test notes.** The stuck-versus-working display is control-like, so it needs a test that holds a fake engine open past the threshold and asserts the page changes, not only that a phase string renders.
Related: #37 (deploy plumbing), #38 (unmeasured limits). Not built in PR #39.
---
_Created via git-mcp on behalf of @mathias_
Requirements from the product owner (2026-10-02), to be treated as part of this issue's acceptance:
Visual style. The indicator continues tapir's existing visual style. Reuse the existing templ components, CSS tokens and layout conventions of the web UI; do not introduce a new design language, a JS framework or a separate stylesheet for it.
Interaction model. The reference is the progress display when a container starts and its layers are pulled and activated (docker/podman-style): a rich, TUI-like experience.
a list of named steps, each with its own state (pending, running, done, failed, skipped)
a running step that visibly moves: a spinner or bar, with elapsed time
finished steps collapse to a done line with their duration, so the page reads as a log of what happened
the whole thing updates in place, with no page reload
For tapir the steps are the real phases of an upload: received, probing, extracting audio, transcribing with each engine, comparing the readings, adjudicating disputes (n of N), saving. The two-engine phases are only shown when reconciliation is on.
Constraint to design around. This only works where the data is real. The two engine calls are single blocking requests, so inside them the display can show elapsed time against the measured ~12x-realtime estimate, never a fabricated percentage. Adjudication has real counts (n of N) and can use a true bar. A step that is genuinely stuck must look different from a running one (see the heartbeat point in the description).
Delivery. HTMX polling or server-sent events both fit the current stack; pick whichever keeps the page working without JS-heavy code. Whatever is chosen needs a screenshot-based review (the UX-tester worker pattern) before it counts as done, since "looks like tapir" and "reads like a TUI" are visual claims that tests cannot assert.
**Requirements from the product owner (2026-10-02), to be treated as part of this issue's acceptance:**
1. **Visual style.** The indicator continues tapir's existing visual style. Reuse the existing templ components, CSS tokens and layout conventions of the web UI; do not introduce a new design language, a JS framework or a separate stylesheet for it.
2. **Interaction model.** The reference is the progress display when a container starts and its layers are pulled and activated (docker/podman-style): a rich, TUI-like experience.
- a list of named steps, each with its own state (pending, running, done, failed, skipped)
- a running step that visibly moves: a spinner or bar, with elapsed time
- finished steps collapse to a done line with their duration, so the page reads as a log of what happened
- the whole thing updates in place, with no page reload
For tapir the steps are the real phases of an upload: received, probing, extracting audio, transcribing with each engine, comparing the readings, adjudicating disputes (n of N), saving. The two-engine phases are only shown when reconciliation is on.
**Constraint to design around.** This only works where the data is real. The two engine calls are single blocking requests, so inside them the display can show elapsed time against the measured ~12x-realtime estimate, never a fabricated percentage. Adjudication has real counts (n of N) and can use a true bar. A step that is genuinely stuck must look different from a running one (see the heartbeat point in the description).
**Delivery.** HTMX polling or server-sent events both fit the current stack; pick whichever keeps the page working without JS-heavy code. Whatever is chosen needs a screenshot-based review (the UX-tester worker pattern) before it counts as done, since "looks like tapir" and "reads like a TUI" are visual claims that tests cannot assert.
---
_Created via git-mcp on behalf of @mathias_
Built on PR #39 (head 7029dd1), per ADR-035 (Proposed). Closes on merge.
Done: the page shows real phases (received, audio, each engine, comparing, deciding n of N, saving) as a step log in tapir's tokens and mono font, with a CSS spinner (reduced-motion safe), a bar only for the counted phase, and states running / quiet / lost / failed / done. Finished steps collapse to a line with their duration, and a done job keeps a collapsed "What was done" list. HTMX poll is 2 s while unfinished and none after. Screenshot-reviewed in light and dark across seven states (three defects found and fixed).
Deviation from the issue text: inside an engine call the page shows elapsed time only; no "estimate" was added, because pianissimo's speed is not measured and a made-up expectation would be the fabricated figure this issue warns against. Not done: queue position (a second upload waiting behind the first shows only "Queued").
Built on PR #39 (head `7029dd1`), per ADR-035 (Proposed). Closes on merge.
Done: the page shows real phases (received, audio, each engine, comparing, deciding n of N, saving) as a step log in tapir's tokens and mono font, with a CSS spinner (reduced-motion safe), a bar only for the counted phase, and states running / quiet / lost / failed / done. Finished steps collapse to a line with their duration, and a done job keeps a collapsed "What was done" list. HTMX poll is 2 s while unfinished and none after. Screenshot-reviewed in light and dark across seven states (three defects found and fixed).
Deviation from the issue text: inside an engine call the page shows elapsed time only; no "estimate" was added, because pianissimo's speed is not measured and a made-up expectation would be the fabricated figure this issue warns against. Not done: queue position (a second upload waiting behind the first shows only "Queued").
---
_Created via git-mcp on behalf of @mathias_
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found 2026-10-02 while the user ran a real 64-minute recording through the two-engine pipeline (PR #39, ADR-034).
Gap.
/uploads/{id}shows onlyqueued/transcribing/done/failed. A 1-hour upload with reconciliation takes minutes to tens of minutes, and during that time the page looks identical whether the job is working, hung on a dead engine, or the process has crashed. The user asked for this directly.What the page cannot say today
FailInterruptedUploadsfixes a crash only on the next start, so after a crash the row saystranscribinguntil someone restarts the serviceSuggested shape (not decided)
phaseandprogress_atheartbeat onuploaded_transcripts(new migration, RLS unchanged). The worker writes the phase on each transition; the reconciler writesadjudicated/total.docs/research/audio-length-ceiling-2026-10-01.md), not a percentage.Test notes. The stuck-versus-working display is control-like, so it needs a test that holds a fake engine open past the threshold and asserts the page changes, not only that a phase string renders.
Related: #37 (deploy plumbing), #38 (unmeasured limits). Not built in PR #39.
Created via git-mcp on behalf of @mathias
Requirements from the product owner (2026-10-02), to be treated as part of this issue's acceptance:
Visual style. The indicator continues tapir's existing visual style. Reuse the existing templ components, CSS tokens and layout conventions of the web UI; do not introduce a new design language, a JS framework or a separate stylesheet for it.
Interaction model. The reference is the progress display when a container starts and its layers are pulled and activated (docker/podman-style): a rich, TUI-like experience.
For tapir the steps are the real phases of an upload: received, probing, extracting audio, transcribing with each engine, comparing the readings, adjudicating disputes (n of N), saving. The two-engine phases are only shown when reconciliation is on.
Constraint to design around. This only works where the data is real. The two engine calls are single blocking requests, so inside them the display can show elapsed time against the measured ~12x-realtime estimate, never a fabricated percentage. Adjudication has real counts (n of N) and can use a true bar. A step that is genuinely stuck must look different from a running one (see the heartbeat point in the description).
Delivery. HTMX polling or server-sent events both fit the current stack; pick whichever keeps the page working without JS-heavy code. Whatever is chosen needs a screenshot-based review (the UX-tester worker pattern) before it counts as done, since "looks like tapir" and "reads like a TUI" are visual claims that tests cannot assert.
Created via git-mcp on behalf of @mathias
Built on PR #39 (head
7029dd1), per ADR-035 (Proposed). Closes on merge.Done: the page shows real phases (received, audio, each engine, comparing, deciding n of N, saving) as a step log in tapir's tokens and mono font, with a CSS spinner (reduced-motion safe), a bar only for the counted phase, and states running / quiet / lost / failed / done. Finished steps collapse to a line with their duration, and a done job keeps a collapsed "What was done" list. HTMX poll is 2 s while unfinished and none after. Screenshot-reviewed in light and dark across seven states (three defects found and fixed).
Deviation from the issue text: inside an engine call the page shows elapsed time only; no "estimate" was added, because pianissimo's speed is not measured and a made-up expectation would be the fabricated figure this issue warns against. Not done: queue position (a second upload waiting behind the first shows only "Queued").
Created via git-mcp on behalf of @mathias