Skip to content

feat(ui): add an indefinite pause and a paused banner with one-click resume - #151

Merged
pmaxhogan merged 3 commits into
mainfrom
feat/indefinite-pause-ui
Jul 25, 2026
Merged

pmaxhogan merged 3 commits into
mainfrom
feat/indefinite-pause-ui

Conversation

@pmaxhogan

@pmaxhogan pmaxhogan commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Stacked PR. The base is fix/tray-pause-not-pausing (PR #148), not
main, so the diff shown here is only this feature's changes.

It depends on #148 in substance too: without that fix a pause does not stop
uploads, so a banner saying "Backups paused" would be lying.

Retarget recipe (please do not just click Retarget). This branch was cut
from fix/tray-pause-not-pausing, and the repo squash-merges - so once #148
lands, main has one new commit with no ancestry link to ab20272, which is
the exact shape that produces a CONFLICTING PR. Worse, ci.yml filters
branches: [main], so this PR has had no CI at all so far (only the title
check runs); a conflicted retarget would fire no workflows either, leaving
local gates as the only evidence. So after #148 merges:

git fetch origin
git checkout -B feat/indefinite-pause-ui origin/main
git cherry-pick 86ef281 88fa297
git push --force-with-lease

then change the base to main. If CI still looks absent, check
gh pr view 151 --json mergeable,mergeStateStatus first.

What this adds

An indefinite pause. The tray menu gains "Pause until I resume" beside
"Pause for 30 minutes". It arms no auto-resume timer - only the tray's "Resume
sync" or the banner's Resume button clears it.

A paused banner. A full-width amber bar under the progress bar, shown
whenever a manual pause is in force:

  • Backups paused - 27m left for a timed pause, counting down live (a
    one-second tick that re-reads the wall clock, so it stays correct across a
    laptop suspend instead of drifting).
  • Backups paused indefinitely for the open-ended one.
  • A Resume button that unpauses on one click. It clears the banner
    optimistically and rolls back (keeping the banner, showing the error) if the
    backend rejects - a failed resume must never look like a successful one.

Styling follows the existing amber-alert idiom already used for the
needs-reauth banner in AccountList.vue (border-amber-400 bg-amber-50 text-amber-800 with the dark: variants), and the show/hide fade mirrors
GlobalProgressBar.vue.

Resume now actually restarts backups. Found while checking that the Resume
button does what the label says: nothing in the orchestrator's run-loop
select! selected on the manual-pause signal (orchestrator.rs:484 threw the
receiver away), so clearing the pause left the next cycle waiting for the
scheduled tick - 600 s as shipped. Resume would clear the banner and then do
nothing visible for up to ten minutes. This was invisible before #148 (the
pause never stopped anything, so there was nothing to restart) and user-facing
after it. Fixed with a pause-change arm in the run loop, which covers every
resume path at once - tray, banner, and the timed auto-expiry - rather than
sprinkling a trigger at each call site. The pause direction goes through the
same gate check and issues zero remote calls.

Pause state over IPC. get_pause_state() returns
{"kind":"timed","until_ms":...} / {"kind":"indefinite"} / null, and a new
sync:pause_changed event carries the same payload on every pause, resume,
auto-expiry, and boot-time restore. A pause Pinia store owns the subscription
at the app root (same pattern as the updater and progress stores), so the banner
reacts live on any route - including to a pause set from the tray while the
window was closed.

The pause now actually persists. DESIGN s5.7 has always said "manual pause
persists across restarts" and it did not: the pause was an in-memory watch cell
plus a detached timer, so restarting silently resumed backups the user had
paused. It is now written to the settings table and re-applied at boot.
Deliberately, a timed pause re-arms only the time it has LEFT (pause 30m,
restart 25 minutes later, resume 5 minutes later - not a fresh 30), and one that
elapsed while the app was closed is cleared rather than re-applied. This is
implemented as the fix for that gap rather than as a second mechanism beside it,
so there is exactly one source of truth for "is sync paused".

Tests

Rust (6 new; 340 driven-core + 249 driven-app pass):

  • orchestrator::tests::run_loop_wakes_and_runs_a_cycle_when_the_manual_pause_clears -
    a one-hour scan interval, so if the resume did not WAKE the loop nothing could
    run a cycle inside the timeout. Verified to fail without the new arm (times
    out after 30 s) and pass with it.
  • pause_state_serializes_kind_tagged_snake_case - pins the wire shape the
    TypeScript PauseState union mirrors, both directions. A rename on either
    side would otherwise silently blank the banner.
  • timed_pause_expires_at_its_deadline_indefinite_never_does
  • remaining_is_the_leftover_window_saturating_at_zero - the restart-resumes-
    the-remainder rule, and no negative/underflowed duration on an elapsed one.
  • pause_state_round_trips_through_the_settings_table_and_clears - both
    variants through a real SqliteStateRepo, and that a resume CLEARS the record
    (a stale one would have a later boot re-apply a pause the user ended).
  • an_unparseable_pause_record_reads_as_not_paused - a corrupt record on the
    boot path degrades to "running", never to an error.
  • The existing tray i18n sweeps were extended to cover tray.pause_indefinite,
    so a menu item added in code but missing from the YAML still fails there
    rather than shipping as a raw key.

