Skip to content

PE-9205: Release v2.88.0 - #2226

Merged
vilenarios merged 98 commits into
masterfrom
release-v2.88.0
Sep 15, 2026
Merged

vilenarios merged 98 commits into
masterfrom
release-v2.88.0

Conversation

@vilenarios

@vilenarios vilenarios commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Promotes dev to production. 95 commits since v2.87.1, 17 merged PRs.

Do not squash. This is the release merge.

master is merged into this branch rather than the other way round, matching #2204 and #2198. The only conflict was pubspec.yaml (2.88.0 against production's 2.87.1), resolved to 2.88.0. The resulting tree is byte-identical to dev, so this release is exactly what has been on staging.

What's new

A login lands on your drives, not inside one. A new Your Drives page lists every drive with its file count, size and when it was last read, and syncs any selection of them rather than all or one.

Sync you can read. The phases that used to sit silent say what they are doing, a finished sync says what it found, and the progress bar keeps moving or admits when it cannot measure something. A sync nobody asked for no longer holds the app: you can navigate, open drives and upload while one runs.

Search on All Drives. Search has always looked across every drive, but was only reachable from inside one. It is now on the drives list too, desktop and mobile, and a result opens the file where it lives with the file selected.

Coming back to an existing session. Nothing re-syncs and nothing is lost. The app now tells you if drives changed while you were away, and offers to sync just those.

A drive you just made shows what you put in it. Creating a drive and uploading to it showed "Never synced" with no file count and no size, because nothing had read it from chain yet. The files were always there. They are shown now.

Check an upload without a full sync. A file waiting to be confirmed has a Check upload status action.

Smaller things

  • The new drive's welcome page fits on a phone, and its buttons look like buttons
  • A drive opened from the list is scrolled into view in the sidebar
  • Shared links get a proper preview card when posted
  • "Pinned" in a shared file's version list is now "This version"

Under the hood

  • An upload that cannot be found is called failed after two hours rather than eight
  • Status is re-checked every 20 minutes while an upload is pending, and only then
  • GraphQL retries only where a retry can change the answer, and the fallback endpoint now fires when it should rather than depending on a string match
  • Faster CI: change to preview roughly halved

Notes for upgraders

  • No database migration: schemaVersion unchanged at 29
  • No config change: configVersion unchanged at 3
  • Uploads and encryption untouched

Pre-flight

schemaVersion 29 both sides, no .drift changes. Migration fixtures only cover v17–v19, so an unnoticed bump would ship untested
configVersion 3 both sides, no asset changes. No stale-config surprise for existing users
Uploads / crypto ardrive_uploader, ardrive_crypto, lib/core/upload, lib/core/crypto, lib/blocs/upload: zero lines changed
Staging Green through #2224, every deploy successful
Tree vs dev Empty diff

Accuracy of the notes

Every claim was checked against master rather than written from memory, and two drafts were cut as a result:

  • "Sync skips drives with no new activity": probeActiveDriveIds is already in master. It shipped earlier; claiming it here would take credit for old work.
  • "Falls back to a second endpoint sooner": not a timing change. The old code string-matched error.toString() for status codes, so the fallback often never fired. The fix makes it fire when it should.

After merging

The merge is the deploy: production.yaml runs on every push to master, and no workflow is triggered by a tag or a release. Once that run is green, publish the GitHub release v2.88.0 pinned to the merge commit, so the tag names exactly what shipped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr

vilenarios and others added 30 commits August 28, 2026 15:33
`dev` said 2.86.0 while production ran 2.87.1, so every staging build reported
a version two releases old.

The drift is structural rather than an oversight: a release branch is cut from
`dev`, the version is bumped *on that branch*, and it merges to `master` - so
the bump never travels back. It happened for both releases done this way and
would happen again on the next one.

The code on the two branches is byte identical; this is only the string they
report.
Fetching the chain head and prefetching snapshots both ran with the dialog
showing a static "0% complete". The snapshot prefetch is the largest download
in a sync - tens of megabytes for a wallet with history - and the longest
stretch of it that said nothing at all. Both now name themselves.

Only those two. The dialog renders `statusMessage` *instead of* the
percentage, not beside it, so a message during the drive walk would delete the
one number the dialog shows across the longest phase of a sync. The walk stays
deliberately silent and keeps its percentage; the dialog already names how many
drives are done on the line above.

Also promotes two numbers the probe already computed and only logged:
`firstTimeSyncDriveCount` and `skippedDriveCount`. Both are set on every path
that decides them - the probe, a deep sync, a failed probe, and a single drive
sync - because a count that is only correct on one path is worse than an
absent one: a caller cannot tell which it is holding. The no-op sync carries
them through too, since "nothing to do" is the sync where the skipped count is
the whole story.

Nothing in the delicate zone: no batching or concurrency arithmetic, no
watermark or rewind, no snapshot validation, and not one extra network request.
Every value surfaced was already computed.

Known and deliberate: the messages are hardcoded English, matching the
existing `syncCheckingForChanges`, which is also a literal with a registered
but unreferenced ARB key. Two more keys are registered here on the same
footing. Wiring localisation needs the repository to emit keys the UI
resolves, and should fix all five messages at once rather than half of them -
its own change. Non-English users see English for these two short windows
where they previously saw a translated "0% complete".

flutter analyze clean; 1465 passing / 4 skipped.
Both sync states painted a full-screen scrim with a modal on top, and the syncs
that run automatically - the one on login and the periodic timer - are ones no
user ever asked for, so every user was locked out of the app by work they never
requested, for as long as their history took to walk.

Syncs now carry provenance. `SyncTrigger` rides on `startSync` and
`startSyncForDrive` and defaults to `userInitiated`, so every existing call site
- the resync menu, the retry button, the failure snackbar, a drive's own resync
- behaves exactly as it did. The two that fire without being asked pass
`background`: the sync `createSyncStream` runs on login, and the periodic
auto-sync the same method schedules. `SyncInProgress` and `SyncCancelled` carry
the trigger through to the shell.

The shell branches on it. `SyncOverlay` - the two scrim blocks lifted out of
`AppShell.build` - paints for a user-initiated sync and nothing at all for a
background one, so the app stays browsable and uploadable while login syncs.
`SyncLoadingDrives` no longer blocks either: it is the cubit's initial state and
the metadata-only login path, both syncs nobody asked for, over a drives list
the local database already has. Errors are the deliberate exception:
`SyncCompleteWithErrors` blocks whoever started the sync, because it is the one
outcome that asks a question and offers a retry.

The top bar reports the quiet ones. `SyncButton` was a static icon that
reflected no state; it is now the indicator - a ring that turns while a sync
runs, filling as progress arrives but never standing still, since sync progress
plateaus for long stretches and a frozen arc reads as a hang. Every state
renders into the same 24x24 slot the icon already occupied, so nothing in the
row moves when a sync starts or stops, and the glyph keeps one colour
(`textMid`) whether a sync is running or not.

The phase does not hide behind a hover. `SyncButton` is mounted in
`MobileAppBar` too, where nothing hovers and a tooltip is never seen, so the
menu the ring already opens now carries a status header: what is syncing, the
phase or the percentage, and the elapsed time - reachable with a tap. The
dropdown sizes itself as `items.length * 48`, so the header is paid for with an
explicit `maxHeight`. The sync is named with the modal's own strings
(`syncingAllDrives` / `syncingSingleDrive`) rather than a third name of its own,
and the percentage is rendered whole - "42% complete", not "42.0%".

Tests: `SyncOverlay` paints no scrim for a background sync or a metadata load
and does for a user-initiated one, errors block regardless, cancelling follows
the sync it came from, and the modal counts whole percent; the indicator turns
on and off with the state, holds its footprint, keeps its glyph colour, keeps
moving after progress stops changing, keeps its menu, and reports the sync to a
tap with no pointer involved; and both the login sync and the periodic timer
emit `background` while a resync and a retry emit `userInitiated`. The button's
test drives the cubit through a broadcast controller, because a single-
subscription one wedges its own tearDown instead of failing if the button is
ever unwired, and the trigger test waits for the emission rather than for a
fixed 50ms of wall clock.
A successful sync emitted SyncIdle and the modal vanished, which told the
user nothing - least of all that nothing had changed, the one result that
teaches them the next sync is safe to ignore.

