feat(multi-account): display names, middle-truncated npub, npub-only account fix

Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
  e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
  (ensureCurrentAccountInStorage called in onLoginSuccess)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-29 09:35:33 +03:00
parent 66310ca336
commit 250bb5a1ad
24 changed files with 7404 additions and 17 deletions
@@ -101,7 +101,7 @@ class TopNavFilterState(
FeedDefinition( FeedDefinition(
code = TopFilter.Mine, code = TopFilter.Mine,
name = ResourceName(R.string.follow_list_mine), name = ResourceName(R.string.follow_list_mine),
) )
val allFavoriteAlgoFeedsFollow = val allFavoriteAlgoFeedsFollow =
FeedDefinition( FeedDefinition(
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.signing
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun SigningAwareButton(
signingState: SigningState,
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
when (signingState.state) {
is SigningOpState.Pending -> {
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
is SigningOpState.Error -> {
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
Icon(
icon,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
}
}
is SigningOpState.Idle -> {
IconButton(onClick = onClick, modifier = modifier.size(32.dp)) {
Icon(
icon,
contentDescription = contentDescription,
tint = tint,
modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.signing
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.cancellation.CancellationException
sealed class SigningOpState {
data object Idle : SigningOpState()
data object Pending : SigningOpState()
data class Error(
val message: String,
) : SigningOpState()
}
/**
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
* Observe [globalState] from a screen-level composable to show a persistent status bar.
*/
object GlobalSigningStatus {
var globalState by mutableStateOf<SigningOpState>(SigningOpState.Idle)
private set
private val activeCount = AtomicInteger(0)
fun onPending() {
activeCount.incrementAndGet()
globalState = SigningOpState.Pending
}
fun onIdle() {
if (activeCount.decrementAndGet() <= 0) {
activeCount.set(0)
globalState = SigningOpState.Idle
}
}
fun onError(message: String) {
activeCount.decrementAndGet()
globalState = SigningOpState.Error(message)
}
}
/**
* Per-component state holder for remote signer operations.
* Tracks Idle/Pending/Error — no Success state (existing UI handles success).
* Error resets to Idle on next execute() call (tap = retry).
* Also updates [GlobalSigningStatus] for screen-level status bar.
*/
@Stable
class SigningState {
var state by mutableStateOf<SigningOpState>(SigningOpState.Idle)
private set
var errorMessage: String? = null
private set
suspend fun <T> execute(block: suspend () -> T): T? {
if (state is SigningOpState.Pending) return null
state = SigningOpState.Pending
GlobalSigningStatus.onPending()
errorMessage = null
return try {
val result = withContext(Dispatchers.IO) { block() }
state = SigningOpState.Idle
GlobalSigningStatus.onIdle()
result
} catch (e: CancellationException) {
state = SigningOpState.Idle
GlobalSigningStatus.onIdle()
throw e
} catch (e: SignerExceptions.TimedOutException) {
setError("Signer timed out")
null
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
setError("Signing rejected")
null
} catch (e: SignerExceptions) {
setError(e.message ?: "Signing failed")
null
} catch (e: Exception) {
setError(e.message ?: "Operation failed")
null
}
}
private fun setError(message: String) {
errorMessage = message
state = SigningOpState.Error(message)
GlobalSigningStatus.onError(message)
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.signing
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun SigningStatusBar(
opState: SigningOpState,
modifier: Modifier = Modifier,
) {
var showPending by remember { mutableStateOf(false) }
LaunchedEffect(opState) {
if (opState is SigningOpState.Pending) {
delay(500)
showPending = true
} else {
showPending = false
}
}
val visible = showPending || opState is SigningOpState.Error
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = modifier,
) {
when (opState) {
is SigningOpState.Pending -> {
var elapsedSeconds by remember { mutableLongStateOf(0L) }
LaunchedEffect(Unit) {
while (true) {
delay(1000)
elapsedSeconds++
}
}
Snackbar(
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.padding(horizontal = 16.dp),
) {
Text(
text = "Waiting for signer approval... (${elapsedSeconds}s)",
style = MaterialTheme.typography.bodyMedium,
)
}
}
is SigningOpState.Error -> {
Snackbar(
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(horizontal = 16.dp),
) {
Text(
text = opState.message,
style = MaterialTheme.typography.bodyMedium,
)
}
}
is SigningOpState.Idle -> {}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
---
review_agents:
- kieran-typescript-reviewer
- performance-oracle
- architecture-strategist
- code-simplicity-reviewer
- security-sentinel
---
## Review Context
Kotlin Multiplatform desktop app (Compose Desktop). Nostr client with WebSocket relay connections.
Key patterns: FeedViewModel + FeedFilter + FeedContentState (cache-centric), Coordinator for relay subscription management.
@@ -729,8 +729,9 @@ fun App(
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
accountManager.startHeartbeat(scope) accountManager.startHeartbeat(scope)
} }
// Refresh account list for switcher // Ensure account is in multi-account storage + refresh list
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage()
accountManager.refreshAccountList() accountManager.refreshAccountList()
} }
}, },
@@ -1050,6 +1051,7 @@ fun MainContent(
DeckSidebar( DeckSidebar(
activeNpub = accountManager.currentAccount()?.npub, activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState, allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub -> onSwitchAccount = { npub ->
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub) accountManager.switchAccount(npub)
@@ -55,12 +55,23 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.model.account.SignerType import com.vitorpamplona.amethyst.commons.model.account.SignerType
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
/**
* Middle-truncates an npub: "npub1abcdef...wxyz"
*/
private fun npubShortMiddle(npub: String): String {
if (npub.length <= 20) return npub
return "${npub.take(10)}...${npub.takeLast(6)}"
}
@Composable @Composable
fun AccountSwitcherDropdown( fun AccountSwitcherDropdown(
activeNpub: String?, activeNpub: String?,
allAccounts: ImmutableList<AccountInfo>, allAccounts: ImmutableList<AccountInfo>,
localCache: DesktopLocalCache?,
onSwitchAccount: (String) -> Unit, onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit, onAddAccount: () -> Unit,
onRemoveAccount: (String) -> Unit, onRemoveAccount: (String) -> Unit,
@@ -90,29 +101,45 @@ fun AccountSwitcherDropdown(
) { ) {
allAccounts.forEach { account -> allAccounts.forEach { account ->
val isActive = account.npub == activeNpub val isActive = account.npub == activeNpub
// Resolve display name from cache
val displayName =
remember(account.npub, localCache) {
val pubkeyHex = decodePublicKeyAsHexOrNull(account.npub)
if (pubkeyHex != null && localCache != null) {
val user = localCache.getUserIfExists(pubkeyHex)
user?.toBestDisplayName()?.takeIf { it != user.pubkeyDisplayHex() }
} else {
null
}
}
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Row 1: Display name (or npub if no name)
Text( Text(
account.npub.take(16) + "...", displayName ?: npubShortMiddle(account.npub),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
style = MaterialTheme.typography.bodyMedium,
) )
val label = // Row 2: npub (middle-truncated) + signer type badge
val signerLabel =
when (account.signerType) { when (account.signerType) {
is SignerType.Remote -> "Bunker" is SignerType.Remote -> " · Bunker"
is SignerType.ViewOnly -> "View only" is SignerType.ViewOnly -> " · View only"
is SignerType.Internal -> null is SignerType.Internal -> ""
} }
if (label != null) { Text(
Text( npubShortMiddle(account.npub) + signerLabel,
label, maxLines = 1,
style = MaterialTheme.typography.labelSmall, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelSmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
} )
} }
if (isActive) { if (isActive) {
Icon( Icon(
@@ -160,11 +187,24 @@ fun AccountSwitcherDropdown(
// Logout confirmation dialog // Logout confirmation dialog
val logoutNpub = confirmLogoutNpub val logoutNpub = confirmLogoutNpub
if (logoutNpub != null) { if (logoutNpub != null) {
val logoutDisplayName =
remember(logoutNpub, localCache) {
val hex = decodePublicKeyAsHexOrNull(logoutNpub)
if (hex != null && localCache != null) {
localCache.getUserIfExists(hex)?.toBestDisplayName()
} else {
null
}
}
AlertDialog( AlertDialog(
onDismissRequest = { confirmLogoutNpub = null }, onDismissRequest = { confirmLogoutNpub = null },
title = { Text("Remove Account") }, title = { Text("Remove Account") },
text = { text = {
Text("Remove ${logoutNpub.take(16)}...? This will delete the account from this device.") Text(
"Remove ${logoutDisplayName ?: npubShortMiddle(logoutNpub)}? " +
"This will delete the account from this device.",
)
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -50,6 +51,7 @@ import kotlinx.collections.immutable.ImmutableList
fun DeckSidebar( fun DeckSidebar(
activeNpub: String?, activeNpub: String?,
allAccounts: ImmutableList<AccountInfo>, allAccounts: ImmutableList<AccountInfo>,
localCache: DesktopLocalCache?,
onSwitchAccount: (String) -> Unit, onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit, onAddAccount: () -> Unit,
onRemoveAccount: (String) -> Unit, onRemoveAccount: (String) -> Unit,
@@ -73,6 +75,7 @@ fun DeckSidebar(
AccountSwitcherDropdown( AccountSwitcherDropdown(
activeNpub = activeNpub, activeNpub = activeNpub,
allAccounts = allAccounts, allAccounts = allAccounts,
localCache = localCache,
onSwitchAccount = onSwitchAccount, onSwitchAccount = onSwitchAccount,
onAddAccount = onAddAccount, onAddAccount = onAddAccount,
onRemoveAccount = onRemoveAccount, onRemoveAccount = onRemoveAccount,
@@ -183,6 +183,7 @@ fun SinglePaneLayout(
AccountSwitcherDropdown( AccountSwitcherDropdown(
activeNpub = accountManager.currentAccount()?.npub, activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState, allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub -> onSwitchAccount = { npub ->
singlePaneScope.launch(Dispatchers.IO) { singlePaneScope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub) accountManager.switchAccount(npub)
+120
View File
@@ -0,0 +1,120 @@
# Amethyst Desktop — Progress Report (March 2026)
**Grantee:** Róbert Nagy
**Period:** March 2026
**Project:** Amethyst Desktop (Kotlin Multiplatform)
**Repository:** [vitorpamplona/amethyst](https://github.com/vitorpamplona/amethyst)
---
## Summary
March focused on remote signing, desktop-native layout features, media, and feed architecture improvements. By month's end the desktop app handles feeds, DMs, search, media, multi-column deck, remote signing, chess, highlights, and drafts — covering the core daily-use surface area outlined in the grant. The deck layout and advanced search are the first desktop-specific power features. Relay health UI is planned next.
**Prior months (Dec 2025 Feb 2026):** Established the desktop module, core feed/profile/thread UI, encrypted DMs (NIP-04/NIP-17), live chess over Nostr (NIP-64), broadcast feedback UI, and initial commons extraction. PRs [#1625](https://github.com/vitorpamplona/amethyst/pull/1625)[#1710](https://github.com/vitorpamplona/amethyst/pull/1710).
---
## Work Completed
### NIP-46 Remote Signing (Bunker Login)
PRs: [#1789](https://github.com/vitorpamplona/amethyst/pull/1789), [#1791](https://github.com/vitorpamplona/amethyst/pull/1791)
Fixed two bugs in the Quartz NIP-46/NIP-44 implementation, then built the bunker login flow for desktop.
**NIP-44 HMAC key mutation fix** — a shared key reference was being mutated by Java's `Mac.destroy()`, silently corrupting all subsequent NIP-44 encryption. Fixed with a defensive copy.
**NIP-46 connect response fix** — `connect()` parsed responses as hex pubkeys but the spec returns `"ack"`. Added proper response type handling.
**Bunker login flow** — dedicated NIP-46 relay client with heartbeat monitoring. A pulsating status icon in the sidebar shows signer connection state with hover tooltip. Supports both `bunker://` and `nostr+connect://` URIs.
### Multi-Column Deck Layout
PR: [#1760](https://github.com/vitorpamplona/amethyst/pull/1760)
Desktop-specific power feature that uses screen space to show multiple feeds side by side. Users can add resizable, reorderable columns for any screen type (feeds, messages, search, profiles, etc.). Toggle between single-pane and deck mode via `Cmd+Shift+D` or the View menu. Column configuration persists across restarts.
### Advanced Search
PRs: [#1802](https://github.com/vitorpamplona/amethyst/pull/1802) (superseded), [#1840](https://github.com/vitorpamplona/amethyst/pull/1840)
Query engine with operator syntax and NIP-50 relay search. Supports operators including `from:`, `kind:`, date ranges, hashtags, exclusions, and boolean `OR`. An advanced filter panel syncs bidirectionally with the text bar. Results are grouped into collapsible sections (users, notes, hashtags). Search state survives screen transitions and history is persisted locally.
The shared search engine lives in `commons/search/` for reuse across platforms.
### Media
PR: [#1873](https://github.com/vitorpamplona/amethyst/pull/1873)
Built the media pipeline from loading to upload to playback. Desktop now covers images (Coil3 with blurhash placeholders), video (VLCJ), audio, file upload (NIP-96 with drag-and-drop), encrypted media in DMs (NIP-17), a lightbox viewer with zoom/pan/keyboard navigation, profile galleries, and media server management.
### Cache-Centric Feed Architecture
PR: [#1905](https://github.com/vitorpamplona/amethyst/pull/1905)
Architectural rewrite to fix feed reliability issues found during daily testing.
**Problem:** Rendering events directly from relay subscriptions caused events to disappear on back-navigation, duplicates in feeds, and a deadlock on launch showing 0 relays/notes/follows.
**Solution:** Events now flow through `DesktopLocalCache``DesktopFeedViewModel` → UI, with the cache as single source of truth. Replaced fixed-size eviction with WeakReference-based caching so notes are GC-driven. Extracted shared stores (bookmarks, mutes, badges) to commons. Result: instant back-navigation, proper deduplication, and reliable subscription lifecycle.
### Desktop UX Features
PR: [#1942](https://github.com/vitorpamplona/amethyst/pull/1942)
**Right-click highlight creation** — "Highlight" and "Highlight with Note" in the context menu. Since Compose Desktop has no API to read selected text, the implementation piggybacks on the clipboard.
**Shared stores** — `DesktopHighlightStore` and `DesktopDraftStore` as app-level singletons, fixing cross-deck reactivity and draft persistence.
**Profile tabs** — notes, replies, media, highlights, bookmarks, and zaps on user profiles.
**Editor toolbar** — formatting buttons (bold, italic, heading, link, image, list) for note composition.
### Chess Cross-Client Compatibility
PR: [#1903](https://github.com/vitorpamplona/amethyst/pull/1903)
Fixed interop with other Jester clients. Different clients use `0-0` vs `O-O` for castling — added a SAN normalization layer. Fixed `JesterContent` JSON serialization where missing fields broke game detection. Added a second shared relay for better discovery and fixed game listing dropping spectator-classified games. Covered with 25 interop tests.
### Stability & Bugfixes
PR: [#2027](https://github.com/vitorpamplona/amethyst/pull/2027)
- **Flaky thread test** — reply linking failed when replies arrived before root notes. Fixed with `getOrCreateNote`.
- **Repost rendering** — kind 6/16 reposts now render with overlapping avatars and "Boosted" label. Quoted notes render as embedded cards.
- **Reads feed** — following-mode events now route through cache correctly.
---
## Obstacles & Resolutions
| Challenge | Resolution |
|-----------|------------|
| NIP-44 key corruption after first use | Defensive byte array copy ([#1789](https://github.com/vitorpamplona/amethyst/pull/1789)) |
| No text selection API in Compose Desktop | Clipboard piggyback in custom context menu ([#1942](https://github.com/vitorpamplona/amethyst/pull/1942)) |
| Feed deadlock on launch | Cache-centric architecture rewrite ([#1905](https://github.com/vitorpamplona/amethyst/pull/1905)) |
| Chess castling notation incompatibility | SAN normalization + 25 interop tests ([#1903](https://github.com/vitorpamplona/amethyst/pull/1903)) |
| Search panel ↔ text bar infinite loops | `ChangeSource` guard pattern ([#1840](https://github.com/vitorpamplona/amethyst/pull/1840)) |
---
## What's Next
- Embedded Tor support with fail-closed privacy routing (work started)
- Customizable navigation modes — let users tailor the sidebar and deck to their workflow (reading/writing vs chess vs browsing/DMs)
- Relay management UI with connection health dashboard — directly addresses the grant goal of protocol-level transparency
- Polish and stability toward public release
---
## Personal Progress Assessment & Decision Making
Two decision drivers guided this month's work:
1. **Pareto rule (80/20)** — write and test the essential use-cases first, defer edge cases until feedback reveals which ones matter.
2. **Building what I want to use** — I'm relying on my own sense of what a desktop Nostr client should feel like to make design and implementation choices.
This approach inherently introduces bias. The features I prioritize and the UX I gravitate toward reflect my usage patterns, not necessarily the broader user base. Right now I consider this an effective way to get something usable into people's hands — it keeps momentum high and decisions fast.
As Desktop matures and feedback increases, this will need to shift. Stability and working with a target audience will become more important than personal intuition. I'm playing this by ear since Desktop is still early, though I'm proud of what's working already.
@@ -0,0 +1,205 @@
---
title: "feat: Remote Signer Loading & Error UX"
type: feat
status: active
date: 2026-03-20
origin: docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md
---
# feat: Remote Signer Loading & Error UX
## Overview
Add proper loading indicators and error handling for all NIP-46 remote signer (bunker) operations. Currently, signing timeouts crash as AWT system popup. Fix with a lightweight `SigningState` holder + catch-all helper, reusing existing UI patterns (inline `CircularProgressIndicator`, snackbar feedback).
## Problem Statement
- 9 signing call sites — only zaps and note publishing have error handling
- Reactions, reposts, bookmarks crash on 30s timeout (`TimedOutException`)
- No loading feedback while waiting for signer approval
- Exceptions propagate to AWT unhandled exception handler
## Proposed Solution
Two lightweight layers (see brainstorm: `docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md`):
### Layer 1: SigningState
Per-component state holder. Tracks Idle/Pending/Error — no Success state (existing UI like filled heart already handles success). Uses version counter to prevent race conditions on auto-reset.
**File:** `desktopApp/.../ui/signing/SigningState.kt`
```kotlin
@Stable
class SigningState {
var state by mutableStateOf<SigningOpState>(SigningOpState.Idle)
private set
private var version = 0
suspend fun <T> execute(
operation: String,
block: suspend () -> T,
): T? {
val myVersion = ++version
state = SigningOpState.Pending(operation, System.currentTimeMillis())
return try {
val result = withContext(Dispatchers.IO) { block() }
state = SigningOpState.Idle
result
} catch (e: SignerExceptions.TimedOutException) {
setError(myVersion, "Signer timed out")
null
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
setError(myVersion, "Signing rejected")
null
} catch (e: SignerExceptions) {
setError(myVersion, e.message ?: "Signing failed")
null
}
}
private suspend fun setError(v: Int, message: String) {
state = SigningOpState.Error(message)
delay(5000)
if (version == v) state = SigningOpState.Idle
}
fun reset() { state = SigningOpState.Idle }
}
sealed class SigningOpState {
data object Idle : SigningOpState()
data class Pending(val operation: String, val startTimeMs: Long) : SigningOpState()
data class Error(val message: String) : SigningOpState()
}
```
Key design decisions from deepening:
- **Version counter** prevents race condition where auto-reset overwrites a new Pending state
- **`withContext(IO)`** wraps block — signer.sign() does network I/O, shouldn't block Main
- **`@Stable`** annotation for Compose recomposition efficiency
- **No `Success` state** — existing UI (filled heart, colored repost icon) already handles success
- **No `retry` lambda** — tapping the button again = retry
- **No `onError` callback** — snackbar feedback added at call site via existing `onZapFeedback` pattern
### Layer 2: UI Integration
No new composables — reuse existing patterns:
- **Loading:** Inline `CircularProgressIndicator(Modifier.size(16.dp))` (already used for zap loading)
- **Error in feed:** Reuse `onZapFeedback(ZapFeedback.Error(message))` → existing snackbar in Main.kt
- **Error in dialogs:** Button turns red (`errorContainer`), shows error text, resets after 5s
- **Elapsed time in dialogs:** `SigningStatusBar` at popup bottom (only for ComposeNoteDialog, not action buttons)
Per-button composable wrapping to prevent full-row recomposition:
```kotlin
@Composable
fun SigningAwareButton(
signingState: SigningState,
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (val s = signingState.state) {
is SigningOpState.Pending -> {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
}
is SigningOpState.Error -> {
Icon(icon, contentDescription, tint = MaterialTheme.colorScheme.error, modifier = modifier)
}
else -> {
IconButton(onClick = onClick, modifier = modifier.size(32.dp)) {
Icon(icon, contentDescription, Modifier.size(20.dp))
}
}
}
}
```
### SigningStatusBar (ComposeNoteDialog only)
Shows "Waiting for signer approval... (12s)" anchored to dialog bottom with rounded corners. Only used in ComposeNoteDialog — not for action buttons.
```kotlin
@Composable
fun SigningStatusBar(opState: SigningOpState, modifier: Modifier = Modifier)
```
## Implementation
### Phase 1: SigningState + UI Components in Commons
- Create `commons/src/commonMain/.../ui/signing/SigningState.kt`
- Create `commons/src/commonMain/.../ui/signing/SigningAwareButton.kt`
- Create `commons/src/commonMain/.../ui/signing/SigningStatusBar.kt`
- Verify `commons` has dependency on quartz (for `SignerExceptions`) — already does
### Phase 2: Wire all call sites
**NoteActions.kt** — reaction, repost, bookmark buttons:
- Each gets its own `remember { SigningState() }`
- Replace current icon rendering with `SigningAwareButton`
- On error, also call `onZapFeedback(ZapFeedback.Error(message))` for snackbar
- Keep existing `isLiked`, `isReposted` booleans — they track liked/reposted STATUS, not signing progress (different concerns)
**ComposeNoteDialog.kt** — post button:
- Add `SigningState` for signing, replace `isPosting` boolean
- Add `SigningStatusBar` at dialog bottom
- Button: spinner while pending, red + error text on failure
**UserProfileScreen.kt** — follow/unfollow + profile edit:
- Wrap existing signing calls in `signingState.execute()`
- Keep existing `FollowState` for follow status tracking
**BookmarksScreen.kt** — bookmark decryption:
- Wrap decryption in `signingState.execute()` to catch signer errors
## Acceptance Criteria
- [x] `SigningState` class with version-counter race protection
- [x] `SigningAwareButton` composable for action buttons
- [x] `SigningStatusBar` for ComposeNoteDialog
- [x] All 9 signing call sites wrapped — no more AWT crash dialogs
- [x] Inline spinner while signing pending
- [x] Error feedback via existing snackbar (feed) + red button state (dialogs)
- [x] Elapsed time shown in ComposeNoteDialog status bar
- [x] `isLiked`/`isReposted` booleans kept for status tracking (not conflated with signing)
- [x] `./gradlew :desktopApp:compileKotlin` passes
- [x] `./gradlew spotlessApply` clean
## Files Modified
| File | Change |
|------|--------|
| `commons/.../ui/signing/SigningState.kt` | **New** in commons/commonMain — ~40 LOC state holder (reusable across Android + Desktop) |
| `commons/.../ui/signing/SigningAwareButton.kt` | **New** in commons/commonMain — ~25 LOC per-button composable |
| `commons/.../ui/signing/SigningStatusBar.kt` | **New** in commons/commonMain — ~30 LOC dialog status bar |
| `desktopApp/.../ui/NoteActions.kt` | Wire SigningState to reaction/repost/bookmark |
| `desktopApp/.../ui/ComposeNoteDialog.kt` | Wire SigningState to post + add status bar |
| `desktopApp/.../ui/UserProfileScreen.kt` | Wire SigningState to follow/unfollow + edit |
| `desktopApp/.../ui/BookmarksScreen.kt` | Wire SigningState to bookmark decryption |
**Placement rationale:** `SigningState`, `SigningAwareButton`, `SigningStatusBar` use only Compose Multiplatform APIs (`mutableStateOf`, `CircularProgressIndicator`, Material3) and `SignerExceptions` from quartz. No platform-specific code. Placing in `commons/commonMain/` makes them reusable by Android app too.
## Sources & References
- **Origin:** [docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md](docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md)
- `desktopApp/.../ui/NoteActions.kt:538-624` — existing zap loading pattern
- `desktopApp/.../ui/NoteActions.kt:98-116` — ZapFeedback sealed class (reuse for signing errors)
- `quartz/.../nip01Core/signers/SignerExceptions.kt` — all exception types
- `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt:289` — exception conversion, 30s timeout
### Deepening Findings Applied
| Agent | Finding | Applied |
|-------|---------|---------|
| **Coroutines** | Race condition on delay reset | Version counter pattern |
| **Coroutines** | block() needs IO dispatcher | `withContext(Dispatchers.IO)` wraps block |
| **Compose** | Needs `@Stable` annotation | Added to SigningState |
| **Compose** | Full row recomposes on single button change | Extract `SigningAwareButton` per-button composable |
| **Compose** | Don't conflate signing state with liked/reposted status | Keep existing booleans, SigningState only tracks signing |
| **Simplicity** | No `Success` state needed (UI handles it) | Removed |
| **Simplicity** | No `retry` lambda needed (tap = retry) | Removed |
| **Simplicity** | Reuse existing `ZapFeedback.Error` + snackbar for errors | Yes, no new feedback mechanism |
| **Simplicity** | No separate `SigningProgressIndicator` composable | Use inline `CircularProgressIndicator` in `SigningAwareButton` |
@@ -0,0 +1,766 @@
---
title: "feat: Clean Cache Architecture — Single Source of Truth"
type: feat
status: active
date: 2026-03-22
origin: docs/brainstorms/2026-03-22-clean-cache-architecture-brainstorm.md
---
# feat: Clean Cache Architecture — Single Source of Truth
## Enhancement Summary
**Deepened on:** 2026-03-23
**Agents used:** kotlin-coroutines, compose-expert, desktop-expert, kotlin-expert
### Critical Issues Discovered
1. **ViewModel scope leak**`remember(key)` doesn't call `onCleared()`, leaking coroutines on feedMode switch
2. **Note.flowSet allocation**`note.flow()` creates `NoteFlowSet` during composition (side effect); needs `remember`
3. **Coordinator needs SupervisorJob** — one failed consume shouldn't kill all subscriptions
4. **@Volatile followedUsers insufficient** — use `MutableStateFlow<Set<HexKey>>` for thread safety + observability
5. **No error handling in consume→emit pipeline** — unhandled exception kills scope
### Key Improvements
- ViewModel lifecycle: `DisposableEffect` cleanup pattern for Desktop
- FeedNoteCard: `NoteInteractionSnapshot` collapses 3 flow observers into recomposition-safe snapshot
- Coordinator: `SupervisorJob` + `ConcurrentHashMap<String, Job>` for subscription tracking
- consume() dispatch: kind-based registry map (O(1)) replaces growing `when` block
---
## Overview
Migrate all desktop feed screens from inline `EventCollectionState` + direct relay subscriptions (System A) to the cache-centric `DesktopFeedViewModel` + `FeedFilter` + `FeedContentState` architecture (System B). Cache becomes the single source of truth. No screen ever reads relay callbacks directly.
```
WebSocket (OkHttp, persistent)
↓ push-based
DesktopRelaySubscriptionsCoordinator
↓ consume()
DesktopLocalCache (Note model with replies, reactions, zaps)
↓ eventStream (SharedFlow<Set<Note>>)
DesktopFeedViewModel (per-screen, with FeedFilter)
↓ FeedContentState (Loading/Loaded/Empty/Error)
UI (collects StateFlow, reads Note.flowSet for live counts)
```
(see brainstorm: `docs/brainstorms/2026-03-22-clean-cache-architecture-brainstorm.md`)
## Problem Statement
Current system (System A) causes:
- **State loss on navigation**`remember`-scoped `EventCollectionState` destroyed when sidebar tab changes
- **Duplicate relay subscriptions** — FeedScreen alone has 8 `rememberSubscription()` calls
- **Fragile count tracking** — zaps/reactions/replies tracked in per-screen `mutableStateOf` maps, lost on recompose
- **Slow tab switching** — 2-5s reload on Home→Profile→Home because data refetched from relays
- **No freshness indicator** — user can't tell if feed is current or stale
System B infrastructure already exists but is not wired to any screen:
| Component | Status | Location |
|-----------|--------|----------|
| `DesktopFeedViewModel` | Built | `desktopApp/.../viewmodels/` |
| 8 `FeedFilter` implementations | Built | `desktopApp/.../feeds/DesktopFeedFilters.kt` |
| `FeedContentState` | Built | `commons/.../ui/feeds/FeedContentState.kt` |
| `DesktopRelaySubscriptionsCoordinator.consumeEvent()` | Built | `desktopApp/.../subscriptions/` |
| `DesktopLocalCache` + `eventStream` | Built | `desktopApp/.../cache/` |
## Proposed Solution
Four-phase migration: FeedScreen first (proves pattern), then expand cache coverage and migrate remaining screens, consolidate subscriptions, add health UI.
### Key Design Decisions (from brainstorm)
1. **Coordinator owns all subscriptions** — each screen has at most ONE subscription request to Coordinator, never multiple `rememberSubscription()` calls
2. **Counts live on Note model**`note.replies.size`, `note.countReactions()`, `note.zapsAmount` (observed via `Note.flowSet`)
3. **Relay health indicator** — "last event received" per subscription, hidden when <30s, shown as duration when stale
4. **Always-alive core subscriptions** — contact list + home feed stay open even on Settings screen
5. **WebSocket push-based** — "pull-to-refresh" = close + reopen subscription with fresh `since` filter
6. **SearchScreen migrates** — NIP-50 results route through cache, AdvancedSearchBarState stays for query/history management, ViewModel reads cache for results
## Technical Approach
### Architecture
```
┌─────────────────────────────────────────────┐
│ Relays │
│ (persistent WebSocket via OkHttp) │
└──────────────────┬──────────────────────────┘
│ push events
┌──────────────▼──────────────┐
│ DesktopRelaySubscriptions │
│ Coordinator │
│ (SupervisorJob scope) │
│ │
│ Always-alive: │
│ - contact list (kind 3) │
│ - home feed (kind 1) │
│ - metadata (rate-limited) │
│ │
│ Screen-requested: │
│ - one sub per screen │
│ - interactions (7,9735,6) │
│ - reads (kind 30023) │
│ - search (NIP-50) │
└──────────────┬──────────────┘
│ consumeEvent() [try-catch per event]
┌──────────────▼──────────────┐
│ DesktopLocalCache │
│ (Single Source of Truth) │
│ │
│ notes: BoundedLargeCache │
│ users: BoundedLargeCache │
│ _followedUsers: StateFlow │
│ subscriptionHealth: Map │
│ │
│ consume(): kind registry │
│ eventStream: │
│ newEventBundles (250ms) │
│ deletedEventBundles │
└──────────────┬──────────────┘
│ SharedFlow<Set<Note>>
┌──────────────▼──────────────┐
│ DesktopFeedViewModel(s) │
│ + DisposableEffect cleanup │
│ │
│ One per screen: │
│ - HomeFeedVM (Following) │
│ - GlobalFeedVM │
│ - ProfileFeedVM(pubkey) │
│ - ThreadVM(noteId) │
│ - BookmarksVM(ids) │
│ - ReadsVM │
│ - NotificationsVM(pubkey) │
│ - SearchVM(query) │
│ │
│ init: refreshSuspended() │
│ collect: eventStream → │
│ FeedFilter.applyFilter() │
└──────────────┬──────────────┘
│ FeedState (Loaded/Loading/Empty/Error)
┌──────────────▼──────────────┐
│ UI Layer │
│ │
│ NoteInteractionSnapshot │
│ (recomposition-safe) │
│ │
│ DisposableEffect for: │
│ - ViewModel cleanup │
│ - Note.clearFlow() │
│ - Coordinator release │
└─────────────────────────────┘
```
### Implementation Phases
---
#### Phase 1: FeedScreen Migration (Foundation)
**Goal:** Prove the pattern on the most complex screen. Remove all inline state management.
##### 1a. Expand `DesktopLocalCache.consume()` coverage
Currently handles: MetadataEvent, TextNoteEvent, ReactionEvent, LnZapRequestEvent, LnZapEvent.
**Add support for:**
| Event Type | Kind | Method | Links |
|------------|------|--------|-------|
| `RepostEvent` | 6 | `consumeRepost()` | `Note.addBoost()` on target |
| `ContactListEvent` | 3 | `consumeContactList()` | Updates `_followedUsers` StateFlow |
| `LongTextNoteEvent` | 30023 | `consumeLongTextNote()` | Creates Note like TextNote |
| `BookmarkListEvent` | 30001 | `consumeBookmarkList()` | Stores on `addressableNotes` or dedicated field |
**File:** `desktopApp/.../cache/DesktopLocalCache.kt`
Each `consume*()` method follows existing pattern:
1. `getOrCreateNote(event.id)` — deduplicate
2. Check `note.event != null` → already seen, return false
3. `getOrCreateUser(event.pubKey)` — ensure author exists
4. `note.loadEvent(event, author, relatedNotes)` — populate
5. Link relationships (`addReply`, `addReaction`, `addZap`, `addBoost`)
6. Return true (new event)
**Research Insight: Use kind-based registry instead of growing `when` block:**
```kotlin
// O(1) dispatch, extensible without editing consume()
private val consumers = mutableMapOf<Int, (Event, NormalizedRelayUrl?) -> Boolean>()
init {
consumers[MetadataEvent.KIND] = { e, r -> consumeMetadata(e as MetadataEvent); true }
consumers[TextNoteEvent.KIND] = { e, r -> consumeTextNote(e as TextNoteEvent, r) }
consumers[ReactionEvent.KIND] = { e, r -> consumeReaction(e as ReactionEvent, r) }
consumers[RepostEvent.KIND] = { e, r -> consumeRepost(e as RepostEvent, r) }
consumers[ContactListEvent.KIND] = { e, r -> consumeContactList(e as ContactListEvent); true }
// ...
}
fun consume(event: Event, relay: NormalizedRelayUrl?): Boolean =
consumers[event.kind]?.invoke(event, relay) ?: false
```
**Research Insight: Replace `@Volatile followedUsers` with `MutableStateFlow`:**
```kotlin
// Thread-safe atomic updates + Compose-observable
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
fun updateFollowedUsers(users: Set<HexKey>) {
_followedUsers.value = users
}
```
`@Volatile` only guarantees reference visibility — read-modify-write races are still possible. `MutableStateFlow` gives atomic `value` swaps plus observability for free (`DesktopFollowingFeedFilter` can react to changes without polling).
##### 1b. Coordinator always-alive subscriptions
Move home feed + contact list subscriptions from FeedScreen into Coordinator.
**File:** `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt`
```kotlin
// New methods on Coordinator
fun subscribeToHomeFeed(relays: Set<NormalizedRelayUrl>, followedUsers: Set<String>)
fun subscribeToContactList(relays: Set<NormalizedRelayUrl>, pubKeyHex: String)
fun requestInteractions(noteIds: List<String>, relays: Set<NormalizedRelayUrl>): String
fun releaseInteractions(subId: String)
```
`requestInteractions()` replaces per-screen zap/reaction/reply/repost subscriptions. Subscribes to kinds 7, 9735, 6, and reply-kind-1 for given note IDs. Results route through `consumeEvent()` → cache → eventStream. Each screen makes at most ONE interaction request.
**Subscription health tracking:**
```kotlin
data class SubscriptionHealth(
val lastEventReceivedAt: Long?,
val eoseReceived: Boolean,
)
val subscriptionHealth: StateFlow<Map<String, SubscriptionHealth>>
```
Updated on every event and EOSE callback.
**Research Insight: Coordinator scope MUST use SupervisorJob:**
```kotlin
// In Main.kt where Coordinator is created:
val coordinatorScope = CoroutineScope(
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable ->
println("Coordinator error: ${throwable.message}")
}
)
```
Without `SupervisorJob`, one failed `consume()` call kills ALL subscription processing. Each subscription should be an independent child coroutine.
**Research Insight: Track subscription Jobs for proper cancellation:**
```kotlin
private val screenSubscriptions = ConcurrentHashMap<String, Job>()
fun requestInteractions(noteIds: List<String>, relays: Set<NormalizedRelayUrl>): String {
val subId = generateSubId("interactions")
val job = scope.launch {
// subscription logic
}
screenSubscriptions[subId] = job
return subId
}
fun releaseInteractions(subId: String) {
screenSubscriptions.remove(subId)?.cancel()
client.close(subId)
}
```
**Research Insight: Add try-catch in consumeEvent:**
```kotlin
fun consumeEvent(event: Event, relay: NormalizedRelayUrl?) {
scope.launch(Dispatchers.IO) {
try {
val consumed = localCache.consume(event, relay)
if (consumed) {
val note = localCache.getNoteIfExists(event.id) ?: return@launch
eventBundler.invalidateList(note) { batch ->
localCache.eventStream.emitNewNotes(batch)
}
}
} catch (e: Exception) {
// Don't rethrow — other events should continue processing
println("Failed to consume ${event.kind}: ${e.message}")
}
}
}
```
##### 1c. Rewrite FeedScreen to use DesktopFeedViewModel
**File:** `desktopApp/.../ui/FeedScreen.kt`
**Remove:**
- `EventCollectionState<Event>` and all `remember` state maps
- All 8 `rememberSubscription()` blocks
- `zapsByEvent`, `reactionIdsByEvent`, `replyIdsByEvent`, `repostIdsByEvent` maps
- `followedUsers` local state (read from `localCache.followedUsers`)
- `eoseReceivedCount` tracking
**Add:**
- `DesktopFeedViewModel` keyed on `feedMode` with `DisposableEffect` cleanup:
```kotlin
val viewModel = remember(feedMode) {
val filter = when (feedMode) {
FeedMode.GLOBAL -> DesktopGlobalFeedFilter(localCache)
FeedMode.FOLLOWING -> DesktopFollowingFeedFilter(localCache) {
localCache.followedUsers.value
}
}
DesktopFeedViewModel(filter, localCache)
}
// CRITICAL: Cancel old ViewModel's viewModelScope on recreation
DisposableEffect(viewModel) {
onDispose { viewModel.clear() }
}
val feedState by viewModel.feedState.feedContent.collectAsState()
```
- Render based on `FeedState`: `Loading`, `Loaded`, `Empty`, `FeedError`
- Relay health indicator from Coordinator
**Research Insight: ViewModel scope leak is the #1 risk.**
`remember(key)` creates a new ViewModel on key change but does NOT call `onCleared()`. On JVM Desktop, there is no `ViewModelStoreOwner`. The old ViewModel's `viewModelScope` coroutines keep running — leaked collectors on `eventStream`. `DisposableEffect(viewModel) { onDispose { viewModel.clear() } }` fixes this.
If `ViewModel.clear()` is not accessible in lifecycle 2.10.0 KMP, add a `fun destroy()` method on `DesktopFeedViewModel` that cancels a custom scope.
**Research Insight: Use `DisposableEffect` (not `LaunchedEffect`) for Coordinator subscriptions:**
```kotlin
// Interaction subscription with proper cleanup
DisposableEffect(loadedNoteIds) {
val subId = coordinator.requestInteractions(loadedNoteIds, relays)
onDispose { coordinator.releaseInteractions(subId) }
}
```
`LaunchedEffect` cancels its coroutine on dispose but doesn't call explicit cleanup. `DisposableEffect` guarantees the `onDispose` block runs.
##### 1d. Rewrite FeedNoteCard to accept Note
**File:** `desktopApp/.../ui/FeedScreen.kt` (FeedNoteCard composable)
**Before:**
```kotlin
fun FeedNoteCard(
event: Event,
zapReceipts: List<ZapReceipt>,
reactionCount: Int,
replyCount: Int,
repostCount: Int,
...
)
```
**After — using NoteInteractionSnapshot for recomposition safety:**
```kotlin
@Immutable
data class NoteInteractionSnapshot(
val reactionCount: Int,
val replyCount: Int,
val boostCount: Int,
val zapAmount: BigDecimal,
)
@Composable
fun rememberNoteInteractionState(note: Note): NoteInteractionSnapshot {
// Cache flowSet reference — note.flow() allocates if null (side effect)
val flowSet = remember(note) { note.flow() }
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
val repliesState by flowSet.replies.stateFlow.collectAsState()
val zapsState by flowSet.zaps.stateFlow.collectAsState()
// Clean up flowSet when note card leaves composition
DisposableEffect(note) {
onDispose { note.clearFlow() }
}
return remember(reactionsState, repliesState, zapsState) {
NoteInteractionSnapshot(
reactionCount = note.countReactions(),
replyCount = note.replies.size,
boostCount = note.boosts.size,
zapAmount = note.zapsAmount,
)
}
}
@Composable
fun FeedNoteCard(
note: Note,
localCache: DesktopLocalCache,
...
) {
val interactions = rememberNoteInteractionState(note)
NoteCard(
note = note.event!!.toNoteDisplayData(localCache),
...
)
NoteActionsRow(
reactionCount = interactions.reactionCount,
replyCount = interactions.replyCount,
repostCount = interactions.boostCount,
zapAmountSats = interactions.zapAmount.toLong(),
...
)
}
```
**Research Insights:**
- **`note.flow()` is a side effect** — it allocates `NoteFlowSet` if null. Wrap in `remember(note)` to avoid repeated allocation during recomposition.
- **`@Immutable NoteInteractionSnapshot`** prevents downstream recomposition when `invalidateData()` fires but counts haven't actually changed. `NoteActionsRow` only recomposes when snapshot content differs.
- **`DisposableEffect` for `clearFlow()`** — without cleanup, every Note that was ever visible retains its `NoteFlowSet` permanently. `clearFlow()` checks `isInUse()` and nullifies when subscriber count = 0.
- **3 StateFlow collectors per card is acceptable** — they're lightweight. With 50 visible cards = 150 collectors. The `NoteInteractionSnapshot` pattern prevents cascading recomposition.
##### 1e. Render FeedState in FeedScreen
```kotlin
when (val state = feedState) {
is FeedState.Loading -> LoadingState("Loading notes...")
is FeedState.Empty -> EmptyState(title = "...", onRefresh = { ... })
is FeedState.FeedError -> ErrorState(message = state.errorMessage)
is FeedState.Loaded -> {
val notes by state.feed.collectAsState()
LazyColumn {
items(notes.list, key = { it.idHex }) { note ->
FeedNoteCard(note = note, localCache = localCache, ...)
}
}
}
}
```
**Research Insight: `key = { it.idHex }` is critical.** Since `Note` is `@Stable`, Compose trusts referential equality. The same `Note` object across two list emissions = skipped recomposition for that item. Cache reuses `Note` instances, so this works correctly.
**Research Insight: First frame shows `Loading` briefly.** `refreshSuspended()` runs on `Dispatchers.IO`. The first compose frame sees `FeedState.Loading` until the IO dispatch completes. This is sub-100ms with warm cache but not truly instant. Acceptable for the UX.
##### Phase 1 Acceptance Criteria
- [ ] `DesktopLocalCache.consume()` handles RepostEvent, ContactListEvent, LongTextNoteEvent, BookmarkListEvent
- [ ] consume() uses kind-based registry dispatch (not growing `when` block)
- [ ] `followedUsers` is `MutableStateFlow` (not `@Volatile`)
- [ ] Coordinator scope uses `SupervisorJob` + `CoroutineExceptionHandler`
- [ ] Coordinator manages always-alive home feed + contact list subscriptions
- [ ] Coordinator exposes `requestInteractions()` / `releaseInteractions()` with Job tracking
- [ ] `consumeEvent()` has try-catch per event
- [ ] FeedScreen uses `DesktopFeedViewModel` — no `EventCollectionState`, no `rememberSubscription()`
- [ ] FeedScreen has `DisposableEffect(viewModel) { onDispose { viewModel.clear() } }`
- [ ] FeedScreen uses `DisposableEffect` for Coordinator subscription cleanup
- [ ] FeedNoteCard takes `Note`, uses `rememberNoteInteractionState()` + `NoteInteractionSnapshot`
- [ ] FeedNoteCard has `DisposableEffect` for `note.clearFlow()` cleanup
- [ ] Feed mode switch (Following ↔ Global) works via ViewModel recreation
- [ ] Navigation Home → Profile → Home shows cached data instantly (<100ms)
- [ ] `./gradlew spotlessApply :desktopApp:compileKotlin` passes
---
#### Phase 2: Migrate Remaining Feed Screens
Apply the proven pattern from Phase 1 to all other screens. Each screen gets at most ONE subscription request to Coordinator. Every screen uses `DisposableEffect` for ViewModel + subscription cleanup.
##### 2a. UserProfileScreen
**Dual concern:** Profile header (metadata, follow status) + notes feed.
- Notes feed → `DesktopFeedViewModel(DesktopProfileFeedFilter(cache, pubKeyHex), cache)`
- Profile header → reads from `localCache.users` for metadata, single Coordinator request for fresh metadata
- Tab switching (Notes/Replies/Gallery) → different FeedFilter per tab, keyed on `remember(tab)`
- **DisposableEffect cleanup** for ViewModel on tab switch and screen exit
**File:** `desktopApp/.../ui/UserProfileScreen.kt`
##### 2b. ThreadScreen
**DesktopThreadFilter** extends `FeedFilter` (not `AdditiveFeedFilter`) — does graph walk via `Note.replies`.
- On new event bundle → full `refreshSuspended()` (re-walks graph from cache). Acceptable because thread size is bounded and `BoundedLargeCache` iteration is lock-free.
- Thread level tracking: `DesktopThreadFilter.feed()` returns flattened list with depth info.
**File:** `desktopApp/.../ui/ThreadScreen.kt`
##### 2c. BookmarksScreen
- `DesktopBookmarkFeedFilter(cache) { bookmarkedIds() }` — lambda provides current bookmark IDs
- Bookmark change propagation: After publishing BookmarkListEvent, call `viewModel.feedState.checkKeysInvalidateDataAndSendToTop()` since `feedKey` hash changes with bookmark set.
**File:** `desktopApp/.../ui/BookmarksScreen.kt`
##### 2d. ReadsScreen
- `DesktopFeedViewModel(DesktopReadsFeedFilter(cache), cache)`
- **Card rendering:** ReadsScreen needs `LongFormNoteCard` that extracts title/summary/topics from `Note.event as? LongTextNoteEvent`. Separate composable, not FeedNoteCard.
**File:** `desktopApp/.../ui/ReadsScreen.kt`
##### 2e. NotificationsScreen
- `DesktopNotificationFeedFilter` returns `List<Note>`. NotificationsScreen transforms to typed `NotificationItem` in screen layer:
```kotlin
fun Note.toNotificationItem(): NotificationItem? = when (event) {
is ReactionEvent -> NotificationItem.Reaction(this)
is LnZapEvent -> NotificationItem.Zap(this)
is TextNoteEvent -> if (isReply) NotificationItem.Reply(this) else NotificationItem.Mention(this)
else -> null
}
```
**File:** `desktopApp/.../ui/NotificationsScreen.kt`
##### 2f. SearchScreen (Cache-Backed + AdvancedSearchBarState)
- **AdvancedSearchBarState stays** — handles query management, history, operator hints
- NIP-50 relay results route through `coordinator.consumeEvent()` → cache
- `DesktopFeedViewModel(DesktopSearchFeedFilter(cache, query), cache)` reads cached results
- AdvancedSearchBarState triggers relay subscription via Coordinator; ViewModel reads cache
- Search results persist across navigation (in cache)
**File:** `desktopApp/.../ui/SearchScreen.kt`
##### Phase 2 Acceptance Criteria
- [ ] All screens use DesktopFeedViewModel (including Search)
- [ ] Each screen has at most ONE subscription request to Coordinator
- [ ] Every screen has `DisposableEffect` cleanup for ViewModel + Coordinator subscription
- [ ] UserProfileScreen: separate header state from feed ViewModel
- [ ] BookmarksScreen: bookmark changes propagate to ViewModel
- [ ] ReadsScreen: uses LongFormNoteCard for kind 30023
- [ ] NotificationsScreen: Note → NotificationItem transformation in screen layer
- [ ] SearchScreen: NIP-50 results go through cache, AdvancedSearchBarState retained
- [ ] All screens show cached data instantly on navigation
---
#### Phase 3: Coordinator Subscription Consolidation
Ensure clean subscription lifecycle. No screen calls `rememberSubscription()`.
##### 3a. Always-alive subscriptions
| Subscription | Kind(s) | Trigger | Lifecycle |
|--------------|---------|---------|-----------|
| Contact list | 3 | App start | Permanent |
| Home feed (following) | 1 | App start | Permanent |
| Metadata (rate-limited) | 0 | On-demand via `loadMetadataForPubkeys()` | Permanent (rate limiter) |
##### 3b. Screen-triggered subscriptions (one per screen)
| Subscription | Kind(s) | Trigger | Lifecycle |
|--------------|---------|---------|-----------|
| Global feed | 1 | Global mode active | Active while mode shown |
| Profile feed + interactions | 1, 7, 9735, 6 | ProfileScreen visible | Active while screen visible |
| Thread + interactions | 1, 7, 9735, 6 | ThreadScreen visible | Active while screen visible |
| Reads | 30023 | ReadsScreen visible | Active while screen visible |
| Notifications | 1, 7, 9735 | NotificationsScreen visible | Active while screen visible |
| Search (NIP-50) | varies | Search query submitted | Active while search active |
| Home interactions | 7, 9735, 6 | FeedScreen visible | Active while screen visible |
Each screen requests ONE consolidated subscription from Coordinator that covers all event types it needs.
##### 3c. Subscription health tracking
```kotlin
// On Coordinator
private val _subscriptionHealth = MutableStateFlow<Map<String, SubscriptionHealth>>(emptyMap())
val subscriptionHealth: StateFlow<Map<String, SubscriptionHealth>>
// Updated in subscription callbacks
onEvent = { event, _, relay, _ ->
consumeEvent(event, relay)
updateHealth(subId, lastEventReceivedAt = System.currentTimeMillis())
}
onEose = { _, _ ->
updateHealth(subId, eoseReceived = true)
}
```
##### Phase 3 Acceptance Criteria
- [ ] No screen calls `rememberSubscription()` directly
- [ ] Each screen has exactly ONE subscription request to Coordinator
- [ ] Coordinator manages all subscription lifecycles via `ConcurrentHashMap<String, Job>`
- [ ] `subscriptionHealth` StateFlow available for UI consumption
---
#### Phase 4: Relay Health UI
##### 4a. Health indicator component
```kotlin
@Composable
fun RelayHealthIndicator(
lastEventReceivedAt: Long?,
modifier: Modifier = Modifier,
) {
val elapsed = /* current time - lastEventReceivedAt */
if (elapsed > 30_000) { // only show if > 30s
Text(
text = formatElapsed(elapsed), // "45s ago", "3m ago"
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
```
**Display rules:**
- `< 30s` → hidden (healthy)
- `30s - 60s` → show seconds
- `> 1 min` → show minutes — indicates relay slowness or offline
**Research Insight: Place health indicator in NavigationRail** (next to existing `BunkerHeartbeatIndicator` at `SinglePaneLayout.kt:148`). This is the established pattern. Also add per-feed indicator in each screen's header.
##### 4b. Pull-to-refresh
Desktop doesn't have swipe gestures. Options:
- Refresh button in feed header (already exists)
- Keyboard shortcut (Cmd+R / Ctrl+R) via `MenuBar`
- Click on health indicator to force refresh
Refresh action: Coordinator closes + reopens relevant subscription with `since` filter.
**Research Insight: Wire Cmd+R from MenuBar to active screen** via `onRefresh` callback hoisted through `SinglePaneLayout``RootContent` → active screen (same pattern as existing `onShowComposeDialog`).
##### Phase 4 Acceptance Criteria
- [ ] Relay health indicator shown when last event > 30s ago
- [ ] Health indicator in NavigationRail (global) + per-feed header (screen-specific)
- [ ] Refresh button triggers subscription reopen
- [ ] Cmd+R / Ctrl+R keyboard shortcut works
- [ ] Health indicator clears on new event received
## System-Wide Impact
### Interaction Graph
```
User navigates to screen
→ Screen creates DesktopFeedViewModel(filter, cache)
→ DisposableEffect registers cleanup (viewModel.clear(), coordinator.release())
→ ViewModel.init: refreshSuspended() queries cache via filter
→ ViewModel.init: collects cache.eventStream.newEventBundles
→ FeedContentState.updateFeedWith(newNotes)
→ AdditiveFeedFilter.applyFilter() + sort()
→ FeedState.Loaded emits ImmutableList<Note>
Relay event arrives (WebSocket push)
→ Coordinator.consumeEvent(event, relay) [try-catch]
→ DesktopLocalCache.consume(event, relay) [kind registry dispatch]
→ Note.loadEvent() + Note.addReply/addReaction/addZap
→ Note.flowSet?.reactions?.invalidateData()
→ eventBundler.invalidateList(note)
→ (250ms batch)
→ localCache.eventStream.emitNewNotes(batch)
→ All active FeedViewModels receive bundle
→ Each filter decides if relevant
→ UI updates via NoteInteractionSnapshot
User leaves screen
→ DisposableEffect.onDispose fires
→ viewModel.clear() cancels viewModelScope
→ coordinator.releaseInteractions(subId) closes subscription
→ note.clearFlow() releases NoteFlowSet
```
### Error Propagation
- **WebSocket disconnect:** Coordinator detects → health indicator shows elapsed → reconnect with `since` → events resume → health clears
- **Cache consume fails:** try-catch in `consumeEvent()` logs and continues. SupervisorJob ensures other subscriptions are unaffected.
- **Filter throws:** `FeedContentState` catches → `FeedState.FeedError` → UI shows error state
- **ViewModel scope leak:** Prevented by `DisposableEffect(viewModel) { onDispose { viewModel.clear() } }`
### State Lifecycle Risks
- **Note eviction:** If cache evicts a Note in a ViewModel's feed, `ImmutableList<Note>` still holds a reference. No crash, but Note won't receive flowSet updates. Acceptable at 50k limit.
- **Note.flowSet leak:** Prevented by `DisposableEffect` calling `note.clearFlow()` when FeedNoteCard leaves composition.
- **ViewModel recreation on mode switch:** Old scope cancelled via `DisposableEffect`, new one created. 250ms gap caught on next `refreshSuspended()`.
- **Note mutation thread safety:** `Note.replies`, `Note.reactions` etc. use immutable list swap (reference assignment is atomic on JVM). Last-write-wins race is possible under contention but self-corrects on next event. This matches Android's pattern.
## Acceptance Criteria
### Functional
- [ ] All feed screens render from cache via DesktopFeedViewModel
- [ ] Navigation between screens shows cached data instantly (<100ms)
- [ ] Zap/reaction/reply/repost counts update live via NoteInteractionSnapshot
- [ ] Feed mode switch (Following ↔ Global) works without reload delay
- [ ] New events from relays appear in feeds within 250ms (batch delay)
- [ ] Contact list always cached, Following feed hydrates instantly
- [ ] Search results persist across navigation (cached)
### Non-Functional
- [ ] No `EventCollectionState` usage in any feed screen
- [ ] No `rememberSubscription()` in any feed screen
- [ ] Each screen has at most ONE subscription request to Coordinator
- [ ] Every screen has `DisposableEffect` cleanup for ViewModel + subscription + flowSet
- [ ] Coordinator uses `SupervisorJob` — one failed event doesn't kill all processing
- [ ] `consumeEvent()` has try-catch — no unhandled exceptions
- [ ] `./gradlew spotlessApply :desktopApp:compileKotlin` passes after each phase
## Dependencies & Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| `ViewModel.clear()` not accessible in lifecycle KMP | Medium | High | Add custom `destroy()` method with own `CoroutineScope` |
| Note.flowSet observation doesn't trigger recomposition | Low | High | Prototype `rememberNoteInteractionState()` early in Phase 1d |
| Missing consume() methods cause blank feeds | Low | High | Add all in Phase 1a before screen migration |
| Always-alive subs overwhelm relays | Low | Medium | Start with contact list + home feed only |
| SearchScreen cache filter semantics differ from NIP-50 | Low | Medium | Search filter only matches cached results; NIP-50 provides the actual search |
| BoundedLargeCache eviction is pseudo-random (by hex key order) | Low | Low | At 50k limit, probability of evicting active feed Note is negligible |
## Success Metrics
- Back navigation: 2-5s → <100ms
- Metadata refetch on navigation: always → never (cached)
- Zap/reaction counts on back nav: lost → preserved
- Subscriptions per screen: 4-8 → 1
- Coroutine scope leaks: possible → prevented by DisposableEffect
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-03-22-clean-cache-architecture-brainstorm.md](docs/brainstorms/2026-03-22-clean-cache-architecture-brainstorm.md) — Key decisions: cache as single source of truth, Coordinator owns subscriptions (always alive), relay health indicator, counts on Note model, SearchScreen migrates with AdvancedSearchBarState retained
### Internal References
- Prior cache plan: `docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md`
- Relay subscription stability: `docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md`
- Cache architecture rationale: `docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md`
- FeedContentState: `commons/.../ui/feeds/FeedContentState.kt`
- FeedViewModel: `commons/.../viewmodels/FeedViewModel.kt`
- DesktopFeedFilters: `desktopApp/.../feeds/DesktopFeedFilters.kt`
- DesktopFeedViewModel: `desktopApp/.../viewmodels/DesktopFeedViewModel.kt`
- Note model: `commons/.../model/Note.kt`
- DesktopLocalCache: `desktopApp/.../cache/DesktopLocalCache.kt`
- Coordinator: `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt`
- SinglePaneLayout: `desktopApp/.../ui/deck/SinglePaneLayout.kt` (NavigationRail, BunkerHeartbeatIndicator pattern)
### Research Insights Applied
- **kotlin-coroutines:** SupervisorJob for Coordinator, DisposableEffect for ViewModel lifecycle, try-catch in consume pipeline, Job tracking for screen subscriptions
- **compose-expert:** NoteInteractionSnapshot pattern, `remember(note) { note.flow() }`, DisposableEffect for clearFlow(), key-based LazyColumn skip optimization
- **desktop-expert:** ViewModel.clear() accessibility concern, MenuBar keyboard shortcut wiring, NavigationRail health indicator placement, overlay subscription cleanup
- **kotlin-expert:** Kind-based registry for consume(), MutableStateFlow for followedUsers, Note mutation thread safety analysis, BoundedLargeCache eviction characteristics
@@ -0,0 +1,523 @@
---
title: "feat: WeakReference Cache + State Class Extraction"
type: feat
status: active
date: 2026-03-24
origin: docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md
---
# feat: WeakReference Cache + State Class Extraction
## Enhancement Summary
**Deepened on:** 2026-03-24
**Agents used:** kotlin-expert, kotlin-multiplatform, kotlin-coroutines, gradle-expert, architecture-strategist, performance-oracle, code-simplicity-reviewer, pattern-recognition-specialist
### Key Improvements from Deepening
1. **Source set correction:** `commons/jvmAndroid` not `jvmMain` — both KMP + Gradle agents confirmed
2. **Massive simplification:** Extract 5 State classes now (not 22) — only what Desktop needs today
3. **Per-feature repositories** instead of single `IAccountSettings` — matches existing commons pattern (`EphemeralChatRepository`, `PublicChatListRepository`)
4. **Fix ICacheProvider `Any?` returns directly**`User`/`Note` already in commons, no new methods needed
5. **DecryptionCaches are pure Kotlin** — move as-is, no interfaces needed
6. **Performance fixes:** `AtomicInteger` size tracker, single-pass `cleanUp()`, debounced `findUsersStartingWith`
7. **Heap monitoring:** `MemoryMXBean` check every 30s, cleanup at >75% or every 5min
8. **2 phases not 4** — cache swap + note pinning
### Critical Issues Discovered
1. **Static `LocalCache.justConsumeMyOwnEvent()` calls** in State classes bypass injected cache param — must fix during extraction
2. **`findUsersStartingWith` becomes O(n) per keystroke** with no cap — needs debouncing
3. **`size()` is O(n)** on ConcurrentSkipListMap — add AtomicInteger tracker
4. **Note.replies unbounded growth** for pinned notes — pruning is NOT optional for long-running Desktop sessions
---
## Overview
Replace `BoundedLargeCache` (strong refs, 50k cap, arbitrary eviction) with Android's `LargeSoftCache` (WeakReference-based, GC-driven). Extract 5 critical note-pinning State classes from `amethyst/` to `commons/` so Desktop gets the same retention logic. All in existing PR #1905 on branch `feat/desktop-cache-v2`.
(see brainstorm: `docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md`)
## Problem Statement
Vitor's feedback on PR #1905: `BoundedLargeCache` causes notes to "just disappear" because eviction is by lowest hex key order — ignoring whether notes are displayed, replied-to, or part of a thread. Android solved this with `LargeSoftCache<K, WeakReference<V>>` where GC respects the reference graph.
Desktop's `DesktopIAccount` has **zero State objects** — it stubs everything. The State classes that pin important notes (bookmarks, follow list, mute list, relay lists, metadata) are Android-only.
## Proposed Solution
Two-phase approach (simplified from original 4 phases):
1. **Phase 1: Cache swap** — Move `LargeSoftCache` to `commons/jvmAndroid`, fix `ICacheProvider` types, replace `BoundedLargeCache`, add cleanup loop
2. **Phase 2: Pin critical notes** — Extract 5 State classes to `commons/commonMain`, wire in `DesktopIAccount`
Remaining 17 State classes extracted incrementally as Desktop builds features that need them.
## Technical Approach
### Architecture
```
┌──────────────────────────────────────────────────────┐
│ LargeSoftCache<K, WeakRef<V>> │
│ (commons/jvmAndroid/ — shared) │
│ │
│ notes: LargeSoftCache<HexKey, Note> │
│ users: LargeSoftCache<HexKey, User> │
│ addressables: LargeSoftCache<String, AddressableNote>│
└───────────────────────┬──────────────────────────────┘
│ WeakRef (GC-managed)
┌───────────────────▼───────────────────┐
│ Note Reference Graph │
│ (strong refs: replies, reactions, │
│ zaps, boosts, replyTo, author) │
│ │
│ Clusters survive together. │
│ GC collects entire cluster when │
│ no external strong ref remains. │
└───────────────────┬───────────────────┘
│ strong refs (pinning)
┌───────────────────▼───────────────────┐
│ State Classes (commons/) │
│ (hold strong refs to important notes) │
│ │
│ BookmarkListState → bookmarkList note │
│ Kind3FollowListState → contact list │
│ MuteListState → mute list note │
│ Nip65RelayListState → relay list │
│ UserMetadataState → metadata note │
│ │
│ Live on Account (Android) or │
│ DesktopIAccount (Desktop) — both │
│ implement IAccount │
└───────────────────────────────────────┘
```
### Implementation Phases
---
#### Phase 1: Cache Swap (~1 day)
**Goal:** Replace BoundedLargeCache with LargeSoftCache, fix ICacheProvider types, add cleanup.
##### 1a. Fix ICacheProvider return types (prerequisite)
**File:** `commons/src/commonMain/kotlin/.../model/cache/ICacheProvider.kt`
`User`, `Note`, `AddressableNote` all live in `commons/commonMain/` already. The `Any?` returns are a legacy artifact. Change directly:
```kotlin
interface ICacheProvider {
// Change these from Any? to proper types
fun getUserIfExists(pubkey: HexKey): User? // was Any?
fun getNoteIfExists(hexKey: HexKey): Note? // was Any?
fun getOrCreateUser(pubkey: HexKey): User? // was Any?
fun findUsersStartingWith(prefix: String, limit: Int = 50): List<User> // was List<Any>
// Already typed correctly — no change
fun checkGetOrCreateNote(hexKey: HexKey): Note?
fun getOrCreateAddressableNote(key: Address): AddressableNote
fun justConsumeMyOwnEvent(event: Event): Boolean
// ...
}
```
Callers that currently cast `as? User` just drop the cast. No breakage.
**Research Insight (kotlin-expert):** `User`/`Note` are already in commons — the `Any?` returns were from an earlier extraction. Safe to fix now.
##### 1b. Move LargeSoftCache to commons/jvmAndroid
**From:** `amethyst/src/main/java/.../model/LargeSoftCache.kt`
**To:** `commons/src/jvmAndroid/kotlin/.../model/cache/LargeSoftCache.kt`
**Research Insight (KMP + Gradle agents):** Must be `jvmAndroid`, NOT `jvmMain`. The `jvmAndroid` source set in commons already exists (line 85-90 of `commons/build.gradle.kts`). Both `jvmMain` and `androidMain` depend on it. Zero Gradle changes needed. `CacheOperations` from `quartz/jvmAndroid` is visible transitively.
**Changes:**
- Move file, update package to `com.vitorpamplona.amethyst.commons.model.cache`
- Update Android's `LocalCache.kt` import (only file that imports it)
**Performance improvements to LargeSoftCache:**
```kotlin
class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
private val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
// NEW: O(1) approximate size tracking (performance-oracle recommendation)
private val _size = AtomicInteger(0)
fun approximateSize(): Int = _size.get()
fun put(key: K, value: V) {
val prev = cache.put(key, WeakReference(value))
if (prev == null) _size.incrementAndGet()
}
// IMPROVED: Single-pass cleanUp (performance-oracle: ~40% faster, zero alloc)
fun cleanUp() {
val iter = cache.entries.iterator()
while (iter.hasNext()) {
val entry = iter.next()
if (entry.value.get() == null) {
iter.remove()
_size.decrementAndGet()
}
}
}
// ... rest unchanged
}
```
##### 1c. Replace BoundedLargeCache on Desktop
**File:** `desktopApp/.../cache/DesktopLocalCache.kt`
```kotlin
// Before
val users = BoundedLargeCache<HexKey, User>(MAX_USERS)
val notes = BoundedLargeCache<HexKey, Note>(MAX_NOTES)
val addressableNotes = BoundedLargeCache<String, AddressableNote>(MAX_ADDRESSABLE)
// After
val users = LargeSoftCache<HexKey, User>()
val notes = LargeSoftCache<HexKey, Note>()
val addressableNotes = LargeSoftCache<String, AddressableNote>()
```
**Remove:** `BoundedLargeCache.kt`, `MAX_NOTES`/`MAX_USERS`/`MAX_ADDRESSABLE` constants.
**API differences:**
- `BoundedLargeCache.values()` → use `forEach` + collect
- `BoundedLargeCache.count(predicate)` → available via `CacheOperations`
- `userCount()`/`noteCount()` → use `approximateSize()` (O(1) vs O(n))
**Performance fix for `findUsersStartingWith`:**
```kotlin
// Before: O(n) per keystroke with no cap
// After: debounce at call site + yield during iteration
override fun findUsersStartingWith(prefix: String, limit: Int): List<User> {
val results = mutableListOf<User>()
users.forEach { _, user ->
if (results.size >= limit) return@forEach
if (user.anyNameStartsWith(prefix)) results.add(user)
}
return results
}
```
Call sites must debounce to 300ms. Acceptable because search already uses debounced state.
##### 1d. Add Desktop cleanup loop
**Research Insight (kotlin-coroutines):** Inline in coordinator, no abstraction needed. `Dispatchers.Default` for CPU-bound work. `Mutex.tryLock()` if heap trigger added. `yield()` every 1000 items.
```kotlin
// In Coordinator or Main.kt — inline, no CacheCleanupService needed
private val cleanupScope = CoroutineScope(
SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, t ->
println("Cleanup failed, will retry: ${t.message}")
}
)
private val memoryBean = ManagementFactory.getMemoryMXBean()
private var lastCleanupTime = 0L
val cleanupJob = cleanupScope.launch {
delay(2.minutes) // Grace period for startup
while (isActive) {
delay(30.seconds) // Check frequently, clean infrequently
val heapPct = memoryBean.heapMemoryUsage.let { it.used.toDouble() / it.max }
val elapsed = System.currentTimeMillis() - lastCleanupTime
if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) {
val ops = listOf(
"cleanMemory" to { localCache.cleanMemory() },
"cleanObservers" to { cleanObserversWithYield() },
"pruneExpired" to { localCache.pruneExpiredEvents() },
"pruneReplaceables" to { localCache.prunePastVersionsOfReplaceables() },
)
ops.forEach { (name, op) ->
try { op() } catch (e: Exception) {
println("Cleanup $name failed: ${e.message}")
}
}
lastCleanupTime = System.currentTimeMillis()
}
}
}
private suspend fun cleanObserversWithYield() {
var count = 0
localCache.notes.forEach { _, note ->
note.clearFlow()
if (++count % 1000 == 0) yield()
}
}
```
##### Phase 1 Acceptance Criteria
- [ ] `ICacheProvider` methods return typed `User?`/`Note?` (not `Any?`)
- [ ] `LargeSoftCache` in `commons/src/jvmAndroid/kotlin/.../model/cache/`
- [ ] `LargeSoftCache` has `AtomicInteger` size tracker + single-pass `cleanUp()`
- [ ] Android's `LocalCache` imports from commons (one import change)
- [ ] Desktop's `DesktopLocalCache` uses `LargeSoftCache`
- [ ] `BoundedLargeCache.kt` deleted
- [ ] Desktop cleanup loop: periodic + heap-pressure triggered
- [ ] `findUsersStartingWith` debounced at call sites
- [ ] `./gradlew :commons:compileKotlinJvm :amethyst:compileDebugKotlin :desktopApp:compileKotlin` passes
---
#### Phase 2: Pin Critical Notes (~1-2 days)
**Goal:** Extract 5 essential State classes to commons so Desktop pins important notes.
##### Why only 5 (not 22)?
**Research Insight (code-simplicity-reviewer):** Desktop currently has zero State classes and zero UI for most of the 22 features. Only 5 State classes serve features Desktop has today:
| State Class | Why Desktop needs it now |
|-------------|------------------------|
| BookmarkListState | Bookmarks screen exists |
| Kind3FollowListState | Follow list drives home feed |
| Nip65RelayListState | Relay list drives subscriptions |
| MuteListState | Content filtering |
| UserMetadataState | Profile display |
The other 17 (GeohashList, ProxyRelay, IndexerRelay, etc.) pin notes for features Desktop hasn't built yet. Extract them when needed.
##### 2a. Per-feature repository interfaces (not IAccountSettings)
**Research Insight (pattern-recognition-specialist):** The existing commons State classes use **per-feature repository interfaces**, not a shared settings interface:
- `EphemeralChatRepository` — 2 methods: `ephemeralChatList()`, `updateEphemeralChatListTo()`
- `PublicChatListRepository` — 2 methods: `publicChatList()`, `updatePublicChatListTo()`
Follow the same pattern. Each State class gets a narrow repository interface:
```kotlin
// commons/commonMain — one per State class that uses settings
interface Kind3FollowListRepository {
val backupContactList: ContactListEvent?
fun updateContactListTo(event: ContactListEvent)
}
interface MuteListRepository {
val backupMuteList: MuteListEvent?
fun updateMuteList(event: MuteListEvent)
}
interface Nip65RelayListRepository {
val backupNIP65RelayList: AdvertisedRelayListEvent?
fun updateNIP65RelayList(event: AdvertisedRelayListEvent)
}
// UserMetadataState — audit for specific settings usage
```
Android's `AccountSettings` implements all 5 interfaces. Desktop implements with stubs initially (no persistence yet), returning null for backup events.
**Naming convention (pattern-recognition):** `I` prefix for broad abstractions (`IAccount`, `ICacheProvider`). No prefix for narrow feature-scoped interfaces (`Kind3FollowListRepository`, `MuteListRepository`).
##### 2b. Move DecryptionCaches as-is
**Research Insight (kotlin-expert):** DecryptionCaches are pure Kotlin wrappers around `PrivateTagArrayEventCache<T>` from `quartz/commonMain`. No interfaces needed, no Android deps:
```kotlin
// Already works in commons/commonMain — no changes needed
class MuteListDecryptionCache(val signer: NostrSigner) {
val cachedPrivateLists = PrivateTagArrayEventCache<MuteListEvent>(signer)
}
```
Only `MuteListState` uses a DecryptionCache among the initial 5 State classes. Move `MuteListDecryptionCache` alongside it.
##### 2c. Fix static LocalCache calls
**Research Insight (pattern-recognition + architecture):** Several State classes call `LocalCache.justConsumeMyOwnEvent(event)` as a **static singleton call**, not through the injected `cache` parameter. Found in:
- `Kind3FollowListState` (line 168)
- `MuteListState` (line 147)
- `SearchRelayListState` (line 108)
During extraction, change to `cache.justConsumeMyOwnEvent(event)` — the method already exists on `ICacheProvider`.
##### 2d. Extraction order
1. **BookmarkListState** — no settings, no decryptionCache, simplest. Verify imports: change `amethyst.model.Note``amethyst.commons.model.Note`
2. **Nip65RelayListState** — needs `Nip65RelayListRepository` interface
3. **Kind3FollowListState** — needs `Kind3FollowListRepository`, fix static `LocalCache` call
4. **MuteListState** — needs `MuteListRepository` + move `MuteListDecryptionCache`, fix static call
5. **UserMetadataState** — audit settings usage, create repository interface
##### 2e. Wire State classes on DesktopIAccount
```kotlin
class DesktopIAccount(
private val accountState: AccountState.LoggedIn,
val localCache: DesktopLocalCache,
// ...
) : IAccount {
// Stub repositories — no persistence yet, return null for backups
private val kind3Repo = object : Kind3FollowListRepository {
override val backupContactList: ContactListEvent? = null
override fun updateContactListTo(event: ContactListEvent) { /* no-op for now */ }
}
// ... similar stubs for other repos
val bookmarkList = BookmarkListState(signer, localCache, scope)
val kind3FollowList = Kind3FollowListState(signer, localCache, scope, kind3Repo)
val nip65RelayList = Nip65RelayListState(signer, localCache, scope, nip65Repo)
val muteList = MuteListState(signer, localCache, MuteListDecryptionCache(signer), scope, muteRepo)
val userMetadata = UserMetadataState(signer, localCache, scope, metadataRepo)
}
```
##### Phase 2 Acceptance Criteria
- [ ] 5 State classes in `commons/commonMain/`
- [ ] Per-feature repository interfaces (not single IAccountSettings)
- [ ] `MuteListDecryptionCache` moved alongside `MuteListState`
- [ ] All static `LocalCache.` calls changed to `cache.` (injected)
- [ ] Android's `Account` imports State classes from commons (no behavior change)
- [ ] Desktop's `DesktopIAccount` creates 5 State objects with stub repos
- [ ] Pinned AddressableNotes survive GC on Desktop
- [ ] `@Stable`/`@Immutable` annotations compile on all targets
- [ ] `./gradlew spotlessApply` + all modules compile
---
## System-Wide Impact
### Interaction Graph
```
Event arrives from relay
→ Coordinator.consumeEvent()
→ DesktopLocalCache.consume()
→ notes.getOrCreate(id) [LargeSoftCache — creates WeakRef]
→ Note populated with event, author, relationships
→ Note.addReply/addReaction/addZap [strong refs to related notes]
→ eventStream.emit()
→ FeedViewModels receive update
State class created on DesktopIAccount
→ State.init { cache.getOrCreateAddressableNote(address) }
→ AddressableNote pinned via strong ref on State object
→ Note's reference graph (replies, reactions) also pinned transitively
→ Cluster survives GC
Periodic cleanup (every 5 min or heap > 75%)
→ LargeSoftCache.cleanUp() sweeps stale WeakRef entries (single-pass)
→ cleanObservers() clears unused NoteFlowSets (yield every 1000)
→ pruneExpiredEvents() removes NIP-40 expired notes
→ prunePastVersionsOfReplaceables() keeps only latest replaceable
```
### Error Propagation
- **getOrCreate race:** Two threads create same Note → `putIfAbsent` ensures one wins. Note constructor is pure (just stores `idHex`), no side effects. Acceptable.
- **GC during render:** Compose holds strong ref during composition. Cache eviction can't crash.
- **State class missing:** If a State class isn't created, that note type has no pin → GC can collect. Mitigated by creating all 5 critical ones at init.
### State Lifecycle Risks
- **Note.replies unbounded growth:** Pinned notes keep all replies alive via strong refs. For long-running Desktop sessions, this grows monotonically. `pruneRepliesAndReactions` is account-dependent — tracked as follow-up work (not deferred indefinitely).
- **NoteFlowSet leak:** `cleanObservers()` with `yield()` handles this.
- **`SharingStarted.Eagerly` on State classes:** All 5 State classes use `stateIn(scope, SharingStarted.Eagerly)`. Desktop has no process lifecycle to bound this. Consider `WhileSubscribed(5000)` for non-critical states when expanding beyond 5.
- **Replaceable event swap:** AddressableNote stays pinned, `.event` swaps. FlowSet emits. No orphaned refs.
### Performance Characteristics
| Operation | Before (BoundedLargeCache) | After (LargeSoftCache) |
|-----------|---------------------------|----------------------|
| `size()` | O(1) via AtomicInteger | O(1) via AtomicInteger (new) |
| `get(key)` | O(log n), strong ref | O(log n), WeakRef deref |
| `forEach` | O(n), no GC checks | O(n), GC check per entry |
| `cleanUp()` | N/A (evict by key order) | O(n) single-pass iterator |
| `findUsersStartingWith` | O(n) capped at 25k | O(n) uncapped — **debounce required** |
| Eviction | Arbitrary (by hex key) | GC-driven (respects refs) |
| Memory cap | Hard (50k/25k/10k) | None (GC-managed) |
## Acceptance Criteria
### Functional
- [ ] Desktop cache uses WeakReferences — no hard size limits
- [ ] Notes only disappear when nothing references them (not arbitrarily)
- [ ] Bookmarks, contact list, mute list, relay lists, metadata pinned on Desktop
- [ ] Navigation persistence maintained (existing behavior)
- [ ] Android behavior unchanged (same code, new import path)
### Non-Functional
- [ ] `BoundedLargeCache` deleted
- [ ] 5 critical State classes in commons
- [ ] Periodic + heap-triggered cleanup on Desktop
- [ ] `LargeSoftCache` shared from `commons/jvmAndroid/`
- [ ] `ICacheProvider` returns typed `User?`/`Note?`
- [ ] `./gradlew spotlessApply` + compile on all modules
## Dependencies & Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Static `LocalCache.` calls in State classes | High | High | Audit each class. Found in Kind3FollowList, MuteList, SearchRelay. Change to `cache.` |
| `findUsersStartingWith` O(n) jank | High | Medium | Debounce 300ms at call sites. Already debounced in search UI. |
| `size()` O(n) in logging paths | Medium | Low | `AtomicInteger` tracker on LargeSoftCache. Use `approximateSize()`. |
| Note.replies unbounded growth | Medium | Medium | Follow-up: implement non-account-dependent reply cap (e.g., 500 per note) |
| Import path changes break Android | Low | Medium | Only `LocalCache.kt` changes import. One-line diff. |
| BookmarkListState imports `amethyst.model.Note` (wrong package) | Low | Low | Change to `amethyst.commons.model.Note` during extraction |
## Future Work (tracked, not deferred indefinitely)
1. **Extract remaining 17 State classes** — as Desktop builds features that need them
2. **`pruneRepliesAndReactions`** — non-account-dependent reply/reaction cap (e.g., 500 per note) to bound memory for popular threads
3. **Desktop persistence** — implement real `Kind3FollowListRepository` etc. with disk-backed storage
4. **`SharingStarted.WhileSubscribed`** — for non-critical State classes when expanding beyond 5
## Success Metrics
- BoundedLargeCache eviction bugs → eliminated (WeakRef model)
- Desktop note retention → matches Android behavior for 5 critical types
- Android behavior → unchanged (regression-free)
- Desktop memory stability → heap monitoring confirms GC collects unpinned notes
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md](docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md) — Key decisions: LargeSoftCache shared, extract State classes, same pinning as Android, extensible for Desktop-specific pins later, one PR in existing #1905
### Internal References
- Prior clean-cache plan: `docs/plans/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md`
- Cache architecture brainstorm: `docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md`
- Android LargeSoftCache: `amethyst/src/main/java/.../model/LargeSoftCache.kt`
- Android LocalCache: `amethyst/src/main/java/.../model/LocalCache.kt`
- Android Account (State objects): `amethyst/src/main/java/.../model/Account.kt`
- Android MemoryTrimmingService: `amethyst/src/main/java/.../service/eventCache/MemoryTrimmingService.kt`
- Desktop DesktopLocalCache: `desktopApp/.../cache/DesktopLocalCache.kt`
- Desktop BoundedLargeCache: `desktopApp/.../cache/BoundedLargeCache.kt`
- Desktop DesktopIAccount: `desktopApp/.../model/DesktopIAccount.kt`
- ICacheProvider: `commons/.../model/cache/ICacheProvider.kt`
- CacheOperations: `quartz/src/jvmAndroid/.../utils/cache/CacheOperations.kt`
- Note model: `commons/.../model/Note.kt`
- Existing pattern: `EphemeralChatRepository` / `PublicChatListRepository` in commons
### Research Insights Applied
- **kotlin-expert:** DecryptionCaches are pure Kotlin (move as-is), fix `Any?` returns directly, normalize static LocalCache calls, only 3 files use `@Stable`/`@Immutable`
- **kotlin-multiplatform:** `commons/jvmAndroid` (not `jvmMain`), zero Gradle changes, `CacheOperations` visible transitively
- **kotlin-coroutines:** `SupervisorJob` + `Dispatchers.Default`, `Mutex.tryLock()` for heap trigger, `yield()` every 1000 items, 2-min startup grace period
- **gradle-expert:** Zero build.gradle.kts changes needed, no circular deps introduced
- **architecture-strategist:** Fix ICacheProvider types as Phase 0 prerequisite, `pruneRepliesAndReactions` is NOT optional for Desktop, consider `SharingStarted.WhileSubscribed` for non-critical states
- **performance-oracle:** `AtomicInteger` size tracker, single-pass `cleanUp()`, `findUsersStartingWith` O(n) jank needs debouncing, non-account-dependent reply cap needed
- **code-simplicity-reviewer:** 5 State classes not 22, per-feature repos not IAccountSettings, inline cleanup not CacheCleanupService, 2 phases not 4
- **pattern-recognition-specialist:** Per-feature repository interfaces match `EphemeralChatRepository` pattern, `I` prefix for broad abstractions only, static `LocalCache.` calls are extraction blockers
### Resolved Questions (from original plan)
1. **AccountSettings audit** → Resolved: use per-feature repository interfaces, not single IAccountSettings. Each State class gets a narrow 2-method interface.
2. **DecryptionCache complexity** → Resolved: pure Kotlin wrappers around quartz's `PrivateTagArrayEventCache`. Move as-is, no interfaces.
3. **ICacheProvider Any? methods** → Resolved: `User`/`Note` already in commons. Change return types directly. Callers drop casts.
4. **Account-dependent prune timing** → Resolved: follow-up work, not deferred. Implement non-account-dependent reply cap (500 per note) as next step.
@@ -0,0 +1,687 @@
---
title: "feat: App Drawer with Categories and Search (v1a)"
type: feat
status: active
date: 2026-03-30
deepened: 2026-03-30
origin: docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md
---
# App Drawer with Categories and Search (v1a)
## Enhancement Summary
**Deepened on:** 2026-03-30
**Agents used:** compose-expert, desktop-expert, kotlin-expert, architecture-strategist, performance-oracle, code-simplicity-reviewer, pattern-recognition-specialist, race-conditions-reviewer, best-practices-researcher
### Key Improvements from Deepening
1. **Eliminated `DrawerScreen` wrapper** — use `DeckColumnType` directly with `category()` extension. Single source of truth (3 reviewers agreed)
2. **Reduced from 4 files to 2**`AppDrawer.kt` + `DeckColumnTypeExtensions.kt` in existing `ui/deck/` package
3. **Fixed critical race condition**`if()` block instead of `AnimatedVisibility` prevents stale state on re-open
4. **Proper Desktop patterns**`onPreviewKeyEvent` (not `onKeyEvent`), `FocusRequester` with `awaitFrame()`, `indication = null` on backdrop
5. **Added `SinglePaneState`** — mirrors `DeckState` pattern, solves state hoisting problem, prepares for v1b/v1c
6. **`derivedStateOf`** for filtered/grouped lists — prevents redundant computation on every recomposition
7. **Double-click guard** — prevents duplicate column creation
### Architectural Corrections
- No new `ui/drawer/` package — files stay in `ui/deck/` (consistent with codebase)
- No `DrawerScreen` data class — `DeckColumnType` already has `title()` and `icon()`
- No `AppDrawerState` separate file — state is simple enough to live inside `AppDrawer.kt`
- No `ScreenCategory` as separate file — `category()` is an extension function on `DeckColumnType`
---
## Overview
Replace the current `AddColumnDialog` with a full-screen App Drawer overlay — a categorized, searchable launcher for all screens in Amethyst Desktop. Phase 1 of 3 in the Desktop Navigation Overhaul (see brainstorm: `docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md`).
## Problem Statement
The current `AddColumnDialog` is a basic `AlertDialog` with 11 hardcoded items, no search, no categories, and no keyboard navigation. Additionally, 3 separate registries duplicate screen metadata (`navItems`, `COLUMN_OPTIONS`, `title()`+`icon()` extensions) — a maintenance hazard.
## Proposed Solution
A full-screen overlay triggered by **Cmd+K** that shows all available screens organized by theme categories, with instant search and keyboard navigation. Works in both Single Pane and Deck modes. Consolidates screen metadata into a single source of truth.
## Design Decisions
### Resolved from SpecFlow Analysis
| Question | Decision | Rationale |
|----------|----------|-----------|
| Parameterized types in drawer | Show **Hashtag** (inline text input after selection) and **Editor** (as "New Draft", null slug). Exclude Profile/Thread/Article | Deep-link targets, not launcher screens |
| Cmd+T in Single Pane | Treat as Cmd+K (navigate to screen) | Consistent behavior, no dead shortcuts |
| Duplicate columns (Deck) | Object types: focus existing. Parameterized: allow duplicates | Two Home columns is odd; two Hashtag columns with different tags is fine |
| Arrow key navigation | Up/Down arrows move selection, Enter confirms | Keyboard-first requirement |
| Empty categories during search | Hide entirely | Cleaner search experience |
| UI button to open drawer | Replace "+" in DeckSidebar, add button in SinglePaneLayout | Discoverability |
| Open column indicators (Deck) | Subtle dot/badge on already-open screens | Prevents accidental duplicates |
| Animation | NO `AnimatedVisibility` — use `if()` block for correctness | `AnimatedVisibility` causes stale state on re-open (critical race condition) |
| Recently-used ordering | Static category order in v1a | Defer to v1b/v1c |
### Screen Categories (14 items across 5 categories)
| Category | Icon | Screens (DeckColumnType) |
|----------|------|--------------------------|
| **Social** | `Icons.Default.Groups` | HomeFeed, Notifications, Messages, GlobalFeed |
| **Long-Form** | `Icons.AutoMirrored.Filled.Article` | Reads, Drafts, Editor*, MyHighlights |
| **Discovery** | `Icons.Default.Explore` | Search, Hashtag*, Bookmarks |
| **Identity** | `Icons.Default.Person` | MyProfile, Settings |
| **Play** | `Icons.Default.SportsEsports` | Chess |
*\* = parameterized type with secondary input*
## Technical Approach
### Architecture
```
Main.kt
├── showAppDrawer: Boolean (replaces showAddColumnDialog)
├── singlePaneState: SinglePaneState (NEW — mirrors DeckState)
├── MenuBar → Cmd+K → showAppDrawer = true (always, both modes)
├── MenuBar → Cmd+T → showAppDrawer = true (backward compat)
└── App()
├── if (showAppDrawer) AppDrawer(...) ← NEW (if block, not AnimatedVisibility)
│ ├── onNavigate: (DeckColumnType) → Unit
│ │ ├── Single Pane: singlePaneState.navigate(type)
│ │ └── Deck: deckState.addColumn() or focusExisting()
│ └── onDismiss: () → Unit
└── MainContent()
├── SINGLE_PANE → SinglePaneLayout(singlePaneState)
└── DECK → DeckLayout(deckState)
```
### New Files (2 files only)
| File | Purpose |
|------|---------|
| `desktopApp/.../ui/deck/AppDrawer.kt` | Overlay composable + private state + screen item composable |
| `desktopApp/.../ui/deck/SinglePaneState.kt` | State holder for single-pane navigation (mirrors DeckState) |
### Modified Files
| File | Changes |
|------|---------|
| `DeckColumnType.kt` | Add `category()` extension. Add `LAUNCHABLE_SCREENS` list |
| `ColumnHeader.kt` | No changes — reuse existing `icon()` extension |
| `Main.kt` | Replace `showAddColumnDialog` with `showAppDrawer`. Add Cmd+K shortcut. Create `SinglePaneState`. Wire `AppDrawer` |
| `DeckSidebar.kt` | Replace "+" button callback → `onShowAppDrawer` |
| `SinglePaneLayout.kt` | Accept `SinglePaneState` instead of local `currentColumnType`. Accept `onOpenAppDrawer` callback |
| `AddColumnDialog.kt` | **Delete** after drawer works |
### Implementation Phases
#### Phase 1: DeckColumnType Extensions + SinglePaneState
**File: `DeckColumnType.kt` — add extensions:**
```kotlin
enum class ScreenCategory(val title: String, val icon: ImageVector) {
SOCIAL("Social", Icons.Default.Groups),
LONG_FORM("Long-Form", Icons.AutoMirrored.Filled.Article),
DISCOVERY("Discovery", Icons.Default.Explore),
IDENTITY("Identity", Icons.Default.Person),
PLAY("Play", Icons.Default.SportsEsports),
}
fun DeckColumnType.category(): ScreenCategory = when (this) {
is DeckColumnType.HomeFeed, is DeckColumnType.Notifications,
is DeckColumnType.Messages, is DeckColumnType.GlobalFeed -> ScreenCategory.SOCIAL
is DeckColumnType.Reads, is DeckColumnType.Drafts,
is DeckColumnType.Editor, is DeckColumnType.MyHighlights -> ScreenCategory.LONG_FORM
is DeckColumnType.Search, is DeckColumnType.Hashtag,
is DeckColumnType.Bookmarks -> ScreenCategory.DISCOVERY
is DeckColumnType.MyProfile, is DeckColumnType.Settings -> ScreenCategory.IDENTITY
is DeckColumnType.Chess -> ScreenCategory.PLAY
else -> ScreenCategory.SOCIAL // fallback for deep-link types
}
// Single source of truth — replaces COLUMN_OPTIONS, navItems, DRAWER_SCREENS
val LAUNCHABLE_SCREENS: List<DeckColumnType> = listOf(
DeckColumnType.HomeFeed,
DeckColumnType.Notifications,
DeckColumnType.Messages,
DeckColumnType.GlobalFeed,
DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.Editor(), // "New Draft" (null slug)
DeckColumnType.MyHighlights,
DeckColumnType.Search,
DeckColumnType.Hashtag(""), // requires input
DeckColumnType.Bookmarks,
DeckColumnType.MyProfile,
DeckColumnType.Settings,
DeckColumnType.Chess,
)
// Used by drawer to detect which items need parameter input
fun DeckColumnType.requiresInput(): Boolean = when (this) {
is DeckColumnType.Hashtag -> true
else -> false
}
```
**File: `SinglePaneState.kt`:**
```kotlin
// Mirrors DeckState pattern — holds current screen for single-pane mode
// Prepares for v1b (customizable nav bar) and v1c (workspaces)
class SinglePaneState {
private val _currentScreen = MutableStateFlow<DeckColumnType>(DeckColumnType.HomeFeed)
val currentScreen: StateFlow<DeckColumnType> = _currentScreen.asStateFlow()
fun navigate(type: DeckColumnType) {
_currentScreen.value = type
}
}
```
> **Research insight (architecture-strategist):** Creating `SinglePaneState` now avoids the state-hoisting problem (drawer at `App()` level needs to set `currentColumnType` in `SinglePaneLayout`) and prepares for v1b where `pinnedScreens: StateFlow<List<DeckColumnType>>` replaces the hardcoded `navItems`.
#### Phase 2: AppDrawer Composable
**File: `AppDrawer.kt` — single file with everything:**
```kotlin
// Private state holder — not a separate file
// @Stable tells Compose all mutations go through snapshot system
@Stable
private class AppDrawerState {
var searchQuery by mutableStateOf("")
var selectedIndex by mutableStateOf(0)
var hashtagInput by mutableStateOf("")
var awaitingHashtag by mutableStateOf(false)
private var consumed by mutableStateOf(false) // double-click guard
// derivedStateOf caches until searchQuery changes — avoids recompute per recomposition
val filteredScreens: List<DeckColumnType> by derivedStateOf {
if (searchQuery.isBlank()) LAUNCHABLE_SCREENS
else LAUNCHABLE_SCREENS.filter {
it.title().contains(searchQuery, ignoreCase = true) ||
it.category().title.contains(searchQuery, ignoreCase = true)
}
}
// Grouped by category, empty categories hidden
val groupedScreens: Map<ScreenCategory, List<DeckColumnType>> by derivedStateOf {
filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() }
}
fun moveSelection(delta: Int) {
val size = filteredScreens.size
if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1)
}
fun select(
screen: DeckColumnType,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
if (consumed) return // double-click guard
if (screen.requiresInput()) {
awaitingHashtag = true
hashtagInput = ""
} else {
consumed = true
onSelectScreen(screen)
onDismiss()
}
}
fun confirmHashtag(
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
if (consumed || hashtagInput.isBlank()) return
consumed = true
onSelectScreen(DeckColumnType.Hashtag(hashtagInput.trim()))
onDismiss()
}
}
@Composable
fun AppDrawer(
openColumnTypes: Set<String>, // typeKey() of open columns (Deck mode)
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
// Fresh state every open — if() block destroys on close, no stale state
val state = remember { AppDrawerState() }
val searchFocusRequester = remember { FocusRequester() }
// Auto-focus search field (awaitFrame for Desktop timing)
LaunchedEffect(Unit) {
awaitFrame()
searchFocusRequester.requestFocus()
}
// Fullscreen scrim — follows GlobalFullscreenOverlay.kt pattern
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
// onPreviewKeyEvent intercepts BEFORE TextField consumes keys
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.Escape -> {
if (state.awaitingHashtag) { state.awaitingHashtag = false; true }
else { onDismiss(); true }
}
Key.DirectionDown -> { state.moveSelection(1); true }
Key.DirectionUp -> { state.moveSelection(-1); true }
Key.Enter -> {
if (state.awaitingHashtag) {
state.confirmHashtag(onSelectScreen, onDismiss); true
} else {
state.filteredScreens.getOrNull(state.selectedIndex)?.let {
state.select(it, onSelectScreen, onDismiss)
}
true
}
}
else -> false // let TextField handle typing
}
}
// Click scrim to dismiss — no ripple indication
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { onDismiss() },
contentAlignment = Alignment.Center,
) {
// Content card — consume pointer events to prevent click-through to scrim
Surface(
modifier = Modifier
.fillMaxWidth(0.6f)
.fillMaxHeight(0.7f)
.pointerInput(Unit) {
awaitPointerEventScope { while (true) { awaitPointerEvent() } }
},
shape = RoundedCornerShape(16.dp),
tonalElevation = 8.dp,
) {
Column {
// Search TextField
TextField(
value = state.searchQuery,
onValueChange = { state.searchQuery = it; state.selectedIndex = 0 },
modifier = Modifier
.fillMaxWidth()
.focusRequester(searchFocusRequester),
placeholder = { Text("Search screens...") },
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, "Search") },
)
if (state.awaitingHashtag) {
// Hashtag parameter input
HashtagInputSection(state, onSelectScreen, onDismiss)
} else {
// Categorized grid — isolated composable for recomposition scoping
DrawerGrid(state, openColumnTypes, onSelectScreen, onDismiss)
}
}
}
}
}
// Separate composable = separate recomposition scope
// Only recomposes when filteredScreens actually changes (derivedStateOf)
@Composable
private fun DrawerGrid(
state: AppDrawerState,
openColumnTypes: Set<String>,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
val listState = rememberLazyListState()
// Auto-scroll to keep selected item visible
LaunchedEffect(state.selectedIndex) {
listState.animateScrollToItem(
(state.selectedIndex / 4).coerceAtLeast(0) // approximate row
)
}
LazyColumn(state = listState) {
var globalIndex = 0
state.groupedScreens.forEach { (category, screens) ->
stickyHeader(key = "header-${category.name}") {
CategoryHeader(category)
}
val startIndex = globalIndex
item(key = "grid-${category.name}") {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
screens.forEachIndexed { localIdx, screen ->
DrawerScreenCard(
type = screen,
isSelected = (startIndex + localIdx) == state.selectedIndex,
isOpen = openColumnTypes.contains(screen.typeKey()),
onClick = { state.select(screen, onSelectScreen, onDismiss) },
)
}
}
}
globalIndex += screens.size
}
}
}
@Composable
private fun DrawerScreenCard(
type: DeckColumnType,
isSelected: Boolean,
isOpen: Boolean,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier
.size(80.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
tonalElevation = if (isSelected) 8.dp else 2.dp,
color = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
type.icon(),
contentDescription = type.title(),
modifier = Modifier.size(28.dp),
)
Spacer(Modifier.height(4.dp))
Text(
type.title(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
}
// Open indicator dot
if (isOpen) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(6.dp)
.background(
MaterialTheme.colorScheme.primary,
CircleShape,
),
)
}
}
}
}
@Composable
private fun CategoryHeader(category: ScreenCategory) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(category.icon, category.title, Modifier.size(16.dp))
Text(
category.title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun HashtagInputSection(
state: AppDrawerState,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
Column(Modifier.padding(16.dp)) {
Text("Enter hashtag:", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
TextField(
value = state.hashtagInput,
onValueChange = { state.hashtagInput = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("#bitcoin, #nostr...") },
singleLine = true,
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { state.awaitingHashtag = false }) { Text("Back") }
Button(
onClick = { state.confirmHashtag(onSelectScreen, onDismiss) },
enabled = state.hashtagInput.isNotBlank(),
) { Text("Open") }
}
}
}
```
> **Research insights applied:**
> - **Compose expert:** `derivedStateOf` for `filteredScreens`/`groupedScreens` prevents triple recomputation per keystroke
> - **Desktop expert:** `onPreviewKeyEvent` (not `onKeyEvent`) intercepts before TextField consumes arrow/Enter keys. Pattern from `SearchScreen.kt:293`, `ChatPane.kt:729`
> - **Desktop expert:** `Box(fillMaxSize)` overlay pattern matches `GlobalFullscreenOverlay.kt` and `LightboxOverlay.kt` — NOT `Popup` or `Dialog`
> - **Race conditions (CRITICAL):** `if (showAppDrawer)` block destroys/recreates composable on each open — fresh `remember` state, no stale data. Do NOT use `AnimatedVisibility`
> - **Race conditions:** `consumed` flag prevents double-click adding duplicate columns
> - **Compose expert:** `pointerInput` consume on content Surface is more robust than `clickable(enabled = false)` for preventing click-through
> - **Kotlin expert:** `@Stable` on `AppDrawerState` tells Compose mutations go through snapshot system
> - **Pattern recognition:** Using `DeckColumnType` directly with `title()`/`icon()`/`category()` instead of wrapper eliminates data duplication
#### Phase 3: Wire into Main.kt
```kotlin
// Create SinglePaneState (mirrors DeckState)
val singlePaneState = remember { SinglePaneState() }
// Replace showAddColumnDialog with showAppDrawer
var showAppDrawer by remember { mutableStateOf(false) }
// MenuBar — Cmd+K always available (View menu)
Menu("View") {
Item(
"App Drawer",
shortcut = if (isMacOS) KeyShortcut(Key.K, meta = true) else KeyShortcut(Key.K, ctrl = true),
onClick = { showAppDrawer = !showAppDrawer },
)
// ... existing layout toggle, deck-only items
}
// Cmd+T also opens drawer (backward compat, both modes)
Item("Add Column", shortcut = KeyShortcut(Key.T, ...)) {
showAppDrawer = true
}
// Collect open columns as state (not .value) for reactivity
val openColumns by deckState.columns.collectAsState()
// Render overlay — if() block, NOT AnimatedVisibility
if (showAppDrawer) {
AppDrawer(
openColumnTypes = if (layoutMode == LayoutMode.DECK) {
openColumns.map { it.type.typeKey() }.toSet()
} else emptySet(),
onSelectScreen = { type ->
when (layoutMode) {
LayoutMode.SINGLE_PANE -> singlePaneState.navigate(type)
LayoutMode.DECK -> {
if (deckState.hasColumnOfType(type)) {
deckState.focusExistingColumn(type)
} else {
deckState.addColumn(type)
}
}
}
},
onDismiss = { showAppDrawer = false },
)
}
```
> **Research insight (race conditions):** Use `collectAsState()` for `openColumnTypes` rendering, not `deckState.columns.value`. Direct `.value` reads are stale for UI — Compose won't recompose when the flow emits.
#### Phase 4: Update DeckSidebar + SinglePaneLayout
**DeckSidebar.kt:**
- Replace `onShowAddColumnDialog` callback → `onShowAppDrawer`
**SinglePaneLayout.kt:**
- Accept `SinglePaneState` instead of local `currentColumnType`
- Accept `onOpenAppDrawer: () -> Unit`
- Add `IconButton(Icons.Default.Apps)` at bottom of NavigationRail
- Replace hardcoded `navItems` with `LAUNCHABLE_SCREENS.filter { it.category() != ScreenCategory.PLAY }` or a curated default list
- Read `val currentScreen by singlePaneState.currentScreen.collectAsState()`
#### Phase 5: Delete AddColumnDialog.kt + Cleanup
- Remove `AddColumnDialog.kt`
- Remove `showAddColumnDialog` from `Main.kt`
- Remove `COLUMN_OPTIONS` list
- Remove `onShowAddColumnDialog` callbacks from `DeckSidebar`, etc.
- Verify `navItems` in `SinglePaneLayout` is replaced or reads from `LAUNCHABLE_SCREENS`
### Keyboard Event Handling
```
Drawer open (onPreviewKeyEvent on outer Box):
Escape → awaitingHashtag ? cancel : onDismiss()
Up Arrow → state.moveSelection(-1) [consumed before TextField]
Down Arrow → state.moveSelection(+1) [consumed before TextField]
Enter → awaitingHashtag ? confirmHashtag : select highlighted
Any printable → falls through to TextField (not consumed)
Backspace → falls through to TextField (not consumed)
```
> **Desktop expert:** `onPreviewKeyEvent` fires before children process the event. Returning `true` for arrows/Enter prevents TextField from consuming them. Returning `false` for printable keys lets TextField handle typing. This exact pattern is used in `SearchScreen.kt:293` and `ChatPane.kt:729`.
## System-Wide Impact
### Interaction Graph
```
Cmd+K pressed
→ Main.kt: showAppDrawer = true
→ AppDrawer composed (fresh state via remember)
→ searchFocusRequester.requestFocus() (after awaitFrame)
→ User types → derivedStateOf recalculates filteredScreens
→ DrawerGrid recomposes (isolated scope)
→ User selects screen (consumed flag prevents double-fire)
→ onSelectScreen(DeckColumnType) fires
→ DECK: deckState.addColumn() or focusExisting()
→ DeckState.save() (debounced 500ms)
→ SINGLE_PANE: singlePaneState.navigate(type)
→ currentScreen flow emits → RootContent recomposes
→ onDismiss → showAppDrawer = false
→ AppDrawer leaves composition → state garbage collected
```
### State Lifecycle
- **No stale state risk:** `if()` block destroys composable on close. `remember` creates fresh `AppDrawerState` on each open. No `LaunchedEffect(Unit) { reset() }` needed.
- **Hashtag input cleanup:** Automatic — state destroyed on dismiss.
- **SinglePaneLayout state:** `SinglePaneState` created at `App()` level, passed down. No hoisting problem.
- **Open column indicators:** `collectAsState()` ensures reactivity.
### API Surface Parity
| Interface | Change |
|-----------|--------|
| `DeckColumnType` | Add `category()` extension, `requiresInput()` extension, `LAUNCHABLE_SCREENS` list |
| `DeckSidebar.onAddColumn` | Rename to `onShowAppDrawer` |
| `SinglePaneLayout` | Accept `SinglePaneState` + `onOpenAppDrawer` params |
| `Main.kt` MenuBar | Add Cmd+K item in "View" menu. Update Cmd+T to open drawer |
| `AddColumnDialog` | Delete |
### Registry Consolidation
| Before (3+ registries) | After (1 source of truth) |
|------------------------|--------------------------|
| `COLUMN_OPTIONS` in AddColumnDialog.kt | **Deleted** — replaced by `LAUNCHABLE_SCREENS` |
| `navItems` in SinglePaneLayout.kt | **Replaced** — reads from `LAUNCHABLE_SCREENS` |
| `title()` in DeckColumnType.kt | **Kept** — single source for labels |
| `icon()` in ColumnHeader.kt | **Kept** — single source for icons |
| `DRAWER_SCREENS` (was planned) | **Never created**`LAUNCHABLE_SCREENS` + extensions |
## Acceptance Criteria
### Functional Requirements
- [ ] Cmd+K opens App Drawer in both Single Pane and Deck modes
- [ ] Cmd+T opens App Drawer (backward compat, both modes)
- [ ] Search field auto-focused on open
- [ ] Typing filters screens across all categories in real-time
- [ ] Empty categories hidden during search
- [ ] 14 screens across 5 categories displayed correctly
- [ ] Each screen shows correct icon (from `icon()`) and label (from `title()`)
- [ ] Clicking a screen in Single Pane navigates via `SinglePaneState`
- [ ] Clicking a screen in Deck adds column (or focuses existing for object types)
- [ ] Hashtag selection shows secondary text input, Enter confirms
- [ ] Editor selection opens new draft (null slug)
- [ ] Escape closes drawer (or cancels hashtag input)
- [ ] Clicking backdrop closes drawer
- [ ] Arrow keys move selection highlight
- [ ] Enter selects highlighted screen
- [ ] Already-open columns show indicator dot in Deck mode
- [ ] Double-click does NOT create duplicate columns
- [ ] "+" button in DeckSidebar opens App Drawer
- [ ] Apps button in SinglePaneLayout nav rail opens App Drawer
- [ ] `AddColumnDialog.kt` deleted, no remaining references
- [ ] `COLUMN_OPTIONS` and `navItems` removed/replaced
### Non-Functional Requirements
- [ ] Drawer opens in <100ms (no network calls, pure UI)
- [ ] Search filtering is instant (`derivedStateOf` caching)
- [ ] No animation (correctness > aesthetics — avoids stale state race condition)
- [ ] Follows Material3 color scheme (Surface, onSurface, primaryContainer)
## Dependencies & Risks
| Risk | Mitigation | Source |
|------|------------|--------|
| `currentColumnType` in SinglePaneLayout is local state | Create `SinglePaneState` class | architecture-strategist |
| `AnimatedVisibility` causes stale state on re-open | Use `if()` block instead | race-conditions-reviewer (CRITICAL) |
| Double-click fires `onSelectScreen` twice | `consumed` flag in `AppDrawerState` | race-conditions-reviewer |
| `clickable(enabled=false)` doesn't consume events | Use `pointerInput` consume pattern | compose-expert |
| `onKeyEvent` doesn't intercept before TextField | Use `onPreviewKeyEvent` | desktop-expert |
| Focus may not grab immediately on Desktop | `awaitFrame()` before `requestFocus()` | compose-expert |
| `deckState.columns.value` is stale for UI | Use `collectAsState()` | race-conditions-reviewer |
| `FlowRow` inside `LazyColumn` measures eagerly | Fine at 14 items; max 4 per category | performance-oracle |
## Sources & References
### Origin
- **Brainstorm document:** [docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md](docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md)
### Internal References (Codebase Patterns)
- `GlobalFullscreenOverlay.kt` — fullscreen Box overlay pattern (z-order in MainContent)
- `LightboxOverlay.kt:202-253` — focusRequester + onKeyEvent + clickable scrim pattern
- `SearchScreen.kt:293``onPreviewKeyEvent` intercepting Enter/Escape before TextField
- `ChatPane.kt:729` — same `onPreviewKeyEvent` pattern
- `DeckColumnType.kt:25-111` — sealed class, `title()`, `typeKey()`
- `ColumnHeader.kt:123-142``icon()` extension
- `DeckState.kt` — StateFlow pattern, addColumn(), save/load persistence
- `SinglePaneLayout.kt:80-93,115` — navItems + currentColumnType (being replaced)
- `Main.kt:187,297-307,570-578` — showAddColumnDialog + shortcuts + dialog render
- `AddColumnDialog.kt:49-62` — COLUMN_OPTIONS (being deleted)
### External References
- [Compose Desktop keyboard events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html)
- [Focus in Compose](https://developer.android.com/develop/ui/compose/touch-input/focus)
- [AnimatedVisibility docs](https://developer.android.com/develop/ui/compose/animation/composables-modifiers)
## Open Questions
- Should `SinglePaneState` persist last-viewed screen to `DesktopPreferences`? (nice-to-have)
- Should `ScreenCategory` live in `DeckColumnType.kt` or `ColumnHeader.kt`? (colocation question)
- Future: Cmd+K also search within content (notes, profiles)? (v2+ concern)
- Mouse hover on grid items should update `selectedIndex`? (nice keyboard+mouse hybrid UX)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,543 @@
---
title: "feat: Customizable Nav Bar (v1b)"
type: feat
status: active
date: 2026-04-17
origin: docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md
---
# Customizable Nav Bar (v1b)
## Overview
Make the sidebar nav bar user-customizable. Users pin/unpin screens from the App Drawer, reorder items via drag-to-sort, and persist their layout. Applies to both SinglePaneLayout's NavigationRail and DeckSidebar's quick-launch icons.
## Problem Statement
The current `navItems` list in `SinglePaneLayout.kt` is hardcoded — 11 items, fixed order, no user control. Users can't hide screens they don't use or promote ones they use frequently. DeckSidebar has no quick-launch icons at all (just "+" and Settings). The App Drawer (v1a) made all screens discoverable but didn't solve personalization.
## Proposed Solution
1. **`PinnedNavBarState`** — state holder managing an ordered list of pinned `DeckColumnType` items, backed by `DesktopPreferences`
2. **Context menus** on drawer items ("Pin to sidebar") and nav items ("Unpin", "Move up/down")
3. **Drag-to-reorder** on nav rail items
4. **DeckSidebar** gets pinned icons between the "+" button and the spacer
5. Default pinned items = current `navItems` list (migration-safe)
## Technical Approach
### Architecture
```
DesktopPreferences
└── pinnedNavItems: String (JSON array of typeKey strings)
PinnedNavBarState (new class)
├── pinnedScreens: StateFlow<List<DeckColumnType>>
├── pin(type) / unpin(type) / move(from, to)
├── save() / load() — debounced, Jackson JSON
└── isPinned(type): Boolean
SinglePaneLayout
└── NavigationRail reads pinnedScreens instead of hardcoded navItems
DeckSidebar
└── Renders pinned icons between "+" and spacer
AppDrawer
└── DrawerScreenCard gets context menu: "Pin to sidebar" / "Unpin"
```
### New Files
| File | Purpose |
|------|---------|
| `desktopApp/.../ui/deck/PinnedNavBarState.kt` | State + persistence for pinned items |
### Modified Files
| File | Changes |
|------|---------|
| `DesktopPreferences.kt` | Add `pinnedNavItems` property |
| `SinglePaneLayout.kt` | Replace hardcoded `navItems` with `PinnedNavBarState.pinnedScreens`, add drag-reorder + context menu |
| `DeckSidebar.kt` | Accept `PinnedNavBarState`, render pinned icons, context menu to unpin |
| `AppDrawer.kt` | Add `onPinScreen`/`onUnpinScreen` callbacks, context menu on `DrawerScreenCard` |
| `Main.kt` | Create `PinnedNavBarState`, pass to SinglePaneLayout/DeckSidebar/AppDrawer |
### Implementation Phases
#### Phase 1: PinnedNavBarState + Persistence
**File: `PinnedNavBarState.kt`**
```kotlin
class PinnedNavBarState(
private val saveScope: CoroutineScope,
) {
private val _pinnedScreens = MutableStateFlow(DEFAULT_PINNED)
val pinnedScreens: StateFlow<List<DeckColumnType>> = _pinnedScreens.asStateFlow()
private var saveJob: Job? = null
fun isPinned(type: DeckColumnType): Boolean =
_pinnedScreens.value.any { it.typeKey() == type.typeKey() }
fun pin(type: DeckColumnType) {
if (isPinned(type)) return
_pinnedScreens.update { it + type }
scheduleSave()
}
fun unpin(type: DeckColumnType) {
_pinnedScreens.update { list ->
list.filter { it.typeKey() != type.typeKey() }
}
scheduleSave()
}
fun move(fromIndex: Int, toIndex: Int) {
_pinnedScreens.update { current ->
if (fromIndex !in current.indices || toIndex !in current.indices) return
current.toMutableList().apply {
val item = removeAt(fromIndex)
add(toIndex, item)
}
}
scheduleSave()
}
private fun scheduleSave() {
saveJob?.cancel()
saveJob = saveScope.launch {
delay(SAVE_DEBOUNCE_MS)
save()
}
}
fun save() {
try {
val keys = _pinnedScreens.value.map { it.typeKey() }
DesktopPreferences.pinnedNavItems = mapper.writeValueAsString(keys)
} catch (e: Exception) {
println("PinnedNavBarState: save failed: ${e.message}")
}
}
fun load() {
try {
val json = DesktopPreferences.pinnedNavItems
if (json.isBlank()) return
val keys: List<String> = mapper.readValue(json)
val loaded = keys.mapNotNull { key ->
LAUNCHABLE_SCREENS.find { it.typeKey() == key }
}
if (loaded.isNotEmpty()) {
_pinnedScreens.value = loaded
}
} catch (e: Exception) {
println("PinnedNavBarState: load failed: ${e.message}")
}
}
companion object {
private const val SAVE_DEBOUNCE_MS = 500L
private val mapper = jacksonObjectMapper()
// Matches current navItems list — zero-migration default
val DEFAULT_PINNED: List<DeckColumnType> = listOf(
DeckColumnType.HomeFeed,
DeckColumnType.Reads,
DeckColumnType.Drafts,
DeckColumnType.MyHighlights,
DeckColumnType.Search,
DeckColumnType.Bookmarks,
DeckColumnType.Messages,
DeckColumnType.Notifications,
DeckColumnType.MyProfile,
DeckColumnType.Chess,
DeckColumnType.Settings,
)
}
}
```
**File: `DesktopPreferences.kt` — add property:**
```kotlin
private const val KEY_PINNED_NAV_ITEMS = "pinned_nav_items"
var pinnedNavItems: String
get() = prefs.get(KEY_PINNED_NAV_ITEMS, "")
set(value) { prefs.put(KEY_PINNED_NAV_ITEMS, value) }
```
#### Phase 2: SinglePaneLayout — Dynamic Nav Rail
Replace hardcoded `navItems` with `PinnedNavBarState`:
```kotlin
// Before: private val navItems = listOf(NavItem(...), ...)
// After: removed. NavItem data class also removed.
@Composable
fun SinglePaneLayout(
// ... existing params ...
pinnedNavBarState: PinnedNavBarState, // NEW
) {
val currentColumnType by singlePaneState.currentScreen.collectAsState()
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
// ...
NavigationRail(...) {
pinnedScreens.forEachIndexed { index, screenType ->
NavigationRailItem(
selected = currentColumnType == screenType && navStack.isEmpty(),
onClick = {
singlePaneState.navigate(screenType)
navState.clear()
},
icon = {
Icon(screenType.icon(), screenType.title(), Modifier.size(22.dp))
},
label = {
Text(
screenType.title(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = Modifier.contextMenuOpenDetector {
// context menu state set here — see Phase 4
},
)
}
// "More" button (App Drawer) stays at bottom
NavigationRailItem(
selected = false,
onClick = onOpenAppDrawer,
icon = { Icon(Icons.Default.Apps, "App Drawer", Modifier.size(22.dp)) },
label = { Text("More", ...) },
)
Spacer(Modifier.weight(1f))
// ... relay health, bunker, tor indicators unchanged ...
}
}
```
#### Phase 3: Context Menus
Desktop Compose supports `ContextMenuArea` or `CursorDropdownMenu`. Use `CursorDropdownMenu` (positioned at cursor) for right-click:
**Nav rail item context menu (SinglePaneLayout):**
```kotlin
var contextMenuTarget by remember { mutableStateOf<DeckColumnType?>(null) }
var contextMenuOffset by remember { mutableStateOf(DpOffset.Zero) }
// Inside NavigationRail, wrapping each item:
Box(
modifier = Modifier.onPointerEvent(PointerEventType.Press) { event ->
if (event.button == PointerButton.Secondary) {
contextMenuTarget = screenType
// position from pointer
}
}
) {
NavigationRailItem(...)
}
// After NavigationRail:
CursorDropdownMenu(
expanded = contextMenuTarget != null,
onDismissRequest = { contextMenuTarget = null },
) {
DropdownMenuItem(
text = { Text("Unpin from sidebar") },
onClick = {
contextMenuTarget?.let { pinnedNavBarState.unpin(it) }
contextMenuTarget = null
},
leadingIcon = { Icon(Icons.Default.PushPin, null) },
)
DropdownMenuItem(
text = { Text("Move up") },
onClick = {
val idx = pinnedScreens.indexOfFirst { it.typeKey() == contextMenuTarget?.typeKey() }
if (idx > 0) pinnedNavBarState.move(idx, idx - 1)
contextMenuTarget = null
},
enabled = pinnedScreens.indexOfFirst { it.typeKey() == contextMenuTarget?.typeKey() } > 0,
)
DropdownMenuItem(
text = { Text("Move down") },
onClick = {
val idx = pinnedScreens.indexOfFirst { it.typeKey() == contextMenuTarget?.typeKey() }
if (idx < pinnedScreens.size - 1) pinnedNavBarState.move(idx, idx + 1)
contextMenuTarget = null
},
enabled = run {
val idx = pinnedScreens.indexOfFirst { it.typeKey() == contextMenuTarget?.typeKey() }
idx < pinnedScreens.size - 1
},
)
}
```
**App Drawer item context menu (AppDrawer.kt):**
```kotlin
// DrawerScreenCard gets right-click handler
@Composable
private fun DrawerScreenCard(
type: DeckColumnType,
isSelected: Boolean,
isOpen: Boolean,
isPinned: Boolean, // NEW
onClick: () -> Unit,
onHover: () -> Unit,
onTogglePin: () -> Unit, // NEW
) {
var showContextMenu by remember { mutableStateOf(false) }
Box {
Surface(
modifier = Modifier
.size(80.dp)
.clickable(onClick = onClick)
.onPointerEvent(PointerEventType.Enter) { onHover() }
.onPointerEvent(PointerEventType.Press) { event ->
if (event.button == PointerButton.Secondary) {
showContextMenu = true
}
},
// ... existing styling ...
) {
// ... existing content ...
// Add pin indicator
if (isPinned) {
Icon(
Icons.Default.PushPin,
null,
modifier = Modifier.align(Alignment.TopStart).padding(4.dp).size(10.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
CursorDropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
) {
DropdownMenuItem(
text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") },
onClick = {
onTogglePin()
showContextMenu = false
},
leadingIcon = {
Icon(
if (isPinned) Icons.Default.PushPinSlash else Icons.Default.PushPin,
null,
)
},
)
}
}
}
```
#### Phase 4: DeckSidebar Pinned Icons
```kotlin
@Composable
fun DeckSidebar(
pinnedNavBarState: PinnedNavBarState, // NEW
onAddColumn: () -> Unit,
onNavigate: (DeckColumnType) -> Unit, // NEW — adds column or focuses
onOpenSettings: () -> Unit,
// ... existing params ...
) {
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
Column(...) {
Text("A", ...) // logo
Spacer(Modifier.size(16.dp))
IconButton(onClick = onAddColumn) {
Icon(Icons.Default.Add, "Add Column", ...)
}
Spacer(Modifier.size(8.dp))
// Pinned quick-launch icons
pinnedScreens.take(MAX_DECK_PINNED).forEach { screenType ->
IconButton(
onClick = { onNavigate(screenType) },
modifier = Modifier.size(36.dp),
) {
Icon(
screenType.icon(),
screenType.title(),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.weight(1f))
// ... bunker, tor, settings unchanged ...
}
}
private const val MAX_DECK_PINNED = 8 // deck sidebar is narrow, cap visible icons
```
#### Phase 5: Wire into Main.kt
```kotlin
// In App() composable:
val pinnedNavBarState = remember(appScope) {
PinnedNavBarState(appScope).also { it.load() }
}
// Pass to SinglePaneLayout:
SinglePaneLayout(
// ... existing ...
pinnedNavBarState = pinnedNavBarState,
)
// Pass to DeckSidebar:
DeckSidebar(
pinnedNavBarState = pinnedNavBarState,
onNavigate = { type ->
if (deckState.hasColumnOfType(type)) deckState.focusExistingColumn(type)
else deckState.addColumn(type)
},
// ... existing ...
)
// Pass to AppDrawer:
if (showAppDrawer) {
AppDrawer(
openColumnTypes = ...,
pinnedNavBarState = pinnedNavBarState, // NEW
onSelectScreen = { ... },
onDismiss = { showAppDrawer = false },
)
}
```
#### Phase 6: Drag-to-Reorder (Enhancement)
Use Compose `draggable` modifier on NavigationRailItems for vertical reorder:
```kotlin
// In SinglePaneLayout NavigationRail:
var dragTargetIndex by remember { mutableStateOf<Int?>(null) }
var dragSourceIndex by remember { mutableStateOf<Int?>(null) }
pinnedScreens.forEachIndexed { index, screenType ->
val offsetY = remember { Animatable(0f) }
NavigationRailItem(
modifier = Modifier
.pointerInput(index) {
detectDragGesturesAfterLongPress(
onDragStart = {
dragSourceIndex = index
},
onDrag = { change, dragAmount ->
change.consume()
// Calculate target index from vertical offset
val targetIdx = (index + (dragAmount.y / ITEM_HEIGHT).roundToInt())
.coerceIn(0, pinnedScreens.size - 1)
dragTargetIndex = targetIdx
},
onDragEnd = {
val from = dragSourceIndex
val to = dragTargetIndex
if (from != null && to != null && from != to) {
pinnedNavBarState.move(from, to)
}
dragSourceIndex = null
dragTargetIndex = null
},
onDragCancel = {
dragSourceIndex = null
dragTargetIndex = null
},
)
}
.graphicsLayer {
// Visual feedback: slight scale-up on dragged item
if (dragSourceIndex == index) {
scaleX = 1.1f
scaleY = 1.1f
alpha = 0.7f
}
},
// ... rest of NavigationRailItem params ...
)
// Drop indicator line
if (dragTargetIndex == index && dragSourceIndex != index) {
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.background(MaterialTheme.colorScheme.primary)
)
}
}
```
> **Note:** Exact drag implementation depends on NavigationRail's layout constraints. If NavigationRailItem doesn't compose well with drag gestures, fallback to context menu "Move up/down" only (Phase 3 already covers this). Drag is a nice-to-have enhancement.
## Acceptance Criteria
### Functional
- [ ] Nav rail items in SinglePaneLayout driven by `PinnedNavBarState` (not hardcoded)
- [ ] Right-click nav rail item shows "Unpin", "Move up", "Move down"
- [ ] Right-click drawer item shows "Pin to sidebar" / "Unpin from sidebar"
- [ ] Pin/unpin immediately updates nav rail
- [ ] Move up/down immediately reorders nav rail
- [ ] DeckSidebar shows pinned quick-launch icons
- [ ] Pinned items persist across app restarts (DesktopPreferences)
- [ ] Default pinned items = current navItems (zero-migration)
- [ ] "More" / App Drawer button always present at bottom of nav rail
- [ ] Drawer shows pin indicator on already-pinned items
- [ ] Works in both Single Pane and Deck modes
### Non-Functional
- [ ] Pin/unpin is instant (no network, pure state)
- [ ] Save debounced 500ms (matches DeckState pattern)
- [ ] JSON persistence uses Jackson (matches DeckState pattern)
- [ ] No new dependencies added
### Stretch (if time)
- [ ] Drag-to-reorder via long-press on nav rail items
- [ ] Keyboard shortcut Cmd+1-9 maps to pinned items (not hardcoded)
## Dependencies & Risks
| Risk | Mitigation |
|------|------------|
| `NavigationRailItem` may not support drag gestures cleanly | Fallback to context menu Move up/down. Drag is Phase 6 (optional) |
| `CursorDropdownMenu` API may differ across Compose versions | Check Compose 1.7.x API; fallback to `DropdownMenu` with manual positioning |
| Right-click detection on Desktop | Use `onPointerEvent(PointerEventType.Press)` + check `event.button == PointerButton.Secondary` |
| Preferences key collision on upgrade | New key `pinned_nav_items` doesn't conflict with existing keys |
| Pinned item references stale if `DeckColumnType` sealed class changes | `load()` uses `mapNotNull` — unknown keys silently dropped, like DeckState |
| Max pinned items could overflow nav rail | SinglePaneLayout: scrollable NavigationRail. DeckSidebar: cap at 8 |
## Open Questions
- Should there be a max number of pinned items, or let the nav rail scroll?
- Should Settings always be pinned (non-removable) as an escape hatch?
- Should drag-to-reorder work in DeckSidebar too, or just SinglePaneLayout?
- Should pinned items be shared between Single Pane and Deck modes, or separate lists?
- Should Cmd+1-9 shortcuts be remapped to pinned items in v1b, or defer to v1c workspaces?
@@ -0,0 +1,641 @@
---
title: "feat: Workspace Management UX"
type: feat
status: active
date: 2026-04-17
origin: docs/brainstorms/2026-04-17-workspace-management-ux-brainstorm.md
---
# Workspace Management UX
## Summary
Add full workspace CRUD to the App Drawer: two-tab layout (Screens/Workspaces), unified search, workspace cards with editor dialog, Cmd+S save, and layout mode auto-switching on workspace change.
## Current State
| Component | File | Notes |
|-----------|------|-------|
| `AppDrawer` | `deck/AppDrawer.kt` | Single-purpose: screen grid + bottom `WorkspaceBar` (chip strip) |
| `AppDrawerState` | `deck/AppDrawer.kt` | Filters `LAUNCHABLE_SCREENS` only, no workspace awareness |
| `WorkspaceManager` | `deck/WorkspaceManager.kt` | Full CRUD, persistence via Jackson+`DesktopPreferences.workspaces` |
| `Workspace` | `deck/Workspace.kt` | Data class w/ `WorkspaceColumn`, `WorkspaceIcons` registry |
| `DeckState` | `deck/DeckState.kt` | `loadFromWorkspace()` exists, handles column loading |
| `SinglePaneState` | `deck/SinglePaneState.kt` | `navigate(type)` — no workspace integration yet |
| `LayoutMode` | `Main.kt:120` | Enum `SINGLE_PANE`/`DECK`, stored as `var` in `App()` composable |
| `WorkspaceBar` | `deck/AppDrawer.kt` | Bottom strip of `FilterChip`s — will be replaced |
| Keyboard shortcuts | `Main.kt` MenuBar | Cmd+K=drawer, Cmd+N=note, Cmd+D+Shift=deck toggle, Cmd+T=add col, Cmd+W=close col |
### Key Observation: layoutMode Threading
`layoutMode` is a `var` inside `App()` (Main.kt:217). The `onSwitchWorkspace` callback in the AppDrawer currently only calls `deckState.loadFromWorkspace()` — it does NOT change `layoutMode`. This must be fixed.
## Architecture
### Phase 1: AppDrawer Tab System + Workspace Cards
**Files modified:** `AppDrawer.kt`
#### 1a. Extend `AppDrawerState` for tab + unified search
```kotlin
enum class DrawerTab { SCREENS, WORKSPACES }
@Stable
private class AppDrawerState(
private val workspaceManager: WorkspaceManager,
) {
var searchQuery by mutableStateOf("")
var selectedIndex by mutableStateOf(0)
var activeTab by mutableStateOf(DrawerTab.SCREENS)
var hashtagInput by mutableStateOf("")
var awaitingHashtag by mutableStateOf(false)
private var consumed by mutableStateOf(false)
// -- Screens filtering (existing) --
val filteredScreens: List<DeckColumnType> by derivedStateOf {
if (searchQuery.isBlank()) LAUNCHABLE_SCREENS
else LAUNCHABLE_SCREENS.filter {
it.title().contains(searchQuery, ignoreCase = true) ||
it.category().title.contains(searchQuery, ignoreCase = true)
}
}
// -- Workspace filtering (new) --
val filteredWorkspaces: List<Workspace> by derivedStateOf {
val all = workspaceManager.workspaces.value
if (searchQuery.isBlank()) all
else all.filter { ws ->
ws.name.contains(searchQuery, ignoreCase = true) ||
ws.columns.any { col ->
col.typeKey.contains(searchQuery, ignoreCase = true)
}
}
}
// True when search is active — show unified results regardless of tab
val isSearching: Boolean by derivedStateOf { searchQuery.isNotBlank() }
// Unified: workspace results + screen results
// Workspaces come first; selectedIndex spans both lists
val totalResultCount: Int by derivedStateOf {
if (isSearching) filteredWorkspaces.size + filteredScreens.size
else filteredScreens.size // tab-based navigation uses per-tab indexing
}
// ... moveSelection, select, confirmHashtag remain the same
}
```
**Key design:** When `searchQuery` is non-empty, the drawer ignores tabs and renders unified results (workspaces at top, screens below). When empty, tabs control what's visible.
#### 1b. Two-tab header using `PrimaryTabRow` + `Tab`
```kotlin
// Inside AppDrawer, between search TextField and content
if (!state.isSearching) {
PrimaryTabRow(selectedTabIndex = state.activeTab.ordinal) {
DrawerTab.entries.forEach { tab ->
Tab(
selected = state.activeTab == tab,
onClick = { state.activeTab = tab },
text = { Text(tab.name.lowercase().replaceFirstChar { it.uppercase() }) },
)
}
}
}
```
#### 1c. Replace `WorkspaceBar` with full Workspaces tab
The bottom `WorkspaceBar` is removed. The Workspaces tab renders full workspace cards:
```kotlin
@Composable
private fun WorkspacesGrid(
state: AppDrawerState,
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Workspace) -> Unit,
onDismiss: () -> Unit,
) {
val workspaces by workspaceManager.workspaces.collectAsState()
val activeIndex by workspaceManager.activeIndex.collectAsState()
var showEditor by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf<Workspace?>(null) }
LazyColumn(Modifier.padding(8.dp)) {
itemsIndexed(workspaces) { index, ws ->
WorkspaceCard(
workspace = ws,
isActive = index == activeIndex,
onSwitch = {
val switched = workspaceManager.switchTo(index)
if (switched != null) {
onSwitchWorkspace(switched)
onDismiss()
}
},
onEdit = { editTarget = ws; showEditor = true },
onDelete = { workspaceManager.deleteWorkspace(ws.id) },
canDelete = workspaces.size > 1,
)
}
// "+" card
item {
AddWorkspaceCard(onClick = { editTarget = null; showEditor = true })
}
}
if (showEditor) {
WorkspaceEditorDialog(
initial = editTarget,
onSave = { ws ->
if (editTarget != null) workspaceManager.updateWorkspace(ws)
else workspaceManager.addWorkspace(ws)
showEditor = false
},
onDismiss = { showEditor = false },
)
}
}
```
#### 1d. Workspace card layout
```kotlin
@Composable
private fun WorkspaceCard(
workspace: Workspace,
isActive: Boolean,
onSwitch: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
canDelete: Boolean,
) {
Surface(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable(onClick = onSwitch),
shape = RoundedCornerShape(12.dp),
tonalElevation = if (isActive) 8.dp else 2.dp,
color = if (isActive) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(WorkspaceIcons.resolve(workspace.iconName), workspace.name, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(workspace.name, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
// Layout mode badge
AssistChip(
onClick = {},
label = {
Text(
if (workspace.layoutMode == LayoutMode.DECK) "Deck" else "Single",
style = MaterialTheme.typography.labelSmall,
)
},
)
}
// Column preview
Text(
workspace.columns.joinToString(", ") { it.typeKey },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Active indicator
if (isActive) {
Icon(Icons.Default.Check, "Active", Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, "Edit", Modifier.size(18.dp))
}
IconButton(onClick = onDelete, enabled = canDelete) {
Icon(Icons.Default.Delete, "Delete", Modifier.size(18.dp))
}
}
}
}
```
### Phase 2: Workspace Editor Dialog
**Files modified:** new composable in `AppDrawer.kt` (or extract to `WorkspaceEditorDialog.kt` if large)
```kotlin
@Composable
fun WorkspaceEditorDialog(
initial: Workspace?,
onSave: (Workspace) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var iconName by remember { mutableStateOf(initial?.iconName ?: "Home") }
var layoutMode by remember { mutableStateOf(initial?.layoutMode ?: LayoutMode.DECK) }
var columns by remember {
mutableStateOf(initial?.columns ?: listOf(Workspace.WorkspaceColumn("home")))
}
var singlePaneScreen by remember { mutableStateOf(initial?.singlePaneScreen) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (initial != null) "Edit Workspace" else "New Workspace") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Icon picker (grid of Material icons)
Text("Icon", style = MaterialTheme.typography.labelMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
WorkspaceIcons.availableNames.forEach { iName ->
IconButton(onClick = { iconName = iName }) {
Icon(
WorkspaceIcons.resolve(iName), iName,
tint = if (iName == iconName)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Layout mode toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Layout:", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.width(8.dp))
SingleChoiceSegmentedButtonRow {
SegmentedButton(
selected = layoutMode == LayoutMode.SINGLE_PANE,
onClick = { layoutMode = LayoutMode.SINGLE_PANE },
shape = SegmentedButtonDefaults.itemShape(0, 2),
) { Text("Single Pane") }
SegmentedButton(
selected = layoutMode == LayoutMode.DECK,
onClick = { layoutMode = LayoutMode.DECK },
shape = SegmentedButtonDefaults.itemShape(1, 2),
) { Text("Deck") }
}
}
// Deck: column list
if (layoutMode == LayoutMode.DECK) {
DeckColumnEditor(columns = columns, onColumnsChange = { columns = it })
}
// Single Pane: screen picker
if (layoutMode == LayoutMode.SINGLE_PANE) {
SinglePaneScreenPicker(
selected = singlePaneScreen,
onSelect = { singlePaneScreen = it },
)
}
}
},
confirmButton = {
Button(
onClick = {
onSave(
Workspace(
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
name = name.ifBlank { "Untitled" },
iconName = iconName,
layoutMode = layoutMode,
columns = columns,
singlePaneScreen = singlePaneScreen,
),
)
},
enabled = name.isNotBlank(),
) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
```
#### DeckColumnEditor (add/remove columns from LAUNCHABLE_SCREENS)
```kotlin
@Composable
private fun DeckColumnEditor(
columns: List<Workspace.WorkspaceColumn>,
onColumnsChange: (List<Workspace.WorkspaceColumn>) -> Unit,
) {
Column {
Text("Columns", style = MaterialTheme.typography.labelMedium)
columns.forEachIndexed { idx, col ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(col.typeKey, Modifier.weight(1f))
IconButton(onClick = {
onColumnsChange(columns.toMutableList().apply { removeAt(idx) })
}) { Icon(Icons.Default.Close, "Remove") }
}
}
// Add column dropdown
var expanded by remember { mutableStateOf(false) }
TextButton(onClick = { expanded = true }) { Text("+ Add Column") }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen ->
DropdownMenuItem(
text = { Text(screen.title()) },
onClick = {
onColumnsChange(columns + Workspace.WorkspaceColumn(screen.typeKey()))
expanded = false
},
)
}
}
}
}
```
### Phase 3: Unified Search Results
When `state.isSearching` is true, replace the tab content with a unified results view:
```kotlin
// In AppDrawer content area
if (state.isSearching) {
UnifiedSearchResults(state, workspaceManager, onSwitchWorkspace, onSelectScreen, onDismiss)
} else {
when (state.activeTab) {
DrawerTab.SCREENS -> DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss)
DrawerTab.WORKSPACES -> WorkspacesGrid(state, workspaceManager, onSwitchWorkspace, onDismiss)
}
}
```
```kotlin
@Composable
private fun UnifiedSearchResults(
state: AppDrawerState,
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Workspace) -> Unit,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
val activeIndex by workspaceManager.activeIndex.collectAsState()
val workspaces by workspaceManager.workspaces.collectAsState()
LazyColumn(Modifier.padding(8.dp)) {
// Workspace results first
if (state.filteredWorkspaces.isNotEmpty()) {
stickyHeader { Text("Workspaces", style = MaterialTheme.typography.titleSmall, ...) }
items(state.filteredWorkspaces) { ws ->
val wsIdx = workspaces.indexOf(ws)
WorkspaceCard(
workspace = ws,
isActive = wsIdx == activeIndex,
onSwitch = {
val switched = workspaceManager.switchTo(wsIdx)
if (switched != null) { onSwitchWorkspace(switched); onDismiss() }
},
onEdit = { /* open editor */ },
onDelete = { workspaceManager.deleteWorkspace(ws.id) },
canDelete = workspaces.size > 1,
)
}
}
// Screen results below
if (state.filteredScreens.isNotEmpty()) {
stickyHeader { Text("Screens", style = MaterialTheme.typography.titleSmall, ...) }
// Render as FlowRow grid per existing pattern
}
}
}
```
### Phase 4: Layout Mode Auto-Switching
**Files modified:** `Main.kt`, `AppDrawer.kt`
Current problem: `onSwitchWorkspace` in Main.kt only calls `deckState.loadFromWorkspace()`. It ignores the workspace's `layoutMode`.
#### Fix: Add `onLayoutModeChange` callback to `AppDrawer`
```kotlin
// AppDrawer signature change
@Composable
fun AppDrawer(
openColumnTypes: Set<String>,
pinnedNavBarState: PinnedNavBarState,
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Workspace) -> Unit, // handles layout mode + columns
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
)
```
No signature change needed — the `Workspace` already carries `layoutMode`. Fix the callback in Main.kt:
```kotlin
// Main.kt — inside AppDrawer call
onSwitchWorkspace = { ws ->
// Switch layout mode
layoutMode = ws.layoutMode
DesktopPreferences.layoutMode = ws.layoutMode.name
// Load columns or single pane screen
when (ws.layoutMode) {
LayoutMode.DECK -> deckState.loadFromWorkspace(ws.columns)
LayoutMode.SINGLE_PANE -> {
val screenKey = ws.singlePaneScreen ?: ws.columns.firstOrNull()?.typeKey ?: "home"
val type = DeckState.parseColumnTypeFromKey(screenKey)
if (type != null) singlePaneState.navigate(type)
}
}
},
```
**Note:** `DeckState.parseColumnType` is currently `private`. Need to expose a public helper:
```kotlin
// DeckState companion
fun parseColumnTypeFromKey(typeKey: String, param: String? = null): DeckColumnType? {
return parseColumnType(mapOf("type" to typeKey, "param" to param))
}
```
### Phase 5: Cmd+S Save Current Layout
**Files modified:** `Main.kt` (MenuBar)
Add to File menu:
```kotlin
Item(
"Save as Workspace",
shortcut = if (isMacOS) KeyShortcut(Key.S, meta = true)
else KeyShortcut(Key.S, ctrl = true),
onClick = {
val columns = deckState.columns.value.map { col ->
Workspace.WorkspaceColumn(
typeKey = col.type.typeKey(),
param = when (col.type) {
is DeckColumnType.Hashtag -> col.type.tag
is DeckColumnType.Editor -> col.type.draftSlug
is DeckColumnType.Article -> col.type.addressTag
else -> null
},
width = col.width,
)
}
val ws = Workspace(
name = "Workspace ${workspaceManager.workspaces.value.size + 1}",
iconName = "Star",
layoutMode = layoutMode,
columns = columns,
singlePaneScreen = if (layoutMode == LayoutMode.SINGLE_PANE)
singlePaneState.currentScreen.value.typeKey() else null,
)
workspaceManager.addWorkspace(ws)
// Optionally show editor dialog for naming
showWorkspaceEditor = true
pendingWorkspace = ws
},
enabled = workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES,
)
```
### Phase 6: Enter on Workspace in Search
Keyboard navigation in unified search must span both workspace and screen results. The `selectedIndex` in `AppDrawerState` indexes a combined list.
```kotlin
// In AppDrawerState
fun selectCurrent(
onSelectScreen: (DeckColumnType) -> Unit,
onSwitchWorkspace: (Workspace) -> Unit,
onDismiss: () -> Unit,
) {
if (consumed) return
if (!isSearching) {
// Tab-based: only screens use Enter
filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) }
return
}
// Unified: workspaces first, then screens
val wsCount = filteredWorkspaces.size
if (selectedIndex < wsCount) {
consumed = true
onSwitchWorkspace(filteredWorkspaces[selectedIndex])
onDismiss()
} else {
val screenIdx = selectedIndex - wsCount
filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) }
}
}
```
## File Change Summary
| File | Changes |
|------|---------|
| `AppDrawer.kt` | Add `DrawerTab` enum, refactor `AppDrawerState` for tabs+unified search, add `WorkspacesGrid`, `WorkspaceCard`, `AddWorkspaceCard`, `UnifiedSearchResults`, remove `WorkspaceBar` |
| `AppDrawer.kt` or new `WorkspaceEditorDialog.kt` | `WorkspaceEditorDialog`, `DeckColumnEditor`, `SinglePaneScreenPicker` |
| `Main.kt` | Fix `onSwitchWorkspace` to handle `layoutMode` change, add Cmd+S menu item |
| `DeckState.kt` | Expose `parseColumnTypeFromKey()` as public companion fun |
| `SinglePaneState.kt` | No changes needed (already has `navigate()`) |
| `Workspace.kt` | No changes needed |
| `WorkspaceManager.kt` | No changes needed |
| `DesktopPreferences.kt` | No changes needed |
## Implementation Order
1. **Phase 4 first** — Fix layoutMode on workspace switch (critical bug fix, smallest change)
2. **Phase 1a-1b** — Tab system in AppDrawerState
3. **Phase 1c-1d** — Workspace cards (replace WorkspaceBar)
4. **Phase 2** — Workspace editor dialog
5. **Phase 3** — Unified search
6. **Phase 6** — Keyboard navigation for unified search
7. **Phase 5** — Cmd+S save
## Acceptance Criteria
- [ ] App Drawer shows two tabs: "Screens" and "Workspaces" when search is empty
- [ ] Workspaces tab shows cards with: icon, name, layout mode badge, column preview, active indicator, edit/delete buttons
- [ ] "+" card at end of workspaces list creates new workspace via editor dialog
- [ ] Workspace editor dialog allows: name, icon picker, layout mode toggle, column selection (Deck) or screen picker (Single Pane)
- [ ] Typing in search bar shows unified results: workspaces at top (larger cards), screens below
- [ ] Unified search works regardless of active tab
- [ ] Enter on workspace result switches to that workspace
- [ ] Enter on screen result opens/adds that screen (existing behavior)
- [ ] Workspace switch changes `layoutMode` and persists it to `DesktopPreferences`
- [ ] Workspace switch in Deck mode loads columns via `deckState.loadFromWorkspace()`
- [ ] Workspace switch in Single Pane mode navigates via `singlePaneState.navigate()`
- [ ] Cmd+S (Ctrl+S on Linux/Windows) saves current layout as new workspace
- [ ] Delete button disabled when only one workspace remains
- [ ] Keyboard up/down navigation works in unified search results (spanning workspace + screen items)
- [ ] `DeckState.parseColumnTypeFromKey()` exposed as public companion function
- [ ] `WorkspaceBar` removed from bottom of drawer
- [ ] `spotlessApply` passes
## Unanswered Questions
- Should Cmd+S immediately save with auto-generated name, or open editor dialog for naming? (Plan assumes: save then optionally open editor)
- Should "Save current" button also appear in Workspaces tab, or just Cmd+S? (Brainstorm says both)
- Should we support Hashtag columns in workspace editor? They require input — may need inline text field per hashtag column
---
## Deepening Review
### P1: Bugs / Phantom APIs / Impossible Patterns
1. **`SegmentedButton` / `SingleChoiceSegmentedButtonRow` availability** — These are Material3 experimental APIs. Must annotate with `@OptIn(ExperimentalMaterial3Api::class)`. Verified they exist in M3 1.2+ which this project uses. **Fix: Add opt-in annotation to `WorkspaceEditorDialog`.**
2. **`AssistChip` in `WorkspaceCard`** — `AssistChip` requires `onClick` parameter, but we're using it as a read-only badge. Should use `SuggestionChip` instead, or just a `Surface` with `Text` for a non-interactive badge. **Fix: Replace with a styled `Surface` + `Text` badge:**
```kotlin
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Text(
if (workspace.layoutMode == LayoutMode.DECK) "Deck" else "Single",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
)
}
```
3. **`DeckState.parseColumnTypeFromKey` exposure** — The plan says "expose as public companion fun" but `parseColumnType` takes `Map<String, Any?>`. The new public wrapper correctly wraps it. No issue, but ensure the wrapper handles the `param` for Hashtag columns (e.g., `parseColumnTypeFromKey("hashtag", "bitcoin")`). **Verified: the existing private `parseColumnType` already handles this correctly.**
4. **`filteredWorkspaces` reads `workspaceManager.workspaces.value` directly** — This is a `StateFlow.value` snapshot inside a `derivedStateOf`. It won't recompose when workspaces change because `derivedStateOf` doesn't track `StateFlow`. **Fix: `AppDrawerState` must accept the workspace list as a parameter from a `collectAsState()` call, or the `derivedStateOf` must be computed at the composable level.** Best approach: pass `workspaces: List<Workspace>` into state methods instead of reading `.value`:
```kotlin
// At composable level:
val allWorkspaces by workspaceManager.workspaces.collectAsState()
// Then filter in derivedStateOf based on that snapshot:
val filteredWorkspaces by remember(allWorkspaces, state.searchQuery) {
derivedStateOf {
if (state.searchQuery.isBlank()) allWorkspaces
else allWorkspaces.filter { ws -> ws.name.contains(state.searchQuery, ignoreCase = true) }
}
}
```
This is a **critical reactivity bug** — without this fix, workspace CRUD won't update the drawer.
5. **Cmd+S conflict risk** — No existing Cmd+S shortcut in the app. However, Cmd+S is universally expected to "save" in text fields / editors. If the user is typing in the Editor column's compose area, Cmd+S would trigger "Save as Workspace" instead of saving the note draft. **Mitigation:** Use `Cmd+Shift+S` instead, which is the standard "Save As" shortcut and avoids conflicts. Or scope Cmd+S to only fire when no text field has focus. **Recommendation: Change to `Cmd+Shift+S`.**
### P2: Over-engineering / YAGNI / Simplification
1. **`showWorkspaceEditor` + `pendingWorkspace` state in Main.kt for Cmd+S** — This adds two state vars to Main.kt just for the save flow. Simpler: Cmd+S saves immediately with auto-generated name, shows a snackbar with "Undo" / "Rename" action. Defer editor-on-save to a polish pass.
2. **`SinglePaneScreenPicker` in editor** — For v1, a simple dropdown of `LAUNCHABLE_SCREENS` is sufficient. No need for a fancy picker component.
3. **Unified search keyboard navigation spanning workspace+screen results** — The combined index approach works but adds complexity. For v1, consider: arrow keys only work within the active tab or within the active section of unified results. Simpler: treat workspaces and screens as two separate groups, Tab key switches between groups.
4. **`DeckColumnEditor` drag-to-reorder** — Brainstorm explicitly defers this. Good.
### P3: Style / Naming / Minor
1. **`DrawerTab` enum** — Consider naming `AppDrawerTab` to avoid collision with `Tab` composable in imports.
2. **`totalResultCount`** — Used for bounds checking but never displayed. Name could be `resultCount`.
3. **Column preview shows raw `typeKey`** — e.g., "home, notifications, messages". Should show display names. Fix: use `DeckState.parseColumnTypeFromKey(col.typeKey)?.title() ?: col.typeKey` for human-readable names.
4. **`WorkspaceEditorDialog` size** — `AlertDialog` may be too small for the icon picker + column editor. Consider using a custom `Dialog` with fixed size instead.
@@ -0,0 +1,826 @@
---
title: "feat: Workspaces (v1c)"
type: feat
status: active
date: 2026-04-17
origin: docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md
---
# Workspaces (v1c)
## Overview
Named layout presets users can create, switch between, and customize. Each workspace stores a layout mode (Deck/SinglePane) + column configuration. Switching destroys current layout and rebuilds from saved config. Max 9 workspaces (Cmd+K then 1-9).
## Problem Statement
Users have different workflows (social browsing, writing, reading) that require different column layouts. Currently they must manually add/remove columns each time they want to change context. No way to save or recall a layout configuration.
## Proposed Solution
Workspace system with:
- Default presets: Social (Deck: Home+Notifs+DMs), Writing (Deck: Editor+Drafts+Reads), Reading (SinglePane: Reads)
- Custom workspace creation: Material icon + name + layout mode + column config
- Workspace picker in App Drawer footer (Cmd+K opens drawer, bottom shows workspaces)
- Cmd+K then 1-9 quick-switches to workspace N
- Edit/delete workspaces (min 1 must remain)
- Persistence via DesktopPreferences + Jackson JSON
## Technical Approach
### Architecture
```
Main.kt (Window level)
├── workspaceManager: WorkspaceManager (created once, persisted)
├── deckState: DeckState (rebuilt on workspace switch)
├── singlePaneState: SinglePaneState (rebuilt on workspace switch)
├── layoutMode: LayoutMode (set by active workspace)
└── App()
├── if (showAppDrawer) AppDrawer(...)
│ └── WorkspaceBar (footer) ← NEW
│ ├── workspace chips (icon + name, click to switch)
│ ├── "+" button → WorkspaceEditorDialog
│ └── long-press/right-click → edit/delete
└── MainContent()
├── SINGLE_PANE → SinglePaneLayout(singlePaneState)
└── DECK → DeckLayout(deckState)
```
### Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| WorkspaceManager at Window level (not App) | Survives key() app rebuilds, same level as deckState |
| Destroy+rebuild on switch | No background state = simpler, no memory bloat |
| Jackson JSON for persistence | Matches existing DeckState.save()/load() pattern |
| Workspace stores column type keys + widths | Reuses DeckState serialization format exactly |
| Max 9 workspaces | Matches Cmd+K+1-9 shortcut range |
| Material icons stored as string name | Serializable, resolve at render time via reflection/map |
### Data Model
```kotlin
// desktopApp/.../ui/deck/Workspace.kt
data class Workspace(
val id: String = java.util.UUID.randomUUID().toString(),
val name: String,
val iconName: String, // Material icon name, e.g. "Groups", "Edit", "MenuBook"
val layoutMode: LayoutMode,
val columns: List<WorkspaceColumn>, // only used in DECK mode
val singlePaneScreen: String? = null, // typeKey for SINGLE_PANE mode
) {
data class WorkspaceColumn(
val typeKey: String,
val param: String? = null,
val width: Float = 400f,
)
}
// Icon registry — map icon names to ImageVectors
object WorkspaceIcons {
private val icons: Map<String, ImageVector> = mapOf(
"Groups" to Icons.Default.Groups,
"Edit" to Icons.Default.Edit,
"MenuBook" to Icons.Default.MenuBook,
"Home" to Icons.Default.Home,
"Chat" to Icons.Default.Chat,
"Search" to Icons.Default.Search,
"SportsEsports" to Icons.Default.SportsEsports,
"Bookmark" to Icons.Default.Bookmark,
"Explore" to Icons.Default.Explore,
"Person" to Icons.Default.Person,
"Star" to Icons.Default.Star,
"Favorite" to Icons.Default.Favorite,
"Work" to Icons.Default.Work,
"Code" to Icons.Default.Code,
)
val availableNames: List<String> = icons.keys.sorted()
fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Groups
}
```
### WorkspaceManager
```kotlin
// desktopApp/.../ui/deck/WorkspaceManager.kt
class WorkspaceManager(
private val saveScope: CoroutineScope,
) {
private val mapper = jacksonObjectMapper()
private val _workspaces = MutableStateFlow(DEFAULT_WORKSPACES)
val workspaces: StateFlow<List<Workspace>> = _workspaces.asStateFlow()
private val _activeIndex = MutableStateFlow(0)
val activeIndex: StateFlow<Int> = _activeIndex.asStateFlow()
val activeWorkspace: Workspace
get() = _workspaces.value[_activeIndex.value.coerceIn(_workspaces.value.indices)]
fun switchTo(
index: Int,
deckState: DeckState,
singlePaneState: SinglePaneState,
onLayoutModeChanged: (LayoutMode) -> Unit,
) {
if (index !in _workspaces.value.indices) return
// Save current workspace's column state before switching
saveCurrentState(deckState, singlePaneState)
_activeIndex.value = index
val workspace = _workspaces.value[index]
// Apply workspace config
onLayoutModeChanged(workspace.layoutMode)
applyWorkspace(workspace, deckState, singlePaneState)
scheduleSave()
}
private fun saveCurrentState(deckState: DeckState, singlePaneState: SinglePaneState) {
val idx = _activeIndex.value
val current = _workspaces.value[idx]
val updated = when (current.layoutMode) {
LayoutMode.DECK -> current.copy(
columns = deckState.columns.value.map { col ->
Workspace.WorkspaceColumn(
typeKey = col.type.typeKey(),
param = when (col.type) {
is DeckColumnType.Profile -> col.type.pubKeyHex
is DeckColumnType.Thread -> col.type.noteId
is DeckColumnType.Hashtag -> col.type.tag
else -> null
},
width = col.width,
)
},
)
LayoutMode.SINGLE_PANE -> current.copy(
singlePaneScreen = singlePaneState.currentScreen.value.typeKey(),
)
}
_workspaces.update { list ->
list.toMutableList().also { it[idx] = updated }
}
}
private fun applyWorkspace(workspace: Workspace, deckState: DeckState, singlePaneState: SinglePaneState) {
when (workspace.layoutMode) {
LayoutMode.DECK -> {
// Rebuild DeckState from workspace columns
deckState.loadFromWorkspace(workspace.columns)
}
LayoutMode.SINGLE_PANE -> {
val typeKey = workspace.singlePaneScreen ?: "home"
val type = DeckState.parseTypeKey(typeKey)
if (type != null) singlePaneState.navigate(type)
}
}
}
fun addWorkspace(workspace: Workspace): Boolean {
if (_workspaces.value.size >= MAX_WORKSPACES) return false
_workspaces.update { it + workspace }
scheduleSave()
return true
}
fun updateWorkspace(id: String, name: String, iconName: String) {
_workspaces.update { list ->
list.map { if (it.id == id) it.copy(name = name, iconName = iconName) else it }
}
scheduleSave()
}
fun deleteWorkspace(id: String) {
if (_workspaces.value.size <= 1) return
val idx = _workspaces.value.indexOfFirst { it.id == id }
if (idx < 0) return
_workspaces.update { it.filter { w -> w.id != id } }
if (_activeIndex.value >= _workspaces.value.size) {
_activeIndex.value = _workspaces.value.size - 1
}
scheduleSave()
}
// -- Persistence --
private var saveJob: Job? = null
private fun scheduleSave() {
saveJob?.cancel()
saveJob = saveScope.launch {
delay(500L)
save()
}
}
fun save() {
try {
val data = mapOf(
"activeIndex" to _activeIndex.value,
"workspaces" to _workspaces.value.map { ws ->
mapOf(
"id" to ws.id,
"name" to ws.name,
"iconName" to ws.iconName,
"layoutMode" to ws.layoutMode.name,
"singlePaneScreen" to ws.singlePaneScreen,
"columns" to ws.columns.map { col ->
mapOf(
"typeKey" to col.typeKey,
"param" to col.param,
"width" to col.width,
)
},
)
},
)
DesktopPreferences.workspaces = mapper.writeValueAsString(data)
} catch (e: Exception) {
println("WorkspaceManager: failed to save: ${e.message}")
}
}
fun load() {
try {
val json = DesktopPreferences.workspaces
if (json.isBlank()) return
val data: Map<String, Any?> = mapper.readValue(json)
val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0
val wsList = (data["workspaces"] as? List<Map<String, Any?>>)?.mapNotNull { entry ->
val id = entry["id"] as? String ?: return@mapNotNull null
val name = entry["name"] as? String ?: return@mapNotNull null
val iconName = entry["iconName"] as? String ?: "Groups"
val layoutMode = try {
LayoutMode.valueOf(entry["layoutMode"] as? String ?: "DECK")
} catch (e: Exception) { LayoutMode.DECK }
val singlePaneScreen = entry["singlePaneScreen"] as? String
val columns = (entry["columns"] as? List<Map<String, Any?>>)?.mapNotNull { col ->
val typeKey = col["typeKey"] as? String ?: return@mapNotNull null
Workspace.WorkspaceColumn(
typeKey = typeKey,
param = col["param"] as? String,
width = (col["width"] as? Number)?.toFloat() ?: 400f,
)
} ?: emptyList()
Workspace(id, name, iconName, layoutMode, columns, singlePaneScreen)
} ?: return
if (wsList.isNotEmpty()) {
_workspaces.value = wsList
_activeIndex.value = activeIdx.coerceIn(wsList.indices)
}
} catch (e: Exception) {
println("WorkspaceManager: failed to load: ${e.message}")
}
}
companion object {
const val MAX_WORKSPACES = 9
val DEFAULT_WORKSPACES = listOf(
Workspace(
id = "default-social",
name = "Social",
iconName = "Groups",
layoutMode = LayoutMode.DECK,
columns = listOf(
Workspace.WorkspaceColumn("home"),
Workspace.WorkspaceColumn("notifications"),
Workspace.WorkspaceColumn("messages"),
),
),
Workspace(
id = "default-writing",
name = "Writing",
iconName = "Edit",
layoutMode = LayoutMode.DECK,
columns = listOf(
Workspace.WorkspaceColumn("editor"),
Workspace.WorkspaceColumn("drafts"),
Workspace.WorkspaceColumn("reads"),
),
),
Workspace(
id = "default-reading",
name = "Reading",
iconName = "MenuBook",
layoutMode = LayoutMode.SINGLE_PANE,
columns = emptyList(),
singlePaneScreen = "reads",
),
)
}
}
```
### DeckState Changes
Add `loadFromWorkspace()` and expose `parseTypeKey()`:
```kotlin
// DeckState.kt — additions
fun loadFromWorkspace(columns: List<Workspace.WorkspaceColumn>) {
val loaded = columns.mapNotNull { col ->
val type = parseColumnType(
mapOf("type" to col.typeKey, "param" to col.param)
) ?: return@mapNotNull null
DeckColumn(
type = type,
width = col.width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH),
)
}
_columns.value = if (loaded.isNotEmpty()) loaded else DEFAULT_COLUMNS
_focusedColumnIndex.value = 0
}
companion object {
// Make parseColumnType accessible for WorkspaceManager
fun parseTypeKey(typeKey: String, param: String? = null): DeckColumnType? {
return parseColumnType(mapOf("type" to typeKey, "param" to param))
}
}
```
### DesktopPreferences Changes
```kotlin
// DesktopPreferences.kt — add workspace key
private const val KEY_WORKSPACES = "workspaces"
var workspaces: String
get() = prefs.get(KEY_WORKSPACES, "")
set(value) { prefs.put(KEY_WORKSPACES, value) }
```
### Workspace Switching in Main.kt
```kotlin
// Main.kt — Window level, alongside existing deckState
val workspaceManager = remember {
WorkspaceManager(deckScope).also { it.load() }
}
// On first load, apply active workspace to deckState + layoutMode
LaunchedEffect(Unit) {
val ws = workspaceManager.activeWorkspace
layoutMode = ws.layoutMode
DesktopPreferences.layoutMode = ws.layoutMode.name
// deckState.load() already ran — only override if workspace data exists
if (DesktopPreferences.workspaces.isNotBlank()) {
workspaceManager.switchTo(
workspaceManager.activeIndex.value,
deckState, singlePaneState,
onLayoutModeChanged = { mode ->
layoutMode = mode
DesktopPreferences.layoutMode = mode.name
},
)
}
}
// Pass to AppDrawer
if (showAppDrawer) {
AppDrawer(
openColumnTypes = ...,
onSelectScreen = { ... },
onDismiss = onDismissAppDrawer,
workspaceManager = workspaceManager, // NEW
onSwitchWorkspace = { index -> // NEW
workspaceManager.switchTo(
index, deckState, singlePaneState,
onLayoutModeChanged = { mode ->
layoutMode = mode
DesktopPreferences.layoutMode = mode.name
},
)
showAppDrawer = false
},
)
}
```
### App Drawer — Workspace Bar (footer)
```kotlin
// AppDrawer.kt — add WorkspaceBar at bottom of drawer Surface
@Composable
fun AppDrawer(
openColumnTypes: Set<String>,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Int) -> Unit,
) {
// ... existing state, scrim, Surface ...
Column {
// Search TextField (existing)
TextField(...)
// Screen grid (existing) — weight(1f) to push workspace bar to bottom
if (state.awaitingHashtag) {
HashtagInputSection(...)
} else {
Box(Modifier.weight(1f)) {
DrawerGrid(...)
}
}
// Workspace bar — footer
HorizontalDivider()
WorkspaceBar(
workspaceManager = workspaceManager,
onSwitchWorkspace = onSwitchWorkspace,
onDismiss = onDismiss,
)
}
}
@Composable
private fun WorkspaceBar(
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val workspaces by workspaceManager.workspaces.collectAsState()
val activeIndex by workspaceManager.activeIndex.collectAsState()
var showEditor by remember { mutableStateOf(false) }
var editingWorkspace by remember { mutableStateOf<Workspace?>(null) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Workspaces",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
workspaces.forEachIndexed { index, workspace ->
WorkspaceChip(
workspace = workspace,
isActive = index == activeIndex,
index = index + 1, // 1-based for shortcut hint
onClick = { onSwitchWorkspace(index) },
onEditRequest = { editingWorkspace = workspace },
)
}
if (workspaces.size < WorkspaceManager.MAX_WORKSPACES) {
IconButton(
onClick = { showEditor = true },
modifier = Modifier.size(32.dp),
) {
Icon(Icons.Default.Add, "Add workspace", Modifier.size(18.dp))
}
}
}
// Editor dialog
if (showEditor || editingWorkspace != null) {
WorkspaceEditorDialog(
existing = editingWorkspace,
onSave = { name, iconName, layoutMode ->
if (editingWorkspace != null) {
workspaceManager.updateWorkspace(editingWorkspace!!.id, name, iconName)
} else {
workspaceManager.addWorkspace(
Workspace(
name = name,
iconName = iconName,
layoutMode = layoutMode,
columns = if (layoutMode == LayoutMode.DECK) {
DeckState.DEFAULT_COLUMNS.map {
Workspace.WorkspaceColumn(it.type.typeKey())
}
} else emptyList(),
singlePaneScreen = if (layoutMode == LayoutMode.SINGLE_PANE) "home" else null,
)
)
}
showEditor = false
editingWorkspace = null
},
onDelete = editingWorkspace?.let { ws ->
if (workspaces.size > 1) {
{ workspaceManager.deleteWorkspace(ws.id); editingWorkspace = null }
} else null
},
onDismiss = { showEditor = false; editingWorkspace = null },
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun WorkspaceChip(
workspace: Workspace,
isActive: Boolean,
index: Int,
onClick: () -> Unit,
onEditRequest: () -> Unit,
) {
Surface(
modifier = Modifier
.clickable(onClick = onClick)
.onPointerEvent(PointerEventType.Press) { event ->
// Right-click / secondary button → edit
if (event.button?.isSecondary == true) onEditRequest()
},
shape = RoundedCornerShape(8.dp),
tonalElevation = if (isActive) 8.dp else 2.dp,
color = if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
WorkspaceIcons.resolve(workspace.iconName),
workspace.name,
modifier = Modifier.size(16.dp),
)
Text(
workspace.name,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
// Shortcut hint
Text(
"$index",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
}
}
```
### Workspace Editor Dialog
```kotlin
// AppDrawer.kt or new WorkspaceEditorDialog.kt
@Composable
private fun WorkspaceEditorDialog(
existing: Workspace?,
onSave: (name: String, iconName: String, layoutMode: LayoutMode) -> Unit,
onDelete: (() -> Unit)?,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(existing?.name ?: "") }
var selectedIcon by remember { mutableStateOf(existing?.iconName ?: "Groups") }
var layoutMode by remember { mutableStateOf(existing?.layoutMode ?: LayoutMode.DECK) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (existing != null) "Edit Workspace" else "New Workspace") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Icon picker — grid of available icons
Text("Icon", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
WorkspaceIcons.availableNames.forEach { iconName ->
IconButton(
onClick = { selectedIcon = iconName },
modifier = Modifier.size(36.dp),
) {
Icon(
WorkspaceIcons.resolve(iconName),
iconName,
modifier = Modifier.size(20.dp),
tint = if (iconName == selectedIcon) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
// Layout mode toggle (only for new workspaces)
if (existing == null) {
Text("Layout", style = MaterialTheme.typography.labelMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = layoutMode == LayoutMode.DECK,
onClick = { layoutMode = LayoutMode.DECK },
label = { Text("Deck") },
)
FilterChip(
selected = layoutMode == LayoutMode.SINGLE_PANE,
onClick = { layoutMode = LayoutMode.SINGLE_PANE },
label = { Text("Single Pane") },
)
}
}
}
},
confirmButton = {
Button(
onClick = { onSave(name, selectedIcon, layoutMode) },
enabled = name.isNotBlank(),
) { Text("Save") }
},
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (onDelete != null) {
TextButton(onClick = onDelete) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
```
### Keyboard Shortcut: Cmd+K then 1-9
```kotlin
// AppDrawer.kt — add to onPreviewKeyEvent block
// Inside the existing onPreviewKeyEvent handler:
Key.One -> { onSwitchWorkspace(0); true }
Key.Two -> { onSwitchWorkspace(1); true }
Key.Three -> { onSwitchWorkspace(2); true }
Key.Four -> { onSwitchWorkspace(3); true }
Key.Five -> { onSwitchWorkspace(4); true }
Key.Six -> { onSwitchWorkspace(5); true }
Key.Seven -> { onSwitchWorkspace(6); true }
Key.Eight -> { onSwitchWorkspace(7); true }
Key.Nine -> { onSwitchWorkspace(8); true }
```
Number keys only fire when the search field is empty (check `state.searchQuery.isBlank()`) to avoid blocking number input in search.
### Implementation Phases
#### Phase 1: Data Model + WorkspaceManager + Persistence
**Files:**
- **New:** `desktopApp/.../ui/deck/Workspace.kt``Workspace` data class, `WorkspaceIcons` object
- **New:** `desktopApp/.../ui/deck/WorkspaceManager.kt` — state management, save/load
- **Modify:** `desktopApp/.../DesktopPreferences.kt` — add `workspaces` key
- **Modify:** `desktopApp/.../ui/deck/DeckState.kt` — add `loadFromWorkspace()`, expose `parseTypeKey()`
**Tests:**
- WorkspaceManager save/load round-trip
- WorkspaceManager.switchTo() correctness
- Max workspace limit enforced
- Delete last workspace prevented
- Default workspaces created on first load
#### Phase 2: Workspace Bar UI in App Drawer
**Files:**
- **Modify:** `desktopApp/.../ui/deck/AppDrawer.kt` — add WorkspaceBar footer, WorkspaceChip, WorkspaceEditorDialog
- **Modify:** `AppDrawer` signature — add `workspaceManager` + `onSwitchWorkspace` params
**Tests:**
- Manual: workspace chips render correctly
- Manual: active workspace highlighted
- Manual: "+" button shows editor dialog
#### Phase 3: Wire into Main.kt + Keyboard Shortcuts
**Files:**
- **Modify:** `desktopApp/.../Main.kt` — create WorkspaceManager, pass to AppDrawer, handle switchTo
- **Modify:** `desktopApp/.../ui/deck/AppDrawer.kt` — add 1-9 key handling in onPreviewKeyEvent
**Tests:**
- Manual: Cmd+K opens drawer, pressing 1/2/3 switches workspace
- Manual: layout mode changes on switch
- Manual: columns rebuild on switch
- Persistence survives app restart
#### Phase 4: Auto-save Current Workspace on Column Changes
**Files:**
- **Modify:** `desktopApp/.../Main.kt` — LaunchedEffect on deckState.columns to auto-save active workspace
- **Modify:** `desktopApp/.../ui/deck/WorkspaceManager.kt` — add `updateCurrentColumns()` for live tracking
```kotlin
// Main.kt — auto-save column changes to active workspace
val currentColumns by deckState.columns.collectAsState()
LaunchedEffect(currentColumns) {
if (layoutMode == LayoutMode.DECK) {
workspaceManager.saveCurrentState(deckState, singlePaneState)
}
}
```
#### Phase 5: Migration + Backward Compatibility
- If `DesktopPreferences.workspaces` is empty but `DesktopPreferences.deckColumns` has data:
- Create "Social" workspace from existing deck columns
- Set as active workspace
- Existing layout preserved on first upgrade
```kotlin
// WorkspaceManager.load() — migration path
fun load() {
val json = DesktopPreferences.workspaces
if (json.isBlank()) {
// Migrate: check if legacy deckColumns exist
val legacyJson = DesktopPreferences.deckColumns
if (legacyJson.isNotBlank()) {
// Create workspace from existing config
_workspaces.value = listOf(
Workspace(
id = "migrated-social",
name = "Social",
iconName = "Groups",
layoutMode = try {
LayoutMode.valueOf(DesktopPreferences.layoutMode)
} catch (e: Exception) { LayoutMode.DECK },
columns = emptyList(), // will be populated by DeckState.load()
),
) + DEFAULT_WORKSPACES.drop(1) // add Writing + Reading presets
save()
}
return
}
// ... normal load path
}
```
### New/Modified Files Summary
| File | Action | Description |
|------|--------|-------------|
| `ui/deck/Workspace.kt` | **New** | Data class + WorkspaceIcons |
| `ui/deck/WorkspaceManager.kt` | **New** | State, persistence, switching |
| `ui/deck/AppDrawer.kt` | **Modify** | Add WorkspaceBar footer, editor dialog, 1-9 keys |
| `ui/deck/DeckState.kt` | **Modify** | Add `loadFromWorkspace()`, expose `parseTypeKey()` |
| `DesktopPreferences.kt` | **Modify** | Add `workspaces` key |
| `Main.kt` | **Modify** | Create WorkspaceManager, wire switching, auto-save |
| `WorkspaceManagerTest.kt` | **New** | Unit tests for save/load/switch/limits |
## Acceptance Criteria
- [ ] 3 default workspaces ship on fresh install: Social, Writing, Reading
- [ ] Clicking workspace chip in App Drawer footer switches workspace
- [ ] Switching destroys current layout and rebuilds from saved config
- [ ] Layout mode (Deck/SinglePane) changes with workspace
- [ ] Cmd+K opens drawer, then 1-9 switches to workspace N
- [ ] Number keys only fire when search field is empty
- [ ] Users can create custom workspaces (name + icon + layout mode)
- [ ] Users can edit workspace name/icon (right-click chip)
- [ ] Users can delete workspaces (min 1 must remain)
- [ ] Max 9 workspaces enforced (add button hidden at limit)
- [ ] Column changes auto-saved to active workspace
- [ ] Workspaces persist across app restarts (DesktopPreferences)
- [ ] Existing users' deck column config migrated to first workspace
- [ ] Active workspace index persisted and restored
- [ ] Workspace switching < 200ms (destroy + rebuild, no animation)
## Dependencies & Risks
| Risk | Mitigation |
|------|------------|
| DeckState.load() reads from DesktopPreferences.deckColumns — conflicts with workspace persistence | WorkspaceManager owns column config; DeckState.load() only used for migration |
| Cmd+K+1-9 conflicts with typing numbers in search | Guard: only intercept digits when `searchQuery.isBlank()` |
| Users lose unsaved column changes on workspace switch | Auto-save via LaunchedEffect on deckState.columns |
| Jackson string limit in Preferences API (8192 bytes per key) | 9 workspaces with 5 columns each ~ 3KB JSON, well within limit |
| Right-click detection on different platforms | Use `onPointerEvent(PointerEventType.Press)` with button check; fall back to long-press if needed |
| WorkspaceManager + DeckState both calling save() | DeckState continues saving to `deckColumns` for backward compat; WorkspaceManager saves to `workspaces` key |
## Open Questions
- Should workspace switching have a brief crossfade transition, or instant swap?
- Should new custom workspaces start empty or copy current layout?
- Should workspace chips show in the sidebar (below nav items) in addition to the drawer footer?
- Should Cmd+K then number keys require a modifier (Shift?) to disambiguate from search input?
- When editing a workspace, should users be able to change its layout mode (Deck <-> SinglePane)?
- Should there be workspace-specific relay configurations in a future version?
- Should the "Writing" default workspace include Editor with a null slug or omit it?
@@ -0,0 +1,118 @@
# Test Plan: Desktop Multi-Platform Distribution
**PR branch:** `feat/desktop-multiplatform-distribution`
**Test date:** 2026-04-17
## Pre-PR Test Matrix
### Phase 0: Version wiring (local, all platforms)
| # | Test | Command | Expected | Status |
|---|---|---|---|---|
| 0.1 | Desktop version reads from catalog | `./gradlew :desktopApp:properties \| grep "^version:"` | `version: 1.08.0` | |
| 0.2 | Android versionName reads from catalog | `./gradlew :amethyst:assembleDebug` then check `amethyst/build/outputs/apk/debug/*.apk` manifest | `versionName=1.08.0-feat-desktop-multipl` (branch suffix) | |
| 0.3 | Quartz publish version unaffected | `grep 'version =' quartz/build.gradle.kts` | `version = "1.08.0"` in `coordinates()` block (explicit, not inherited) | |
| 0.4 | RPM version strips dashes correctly | `echo '1.08.0-rc1' \| sed 's/-/~/g'` | `1.08.0~rc1` | |
### Phase 1: Local build per format (run on native platform)
| # | Test | Platform | Command | Pass criteria |
|---|---|---|---|---|
| 1.1 | macOS ARM DMG | macOS ARM (M-series) | `./gradlew :desktopApp:packageReleaseDmg` | DMG produced; `file ...Amethyst.app/Contents/MacOS/Amethyst` shows `arm64` |
| 1.2 | macOS Intel DMG | macOS Intel (or macos-13 CI) | same command | DMG produced; `file` shows `x86_64` |
| 1.3 | Windows MSI | Windows 11 | `./gradlew :desktopApp:packageReleaseMsi` | MSI produced; double-click installs to Start Menu |
| 1.4 | Linux DEB | Ubuntu 22.04+ | `./gradlew :desktopApp:packageReleaseDeb` | `.deb` produced; `dpkg-deb -I *.deb` shows `Version: 1.08.0` |
| 1.5 | Linux RPM | Ubuntu (with `apt install rpm`) | `./gradlew :desktopApp:packageReleaseRpm` | `.rpm` produced; `rpm -qpi *.rpm` shows `Version: 1.08.0`, `License: MIT` |
| 1.6 | Linux AppImage | Ubuntu 22.04+ | `./gradlew :desktopApp:createReleaseAppImage` | `.AppImage` produced; `chmod +x && ./Amethyst-*.AppImage` launches |
| 1.7 | Portable tar.gz | Linux | `./gradlew :desktopApp:createReleaseDistributable && cd .../app && tar czf amethyst.tar.gz Amethyst/` | Extract + `./Amethyst/bin/Amethyst` runs |
| 1.8 | Portable zip | Windows | `./gradlew :desktopApp:createReleaseDistributable` + zip | Extract + `Amethyst.exe` runs without JRE |
### Phase 2: VLC video playback verification
| # | Test | Platform | Steps | Pass criteria |
|---|---|---|---|---|
| 2.1 | VLC on ARM macOS | M-series Mac | Open DMG → launch → navigate to note with video → play | Video plays without crash; Console.app shows no `libvlc` errors |
| 2.2 | VLC on Intel macOS | Intel Mac | Same | Same |
| 2.3 | VLC in AppImage | Linux (no system VLC) | `./Amethyst-*.AppImage` → find video note → play | Video plays (proves `LD_LIBRARY_PATH` + `VLC_PLUGIN_PATH` correct) |
| 2.4 | VLC dylib arch check | macOS ARM build | `file desktopApp/src/jvmMain/appResources/macos/vlc/libvlc.dylib` | `Mach-O universal binary with 2 architectures: [x86_64] [arm64]` |
### Phase 3: CI workflow dry-run
| # | Test | Trigger | Expected |
|---|---|---|---|
| 3.1 | Dry-run builds all 5 desktop legs | `gh workflow run create-release.yml -f dry_run=true -f test_tag=v0.0.0-dryrun --ref feat/desktop-multiplatform-distribution` | All 5 matrix legs green; "Dry-run summary" in step output shows assets + sizes |
| 3.2 | Dry-run skips Android | Check workflow run | `deploy-android` job shows "skipped" |
| 3.3 | Dry-run skips upload | Check step logs | "Upload to GH Release" step shows "skipped" |
| 3.4 | Dry-run does NOT trigger bumps | `gh run list --workflow=bump-homebrew.yml --limit 1` | No new runs |
| 3.5 | Asset size ≤ 1 GB per asset | "Enforce asset size budget" step log | All assets show "OK: ... MB" |
| 3.6 | Tag-vs-catalog assertion skipped on dry-run | Step log | No assertion error (dry-run uses synthetic tag) |
### Phase 4: First real tag push (staging)
**Pre-req**: Push a test prerelease tag (e.g. `v1.08.0-test`) to the fork.
| # | Test | Expected |
|---|---|---|
| 4.1 | Tag-vs-catalog assertion passes | Desktop matrix starts building (tag `v1.08.0-test`, TOML `1.08.0` — assertion skips `-test` suffix mismatch? **NO** — assertion requires exact match. Must temporarily set TOML to `1.08.0-test` or use a matching tag like `v1.08.0`.) |
| 4.2 | All 5 desktop legs produce assets | GH Release has 8 desktop assets (dmg×2, msi, zip, deb, rpm, AppImage, tar.gz) |
| 4.3 | Android produces 12 assets | Same 12 APKs + 2 AABs as before |
| 4.4 | Asset naming correct | Names match `amethyst-desktop-<ver>-<family>-<arch>.<ext>` |
| 4.5 | Prerelease classified correctly | Tag `v1.08.0-test` → release marked as prerelease |
| 4.6 | Bump workflows NOT triggered | Prerelease → `release.released` event doesn't fire for bump workflows |
| 4.7 | Android asset names identical to old scheme | `amethyst-googleplay-universal-v1.08.0-test.apk` etc. — compare with previous release |
| 4.8 | Quartz publish succeeds (if secrets available) | Maven Central shows artifact OR step skipped (no secrets on fork) |
### Phase 5: Stable release simulation
**Pre-req**: Tag matching TOML exactly (e.g. `v1.08.0`).
| # | Test | Expected |
|---|---|---|
| 5.1 | Tag-vs-catalog passes | `1.08.0 == 1.08.0` |
| 5.2 | Release marked stable | `prerelease: false` |
| 5.3 | Bump-homebrew fires | Workflow starts (will fail without `HOMEBREW_TOKEN` — expected) |
| 5.4 | Bump-winget fires | Workflow starts (will fail without `WINGET_TOKEN` — expected) |
| 5.5 | Assert-stable-release passes | Composite action log shows `tag=v1.08.0 prerelease=false draft=false` |
### Phase 6: Negative tests
| # | Test | Expected |
|---|---|---|
| 6.1 | Tag mismatch (tag `v9.9.9`, TOML `1.08.0`) | Matrix jobs fail fast: `::error::gradle/libs.versions.toml app=1.08.0 but tag is v9.9.9` |
| 6.2 | Manual bump dispatch with RC tag | `gh workflow run bump-homebrew.yml -f tag=v1.08.0-rc1` → composite action rejects: `does not match stable vMAJOR.MINOR.PATCH format` |
| 6.3 | Manual bump dispatch with stable tag | `gh workflow run bump-homebrew.yml -f tag=v1.08.0` → composite action passes (bump still fails without token — expected) |
### Phase 7: Android regression
| # | Test | Comparison | Pass criteria |
|---|---|---|---|
| 7.1 | APK/AAB count | Old release vs new | Same 12 files |
| 7.2 | APK naming | Old: `amethyst-googleplay-arm64-v8a-v1.07.5.apk` | New: identical scheme (just different version number) |
| 7.3 | APK signing | `jarsigner -verify dist/amethyst-googleplay-universal-v*.apk` | "jar verified" (requires signing secrets — skip on fork) |
| 7.4 | Gradle `allprojects.version` doesn't break Android build | `./gradlew :amethyst:assembleDebug` | Successful build; no version conflicts |
---
## Test Execution Order (recommended)
1. **Phase 0** — takes 2 min, validates version wiring locally
2. **Phase 2.4** — takes 10 sec, validates VLC arch (non-blocking sanity check)
3. **Phase 1.1 or 1.4** — build one format on your native platform to validate Gradle changes
4. **Phase 3** — CI dry-run (takes ~15 min; validates entire matrix without side effects)
5. **Phase 7.4** — Android regression (local `assembleDebug` — takes ~5 min)
6. **Phase 4** — first real tag on fork (requires fork push; takes ~25 min)
7. **Phases 5, 6** — stable release + negative tests (requires fork tag pushes)
8. **Phases 1.2-1.8, 2.1-2.3** — platform-specific tests (delegate to testers with hardware)
## What Can't Be Tested Without Maintainer
| Item | Blocked on |
|---|---|
| Homebrew cask initial PR + auto-bump | `HOMEBREW_TOKEN` secret |
| Winget initial submission + auto-bump | `WINGET_TOKEN` secret |
| Quartz Maven Central publish | `SONATYPE_*` + `SIGNING_*` secrets |
| Android APK signing | `SIGNING_KEY` + `KEY_*` secrets |
| VLC on Intel Mac | Access to Intel Mac hardware |
| AppImage on Alpine/NixOS | Access to minimal Linux distro |
These require running on the upstream repo or providing secrets to the fork.
@@ -0,0 +1,607 @@
---
title: "feat: Relay Power Tools — Dashboard Screen + Compose Relay Picker"
type: feat
status: active
date: 2026-04-20
origin: docs/brainstorms/2026-04-20-relay-power-tools-brainstorm.md
deepened: 2026-04-20
---
# feat: Relay Power Tools — Dashboard Screen + Compose Relay Picker
## Enhancement Summary
**Deepened on:** 2026-04-20
**Review agents used:** compose-expert, desktop-expert, kotlin-coroutines, nostr-expert, performance-oracle, security-sentinel, architecture-strategist, code-simplicity-reviewer
### Key Improvements from Review
1. **Separate metrics from RelayStatus** — avoid StateFlow churn that caused subscription issues (March 2026 brainstorm)
2. **Kill RelayMetricsStore** — session-only metrics, no persistence (YAGNI, Preferences 8KB limit)
3. **Cut to 3 relay categories** — desktop only uses NIP-65, DM, connected today
4. **Reuse existing `publish()` API** — already accepts relay set, no new `publishTo()` needed
5. **Reuse existing `Note.addRelay()`** — relay source tracking already exists in cache layer
6. **Fix Nip11Fetcher Tor regression** — must use fail-closed HTTP client, not fail-open `getHttpClient()`
7. **Block DM fallback to all relays** — security: never fall back to all connected for DMs
8. **7 files for DeckColumnType, not 5** — add PinnedNavBarState + AppDrawer `category()`
9. **New ScreenCategory.NETWORK** — semantically correct vs IDENTITY
### Files Eliminated (YAGNI)
- `RelayMetricsStore.kt` — no UI consumes historical data
- `RelaySuggestions.kt` — deferred entirely, no demand signal
- `RelayBadge.kt` — per-event tracking overhead for nice-to-have
---
## Overview
Add full relay visibility and control to Amethyst Desktop through two features:
1. **Relay Dashboard** — New `DeckColumnType.Relays` screen with live session metrics, NIP-11 info, and per-category relay list management
2. **Compose Relay Picker** — Expandable relay section in compose dialogs for per-action relay selection
## Problem Statement / Motivation
Desktop currently has a minimal relay settings section (~70 lines in Main.kt) that only supports add/remove/reconnect with basic status cards. Users cannot:
- Monitor relay health (event counts, latency, NIP support)
- Configure per-category relay lists (NIP-65, DM)
- Choose which relays to publish to when composing notes
Gossip and noStrudel lead in relay management UX — this feature brings Amethyst Desktop ahead of both.
## Proposed Solution
Dashboard-first build order (see brainstorm: Key Decisions). Dashboard establishes shared state layer (session metrics, NIP-11, category management) that compose picker reuses.
## Technical Approach
### Architecture
```
New/Modified Files:
├── desktopApp/src/jvmMain/.../
│ ├── network/
│ │ ├── RelayStatus.kt # UNCHANGED — connection state only
│ │ ├── RelayConnectionManager.kt # MODIFY: add relayMetrics flow, 1Hz snapshot
│ │ └── Nip11Fetcher.kt # NEW: Mutex-based HTTP GET + session cache
│ ├── ui/
│ │ ├── relay/
│ │ │ ├── RelayDashboardScreen.kt # NEW: main dashboard screen
│ │ │ ├── RelayMetricsTab.kt # NEW: live metrics tab
│ │ │ ├── RelayConfigTab.kt # NEW: per-category editor tab
│ │ │ ├── RelayDetailPanel.kt # NEW: NIP-11 detail expansion
│ │ │ ├── RelayMetricCard.kt # NEW: enhanced status card w/ metrics
│ │ │ ├── RelayListEditor.kt # NEW: add/remove for a category
│ │ │ └── RelayStatusCard.kt # MODIFY: M3 colors
│ │ └── compose/
│ │ └── ComposeRelayPicker.kt # NEW: expandable relay section
│ ├── ui/deck/
│ │ ├── DeckColumnType.kt # MODIFY: add Relays
│ │ ├── ColumnHeader.kt # MODIFY: add icon
│ │ ├── DeckColumnContainer.kt # MODIFY: add routing + thread Nip11Fetcher
│ │ ├── AppDrawer.kt # MODIFY: add to LAUNCHABLE_SCREENS + category()
│ │ ├── DeckState.kt # MODIFY: add parse
│ │ └── PinnedNavBarState.kt # MODIFY: add to DEFAULT_PINNED
│ ├── Main.kt # MODIFY: create Nip11Fetcher, MenuBar item
│ └── ComposeNoteDialog.kt # MODIFY: integrate ComposeRelayPicker
```
### Design Decisions (consolidated from SpecFlow + all reviews)
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Metrics vs status | **Separate `relayMetrics` StateFlow** | Avoids relayStatuses churn (March brainstorm). Metrics update 1Hz, status updates on connect/disconnect |
| Metrics persistence | **Session-only** (no disk) | YAGNI: no UI for historical data. Preferences has 8KB limit. Add SQLite later if needed |
| Config categories | **3: NIP-65, DM, Connected** | Desktop doesn't use search/trusted/proxy/indexer/key-package relays yet |
| Save semantics | Explicit "Save" button per tab | Avoid event spam from immediate publish on every toggle |
| Compose picker default | NIP-65 write relays, fallback to all connected | Correct event visibility for followers |
| DM picker fallback | **Block send if no kind 10050** | Security: never fall back to all relays for DMs (metadata leak) |
| Selective publish | **Use existing `publish(event, relays)`** | API already exists on RelayConnectionManager |
| Relay source tracking | **Use existing `Note.addRelay()`** | Already populated in cache consume methods |
| NIP-11 fetch client | **Fail-closed** via `currentClient()` | `getHttpClient()` fails open during Tor bootstrap |
| NIP-11 response | Limit body to 256KB | Prevent DoS from malicious relay |
| Drag-to-reorder | **Cut** | Order is display-only, no functional value |
| Relay order | Alphabetical sort | Simple, deterministic |
| AppDrawer category | **New `ScreenCategory.NETWORK`** | IDENTITY is wrong (user-centric), NETWORK is infrastructure |
| Tab identity | Enum `DashboardTab` | Not magic int (CMP compatible) |
| Status colors | M3 `colorScheme.primary/error/outline` | Not hardcoded Color.Green/Red |
| Card expansion | Hoisted `isExpanded` + `AnimatedVisibility` | Enables single-expanded-at-a-time, smooth animation |
| Collapsed chips | `LazyRow` with keys | Not `Row` with `horizontalScroll` |
| NIP-11 per card | `produceState` per card | Prevents parent recomposition cascade |
| NIP-11 dedup | `Mutex` per URL, double-check pattern | Not `ConcurrentHashMap.getOrPut` (not atomic for lambda) |
| Picker state | Fully hoisted to parent | Not split between internal mutableState + callback |
### Implementation Phases
#### Phase 1: State Layer + Dashboard Screen
**Goal:** Relay Dashboard as new DeckColumnType with live session metrics and NIP-11 info.
##### Phase 1a: Extend Relay Infrastructure
**1. Add separate `relayMetrics` StateFlow** (`RelayConnectionManager.kt`)
> **Research Insight (Performance/Coroutines):** DO NOT add eventCount/lastEventAt to `RelayStatus`. The `relayStatuses` StateFlow is used as a `remember` key in FeedScreen. Metrics change per-event (100+/sec). This creates the exact churn documented in the March 2026 brainstorm. Instead, accumulate in `ConcurrentHashMap`, emit snapshot at 1Hz.
```kotlin
// In RelayConnectionManager
@Immutable // Compose skip optimization
data class RelayMetrics(
val eventCount: Long = 0,
val lastEventAt: Long? = null,
)
// Hot metrics — written on every event, no StateFlow emission
private val _rawMetrics = ConcurrentHashMap<NormalizedRelayUrl, RelayMetrics>()
// Throttled snapshot — 1Hz emission for UI
private val _metricsFlow = MutableStateFlow<Map<NormalizedRelayUrl, RelayMetrics>>(emptyMap())
val relayMetrics: StateFlow<Map<NormalizedRelayUrl, RelayMetrics>> = _metricsFlow.asStateFlow()
// In listener callback — atomic, no StateFlow emission
override fun onEvent(relay: IRelayClient, event: Event) {
_rawMetrics.compute(relay.url) { _, m ->
RelayMetrics(
eventCount = (m?.eventCount ?: 0) + 1,
lastEventAt = System.currentTimeMillis()
)
}
}
// 1Hz snapshot coroutine — started in init or connect()
private fun startMetricsSnapshot(scope: CoroutineScope) {
scope.launch {
while (isActive) {
delay(1_000)
_metricsFlow.value = HashMap(_rawMetrics)
}
}
}
```
**`RelayStatus` stays unchanged** — connection-only state.
**2. Create `Nip11Fetcher`** (`Nip11Fetcher.kt`)
> **Research Insight (Security):** `getHttpClient()` has a fail-open fallback — returns `directClient` when Tor is bootstrapping. NIP-11 fetch MUST use fail-closed client. Either fix `getHttpClient()` or use `currentClient()`.
>
> **Research Insight (Coroutines):** `ConcurrentHashMap.getOrPut` is NOT atomic for the lambda. Use `Mutex` per URL with double-check pattern.
>
> **Research Insight (Performance):** Limit response body to 256KB to prevent DoS from malicious relay.
```kotlin
class Nip11Fetcher(
private val httpClient: DesktopHttpClient,
private val scope: CoroutineScope,
) {
private val cache = ConcurrentHashMap<NormalizedRelayUrl, Nip11RelayInformation>()
private val locks = ConcurrentHashMap<NormalizedRelayUrl, Mutex>()
companion object {
private const val MAX_RESPONSE_BYTES = 256 * 1024L // 256KB
private val SEMAPHORE = Semaphore(5) // max 5 concurrent fetches
}
suspend fun fetch(url: NormalizedRelayUrl): Nip11RelayInformation? {
cache[url]?.let { return it }
val mutex = locks.getOrPut(url) { Mutex() }
return mutex.withLock {
cache[url]?.let { return it } // double-check
SEMAPHORE.withPermit {
withContext(Dispatchers.IO) {
fetchFromNetwork(url)
}
}?.also { info ->
cache[url] = info
locks.remove(url)
}
}
}
private fun fetchFromNetwork(url: NormalizedRelayUrl): Nip11RelayInformation? {
// FAIL-CLOSED: use currentClient() not getHttpClient()
val client = httpClient.currentClient() ?: return null
val httpUrl = url.toHttp()
val request = Request.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.source()
?.readUtf8(MAX_RESPONSE_BYTES) ?: return null
Nip11RelayInformation.fromJson(body)
} else null
}
} catch (_: Exception) { null }
}
fun getCached(url: NormalizedRelayUrl): Nip11RelayInformation? = cache[url]
fun clearCache() { cache.clear(); locks.clear() }
}
```
> **Research Insight (Architecture):** Consider extracting `Nip11Retriever` from Android to commons later. For now, desktop-specific is fine since the caching layer differs (Android LruCache vs ConcurrentHashMap).
##### Phase 1b: Register DeckColumnType
**7 files to modify** (desktop-expert caught 2 missing):
| File | Change |
|------|--------|
| `DeckColumnType.kt` | Add `object Relays : DeckColumnType()`, add to `title()` = "Relays" and `typeKey()` = "relays" |
| `ColumnHeader.kt` | Add `DeckColumnType.Relays -> Icons.Default.Dns` in `icon()` |
| `DeckColumnContainer.kt` | Add `DeckColumnType.Relays -> RelayDashboardScreen(...)` in `RootContent()` |
| `AppDrawer.kt` | Add new `ScreenCategory.NETWORK("Network", Icons.Default.Dns)` enum value. Add `DeckColumnType.Relays` to `LAUNCHABLE_SCREENS`. Add to `category()``NETWORK` |
| `DeckState.kt` | Add `"relays" -> DeckColumnType.Relays` in `parseColumnType()` |
| `PinnedNavBarState.kt` | Add `DeckColumnType.Relays` to `DEFAULT_PINNED` list |
| `Main.kt` | Create `Nip11Fetcher` at app level. Thread through `DeckColumnContainer`. Add MenuBar item: `View > Relay Dashboard (Cmd+Shift+R)` |
> **Research Insight (Desktop):** `DeckColumnType` is a sealed class. ALL exhaustive `when` expressions MUST be updated or compilation fails. The exhaustive ones: `title()`, `typeKey()`, `icon()`, `category()`, `RootContent()`.
##### Phase 1c: Dashboard UI
**3. Create `RelayDashboardScreen`** (`RelayDashboardScreen.kt`)
> **Research Insight (Compose):** `mutableIntStateOf` is Android-only. Use `mutableStateOf(0)` or better, an enum.
```kotlin
enum class DashboardTab(val label: String) {
MONITOR("Monitor"),
CONFIGURE("Configure"),
}
@Composable
fun RelayDashboardScreen(
relayManager: DesktopRelayConnectionManager,
nip11Fetcher: Nip11Fetcher,
accountRelays: DesktopAccountRelays,
) {
var selectedTab by remember { mutableStateOf(DashboardTab.MONITOR) }
Column {
TabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) {
DashboardTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { selectedTab = tab },
text = { Text(tab.label) },
)
}
}
when (selectedTab) {
DashboardTab.MONITOR -> RelayMetricsTab(relayManager, nip11Fetcher)
DashboardTab.CONFIGURE -> RelayConfigTab(relayManager, accountRelays)
}
}
}
```
**4. Create `RelayMetricsTab`** (`RelayMetricsTab.kt`)
- Summary header: "X of Y connected" + global reconnect button
- LazyColumn of `RelayMetricCard` per relay, **with `key = { it.url.url }`**
- Single-expanded-at-a-time behavior (parent tracks `expandedUrl: NormalizedRelayUrl?`)
> **Research Insight (Compose):** Apply `distinctUntilChanged()` on `relayStatuses` flow before collecting. Sort by URL for stable list ordering.
```kotlin
val statuses by relayManager.relayStatuses
.map { it.values.toList().sortedBy { s -> s.url.url } }
.distinctUntilChanged()
.collectAsState(emptyList())
val metrics by relayManager.relayMetrics
.collectAsState() // already throttled to 1Hz
```
**5. Create `RelayMetricCard`** (`RelayMetricCard.kt`)
> **Research Insight (Compose):** Hoist expansion state. Use `AnimatedVisibility` for expand/collapse. Use `produceState` for NIP-11 per-card to avoid parent recomposition cascade. Use M3 colors via `RelayStatusColors` object.
```kotlin
@Composable
fun RelayMetricCard(
status: RelayStatus,
metrics: RelayMetrics?,
isExpanded: Boolean,
onToggleExpand: () -> Unit,
nip11Fetcher: Nip11Fetcher,
modifier: Modifier = Modifier,
) {
// NIP-11 fetched per-card, no parent recomposition
val nip11 by produceState<Nip11RelayInformation?>(null, status.url) {
value = nip11Fetcher.fetch(status.url)
}
Card(modifier) {
// Connection status icon (M3 colors)
// Relay URL + display name from NIP-11
// Ping with Tor badge if .onion
// Events count (session) from metrics
// Last event relative time from metrics
Row(Modifier.clickable { onToggleExpand() }) { /* ... */ }
AnimatedVisibility(isExpanded) {
RelayDetailPanel(nip11)
}
}
}
object RelayStatusColors {
@Composable fun connected() = MaterialTheme.colorScheme.primary
@Composable fun error() = MaterialTheme.colorScheme.error
@Composable fun disconnected() = MaterialTheme.colorScheme.outline
}
```
> **Research Insight (Desktop):** Add right-click context menu on relay rows: Copy URL, Open in Browser, Disconnect, Remove. Add tooltips on all icon buttons. Support double-click to expand.
**6. Create `RelayDetailPanel`** (`RelayDetailPanel.kt`)
Simplified NIP-11 display (per simplicity review):
- Name + description
- Software + version
- Supported NIPs (comma-separated text, not clickable chips)
- Payment: free/paid badge
> **Research Insight (Simplicity):** Cut: geo, countries, languages, contact email, retention, fees. These are relay-operator info, not end-user info.
**7. Create `RelayConfigTab`** (`RelayConfigTab.kt`)
> **Research Insight (Simplicity):** Cut from 8 categories to 3. Desktop doesn't use search/trusted/proxy/indexer/key-package relay lists yet.
Collapsible sections:
- **NIP-65 Inbox/Outbox** (kind 10002) — with read/write toggle per relay
- **DM Relays** (kind 10050)
- **Connected Relays** — the flat list that already exists (add/remove/reconnect)
Each section uses `RelayListEditor`.
**8. Create `RelayListEditor`** (`RelayListEditor.kt`)
- Relay URL input field with validation (wss://, .onion allowed)
- Add button (Enter to add)
- LazyColumn of relay items with key, **alphabetically sorted**:
- Relay URL + NIP-11 name
- Connection status dot
- Remove button (X)
- Read/write toggle (NIP-65 only)
- "Save" button — publishes updated relay list event
- "Reset to defaults" option
> **Research Insight (Simplicity):** No drag-to-reorder. Order is display-only. Alphabetical sort is deterministic and simple.
URL validation:
- Must start with `wss://` or `ws://`
- Normalize via `RelayUrlNormalizer`
- Reject duplicates within same category
> **Research Insight (Nostr):** NIP-65 uses `"r"` tags. DM (10050) uses `"relay"` tags. Different tag formats — use the appropriate event builder from quartz.
>
> **Research Insight (Nostr):** After publishing, show "Published to X of Y relays" not just "Saved". Warn if fewer than 2 relays confirm.
#### Phase 2: Compose Relay Picker
**Goal:** Expandable relay section in note compose and DM compose dialogs.
**9. Create `ComposeRelayPicker`** (`ComposeRelayPicker.kt`)
> **Research Insight (Compose):** Fully hoist state to parent. Use `combine` on `connectedRelays` (NOT `relayStatuses`) + category relay list into `@Immutable` data class with `distinctUntilChanged()`.
>
> **Research Insight (Security):** DM action type must NOT fall back to all connected relays. Block send if no kind 10050 configured.
```kotlin
@Immutable
data class RelayPickerState(
val allRelays: Set<NormalizedRelayUrl>,
val connectedRelays: Set<NormalizedRelayUrl>,
) {
companion object { val EMPTY = RelayPickerState(emptySet(), emptySet()) }
}
enum class RelayActionType { NOTE, DM }
@Composable
fun ComposeRelayPicker(
pickerState: RelayPickerState, // hoisted — parent combines flows
selectedRelays: Set<NormalizedRelayUrl>, // hoisted
onToggleRelay: (NormalizedRelayUrl) -> Unit,
onSaveAsDefault: () -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
// Collapsed: "▸ Relays (3 of 5)" with chips
Row(Modifier.clickable { expanded = !expanded }) {
Icon(if (expanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight)
Text("Relays (${selectedRelays.size} of ${pickerState.allRelays.size})")
}
if (!expanded) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(selectedRelays.toList(), key = { it.url }) { url ->
AssistChip(onClick = {}, label = { Text(url.displayUrl()) })
}
}
}
AnimatedVisibility(expanded) {
Column {
// Connected relays: checkboxes
pickerState.allRelays.sortedBy { it.url }.forEach { url ->
val connected = url in pickerState.connectedRelays
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = url in selectedRelays,
onCheckedChange = { onToggleRelay(url) },
enabled = connected,
)
Text(
url.displayUrl(),
color = if (connected) LocalContentColor.current
else MaterialTheme.colorScheme.outline,
)
if (!connected) Text(" (disconnected)", style = MaterialTheme.typography.labelSmall)
}
}
TextButton(onClick = onSaveAsDefault) { Text("Save as default") }
}
}
}
```
Default relay population per action type:
- `NOTE` → NIP-65 outbox relays, fallback to all connected
- `DM` → DM relay list (kind 10050), **NO FALLBACK — block send if empty**
> **Research Insight (Nostr):** `publish(event, relays)` already exists on `RelayConnectionManager`. No new `publishTo()` method needed. For non-DM events, always include NIP-65 write relays in the set to maintain outbox model compliance.
**10. Integrate into `ComposeNoteDialog`** (`ComposeNoteDialog.kt`)
- Parent combines `connectedRelays` + NIP-65 outbox into `RelayPickerState`
- Add `ComposeRelayPicker` above send button
- Replace `relayManager.broadcastToAll(signedEvent)` with `relayManager.publish(signedEvent, selectedRelays)`
**11. Integrate into DM compose**
- Same `ComposeRelayPicker` with DM relay list as source
- If no DM relay list (kind 10050): show warning "Configure DM relays first", disable Send
#### Phase 3: Future (Deferred — not in this PR)
> **Research Insight (Simplicity):** These are all nice-to-haves without demand signal. Defer entirely. If demand emerges, create separate feature tickets.
- Relay source badges on notes (Note.addRelay already tracks this)
- Relay suggestions from contacts' NIP-65 lists
- Additional relay categories (search, trusted, proxy, indexer, key package)
- Historical metrics with SQLite persistence
- Keyboard navigation refinements (Tab/arrows in relay lists)
## System-Wide Impact
### Interaction Graph
- `RelayConnectionManager.onEvent()` → atomic increment on `_rawMetrics` ConcurrentHashMap (no StateFlow emission)
- 1Hz snapshot coroutine → copies `_rawMetrics` to `_metricsFlow` StateFlow → dashboard recomposes at max 1fps
- `relayStatuses` StateFlow → **unchanged**, only emits on connect/disconnect/error (low frequency)
- `Nip11Fetcher.fetch()` triggered per-card via `produceState` → HTTP GET via fail-closed `currentClient()` → cached in memory
- `ComposeRelayPicker` reads `connectedRelays` + category relay list via `combine().distinctUntilChanged()` → user toggles → `publish(event, selectedRelays)` called
- Relay list save → signs event via signer → publishes to relays → shows "Published to X of Y"
### Error Propagation
| Error | Source | Handling |
|-------|--------|----------|
| NIP-11 fetch fails | HTTP timeout/404/parse/Tor not ready | Show "Info unavailable" in detail panel |
| NIP-11 body too large | Malicious relay sends >256KB | Truncated by `readUtf8(MAX_RESPONSE_BYTES)` |
| Relay list publish rejected | Auth required, rate limited | Snackbar "Published to X of Y relays" with warning |
| Signer timeout (NIP-46) | Remote bunker unresponsive | "Signing timed out" dialog, offer retry |
| All relays disconnected at compose | Network failure | Warning banner in picker, disable Send |
| No DM relay list configured | User never published kind 10050 | Block DM send, show "Configure DM relays" warning |
| Tor bootstrapping when NIP-11 fetched | Tor not ready yet | `currentClient()` returns null → fetch returns null → "Info unavailable" |
### State Lifecycle Risks
- **Relay list conflict**: User edits on desktop while mobile publishes newer version → desktop overwrites. Mitigation: show "last updated" timestamp, warn if remote version is newer.
- **NIP-11 cache stale within session**: Relay updates NIP-11 info after initial fetch → acceptable, session cache is intentional.
- **Metrics lost on restart**: Session-only by design. No persistence to corrupt.
## Acceptance Criteria
### Phase 1: Dashboard
- [ ] `DeckColumnType.Relays` registered in all 7 files, compiles
- [ ] New `ScreenCategory.NETWORK` in AppDrawer
- [ ] Relays appears in App Drawer under Network
- [ ] Can pin Relays to nav bar, open as deck column
- [ ] Added to `DEFAULT_PINNED` in PinnedNavBarState
- [ ] MenuBar: View > Relay Dashboard (Cmd+Shift+R)
- [ ] Monitor tab shows all relays with: status, ping, event count (1Hz), last event time
- [ ] Separate `relayMetrics` StateFlow (1Hz), `relayStatuses` unchanged
- [ ] NIP-11 detail panel shows: name, software, supported NIPs, paid/free
- [ ] NIP-11 fetched on-demand per-card via `produceState`, Mutex dedup, fail-closed Tor
- [ ] NIP-11 response limited to 256KB
- [ ] Configure tab shows 3 categories: NIP-65 inbox/outbox, DM, Connected
- [ ] Save button per category publishes appropriate event kind
- [ ] After save, shows "Published to X of Y relays"
- [ ] URL input validates wss:// format, rejects duplicates
- [ ] Relay list sorted alphabetically (no drag reorder)
- [ ] Tor relays show .onion badge next to latency
- [ ] Right-click context menu on relay rows (Copy URL, Remove)
- [ ] M3 colors for status indicators (not hardcoded Color.Green/Red)
- [ ] Card expansion hoisted + AnimatedVisibility
### Phase 2: Compose Picker
- [ ] Expandable "Relays (N of M)" section in ComposeNoteDialog
- [ ] Collapsed shows relay name chips via LazyRow, expanded shows checkboxes
- [ ] Default: NIP-65 outbox for notes
- [ ] DM: kind 10050 relays only, **no fallback** — block send if empty
- [ ] Disconnected relays shown greyed out, not checkable
- [ ] "Save as default" persists to appropriate relay list event
- [ ] Per-action selection resets on dialog reopen
- [ ] Uses existing `publish(event, relays)` API (no new method)
- [ ] State fully hoisted, combined via `combine().distinctUntilChanged()`
## Dependencies & Prerequisites
| Dependency | Status | Notes |
|------------|--------|-------|
| `Nip11RelayInformation` (quartz) | ✅ Exists | Full data model with `fromJson()` |
| `RelayConnectionManager` | ✅ Exists | Add `relayMetrics` flow + 1Hz snapshot |
| `RelayStatusCard` | ✅ Exists | Enhance with M3 colors |
| `DesktopAccountRelays` | ✅ Exists | DM relay state |
| `Nip65RelayListState` (commons) | ✅ Exists | Inbox/outbox state |
| `DesktopHttpClient.currentClient()` | ✅ Exists | Fail-closed Tor-aware client |
| `publish(event, relays)` | ✅ Exists | Already on RelayConnectionManager |
| `Note.addRelay()` | ✅ Exists | Relay source tracking in cache |
| Relay list event types (quartz) | ✅ Exists | Kind 10002, 10050 |
| `Nip11Fetcher` | 🆕 New | HTTP + session cache + Mutex dedup |
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| NIP-11 fetch leaks IP during Tor bootstrap | Medium | **High** | Use `currentClient()` (fail-closed), not `getHttpClient()` (fail-open) |
| DM published to non-DM relays | Low | **High** | Block DM send when no kind 10050 configured, never fall back to all relays |
| NIP-11 DoS via large response | Low | Medium | 256KB body limit via `readUtf8(MAX_RESPONSE_BYTES)` |
| Dashboard recomposition churn | Medium | Medium | Separate `relayMetrics` flow at 1Hz, `relayStatuses` unchanged |
| Relay list publish partially accepted | Low | Medium | Show "Published to X of Y relays", warn if < 2 confirm |
| Relay list conflict with mobile | Low | Medium | Show "last updated" timestamp |
| Metrics persistence when Tor active leaks patterns | N/A | N/A | **Eliminated** — session-only metrics, nothing persisted to disk |
## Sources & References
### Origin
- **Brainstorm document:** [docs/brainstorms/2026-04-20-relay-power-tools-brainstorm.md](docs/brainstorms/2026-04-20-relay-power-tools-brainstorm.md) — Key decisions: dashboard-first build order, expandable compose picker UX, per-session NIP-11 cache
### Internal References
- `RelayConnectionManager`: `desktopApp/.../network/RelayConnectionManager.kt`
- `RelayStatus`: `desktopApp/.../network/RelayStatus.kt`
- `DesktopHttpClient.currentClient()`: `desktopApp/.../network/DesktopHttpClient.kt:168`
- `publish(event, relays)`: `desktopApp/.../network/RelayConnectionManager.kt` (already accepts relay set)
- `Note.addRelay()`: relay source tracking already in cache consume methods
- `RelayStatusCard`: `desktopApp/.../ui/relay/RelayStatusCard.kt`
- `ComposeNoteDialog`: `desktopApp/.../ui/ComposeNoteDialog.kt`
- `DeckColumnType`: `desktopApp/.../ui/deck/DeckColumnType.kt`
- `AppDrawer`: `desktopApp/.../ui/deck/AppDrawer.kt`
- `PinnedNavBarState`: `desktopApp/.../ui/deck/PinnedNavBarState.kt`
- `Nip11RelayInformation`: `quartz/.../nip11RelayInfo/Nip11RelayInformation.kt`
- FeedScreen relay subscription strategy: `docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md`
### External References
- [NIP-11 Relay Information](https://github.com/nostr-protocol/nips/blob/master/11.md)
- [NIP-65 Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md)
- [NIP-17 Private Direct Messages](https://github.com/nostr-protocol/nips/blob/master/17.md)
## Unanswered Questions
1. Should `currentClient()` in DesktopHttpClient be the default for ALL non-websocket HTTP requests, or just NIP-11?
2. Should the relay config "Save" button compare `created_at` with the remote version before overwriting?
3. Should Relays be in `DEFAULT_PINNED` or just pinnable?
4. Does `DesktopHttpClient.currentClient()` support the `Accept: application/nostr+json` header routing, or does it need a separate method for HTTP GET vs WebSocket?
@@ -0,0 +1,626 @@
---
title: "feat: Desktop Multi-Account Support"
type: feat
status: active
date: 2026-04-23
origin: docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md
---
# feat: Desktop Multi-Account Support
## Enhancement Summary
**Deepened on:** 2026-04-24
**Research agents used:** kotlin-expert, kotlin-coroutines, kotlin-multiplatform, compose-expert, desktop-expert, nostr-expert, security-reviewer, desktop-api-researcher
### Key Improvements
1. **KMP extraction corrected** — only extract types + interface to commons (not managers); AccountSessionManager/CacheState too Android-coupled
2. **Switch ordering fixed** — load new account BEFORE cancelling old scope (prevents unrecoverable partial failure)
3. **Encryption key strategy changed** — store random AES key in OS keychain via SecureKeyStorage instead of fragile machine-derived key
4. **SignerType changed to sealed class** — carries `packageName` in Remote subtype instead of leaking nullable field
5. **Background notification filters specified** — kinds 1, 6, 9735, 1059, 4 with `#p` tag; exclude kind 7 reactions (too noisy)
6. **Desktop API patterns grounded** — Compose `Tray` dynamic menus, custom `Painter` for badge overlay, `DialogWindow` for add-account, `DropdownMenu` with offset for sidebar
### Critical Corrections Found
- `AccountSessionManager` and `AccountCacheState` **cannot** go to `commons/commonMain` as written (Android-only deps: ContentResolver, MLS stores, Route class)
- `java.security.KeyStore` on JVM desktop provides **no real security** (file-based, no TPM) — use OS keychain instead
- `AccountState.LoggedIn` has mutable `var route` — breaks `@Stable` contract; must be `val`
- `nwc_connection.txt` stored in **plaintext** — high-severity, must migrate to encrypted storage
- macOS `TrayIcon.displayMessage()` is **broken** since macOS 10.14+ — use `two-slices` library or in-app toasts
---
## Overview
Add multi-account support to Amethyst Desktop — store multiple Nostr identities (nsec, NIP-46 bunker, npub view-only), switch between them via a sidebar dropdown, with background notification counts, system tray integration, and per-account desktop notifications. One active account at a time, kill & reconnect relay connections on switch.
## Problem Statement
Desktop currently supports only a single account with file-based storage (`last_account.txt`, `bunker_uri.txt`). Users who manage multiple Nostr identities (personal, work, project) must log out and re-enter credentials each time. Android Amethyst already has full multi-account support — desktop should match.
## Proposed Solution
Extract account **types and interfaces** to `commons/commonMain/`, build platform-specific session managers for both Android and Desktop, build desktop-specific UI (sidebar dropdown, add-account dialog), encrypted account metadata storage, background relay subscriptions for inactive account notification counts, system tray integration, and silent migration from single-account.
(see brainstorm: `docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md`)
## Technical Approach
### Architecture
```
commons/commonMain/
model/
AccountInfo.kt # npub, signerType sealed class, isTransient
AccountState.kt # Sealed: Loading, LoggedIn(IAccount), LoggedOff
AccountStorage.kt # Interface only — injected, NOT expect/actual
desktopApp/
account/
AccountManager.kt # MODIFY — multi-account lifecycle (platform-specific)
DesktopAccountStorage.kt # NEW — encrypted JSON + SecureKeyStorage for nsecs
DesktopAccountCacheState.kt # NEW — Map<HexKey, DesktopIAccount> + scope lifecycle
BackgroundNotificationManager.kt # NEW — lightweight relay subs for inactive accounts
ui/
AccountSwitcherDropdown.kt # NEW — top-of-sidebar dropdown
AddAccountDialog.kt # NEW — nsec / bunker / npub import via DialogWindow
tray/
DesktopTrayIntegration.kt # NEW — Compose Tray with custom badge Painter
amethyst/
AccountSessionManager.kt # MODIFY — use commons AccountState/AccountInfo types
LocalPreferences.kt # MODIFY — implement AccountStorage interface
```
### Research Insight: What Goes Where
The KMP expert found that `AccountSessionManager` and `AccountCacheState` **cannot** be extracted to `commons/commonMain` as originally planned — they depend on Android-only types (`ContentResolver`, `AndroidMlsGroupStateStore`, `Route`). Only the **types and interface** go to commons:
| Component | Source Set | Reason |
|-----------|-----------|--------|
| `AccountInfo` data class | `commons/commonMain` | Pure Kotlin, no platform APIs |
| `AccountState` sealed class | `commons/commonMain` | Uses `IAccount` (already in commonMain), `String?` route |
| `AccountStorage` interface | `commons/commonMain` | Pure interface, domain types only |
| `AccountSessionManager` | **Platform-specific** | Android: NIP-55/ContentResolver; Desktop: file-based signers |
| `AccountCacheState` equivalent | **Platform-specific** | Android: MLS/Marmot stores; Desktop: different signer types |
| `DesktopAccountStorage` | `desktopApp/jvmMain` | `javax.crypto`, file I/O, OS keychain |
### Key Design Decisions (from brainstorm)
| Decision | Choice |
|----------|--------|
| Account model | One active, quick switch |
| UI placement | Top of sidebar dropdown |
| Relay on switch | Load new FIRST, then kill old scope |
| Login methods | nsec + NIP-46 bunker + npub (view-only) |
| Code sharing | Extract types + interface to commons; managers platform-specific |
| Nsec storage | SecureKeyStorage (already in quartz, uses OS keychain) |
| Metadata storage | Encrypted JSON file (`~/.amethyst/accounts.json.enc`) |
| Encryption key | Random AES key stored in OS keychain (not machine-derived) |
| App launch | Auto-login last account, optional lock |
| Add account UX | `DialogWindow` overlay from switcher dropdown |
| Unread badges | Live counts via lightweight background relay subs |
| Background subs | Up to 5 relay connections per inactive account |
| Background filters | Kinds 1, 6, 9735, 1059, 4 with `#p` tag (NOT kind 7) |
| Account removal | Delete everything (credentials + cached data) |
| NIP-46 multi-acct | One bunker URI per account (protocol is 1:1) |
| Migration | Auto-migrate existing single-account silently |
| Account ordering | Fixed by add date (stable kbd shortcuts 1-9) |
| Cache on switch | Keep SharedLocalCache across switches |
### Implementation Phases
#### Phase 1: Extract Account Types to Commons
**Goal:** Share account data types and storage interface across platforms.
**Tasks:**
- [x] Create `SignerType` as **sealed class** (not enum) in `commons/commonMain`:
```kotlin
@Immutable
sealed class SignerType {
data object Internal : SignerType()
data class Remote(val packageName: String) : SignerType()
data object ViewOnly : SignerType()
}
```
- [x] Create `AccountInfo` in `commons/commonMain`:
```kotlin
@Immutable
data class AccountInfo(
val npub: String,
val signerType: SignerType,
val isTransient: Boolean = false,
)
```
- [x] Create `AccountState` sealed class in `commons/commonMain`:
```kotlin
sealed class AccountState {
data object Loading : AccountState()
data object LoggedOff : AccountState()
@Immutable
data class LoggedIn(
val account: IAccount, // NOT Android Account
val route: String? = null, // route ID, NOT Route class
) : AccountState()
}
```
- [x] Create `AccountStorage` interface in `commons/commonMain` (constructor-injected, NOT expect/actual):
```kotlin
interface AccountStorage {
suspend fun loadAccounts(): List<AccountInfo>
suspend fun saveAccount(info: AccountInfo)
suspend fun deleteAccount(npub: String)
suspend fun currentAccount(): String?
suspend fun setCurrentAccount(npub: String)
}
```
- [ ] Update Android `AccountSessionManager` to use commons `AccountInfo`, `AccountState` (future PR)
- [ ] Update Android `LocalPreferences` to implement `AccountStorage` (future PR)
- [ ] Tests: unit tests for `AccountInfo`/`AccountState` in `commons/jvmTest` (deferred — type tests trivial)
**Research Insights:**
**Kotlin patterns (kotlin-expert):**
- Use `data object` (Kotlin 1.9+) for `Loading`/`LoggedOff` — proper `toString()` and equality
- `LoggedIn` must be `data class` with `val route` (not `var`) — `var` breaks `@Stable`/`@Immutable`
- Route mutation via `_accountState.update { (it as? LoggedIn)?.copy(route = newRoute) ?: it }`
- Private `MutableStateFlow`, public `StateFlow` — never expose mutable flow externally
**KMP patterns (kotlin-multiplatform):**
- `AccountStorage` uses constructor injection because the interface is identical across platforms — only the storage substrate differs
- `SecureKeyStorage` uses expect/actual because the mechanism (Android Keystore vs macOS Keychain) is fundamentally different — `AccountStorage` doesn't need that
**Files modified:**
- `commons/commonMain/kotlin/.../model/AccountInfo.kt` (NEW)
- `commons/commonMain/kotlin/.../model/AccountState.kt` (NEW)
- `commons/commonMain/kotlin/.../model/AccountStorage.kt` (NEW)
- `commons/commonMain/kotlin/.../model/SignerType.kt` (NEW)
- `amethyst/.../AccountSessionManager.kt` (MODIFY — use commons types)
- `amethyst/.../LocalPreferences.kt` (MODIFY — implement AccountStorage)
#### Phase 2: Desktop Account Storage
**Goal:** Encrypted persistence for multiple accounts on desktop.
**Tasks:**
- [x] Create `DesktopAccountStorage.kt` implementing `AccountStorage`
- Account metadata (list, active npub, per-account prefs) in encrypted JSON
- File location: `~/.amethyst/accounts.json.enc`
- Encryption: AES-256-GCM via `javax.crypto`
- Nsec storage delegated to existing `SecureKeyStorage` (quartz) — already uses OS keychain
- [x] Implement encryption key bootstrap via OS keychain:
```kotlin
private suspend fun getOrCreateMetadataKey(): ByteArray {
val alias = "account-metadata-key"
val existing = secureStorage.getPrivateKey(alias)
if (existing != null) return Base64.getDecoder().decode(existing)
val key = ByteArray(32).also { SecureRandom().nextBytes(it) }
secureStorage.savePrivateKey(alias, Base64.getEncoder().encodeToString(key))
return key
}
```
- [x] Implement two save paths (debounced + immediate):
```kotlin
// Debounced for preference changes (500ms coalesce)
fun saveAsync(accounts: List<AccountInfo>) { saveRequests.tryEmit(accounts) }
// Immediate for critical operations (add, remove, logout)
suspend fun saveImmediate(accounts: List<AccountInfo>) { writeToDisk(accounts) }
```
- [x] Migration logic: on first launch, detect `last_account.txt` + `bunker_uri.txt`:
- Read existing npub, key, bunker URI
- Create first `AccountInfo` entry
- Write to new encrypted format, read back and verify decrypted npub matches
- Best-effort secure-delete old files (zero-overwrite + delete)
- [ ] Migrate `nwc_connection.txt` to encrypted storage (currently **plaintext** — high-severity security issue)
- [ ] Per-account preferences namespace via `prefs.node(npub)`
- [ ] JSON serialization via Jackson (matches WorkspaceManager pattern)
**Research Insights:**
**Security (security-reviewer):**
- `java.security.KeyStore` on JVM desktop is file-based (PKCS12/JKS) with **no TPM/HSM backing** — provides zero additional security. If attacker can read `~/.amethyst/`, they can read both the encrypted file and the keystore
- **Use OS keychain** (same `java-keyring` / `SecureKeyStorage` already in quartz) to store a random 256-bit AES key. This is the password-manager pattern (1Password, Bitwarden)
- Hardware ID PBKDF2 is fragile — MAC addresses/disk serials change on hardware replacement/VM migration, locking users out
- `nwc_connection.txt` contains wallet credentials stored in plaintext — must migrate to encrypted storage
- `.bak` files should be zero-overwritten before deletion (best-effort on SSDs, but good hygiene)
- Remove `nsec` from `AccountState.LoggedIn` state object — fetch on-demand from `SecureKeyStorage` instead of caching in memory for the entire session
**Coroutines (kotlin-coroutines):**
- Debounced saves use `MutableSharedFlow` + `.debounce(500)` for preference changes
- Immediate saves bypass debounce for account add/remove/logout — don't want 500ms delay before confirming data written
- Both paths use `Dispatchers.IO`
**NIP-46 relay isolation** (from `docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md`):
- Each NIP-46 account MUST have a dedicated isolated `NostrClient`
- Created via `AccountManager.getOrCreateNip46Client()` with `Mutex` thread-safety
- Prevents relay pool pollution (bunker relays leaking into general pool)
- Cancel scope (not just job) on logout — kills any pending auth flows
**Files modified:**
- `desktopApp/.../account/DesktopAccountStorage.kt` (NEW)
- `desktopApp/.../account/AccountManager.kt` (MODIFY)
- `desktopApp/.../DesktopPreferences.kt` (MODIFY — add per-account scoping)
#### Phase 3: Account Switching Integration
**Goal:** Wire account switching into desktop app lifecycle.
**Tasks:**
- [x] Modify `AccountManager` to support multiple accounts:
- `MutableStateFlow<AccountState>` driven by `DesktopAccountStorage`
- `StateFlow<List<AccountInfo>>` for all accounts list
- `switchAccount(accountInfo)`: **load new account FIRST**, then cancel old scope
- `addAccount(key/bunkerUri/npub)`: validate, save to storage, optionally switch
- `removeAccount(npub)`: cancel scope, delete from storage + SecureKeyStorage, clear preferences
- [ ] Implement safe switch ordering (CRITICAL — coroutines review):
```kotlin
suspend fun switchAccount(newInfo: AccountInfo): Result<Unit> {
val previousInfo = currentAccountInfo
val previousScope = accountScope
return try {
// Phase 1: load + validate BEFORE cancelling old scope
val newAccount = loadAccount(newInfo) // throws on failure
// Phase 2: now safe to transition
_accountState.value = AccountState.Loading
accountScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
previousScope.cancel() // Only cancel after new scope ready
// Phase 3: bring up new connections
relayConnectionManager.reconnect(newAccount.relays)
_accountState.value = AccountState.LoggedIn(newAccount)
Result.success(Unit)
} catch (e: Exception) {
// Old scope still alive — no partial state
_accountState.value = AccountState.LoggedIn(previousInfo!!)
Result.failure(e)
}
}
```
- [ ] Create `DesktopAccountCacheState`:
- `MutableStateFlow<Map<HexKey, DesktopIAccount>>`
- `loadAccount()` / `removeAccount()` with scope lifecycle
- Mirrors Android pattern but uses `DesktopIAccount` (not extractable — Android has MLS/Marmot stores)
- [ ] Update `App` composable in `Main.kt`:
- Gate on `AccountManager.accountState` (now multi-account aware)
- Use `remember(activeAccountKey)` to rebuild deck/workspace state per account (no app restart)
- Pass account switcher callbacks down to sidebar
- [ ] Share `DesktopLocalCache` across accounts (keep cache on switch)
- [ ] Update `RelayConnectionManager` lifecycle on switch
**Research Insights:**
**Coroutines (kotlin-coroutines) — CRITICAL FIX:**
- Original plan said "cancel old scope → load new account" — **wrong order**. You cannot reinstate a cancelled `CoroutineScope`. Load and validate the new account FIRST, then cancel old scope
- Use `SupervisorJob()` for account scope so individual relay failures don't kill the entire account
- `CoroutineExceptionHandler` on account scope to log unhandled errors
**Desktop (desktop-expert):**
- Do NOT use `key(appRestartKey)` for account switching (current pattern for logout/restart) — switching should be seamless
- Use `remember(activeAccountKey) { DeckState(...) }` — recreates when key changes, no app restart
- The `DeckState` and `WorkspaceManager` should be keyed per-account or reset on switch
**Files modified:**
- `desktopApp/.../account/AccountManager.kt` (MODIFY)
- `desktopApp/.../account/DesktopAccountCacheState.kt` (NEW)
- `desktopApp/.../Main.kt` (MODIFY)
- `desktopApp/.../model/DesktopIAccount.kt` (MODIFY)
- `desktopApp/.../network/RelayConnectionManager.kt` (MODIFY)
- `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt` (MODIFY)
#### Phase 4: Account Switcher UI
**Goal:** Sidebar dropdown for switching accounts.
**Tasks:**
- [x] Create `AccountSwitcherDropdown.kt`:
- 48dp-wide avatar button replaces "A" logo at top of `DeckSidebar`
- `BadgedBox` with total unread count on avatar
- Click opens `DropdownMenu` with `DpOffset(x = 48.dp)` (expand right of sidebar)
- Account rows: avatar (via existing `UserAvatar` commons composable), name, active checkmark, per-account `Badge` count
- `HorizontalDivider` + "Add Account" at bottom
- `AnimatedContent` with fade for avatar transition on switch
- [ ] Add keyboard shortcut `Cmd+Shift+A` / `Ctrl+Shift+A`:
- Register in `MenuBar` "Accounts" menu (new menu between File and Edit)
- No conflict with existing shortcuts (verified: Cmd+N, Cmd+S+Shift, Cmd+K, Cmd+D+Shift, Cmd+T, Cmd+W, Cmd+1..9 all free)
- `Cmd+Shift+1..9` for direct account quick-select (also free)
- [x] Modify `DeckSidebar.kt`:
- Replace "A" text with `AccountSwitcherDropdown` at top
- Add params: `activeAccount`, `allAccounts`, `unreadCounts`, `onSwitchAccount`, `onAddAccount`
- [ ] Create `AddAccountDialog.kt`:
- Use `DialogWindow` (real OS window, not Compose overlay) — avoids AWT/VLC z-order issues
- Size: `DpSize(480.dp, 600.dp)`, `resizable = false`
- Wrap existing `LoginCard` composable (already has nsec/bunker/nostrconnect tabs) — do NOT duplicate
- Suppress "Generate New" path, different title/subtitle
- [ ] Account context menu (right-click on account in dropdown):
- "Remove Account" with confirmation dialog
- "Copy npub"
- [ ] Add "Accounts" menu to `MenuBar` with shortcut items per account
**Research Insights:**
**Compose (compose-expert):**
- `@Immutable` on `AccountInfo` — passed to every row; without it, every row recomposes on any state change
- Use `derivedStateOf` inside each badge to isolate per-account count reads (prevents full dropdown recomposition on any count change)
- Use `ImmutableMap` from `kotlinx.collections.immutable` (already in project) for `unreadCounts` StateFlow
- `allAccounts.forEach` (not `LazyColumn`) inside `DropdownMenu` — Material3 `DropdownMenu` uses `Column` internally, `LazyColumn` causes scroll conflicts. Fine for max ~9 accounts
- `BadgedBox` + `Badge` — correct M3 API, available in CMP 1.7.x, no `@Experimental` needed
- Use `HorizontalDivider` (not deprecated `Divider`)
**Desktop (desktop-expert):**
- `DropdownMenu` is correct over modal Dialog for the switcher — lightweight, dismisses on outside click, expands right from 48dp sidebar
- `DialogWindow` (not `Dialog` composable) for AddAccount — gets OS-level modality, proper focus, avoids z-order conflicts with VLC
- macOS: tray icon renders monochrome by default — use template image with `apple.awt.enableTemplateImages=true` JVM arg
- `onPreviewKeyEvent` on Window for shortcuts (intercepts before children)
**Files modified:**
- `desktopApp/.../ui/AccountSwitcherDropdown.kt` (NEW)
- `desktopApp/.../ui/AddAccountDialog.kt` (NEW)
- `desktopApp/.../ui/deck/DeckSidebar.kt` (MODIFY)
- `desktopApp/.../Main.kt` (MODIFY — MenuBar "Accounts" menu)
#### Phase 5: Background Notification Subscriptions
**Goal:** Live unread counts for inactive accounts.
**Tasks:**
- [ ] Create `BackgroundNotificationManager.kt`:
- Per-account jobs in `Mutex`-guarded `mutableMapOf<String, Job>()`
- `supervisorScope` per account so one relay failure doesn't kill others
- Connect to account's NIP-65 inbox relays (`readRelaysNorm()`, max 3) + DM relays (kind 10050, max 2)
- Subscribe with these filters per inactive account:
```kotlin
// Filter 1: Mentions + reposts
Filter(kinds = listOf(1, 6), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 50)
// Filter 2: Zap receipts
Filter(kinds = listOf(9735), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 20)
// Filter 3: NIP-17 gift-wrapped DMs (on DM inbox relays)
Filter(kinds = listOf(1059), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 50)
// Filter 4: Legacy NIP-04 DMs
Filter(kinds = listOf(4), tags = mapOf("p" to listOf(pubkey)), since = lastSeen, limit = 50)
```
- Expose `StateFlow<ImmutableMap<String, Int>>` — npub → unread count
- Reset count for account when user switches to it
- [ ] Reconnect with exponential backoff + jitter:
```kotlin
val delayMs = minOf(30_000L, 1000L * (1L shl minOf(attempt, 5)))
.plus(Random.nextLong(0, 500))
```
- Use `while (currentCoroutineContext().isActive)` (not `while(true)`) for cancellation
- [ ] Wire counts into `AccountSwitcherDropdown` and tray menu
- [ ] Tie to app-level `CoroutineScope`, cancel in shutdown hook
**Research Insights:**
**Nostr (nostr-expert):**
- Use `#p` tag filters (not `authors`) — listening *for* the account, not *from* it
- **Exclude kind 7 reactions** — too noisy for background counts. Use NIP-45 count query at foreground activation if needed
- **Include kind 6 reposts** — same filter as kind 1 mentions, relatively rare, worth tracking
- NIP-65 `readRelaysNorm()` for general notifications; kind 10050 `ChatMessageRelayListEvent` for DM-specific inbox relays
- Max 5 relay connections per inactive account (3 inbox + 2 DM)
- Always set `since` field — prevents relay flooding on reconnect
- Separate `RelayAuthenticator` for background connections (don't reuse active account's)
**Coroutines (kotlin-coroutines):**
- `supervisorScope` inside each account job so one relay failure doesn't kill other relays
- `Mutex` on `accountJobs` map — switch can race with notification events
- `startTracking(account)` cancels existing job for that account before creating new one
- `stopTracking(npub)` removes and cancels the job
**Files modified:**
- `desktopApp/.../account/BackgroundNotificationManager.kt` (NEW)
- `desktopApp/.../ui/AccountSwitcherDropdown.kt` (MODIFY — add badges)
#### Phase 6: System Tray Integration
**Goal:** Tray icon with account menu and notification badges.
**Tasks:**
- [ ] Create `DesktopTrayIntegration.kt`:
- Use Compose Desktop `Tray` composable in `application {}` scope (NOT `java.awt.SystemTray`)
- Dynamic menu driven by `collectAsState()` — updates when accounts added/removed
- Custom `Painter` subclass for badge overlay on tray icon (no built-in badge API):
```kotlin
class BadgedIconPainter(val base: Painter, val count: Int) : Painter() {
override val intrinsicSize get() = base.intrinsicSize
override fun DrawScope.onDraw() {
with(base) { draw(size) }
if (count > 0) {
drawCircle(Color.Red, radius = size.minDimension * 0.25f,
center = Offset(size.width * 0.75f, size.height * 0.25f))
}
}
}
```
- Tray menu: active account (bold), separator, all accounts with unread counts, separator, Add Account, Quit
- [ ] Desktop notification integration:
- **macOS**: `TrayIcon.displayMessage()` is broken since macOS 10.14+ — use `two-slices` library (`com.sshtools:two-slices:0.9.6`) for native Notification Center
- **Windows/Linux**: `TrayIcon.displayMessage()` works, or `two-slices` for consistency
- `two-slices` supports click callbacks (`defaultAction { }`) for notification-to-account routing
- Prefix with account name: `[Alice] New mention from Bob`
- Click notification → switch to that account
- [ ] macOS dark mode: set `apple.awt.enableTemplateImages=true` JVM arg for proper template image behavior
**Research Insights:**
**Desktop API (desktop-api-researcher):**
- Compose `Tray` composable wraps AWT internally but exposes clean composable API with recomposition support
- `TrayState.sendNotification(Notification)` available but limited (no click callbacks through Compose)
- For click-to-open-account on notification: need raw AWT `TrayIcon.addActionListener` or `two-slices` library
- No notification grouping API — tag account ID in title, route via click callback
- macOS tray icon size: 22x22, monochrome template; Windows: 16x16, colored OK; Linux: 22x22, varies by DE
- `Tray` `onAction` fires on primary action (macOS double-click, Windows left-click, Linux varies)
**Platform differences:**
| Platform | Tray click | Notifications | Icon theme |
|----------|-----------|---------------|------------|
| macOS | Single-click = menu | `two-slices` needed | Monochrome template |
| Windows | Left-click = `onAction` | `displayMessage()` works | Colored OK |
| Linux | Varies by DE | `displayMessage()` works | Monochrome |
**Files modified:**
- `desktopApp/.../tray/DesktopTrayIntegration.kt` (NEW)
- `desktopApp/.../Main.kt` (MODIFY — add `Tray` in `application {}` scope)
**Dependencies to add:**
- `com.sshtools:two-slices:0.9.6` (cross-platform desktop notifications with click callbacks)
#### Phase 7: App Launch & Auto-Login
**Goal:** Seamless startup with auto-login and optional lock.
**Tasks:**
- [ ] Modify app startup in `Main.kt`:
- On launch: `DesktopAccountStorage.currentAccount()`
- If account exists → auto-login (skip login screen)
- If no accounts → show login screen
- If lock enabled → show account picker with unlock
- [ ] Add lock setting to `DesktopPreferences`:
- `appLockEnabled: Boolean` (default false)
- When enabled: show account picker on every launch
- Optional: PIN/passphrase unlock (stretch goal)
- [ ] Migration on first launch:
- Detect `last_account.txt` / `bunker_uri.txt` / `nwc_connection.txt`
- Auto-migrate to new encrypted multi-account store
- **Verify**: read back decrypted npub matches before deleting old files
- Set migrated account as active
- Best-effort secure-delete old files (zero-overwrite + delete)
- User sees no change
**Files modified:**
- `desktopApp/.../Main.kt` (MODIFY)
- `desktopApp/.../DesktopPreferences.kt` (MODIFY — add lock setting)
## System-Wide Impact
### Interaction Graph
- Account switch → `AccountManager.switchAccount()` → loads new account (validates) → creates new `CoroutineScope` → cancels old scope → relay connections killed → `BackgroundNotificationManager` starts tracking old account → new relay connections established → new subscriptions → UI recomposes via `AccountState.LoggedIn`
- Background notification → `BackgroundNotificationManager` receives event → updates `ImmutableMap` StateFlow → `derivedStateOf` in each badge → only affected badge recomposes → `DesktopTrayIntegration` updates menu via recomposition + fires OS notification via `two-slices`
### Error Propagation
- Failed relay connection during switch → show error toast, offer retry
- Corrupted encrypted storage → fallback to empty account list, show login. Nsecs survive in OS keychain
- SecureKeyStorage failure (OS keychain unavailable) → log error, fall back to encrypted file storage
- NIP-46 bunker unreachable on switch → show "Connecting to signer..." state, timeout after 30s
- **Switch failure** → old scope still alive (new ordering), revert to previous `AccountState.LoggedIn`
### State Lifecycle Risks
- **Partial switch failure**: With corrected ordering (load first, cancel second), old scope remains alive on failure. Revert by re-emitting previous `AccountState.LoggedIn`. No unrecoverable state.
- **Background subscription leak**: `BackgroundNotificationManager` tied to app-level `CoroutineScope`, cancelled in shutdown hook. Per-account jobs tracked in `Mutex`-guarded map.
- **Migration data loss**: Write new encrypted format → read back and verify decrypted content → only then secure-delete old files.
- **NIP-46 relay pollution**: Each bunker account has dedicated isolated `NostrClient` with its own `CoroutineScope` (proven fix from `docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md`).
### API Surface Parity
- `AccountStorage` interface shared between Android (`LocalPreferences`) and Desktop (`DesktopAccountStorage`)
- `AccountInfo`, `AccountState` types shared — both platforms use identical state representations
- `IAccount` interface already in commons — both platforms implement it
- Session managers remain platform-specific (necessary due to Android ContentResolver/MLS deps)
## Acceptance Criteria
### Functional Requirements
- [ ] Can add multiple accounts (nsec, NIP-46 bunker, npub view-only)
- [ ] Can switch between accounts via sidebar dropdown
- [ ] Relay connections properly killed and re-established on switch
- [ ] Active account persists across app restarts (auto-login)
- [ ] Per-account notification counts shown in switcher (kinds 1, 6, 9735, 1059, 4)
- [ ] System tray shows active account and switch menu with dynamic updates
- [ ] Desktop notifications tagged with account name (working on all platforms)
- [ ] Clicking notification switches to that account
- [ ] Account removal deletes all associated data
- [ ] Existing single-account users auto-migrated silently
- [ ] `Cmd+Shift+A` / `Ctrl+Shift+A` opens account switcher
- [ ] `Cmd+Shift+1..9` / `Ctrl+Shift+1..9` for direct account switching
- [ ] NWC URI migrated from plaintext to encrypted storage
### Non-Functional Requirements
- [ ] Account switch completes in <2s (relay reconnection)
- [ ] Background subscriptions use <5MB memory per inactive account
- [ ] Max 5 relay connections per inactive account
- [ ] Encrypted storage uses AES-256-GCM with random key in OS keychain
- [ ] No nsec stored in plaintext anywhere
- [ ] No nsec cached in AccountState (fetch on-demand from SecureKeyStorage)
### Quality Gates
- [ ] Unit tests for AccountInfo/AccountState types
- [ ] Unit tests for DesktopAccountStorage (encrypt/decrypt, migration, key bootstrap)
- [ ] Unit tests for switch ordering (load-first-cancel-second)
- [ ] Unit tests for BackgroundNotificationManager (start/stop tracking, count aggregation)
- [ ] Integration test: add account, switch, verify relay reconnection
- [ ] `./gradlew spotlessApply` passes
- [ ] `./gradlew :desktopApp:compileKotlin` succeeds
- [ ] `./gradlew :commons:compileKotlinJvm` succeeds
- [ ] Manual test: multi-account flow end-to-end on macOS, verify tray + notifications
## Dependencies & Prerequisites
- `SecureKeyStorage` in quartz (already implemented — used for both nsecs and metadata AES key)
- `IAccount` interface in commons (already exists)
- NIP-46 relay isolation fix (from `docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md`)
- Compose Desktop `Tray` API (available in Compose Multiplatform 1.7.x)
- `kotlinx.collections.immutable` (already in project)
- `com.sshtools:two-slices:0.9.6` (NEW — cross-platform desktop notifications)
## Risk Analysis & Mitigation
| Risk | Impact | Mitigation |
|------|--------|------------|
| NIP-46 relay pool pollution | Bunker relays leak into general pool | Dedicated isolated NostrClient per NIP-46 account (proven fix) |
| Encrypted storage corruption | Account metadata lost | Nsecs survive in OS keychain independently; re-add accounts from keychain |
| Background sub memory leak | Growing memory usage | `Mutex`-guarded job map, `supervisorScope`, app-scope cancel in shutdown hook |
| Migration breaks existing users | Can't login after update | Verify read-back before deleting old files; keep `.bak` as fallback |
| OS keychain unavailable | Can't store nsecs or AES key | Fallback to encrypted file (SecureKeyStorage already handles this) |
| macOS notification broken | No OS notifications | `two-slices` library uses native Notification Center instead of broken AWT |
| Switch failure mid-transition | Stuck in loading state | Load new account FIRST; old scope alive until success; revert on failure |
| Kind 7 reaction flood | Background sub memory explosion | Excluded from background filters by design |
## Future Considerations
- **Simultaneous account views** — architecture supports it (clean scope per account), would need column-level account binding
- **NIP multi-key bunker** — if NIP-46 adds `get_public_keys`, could auto-discover keys from bunker
- **Account sync** — export/import encrypted account bundle between machines
- **Drag-to-reorder accounts** — if users want custom ordering
- **NIP-45 reaction counts** — query on foreground activation instead of background subscription
- **Global keyboard shortcuts**`jnativehook` library for shortcuts even when app unfocused (stretch goal)
## Open Questions
1. **`lastSeenTimestamp` persistence** — where to store per-account `since` timestamp for background subs? In encrypted metadata file or separate preference?
2. **NIP-65 relay list for new accounts** — if relay list hasn't been fetched yet for a newly added inactive account, fetch eagerly during add or use defaults?
3. **Legacy NIP-04 DMs** — always include kind 4 in background filters, or only if account has prior DM history?
4. **`two-slices` maturity** — evaluate library stability; fallback to in-app toast notifications (dorkbox/Notify) if issues arise
5. **Workspace state per-account** — should deck layout and workspace configuration be per-account or global?
## Sources & References
### Origin
- **Brainstorm document:** [docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md](docs/brainstorms/2026-04-23-desktop-multi-account-brainstorm.md) — Key decisions: one-active-account model, top-of-sidebar dropdown, kill & reconnect relays, extract to commons, encrypted storage
### Internal References
- Android AccountSessionManager: `amethyst/src/main/java/.../ui/screen/AccountSessionManager.kt`
- Android AccountCacheState: `amethyst/src/main/java/.../model/accountsCache/AccountCacheState.kt`
- Android LocalPreferences: `amethyst/src/main/java/.../LocalPreferences.kt`
- Desktop AccountManager: `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt`
- Desktop DeckSidebar: `desktopApp/src/jvmMain/.../desktop/ui/deck/DeckSidebar.kt`
- Desktop LoginCard: `desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt`
- SecureKeyStorage: `commons/src/commonMain/.../commons/keystorage/SecureKeyStorage.kt`
- IAccount: `commons/src/commonMain/.../commons/model/IAccount.kt`
- NIP-46 relay isolation: `docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md`
- Workspace persistence pattern: `docs/plans/2026-04-17-feat-workspaces-v1c-plan.md`
- Cache state extraction pattern: `docs/plans/2026-03-24-feat-weakref-cache-state-extraction-plan.md`
### External References
- [Compose Desktop Tray API](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md)
- [two-slices notifications library](https://github.com/sshtools/two-slices)
- [Compose Desktop keyboard handling](https://kotlinlang.org/docs/multiplatform/compose-desktop-keyboard.html)
- [DropdownMenu positioning issue #3129](https://github.com/JetBrains/compose-multiplatform/issues/3129)
@@ -0,0 +1,70 @@
# Fix: Relax libicu dependency in .deb packages
**Date**: 2026-04-27
**Issue**: [#2579](https://github.com/vitorpamplona/amethyst/issues/2579)
**Branch**: `fix/deb-libicu-dependency`
**Reference**: upstream branch `fix-libicu74-dependency`
## Problem
jpackage runs `dpkg-shlibdeps` against the bundled JDK's native libs (libfontmanager.so links libicu), pinning `Depends:` to the build host's libicu version (libicu74 on ubuntu-24.04). Users on Debian 12 (libicu72), Debian 13 (libicu76), Ubuntu 22.04 (libicu70) etc. cannot install.
Neither jpackage nor Compose Desktop DSL expose a way to override auto-generated Depends.
## Solution
Post-process the .deb after jpackage: extract, rewrite the libicu dependency to accept any version from libicu66libicu77, repack.
## Changes
### 1. Add `scripts/relax-deb-libicu.sh`
Shell script that:
- Takes one or more .deb paths as arguments
- Extracts with `dpkg-deb -R`
- Uses sed to replace `libicuNN` (or alternation) with `libicu66 | libicu67 | libicu70 | libicu72 | libicu74 | libicu76 | libicu77`
- Repacks with `dpkg-deb -b`
- Idempotent — no-op when no libicu dep present
### 2. Update `.github/workflows/build.yml`
Add step after "Build Desktop Distribution" for the `packageDeb` matrix leg:
```yaml
- name: Relax libicu dependency in .deb
if: matrix.task == 'packageDeb'
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main/deb/*.deb
```
Insert between "Build Desktop Distribution" (line 160) and "Stop Gradle Daemon" (line 162).
### 3. Update `.github/workflows/create-release.yml`
Add step after "Build desktop artifacts" for the `linux` family leg:
```yaml
- name: Relax libicu dependency in .deb
if: matrix.family == 'linux'
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
```
Insert between "Build desktop artifacts" (line 99-104) and "Build portable archives" (line 106).
Note: `linux-portable` leg doesn't build .deb so no action needed there.
## Testing
- Script is idempotent — safe to run on any .deb
- CI will validate on next PR build (ubuntu-latest produces .deb artifact)
- Manual: install resulting .deb on Debian 13 (libicu76) to verify
## Unanswered Questions
- Should we also handle RPM libicu deps? (RPM has different dep mechanism, likely not affected since RPM uses `Requires:` from bundled libs differently)
- Should the libicu version list be externalized to a config file for easier updates? (Probably YAGNI — adding a new version is a one-line sed edit)
@@ -0,0 +1,106 @@
# Multi-Account Testing Sheet
**Branch:** `feat/desktop-multi-account`
**Date:** 2026-04-28
**Tester:**
## Pre-test Setup
- [ ] Clean build: `./gradlew :desktopApp:clean :desktopApp:run`
- [ ] Delete `~/.amethyst/accounts.json.enc` if exists (fresh state)
---
## T1: Fresh Launch (No Accounts)
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 1.1 | Launch app | Login screen shown | | |
| 1.2 | Check sidebar | Person icon visible at top of sidebar (replaces "A" logo) | | |
| 1.3 | Click person icon | Dropdown shows "No accounts" + "Add Account" | | |
## T2: Login with nsec (First Account)
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 2.1 | Paste nsec on login screen | Login succeeds, feed loads | | |
| 2.2 | Click "Save" (if prompted) | Account saved to encrypted storage | | |
| 2.3 | Check `~/.amethyst/accounts.json.enc` exists | File exists, not readable as plaintext | | |
| 2.4 | Click person icon in sidebar | Dropdown shows 1 account with checkmark + "Add Account" | | |
| 2.5 | Verify account npub shown | First 16 chars of npub visible | | |
## T3: Login with Bunker (Second Account)
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 3.1 | Log out current account | Login screen shown | | |
| 3.2 | Login with bunker:// URI | Bunker connection succeeds, feed loads | | |
| 3.3 | Click person icon | Dropdown shows 2 accounts (nsec + bunker) | | |
| 3.4 | Bunker account shows "Bunker" label | Signer type label visible under npub | | |
| 3.5 | Active account has checkmark | Only current account checked | | |
## T4: Login with npub (View-Only, Third Account)
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 4.1 | Log out, paste npub on login | Login succeeds (read-only mode) | | |
| 4.2 | Click person icon | Dropdown shows 3 accounts | | |
| 4.3 | View-only shows label | "View only" label visible | | |
## T5: Account Switching
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 5.1 | Click different account in dropdown | App switches, feed reloads | | |
| 5.2 | Check relay reconnection | Relay indicators show reconnection activity | | |
| 5.3 | Switch back to first account | Feed loads with first account's data | | |
| 5.4 | Verify active checkmark moves | Checkmark on newly active account | | |
| 5.5 | Switch to bunker account | Bunker heartbeat indicator appears | | |
| 5.6 | Switch away from bunker | Heartbeat stops, NIP-46 cleaned up | | |
## T6: Persistence Across Restarts
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 6.1 | Close app (Cmd+Q) | Clean exit | | |
| 6.2 | Relaunch app | Auto-login to last active account | | |
| 6.3 | Click person icon | All previously added accounts still listed | | |
| 6.4 | Switch to different account, restart | Restarts into the account you switched to | | |
## T7: Account Removal
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 7.1 | Remove a non-active account | Account disappears from list, active stays | | |
| 7.2 | Remove the active account | App switches to next account or shows login | | |
| 7.3 | Remove all accounts | Login screen shown | | |
## T8: Encrypted Storage Security
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 8.1 | `cat ~/.amethyst/accounts.json.enc` | Binary/encrypted data, no plaintext npubs | | |
| 8.2 | `hexdump -C ~/.amethyst/accounts.json.enc | head` | No readable strings | | |
| 8.3 | File permissions: `ls -la ~/.amethyst/accounts.json.enc` | `-rw-------` (owner only) | | |
## T9: Edge Cases
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 9.1 | Login with invalid nsec | Error message, stays on login screen | | |
| 9.2 | Login with same account twice | Updates existing entry, no duplicate | | |
| 9.3 | Rapid account switching (click fast) | No crash, settles on last clicked | | |
| 9.4 | Switch while relays still connecting | Handles gracefully, no stuck state | | |
## T10: UI/UX
| # | Step | Expected | Pass? | Notes |
|---|------|----------|-------|-------|
| 10.1 | Dropdown positioning | Opens to the right of 48dp sidebar | | |
| 10.2 | Click outside dropdown | Dropdown dismisses | | |
| 10.3 | "Add Account" button | Visible at bottom with divider | | |
| 10.4 | Sidebar icon size | Person icon fits within 48dp width | | |
---
## Known Limitations (Not Bugs)
- "Add Account" button is wired but has no dialog yet (TODO)
- No keyboard shortcut (Cmd+Shift+A) yet
- No background notification counts yet
- No system tray integration yet
- Account removal not yet exposed in UI (only programmatic)
- NWC connection is global, not per-account
## Test Environment
- OS:
- JDK version:
- Amethyst version: 1.08.0 (feat/desktop-multi-account)