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>
18 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: Customizable Nav Bar (v1b) | feat | active | 2026-04-17 | 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
PinnedNavBarState— state holder managing an ordered list of pinnedDeckColumnTypeitems, backed byDesktopPreferences- Context menus on drawer items ("Pin to sidebar") and nav items ("Unpin", "Move up/down")
- Drag-to-reorder on nav rail items
- DeckSidebar gets pinned icons between the "+" button and the spacer
- Default pinned items = current
navItemslist (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
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:
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:
// 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):
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):
// 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
@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
// 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:
// 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?