Cloud fetch — states & messaging

Every state a researcher sees when a project's media are cloud placeholders. Grounded in the 29 Jul 2026 reproduction (Dropbox, three 307–649 MB recordings) — see docs/design-project-storage.md §3.

The defect this replaces. Today the fetch happens inside a 30-second ffprobe budget, so the researcher is told “ffprobe timed out after 30s probing …” — which reads as “my video is broken” when it means “my file was still downloading.” Opposite remedies. Every message below exists to keep those two apart.

A · The sequence — cloud project, media evicted

1
At rest — no cloud state at all, even when every file is evicted. The subtitle is the bare last-run date (formatBareDate), exactly as today.

An earlier draft of this mockup showed “3 interviews · 3 in Dropbox” here. Cut, on the user's reasoning: drag-and-drop starts processing immediately, so files rarely sit unprocessed; and interviews pulled from Teams or Zoom today are local and on Dropbox, because today is the day they get dragged in. Eviction is a later-life event. Until the researcher has to do something differently, the cloud is not information — it's trivia with a glyph attached. (It was also unreachable in shipped code; the right response turned out to be leaving it that way.)
2
Fetch begins. info — weather, self-resolving. Indeterminate spinner, never a ring: providers stage the download and swap the file in atomically, so st_blocks reads 0 until it reads 100%. But the file count is knowable, so “1 of 3” is honest and matches the existing “Transcribing · 2 of 3” ladder.
3
Bytes resident → normal ladder resumes. Nothing about the pipeline changes; the fetch was a prologue. The ETA only becomes meaningful here, which is why step 2 has none.
4
Done — and the row goes quiet. Under Schema E (decided 29 Jul, see §B2) a clean .ready row shows no subtitle at all and collapses to a single 32pt line. No date, no verb, no tick. Nothing about the cloud, then or later.

Earlier drafts of this step showed “3 interviews · 47 quotes”, then a bare date (formatBareDate) with an optional delta. Neither is right: the first was invented, the second was designed but never once rendered — lastPipelineRunAt has no write site in any build. The delta (+2 new) still shows when there is one; that's an exception, and exceptions are the only thing this line carries.

B · Failure modes

5
Slow or stalled — after ~3 min. warning A human should look, but nothing has failed and the run continues. Needed because BN cannot tell a healthy fetch from a dead one — only isDownloading true/false, never a rate. This is the one legitimate wall-clock number, and it's a hint, not a failure.

No cause is offered, deliberately. BN doesn't know why it's slow — paused sync, quota, network, selective-sync config, disk space are all live possibilities. “Is Dropbox running?” is one guess dressed as a diagnosis, and it sends the researcher to check the thing that's probably fine. Naming the provider already points them at the right app; a professional will investigate from there.
6
Never arrived — after 30 min. error The run stops. Generous on purpose: measured, a fetch sat at zero bytes for 90+ seconds before moving, so anything tighter fails healthy transfers.
Couldn’t fetch “Screen Recording 2026-01-27” from Dropbox
The file is stored online only and didn’t download in 30 minutes.
help.dropbox.com

Names the provider because each has a different fix path — Dropbox's menu-bar item, iCloud's Settings pane, the OneDrive client. “The cloud” leaves the researcher nowhere to go. Naming isn't special-casing behaviour; it's attribution, and the sibling state already names the volume.

Hands off to the provider's own docs rather than writing remedy prose. Dropbox documents its sync failures better than we can and keeps them current; every cause we agreed not to guess between — paused sync, quota, selective sync, network, permissions — is on that page. It also collapses 5 providers × 20 locales of maintained instructions into one URL per provider, resolved from the same lookup that gives the name.

