The composer could only attach the current GPS location (fixed ~5km), which
fails when GPS is denied/unavailable and can't tag a post with a different
place. Add a "pick a place on the map" action that opens the shared
GeohashLocationPickerDialog and stores the result in
ShortNotePostViewModel.pickedGeoHash, which overrides the GPS fix at build time
and round-trips through drafts. Picking a place also skips the GPS permission
prompt. The existing "use my location" GPS flow is unchanged; this is additive.
Scoped to the short-note composer for now; the other ILocationGrabber composers
(long-form, classifieds, DMs, comments, channel/nest messages) can adopt the
same pickedGeoHash override + section in a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Extract the picker body into GeohashLocationPickerContent (chrome-less: map +
search + use-my-location + precision + readout + confirm), leaving
GeohashLocationPickerDialog as a thin dialog wrapper around it.
Rewrite GeohashTeleportScreen to host that shared content inside its own scaffold,
passing a teleport-specific confirm label and action (follow the cell + open the
chat with the teleport flag). This dedupes the map/precision/city-name code that
Teleport previously copied, and gives Teleport place search, "use my location",
dark-mode tiles, area-size chips, and the animated pin for free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Refinements after the initial map picker:
- Dark mode: apply the night tile filter to the interactive LocationPickerMap
too, so the picker matches the app theme instead of showing a bright map.
- Search results: the place search now shows a tappable list of candidates
(with human place names) instead of silently jumping to the first hit.
- Precision chips now show approximate area sizes (e.g. "City · ~5 km").
- The center pin lifts with an animated shadow while the target moves.
Generalization: the picker's own strings are renamed from relay_group_location_*
to neutral location_picker_* keys so any caller can reuse
GeohashLocationPickerDialog, not just the NIP-29 group form. The group-form card
strings stay namespaced. LocationPreviewMap gains a configurable aspectRatio.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
The NIP-29 group create/edit form asked for the group's location as a raw
geohash text field (placeholder "u0nd") — unusable, since nobody knows their
geohash, so the discovery geo-filter it feeds stayed empty.
Replace it with a first-class, map-first location experience, reusing the
pieces that already power the Geohash-chat Teleport screen:
- New GeohashLocationPickerDialog: a full-screen picker where the user pans a
map under a fixed center pin, searches for a place by name, or taps "use my
location" (device GPS). Precision chips (GeohashChannelLevel) control the
area size; the resolved place name is shown via LoadCityName. Never surfaces
a raw geohash.
- New ForwardGeolocation service (Geocoder.getFromLocationName) powering search,
mirroring the existing ReverseGeolocation.
- Extend LocationPickerMap with backward-compatible recenter/recenterZoom and
onCenterChanged hooks for the center-pin interaction; extend LocationPreviewMap
with a configurable aspectRatio for the form's map thumbnail.
- Rework the form's location field: an inviting empty-state card, a filled card
with a themed map thumbnail + place name + geohash + clear, and a collapsed
"enter manually" field so power users can still paste a known geohash.
The ViewModel's geohash state stays the single source of truth, so the publish
path (parseGeohashes -> kind-9002 EditMetadataEvent) is unchanged. Adds the
MyLocation glyph and regenerates the Material Symbols subset font.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Primal iOS writes a bare subtype in the imeta MIME tag (`m jpeg`) instead of a
full type (`m image/jpeg`). `createMediaContent` classified media only by the
MIME `startsWith` prefixes when a MIME was present, so a bare subtype matched
neither image/video/pdf and the whole imeta was dropped: the URL rendered as a
plain link. That path is doubly bad — it discards the imeta `dim`/blurhash, so
the loading placeholder cannot reserve the image's height and the feed jumps
when the bitmap finally arrives, and it forces a URL-preview network round-trip
just to rediscover the type the imeta already declared.
Fall back to file-extension detection whenever the type is still unknown after
the MIME/data: checks. This recovers the `.jpg` (or `.mp4`, …) classification
and keeps the imeta metadata, so the image renders through the fast media path
with its dimensions reserved up front. `data:` URIs keep their type in the
prefix and are left unprobed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQ9F7xnRvRgBmVNbo8hRKc
isEnabled() reads plain SharedPreferences (a synchronous disk read, by
design) and scans the accounts cache. Since onReceive runs on the main
thread, this triggered a StrictMode DiskReadViolation at boot. Wrap the
work in goAsync() + a background thread so it runs off the main thread
while keeping the receiver alive until the check completes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4tx1QSE27uoDpnxZZ7msT
Audit fixes for the Blossom client:
- BlossomBlobManagerViewModel: use StateFlow.update{} for the presence
matrix so the Main-thread sync collector and IO-thread delete/mirror
actions can't lose each other's writes; add refreshJob de-dup so two
quick refreshes can't interleave; rethrow CancellationException; bound
the /list HEAD-probe backfill with a Semaphore(8).
- BlossomClient.has(): rethrow CancellationException instead of
swallowing it as 'not found'.
- BlossomSyncForegroundService: drop the stale 'running' de-dup guard so
a fresh sweep always gets foreground protection.
- CLI mirror: strip query/fragment before extracting the sha256.
- DisplayBlossomSyncProgress: retain the last state so the slide-out exit
animation still has content to draw after the state clears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
The sweep was strictly sequential (one mirror at a time). Now it parallelizes
ACROSS servers but stays serial WITHIN each server, so every server gets a steady
one-at-a-time stream (no rate-limit storms) while all of the user's servers work at
once — wall-clock drops to roughly the slowest single server's queue instead of the
sum of everything.
- BlossomMirrorQueue: group work by target server, run one coroutine per server, and
update the shared progress atomically via MutableStateFlow.update from the parallel
workers. Extracted mirrorOne() for the per-task call.
- Dropped the now-ambiguous currentHost from BlossomSyncState (and the banner /
notification text) since multiple servers are in flight at once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Generalizes the shared foreground-service scaffolding (channel setup, start/stop-
when-idle over a StateFlow, the ProgressStyle notification skeleton, tap + cancel
intents, onTimeout) into an abstract FlowProgressForegroundService<T>. Kept as a
base — NOT one mega-service — because Android 14+ binds foregroundServiceType to
behavior, so each workload keeps its own subclass with the correct type.
- FlowProgressForegroundService: the shared base.
- PowMiningForegroundService: refactored onto the base (still shortService); mining
state maps to the card via render(); behavior preserved.
- BlossomSyncForegroundService: new, typed dataSync (the right type for a multi-file
sync that exceeds shortService's ~3-min budget), bound to blossomMirrorQueue.state.
Started (from the foreground, as dataSync requires) when a "Sync all" sweep begins,
so it survives backgrounding; stops itself when done.
- BlossomMirrorQueue: publishes the active state and fires onActive() synchronously so
the FGS starts with live state; wired in AppModules.
- Manifest: FOREGROUND_SERVICE_DATA_SYNC permission + the service declaration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Sync-all now runs like the PoW miner — an app-level job with a floating progress
banner — instead of a screen-bound loop, so it keeps going as the user navigates:
- BlossomMirrorQueue (app-level, on applicationIOScope via Amethyst.instance): runs
the BUD-04 fan-out, exposes an aggregate BlossomSyncState for the banner and a
results SharedFlow so an open manager flips each pill green/grey live. Servers that
need payment are skipped (pay those per-row).
- DisplayBlossomSyncProgress: a bottom floating banner mounted at the navigation root
(sibling of the mining/broadcast banners) with a determinate bar, "x / N · host",
failed count, and cancel/dismiss.
- Manager: a "Sync all" banner appears when any file has gaps; tapping it enqueues the
sweep and optimistically spins the affected pills.
Also, per request: the screen is renamed "My Blossom Data" and moved into the left
drawer's "You" section (just before My Emoji Packs); the standalone Settings-catalog
entry is removed and the Media Servers shortcut relabeled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Addresses three UX gaps on the "Manage stored files" screen:
- Loading was a full-screen spinner until every server × every blob HEAD probe
finished, sequentially — and it even HEAD-probed servers that had already
returned a /list. Now: /list runs across all servers in parallel and renders
immediately; only servers that don't implement /list are HEAD-probed, and those
run in parallel too. A server that listed its blobs needs no probe. Result is
shown as soon as the lists land.
- Mirror-to-missing gave no feedback. Presence is now a per-server state
(present/missing/pending); the target pill shows a spinner while mirroring and
turns green on success or back to grey on failure — updated in place, no full
reload. Delete spins the pill then greys it (and drops the row when it's gone
everywhere).
- The refresh control was a FAB mislabeled "Retry". It's now a proper refresh
action in the top bar (a spinner while loading); the contextual "Retry" stays
only on the error state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Two regressions from the mirror-on-upload change:
1. Progress state stuck. verifyHeader() ends by calling finish() (state=Finished,
progress=1.0), but mirrorToOtherServers then ran updateState(0.95,
ServerProcessing) and never restored Finished — so the on-screen progress ended
pinned at "processing / 95%" and isUploading never cleared. Mirroring is now
fire-and-forget on account.scope AFTER the upload finishes: it never touches the
progress state, never delays the post, and can never turn a completed upload into
a failure.
2. Upload could fail outright. The upload token was BUD-11 server-scoped; some
servers reject an upload whose auth carries a `server` tag, surfacing as a bare
"Uploading error:" with no detail. Upload-token replay isn't the threat scoping
guards against (delete tokens are — those stay scoped), so upload/media tokens
are no longer scoped. Also: blank exception messages now fall back to the class
name so the dialog is never empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Brings the "Manage stored files" screen up to the design language main just
applied to the Media Servers screen:
- Rounded surfaceContainer cards instead of flat Cards.
- Image thumbnail, or a file-type glyph in a rounded secondaryContainer box
for non-images.
- Monospace, truncated-both-ends hash; type · size subtitle.
- Per-server presence as pills with a status dot — allGoodColor tint for
present, muted for missing — replacing the ambiguous same-shaped chips.
- A single MoreVert overflow (copy link / open / report / per-server delete)
with leading icons, plus a full-width tonal "Mirror to missing" CTA.
- Icon-in-rounded-box empty/error states and an ExtendedFloatingActionButton
refresh.
- BUD-07 pay dialog restored with a Bolt icon and tonal confirm; payment
message now names the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Adds cli/tests/blossom/blossom-live.sh, an end-to-end harness that drives the
full amy blossom lifecycle against a REAL public Blossom server: upload → HEAD
check → download-and-verify-hash → list → cross-server mirror → delete. Verified
green (6/6) against files.sovbit.host with a mirror to blossom.primal.net, which
exercises the whole quartz/commons/CLI Blossom stack over the wire.
Server-side write rejections (whitelists, rate limits, payment) record as SKIP,
not FAIL — only a broken client contract (bad descriptor, hash mismatch,
unparseable list) fails — since public servers gate anonymous writes very
differently. Documented in cli/tests/README.md; state dir gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled
from the app instead of only being reported.
- quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the
X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof.
- BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the
account's existing NIP-47 (NWC) wallet and returns the preimage — it never
handles keys or funds itself, only drives the connected wallet. Decodes the
invoice amount for display.
- Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows
the amount and, on confirm, pays and retries the mirror, then continues with the
remaining servers. Cancel leaves the blob unmirrored.
Cashu-only servers and the composer upload path still surface a clear message;
auto-settlement there can reuse this handler next. Not yet validated against a
live paid server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
- Blob manager rows now show an image thumbnail (image/* blobs) and Copy-link /
Open controls, alongside the existing mirror-to-missing / delete / report.
- BUD-07: BlossomUploader now raises a typed BlossomPaymentException on a 402, and
UploadOrchestrator surfaces a specific "server requires payment" message instead
of a generic upload failure (auto-settlement via the NIP-60 wallet is still TBD).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Audit follow-ups on the Messages unread dot:
- concordCommunityHasUnreadFlow captured the community session once, but a
Refounding rebuilds a still-joined community's session in place (same id, new
object). Because the row's flow is remember(communityId)-scoped it was never
recreated, so the fan-in kept watching the dead session and the dot froze.
Re-resolve the session on every concordSessions.revision tick instead — the
same signal ConcordServerRoomCompose already uses to refresh the row's
name/icon — which also covers channel-set changes from folds.
- distinctUntilChanged() on both aggregated server flows so a new message that
doesn't flip the has-unread boolean no longer pushes a redundant value into
composition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
Adds first-class Blossom upload controls to the Media Servers screen and rounds
out the blob manager:
- New persisted account settings (LocalPreferences + AccountSettings):
`mirrorUploadsToAllServers` (BUD-04, default on) and `optimizeMediaOnUpload`
(BUD-05, default off), each with a change fn.
- UploadOrchestrator honours both: uploads via /media with a t=media token when
optimize is on, and only mirrors when the mirror toggle is on.
- BlossomUploader gains a useMediaEndpoint flag to target /media vs /upload.
- "Upload behaviour" section on the Media Servers screen: mirror toggle,
optimize-via-/media toggle, and a shortcut into the blob manager.
- Blob manager gains a refresh FAB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Both the mirror-on-upload fan-out and the blob-manager matrix read the raw
kind-10063 list (BlossomServerListState.flow) instead of hostNameFlow, which
injects the 10 public DEFAULT_MEDIA_SERVERS when the user has none set. This
avoids fanning uploads out to — and listing/HEAD-probing across — public
servers the user never opted into.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Move the All Settings search field from a separate row below the top bar
into the app bar itself, replacing the static "Settings" title. The back
arrow is still shown only when the back stack can pop (deep-link entry),
so bottom-nav entries stay chromeless.
Adds SettingsSearchTopBar with a compact pill search field sized to fit
the app bar and leaves the shared TopBarWithBackButton untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBSLyPYssVu8QdBrvCM7wk
The crowdin/github-action runs inside a Docker container as root, so files
and directories it downloads are root-owned. When Crowdin creates a brand-new
locale folder (e.g. values-en-rGB/ under commons), the unprivileged runner
user in the peter-evans/create-pull-request step can't unlink files inside it,
so the branch checkout aborts with 'unable to unlink ... Permission denied'.
Add a chown step that reclaims ownership of the whole working tree after the
Crowdin action and before any git manipulation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179E3AfsnB1NP87YmtAMZNX
Extends Blossom support toward a full client on both the CLI and the mobile app.
Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
`server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders
Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping
CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
and `report` verbs; auth tokens are scoped to --server
Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
BUD-01 HEAD), delete, mirror-to-missing, and report actions
Tests: quartz URL/auth/descriptor/payment parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
The Messages list showed the purple "new messages" dot (NewItemsBubble) for
DMs, NIP-28 public chats, ephemeral chats, geohash chats and Marmot groups,
but hardcoded hasNewMessages = false for the four NIP-29 / Concord rows, so
those never indicated unread activity.
Wire real unread detection into all four, reusing the last-read markers their
open-chat screens already advance:
- Individual NIP-29 group row and Concord channel row compare the newest
message against the persisted last-read timestamp, exactly like the Marmot /
public-chat rows do.
- The collapsed "grouped by relay" (RelayGroupServerRoomNote) and "grouped by
community" (ConcordServerRoomNote) rows light the dot when ANY child group /
channel has unread, via new reactive fan-in flows that follow the joined list
/ community session so joins and folds re-subscribe.
Extract the relay-group last-read route into relayGroupChannelLastReadRoute so
the read side (row) and write side (RelayGroupChannelView) can't drift, mirroring
marmotGroupLastReadRoute / concordChannelLastReadRoute.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
FollowerCrawler is in commonMain but used `Dispatchers.IO` without importing
the multiplatform `kotlinx.coroutines.IO` extension. On JVM `Dispatchers.IO`
resolves to the JVM member (compiled fine locally), but on Kotlin/Native that
member is internal, so `:quartz:compileKotlinIosSimulatorArm64` failed with
"Cannot access 'val IO': it is internal". Add the explicit import — the same
idiom the sibling GrapeRankCrawler already uses across all targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
The first "Upload priority" header stacked FeedPadding's top inset, an
extra 8dp, and SectionLabel's 20dp inter-section top padding — ~38dp of
dead space under the top bar. Make SectionLabel's top padding a parameter
and shrink it for the first section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
Addresses review feedback on the redesign:
- Auto-save: drop the Save button and replace the cancel "X" with a
standard back arrow (TopBarWithBackButton). Every add/remove persists
immediately; a reorder persists once the drag gesture ends, so dragging
no longer publishes a kind-10063 event on every intermediate swap.
- On-device cache card now uses the app's filled surfaceContainer card
style (no outline), matching the main Settings screens.
- Drop the redundant intro sentence at the top of the screen.
- Rework the "Add a server" block: recommended servers come first as
one-tap chips, followed by the "or paste a server address" field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
The Crowdin action now downloads the commons module's Compose-resource
translations too, but the create-pull-request step's add-paths still only
covered amethyst/ and docs/. New untracked commons files created by the
Crowdin action (run as root in its container) were therefore left out of
the commit and shelved by git stash --include-untracked, which failed with
'Permission denied' when trying to remove the root-owned files.
Add commons/src/commonMain/composeResources/**/strings.xml to add-paths so
those translations are committed into the PR instead of being stashed.
Health probe was hitting the bare server root with HEAD. CDN-fronted hosts
like cdn.satellite.earth never answer `/` (verified: HEAD/GET `/` time out,
while HEAD `/upload` returns 404 in ~0.5s), so a fast, working server was
wrongly shown Offline. Probe the BUD-01 `/upload` endpoint instead — the
path that actually matters for an upload target.
Visual polish:
- Server rows are now outlined cards; the primary target (#1) gets an accent
border, a faint accent tint, and a "Primary" badge.
- Rank and monogram merge into one avatar (rank as a small corner badge),
removing the separate rank tile and decluttering the row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
`amy graperank followers <observer>` (no --relay) assembles the relay universe
via allKnownRelays, which read the reachability cache through ctx.reachability —
and that lazily derives the monitor key from the operator master, forcing a
passphrase prompt (or failing on a fresh machine) purely to READ kind:30166
records. The follower crawl signs nothing, so this defeats its anonymous path.
Read the reachability snapshot with a throwaway signer instead (snapshot() only
reads; the signer is used solely for writes) — the same trick `graperank status`
already uses to stay side-effect-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Replaces the Servers/Local-cache segmented tabs with a single cohesive
scroll, and redesigns every zone of the screen:
- Upload priority: draggable, ranked server rows now carry a colored
monogram (identity) and a "Primary" badge on #1, alongside the health dot.
- Add a server: the old inline URL field + a second 10-row recommended
list are replaced by one inline add area — the URL field plus the
recommended servers as a horizontal strip of add-chips that read as
"done" once added (matched by host so URL normalization can't hide it).
- On-device cache: the old bare switch rows become a self-contained card
with a storage icon, a detection-status dot, and the profile-pics-only
sub-toggle nested under it.
Removes the segmented tabs and their now-dead strings. Same staged
Save/Cancel model, kind-10063 publish, drag utility, and health probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA