git.iko.soy/opencode-android
on 066cded help sign in
266 lines 13 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 var newSessionTrigger by remember { mutableStateOf(0) }
60 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
61 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
62 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
63 // Same VM instance the left pane's SessionListScreen creates (shared ViewModelStoreOwner,
64 // keyed by class), so its session list drives the detail pane's stale-selection cleanup.
65 val sessionListVm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
66 val sessionListState by sessionListVm.state.collectAsStateWithLifecycle()
68 // A notification tap / deep link requests a session: open it in the detail pane.
69 // The connection switch (if the pending session is from a different server) is handled
70 // by the LaunchedEffect in OpencodeApp before we get here, so by the time this runs
71 // the active connection already matches.
72 LaunchedEffect(pendingOpenSession) {
73 pendingOpenSession?.let {
74 selected = it.sessionId
75 container.consumePendingOpenSession()
76 }
77 }
79 // Clear the selection when the active server profile changes (a server switch via
80 // the ServerSwitcherMenu). Without this, `selected` still points at the old
81 // server's session id and the detail pane renders a ChatScreen for a session that
82 // doesn't exist on the new server, showing a load error with no explanation.
83 // Skips the initial transition (null -> a profile id) so a deep link / pending
84 // open session that sets `selected` on the same first composition isn't clobbered.
85 var lastProfileId by remember { mutableStateOf<String?>(null) }
86 LaunchedEffect(activeConnection?.profile?.id) {
87 val currentId = activeConnection?.profile?.id
88 if (lastProfileId != null && lastProfileId != currentId) selected = null
89 lastProfileId = currentId
90 }
92 StaleSelectionCleanup(
93 loading = sessionListState.loading,
94 error = sessionListState.error,
95 sessions = sessionListState.sessions,
96 selected = selected,
97 onClear = { selected = null },
98 )
100 // Auto-select the most-recent session into the detail pane so a wide screen doesn't
101 // open on a blank right pane. Runs at most once per composition (the `autoSelected`
102 // latch, non-saveable so it re-arms after process death), and only once the first list
103 // load has completed. It defers to any explicit selection already present — a restored
104 // `selected`, a deep-link/notification (pendingOpenSession), or a user pick — and, since
105 // the latch is set the moment any of those wins, it never re-selects after a user clears
106 // the pane (BackHandler) or a server switch nulls `selected`.
107 var autoSelected by remember { mutableStateOf(false) }
108 LaunchedEffect(
109 sessionListState.loading,
110 sessionListState.sessions,
111 sessionListState.archivedIds,
112 sessionListState.showArchived,
113 pendingOpenSession,
114 selected,
115 ) {
116 if (autoSelected || sessionListState.loading) return@LaunchedEffect
117 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
118 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
119 // Pick by recency regardless of the list's current sort mode, but only among
120 // sessions visible under the current archive filter so we never open a hidden row.
121 val id = mostRecentSessionId(
122 sessionListState.sessions,
123 sessionListState.archivedIds,
124 sessionListState.showArchived,
125 ) ?: return@LaunchedEffect
126 selected = id
127 autoSelected = true
128 }
130 // Inject a pending share into the currently selected session's draft (if any).
131 // Unlike single-pane mode where the session list is navigated to specifically for
132 // the share, in two-pane mode the list is always visible — so we inject into the
133 // session the user is already viewing. If no session is selected, the share is
134 // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
135 // changes to non-null).
136 LaunchedEffect(pendingShare, selected) {
137 val target = selected
138 if (pendingShare != null && target != null) {
139 container.consumePendingShare()?.let { container.draftStore.set(target, it) }
140 }
141 }
143 Row(modifier = modifier.fillMaxSize()) {
144 Box(
145 modifier = Modifier
146 .weight(NetworkConfig.twoPaneLeftWeight)
147 .widthIn(max = 460.dp),
148 ) {
149 SessionListScreen(
150 container = container,
151 onOpenSession = { id ->
152 selected = id
153 },
154 onDisconnect = onDisconnect,
155 onOpenFiles = onOpenFiles,
156 onOpenSettings = onOpenSettings,
157 onAddServer = onAddServer,
158 onOpenSearch = onOpenSearch,
159 externalNewSessionTrigger = newSessionTrigger,
160 selectedSessionId = selected,
161 )
162 }
164 // Clear boundary between the list and detail panes (the custom split has no divider
165 // of its own).
166 VerticalDivider(modifier = Modifier.fillMaxHeight())
168 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
169 val sessionId = selected
170 if (sessionId == null) {
171 // Route through the same new-session directory picker the left-pane FAB
172 // opens (rather than creating a directory-less session here); the created
173 // session opens into the detail pane via the shared onOpenSession callback.
174 EmptyDetail(onNewSession = { newSessionTrigger++ })
175 } else {
176 BackHandler { selected = null }
177 // key() on the session id so switching conversations in two-pane mode
178 // gives ChatScreen a fresh composition. Without it, position-anchored
179 // state (rememberLazyListState, didInitialScroll, and the LaunchedEffect(Unit)
180 // that wires error/retry snackbars) persists across the switch: the new
181 // session inherits the old scroll offset, skips its initial scroll-to-bottom,
182 // and its error events go unhandled. viewModel(key=sessionId) already swaps
183 // the VM; this aligns the composition state with it.
184 key(sessionId) {
185 ChatScreen(
186 container = container,
187 sessionId = sessionId,
188 onBack = { selected = null },
189 onOpenFile = onOpenFile,
190 onOpenSession = { id -> selected = id },
191 )
192 }
193 }
194 }
195 }
198/**
199 * Id of the most recently updated (or created) session that is *visible* under the current
200 * archive filter (archived sessions excluded unless [showArchived]), or null when none
201 * qualify. Auto-selecting a hidden/archived session would open the detail pane on a row with
202 * no corresponding highlighted entry in the list.
203 */
204private fun mostRecentSessionId(
205 sessions: List<Session>,
206 archivedIds: Set<String>,
207 showArchived: Boolean,
208): String? =
209 sessions
210 .filter { showArchived || it.id !in archivedIds }
211 .maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }
212 ?.id
214/** Whether a session with [id] is present in [sessions]. */
215private fun containsSession(sessions: List<Session>, id: String): Boolean =
216 sessions.any { it.id == id }
218/**
219 * Clears a stale detail-pane selection. Two guards:
220 *
221 * - Clear only when the open session is *deleted* — present in the list, then dropped out.
222 * A freshly created session sets `selected` before its async refresh lands, so the id is
223 * legitimately absent for a moment; the `hasAppeared` latch (reset when `selected` changes)
224 * distinguishes "never appeared yet" (keep) from "appeared, then removed" (clear).
225 * - The `hasAppeared` latch can't survive process death, so a `selected` restored across a
226 * kill whose session was deleted server-side while dead never "appears" and would be kept
227 * forever (permanent load error). Once the first list load completes without the restored
228 * target, clear it. Runs once (non-saveable flag, re-arms after death) and only on a
229 * successful, completed load — not mid-load, and not for a freshly-created pending row.
230 */
231@Composable
232private fun StaleSelectionCleanup(
233 loading: Boolean,
234 error: String?,
235 sessions: List<Session>,
236 selected: String?,
237 onClear: () -> Unit,
238) {
239 var hasAppeared by remember(selected) { mutableStateOf(false) }
240 LaunchedEffect(selected, sessions) {
241 val target = selected ?: return@LaunchedEffect
242 if (containsSession(sessions, target)) hasAppeared = true
243 else if (hasAppeared) onClear()
244 }
245 var initialSelectionValidated by remember { mutableStateOf(false) }
246 LaunchedEffect(loading, error, sessions) {
247 if (initialSelectionValidated) return@LaunchedEffect
248 if (loading || error != null) return@LaunchedEffect
249 initialSelectionValidated = true
250 val target = selected ?: return@LaunchedEffect
251 if (!containsSession(sessions, target)) onClear()
252 }
255@Composable
256private fun EmptyDetail(onNewSession: () -> Unit) {
257 EmptyState(
258 icon = Icons.AutoMirrored.Filled.Chat,
259 title = stringResource(R.string.empty_detail_title),
260 description = stringResource(R.string.empty_detail_pane),
261 actionIcon = Icons.Filled.Add,
262 actionLabel = stringResource(R.string.new_session),
263 onAction = onNewSession,
264 modifier = Modifier.fillMaxSize(),
265 )