ProviderShownLinks toPage
Dropboxhelp.dropbox.com/sync/files-not-syncing“Dropbox stopped syncing: how to fix…”
Google Drivesupport.google.com/drive/answer/2565956“Fix problems in Drive for desktop”
OneDrivesupport.microsoft.com/en-us/onedrive/fix-onedrive-sync-problems-on-macMac-specific — prefer it over the generic page, the desktop is Mac-only
iCloud Drivesupport.apple.com/guide/mac-help/mchl1a02d711/mac“Work with folders and files in iCloud Drive” — gives Control-click ▸ Download Now directly
Box · Proton DriveBoth ship File Providers, so both resolve via the stem fallback. No URL — not worth the research time. (Box's top search hit is for the EOL Box Sync client, so it would be the wrong link anyway.)
unknown / otherOmit the line entirely. Never invent a URL.

The four above are the list. Research (29 Jul) didn't add a fifth worth the effort — it confirmed the criterion is ships a macOS File Provider rather than market share, since that's what produces the placeholder at all. Apple has made File Provider the only approved mechanism, so the set only grows, which is the argument for keeping the lookup structural rather than an allowlist.

The cost of an unlisted provider is one missing link, not a broken experience. The stem fallback already gives an Egnyte user “Fetching from Egnyte…”, correctly, with no code change. That's what makes “omit the line” a safe default rather than a gap — and why deciding which providers “matter” isn't a decision we have to get right.

Show the domain, link the deep path. The domain is the whole flavour — it tells the researcher they're going to the provider's own help, not some blog — while the path is noise nobody reads. Shorter than a bare “Learn more…”, and more honest, since it says where they're being sent.

The CLI prints the full URL — no click target to hide it behind, and a truncated URL you can't paste is worse than a long one.

All four verified live 29 Jul 2026. Apple's is instructional where the others are diagnostic — it tells the researcher how to force the download rather than why sync broke, which suits iCloud's actual failure mode (usually “not fetched yet” rather than “sync is wedged”). Link to the most useful page per provider; don't force one category on all of them.

Liveness check at release — and it's the point, not the cost. Third-party URLs rot and we don't control them, so they need checking alongside the other release gates. But the reason to bother is that this link fires at the moment a researcher is most stuck: a run has failed, they don't know why, and the thing they need is owned by someone else. A working link there is a small effort that shows the tool thought about them being stuck. A dead one, at exactly that moment, says the opposite — which is why it's worth a gate rather than good intentions.

7
Genuinely damaged file — resident, ffprobe fails or times out. error This is the message that used to fire for downloads. Now that the fetch is handled separately, a probe failure means what it says, and “the file may be damaged” is safe advice rather than a lie.

The row counts; the pane names. A filename doesn't fit — the sidebar's ideal width is ~220px, so “Couldn’t read Screen Recording 2026-01-27 at…” truncates to noise. Counting also scales: naming works for one bad file and breaks at three. Full list in the tooltip, per the existing row convention.
Couldn’t read one interview
Screen Recording 2026-01-27 at 23.37.37.mov
This file downloaded, but couldn’t be read. It may be damaged or in a format Bristlenose doesn’t support. The other two interviews analysed normally.

Says “downloaded, but couldn’t be read” — ruling out the cloud explicitly, because that's the confusion this whole change exists to prevent. And it says what didn't fail: a researcher who knows the other two are fine won't re-run the whole study.

B2 · Decided — how loudly should a partial run nag?

A run finished. Three interviews analysed, one couldn't be read. The row has to say both things, and the subtitle only fits one. Five options, quietest first, drawn at the real geometry so the judgement was about pixels rather than prose. Option 5 won — jump to it for the shipping design.

Read options 1–4 as history. They're drawn in the pre-29-Jul world where every row carried a date (Schema A). That's since been retired — a clean row now shows no status line at all — so only option 5 is drawn as the app will look. Kept because the comparison is what produced the decision.

Option 3 is what ships today. .completedPartial is a PipelineState, so it outranks the idle tier — ProjectSubtitle.resolve returns before the date/delta chain is ever consulted (ProjectSubtitle.swift:156-186), and ProjectRow renders the warning glyph plus the localised “Partial completion” header (ProjectRow.swift:255-257). So the drift is already dropped on a partial. The question isn't which to build — it's whether to change what's there.

Every number is read from source. Sidebar 220pt (its ideal; min 180, max 300 — ContentView.swift:358). Rows 32pt single / 46pt two-line, 7.5pt top inset, 20pt icon column, 13pt title, 10pt count, 10pt subtitle — all from ProjectCellSpec.swift, which is itself reverse-engineered verbatim from ProjectRow. The glyph is exclamationmark.triangle.fill in system orange (MessageKind.warning); Tabler stands in for SF Symbols, at the right size. Rows are drawn unselected and with two ordinary neighbours — a row is never judged alone, and “nagging” is a property of the stack.

1Trailing glyph, subtitle untouchedquietest

220pt · ideal

Clinic intake6 Monday
Onboarding pilot3 Yesterday · 3 new
Retail wayfinding12 3 Jul

180pt · minimum

Onboarding pilot3 Yesterday · 3 new
Uses the subtitle's right slot — the one the cloud glyph occupies, free unless the project is .inCloud. Both facts survive at both widths, and the drift stays clickable.

The cost is that it's easy to miss, and it borrows a slot whose current meaning is ambient status, not something went wrong. It also collides the day a partial run happens on a cloud project.
2Prefix glyph, subtitle keeps the driftglyph + delta

220pt · ideal

Clinic intake6 Monday
Onboarding pilot3 Yesterday · 3 new
Retail wayfinding12 3 Jul

180pt · minimum

Onboarding pilot3 Yesterday · 3 new
The prefix slot is already the distress slotsubtitleText(prefix:) and diagnosticSubtitle both put the clickable glyph there, so this reuses the existing vocabulary rather than adding one. Colour carries severity; the text carries the actionable half.

What it costs: the glyph eats 14pt of a line that's already tight, and the failure is stated only in colour — the row never says what went wrong. That's fine if the glyph opens the popover, and wrong if it doesn't.
3Shipped today — failure takes the subtitle, genericallystatus quo

220pt · ideal

Clinic intake6 Monday
Onboarding pilot3 Partial completion
Retail wayfinding12 3 Jul

180pt · minimum

Onboarding pilot3 Partial completion
Zero work, and it fits at 180pt. Consistent with every other distress state in the sidebar, which is why it reads as calm here.

“Partial completion” is a status, not a fact about the study. It doesn't say what worked, what didn't, or how many — the researcher has to open the popover to learn anything at all, including the good news that three interviews are ready. That's the weakness worth weighing against its zero cost.
4Failure takes the subtitle, specificallylouder

220pt · ideal

Clinic intake6 Monday
Onboarding pilot3 1 interview couldn’t be read
Retail wayfinding12 3 Jul

180pt · minimum

Onboarding pilot3 1 interview couldn’t be read
The only option that says what actually happened without opening anything. It's also the sentence the failure pane leads with, so the row and the pane agree.

It truncates at 180pt — and the tail it loses is the verb, leaving “1 interview couldn’t be…”. It also keeps nagging: the phrasing has no tense, so a fortnight later the row still reads like something needs doing, when the drift (“3 new”) is the only live thing left.
5Composed — both halves in one line★ chosen

220pt · ideal — drawn under Schema E

Clinic intake6
Onboarding pilot3 +3 new, 1 failure
Retail wayfinding12
Ward handover study9

180pt · minimum

Clinic intake6
Onboarding pilot3 +3 new, 1 failure
Retail wayfinding12
This is the one that ships, and it's drawn in its real context — Schema E, where a clean row has no subtitle at all and collapses to a single 32pt line. The exception is the only two-line row in the list, so it's found by shape before any text is read. Options 1–4 above are still drawn in the superseded Schema A context (a date on every row); they're kept as the record of what was compared, not as a picture of the app.

It fits at both widths — measured, not estimated. Available subtitle width is 163pt at 220pt, 123pt at 180pt. +3 new, 1 failure is 78.4pt; the realistic worst case +24 new, 11 failures is 94.6pt. Even today's longer copy (+3 unanalysed, 1 failure, 114.1pt) fits at both. Dropping the date is what bought the room: with it, the same line was 170.5pt and overran the ideal width.
Decided — option 5, the composed line, under Schema E. ⚠ +3 new, 1 failure: the drift, then the failure clause, in one subtitle behind the orange warning glyph. No date — see below.

This overrides the “one winner, never composed” rule for this state — deliberately. That rule came from a character-count estimate that measurement disproved: the line has 163pt to work with and needs 78–95pt. The rule still holds wherever a state genuinely can't fit both halves; a future pass should not “fix” this back to a single clause.
Schema E — a clean row shows no status line at all. Decided 29 Jul, and it's why the composed line fits.

The date is gone from every row. Not deferred — retired. A timestamp earns chrome when it records someone else's action (that's why Mail has dates); on one researcher's own machine, the date we happened to run the pipeline is metadata about our tool, not about the research. The evidence that settled it: lastPipelineRunAt has never had a write site in any build, so the bare date has never once rendered — months of use, nobody filed it. An Appearance pref to bring it back is noted for a future experiment, not built; the revisit trigger is multi-user, not multi-project.