- count what a sync actually writes. SyncProgress.entitiesSynced was a
  number nothing in lib/ ever set: 0 in initial(), 0 in
  emptySyncCompleted(), and no copyWith gave it a value, so a sync that
  pulled in five hundred files still reported "up to date, nothing new".
  The repository now accumulates the file and folder revisions it inserts,
  keyed by drive, next to the skipped-entity count it already kept, and
  surfaces it on the same final copyWith. Counted per revision written,
  not per revision the batch hands back: those lists carry cached
  revisions for entities that did not change, and every sync re-walks the
  last 240 blocks, so counting them announced a drive full of new items
  on a sync that wrote none
- add SyncComplete, extending SyncIdle so DriveDetailCubit's post-sync
  refresh and SharingFileListener's handoff, which both gate on
  `is SyncIdle`, keep working untouched
- carry entitiesSynced, skippedEntityCount, the drive name and the trigger
  straight off SyncProgress, plus a sequence so two zero-change results
  are two states rather than one bloc drops. Not a timestamp: two syncs
  with no I/O between them land in the same millisecond
- say nothing when there is nothing to say. Both paths fell through the
  catch into the completion block, so a sync that threw before any drive
  query recorded a failure emitted "Up to date - nothing new" beside the
  snackbar saying the sync failed; a sync with no logged-in profile did
  the same. Both now emit a plain SyncIdle, exactly as before results
  existed
- build one line from those counts: up to date, some items, a named drive,
  and items that could not be read. No count of drives - drivesSynced
  counts drives walked, failures included, so "12 new items across 3
  drives" sent a user whose twelve files landed in one drive looking
  through three
- keep the unreadable clause out of the ellipsis. On a 320px phone the
  joined line overflowed and cut exactly the clause that says the sync has
  holes in it, so the pill lays the two halves out as separate lines
- refuse to announce a stale result. SyncComplete stays the cubit's state
  until the next sync and both surfaces start their timer when built, so
  crossing the shell's breakpoint an hour after login popped a card for a
  long-finished sync
- report it where each sync ran: a background sync flashes beside the top
  bar indicator, out of the bar's layout, for four seconds; a sync the
  user asked for ends its modal on the summary, without a scrim and
  without a click, and dismisses itself

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
the drive walk owned 0.0-0.9 and everything after it was three fixed
jumps - 0.92 for ghost folders, 0.96 for transaction statuses, 1.0 for
done. so the bar climbed quickly and then stood perfectly still through
two phases of unknown length, one of them a gateway round trip bounded
only by a 30s timeout. a bar that has not moved in a minute reads as
hung.

bar travel now goes where progress is real, and where it is not real the
bar stops pretending to have a number.

- the walk keeps the large share (0.85). it is the only phase whose
  length grows with the user's history and the only one that already
  reports continuously - per drive, per block range within a drive
- the fast local phases get small shares they can actually spend: ghost
  writes 0.85-0.88 (per row), the snapshot/preference reads 0.88-0.92
  (per chunk), the pending-tx local reads 0.92-0.97 (per read)
- the gateway confirmation calls get the smallest share of all, 0.97-
  0.99, which is the opposite of what their duration suggests and is the
  point: that phase reports one step per 5000 pending transactions, so
  for a real user it reports exactly one step, when the gateway answers.
  points handed to it are points the bar crosses on no evidence
- so it is drawn indeterminate instead. SyncProgress carries an
  isIndeterminate flag, set while the confirmation loop runs and cleared
  however it ends; the modal's bar animates and the top bar's ring drops
  its value and sweeps. the phase still names itself, and there is no
  ETA and no time-based ramp
- three local database reads that already happened before the gateway
  loop - the owner overrides, the per-drive owners, the pending rows -
  now report a step each. free granularity, no new query

two bugs this reweighting introduced or exposed:

- the walk's own number can exceed 1. it is 1 - (head - height) / range,
  and head is read once at the top of a sync that then runs for minutes,
  so a transaction mined after that read makes the term negative. head
  100 with transactions at 99/101/103 emitted 1.2 then 2.4, and the
  monotonic sink took 2.4 as its high-water mark: every later emission,
  "Sync complete" included, reported 240% and the bar never moved again.
  the walk's contribution is now capped at its own phase end where it is
  computed, and the sink bounds to 0..1 before anything becomes the mark
- publishing to the sink after it closed threw. Future.timeout does not
  cancel its source, so the abandoned _updateTransactionStatuses goes on
  issuing confirmation batches while the sync finishes and closes; the
  throw aborted the batches it had left, invisibly - the fired timeout
  swallows the error - and those transaction statuses were never
  written, leaving uploads "pending" for another cycle. add and addError
  now stop at close. reporting stops; the work does not

what sync does is otherwise untouched: same batching, concurrency,
watermark, snapshot validation, cancellation, error handling and order
of operations. no new network request and no new database query.

the tests drive a real SyncRepository over a real in-memory DriveDao
with the gateway mocked, both paths. the phase-advance tests now assert
distinct values strictly inside a single phase's open interval - the old
count over the whole tail was satisfied by the three fixed boundaries
alone and stayed green with every intra-phase report deleted. each new
test was proved by reverting only the line it guards.
An adversarial review of the whole stack together found six defects no
per-PR review could see: unblocking the app made three surfaces reachable
that silently did nothing, and the indeterminate bar reintroduced the
backwards jump it exists to prevent. Fixed here as one commit rather than
rebased into the commits that introduced them; the parent PRs stand.

- a drive clicked during a background sync answers immediately.
  openFolder emitted its loading state after awaiting the sync, and
  changeDrive had already cancelled the folder subscription by then, so a
  click produced nothing at all - no navigation, no spinner, the previous
  drive still on screen - for as long as the sync ran. The emit moves
  above the wait; the wait itself stays, because a folder must not be read
  from a half-written database.
- the sync modal's bar keeps one widget type across both phases. Two types
  in one slot meant Widget.canUpdate failed on the swap, the element was
  destroyed, and percent_indicator replayed its fill from zero: 0.97 ->
  0.0 -> 1.0 over a second, for anyone with pending transactions.
- SyncCompleteWithErrors carries a trigger like every other terminal
  state, and the overlay honours it. A background sync that failed reports
  at the top bar, drawn as a failure, and keeps Retry Failed in the sync
  menu after the announcement has gone - instead of dropping a scrim over
  whatever the user had moved on to. A sync the user asked for keeps its
  modal exactly.
- the resync items are visibly unavailable while a sync runs, because
  startSync turns the request away. A disabled item has no closure, so no
  Plausible event is recorded for a sync that never starts.
- multi-select stays locked while a sync rewrites rows, but says so: one
  quiet line above the file list, rather than a ctrl-click that does
  nothing at all.
- the sync button keeps one widget type at its slot in every state, so a
  menu open when a sync starts or ends is still open afterwards instead of
  vanishing from under the thumb reaching for Resync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Logging in walked every drive's whole history, on every unlock, because
syncAllDrivesOnLogin shipped as true and nothing consulted whether there
was anything to find.

- default syncAllDrivesOnLogin to false, in the model and in the
  repository's read of an empty store. A stored value either way is an
  explicit choice and is honoured; only the never-touched case changes.
  The settings toggle is untouched.
- keep refreshing the drive list on the quiet path: syncMetadataOnly is
  updateUserDrives and nothing more, so a drive created or renamed
  elsewhere still appears.
- sync anyway when a transaction is still unresolved, since nothing but
  a sync resolves one. The signal is a new local read,
  SyncRepository.hasPendingTransactions - LIMIT 1 against the status
  index, no network request - and the sync it starts is a background
  one, exactly like the login sync it stands in for.
