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>
27 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: Workspace Management UX | feat | active | 2026-04-17 | 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 FilterChips — 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
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
// 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:
@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
@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)
@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)
@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:
// 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)
}
}
@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
// 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:
// 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:
// 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:
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.
// 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
- Phase 4 first — Fix layoutMode on workspace switch (critical bug fix, smallest change)
- Phase 1a-1b — Tab system in AppDrawerState
- Phase 1c-1d — Workspace cards (replace WorkspaceBar)
- Phase 2 — Workspace editor dialog
- Phase 3 — Unified search
- Phase 6 — Keyboard navigation for unified search
- 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
layoutModeand persists it toDesktopPreferences - 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 functionWorkspaceBarremoved from bottom of drawerspotlessApplypasses
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
-
SegmentedButton/SingleChoiceSegmentedButtonRowavailability — 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 toWorkspaceEditorDialog. -
AssistChipinWorkspaceCard—AssistChiprequiresonClickparameter, but we're using it as a read-only badge. Should useSuggestionChipinstead, or just aSurfacewithTextfor a non-interactive badge. Fix: Replace with a styledSurface+Textbadge: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, ) } -
DeckState.parseColumnTypeFromKeyexposure — The plan says "expose as public companion fun" butparseColumnTypetakesMap<String, Any?>. The new public wrapper correctly wraps it. No issue, but ensure the wrapper handles theparamfor Hashtag columns (e.g.,parseColumnTypeFromKey("hashtag", "bitcoin")). Verified: the existing privateparseColumnTypealready handles this correctly. -
filteredWorkspacesreadsworkspaceManager.workspaces.valuedirectly — This is aStateFlow.valuesnapshot inside aderivedStateOf. It won't recompose when workspaces change becausederivedStateOfdoesn't trackStateFlow. Fix:AppDrawerStatemust accept the workspace list as a parameter from acollectAsState()call, or thederivedStateOfmust be computed at the composable level. Best approach: passworkspaces: List<Workspace>into state methods instead of reading.value:// 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.
-
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+Sinstead, 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 toCmd+Shift+S.
P2: Over-engineering / YAGNI / Simplification
-
showWorkspaceEditor+pendingWorkspacestate 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. -
SinglePaneScreenPickerin editor — For v1, a simple dropdown ofLAUNCHABLE_SCREENSis sufficient. No need for a fancy picker component. -
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.
-
DeckColumnEditordrag-to-reorder — Brainstorm explicitly defers this. Good.
P3: Style / Naming / Minor
DrawerTabenum — Consider namingAppDrawerTabto avoid collision withTabcomposable in imports.totalResultCount— Used for bounds checking but never displayed. Name could beresultCount.- Column preview shows raw
typeKey— e.g., "home, notifications, messages". Should show display names. Fix: useDeckState.parseColumnTypeFromKey(col.typeKey)?.title() ?: col.typeKeyfor human-readable names. WorkspaceEditorDialogsize —AlertDialogmay be too small for the icon picker + column editor. Consider using a customDialogwith fixed size instead.