Two things still have to be built, and the first is a trap. (1) lastPipelineRunAt must still be written even though the date is no longer shown — ProjectIndex.swift:892 gates the +N new delta on it being non-nil, so skipping the write leaves the sidebar permanently blank while looking finished. (2) The drift copy stays as it is. Shortening +{{count}} unanalysed to +{{count}} new was considered and rejected — they aren't synonyms. Unanalysed is a state; new is a claim about arrival time that the code only infers, and several things land in that set without being new (the failed file above, an unsupported format, anything excluded from a re-run). No locale change.

Why this beats the quieter options. A partial run is two facts and both are live: three interviews are ready to read, and one needs attention. Options 1 and 2 tell the researcher only the first and hide the second behind a colour; options 3 and 4 tell them only the second and drop the news that anything worked at all. The composed line is the only one where the row is honest about the state of the study without being opened.

Under Schema E it also gets a second, pre-attentive channel for free: it is the only two-line row in the list. Most runs succeed, so the sidebar is a uniform stack of single-line rows and the exception is found by shape before a word is read. The height difference isn't raggedness to be normalised — it's the row having more to say.

C · Must never collapse with these

Same glyph family, opposite severity. One says wait; one says cannot proceed.

8
Volume ejected. warning Path genuinely gone, hard FileNotFoundError, row dims. Outranks everything. Resolves only when the human plugs something in — unlike a fetch, which resolves itself.
9
Analysed files deleted. warning Not evicted — gone. Distinguished today by isCloudEvicted, which already runs per file and currently discards its answer.

C2 · Count variants — both failure panes

“The other two interviews analysed normally” only works when there are exactly three and one failed. Four shapes, and the last one changes the claim, not just the numbers.

One of several
Screen Recording 2026-01-27 at 23.37.37.mov
This file downloaded, but couldn’t be read. It may be damaged or in a format Bristlenose doesn’t support.
The other 2 interviews analysed normally.
Several of several
Screen Recording 2026-01-27 at 23.37.37.mov
Screen Recording 2026-01-28 at 00.13.56.mov
These files downloaded, but couldn’t be read. They may be damaged or in a format Bristlenose doesn’t support.
The other 3 interviews analysed normally.
The only one
Screen Recording 2026-01-27 at 23.37.37.mov
This file downloaded, but couldn’t be read. It may be damaged or in a format Bristlenose doesn’t support. — no reassurance clause; there is nothing else to reassure about.
All of them — different message
Couldn’t read any of the 5 interviews
None of these files could be read. That’s unusual for a whole study — it more often means a problem with Bristlenose’s media tools than with the files themselves.
Why the last one forks. When every file fails, “they may be damaged” is almost certainly wrong — five simultaneously-damaged recordings is far less likely than one broken toolchain. Telling a researcher their whole study is corrupt would be the same misdiagnosis this work exists to remove, at maximum stakes. AudioToolError's own docstring already makes this call: “a broken tool must never be mislabelled as ‘your interview has no audio’.”

This is an inference BN can legitimately make — all-failed versus some-failed is something it observes, not a guess about someone else's system — but the wording still hedges (“more often means”) and routes to Check Health rather than asserting a cause.

Also applies to the fetch pane. Three files failing to fetch is the common case, not the exception — the reproduction had exactly that. Same four shapes; the all-failed variant there points at the provider rather than the toolchain.

Cap the list. Show ~5 filenames then “and 3 more”. Fifteen monospace lines is a wall, and the researcher only needs enough to recognise the pattern.

Plurals are CLDR, not string concatenation — desktop goes through t(key, count: n), so “1 interview / 2 interviews” is the framework's job in all 20 locales. Don't hand-roll the s.

C3 · Plural audit

Scanned the spec and the shipped code. Keys follow the live convention — key_one / key_other with {{count}}, as interviewCount_* and missingSubtitle_* already do.