Vitest (20 new; 309 pass):

  • pause-store.test.ts (13) - timed vs indefinite shapes, minutes rounding up
    so the last partial minute reads "1m left", the elapsed-pause floor at zero,
    the tick recomputing from the wall clock rather than decrementing, live
    ingest + clear, idempotent subscribe, hydrate (including a swallowed backend
    failure), and resume's optimistic clear + rollback-and-rethrow.
  • paused-banner.test.ts (7) - a mount test per render branch: hidden when
    unpaused, both copy variants, the live countdown, hiding when the pause
    clears, the Resume click, and the failed-resume path keeping the banner and
    showing the error.
  • app-shell.test.ts updated for the fifth listener + the new hydrate call.

Gates

  • cargo test -p driven-core -p driven-app - 340 + 249 + integration suites
    pass, 0 fail.
  • cargo clippy --workspace --all-targets -- -D warnings - clean.
  • cargo fmt --all --check - clean.
  • pnpm test:unit - 34 files, 309 tests pass.
  • pnpm lint - 0 errors (17 pre-existing unused-i18n-key warnings, none new).
  • pnpm format:check - clean.
  • pnpm build (vue-tsc --noEmit && vite build) - clean.
  • Coverage gate checked by hand, since no CI runs on a non-main base: UI line
    coverage measured on this branch (89.89%) vs the base branch, whose UI tree is
    identical to main (89.71%) - it goes UP, and every added file is above the
    mean (PausedBanner.vue 100%, pause.ts 92.2%, App.vue 97.5%). The Rust
    half is unaffected because coverage.yml runs cargo llvm-cov --workspace --exclude src-tauri.

Notes

  • There is no in-app pause control other than this banner today (pausing is a
    tray action), so "any in-app pause control" came down to the banner's Resume.
  • The banner reads the pause record, not OrchestratorState, so it is
    specifically about the MANUAL pause. An automatic pause (battery, metered,
    offline, schedule) still surfaces through the tray icon and tooltip only -
    giving those the same treatment is a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_016YnYLjHgYhhnJjUxojsRhn

@github-project-automation github-project-automation Bot moved this to Todo in Driven Jul 25, 2026
@pmaxhogan
pmaxhogan force-pushed the fix/tray-pause-not-pausing branch from eacf51b to ab20272 Compare July 25, 2026 03:53
@pmaxhogan
pmaxhogan force-pushed the feat/indefinite-pause-ui branch from 6d97f38 to 88fa297 Compare July 25, 2026 04:01
Base automatically changed from fix/tray-pause-not-pausing to main July 25, 2026 04:17
pmaxhogan and others added 2 commits July 24, 2026 23:17
…resume

The tray could only pause for 30 minutes, and nothing in the window said
backups were paused at all - the only signal was the tray icon.

- Tray: "Pause until I resume" next to "Pause for 30 minutes".
- A yellow banner at the top of the window whenever a manual pause is in
  force: "Backups paused indefinitely" or "Backups paused - 27m left"
  (counting down live), with a Resume button that unpauses on one click.
- IPC: `get_pause_state` plus a `sync:pause_changed` event carrying the
  pause kind and deadline, so the banner appears/disappears with no
  refresh; a pause Pinia store subscribed at the app root.
- The pause is now persisted, so it survives a restart as DESIGN s5.7
  already claimed. A timed pause re-arms only the time it has LEFT; one
  that elapsed while the app was closed is cleared, not re-applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YnYLjHgYhhnJjUxojsRhn
Nothing in the run loop's select selected on the manual-pause signal, so
clearing it (tray "Resume sync", the paused banner's Resume button, or a
timed pause auto-expiring) left the next cycle waiting for the scheduled
tick - 600s in the shipped default. That made the banner's "unpauses
immediately" untrue.

Adds a pause-change arm. Resuming runs a cycle now; a pause re-derives
Paused{Manual} through the same gate check and issues zero remote calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YnYLjHgYhhnJjUxojsRhn
@pmaxhogan
pmaxhogan force-pushed the feat/indefinite-pause-ui branch from 88fa297 to 1bdfec1 Compare July 25, 2026 04:18
Comment thread src-tauri/src/commands/sync.rs Fixed
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Area main this PR delta
Rust (lib crates) 79.58% 79.51% -0.06 (OK)
UI (vue/ts) 90.06% 90.23% +0.17 (OK)

Gate: passed - no coverage regression (epsilon 0.1 pp).

The hand-rolled env::temp_dir()+format!(tag) path tripped CodeQL
rust/path-injection (2 high alerts on PR 151). RAII TempDir removes
the tainted flow and cleans up after itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YnYLjHgYhhnJjUxojsRhn
@pmaxhogan
pmaxhogan merged commit 090d70c into main Jul 25, 2026
26 of 27 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Driven Jul 25, 2026
@pmaxhogan
pmaxhogan deleted the feat/indefinite-pause-ui branch July 25, 2026 05:36
pmaxhogan added a commit that referenced this pull request Jul 25, 2026
🤖 I have created a release *beep* *boop*
---


## [2.2.0](v2.1.0...v2.2.0)
(2026-07-25)


### Features

* **ui:** add an indefinite pause and a paused banner with one-click
resume ([#151](#151))
([090d70c](090d70c))
* **ui:** last-5m throughput sparkline behind the current-throughput
stat ([#153](#153))
([e60b27f](e60b27f))


### Bug Fixes

* **core:** make the manual pause actually halt in-flight upload
dispatch ([#148](#148))
([d984826](d984826))
* **core:** mirror local folder structure on Drive for plaintext sources
([#150](#150))
([a5d0673](a5d0673))
* **ui:** stream live scan progress from the moment Run Now is clicked
([#149](#149))
([12ddd1e](12ddd1e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants