Sync narration: SyncPhase expansion + activity feed + drill-down sheet
Sync narration: SyncPhase expansion + activity feed + drill-down sheet
Status: planned, not yet implemented.
Context: sync on a large library appears to hang for ~10s before any
counter ticks; the existing SyncPhase enum (idle / hashing /
fetchingServer / reconciling) collapses everything into “hashing” before
the actual hash work begins. Perf fixes in a189a65 and timing logs in
d0006fd shrank the wait and made it diagnosable; this plan adds
user-facing observability so the remaining wait is no longer opaque.
The phases proposed here mirror the tick(...) boundaries already
emitting [cairn.recon.timing] phase=X took=Yms lines, so the plan is
grounded in real instrumentation, not invented categories.
Phase 1 — Model
1a. Expand CairnAppModel.SyncPhase
Replace the four-case enum with six:
public enum SyncPhase: Sendable, Equatable {
case idle
case preparing // fetchPersistentChanges, cachedLocalIds, discoverUntracked,
// deferredQueue snapshot, scope membership — pre-hash work
case fetchingServer // listAllAssets pagination (parallel; non-blocking)
case hashing // observeAndFilter + actual SHA1 work (existing progress bar)
case reconciling // orphan sweep + engine compute + orphan match
case finalizing // journal append + persistSnapshot + refresh helpers
}
fetchingServer stays as a phase even though it’s parallel — the
drill-down can show it as a concurrent track rather than a strict
next-step.
The existing SyncPhaseChecklist view in StatusScreen has a fixed
three-row layout. Decision: simplify to a one-line current-phase
indicator and push the timeline into the drill-down sheet. Keeps the
Status syncCard quiet; the curious user opens the sheet for the full
breakdown.
1b. Add SyncActivity + ring buffer
public struct SyncActivity: Identifiable, Sendable, Equatable {
public let id: UUID = UUID()
public let timestamp: Date
public let kind: Kind
public let detail: String
public enum Kind: Sendable, Equatable {
case phaseStart // "preparing" / "hashing" — written from phase transitions
case hashed // "IMG_4612.HEIC" — throttled ~per-250ms or per-50 assets
case fetched // "server page 3 (of 12)"
case stamped // "5 confirmed-deleted"
case note // generic info ("untracked sweep: 142 ids")
case warning // degraded mode ("Limited Photos auth — pending review only")
}
}
public var syncActivity: [SyncActivity] = [] // capped at 50, newest first
public static let syncActivityCap = 50
Append helper that respects the cap:
@MainActor
public func appendSyncActivity(_ entry: SyncActivity) {
syncActivity.insert(entry, at: 0)
if syncActivity.count > Self.syncActivityCap {
syncActivity.removeLast()
}
}
1c. SyncPhaseTimeline
Per-phase elapsed plus order:
public struct PhaseEntry: Sendable, Equatable {
public let phase: SyncPhase
public let startedAt: Date
public let durationMs: Int? // nil while in-flight
}
public var syncTimeline: [PhaseEntry] = [] // cleared on each sync start, rebuilt as phases advance
Phase 2 — Wire the phase transitions
The tick(...) closures already mark every boundary in
runIncremental / runFullEnumeration. Mirror them with
model.syncPhase = ... writes plus activity emits.
The reconciler runs detached, so it needs a callback into the main actor.
Add an onPhaseChange closure to
PhotoKitPersistentChangeReconciler.init:
public typealias PhaseHandler = @Sendable (_ phase: String) async -> Void
public let onPhaseChange: PhaseHandler?
AppDependencies.persistentChangeReconciler wires it:
onPhaseChange: { [weak self] phaseName in
await MainActor.run {
self?.appendSyncActivity(.init(timestamp: Date(), kind: .phaseStart, detail: phaseName))
}
}
The existing tick(...) closure inside the reconciler also calls
onPhaseChange — single source of truth, no duplicate emit sites.
performLiveReconciliation writes model.syncPhase directly at six
points:
.preparingat top of function.fetchingServerwhenserverAssetsTaskactually starts (currently set after the scan, which is wrong; move to before).hashingwhen reconciler enters its hashing batch (the existingonHashProgresscallback already fires; on first hash event flip to.hashing).reconcilingafter scan returns.finalizingafter engine + orphan match.idleat the end
Phase 3 — Activity feed production
3a. Hashing throttle
onHashProgress fires on every asset (~500/sec on Apple silicon).
Unthrottled SwiftUI rerender cost would be unacceptable. Add to
AppDependencies:
private var lastHashActivityAt: Date = .distantPast
private static let hashActivityThrottleMs: Int = 250
func emitHashActivity(filename: String) {
let now = Date()
let elapsed = Int(now.timeIntervalSince(lastHashActivityAt) * 1000)
guard elapsed >= Self.hashActivityThrottleMs else { return }
lastHashActivityAt = now
Task { @MainActor in
self.model.appendSyncActivity(.init(timestamp: now, kind: .hashed, detail: filename))
}
}
The reconciler’s onHashProgress callback is the natural place to call
this. filename comes from LocalAssetMetadataStore lookup or the
originalFileName already inside the hash batch.
3b. Server fetch pagination
ImmichClient.listAllAssets already paginates internally. Add an
onPage: closure:
public func listAllAssets(onPage: ((_ page: Int, _ assets: Int) -> Void)? = nil) async throws -> [ServerAsset]
AppDependencies wires it to emit .fetched activity per page. ~10-20
entries for a typical Immich.
3c. Phase boundaries + notable events
Every tick(...) call in the reconciler emits a .phaseStart activity
via onPhaseChange. Plus discrete notable events as .note /
.warning:
- “Untracked sweep: 142 ids” (existing log line, mirror to feed)
- “Skipped 6 stale-modDate updates” (existing)
- “Limited Photos: missed deletes will route to pending review” (existing toast — also surface here)
Phase 4 — SyncDetailSheet
New file Sources/CairnIOSCore/UI/SyncDetailSheet.swift. Layout:
┌─────────────────────────────────┐
│ ← Sync detail ✕│ ← title bar with cancel
├─────────────────────────────────┤
│ ● Hashing — 12.4s │ ← current phase + elapsed
│ ░░░░░░░░░░░ 1,247 / 6,512 │ ← progress bar when applicable
├─────────────────────────────────┤
│ Phase timeline │ ← KeylineSection
│ ✓ Preparing 820ms │
│ ✓ Fetching server 3.2s │
│ ● Hashing 12.4s ← live │
│ — Reconciling │
│ — Finalizing │
├─────────────────────────────────┤
│ Activity │ ← KeylineSection, monospace
│ 12:34:18 hashed IMG_4612.HEIC │
│ 12:34:18 hashed IMG_4611.HEIC │
│ 12:34:17 fetched server page 4│
│ ... │
├─────────────────────────────────┤
│ [Cancel sync] │ ← mirrors existing onCancelSync
└─────────────────────────────────┘
Reuses existing primitives (AppHeader, KeylineSection, CairnCard,
tokens). Activity rows are one line each with monospace digits for
timestamps.
Props:
SyncDetailSheet(
phase: SyncPhase,
syncStartedAt: Date?,
progress: (hashed: Int, total: Int)?,
timeline: [PhaseEntry],
activity: [SyncActivity],
onCancel: () -> Void,
onClose: () -> Void
)
Uses TimelineView(.periodic(by: 0.5)) for the elapsed-time refresh —
half-second tick gives smooth-enough “12.4s” updates without 60Hz
redraws.
Phase 5 — Two entry points
5a. StatusScreen syncCard
Add a “Show details” button below the existing checklist when
isSyncing == true:
if isSyncing {
Button("Show details") { onOpenSyncDetail() }
.buttonStyle(.plain)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(t.accent)
}
Plumbs onOpenSyncDetail through StatusScreen’s init → CairnAppRoot
wires to model.presentedSheet = .syncDetail.
5b. InitialScanScreen
Decision: skip the drill-down entry point on InitialScanScreen. The screen already IS the detail view for first-run hashing — adding a “Show details” button on top of an existing progress display is redundant. Only StatusScreen gets the drill-down.
5c. PresentedSheet enum
Add .syncDetail case to CairnAppModel.PresentedSheet and route in
CairnAppRoot.sheetContent(for:).
Phase 6 — Throttling and tests
Throttling rules
- Hashing: 250ms minimum between activity emits.
- Phase transitions: always emit (low N — ~10 per sync).
- Server fetch: per-page (low N).
- Notes/warnings: always.
- Activity buffer cap: 50 entries, newest first.
Tests
@Suite("SyncActivity")
struct SyncActivityTests {
@Test("appendSyncActivity respects the cap and orders newest-first")
@Test("phase transitions emit one activity per change")
@Test("hash activity throttled — three rapid emits within 250ms produce one entry")
}
@Suite("SyncPhase rendering")
struct SyncPhaseTests {
@Test("display label for each case")
@Test("timeline preserves phase order across transitions")
}
UI snapshot tests for the sheet are deferred — no snapshot harness yet.
Files touched
Sources/CairnIOSCore/
PhotoKitPersistentChangeReconciler.swift +30 LoC (onPhaseChange callback + emits)
UI/
CairnAppModel.swift +60 LoC (SyncPhase expand, SyncActivity, timeline)
CairnAppRoot.swift +20 LoC (sheet routing, prop plumbing)
StatusScreen.swift +15 LoC ("Show details" affordance)
InitialScanScreen.swift (no change — see decision 2)
SyncDetailSheet.swift +200 LoC (new file)
iOS/App/AppDependencies.swift +60 LoC (phase writes, throttle helper, wiring)
Tests/CairnIOSCoreTests/
SyncActivityTests.swift +50 LoC (new file)
Estimate: ~450 LoC total, three logical commits.
Commit boundaries
- Model: SyncPhase expansion + SyncActivity buffer. Pure model surface, no UI consumers yet. Tests included. Backwards-compatible — old switches over SyncPhase get default cases.
- Reconciler + AppDependencies: phase writes + activity emits. Wires production. No UI yet; activity buffer fills correctly but no view reads it.
- SyncDetailSheet + Status entry point. Adds the sheet, plumbs the entry, ships.
Each commit independently builds + tests. If commit 3 has issues, commits 1 and 2 are still valuable (better Console output, model state for future use).
Decisions
SyncPhaseChecklistshape. Simplify to a one-line current-phase indicator on Status’ syncCard. The full timeline lives in the drill-down sheet. Status stays quiet for the steady-state user; the curious user opens the sheet.- InitialScanScreen drill-down entry. Skip. The screen already IS the detail view for first-run hashing; bolting a “Show details” button onto an existing progress display is redundant. Only StatusScreen gets the drill-down.
- Activity feed forensic depth. 50-entry ring buffer is the v1. Throttled at 250ms during hashing, that surfaces ~12s of recent history on a long sync. Sufficient for “is anything happening” confidence; not a full forensic timeline. Deeper history (tee to the journal) is deferred — stretch goal if real-world reports show 12s isn’t enough.
- SwiftUI re-render cost.
@Observable+ array prepend triggers re-render of every view that reads any property on the model. OnlySyncDetailSheetreadssyncActivitydirectly; Status’syncCardMUST NOT readmodel.syncActivity.countor any derivative — doing so would re-render Status on every activity emit (potentially 4×/sec during hashing). Pin this constraint in code review.
Predecessors
a189a65— perf: collapsed pre-hash full-table fetches (4 → 1)d0006fd— recon: per-phase timing logs ([cairn.recon.timing])
This plan builds on those. The timing logs already give Console-level narration; this plan adds the equivalent in-app narration plus a drill-down for the curious user.