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.key
20import androidx.compose.runtime.mutableStateOf
21import androidx.compose.runtime.remember
22import androidx.compose.runtime.saveable.rememberSaveable
23import androidx.compose.runtime.setValue
24import androidx.compose.ui.Alignment
25import androidx.compose.ui.Modifier
26import androidx.compose.ui.res.stringResource
27import androidx.compose.ui.unit.dp
28import androidx.lifecycle.compose.collectAsStateWithLifecycle
29import androidx.lifecycle.viewmodel.compose.viewModel
30import soy.iko.opencode.R
31import soy.iko.opencode.data.network.NetworkConfig
32import soy.iko.opencode.di.AppContainer
33import soy.iko.opencode.ui.chat.ChatScreen
34import soy.iko.opencode.ui.vmFactory
37 * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
38 * the session list on the left, the selected conversation on the right. On compact
39 * widths the app uses the single-pane [androidx.navigation] back stack instead.
41 * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
42 * two panes are self-contained. Selecting a session updates the detail pane rather than
43 * pushing a destination; the chat [onBack] clears the selection. The chat composition is
44 * keyed by session id so each conversation gets its own ViewModel.
47fun TwoPaneSessionChat(
48 container: AppContainer,
49 onOpenFiles: () -> Unit,
50 onOpenFile: (String) -> Unit,
51 onOpenSettings: () -> Unit,
52 onDisconnect: () -> Unit,
53 onAddServer: () -> Unit,
54 onOpenSearch: () -> Unit = {},
55 modifier: Modifier = Modifier,
57 var selected by rememberSaveable { mutableStateOf<String?>(null) }
58 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
59 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
60 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
61 // Same VM instance the left pane's SessionListScreen creates (shared ViewModelStoreOwner,
62 // keyed by class), so its session list drives the detail pane's stale-selection cleanup.
63 val sessionListVm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
64 val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
66 // A notification tap / deep link requests a session: open it in the detail pane.
67 // The connection switch (if the pending session is from a different server) is handled
68 // by the LaunchedEffect in OpencodeApp before we get here, so by the time this runs
69 // the active connection already matches.
70 LaunchedEffect(pendingOpenSession) {
71 pendingOpenSession?.let {
72 selected = it.sessionId
73 container.consumePendingOpenSession()
77 // Clear the selection when the active server profile changes (a server switch via
78 // the ServerSwitcherMenu). Without this, `selected` still points at the old
79 // server's session id and the detail pane renders a ChatScreen for a session that
80 // doesn't exist on the new server, showing a load error with no explanation.
81 // Skips the initial transition (null -> a profile id) so a deep link / pending
82 // open session that sets `selected` on the same first composition isn't clobbered.
83 var lastProfileId by remember { mutableStateOf<String?>(null) }
84 LaunchedEffect(activeConnection?.profile?.id) {
85 val currentId = activeConnection?.profile?.id
86 if (lastProfileId != null && lastProfileId != currentId) selected = null
87 lastProfileId = currentId
90 // Clear the selection only when the open session is *deleted* — i.e. it was present
91 // in the list and then dropped out. A freshly created session sets `selected` to the
92 // new id before the async refresh lands, so the id is legitimately absent from the
93 // (still-stale) list for a moment; a naive "not in list" check would wrongly clear it
94 // and blank the detail pane. The `hasAppeared` latch (reset whenever `selected`
95 // changes, via remember keyed to it) distinguishes "never appeared yet" (keep) from
96 // "appeared, then removed" (a real deletion → clear).
97 var hasAppeared by remember(selected) { mutableStateOf(false) }
98 LaunchedEffect(selected, sessionListState.sessions) {
99 val target = selected ?: return@LaunchedEffect
100 if (sessionListState.sessions.any { it.id == target }) {
102 } else if (hasAppeared) {
107 // The hasAppeared latch above can't survive process death (plain remember), so a
108 // `selected` restored across process death whose session was deleted server-side while
109 // dead never "appears" and is kept forever — the detail pane then renders ChatScreen for
110 // a nonexistent session (permanent load error). Once the first list load completes
111 // successfully without the restored target, clear it. Guarded to run once (non-saveable
112 // flag, so it re-arms after process death) and only on a successful, completed load — not
113 // during the initial load, and not for a freshly-created session whose refresh keeps
114 // `loading` false while its row is still pending.
115 var initialSelectionValidated by remember { mutableStateOf(false) }
116 LaunchedEffect(sessionListState.loading, sessionListState.error, sessionListState.sessions) {
117 if (initialSelectionValidated) return@LaunchedEffect
118 if (sessionListState.loading || sessionListState.error != null) return@LaunchedEffect
119 initialSelectionValidated = true
120 val target = selected ?: return@LaunchedEffect
121 if (sessionListState.sessions.none { it.id == target }) selected = null
124 // Inject a pending share into the currently selected session's draft (if any).
125 // Unlike single-pane mode where the session list is navigated to specifically for
126 // the share, in two-pane mode the list is always visible — so we inject into the
127 // session the user is already viewing. If no session is selected, the share is
128 // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
129 // changes to non-null).
130 LaunchedEffect(pendingShare, selected) {
131 val target = selected
132 if (pendingShare != null && target != null) {
133 container.consumePendingShare()?.let { container.draftStore.set(target, it) }
137 Row(modifier = modifier.fillMaxSize()) {
140 .weight(NetworkConfig.twoPaneLeftWeight)
141 .widthIn(max = 460.dp),
144 container = container,
145 onOpenSession = { id ->
148 onDisconnect = onDisconnect,
149 onOpenFiles = onOpenFiles,
150 onOpenSettings = onOpenSettings,
151 onAddServer = onAddServer,
152 onOpenSearch = onOpenSearch,
153 selectedSessionId = selected,
157 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
158 val sessionId = selected
159 if (sessionId == null) {
162 BackHandler { selected = null }
163 // key() on the session id so switching conversations in two-pane mode
164 // gives ChatScreen a fresh composition. Without it, position-anchored
165 // state (rememberLazyListState, didInitialScroll, and the LaunchedEffect(Unit)
166 // that wires error/retry snackbars) persists across the switch: the new
167 // session inherits the old scroll offset, skips its initial scroll-to-bottom,
168 // and its error events go unhandled. viewModel(key=sessionId) already swaps
169 // the VM; this aligns the composition state with it.
172 container = container,
173 sessionId = sessionId,
174 onBack = { selected = null },
175 onOpenFile = onOpenFile,
176 onOpenSession = { id -> selected = id },
185private fun EmptyDetail() {
187 modifier = Modifier.fillMaxSize().padding(32.dp),
188 verticalArrangement = Arrangement.Center,
189 horizontalAlignment = Alignment.CenterHorizontally,
192 Icons.AutoMirrored.Filled.Chat,
193 contentDescription = null,
194 tint = MaterialTheme.colorScheme.outline,
197 stringResource(R.string.empty_detail_pane),
198 style = MaterialTheme.typography.bodyLarge,
199 color = MaterialTheme.colorScheme.onSurfaceVariant,
200 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