- when the sync is skipped the user is told nothing. Silence is the
  point; Resync stays in the top bar.

Nothing about what a sync does once started is touched.
`DriveDetailLoadInProgress` drew a bare `CircularProgressIndicator`: no
words, no progress, no drive name, no time bound. That was survivable
while a sync held a scrim over the whole app, because nobody could reach
the screen during one. Now that a background sync leaves the app usable,
and that opening a folder says it heard the click before it waits for the
sync, a drive clicked mid-sync sits on that spinner for the length of the
sync. A silent failure traded for a blank wait.

- new `DriveDetailSyncingCard`, drawn in that slot on both mobile and
  desktop, with the content the modal used to carry: what is being synced,
  the drive that will open, the phase the sync names for itself, the
  modal's own `ProgressBar` - indeterminate treatment included - and the
  seconds elapsed
- `ProgressBar` takes an optional `initialPercentage`. This bar mounts
  during a sync and the cubit replays nothing, so without a seed it read
  empty until the next event - up to half a minute in the unmeasurable
  phase. The modal passes nothing and is unchanged
- the top bar's elapsed counter moves to `SyncElapsedTime` so both
  surfaces count from the same `SyncCubit.syncStartTime`
- `DriveDetailUnsyncedCard` is untouched: a drive waiting on a decision
  still reads as one, and must not be confused with a wait

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
The app rendered both of those as "there is nothing". On every login with
an empty local database it drew "Getting Started", two create-a-drive
buttons and an empty sidebar for the whole length of the drive-list
fetch, on the success path - because `DrivesCubit` reports Drift's
immediate empty-table read before `updateUserDrives` returns, and the
wait in `showEmptyDriveDetail` did not cover that fetch.

- `SyncCubit.waitForDriveListRefresh` waits on the drive list
  specifically. `waitCurrentSync` still treats `SyncLoadingDrives` as
  finished, deliberately, so folder opens do not hang behind a refresh -
  which is exactly the hole the empty screen fell through
- three states where there was one: still looking keeps the loading
  panel, looked and found nothing keeps today's `NoDrivesPage`, and could
  not look gets `DriveDetailDrivesUnavailable` - a screen that says the
  drive list could not be read, offers Retry, and neither claims the user
  has no drives nor offers one to create. Drawn by
  `DriveDetailSyncingCard` in its own frame, not a new one-off widget
- `SyncFailure` gets a persistent surface at the top bar: the red
  triangle, the same menu header a partial failure gets, and a Try Again
  that re-runs `syncMetadataOnly`. It was falling through to the idle
  refresh icon, and `autoSync` is false in all three flavours, so the app
  looked fully synced while showing nothing, permanently
- `DriveDetailLoadUnsynced` carries `syncFoundNothing`, so a sync that ran
  and found no root metadata says what happened instead of re-rendering
  the card the user just pressed Sync Now on

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
DrivesCubit reported Drift's first, immediate read of an empty drives
table as DrivesLoadSuccess - long before updateUserDrives had said
whether the user owns anything. The sidebar, the router's drive-selection
listener and the explorer all treat that as fact, so every login with an
empty local database claimed the user had no drives for the whole length
of the fetch.

- gate the emission on SyncCubit.waitForDriveListRefresh() when, and only
  when, the table is empty; a returning user whose drives are already
  local answers immediately as before
- re-read allDrives() after the wait and stand down if drives arrived, so
  a stale empty snapshot cannot land on top of the firing that found them
- share one wait across every firing of the watch, guard emits with
  isClosed, and answer anyway if the wait itself throws, so none of the
  three ways this ends can leave the sidebar quiet for good
- wire SyncCubit into DrivesCubit at its one construction site

- replace the skipped, @tags(['broken']) DrivesCubit suite with a working
  harness. It was broken for two reasons, both of them unstubbed mocks
  whose TypeErrors were swallowed by the async stream listener:
  ArDriveAuth.onAuthStateChanged() and UserPreferencesRepository.load()
- eight tests, each proved to fail with the change reverted
…g PE-9205

An empty nav is indistinguishable from a wallet that has no drives, which is
exactly what a returning user sees on a device the app has not read yet. The
state underneath is now honest; this makes the nav say so.
Nearly every way a login stranded someone had one cause: the app opened a
drive before it knew enough to do so honestly. "Getting Started" over a list
that was still loading, "Drive Not Synced" as a first impression, a blank
screen for a bookmarked id, a hidden drive restored from lastSelectedDriveId.
A list of drives has none of those, because the drive list is the one thing a
login actually fetches - and it is fetched whether sync-on-login is on or not.

- a new /drives route, and where a login with no link to honour lands
- deep links bypass it entirely: /drives/{id}, /drives/{id}/folders/{id} and
  the shared file routes resolve exactly as they always have
- each row: name, public/private, a shared-with-me marker, sync state live
  from SyncCubit, items, size and the drive's creation date
- items and size are withheld until a drive has actually been walked. Both
  count local rows, and for a drive nothing has looked at that count is zero
  for a reason that has nothing to do with the drive
- per-drive "last synced", written when a sync finishes for that drive, kept
  in the key-value preferences store rather than a new drives column
- the four states the PR beneath this built: loading, empty, could not be
  loaded, populated - with the same words, and "could not be loaded" still
  never offering to create a drive
- one "Sync All Drives" offer, on the one login where nothing has ever been
  synced, and withdrawn as soon as one drive has been
- opening a never-synced drive from the list syncs it in the background; the
  explorer's panel reports it from there
- desktop and phone are two layouts, chosen on real width, not one squeezed

The sidebar is untouched: it stays navigation, and the drive metadata lives
here so the two cannot drift apart.

Review fixes:

- the retry no longer says the account is empty while it is running. Try Again
  starts a drive-list refresh, and syncMetadataOnly emits SyncLoadingDrives
  before its request - which woke the list, found an empty table and landed on
  "Getting Started" for the whole retry. The cubit now withholds emptiness
  while a refresh it started is in flight, and again while the drives cubit
  lags the table that refresh has just written
- a sidebar drive tap opens that drive. selectDrive announces the choice, the
  list navigates on it, and the tile for the already-selected drive selects
  rather than returning early - so on the drives list, where the selected drive
  is not what is drawn, the tap is no longer silent
- one source of truth for the columns/stacked decision: the page decides on the
  width the row will actually lay out in, and tells the header and every row.
  A table header over stacked cards was reachable at a 972-1004px window
- the last-synced column is wide enough for the sentence it carries: the
  breakpoint and the flexes are now fixed by that measurement rather than by eye
- the sync-everything button has a width, instead of expanding to a 1200px slab
  of primary colour on the one screen that says nothing is wrong
- the whole page scrolls. At 568x264 the sync card filled the viewport and the
  list was given no height at all
- withheld figures are dropped from the stacked layout, where no heading and no
  hover tooltip can explain them, and the mark itself is ASCII: Wavehaus has no
  em dash, en dash or ellipsis
- the list stops growing past 1200px
- DriveListRow.isSelected, never passed and describing behaviour the page does
  not have, is gone

Browser-test fixes:

- a single-drive sync no longer shows a motionless "0% complete" for the whole
  first GraphQL round trip. The walk's first fraction is read off a block
  height and none exists until the gateway answers, so that stretch now names
  itself - "Reading the drive history..." - and tells the bar it has nothing
  to measure. Both are given up the moment the walk reports a fraction, and
  the percentage takes over exactly as before. No invented ramp
- a sync that fails no longer ends still naming the phase it died in
- the drives-list sync trigger is decided at the moment of the tap, off the
  sync cubit, rather than off the item a row happened to be drawn with: that
  item is a snapshot whose isSyncing is true for every row while an all-drives
  sync runs, and deciding from it is how a tap could silently start nothing.
  Now guarded by drives_list_open_syncs_test
