Files
amethyst/docs/plans/2026-03-19-feat-deck-messages-stacked-layout-plan.md
T
nrobi144 e06287acb3 feat(chats): stacked messages layout in deck columns
In multi-deck mode, Messages column now uses stacked navigation instead
of side-by-side split pane. Full-width contact list OR full-width chat —
clicking a conversation navigates to chat, back arrow returns to list.

Single-pane mode keeps the existing split layout (280dp list + flex chat).

Changes:
- Add compactMode param to DesktopMessagesScreen (default false)
- Extract SplitMessagesContent and CompactMessagesContent composables
- Add onBack callback to ChatPane with back arrow in header
- Remove hardcoded 280dp from ConversationListPane (caller controls width)
- Pass compactMode=true from deck RootContent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:49:36 +02:00

12 KiB

title, type, status, date, deepened, origin
title type status date deepened origin
feat: Stacked messages layout in deck columns feat completed 2026-03-19 2026-03-19 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:

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.

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:

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):

// 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:

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.

@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:

@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)

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

  • Deck Messages column shows full-width contact list (no split)
  • Clicking conversation navigates to full-width chat view
  • Back arrow visible in chat header (compact mode only)
  • Clicking back arrow returns to contact list
  • Escape key still returns to contact list
  • Single-pane mode unchanged (split layout preserved)
  • Keyboard navigation (up/down/enter) still works in contact list
  • New DM dialog still works in both modes
  • 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