1package soy.iko.opencode.ui.session
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.widthIn
10import androidx.activity.compose.BackHandler
11import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.automirrored.filled.Chat
13import androidx.compose.material3.Icon
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.mutableStateOf
20import androidx.compose.runtime.remember
21import androidx.compose.runtime.saveable.rememberSaveable
22import androidx.compose.runtime.setValue
23import androidx.compose.ui.Alignment
24import androidx.compose.ui.Modifier
25import androidx.compose.ui.res.stringResource
26import androidx.compose.ui.unit.dp
27import androidx.lifecycle.compose.collectAsStateWithLifecycle
28import soy.iko.opencode.R
29import soy.iko.opencode.data.network.NetworkConfig
30import soy.iko.opencode.di.AppContainer
31import soy.iko.opencode.ui.chat.ChatScreen
34 * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
35 * the session list on the left, the selected conversation on the right. On compact
36 * widths the app uses the single-pane [androidx.navigation] back stack instead.
38 * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
39 * two panes are self-contained. Selecting a session updates the detail pane rather than
40 * pushing a destination; the chat [onBack] clears the selection. The chat composition is
41 * keyed by session id so each conversation gets its own ViewModel.
44fun TwoPaneSessionChat(
45 container: AppContainer,
46 onOpenFiles: () -> Unit,
47 onOpenFile: (String) -> Unit,
48 onOpenSettings: () -> Unit,
49 onDisconnect: () -> Unit,
50 onAddServer: () -> Unit,
51 modifier: Modifier = Modifier,
53 var selected by rememberSaveable { mutableStateOf<String?>(null) }
54 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
55 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
56 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
58 // A notification tap / deep link requests a session: open it in the detail pane.
59 LaunchedEffect(pendingOpenSession) {
60 pendingOpenSession?.let {
62 container.consumePendingOpenSession()
66 // Clear the selection when the active server profile changes (a server switch via
67 // the ServerSwitcherMenu). Without this, `selected` still points at the old
68 // server's session id and the detail pane renders a ChatScreen for a session that
69 // doesn't exist on the new server, showing a load error with no explanation.
70 // Skips the initial transition (null -> a profile id) so a deep link / pending
71 // open session that sets `selected` on the same first composition isn't clobbered.
72 var lastProfileId by remember { mutableStateOf<String?>(null) }
73 LaunchedEffect(activeConnection?.profile?.id) {
74 val currentId = activeConnection?.profile?.id
75 if (lastProfileId != null && lastProfileId != currentId) selected = null
76 lastProfileId = currentId
79 // Inject a pending share into the currently selected session's draft (if any).
80 // Unlike single-pane mode where the session list is navigated to specifically for
81 // the share, in two-pane mode the list is always visible — so we inject into the
82 // session the user is already viewing. If no session is selected, the share is
83 // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
84 // changes to non-null).
85 LaunchedEffect(pendingShare, selected) {
87 if (pendingShare != null && target != null) {
88 container.consumePendingShare()?.let { container.draftStore.set(target, it) }
92 Row(modifier = modifier.fillMaxSize()) {
95 .weight(NetworkConfig.twoPaneLeftWeight)
96 .widthIn(max = 460.dp),
99 container = container,
100 onOpenSession = { id ->
103 onDisconnect = onDisconnect,
104 onOpenFiles = onOpenFiles,
105 onOpenSettings = onOpenSettings,
106 onAddServer = onAddServer,
110 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
111 val sessionId = selected
112 if (sessionId == null) {
115 BackHandler { selected = null }
117 container = container,
118 sessionId = sessionId,
119 onBack = { selected = null },
120 onOpenFile = onOpenFile,
128private fun EmptyDetail() {
130 modifier = Modifier.fillMaxSize().padding(32.dp),
131 verticalArrangement = Arrangement.Center,
132 horizontalAlignment = Alignment.CenterHorizontally,
135 Icons.AutoMirrored.Filled.Chat,
136 contentDescription = null,
137 tint = MaterialTheme.colorScheme.outline,
140 stringResource(R.string.empty_detail_pane),
141 style = MaterialTheme.typography.bodyLarge,
142 color = MaterialTheme.colorScheme.onSurfaceVariant,
143 modifier = Modifier.padding(top = 12.dp),
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list