- the paragraph under "Your Drives" is gone. It said nothing here had been
  fetched from the network, which stopped being true when opening a drive
  became the act that fetches it
- no first person anywhere on the page: the drive list that could not be read,
  the offer to sync everything, and the top bar's sync-failure line
- the sync-everything card is two columns - words left, button right - down to
  a breakpoint measured off the title in Wavehaus (173.1px) by a test that
  loads the real face, and stacked below it
- a per-drive overflow menu: sync this drive, rename, share, hide/unhide,
  detach. Every one of them calls the drive detail page's own implementation;
  no new dialogs. Owner-only and shared-only actions are absent rather than
  inert. 48px tap target, sitting in a gutter the header reserves too, and it
  adds no height to a row on a phone
- a stacked row shrink-wraps its height instead of filling whatever box it is
  given
In a perfect world it just works, and the app says nothing. If it is not
working, the user can keep drilling in. Every phase and every state is still
accounted for - it moves from "always on screen" to "one tap away".

Level 0 - the spinner, and nothing else. The always-visible SyncStrip and
SyncNarration are gone: no banner, no reserved slot, no hairline progress
line. The top bar's ring is the whole of what a running sync costs the
chrome, and the page reaches both layouts unwrapped.

Level 1 - tap the spinner. Restore the status header in the sync dropdown,
carrying what nothing else in the app says: which sync this is - naming the
drive, from SyncCubit.syncingDriveId and DrivesCubit, which the strip never
did - the phase or "Reading {completed} of {total}..." while a metadata fetch
is in flight, and the elapsed time. A tap on both breakpoints, because a
phone has no pointer.

Level 2 - sync history, in the Troubleshooting modal, beside the diagnostic
logs a user with a problem is already there to send. Each entry: when it
started, how long it took, what asked for it, which drive, what it found,
what it could not read and the error text where there is one. The sync
dropdown gains ONE row that opens it - the dropdown sizes itself as
items.length * 48 and closes on any tap inside, so it is the wrong container
for a scrolling record.

- store the history as JSON in the key-value preferences store, the way
  per-drive last-synced is stored: no .drift change, survives a reload,
  cleared on logout. Capped at 20 runs, trimmed on the way in.
- record a run from SyncCubit at each terminal point, fire and forget and
  wrapped in its own try/catch, exactly like the per-drive timestamps beside
  it. A sync refused before it started is not recorded: nothing was fetched.
- move SyncTrigger to its own file, re-exported by SyncCubit, so a record on
  disk does not depend on the cubit that wrote it.
- fix a 320px/text-scale-2.0 overflow this surfaced: every ArDriveDropdown
  item was laid out at its own intrinsic width, so "Deep Resync" ran 58px off
  the right edge of every menu in the app. The item is Expanded now and its
  label can ellipsize.
- fix the same class of overflow on the support modal's resource links, and
  let that modal scroll now that it is taller than a phone.

Nothing about what a sync does, when it starts, its batching, watermark,
snapshot validation, progress arithmetic or error handling is changed, and no
new network request is made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
… PE-9205

The menu row labelled "Sync history" called openHelp: it opened a modal
titled Help and left the reader to scroll past support email, Help Center,
Discord, Docs and Troubleshooting to reach the record. A row that names one
thing and opens another is worse than a row that is hard to find.

- showSyncHistoryModal: the record, its own title, its own scroll
- the sync menu's row opens that, so the label and the destination agree
- Help keeps a one-line link to it beside the logs, not the panel itself
- the description of what the record covers moves with the record

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
It sat between the New button and the drive list, indented 43px to line up
with the accordion's uppercase category headings but written in sentence
case with an icon - so it read as a fourth category rather than as the page
it opens.

- moved directly under the logo, above the New button: destination, then
  the action that creates one, then the drives themselves
- lit with containerL1 at radius 4 when the drives list is on screen, the
  same tokens a selected drive row is lit with, because it answers the same
  question
- aligned to the drive rows' left edge so the two highlights agree
- the explorer's breadcrumb now starts at "Your Drives /", which is where a
  file manager puts the way up and where a reader looks for it
- one name for one destination: nav, page and breadcrumb all say Your Drives

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Five defects from the adversarial review, all of them consequences of the
login default moving to metadata-only: the full sync was quietly doing this
cleanup, and nothing took over when it stopped running.

- updateUserDrives cached a *completed* fetch on a repository that outlives
  the session, so logging out (which drops every local table) and back in
  joined a fetch that had already happened, wrote nothing, and left the app
  stating the user has no drives. Same wallet, same tab was enough. It now
  only joins a fetch still in flight.
- _recordSyncRun and _recordDrivesSynced fire unawaited and landed after
  logout cleared the store, resurrecting the previous wallet's history for
  the next one to read. Both check isClosed.
- syncMetadataOnly emitted a terminal state unconditionally, landing it on
  top of a full sync the user had started underneath it - releasing every
  waitCurrentSync against a half-written database. It now reports only if
  nothing else has taken the state off it.
- onError fell from SyncFailure straight back to SyncIdle in the same turn,
  so driveListRefreshFailed was never true and an unreadable drive list was
  drawn as 'Getting Started'. SyncFailure now rests.
- waiters asked for SyncIdle when they meant 'the sync is over'.
  SyncCompleteWithErrors is not a SyncIdle and nothing clears it, so a share
  arriving during a sync that lost one drive was dropped in silence. One
  public predicate now answers that question, and it counts
  SyncWalletMismatch, which was missing.

Also converts the five emits that could still throw after close, and cancels
the share listener's subscription once it has fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…PE-9205

- a shared drive's name was the child that gave when the row ran out of
  room: at 320px it collapsed to zero width and vanished while the badge ran
  off the screen, from about 1.1x text scale. The two now wrap. The row's
  test harness had no text-scale knob at all, which is why its own '320px
  does not overflow' case passed; it has one now, and runs at four scales in
  both themes.
- ArDriveDropdown discarded the caller's whole anchor whenever
  calculateVerticalAlignment was supplied, so the per-drive actions menu -
  which supplies both - was re-anchored to its button's bottom-left with no
  shifting and opened 51px off a phone's left edge, every item's icon
  outside the viewport. Only the follower is the calculation's to decide.
- the drives list is offered only to someone who can reach it. An anonymous
  share-link viewer gets the explorer but not the list, and was handed a nav
  entry and a breadcrumb root that flipped a flag nothing read: the screen
  never changed, the address bar claimed /drives, the entry lit up as the
  page in view, and every later tap was a hard no-op. One predicate now
  answers for the branch and both doors.
- attaching a drive honours the refusal startSyncForDrive now returns
  instead of discarding it and selecting a drive that was never read.
- 'Reading X of X' can differ: the total is the chunk in hand, announced
  before any of it is fetched, rather than growing one batch at a time to
  meet the count at every boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
SyncCompleteWithErrors has always carried failedDriveIds; only the top bar's
retry item read them. So after "1 of 5 drives failed" the list a reader goes
to next showed every row as either "Never synced" or a stale timestamp, and
the drive that failed looked exactly like the four that did not.

DriveListItem carries it as its own fact, because none of the others can
stand in for it: a failed drive keeps whatever lastSyncedAt it had. Ranked
below a running sync, which is happening now, and above the timestamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
_recordSyncedEntities ran inside runTransaction, before the commit, so a
transaction that rolled back left its rows counted anyway - and the sync
reported changes the database never took, in 'N items changed' and in the
history it persists.

Staged now, and promoted only once the transaction returns. Discarded
otherwise, so a failed batch cannot be counted by the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…ts PE-9205

- the sidebar says nothing while the list is being read. The nav is where a
  reader looks for drives, not for a report on fetching them; the surfaces
  actually waiting on it already say so.
- 'Loading your drives...' carries a figure once there is one: the drive
  transactions are listed before any is fetched, so the total is known up
  front and the count is real rather than a guess. Reported through
  runPooled's existing onItemDone, so nothing new polls.
