@@ -20,8 +20,12 @@ import androidx.compose.material.icons.filled.Menu
2020import androidx.compose.material3.Icon
2121import androidx.compose.material3.IconButton
2222import androidx.compose.material3.MaterialTheme
23+ import androidx.compose.material3.ModalDrawerSheet
24+ import androidx.compose.material3.ModalNavigationDrawer
2325import androidx.compose.material3.Surface
2426import androidx.compose.material3.Text
27+ import androidx.compose.material3.rememberDrawerState
28+ import androidx.compose.material3.DrawerValue
2529import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
2630import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
2731import androidx.compose.material3.adaptive.currentWindowSize
@@ -287,6 +291,15 @@ fun ChatSplitScaffold(
287291 */
288292 draftPlaceholderId: String? ,
289293 onSessionSelected: (String ) -> Unit ,
294+ /* *
295+ * [P1-6] True when the list is drawn inside the compact-width modal
296+ * drawer rather than as a full screen or the two-pane sidebar. The
297+ * list drops its own top bar then — the drawer sheet carries the
298+ * chrome. Same composable either way; the pane navigator stays the
299+ * single owner of list-vs-detail, which is what keeps chat switching
300+ * identical across the three presentations.
301+ */
302+ embedded: Boolean ,
290303 ) -> Unit ,
291304 detailPane : @Composable (
292305 sessionId: String ,
@@ -301,6 +314,13 @@ fun ChatSplitScaffold(
301314 onToggleSidebar: (() -> Unit )? ,
302315 /* * Whether the list is currently hidden — picks the icon direction. */
303316 sidebarCollapsed: Boolean ,
317+ /* *
318+ * [P1-6] Open the compact-width session drawer, or null in two-pane
319+ * (the sidebar is already the list). The drawer state lives inside
320+ * [ChatSplitScaffold], so the slot hands the opener to the detail
321+ * pane instead of the pane reaching into scaffold internals.
322+ */
323+ onOpenSessionDrawer: (() -> Unit )? ,
304324 ) -> Unit ,
305325) {
306326 val twoPane = shouldUseTwoPane()
@@ -455,6 +475,13 @@ fun ChatSplitScaffold(
455475 )
456476 val scope = rememberCoroutineScope()
457477
478+ // [P1-6] Compact-width session drawer. State lives here, inside the
479+ // navigator's key block, so a window-size rebuild (rotation, fold,
480+ // split-screen) starts the drawer closed rather than resurrecting it
481+ // over a layout that may now be two-pane.
482+ val sessionDrawerState = rememberDrawerState(DrawerValue .Closed )
483+ val openSessionDrawer: () -> Unit = { scope.launch { sessionDrawerState.open() } }
484+
458485 val currentSessionId = navigator.currentDestination
459486 ?.takeIf { it.pane == ListDetailPaneScaffoldRole .Detail }
460487 ?.contentKey
@@ -509,6 +536,53 @@ fun ChatSplitScaffold(
509536 // Deliberately NOT focusable itself: preview events travel down from the
510537 // focus owner's ancestors, so this sees the chord without competing for
511538 // focus with the composer's text field.
539+ //
540+ // [P1-6] Compact-width session drawer. Wraps the WHOLE list/detail pair
541+ // (including the two-pane layout) so the gesture surface is one thing at
542+ // every window size: edge-swipe opens it, but the sheet only mounts while
543+ // single-pane — at ≥840dp the persistent sidebar already shows the list
544+ // and a second copy would be two lists of the same sessions. Switching a
545+ // chat from the drawer goes through the SAME pane navigator as the
546+ // full-screen list and the two-pane sidebar (see the selection callback
547+ // below), so the three presentations cannot drift.
548+ ModalNavigationDrawer (
549+ drawerState = sessionDrawerState,
550+ gesturesEnabled = ! twoPane,
551+ drawerContent = {
552+ if (! twoPane) {
553+ ModalDrawerSheet (modifier = Modifier .width(320 .dp)) {
554+ listPane(
555+ // The drawer sits OVER the chat, so the current chat
556+ // is highlighted — same row the two-pane sidebar marks.
557+ if (currentSessionId != null ) {
558+ com.openminis.app.ui.chat.ChatViewModelStore
559+ .rememberPersistedId(currentSessionId)
560+ } else null ,
561+ if (currentSessionId != null &&
562+ isDraftSessionId(
563+ com.openminis.app.ui.chat.ChatViewModelStore
564+ .rememberPersistedId(currentSessionId),
565+ )
566+ ) currentSessionId else null ,
567+ { sessionId ->
568+ selectedSessionId = sessionId
569+ scope.launch {
570+ navigator.navigateTo(
571+ ListDetailPaneScaffoldRole .Detail ,
572+ sessionId,
573+ )
574+ sessionDrawerState.close()
575+ }
576+ },
577+ // [P1-6] Drawer presentation — positional args: the
578+ // listPane slot is a function TYPE, whose call sites
579+ // cannot use named arguments.
580+ true ,
581+ )
582+ }
583+ }
584+ },
585+ ) {
512586 Box (
513587 Modifier
514588 .fillMaxSize()
@@ -559,12 +633,17 @@ fun ChatSplitScaffold(
559633 .rememberPersistedId(currentSessionId),
560634 )
561635 ) currentSessionId else null ,
562- ) { sessionId ->
563- selectedSessionId = sessionId
564- scope.launch {
565- navigator.navigateTo(ListDetailPaneScaffoldRole .Detail , sessionId)
566- }
567- }
636+ { sessionId ->
637+ selectedSessionId = sessionId
638+ scope.launch {
639+ navigator.navigateTo(ListDetailPaneScaffoldRole .Detail , sessionId)
640+ }
641+ },
642+ // Full screen / sidebar presentation — own top bar.
643+ // Positional: the listPane slot is a function TYPE, whose
644+ // call sites cannot use named arguments.
645+ false ,
646+ )
568647 }
569648 },
570649 detailPane = {
@@ -653,6 +732,9 @@ fun ChatSplitScaffold(
653732 null
654733 },
655734 listCollapsed,
735+ // [P1-6] Drawer opener for compact width; null when
736+ // the sidebar already is the list.
737+ if (twoPane) null else openSessionDrawer,
656738 )
657739 }
658740 }
@@ -714,6 +796,7 @@ fun ChatSplitScaffold(
714796 }
715797 }
716798 }
799+ }
717800}
718801
719802/* *
@@ -882,7 +965,7 @@ fun ChatSplitScaffoldRoute(
882965) {
883966 ChatSplitScaffold (
884967 initialSessionId = initialSessionId,
885- listPane = { selectedSessionId, draftPlaceholderId, onSessionSelected ->
968+ listPane = { selectedSessionId, draftPlaceholderId, onSessionSelected, embedded ->
886969 com.openminis.app.ui.sessions.SessionListScreen (
887970 chatRepository = chatRepository,
888971 providerRepository = providerRepository,
@@ -903,10 +986,12 @@ fun ChatSplitScaffoldRoute(
903986 // [T-android-draft-placeholder-row] Synthetic "New Chat" row,
904987 // never persisted — see the listPane param docs.
905988 draftPlaceholderId = draftPlaceholderId,
989+ // [P1-6] Drawer presentation drops the list's own top bar.
990+ embedded = embedded,
906991 )
907992 },
908993 detailPane = { sessionId, onBackInPane, onNewChatInPane, onMoveToInPane,
909- onToggleSidebar, sidebarCollapsed ->
994+ onToggleSidebar, sidebarCollapsed, onOpenSessionDrawer ->
910995 com.openminis.app.ui.chat.ChatScreen (
911996 // The pane navigator's contentKey IS the session id, so a
912997 // draft ("__new__…") flows through unchanged and ChatScreen's
@@ -930,6 +1015,9 @@ fun ChatSplitScaffoldRoute(
9301015 // looks for "get me back to the list".
9311016 onToggleSidebar = onToggleSidebar,
9321017 sidebarCollapsed = sidebarCollapsed,
1018+ // [P1-6] Compact width only — the drawer is not composed in
1019+ // two-pane (the sidebar is already the list).
1020+ onOpenSessionDrawer = onOpenSessionDrawer,
9331021 // Draft handling: the pane navigator gets a fresh `__new__…`
9341022 // key, so the list keeps its scroll position and simply stops
9351023 // highlighting a row (no persisted id matches a draft) — the
0 commit comments