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>
30 KiB
title, type, status, date, deepened, origin
| title | type | status | date | deepened | origin |
|---|---|---|---|---|---|
| feat: App Drawer with Categories and Search (v1a) | feat | active | 2026-03-30 | 2026-03-30 | 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
- Eliminated
DrawerScreenwrapper — useDeckColumnTypedirectly withcategory()extension. Single source of truth (3 reviewers agreed) - Reduced from 4 files to 2 —
AppDrawer.kt+DeckColumnTypeExtensions.ktin existingui/deck/package - Fixed critical race condition —
if()block instead ofAnimatedVisibilityprevents stale state on re-open - Proper Desktop patterns —
onPreviewKeyEvent(notonKeyEvent),FocusRequesterwithawaitFrame(),indication = nullon backdrop - Added
SinglePaneState— mirrorsDeckStatepattern, solves state hoisting problem, prepares for v1b/v1c derivedStateOffor filtered/grouped lists — prevents redundant computation on every recomposition- Double-click guard — prevents duplicate column creation
Architectural Corrections
- No new
ui/drawer/package — files stay inui/deck/(consistent with codebase) - No
DrawerScreendata class —DeckColumnTypealready hastitle()andicon() - No
AppDrawerStateseparate file — state is simple enough to live insideAppDrawer.kt - No
ScreenCategoryas separate file —category()is an extension function onDeckColumnType
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:
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:
// 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
SinglePaneStatenow avoids the state-hoisting problem (drawer atApp()level needs to setcurrentColumnTypeinSinglePaneLayout) and prepares for v1b wherepinnedScreens: StateFlow<List<DeckColumnType>>replaces the hardcodednavItems.
Phase 2: AppDrawer Composable
File: AppDrawer.kt — single file with everything:
// 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:
derivedStateOfforfilteredScreens/groupedScreensprevents triple recomputation per keystroke- Desktop expert:
onPreviewKeyEvent(notonKeyEvent) intercepts before TextField consumes arrow/Enter keys. Pattern fromSearchScreen.kt:293,ChatPane.kt:729- Desktop expert:
Box(fillMaxSize)overlay pattern matchesGlobalFullscreenOverlay.ktandLightboxOverlay.kt— NOTPopuporDialog- Race conditions (CRITICAL):
if (showAppDrawer)block destroys/recreates composable on each open — freshrememberstate, no stale data. Do NOT useAnimatedVisibility- Race conditions:
consumedflag prevents double-click adding duplicate columns- Compose expert:
pointerInputconsume on content Surface is more robust thanclickable(enabled = false)for preventing click-through- Kotlin expert:
@StableonAppDrawerStatetells Compose mutations go through snapshot system- Pattern recognition: Using
DeckColumnTypedirectly withtitle()/icon()/category()instead of wrapper eliminates data duplication
Phase 3: Wire into Main.kt
// 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()foropenColumnTypesrendering, notdeckState.columns.value. Direct.valuereads are stale for UI — Compose won't recompose when the flow emits.
Phase 4: Update DeckSidebar + SinglePaneLayout
DeckSidebar.kt:
- Replace
onShowAddColumnDialogcallback →onShowAppDrawer
SinglePaneLayout.kt:
- Accept
SinglePaneStateinstead of localcurrentColumnType - Accept
onOpenAppDrawer: () -> Unit - Add
IconButton(Icons.Default.Apps)at bottom of NavigationRail - Replace hardcoded
navItemswithLAUNCHABLE_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
showAddColumnDialogfromMain.kt - Remove
COLUMN_OPTIONSlist - Remove
onShowAddColumnDialogcallbacks fromDeckSidebar, etc. - Verify
navItemsinSinglePaneLayoutis replaced or reads fromLAUNCHABLE_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:
onPreviewKeyEventfires before children process the event. Returningtruefor arrows/Enter prevents TextField from consuming them. Returningfalsefor printable keys lets TextField handle typing. This exact pattern is used inSearchScreen.kt:293andChatPane.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.remembercreates freshAppDrawerStateon each open. NoLaunchedEffect(Unit) { reset() }needed. - Hashtag input cleanup: Automatic — state destroyed on dismiss.
- SinglePaneLayout state:
SinglePaneStatecreated atApp()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 (fromtitle()) - 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.ktdeleted, no remaining referencesCOLUMN_OPTIONSandnavItemsremoved/replaced
Non-Functional Requirements
- Drawer opens in <100ms (no network calls, pure UI)
- Search filtering is instant (
derivedStateOfcaching) - 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
Internal References (Codebase Patterns)
GlobalFullscreenOverlay.kt— fullscreen Box overlay pattern (z-order in MainContent)LightboxOverlay.kt:202-253— focusRequester + onKeyEvent + clickable scrim patternSearchScreen.kt:293—onPreviewKeyEventintercepting Enter/Escape before TextFieldChatPane.kt:729— sameonPreviewKeyEventpatternDeckColumnType.kt:25-111— sealed class,title(),typeKey()ColumnHeader.kt:123-142—icon()extensionDeckState.kt— StateFlow pattern, addColumn(), save/load persistenceSinglePaneLayout.kt:80-93,115— navItems + currentColumnType (being replaced)Main.kt:187,297-307,570-578— showAddColumnDialog + shortcuts + dialog renderAddColumnDialog.kt:49-62— COLUMN_OPTIONS (being deleted)
External References
Open Questions
- Should
SinglePaneStatepersist last-viewed screen toDesktopPreferences? (nice-to-have) - Should
ScreenCategorylive inDeckColumnType.ktorColumnHeader.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)