KeyStringPlural?
cloudAtRest{{count}} in {{provider}}No noun — safe. Omit entirely at 0 rather than rendering “0 in Dropbox”.
cloudFetchingFetching from {{provider}} · {{done}} of {{total}}No noun — safe
cloudFetchSlowStill fetching from {{provider}}No count — safe
cloudFetchFailedCouldn’t fetch from {{provider}}No count — safe
cloudFetchFailedBody_one/otherThe file is stored… / These files are stored…Yes — verb agreement, not just the noun
unreadableCount_one/otherCouldn’t read 1 interview / {{count}} interviewsYes
unreadableBody_one/otherThis file downloaded… / These files downloaded…Yes
unreadableOthersOk_one/otherThe other interview analysed normally. / The other {{count}} interviews…Yes
unreadableAll_other onlyCouldn’t read any of the {{count}} interviewsNo _one. See trap 1
…listOverflowand {{count}} more“more” is invariant — safe
…durationdidn’t download in {{duration}}Pre-formatted string, never a raw number. See trap 2
Trap 1 — all-failed has no singular. “Couldn’t read any of the 1 interviews” is nonsense, and “any of the 2 interviews” is clumsy English. When the count is 1, route to the single-file variant instead; unreadableAll only ever needs _other. A missing _one here is correct, not an oversight — worth a comment in the locale file so a future sweep doesn't “fix” it.

Trap 2 — the duration is a formatted string. Interpolating a raw number gives “didn’t download in 1 minutes”. This bug shipped in today's code and was caught by this scan: fs.py rendered after {int(timeout // 60)} minutes, which produced “after 1 minutes” at 60 s and “after 0 minutes” at anything under a minute. Now count_noun(), with a seconds fallback below a minute — the convention the CLAUDE.md already mandated and I'd read that morning.

Trap 3 — Slavic locales need more than two forms. The locale set includes pl, ru, uk, cs, which have _few and _many categories on top of _one/_other. i18next selects correctly on its own, but the translator has to supply those forms — an en-only _one/_other pair will silently fall back and read wrong for “2 interviews” vs “5 interviews” in Polish. Flag it on the translation ticket rather than discovering it in review.

Trap 4 — two surfaces, two mechanisms. These strings surface in the desktop and in CLI/pipeline errors. Desktop uses CLDR via t(key, count:); the Python side uses count_noun() from utils/text.py. AudioToolError messages cross that boundary — they're raised in Python and rendered on the row — so they follow the Python convention. Don't hand-roll s on either side.

D · Every string, in one place

StateRow subtitleKindDetail pane / notes
At rest, evicted3 in DropboxinfoAppended to the normal subtitle. Cloud glyph trailing. Counts files → partial vs whole legible.
FetchingFetching from Dropbox · 1 of 3infoIndeterminate spinner. No ETA — none is derivable.
Fetch slow (~3 min)Still fetching from DropboxwarningRun continues. Only legitimate wall-clock number.
Fetch failed (30 min)Couldn’t fetch from DropboxerrorPane names the file: “The file is stored online only and didn’t download in 30 minutes.” + provider help link, shown as the bare domain. Try again · Show in Finder
File damagedCouldn’t read 1 interviewerrorPane names the file: “This file downloaded, but couldn’t be read. It may be damaged or in a format Bristlenose doesn’t support. The other two interviews analysed normally.” Only reachable once bytes are resident.
Volume ejectedCan’t find this project — connect “T7”warningExisting. Outranks all activity states.
Files missing2 interviews missing from the folderwarningExisting. Deleted, not evicted.

E · Precedence

can’t-find → failed → fetch-failed → running → stopped/partial → copying → fetchingfetch-slow → files-missing → unanalysed → ready

Fetch-failed sits with the terminal failures; fetching and fetch-slow sit with the self-resolving states, below activity — they're weather, not a dead end. Unchanged from the shipped chain apart from the three new entries.

F · Open questions

G · Two copy rules this settled

The row counts; the pane names.

A ~220px sidebar row can't hold Screen Recording 2026-01-27 at 23.37.37.mov, and naming one file doesn't scale to three. So the row carries a count, the tooltip carries the list, and the detail pane names each file with the remedy. Applies to both failure panes above, and matches the existing convention that status truncates to the row tooltip.

State what was observed. Name what you're waiting on. Don't guess the cause.

BN knows two things about a slow fetch: isDownloading is true, and how long it's been. It does not know whether the cause is paused sync, quota, network, selective-sync config, or free disk. “Is Dropbox running?” presents one of those as a finding, and sends the researcher to check the thing that's most likely fine.

That's the same defect as “ffprobe timed out” implying corruption, one notch quieter — a message asserting more than the system actually established. Naming the provider is the whole job: it points at the right app without pretending to know what's wrong inside it, and a working professional takes it from there.