Files
amethyst/docs/plans/2026-04-17-feat-workspace-management-ux-plan.md
T
nrobi144 250bb5a1ad 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>
2026-04-29 09:35:33 +03:00

642 lines
27 KiB
Markdown

---
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.