1package soy.iko.opencode.ui.session
3import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxHeight
6import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.widthIn
8import androidx.activity.compose.BackHandler
9import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.automirrored.filled.Chat
11import androidx.compose.material.icons.filled.Add
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.LaunchedEffect
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.key
16import androidx.compose.runtime.mutableStateOf
17import androidx.compose.runtime.remember
18import androidx.compose.material3.VerticalDivider
19import androidx.compose.runtime.saveable.rememberSaveable
20import androidx.compose.runtime.setValue
21import androidx.compose.ui.Modifier
22import androidx.compose.ui.res.stringResource
23import androidx.compose.ui.unit.dp
24import androidx.lifecycle.compose.collectAsStateWithLifecycle
25import androidx.lifecycle.viewmodel.compose.viewModel
26import soy.iko.opencode.R
27import soy.iko.opencode.data.model.Session
28import soy.iko.opencode.data.network.NetworkConfig
29import soy.iko.opencode.di.AppContainer
30import soy.iko.opencode.ui.chat.ChatScreen
31import soy.iko.opencode.ui.components.EmptyState
32import soy.iko.opencode.ui.vmFactory
35 * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
36 * the session list on the left, the selected conversation on the right. On compact
37 * widths the app uses the single-pane [androidx.navigation] back stack instead.
39 * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
40 * two panes are self-contained. Selecting a session updates the detail pane rather than
41 * pushing a destination; the chat [onBack] clears the selection. The chat composition is
42 * keyed by session id so each conversation gets its own ViewModel.
45fun TwoPaneSessionChat(
46 container: AppContainer,
47 onOpenFiles: () -> Unit,
48 onOpenFile: (String) -> Unit,
49 onOpenSettings: () -> Unit,
50 onDisconnect: () -> Unit,
51 onAddServer: () -> Unit,
52 onOpenSearch: () -> Unit = {},
53 onEditProfile: ((String) -> Unit)? = null,
54 modifier: Modifier = Modifier,
56 var selected by rememberSaveable { mutableStateOf<String?>(null) }
57 // Bumped by the empty-detail "New session" button to open the left pane's new-session
58 // directory picker (the same dialog the FAB uses) instead of creating a directory-less
59 // session; the created session then opens into the detail pane via onOpenSession.
60 // Saveable so a rotation between the tap and the dialog-opening effect doesn't drop it.
61 var newSessionTrigger by rememberSaveable { mutableStateOf(0) }
62 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
63 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
64 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
65 // Same VM instance the left pane's SessionListScreen creates (shared ViewModelStoreOwner,
66 // keyed by class), so its session list drives the detail pane's stale-selection cleanup.
67 val sessionListVm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
68 val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
70 // A notification tap / deep link requests a session: open it in the detail pane.
71 // The connection switch (if the pending session is from a different server) is handled
72 // by the LaunchedEffect in OpencodeApp before we get here, so by the time this runs
73 // the active connection already matches.
74 LaunchedEffect(pendingOpenSession) {
75 pendingOpenSession?.let {
76 selected = it.sessionId
77 container.consumePendingOpenSession()
81 // Auto-select latch: runs at most once per Activity instance (saveable so an explicit
82 // "clear detail pane" via BackHandler survives a rotation — otherwise rotation resets the
83 // latch, re-arms the effect, and re-selects a session the user just cleared). Reset to
84 // false on a server switch (see the profile-change effect below) so the new server's top
85 // session auto-fills the detail pane instead of leaving it empty.
86 var autoSelected by rememberSaveable { mutableStateOf(false) }
88 // Clear the selection when the active server profile changes (a server switch via
89 // the ServerSwitcherMenu). Without this, `selected` still points at the old
90 // server's session id and the detail pane renders a ChatScreen for a session that
91 // doesn't exist on the new server, showing a load error with no explanation.
92 // Skips the initial transition (null -> a profile id) so a deep link / pending
93 // open session that sets `selected` on the same first composition isn't clobbered.
94 // Also resets `autoSelected` so the new server's top session auto-fills the detail
95 // pane — without that, a server switch leaves the pane empty ("Select a session")
96 // even though the new server has sessions, because the latch stayed `true` from the
98 var lastProfileId by remember { mutableStateOf<String?>(null) }
99 LaunchedEffect(activeConnection?.profile?.id) {
100 val currentId = activeConnection?.profile?.id
101 if (lastProfileId != null && lastProfileId != currentId) {
105 lastProfileId = currentId
108 StaleSelectionCleanup(
109 loading = sessionListState.loading,
110 error = sessionListState.error,
111 sessions = sessionListState.sessions,
113 onAdvance = { id -> selected = id },
114 onClear = { selected = null },
117 // Auto-select the top of the sorted/filtered list into the detail pane so a wide screen
118 // doesn't open on a blank right pane. Runs at most once per Activity instance (the
119 // `autoSelected` latch above), and only once the first list load has completed. It
120 // defers to any explicit selection already present — a restored `selected`, a
121 // deep-link/notification (pendingOpenSession), or a user pick — and, since the latch
122 // is set the moment any of those wins, it never re-selects after a user clears the pane
123 // (BackHandler) or a server switch nulls `selected` (and resets the latch). Using
124 // state.filtered (not a raw recency sort) means the auto-selected detail matches the top
125 // list row under the active sort mode, pinned float, archived-hidden, and directory filter.
126 val autoSelectCandidate = sessionListState.filtered.firstOrNull()?.id
128 sessionListState.loading,
133 if (autoSelected || sessionListState.loading) return@LaunchedEffect
134 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
135 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
136 // Pick the top of the filtered list (respects sort mode, pinned, archived-hidden,
137 // and directory filter) so the auto-selected detail matches the top list row.
138 val id = autoSelectCandidate ?: return@LaunchedEffect
143 // Inject a pending share into the currently selected session's draft (if any).
144 // Unlike single-pane mode where the session list is navigated to specifically for
145 // the share, in two-pane mode the list is always visible — so we inject into the
146 // session the user is already viewing. If no session is selected, the share is
147 // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
148 // changes to non-null).
149 LaunchedEffect(pendingShare, selected) {
150 val target = selected
151 if (pendingShare != null && target != null) {
152 container.consumePendingShare()?.let { container.draftStore.set(target, it) }
156 Row(modifier = modifier.fillMaxSize()) {
159 .weight(NetworkConfig.twoPaneLeftWeight)
160 .widthIn(max = NetworkConfig.twoPaneLeftMaxWidthDp.dp),
163 container = container,
164 onOpenSession = { id ->
167 onDisconnect = onDisconnect,
168 onOpenFiles = onOpenFiles,
169 onOpenSettings = onOpenSettings,
170 onAddServer = onAddServer,
171 onOpenSearch = onOpenSearch,
172 onEditProfile = onEditProfile,
173 externalNewSessionTrigger = newSessionTrigger,
174 selectedSessionId = selected,
178 // Clear boundary between the list and detail panes (the custom split has no divider
180 VerticalDivider(modifier = Modifier.fillMaxHeight())
182 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
183 val sessionId = selected
184 if (sessionId == null) {
185 // Route through the same new-session directory picker the left-pane FAB
186 // opens (rather than creating a directory-less session here); the created
187 // session opens into the detail pane via the shared onOpenSession callback.
188 EmptyDetail(onNewSession = { newSessionTrigger++ })
190 BackHandler { selected = null }
191 // key() on the session id so switching conversations in two-pane mode
192 // gives ChatScreen a fresh composition. Without it, position-anchored
193 // state (rememberLazyListState, didInitialScroll, and the LaunchedEffect(Unit)
194 // that wires error/retry snackbars) persists across the switch: the new
195 // session inherits the old scroll offset, skips its initial scroll-to-bottom,
196 // and its error events go unhandled. viewModel(key=sessionId) already swaps
197 // the VM; this aligns the composition state with it.
200 container = container,
201 sessionId = sessionId,
202 onBack = { selected = null },
203 onOpenFile = onOpenFile,
204 onOpenSession = { id -> selected = id },
205 onEditProfile = onEditProfile,
213/** Whether a session with [id] is present in [sessions]. */
214private fun containsSession(sessions: List<Session>, id: String): Boolean =
215 sessions.any { it.id == id }
218 * Keeps the detail pane pointed at a real conversation. When the open session disappears from
219 * the list (deleted, or removed server-side), advance to a neighbor instead of blanking the
220 * pane — matches the Gmail/Mail master-detail convention where deleting the open conversation
221 * shows the next one rather than an empty detail. Only falls back to [onClear] when the list
222 * is genuinely empty (nothing to advance to).
226 * - Act only when the open session is *removed* — present in the list, then dropped out. A
227 * freshly created session sets `selected` before its async refresh lands, so the id is
228 * legitimately absent for a moment; the `hasAppeared` latch (reset when `selected` changes)
229 * distinguishes "never appeared yet" (keep) from "appeared, then removed" (advance).
230 * - The `hasAppeared` latch can't survive process death, so a `selected` restored across a
231 * kill whose session was deleted server-side while dead never "appears". Once the first list
232 * load completes without the restored target, advance to an existing session (or clear when
233 * none). Runs once (non-saveable flag, re-arms after death) and only on a successful,
234 * completed load — not mid-load, and not for a freshly-created pending row.
237private fun StaleSelectionCleanup(
240 sessions: List<Session>,
242 onAdvance: (String) -> Unit,
245 var hasAppeared by remember(selected) { mutableStateOf(false) }
246 // Snapshot of the list as of the last time the target was present, so when the target
247 // disappears we can advance to the session that took its row (the next neighbor) rather
248 // than jumping to an unrelated row. Tracked here rather than derived, because [sessions]
249 // arriving at this call no longer contains the removed id.
250 var prevList by remember { mutableStateOf<List<Session>>(emptyList()) }
251 LaunchedEffect(selected, sessions) {
252 val target = selected ?: return@LaunchedEffect
253 if (containsSession(sessions, target)) {
256 } else if (hasAppeared) {
257 // Removed mid-session: advance to the neighbor that now occupies the target's
258 // slot, falling back to the prior slot, then to any remaining session.
259 val prevIndex = prevList.indexOfFirst { it.id == target }.coerceAtLeast(0)
260 val replacement = sessions.getOrNull(prevIndex)?.id
261 ?: sessions.getOrNull(prevIndex - 1)?.id
262 ?: sessions.firstOrNull()?.id
263 if (replacement != null && replacement != target) onAdvance(replacement) else onClear()
267 var initialSelectionValidated by remember { mutableStateOf(false) }
268 LaunchedEffect(loading, error, sessions) {
269 if (initialSelectionValidated) return@LaunchedEffect
270 if (loading || error != null) return@LaunchedEffect
271 initialSelectionValidated = true
272 val target = selected ?: return@LaunchedEffect
273 if (!containsSession(sessions, target)) {
274 // Process-death case: prefer advancing to an existing session over a blank pane.
275 val replacement = sessions.firstOrNull()?.id
276 if (replacement != null) onAdvance(replacement) else onClear()
282private fun EmptyDetail(onNewSession: () -> Unit) {
284 icon = Icons.AutoMirrored.Filled.Chat,
285 title = stringResource(R.string.empty_detail_title),
286 description = stringResource(R.string.empty_detail_pane),
287 actionIcon = Icons.Filled.Add,
288 actionLabel = stringResource(R.string.new_session),
289 onAction = onNewSession,
290 modifier = Modifier.fillMaxSize(),
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