The researcher presses Import, sees it working, and closes the window — because it looks like it is working. From that moment the only place the download exists on screen is the project's own row. This is what goes there, and how it gets there.
ProjectRowActivityIndicator.Kind already has .copying(fraction:) —
“a drag-import copy is in flight into this project, fraction is the 0…1 byte ratio, always
determinate” — rendered identically to the run ring and sharing its hover-to-cancel, differing only
in the onStop closure the call site supplies. A cloud download is a copy from
further away. Nothing needs drawing. The work is entirely in the plumbing, and in one number.
This is the load-bearing fact and it is already true: the store that owns the download lives above the window, so closing the window changes nothing about the transfer.
@StateObject in BristlenoseAppprogress[rowID], outcomes, the URLSession tasksstate[projectID], liveData, ringFraction@Published inFlight — one at a time, matched by projectID"cloud-import"Kind per rowSo there is no “keep it alive” problem to solve. The transfer already outlives the window. What is missing is only that nothing above the waterline currently publishes a per-project number, and nothing in the sidebar currently looks for one.
Five messages. Three of them already exist and are used by drag-and-drop today; the two marked in orange are the new ones.
| # | From → to | Message | Why it is there |
|---|---|---|---|
| 1 | Window → Store | startFetch(destination:) | Exists. The lease URL, so the publish move lands inside the sandbox grant. |
| 2 | Store → Runner | beginAddingInterviews( projectID:count:floor:) |
Exists, unchanged. The gesture ack — “Adding 4 interviews…” — declares the
batch before the first byte, so the row is already saying something when the window closes
two seconds later. floor is already a parameter; a download wants it held until the
first fraction arrives rather than the 2s a clonefile needs. |
| 3 | Store → row | CopyDisplay .copying(fraction:) |
The only new plumbing. Not a new verb and not a new type —
copyDisplay(for:) is already the row's channel for “a determinate transfer is landing
in this project”, fed by CopyMachinery. It gains a second source, and an item count
the copy path never had — its byte-% subtitle carries the comment “no file-item N of M
source exists”, which cloud import can supply. Clamped monotonic by
RunProgressMath.clampedFraction. |
| 4 | Runner → Row | Kind.copying(fraction:) | Exists, unchanged. Three renderers already switch on it
(ProjectRow, ProjectSidebarOutline, ProjectSubtitle). |
| 5 | Row → Store | onStop → stopFetch() | The ring's hover-✕ already carries an onStop closure; the call site
supplies it. Cancel the batch from the row, with the window shut. |
Why the Runner in the middle rather than the row reading the store directly.
Download and analysis are consecutive on the same project, and the row must never show two things at
once or flicker at the handover. One owner of “is this project busy, and how busy” is what makes the
seam invisible. It is also where the precedent is: CopyMachinery already publishes
exactly this shape and the row already merges it.
One row, five moments. The ring never empties and refills within a phase; the subtitle is what names which phase you are in — and the download phase needs no verb at all — “3 of 4” is the whole sentence.
1. Import pressed, window still open
2. Window closed — a count, because that is the question
3. Pointer over the ring — cancel the batch
4. Last file lands — the ring restarts, the words change
5. Done — the row goes quiet (Schema E)
Two sweeps, not one blended bar. A single 0→100% spanning both phases needs a weight for “how much of the wall clock is download vs transcription”, and that ratio swings from 10:1 to 1:20 depending on file size and whether the model is cached. A ring built on a number that wrong stalls visibly at the seam, which is the specific way a progress indicator loses the user's trust. Two honest sweeps with the subtitle naming the phase costs one restart and lies about nothing.
And they cancel differently. Stopping a download is not stopping an analysis — different confirmations, different consequences, different recovery. The hover-✕ must mean the right thing at each moment, and a blended ring has no moment to switch on.
A study is five interviews of about fifty minutes, not the lopsided test batch this document was first written against. So share-of-work weighting is not the design problem it looked like — equal pixels for equal minutes is a nicety when the files are the same length anyway. What the researcher actually wants from a closed window is smaller and more specific:
| The question | Answered by | Not answered by |
|---|---|---|
| “Are they all coming?” | “3 of 4” in the subtitle. A count of discrete recordings is the direct answer; a percentage is an inference from one. | A bare percentage. |
| “Should I get a coffee?” | A ring that visibly creeps. Motion at a readable rate is the answer; precision is not. | “4 min 32 s”, which is wrong the moment the wifi changes and invites the user to hold it against you. |
| “Which one is stuck?” | Reopening the window. Per-recording rows, per-file bars, per-file outcomes — the detail lives there and is already built. | One ring, ever. A single indicator summarising N transfers cannot name one of them, and pretending otherwise is how a row grows a second line it cannot fill. |
So the sidebar's job is reassurance plus a route back, and the import window's job is diagnosis. That division is why the window keeps its bars (§5) rather than inheriting rings: the two surfaces are answering different questions, not rendering the same answer at two sizes.
Weighting by file count would be accurate enough for a real study. It would also make the ring jump: five identical interviews means five 20% steps with four-minute stalls between them. A ring that has not moved for four minutes answers the coffee question with silence, which is exactly the reading that sends someone to Activity Monitor.
And the bytes are nearly free, at a moment that was already going to
happen. Sizes are unknown while the researcher is choosing — that much is structural, the
per-file grant does not exist yet. But the moment they commit, prepareBatch obtains that
grant and returns before the task group starts, and a grant is exactly what a size read needs.
One files.get?fields=size sweep in that gap, four at a time, sub-second for a batch this
size. driveFileSize(fileID:accessToken:) already exists in the adapter — it is called
per file inside fetch, one round trip too late to inform anything.
It also un-blinds the free-space precheck. That check reads
sizeBytes, nil on every Google row, so today it cannot warn before a multi-gigabyte
batch. The sweep is both when that becomes possible and when it is useful — before the first byte,
not at file four with the disk full.
The fallback is one line, not a ladder. A size read that fails leaves that file counted as the batch mean. Nothing more elaborate earns its keep, because the endpoint was never the point — the motion was.
Counting the ticked set instead of the granted set. Google's Picker sits
between mouseup and the grant, and it is a real decision point — requestMediaGrant
unions what was granted, with the comment “honour what was granted, never what was asked
for”. A denominator built at mouseup from ticks is too big whenever the researcher deselects, so the
ring stops at 80% over a batch that finished perfectly, and “3 of 4” counts a file nobody asked
for.
Completing the arc over a partial batch. On a clean batch the ring should reach 100% and hold a beat before the processing sweep starts at zero — a ring that vanishes at 87% reads as breakage. On a batch where two downloads failed it must stop where it stands. A filled ring is the strongest “done” signal the row has, and putting it over an incomplete import is this feature's own failure mode wearing the phase indicator's clothes.
Time waits for calibration. Bytes-remaining over the first sample says “4
hours”. The run ring already refuses exactly this — nil → indeterminate spinner (uncalibrated
first run before any measured signal) — and coffee-scale wording (“about 5 minutes”) is the
most precision this deserves even once it has settled.
| Piece | Reused? | Reason |
|---|---|---|
SidebarActivityRing | yes | 16pt determinate arc with hover-to-✕. Already renders .copying
identically to .running. |
Kind.copying(fraction:) | yes | Already means “a determinate, cancellable transfer into this project”. A download is one. |
RunProgressMath.clampedFraction | yes | A fraction over a predicted total must be monotonic. Identical problem, identical answer. |
ProjectSubtitle precedence | yes | So “Downloading · 3 of 4” competes for the line under the same rules as “Transcribing”, instead of racing it. |
| Per-row bars in the import window | no | One file, known byte total, rows compared down a column — a bar is right and is already there. A ring there would be borrowing a shape without its reason. |
| The Welford ETA | no | Downloads have measured bytes, not a modelled stage duration. Nothing to predict beyond bandwidth, which no history makes more accurate. |
| A global toolbar pill | no | Per-project activity lives on the project's row — settled 19 Jun 2026, and it is why the drag-import copy pill was removed. |
Process what landed. Refusing to would be treating an ordinary Tuesday as an emergency: incremental analysis is the shipped model, so a partial batch is not a special case — the CLI rescans the folder at run time, re-transcribes only the new sessions, re-clusters, and curation survives the re-import. Three now and the fourth tomorrow is the design working.
The one thing that genuinely changes is theme boundaries. Themes over three interviews are not a subset of themes over four — they are a different answer. But that is inherent to incremental analysis and is already what happens every time anybody adds a file. It argues for saying so, not for blocking.
Once the import window is closed, a failed row lives in store.outcomes
and appears nowhere. The researcher sees a finished report over three interviews and no signal at all
that a fourth exists in the cloud and never came down. That is this feature's own failure mode —
a shorter set that reads as complete — arriving after the window that was built to prevent it has
been dismissed.
| What happened | Process? | What the row owes the researcher |
|---|---|---|
| All four arrived | yes | Nothing. Schema E — a clean row is silent. |
| One failed network, 403, expiry |
yes, the three | A shortfall that outlives the window, with retry reachable — the Drive file ids are still in the store, so retrying needs no re-list. This is the line that has no home today. |
| Researcher pressed Stop | yes, whatever landed | Nothing. A decision is not a shortfall. Nagging about files they chose to abandon tells them they got it wrong — and Stop was in the download footer, so reading it as “and don't analyse what already arrived” infers more than the button said. |
| None arrived | no | The failure, and only that. A run over an empty delta is a no-op wearing work's clothes — a ring, a stage ladder, and a green finish over nothing. |
| Disk filled mid-batch | no | The disk, in its own words. Transcription writes too, so chaining into a run turns one legible failure into two — the second of which blames the pipeline. §4's size sweep is what lets this be caught before the first byte instead. |
| Project folder vanished unmounted, moved |
no | The lease was resolved at start and died in flight, so every publish fails. The row already has vocabulary for an unreachable project; reuse it rather than reporting N download failures. |
The rule under the table. Process when something landed and the next phase can plausibly run. Never let a certainly-failing situation be handed forward — one clear failure beats a clear one followed by a confusing one.
And the distinction that does the real work: failed is a fault, cancelled is a
decision. They arrive identically at the row — files that did not come down — and must not be
treated identically. FetchOutcome already tells them apart, and it was made to
(URLError(.cancelled) used to be recorded as a failure, so the terminus counted the
user's own choice as a fault and offered Retry for rows they had deliberately abandoned).
The transfers use an ordinary URLSession, not a background one, so
quitting the app kills them. What remains is .part files on disk and nothing anywhere
that remembers a batch was in flight — no resume, no shortfall, no mention. That is the same shape as
the stranded-run problem the pipeline's event log was built to solve, and it wants the same answer:
write the batch's intent somewhere durable before the first byte, so the next launch can say
what happened even though the store that knew is gone.
Two projects receiving at once. CopyMachinery allows exactly one
in-flight copy and refuses a second. The import store is per-window and there is one window globally,
so today it cannot happen either — but the ring is per-row and would render N of them correctly, so
this is a policy question rather than a rendering one.
Where a persistent shortfall renders. Schema E says a clean row is silent and a
delta earns a line, which is the right home — but the existing deltas
(unanalysedCount, missingCount) are read from the folder, and “four were
promised, three came” is knowledge only the import held.