250bb5a1ad
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
30 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: Workspaces (v1c) | feat | active | 2026-04-17 | 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
// 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
// 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():
// 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
// 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
// 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)
// 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
// 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
// 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—Workspacedata class,WorkspaceIconsobject - New:
desktopApp/.../ui/deck/WorkspaceManager.kt— state management, save/load - Modify:
desktopApp/.../DesktopPreferences.kt— addworkspaceskey - Modify:
desktopApp/.../ui/deck/DeckState.kt— addloadFromWorkspace(), exposeparseTypeKey()
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:
AppDrawersignature — addworkspaceManager+onSwitchWorkspaceparams
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— addupdateCurrentColumns()for live tracking
// 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.workspacesis empty butDesktopPreferences.deckColumnshas data:- Create "Social" workspace from existing deck columns
- Set as active workspace
- Existing layout preserved on first upgrade
// 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?