- the count rides SyncLoadingDrives and DrivesListLoading rather than being
  read off a cubit at the widget: DrivesListBody draws what it is given and
  reaches for nothing, which is why its tests need no providers.
- one helper words it, because three surfaces say it and they report one
  sync between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…thers PE-9205

The router's profile listener had no ProfileLoggedIn exclusion, so on every
profile emission - a sync finishing, a drive being selected, anything that
made ProfileCubit republish - it set signingIn for a signed-in user, and the
block below then cleared it and took showingDrivesList with it. Two symptoms
from one cause: a user thrown back to the drives list at random, and a
showDrivesList whose early return found the flag already set and did nothing,
so neither the nav entry nor the breadcrumb worked.

- the redirect-to-sign-in never fires for somebody already signed in
- showDrivesList clears the routes checked ahead of it, and only returns
  early when the list is demonstrably what is on screen rather than when a
  flag says so
- 'Your Drives' is gone from the sidebar; the breadcrumb is the way home
- opening a drive waits only for a sync that could be writing *that* drive,
  so syncing drive B no longer makes drive A unopenable
- pressing Sync on a drive row records as Manual, not Automatic: the trigger
  was doing double duty and the record was simply wrong
- 'Reading N of N' says 'Reading N files...'. The total was only ever what
  had been asked for so far, which the count met at every batch boundary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
… PE-9205

The sidebar was the wrong container for it: that sidebar already lists every
drive, so an entry pointing at 'all drives' sat directly above a list of all
your drives and read as redundant however it was styled. Taking it out left
only the explorer's breadcrumb, which _desktopView alone builds - so a phone
had no way back to the list at all.

The help button's slot is the one place that exists on every screen and both
breakpoints, so the house takes it and help moves in beside log out, where a
reader looks for it anyway.

- HomeButtonTopBar in all four top bars, gated on the same predicate the
  router uses before it will draw the list
- the breadcrumb root becomes the same house, so the two doors to one place
  read as one thing
- Material's house until the icon font has one of its own

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
There were three, and they did not agree: the drives list drew a linear bar
in buttonPrimaryDefault, the explorer's panel drew one in textHigh, and the
top bar turned a ring - so the same wait read as a red bar on one screen and
a white one on the next.

The plate stack is the loader this app already uses while it sets up an
account and prepares an upload, so it is the mark a user has been taught
means ArDrive is working. Sync had simply never used it.

- SyncLoadingIndicator: the plates, shared by every surface that waits
- the drives list shows it instead of a bar it could not fill
- the explorer's panel shows it above the bar, not instead of it: motion
  means working, the bar means progress, and neither pretends to be the other
- the measured bar fills in the same token the ring does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
The delegate's Navigator held one page under a constant ValueKey('AppShell'),
so however the branch chain resolved the key never changed, the Navigator
treated it as the same page, and it kept the route it already had. The
address bar was right and the screen was wrong: showDrivesList set the flag,
currentConfiguration reported /drives, the URL changed - and the drive the
user was looking at stayed on screen.

The page is keyed by which shell it is, named inside each branch so it cannot
drift out of step with the chain that assigns it.

Also names the second half of the drive-list read. After the pooled fetch
there is a serial loop that, per private drive, reads a signature over the
network, derives a key against the wallet and decrypts the metadata - so the
count reached its total and appeared to hang with nothing said. It now says
'Unlocking your private drives... 2 of 5', and never appears when there are
none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Info is the one menu item that cannot act from the list. The panel is
DriveDetailCubit.selectDataItem, which begins 'this.state as
DriveDetailLoadSuccess', and the cubit that page provides is built against no
drive - a different instance from the explorer's besides. Nor can the panel
be shown in a dialog: it is a side panel, returning a Flexible around its own
card for a full-height parent, and every attempt to host it in one overflows.
The row cannot stand in for it either, because Activity and Snapshots are
tabs that live only there.

So Info asks rather than acts, on the same one-shot mechanism a folder deep
link uses: the request is recorded on the router, the drive opens by the road
every other tap takes, and the explorer's own cubit opens the panel when it
reports the drive loaded - where a side panel belongs and where its tabs
work.

Separately: prompt_to_snapshot_bloc_test waited 250ms for a prompt the bloc
schedules at 200ms. Fifty milliseconds is not a margin under a full suite;
it failed about one run in two, which is the flakiness the review reported
and I had wrongly written off. Three full runs green since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
showDrivesList kept guessing whether the drives list was already on screen so
it could skip a duplicate history entry, and every version of that guess has
failed the same way: the flag reads true while something else renders, the
request is dropped in silence, and Home is dead for the rest of the session
with the address bar insisting /drives over a drive.

There is no condition that method can test which proves what is on screen, so
it no longer tries. A repeated history entry is the cheaper failure by a wide
margin than a navigation control that does nothing.

Also logs which shell the router chose and the state that decided it. If this
is still reachable, that line turns the next report from a description into a
diagnosis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
ArDriveAppWithDevTools hosted the whole app in an Overlay through
initialEntries, which Flutter reads exactly once - when the Overlay is
created. Rebuilding produced a new OverlayEntry closing over the new app
tree and the Overlay ignored it, so AppRouterDelegate.build() ran, produced
the right tree, and had it discarded.

The mounted tree then only re-evaluated when some bloc emitted. That is why
navigation driven by a cubit worked and navigation driven by the router's own
fields did not: showDrivesList set its flag and notified, nothing rebuilt, and
the user was dropped on the drives list minutes later when an unrelated sync
happened to emit. Every routing fix before this one was correct and had no
way to take effect.

Also:
- opening a drive never starts a sync, from any surface. A tap in the nav is
  a request to look at something, not to fetch it; a drive with nothing local
  lands on the card that says so and carries its own Sync button.
- every sync started by pressing a control records as Manual. The trigger was
  overloaded to mean 'do not raise a modal', and that modal is gone.
- one indicator per wait: the plates where a phase cannot be measured, the
  bar where it can, never both.
- docs/LOGIN_TO_SYNCED.md traces wallet connect to completed sync, marking
  what blocks and what does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Three reviewers over the sync experience - edges, what it tells the user, and
how it looks. Four of these I introduced in this stack.

- a sync that failed rested in SyncIdle. onError left SyncFailure standing but
  both tails dropped SyncIdle on top in the same turn, so the ring stopped and
  a failed sync looked exactly like a successful one. The top bar has a
  failure branch and was never given a state to render.
- startSync and startSyncForDrive returned true after throwing, against their
  own documented contract - which DriveAttachCubit reads to decide whether to
  retry.
- attaching a drive reliably synced nothing. The attach dialog runs through
  performUninterruptableActivity, which holds ActivityInProgress until the
  route finishes closing 200ms after Navigator.pop, and both attempts landed
  inside that window. It now waits for the activity, bounded.
- syncMetadataOnly painted over a running full sync on the way in. The
  terminal emit had this rule; the entry emit never did.
- 'Loading your drives... N of M' counted transactions, not drives: the
  listing dedupes per GQL page, so a wallet spanning pages read '43 of 137'
  for twelve drives.
- 'Unlocking your private drives... N of M' counted only keys derived here, so
  it stopped short whenever a signature could not be read and sat at '0 of 5'
  for a whole second pass with every key already cached.
- the explorer's panel chose plates or bar at mount and never revised it, so a
  panel opened at 99% kept a dead bar after the sync ended.
- a failed drive was drawn in the same grey as a success. The top bar reports
  the failure in red and sends the reader to the list; the list whispered it.
- the way home was a 24px target in a 34px box that looked live, and the
  breadcrumb house vanished exactly when the trail collapsed - two folders
  deep on a phone, which is when it is most wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
… PE-9205

Keying the Navigator's page by which shell was on screen made it replace the
route on every move, tearing down the sidebar and the top bar with the body
and running a page transition over the top - which reads as the whole page
reloading rather than the middle of it changing.

