diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 6f8c8f9cb..80200b620 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Lock @@ -141,6 +142,7 @@ fun ChatPane( messageState: ChatNewMessageState, dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, onNavigateToProfile: (String) -> Unit = {}, + onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -209,24 +211,43 @@ fun ChatPane( ), ) { // Header - if (isGroup) { - GroupChatroomHeader( - users = users, - onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, - ) - } else { - users.firstOrNull()?.let { user -> - ChatroomHeader( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) - } ?: run { - // Fallback header with raw pubkey - Text( - text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(10.dp), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + if (onBack != null) { + IconButton( + onClick = onBack, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to conversations", + ) + } + } + + Box(modifier = Modifier.weight(1f)) { + if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) + } else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + // Fallback header with raw pubkey + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index ec2c9d9e7..8f6bb36dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -119,7 +119,6 @@ fun ConversationListPane( Column( modifier = modifier - .width(280.dp) .fillMaxHeight() .focusRequester(focusRequester) .focusable() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 599ee6929..fa8da9e2e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -24,8 +24,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Icon @@ -59,18 +61,19 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.CoroutineScope private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") /** - * Desktop DM screen with split-pane layout. + * Desktop DM screen with two layout modes: * - * Left pane (280dp): ConversationListPane with Known/New tabs - * Right pane (flex): ChatPane with messages + input, or empty state + * - **Split mode** (compactMode = false): Side-by-side with conversation list (280dp) + chat pane. + * Used in single-pane layout where there's plenty of horizontal space. * - * @param account The user's IAccount for DM operations - * @param cacheProvider ICacheProvider for user/note lookups - * @param onNavigateToProfile Called when navigating to a user profile + * - **Compact mode** (compactMode = true): Stacked navigation — full-width contact list OR + * full-width chat. Used in multi-deck columns where width is limited. */ @Composable fun DesktopMessagesScreen( @@ -78,6 +81,7 @@ fun DesktopMessagesScreen( cacheProvider: ICacheProvider, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, + compactMode: Boolean = false, onNavigateToProfile: (String) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -89,51 +93,159 @@ fun DesktopMessagesScreen( val listFocusRequester = remember { FocusRequester() } var showNewDmDialog by remember { mutableStateOf(false) } - Row( - modifier = - Modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + // Shared keyboard shortcuts + val keyHandler = + Modifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed - when { - // Escape -> deselect conversation - event.key == Key.Escape -> { - listState.clearSelection() - true - } + when { + event.key == Key.Escape -> { + listState.clearSelection() + true + } - // Cmd+Shift+N / Ctrl+Shift+N -> new DM - event.key == Key.N && isModifier && event.isShiftPressed -> { - showNewDmDialog = true - true - } + event.key == Key.N && isModifier && event.isShiftPressed -> { + showNewDmDialog = true + true + } - else -> { - false - } - } - }, - ) { - // Left pane: conversation list (280dp fixed) + else -> { + false + } + } + } + + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } + + if (showNewDmDialog) { + NewDmDialog( + cacheProvider = cacheProvider, + relayManager = relayManager, + localCache = localCache, + onUserSelected = { roomKey -> + listState.selectRoom(roomKey) + showNewDmDialog = false + }, + onDismiss = { showNewDmDialog = false }, + ) + } +} + +/** + * Compact (stacked) layout for deck columns. + * Shows either the contact list OR the chat, never both. + */ +@Composable +private fun CompactMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Box(modifier = Modifier.fillMaxSize().then(keyHandler)) { + val currentRoom = selectedRoom + if (currentRoom != null) { + val feedViewModel = + remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = + remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + val broadcastStatus = + if (account is DesktopIAccount) { + account.dmSendTracker.status + .collectAsState() + .value + } else { + DmBroadcastStatus.Idle + } + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + dmBroadcastStatus = broadcastStatus, + onNavigateToProfile = onNavigateToProfile, + onBack = { listState.clearSelection() }, + ) + } else { + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, + focusRequester = listFocusRequester, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** + * Split (side-by-side) layout for single-pane mode. + * Contact list (280dp) + divider + chat pane (flex). + */ +@Composable +private fun SplitMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Row(modifier = Modifier.fillMaxSize().then(keyHandler)) { ConversationListPane( state = listState, selectedRoom = selectedRoom, - onConversationSelected = { roomKey -> - listState.selectRoom(roomKey) - }, - onNewConversation = { showNewDmDialog = true }, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, focusRequester = listFocusRequester, + modifier = Modifier.width(280.dp), ) VerticalDivider(modifier = Modifier.fillMaxHeight()) - // Right pane: chat or empty state (flex) Box(modifier = Modifier.weight(1f).fillMaxHeight()) { val currentRoom = selectedRoom if (currentRoom != null) { - // Create feed VM and message state scoped to the selected room val feedViewModel = remember(currentRoom) { ChatroomFeedViewModel(currentRoom, account, cacheProvider) @@ -142,7 +254,6 @@ fun DesktopMessagesScreen( remember(currentRoom) { ChatNewMessageState(account, cacheProvider, scope) } - val broadcastStatus = if (account is DesktopIAccount) { account.dmSendTracker.status @@ -162,28 +273,14 @@ fun DesktopMessagesScreen( onNavigateToProfile = onNavigateToProfile, ) } else { - // Empty state EmptyConversationState() } } } - - if (showNewDmDialog) { - NewDmDialog( - cacheProvider = cacheProvider, - relayManager = relayManager, - localCache = localCache, - onUserSelected = { roomKey -> - listState.selectRoom(roomKey) - showNewDmDialog = false - }, - onDismiss = { showNewDmDialog = false }, - ) - } } /** - * Shown when no conversation is selected in the right pane. + * Shown when no conversation is selected in the split layout right pane. */ @Composable private fun EmptyConversationState() { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 7a7d8847f..bfe9748de 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -209,6 +209,7 @@ internal fun RootContent( cacheProvider = localCache, relayManager = relayManager, localCache = localCache, + compactMode = true, onNavigateToProfile = onNavigateToProfile, ) } diff --git a/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md new file mode 100644 index 000000000..640609897 --- /dev/null +++ b/docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md @@ -0,0 +1,75 @@ +# Brainstorm: Stacked Messages Layout in Multi-Deck + +**Date:** 2026-03-19 +**Status:** Ready for planning + +## What We're Building + +Replace the side-by-side split-pane Messages layout (contact list + chat) with a stacked navigation in deck columns. Clicking a conversation navigates from the contact list to the chat view; a back arrow returns to the list. Single-pane (non-deck) mode keeps the current split layout. + +## Why This Approach + +In multi-deck mode, columns can be 350-400dp wide. The current split layout allocates 280dp to the contact list, leaving only 70-120dp for the chat pane — unusable. A stacked layout gives the full column width to whichever view is active. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Layout mode | Always stacked in deck columns | Simplest, works at any column width | +| Single-pane mode | Keep split layout | Plenty of horizontal space in single-pane | +| Back navigation | Back arrow in chat header | Discoverable; Escape key already works | +| State management | `selectedRoom` in `ChatroomListState` already controls this | No new state needed — just show list when null, chat when selected | + +## Architecture + +### Current Flow (DesktopMessagesScreen.kt) +``` +Row { + ConversationListPane(280dp) // always visible + VerticalDivider + ChatPane(flex) // or EmptyState +} +``` + +### New Flow (Deck Mode) +``` +// When selectedRoom == null: +ConversationListPane(full width) + +// When selectedRoom != null: +Column { + BackArrow + ChatroomHeader + ChatPane(full width) +} +``` + +### Implementation Approach + +`DesktopMessagesScreen` already has `selectedRoom` state. The change is layout-only: + +1. Add a `compactMode: Boolean` parameter to `DesktopMessagesScreen` +2. In deck mode (`compactMode = true`): show either list OR chat, not both +3. In single-pane mode (`compactMode = false`): keep current split Row +4. Add back arrow to `ChatPane` header when in compact mode +5. `clearSelection()` → back to list (already exists) + +### What Changes + +| Component | Change | +|-----------|--------| +| `DesktopMessagesScreen` | Add `compactMode` param; conditional layout (Row vs when/else) | +| `ChatPane` | Add `onBack` callback + back arrow in header when provided | +| `RootContent` | Pass `compactMode = true` to DesktopMessagesScreen | +| `SinglePaneLayout` `RootContent` | Pass `compactMode = false` | +| `ConversationListPane` | No changes — already works at any width | + +### Edge Cases + +- **Keyboard nav**: Escape already calls `clearSelection()` — works as back +- **New DM dialog**: Opens over whichever view is active — no change needed +- **Receiving DM while in list**: Room appears/updates in list normally +- **Receiving DM while in chat**: Messages appear in real-time — no change + +## Open Questions + +None — all decisions resolved. diff --git a/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md new file mode 100644 index 000000000..bdb90980e --- /dev/null +++ b/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md @@ -0,0 +1,343 @@ +--- +title: "feat: Stacked messages layout in deck columns" +type: feat +status: completed +date: 2026-03-19 +deepened: 2026-03-19 +origin: docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md +--- + +# feat: Stacked Messages Layout in Deck Columns + +## Enhancement Summary + +**Deepened on:** 2026-03-19 +**Files to change:** 4 +**Approach:** Add `compactMode` flag, conditional layout in `DesktopMessagesScreen` + +### Key Implementation Details +1. `ConversationListPane` width is hardcoded at line 122 (`Modifier.width(280.dp)`) — remove it, let caller control width +2. `ChatPane` header (lines 211-231) uses `ChatroomHeader`/`GroupChatroomHeader` — wrap with `Row` adding back arrow +3. `DesktopMessagesScreen` already has `selectedRoom` state and `clearSelection()` — stacked nav is pure layout change +4. Keyboard Escape handling already at line 102 calls `listState.clearSelection()` — works as-is for back nav + +--- + +## Overview + +Replace the side-by-side split-pane Messages layout with stacked navigation in deck columns. Full-width contact list OR full-width chat — clicking a conversation navigates to chat, back arrow returns to list. Single-pane mode keeps the current split layout. + +## Problem Statement + +In multi-deck mode, columns are 350-400dp wide. The current layout allocates 280dp to `ConversationListPane` (line 122) and the remaining 70-120dp to `ChatPane` — unusable. + +(see brainstorm: `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`) + +--- + +## Step 1: Make ConversationListPane width flexible + +**File:** `desktopApp/.../ui/chats/ConversationListPane.kt` (line 119-123) + +**Current code:** +```kotlin +Column( + modifier = + modifier + .width(280.dp) // ← hardcoded, breaks compact mode + .fillMaxHeight() +``` + +**Change:** Remove the hardcoded width from the composable. The caller controls width via the `modifier` parameter. + +```kotlin +Column( + modifier = + modifier + .fillMaxHeight() +``` + +**Call sites:** +- Split mode (DesktopMessagesScreen): passes no modifier → add `Modifier.width(280.dp)` at call site +- Compact mode: passes `Modifier.fillMaxWidth()` → uses full column width + +### Research Insights + +- `ConversationListPane` already accepts `modifier: Modifier = Modifier` (line 95) — just unused for width +- The keyboard nav (`onPreviewKeyEvent` at line 126) and `LazyColumn` (line 243) work at any width +- `ConversationCard` (line 268) uses `Modifier.fillMaxWidth()` — adapts automatically + +--- + +## Step 2: Add `onBack` to ChatPane header + +**File:** `desktopApp/.../ui/chats/ChatPane.kt` (lines 136-144, 211-231) + +**Current signature:** +```kotlin +fun ChatPane( + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, + feedViewModel: ChatroomFeedViewModel, + messageState: ChatNewMessageState, + dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, + onNavigateToProfile: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) +``` + +**Add:** `onBack: (() -> Unit)? = null` parameter. + +**Current header (lines 211-231):** +```kotlin +// Header +if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) +} else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } +} +``` + +**New header:** Wrap in a `Row` with conditional back arrow: + +```kotlin +Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), +) { + if (onBack != null) { + IconButton( + onClick = onBack, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to conversations", + ) + } + } + + Box(modifier = Modifier.weight(1f)) { + // Existing header content (ChatroomHeader / GroupChatroomHeader / fallback) + } +} +``` + +### Research Insights + +- `Icons.AutoMirrored.Filled.ArrowBack` is already available in material-icons-extended +- `ChatroomHeader` and `GroupChatroomHeader` are shared composables from commons — don't modify them +- The `Row` wrapper doesn't affect the existing divider at line 233 (`HorizontalDivider()`) + +--- + +## Step 3: Refactor DesktopMessagesScreen layout + +**File:** `desktopApp/.../ui/chats/DesktopMessagesScreen.kt` + +**Current:** Single `Row` layout (lines 92-168) with `ConversationListPane` + `VerticalDivider` + `ChatPane`/`EmptyState`. + +**Change:** Add `compactMode: Boolean = false` parameter. Extract existing Row into a private `SplitMessagesContent` composable. Add a new `CompactMessagesContent` for stacked mode. + +```kotlin +@Composable +fun DesktopMessagesScreen( + account: IAccount, + cacheProvider: ICacheProvider, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + compactMode: Boolean = false, // NEW + onNavigateToProfile: (String) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val listState = remember(account) { + ChatroomListState(account, cacheProvider, relayManager, localCache, scope) + } + val selectedRoom by listState.selectedRoom.collectAsState() + val listFocusRequester = remember { FocusRequester() } + var showNewDmDialog by remember { mutableStateOf(false) } + + // Keyboard shortcuts (shared between modes) + val keyHandler = Modifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + when { + event.key == Key.Escape -> { + listState.clearSelection() + true + } + event.key == Key.N && isModifier && event.isShiftPressed -> { + showNewDmDialog = true + true + } + else -> false + } + } + + if (compactMode) { + CompactMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + showNewDmDialog = showNewDmDialog, + onShowNewDm = { showNewDmDialog = true }, + keyHandler = keyHandler, + ) + } else { + SplitMessagesContent( + selectedRoom = selectedRoom, + listState = listState, + account = account, + cacheProvider = cacheProvider, + scope = scope, + onNavigateToProfile = onNavigateToProfile, + listFocusRequester = listFocusRequester, + keyHandler = keyHandler, + onShowNewDm = { showNewDmDialog = true }, + ) + } + + // New DM dialog (shared) + if (showNewDmDialog) { /* existing NewDmDialog code */ } +} +``` + +**CompactMessagesContent:** +```kotlin +@Composable +private fun CompactMessagesContent( + selectedRoom: ChatroomKey?, + listState: ChatroomListState, + account: IAccount, + cacheProvider: ICacheProvider, + scope: CoroutineScope, + onNavigateToProfile: (String) -> Unit, + listFocusRequester: FocusRequester, + showNewDmDialog: Boolean, + onShowNewDm: () -> Unit, + keyHandler: Modifier, +) { + Box(modifier = Modifier.fillMaxSize().then(keyHandler)) { + val currentRoom = selectedRoom + if (currentRoom != null) { + // Full-width chat with back arrow + val feedViewModel = remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + val broadcastStatus = if (account is DesktopIAccount) { + account.dmSendTracker.status.collectAsState().value + } else DmBroadcastStatus.Idle + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + dmBroadcastStatus = broadcastStatus, + onNavigateToProfile = onNavigateToProfile, + onBack = { listState.clearSelection() }, + ) + } else { + // Full-width contact list + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { listState.selectRoom(it) }, + onNewConversation = onShowNewDm, + focusRequester = listFocusRequester, + modifier = Modifier.fillMaxSize(), + ) + } + } +} +``` + +**SplitMessagesContent:** Extract existing `Row` code verbatim from current `DesktopMessagesScreen`, adding `Modifier.width(280.dp)` to the `ConversationListPane` call. + +--- + +## Step 4: Wire compactMode from deck + +**File:** `desktopApp/.../ui/deck/DeckColumnContainer.kt` (line 206-214) + +```kotlin +DeckColumnType.Messages -> { + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + relayManager = relayManager, + localCache = localCache, + compactMode = true, // ← ADD THIS + onNavigateToProfile = onNavigateToProfile, + ) +} +``` + +`SinglePaneLayout` — no change needed, `compactMode` defaults to `false`. + +--- + +## Edge Cases + +| Scenario | Behavior | Verified by | +|----------|----------|-------------| +| Escape in chat | `clearSelection()` → back to list | Existing keyboard handler (line 102) | +| Escape in list | No-op (already no selection) | Same handler, `clearSelection()` on null is safe | +| New DM dialog in compact chat | Dialog opens over chat, selecting user switches to that chat | `showNewDmDialog` is shared state | +| Receiving DM while viewing list | Chatroom list updates via 2s polling | `ChatroomListState.refreshRooms()` | +| Receiving DM while in chat | Messages appear real-time via `ChatroomFeedViewModel` | No change needed | +| Back arrow + drag-drop | Drag-drop zone is on the ChatPane Column, unaffected by back arrow Row | Separate modifier chain | + +--- + +## Acceptance Criteria + +- [x] Deck Messages column shows full-width contact list (no split) +- [x] Clicking conversation navigates to full-width chat view +- [x] Back arrow visible in chat header (compact mode only) +- [x] Clicking back arrow returns to contact list +- [x] Escape key still returns to contact list +- [x] Single-pane mode unchanged (split layout preserved) +- [x] Keyboard navigation (up/down/enter) still works in contact list +- [x] New DM dialog still works in both modes +- [x] ConversationListPane uses full width in compact mode + +## Files Changed + +| File | Change | Lines affected | +|------|--------|---------------| +| `ConversationListPane.kt` | Remove hardcoded `width(280.dp)` | Line 122 | +| `ChatPane.kt` | Add `onBack` param, wrap header in Row with back arrow | Lines 136-144, 211-231 | +| `DesktopMessagesScreen.kt` | Add `compactMode`, extract `SplitMessagesContent`/`CompactMessagesContent` | Major refactor | +| `DeckColumnContainer.kt` | Pass `compactMode = true` | Line ~208 | + +## Sources + +- **Brainstorm:** `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md` +- `DesktopMessagesScreen.kt:75-213` — current split-pane layout +- `ChatPane.kt:136-144` — current signature; `211-231` — header section +- `ConversationListPane.kt:119-122` — hardcoded width; `95` — modifier param +- `DeckColumnContainer.kt:206-214` — Messages deck routing