git.iko.soy/opencode-android
on 61cdc75 help sign in
297 lines 15 kB history raw
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
34/**
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.
38 *
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.
43 */
44@Composable
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 modifier: Modifier = Modifier,
54) {
55 var selected by rememberSaveable { mutableStateOf<String?>(null) }
56 // Bumped by the empty-detail "New session" button to open the left pane's new-session
57 // directory picker (the same dialog the FAB uses) instead of creating a directory-less
58 // session; the created session then opens into the detail pane via onOpenSession.
59 // Saveable so a rotation between the tap and the dialog-opening effect doesn't drop it.
60 var newSessionTrigger by rememberSaveable { mutableStateOf(0) }
61 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
62 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
63 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
64 // Same VM instance the left pane's SessionListScreen creates (shared ViewModelStoreOwner,
65 // keyed by class), so its session list drives the detail pane's stale-selection cleanup.
66 val sessionListVm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
67 val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
69 // A notification tap / deep link requests a session: open it in the detail pane.
70 // The connection switch (if the pending session is from a different server) is handled
71 // by the LaunchedEffect in OpencodeApp before we get here, so by the time this runs
72 // the active connection already matches.
73 LaunchedEffect(pendingOpenSession) {
74 pendingOpenSession?.let {
75 selected = it.sessionId
76 container.consumePendingOpenSession()
77 }
78 }
80 // Clear the selection when the active server profile changes (a server switch via
81 // the ServerSwitcherMenu). Without this, `selected` still points at the old
82 // server's session id and the detail pane renders a ChatScreen for a session that
83 // doesn't exist on the new server, showing a load error with no explanation.
84 // Skips the initial transition (null -> a profile id) so a deep link / pending
85 // open session that sets `selected` on the same first composition isn't clobbered.
86 var lastProfileId by remember { mutableStateOf<String?>(null) }
87 LaunchedEffect(activeConnection?.profile?.id) {
88 val currentId = activeConnection?.profile?.id
89 if (lastProfileId != null && lastProfileId != currentId) selected = null
90 lastProfileId = currentId
91 }
93 StaleSelectionCleanup(
94 loading = sessionListState.loading,
95 error = sessionListState.error,
96 sessions = sessionListState.sessions,
97 selected = selected,
98 onAdvance = { id -> selected = id },
99 onClear = { selected = null },
100 )
102 // Auto-select the most-recent session into the detail pane so a wide screen doesn't
103 // open on a blank right pane. Runs at most once per Activity instance (the `autoSelected`
104 // latch, saveable so an explicit "clear detail pane" via BackHandler survives a rotation
105 // — otherwise rotation resets the latch, re-arms the effect, and re-selects a session the
106 // user just cleared), and only once the first list load has completed. It defers to any
107 // explicit selection already present — a restored `selected`, a deep-link/notification
108 // (pendingOpenSession), or a user pick — and, since the latch is set the moment any of
109 // those wins, it never re-selects after a user clears the pane (BackHandler) or a server
110 // switch nulls `selected`.
111 var autoSelected by rememberSaveable { mutableStateOf(false) }
112 LaunchedEffect(
113 sessionListState.loading,
114 sessionListState.sessions,
115 sessionListState.archivedIds,
116 sessionListState.showArchived,
117 pendingOpenSession,
118 selected,
119 ) {
120 if (autoSelected || sessionListState.loading) return@LaunchedEffect
121 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
122 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
123 // Pick by recency regardless of the list's current sort mode, but only among
124 // sessions visible under the current archive filter so we never open a hidden row.
125 val id = mostRecentSessionId(
126 sessionListState.sessions,
127 sessionListState.archivedIds,
128 sessionListState.showArchived,
129 ) ?: return@LaunchedEffect
130 selected = id
131 autoSelected = true
132 }
134 // Inject a pending share into the currently selected session's draft (if any).
135 // Unlike single-pane mode where the session list is navigated to specifically for
136 // the share, in two-pane mode the list is always visible — so we inject into the
137 // session the user is already viewing. If no session is selected, the share is
138 // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
139 // changes to non-null).
140 LaunchedEffect(pendingShare, selected) {
141 val target = selected
142 if (pendingShare != null && target != null) {
143 container.consumePendingShare()?.let { container.draftStore.set(target, it) }
144 }
145 }
147 Row(modifier = modifier.fillMaxSize()) {
148 Box(
149 modifier = Modifier
150 .weight(NetworkConfig.twoPaneLeftWeight)
151 .widthIn(max = 460.dp),
152 ) {
153 SessionListScreen(
154 container = container,
155 onOpenSession = { id ->
156 selected = id
157 },
158 onDisconnect = onDisconnect,
159 onOpenFiles = onOpenFiles,
160 onOpenSettings = onOpenSettings,
161 onAddServer = onAddServer,
162 onOpenSearch = onOpenSearch,
163 externalNewSessionTrigger = newSessionTrigger,
164 selectedSessionId = selected,
165 )
166 }
168 // Clear boundary between the list and detail panes (the custom split has no divider
169 // of its own).
170 VerticalDivider(modifier = Modifier.fillMaxHeight())
172 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
173 val sessionId = selected
174 if (sessionId == null) {
175 // Route through the same new-session directory picker the left-pane FAB
176 // opens (rather than creating a directory-less session here); the created
177 // session opens into the detail pane via the shared onOpenSession callback.
178 EmptyDetail(onNewSession = { newSessionTrigger++ })
179 } else {
180 BackHandler { selected = null }
181 // key() on the session id so switching conversations in two-pane mode
182 // gives ChatScreen a fresh composition. Without it, position-anchored
183 // state (rememberLazyListState, didInitialScroll, and the LaunchedEffect(Unit)
184 // that wires error/retry snackbars) persists across the switch: the new
185 // session inherits the old scroll offset, skips its initial scroll-to-bottom,
186 // and its error events go unhandled. viewModel(key=sessionId) already swaps
187 // the VM; this aligns the composition state with it.
188 key(sessionId) {
189 ChatScreen(
190 container = container,
191 sessionId = sessionId,
192 onBack = { selected = null },
193 onOpenFile = onOpenFile,
194 onOpenSession = { id -> selected = id },
195 )
196 }
197 }
198 }
199 }
202/**
203 * Id of the most recently updated (or created) session that is *visible* under the current
204 * archive filter (archived sessions excluded unless [showArchived]), or null when none
205 * qualify. Auto-selecting a hidden/archived session would open the detail pane on a row with
206 * no corresponding highlighted entry in the list.
207 */
208private fun mostRecentSessionId(
209 sessions: List<Session>,
210 archivedIds: Set<String>,
211 showArchived: Boolean,
212): String? =
213 sessions
214 .filter { showArchived || it.id !in archivedIds }
215 .maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }
216 ?.id
218/** Whether a session with [id] is present in [sessions]. */
219private fun containsSession(sessions: List<Session>, id: String): Boolean =
220 sessions.any { it.id == id }
222/**
223 * Keeps the detail pane pointed at a real conversation. When the open session disappears from
224 * the list (deleted, or removed server-side), advance to a neighbor instead of blanking the
225 * pane — matches the Gmail/Mail master-detail convention where deleting the open conversation
226 * shows the next one rather than an empty detail. Only falls back to [onClear] when the list
227 * is genuinely empty (nothing to advance to).
228 *
229 * Two guards:
230 *
231 * - Act only when the open session is *removed* — present in the list, then dropped out. A
232 * freshly created session sets `selected` before its async refresh lands, so the id is
233 * legitimately absent for a moment; the `hasAppeared` latch (reset when `selected` changes)
234 * distinguishes "never appeared yet" (keep) from "appeared, then removed" (advance).
235 * - The `hasAppeared` latch can't survive process death, so a `selected` restored across a
236 * kill whose session was deleted server-side while dead never "appears". Once the first list
237 * load completes without the restored target, advance to an existing session (or clear when
238 * none). Runs once (non-saveable flag, re-arms after death) and only on a successful,
239 * completed load — not mid-load, and not for a freshly-created pending row.
240 */
241@Composable
242private fun StaleSelectionCleanup(
243 loading: Boolean,
244 error: String?,
245 sessions: List<Session>,
246 selected: String?,
247 onAdvance: (String) -> Unit,
248 onClear: () -> Unit,
249) {
250 var hasAppeared by remember(selected) { mutableStateOf(false) }
251 // Snapshot of the list as of the last time the target was present, so when the target
252 // disappears we can advance to the session that took its row (the next neighbor) rather
253 // than jumping to an unrelated row. Tracked here rather than derived, because [sessions]
254 // arriving at this call no longer contains the removed id.
255 var prevList by remember { mutableStateOf<List<Session>>(emptyList()) }
256 LaunchedEffect(selected, sessions) {
257 val target = selected ?: return@LaunchedEffect
258 if (containsSession(sessions, target)) {
259 hasAppeared = true
260 prevList = sessions
261 } else if (hasAppeared) {
262 // Removed mid-session: advance to the neighbor that now occupies the target's
263 // slot, falling back to the prior slot, then to any remaining session.
264 val prevIndex = prevList.indexOfFirst { it.id == target }.coerceAtLeast(0)
265 val replacement = sessions.getOrNull(prevIndex)?.id
266 ?: sessions.getOrNull(prevIndex - 1)?.id
267 ?: sessions.firstOrNull()?.id
268 if (replacement != null && replacement != target) onAdvance(replacement) else onClear()
269 prevList = sessions
270 }
271 }
272 var initialSelectionValidated by remember { mutableStateOf(false) }
273 LaunchedEffect(loading, error, sessions) {
274 if (initialSelectionValidated) return@LaunchedEffect
275 if (loading || error != null) return@LaunchedEffect
276 initialSelectionValidated = true
277 val target = selected ?: return@LaunchedEffect
278 if (!containsSession(sessions, target)) {
279 // Process-death case: prefer advancing to an existing session over a blank pane.
280 val replacement = sessions.firstOrNull()?.id
281 if (replacement != null) onAdvance(replacement) else onClear()
282 }
283 }
286@Composable
287private fun EmptyDetail(onNewSession: () -> Unit) {
288 EmptyState(
289 icon = Icons.AutoMirrored.Filled.Chat,
290 title = stringResource(R.string.empty_detail_title),
291 description = stringResource(R.string.empty_detail_pane),
292 actionIcon = Icons.Filled.Add,
293 actionLabel = stringResource(R.string.new_session),
294 onAction = onNewSession,
295 modifier = Modifier.fillMaxSize(),
296 )