The key was added when the router's rebuilt tree was being discarded, before
that turned out to be Overlay.initialEntries in ArDriveAppWithDevTools. With
the real cause fixed the route updates its own child, so the key goes back to
being the constant it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
vilenarios and others added 26 commits September 5, 2026 08:12
…king PE-9205

Four follow-ups, three of them from a staging pass.

The onboarding screen showed a grid of white dots with a seam across it. The
dots are deliberate texture and the radial gradient over them is meant to fade
them out - but at `radius: 2.5` the transparent stop sits two and a half
box-widths away, so everything actually drawn came from the first fraction of
the gradient. The dots stayed near full strength all the way down and the
ClipRect ended them on a hard horizontal line. A radius of 1 puts the
transparent stop at the box's own edge, which is what makes it a fade, and dark
mode drops from 40% to 20%: texture is what you stop noticing.

A transaction that cannot be found is now called failed after two hours rather
than eight, which is about sixty Arweave blocks. One that has not been mined in
sixty blocks is not waiting its turn - either the network has stalled or the
bundler is out, and neither resolves by being waited on longer. The comment
above it claimed forty-five minutes while the constant said `60 * 8`, so
nobody reading it knew what it did; the reasoning now lives on the constant.

The confirmation watch moves to twenty minutes, about ten blocks, which also
divides that window evenly - six looks before an upload is given up on.

And GraphQL retries now distinguish a failure about the moment from a failure
about the request. `package:retry` retries every exception when `retryIf` is
not given, and the budget is five attempts with growing backoff, so a request
that could not succeed spent six seconds failing five times - and delayed the
fallback that might have helped. A 4xx is not retried. Neither is a
GraphQLException raised from an `errors` payload: that came back over a
*successful* HTTP response, so the endpoint read the question and answered it.
Anything with no status to read - a dropped socket, a DNS hiccup, a timeout -
still is, because that is what a retry is for.

Not changed: skipPendingTxFetch keying off ownership rather than write access.
Raised as an inconsistency and it is not one - a drive somebody else owns
cannot be uploaded to, so ownership *is* write access here. Worth knowing that
predicate is where it would bite if that ever changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…ong axis PE-9205

Both from CodeRabbit on #2217, both real.

The pending-threshold comparison used `>` against a truncating `inMinutes`, so
a transaction was not called failed until its 121st minute. The confirmation
watch only looks every twenty minutes, so missing the boundary by one minute
did not cost a minute - it cost a whole cycle, and a transaction sat pending
until 140 minutes while the constant said 120. Both comparison sites move to
`>=`, and the constant now carries the reason so it does not get tidied back.

The gradient comment claimed the radius is scaled by the box width. It is
scaled by the paint bounds' shortest side, which for a full-width box 264 tall
is that height at any real viewport - so radius 1 puts the transparent stop
264px below the top-centre origin, exactly the bottom edge, and radius 2.5 put
it 660px down, leaving the bottom of the box only 40% through the gradient.
The fix was right; the explanation was off by a dimension, which is the kind of
comment that misdirects whoever tunes this next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: Fade the onboarding dots, call a failed upload sooner, retry only what a retry can fix
…9205

Two problems on "You're on chain!", the page a brand new drive opens on.

Its buttons were `ButtonVariant.secondary`, which on the dark card is
`solidGrey700` against a `solidGrey800` background - while the *disabled*
button token is `solidGrey600`. The button that worked was less prominent than
one that would not, which is a reasonable description of "doesn't look
clickable". Both offers are primary now, on both layouts. This page has exactly
two cards and its whole job is to get one of them pressed, so two primaries is
the design rather than a competition.

On a phone the same two offers were 283px squares stacked under a heading and a
paragraph: 239px of scroll on a 375x667 screen, with Create Folder sitting at
y=866 - two hundred pixels below the fold, on the one page whose job is to say
"well done, now do one of these two things". Laid on their side they take about
130px each and the whole page is visible at once.

The SingleChildScrollView stays. At a large enough text scale something has to
give, and a page that scrolls degrades better than one that overflows.

The four-card empty states are left alone: they have the same grey-on-grey
problem, but four red buttons is a wall rather than a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Coming back to an existing session restores everything from the local database
and syncs nothing, which is right: IndexedDB survives a closed tab, so counts,
sizes and block heights are all still there, and `autoSync` is false in every
flavour. The cost is that the page says nothing about how old any of it is. A
reader who closed the tab on Tuesday sees Tuesday's file counts drawn with
exactly the confidence of current ones, and the only cue is a `Last synced` cell
they have to read and do arithmetic on.

The one prompt this page has is gated on `nothingHasEverBeenSynced` - every
drive unwalked - so it only ever fires on a first login. Returning readers get
silence.

So: one query, at the exact moment the app currently goes quiet. `probeActiveDriveIds`
already exists and the sync already uses it to skip unchanged drives;
`probeDrivesWithChanges` runs the same per-owner query read-only, writes
nothing, and starts no sync. If drives have moved, a neutral notice above the
table names how many and offers to sync *only those*. If nothing moved, nothing
is said.

An unanswerable probe says nothing either. That is the opposite of the fallback
inside a sync, where `isComplete: false` means sync everything: there the cost
of guessing wrong is a slower sync, here it is a banner claiming changes nobody
has confirmed, which is a nag.

Two bugs found while testing it:

`close()` shuts the subject down before it awaits its way to `super.close()`,
so there is a window where `isClosed` is still false and adding throws `Cannot
add new events after calling close` - out of an un-awaited future, where
nothing catches it. Guarded on the subject, not the cubit.

The failure swallow lived in the repository, which left the cubit's
fire-and-forget call unprotected: an exception there was an unhandled async
error rather than a failed request. Caught at the boundary that is actually
un-awaited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…t PE-9205

A new user creates their first drive, uploads five files, and opens Your Drives.
The row said `Never synced`, a dash for files and a dash for size - not a
cautious label over correct figures, but the figures withheld. And because that
was their only drive, `nothingHasEverBeenSynced` was trivially true, so the card
announcing "Nothing has been synced yet - their contents have not been fetched
yet" went up over contents they had just put there themselves.

Both come from `hasBeenWalked` being asked two questions and answering with one
flag. "Has anything read this drive from chain?" is what a block height knows,
and it is right about that: `createDrive` inserts without one and the schema
defaults it to zero. "Do the local numbers mean anything?" is a different
question, and for a drive this device created and uploaded to the answer is yes
- `writeFileEntity` put those rows in `fileEntries`, and they are as true as any
sync would make them.

So the figures are shown when the drive has been walked *or* has local content,
where local content means a non-zero count. A zero from a walked drive still
means empty; a zero from an unwalked one still means unknown. Only a real count
proves the device knows something.

`Never synced` stays in the last-synced column, because it is true and nothing
here has checked the chain. It just no longer sits next to two dashes, which is
what made it read as a failure rather than a fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…ktop PE-9205

Desktop search is global chrome - a field in `AppTopBar`, capped at 400px,
beside the hide toggle. Mobile search was page furniture inside the drive
explorer, taking a 60px band above the file list on the layout with the least
vertical room to give.

The field also did nothing until it was submitted, at which point it opened the
same bottom sheet the icon now opens. So nothing is lost by starting from the
sheet, and the band is gained.

`showSearch` is opt-in rather than always on, and that is the load-bearing part:
`MobileAppBar` is also worn by the drives list and the no-drives page. The
drives list subtree provides a `DriveDetailCubit` built against the root path
rather than a chosen drive, and the no-drives page has nothing to search - so a
bar that offered search everywhere would be offering it where it could only
disappoint, or crash. Drive detail asks for it; nothing else does yet.

Also removes a `TextEditingController` the explorer held for the field and never
disposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
A file waiting to be confirmed shows a pending dot, and until now the only two
ways to change it were to wait up to twenty minutes for the confirmation watch
or to run a whole sync.

The pass that actually answers the question is already standalone in everything
but reach. `_updateTransactionStatuses` runs wallet-wide at the end of
`syncAllDrives` and reads its pending transactions out of the local tables
rather than out of the walk, so it does not depend on the sync around it, or on
which drives that run happened to cover. `refreshTransactionStatuses` exposes
exactly that pass and nothing else: no history read, no block-height watermark
moved. It cannot find a file somebody else uploaded - only settle the state of
one already known about, which is precisely what a pending dot is asking.

On the file kebab, and only while that file is pending: a confirmed file has
nothing to check, and a menu item that is present and does nothing is worse than
one that is absent.

Refused while a sync runs, on the standing one-at-a-time rule - that sync ends
with this very pass, so a second would ask the same question twice and race its
own answer. On a refusal or a failure the row keeps the status it had and the
app says nothing, rather than claiming a check it did not complete.

Worth recording what this is *not*: the twenty-minute watch was never doing a
full walk to confirm an upload. The probe skips drives with no newly-mined
activity and the status pass runs regardless, so that path already costs about
three queries. What did not exist was any way to ask on demand. The saving here
is the twenty minutes, not the queries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Three from CodeRabbit's review of the first half of this branch, all real.

The probe is un-awaited, so a sync can start *and finish* while it is in
flight. That sync's completion retires the ids it just read, and the late probe
answer then put them straight back - the list offered to sync drives that had
only just been synced. Checking the state again after the await does not catch
this, because by then the sync is over and the state is idle: only something
that counts runs can tell "no sync happened" from "a whole sync happened". A
generation is now read before the await and compared after it.

`probeDrivesWithChanges` documented an unconfirmable probe as returning nothing
and then wrote `continue`, which drops one owner and reports the rest. That is
a partial answer through an interface documented to return none: a count that
reads as "these are the drives that changed" when it means "these are the ones
we could confirm, out of some number we cannot state". One incomplete owner now
ends the whole probe. Silence is the fallback this method is built around, and
it has to hold for a partial failure too or the contract means nothing.

And the probe tests waited fifty milliseconds on an un-awaited future, which is
a race dressed as a test. Each stub now completes a `Completer` and the tests
await that.

The race test was checked by removing the guard, which is worth recording
because the first attempt at that check was broken: the formatter had collapsed
the condition onto one line, the string replace matched nothing, and a silent
no-op looked like a passing vacuity check. With the guard genuinely gone the
list shows the synced drive and the test fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
CodeRabbit, Major, and correct: `refreshTransactionStatuses` called
`_updateTransactionStatuses` with no cancellation token.

That method writes rows through `insertNewNetworkTransactions`, and this repo
already knows what that means without a token - `sync_shutdown_test.dart` spells
it out. `ArDriveAuth.logout()` empties every table *before* the cubit closes,
and `SyncRepository` is an app-level singleton above the auth gate, so anything
still in flight spends the next few seconds writing the previous wallet's rows
into a database that has just been cleared. The sync path carries a token for
exactly this; the new on-demand refresh was a second door into the same hazard
with no lock on it.

It gets its own token rather than sharing `_currentSyncToken`: that one is
cancelled and replaced by every sync, and a refresh must neither be torn down by
a sync starting nor survive one that cancelled it. `close` cancels it, and a
refresh that finds itself cancelled while the gateway was answering reports
false - the wallet it was about is gone, so there is nothing to tell anyone.

The batch loop in `_updateTransactionStatuses` already checks the token before
each write, so passing one is all that was needed for it to bite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
CodeRabbit, on the token fix itself, and right.

`SyncCancellationToken.dispose` closes the token's stream controller and nothing
else - it does not set `isCancelled`. So a token disposed without being
cancelled answers false forever and `checkCancellation` never throws: two
overlapping refreshes, and the replaced one goes on writing statuses underneath
the live one. That is the leak the token was added to stop, one layer in.

Cancelled then disposed, and the order matters both ways: the reverse throws,
because `cancel` adds to a controller `dispose` has already closed. The `close`
path happened to have it right already.

Left alone: `startSync` has the same shape at its own token. It is not reachable
there - the one-at-a-time rule means the previous token has always finished - so
this does not widen into pre-existing code to fix something that cannot happen.
Worth knowing it is the same shape if that guard ever loosens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: What a returning or brand-new user is told about their drives
"Check upload status" shipped into the row kebab alone. The details panel builds
its own copy of that menu - `EntityActionsMenu`, used by `details_panel.dart` -
so the surface somebody opens to find out why a file is showing an amber dot
could do nothing about it. Half the app had the feature.

This is the trap `UnsyncedDriveMenu` carries a comment about, one file over:
"One menu, mounted twice... a fix applied to one copy is not a fix, because
nothing in either copy says the other exists." Walked into anyway.

So it becomes a free function beside `hideFileDropdownItem`, which is a free
function for exactly this reason, and both menus call it.

The test for it reads the source and counts uses, which is unusual and
deliberate: the failure mode is an omission across two copies, and a widget test
proves only that the arrangement it happened to build works - it cannot notice
the copy nobody wired up. Counting call sites is what actually answers "do both
offer this?".

Also closes three rendering paths that were built and never looked at:

- Dark mode on "You're on chain!". The card sits on `containerL2` and the button
  colour is picked per theme, so "does it fit" had only ever been asked in one
  of the two themes it ships in.
- 1.6x text scale on the same page. It asserts no RenderFlex overflow and that
  both offers stay reachable, *not* that it fits - at some scale it cannot, and
  that is the whole reason the SingleChildScrollView stays.
- The unread-changes notice at 375px, where its Wrap has to fall back to one
  column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Both from CodeRabbit, and the first one undoes a fix that fixed nothing.

"Check upload status" went into `EntityActionsMenu`'s `DriveDataItem` branch
rather than its file branch. So the gap this was written to close - the details
panel having no way to act on a pending file - was still open, and a drive was
being offered an action about a file's upload. The file branch is the
fallthrough that casts `item as FileDataTableItem`; it is there now.

The parity test passed straight through that, because counting call sites proves
"three uses exist" and not "the right branches have them". It now slices
`_getItems` into its type branches and asserts the item is in both file branches
and in no folder or drive branch. Checked against both bugs that actually
happened: omitting it from the second menu fails one test, putting it in the
drive branch fails both.

Worth recording how the misplacement was caught on the way out, too. Moving the
code failed an `assert count == 1`, because both menus carry a byte-identical
`promptToDownloadProfileFile` fallthrough - so the edit stopped rather than
silently landing in whichever one came first. That is the same silent-no-op that
went unnoticed earlier in this stack; the assertion is the difference.

And the phone-layout test asserted `findsOneWidget`, which says mounted, not
readable: a widget laid out past the right edge passes it and is still invisible
to whoever is holding the phone. It now checks real bounds - left at or after
zero, right within the screen, non-zero size - and that the button is above the
fold, since a notice nobody scrolls to is a notice nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: The item menu exists twice, so the item has to
Every shared drive, folder and file renders as this card, and it carried
"Secure, permanent storage." plus five tags, one of them wrong.

The copy now says what the product does rather than what category it is in:
permanent storage on Arweave, paid once, with nothing to renew. Accurate and
specific beats accurate and generic on a card somebody sees before they have
heard of you.

Four tag fixes:

- No `twitter:card`, so X rendered the small summary card and cropped the
  1200x630 image to a square. Title, description and image all fall back to the
  Open Graph tags, so the one line is the whole fix.
- `og:image:width` and `height` were absent. The asset was fetched and measured
  rather than assumed - it really is 1200x630 - so scrapers stop guessing and
  cropping while the image loads.
- `og:image:alt` was absent, which left screen-reader users with nothing.
- `og:url` was hardcoded to https://ardrive.io. This page is also served from
  app.ardrive.io, staging.ardrive.io, PR previews and any AR.IO gateway, so on
  all but one of those it claimed a canonical identity that was not its own -
  and a card for a shared file could send a reader to the marketing site instead
  of the file. Removed rather than corrected: with no og:url a scraper falls back
  to the address it fetched, which is the only answer that is right on every
  host.

The comment records what this card cannot become, because it is the kind of
thing somebody will reasonably want later. Under hash routing a crawler never
receives anything after the `#`, so per-link previews are impossible today - and
share links carry `n` and `ct`, which are private-file secrets. A per-link
preview means a server reading those to render a filename into a public card, so
whatever this grows into must never cover private links.

The GitHub repository description was empty and is set to match, with the
homepage pointed at app.ardrive.io. Topics were already in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
The description landed on "Permanent storage for files, sites and apps, built
on Arweave." after several rounds that all failed the same way, and the failures
are the useful part.

ardrive.io leads with "Pay once to store your files permanently. No
subscriptions, no renewals, no data loss." That is the right pitch for somebody
deciding whether to sign up. This card is mostly seen by somebody who was sent a
link and is about to open a stranger's file, and telling them there is no
subscription answers a question they did not ask.

More importantly, two of those claims are the kind that stop being true. "No
subscriptions" is a promise about a business model, so it is a hostage to the
roadmap. "No data loss" is an absolute about an outcome, so it only has to be
wrong once. A brand whose entire product is permanence cannot afford marketing
that expires - archived pages and quotes become receipts. Claims about what a
thing *is* survive both a pricing change and a bad day; claims about what it
will always *do* do not.

Everything tried in between overreached in one direction or another: links that
never break (they sometimes do, gateways being gateways), nothing that expires,
data that is yours to keep, owning your data. Each added something to disagree
with. The noun was already carrying the difference.

Also drops the pricing lead entirely. Arguing you are a cheaper Dropbox is
arguing on Dropbox's terms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
**Search on All Drives.** `DriveDao.search` takes no drive id - it is global
across every drive plus ARNS names - so this page had no business being the one
screen without a way in. Desktop search lived in `AppTopBar`, which only the
explorer mounts; mobile search was opt-in on `MobileAppBar` and this page had
not opted in.

Adding the field was the easy half. The explorer navigates a result by calling
`openFolder` on its own `DriveDetailCubit`, which works there because that cubit
is long-lived and switches drives underneath the page. On the drives list it is
not: selecting a drive replaces the whole subtree, so the cubit the modal was
handed is torn down mid-navigation and the reader lands at the drive root
instead of the file they searched for. `AppRouterDelegate.requestFolder` is the
road that survives it - asked for before the selection, honoured by
`onDriveSelected` when the drive arrives, and one shot so navigating away and
back lands at the root.

The callback is optional, so the explorer keeps its own path rather than taking
a slower road to the same place. Known gap, stated rather than papered over:
from the drives list a *file* result opens its containing folder but does not
pre-select the file, because the router opens a drive at a folder and has
nowhere to put an item to select.

**"Pinned" was the third meaning of that word in one product.** An ArDrive File
Pin brings a permaweb file into a drive, IPFS pinning is what many readers
arrive already knowing, and neither is what this chip meant - a link aimed at
one fixed version. It says "This version" now, which is what it is.

That rename overflowed the version row by eleven pixels, because the chip was
the only thing in it that could not give: the date is Expanded and the size is
short and numeric. Any longer translation would have done the same, so the row
is the fix rather than a shorter word.

**A selection nobody could see.** Both sidebar sections ship expanded, so a
wallet with a long public list pushes the private one below the fold - and
opening a private drive highlighted a row off screen, which reads as the click
having done nothing. It scrolls to the selection now, and only when the
selection actually changes: a sync tick alone rebuilds this several times, and
scrolling on each would drag the list out from under somebody reading it.
Scrolling rather than collapsing the other section, because a reader who wanted
their public drives hidden would have collapsed that section themselves.

Doing that turned up a real bug underneath: `DriveListTile` forwarded its own
`key` to an inner `GestureDetector`, putting one key on two elements. Harmless
for a `ValueKey` and an outright duplicate-GlobalKey assertion the moment
anything needs to find the tile - which is why an existing test started failing
the moment this one did.

**"Each row will say what it holds as it arrives"** told the reader to go and
watch something, and narrated the interface rather than saying the one thing the
screen could not: how long this takes. It says "Large drives can take a few
minutes."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: Give the shared-link card more than one sentence
PE-9205: Search on All Drives, and three things that read wrong
… PE-9205

#2221 shipped search on the drives list with navigation that does not work. A
file or folder result lands at the root of the right drive rather than at the
thing that was searched for.

`requestFolder` set a pending folder and `onDriveSelected` honoured it - but
`onDriveSelected` is only mounted on the explorer's branch of the router and
never sees a drive opened from the list. The road that actually runs is
`OpenDriveOnSelection`, which hears the selection and calls `openDriveFromList`,
and that cleared the pending folder outright.

The tests passed because they called `requestFolder` then `onDriveSelected`
directly: a path that exists, and not the one a reader takes. The new group goes
through `openDriveFromList`, and three of its tests fail against what is live on
staging now.

A first attempt at the fix broke `drives_list_routing_test` - correctly. A
folder deep link and a search request were sharing one pair of fields, and they
are not the same intent: arriving through `/drives/X/folders/Y` and later
tapping X on the list should open X at its root, because a link is not an
instruction to resume. A search result is the opposite - the reader picked that
folder out of a list of them. Search requests now have their own fields, so
`openDriveFromList` honours one and discards the other. The existing test needed
no changes, which is what says the invariant was real.

**Highlighting the file.** `DriveDetailCubit` takes an `initialSelectedItemId`,
carried on the same one-shot road, so a file result now arrives selected the way
it does from inside a drive. It is keyed by drive id because `_driveDetailCubit`
is built twice over - once for the list against the root path, once for the
explorer against the chosen drive - and unkeyed the list's cubit would swallow
it.

**A crash that was already there.** `openFolder`'s `firstWhere` had no `orElse`,
so a selection that is not in the folder threw out of the folder load. There are
ordinary reasons for that: the file moved or was deleted between the search and
the tap, or hidden items are being filtered. Failing to highlight a row is a
disappointment; throwing is a broken screen.

**Two more found by attacking this before shipping it**, neither of which the
happy path or CI would have caught:

- `clearState` clears every sibling pending field on logout and did not clear
  these, so a search from one session could reach the next person to sign in on
  that device.
- A request honoured in a frame where the explorer never built sat waiting, and
  would have highlighted a stale file the next time that drive opened.
  `showDrivesList` abandons it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…9205

CodeRabbit, and the interesting part is which half was missing.

`showDrivesList` cleared `_selectedItemForFolder` and its drive id - a request
that had been honoured - and left `_requestedFolderDriveId`, `_requestedFolderId`
and `_requestedItemId` alone. So a request that was never spent could survive a
return to the list and jump a later, unrelated row tap into a folder somebody
searched for once.

No live path is known to reach it: `requestFolder` is always followed
immediately by the selection that spends it. This is consistency rather than a
second live bug.

What makes it worth taking is the shape. The comment above those two lines said
"going back to the list abandons whatever a search asked for" while the code
abandoned two fields out of five - and a comment claiming more than its code
delivers is exactly how the honoured-but-unconsumed case got missed in the first
place, one commit ago. Same failure mode, caught earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: A search result from All Drives never actually opened its folder
Ninety-five commits since 2.87.1, and a minor rather than a patch because most
of them are things a user can see: search on All Drives, a returning reader
being told what changed while they were away, the new drive's empty page, the
shared-link card, and a drive's figures no longer being withheld from the person
who uploaded them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: chore(version): bump version to 2.88.0
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: fbd7f4a9-7e58-40d7-8ea7-09434209345d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vilenarios
vilenarios merged commit 2efa302 into master Sep 15, 2026
1 check passed
@vilenarios
vilenarios deleted the release-v2.88.0 branch September 15, 2026 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants