@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.PaddingValues
88import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
910import androidx.compose.foundation.layout.fillMaxSize
1011import androidx.compose.foundation.layout.fillMaxWidth
1112import androidx.compose.foundation.layout.padding
@@ -16,10 +17,14 @@ import androidx.compose.material.icons.Icons
1617import androidx.compose.material.icons.automirrored.filled.Logout
1718import androidx.compose.material.icons.filled.Add
1819import androidx.compose.material.icons.filled.ArrowDropDown
20+import androidx.compose.material.icons.filled.ChatBubbleOutline
1921import androidx.compose.material.icons.filled.Delete
2022import androidx.compose.material.icons.filled.Folder
2123import androidx.compose.material.icons.filled.Refresh
24+import androidx.compose.material.icons.filled.Search
2225import androidx.compose.material.icons.filled.Settings
26+import androidx.compose.material3.AlertDialog
27+import androidx.compose.material3.Button
2328import androidx.compose.material3.Card
2429import androidx.compose.material3.CircularProgressIndicator
2530import androidx.compose.material3.DropdownMenu
@@ -29,8 +34,10 @@ import androidx.compose.material3.ExtendedFloatingActionButton
2934import androidx.compose.material3.Icon
3035import androidx.compose.material3.IconButton
3136import androidx.compose.material3.MaterialTheme
37+import androidx.compose.material3.OutlinedTextField
3238import androidx.compose.material3.Scaffold
3339import androidx.compose.material3.Text
40+import androidx.compose.material3.TextButton
3441import androidx.compose.material3.TopAppBar
3542import androidx.compose.runtime.Composable
3643import androidx.compose.runtime.getValue
@@ -39,11 +46,14 @@ import androidx.compose.runtime.remember
3946import androidx.compose.runtime.setValue
4047import androidx.compose.ui.Alignment
4148import androidx.compose.ui.Modifier
49+import androidx.compose.ui.platform.LocalHapticFeedback
4250import androidx.compose.ui.text.style.TextOverflow
4351import androidx.compose.ui.unit.dp
4452import androidx.lifecycle.compose.collectAsStateWithLifecycle
4553import androidx.lifecycle.viewmodel.compose.viewModel
54+import soy.iko.opencode.data.model.Session
4655import soy.iko.opencode.di.AppContainer
56+import soy.iko.opencode.ui.components.relativeTime
4757import soy.iko.opencode.ui.vmFactory
4858
4959@OptIn(ExperimentalMaterial3Api::class)
@@ -60,7 +70,9 @@ fun SessionListScreen(
6070 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
6171 val profiles by vm.profiles.collectAsStateWithLifecycle()
6272 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
73+ val haptics = LocalHapticFeedback.current
6374 var showServerMenu by remember { mutableStateOf(false) }
75+ var pendingDelete by remember { mutableStateOf<Session?>(null) }
6476
6577 Scaffold(
6678 topBar = {
@@ -145,35 +157,39 @@ fun SessionListScreen(
145157 modifier = Modifier.align(Alignment.Center).padding(24.dp),
146158 color = MaterialTheme.colorScheme.error,
147159 )
148− state.sessions.isEmpty() -> Text(
149− "No sessions yet.\nTap “New session” to start.",
150− modifier = Modifier.align(Alignment.Center).padding(24.dp),
160+ state.sessions.isEmpty() -> EmptySessions(
161+ onCreate = { vm.createSession(onCreated = onOpenSession) },
162+ modifier = Modifier.align(Alignment.Center),
151163 )
152− else -> LazyColumn(
153− modifier = Modifier.fillMaxSize(),
154− contentPadding = PaddingValues(16.dp),
155− verticalArrangement = Arrangement.spacedBy(12.dp),
156− ) {
157− items(state.sessions, key = { it.id }) { session ->
158− Card(
159− modifier = Modifier
160− .fillMaxWidth()
161− .clickable { onOpenSession(session.id) },
164+ else -> Column(modifier = Modifier.fillMaxSize()) {
165+ OutlinedTextField(
166+ value = state.query,
167+ onValueChange = vm::setQuery,
168+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
169+ placeholder = { Text("Search sessions…") },
170+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
171+ singleLine = true,
172+ )
173+ val sessions = state.filtered
174+ if (sessions.isEmpty()) {
175+ Text(
176+ "No sessions match “${state.query}”.",
177+ modifier = Modifier.padding(24.dp),
178+ color = MaterialTheme.colorScheme.onSurfaceVariant,
179+ )
180+ } else {
181+ LazyColumn(
182+ modifier = Modifier.fillMaxSize(),
183+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
184+ verticalArrangement = Arrangement.spacedBy(12.dp),
162185 ) {
163− Row(
164− modifier = Modifier.fillMaxWidth().padding(16.dp),
165− verticalAlignment = Alignment.CenterVertically,
166− ) {
167− Text(
168− session.displayTitle,
169− style = MaterialTheme.typography.titleMedium,
170− maxLines = 2,
171− overflow = TextOverflow.Ellipsis,
172− modifier = Modifier.weight(1f),
186+ items(sessions, key = { it.id }) { session ->
187+ SessionCard(
188+ session = session,
189+ preview = state.previews[session.id],
190+ onClick = { onOpenSession(session.id) },
191+ onDelete = { pendingDelete = session },
173192 )
174− IconButton(onClick = { vm.deleteSession(session) }) {
175− Icon(Icons.Filled.Delete, contentDescription = "Delete")
176− }
177193 }
178194 }
179195 }
@@ -181,4 +197,100 @@ fun SessionListScreen(
181197 }
182198 }
183199 }
200+
201+ pendingDelete?.let { session ->
202+ AlertDialog(
203+ onDismissRequest = { pendingDelete = null },
204+ title = { Text("Delete session?") },
205+ text = { Text("“${session.displayTitle}” will be permanently deleted. This cannot be undone.") },
206+ confirmButton = {
207+ TextButton(onClick = {
208+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
209+ pendingDelete = null
210+ vm.deleteSession(session)
211+ }) { Text("Delete", color = MaterialTheme.colorScheme.error) }
212+ },
213+ dismissButton = {
214+ TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
215+ },
216+ )
217+ }
218+}
219+
220+@Composable
221+private fun SessionCard(
222+ session: Session,
223+ preview: String?,
224+ onClick: () -> Unit,
225+ onDelete: () -> Unit,
226+) {
227+ Card(
228+ modifier = Modifier
229+ .fillMaxWidth()
230+ .clickable { onClick() },
231+ ) {
232+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
233+ Row(
234+ modifier = Modifier.fillMaxWidth(),
235+ verticalAlignment = Alignment.CenterVertically,
236+ ) {
237+ Text(
238+ session.displayTitle,
239+ style = MaterialTheme.typography.titleMedium,
240+ maxLines = 1,
241+ overflow = TextOverflow.Ellipsis,
242+ modifier = Modifier.weight(1f),
243+ )
244+ val time = relativeTime(session.time?.updated ?: session.time?.created)
245+ if (time.isNotEmpty()) {
246+ Text(
247+ time,
248+ style = MaterialTheme.typography.labelSmall,
249+ color = MaterialTheme.colorScheme.onSurfaceVariant,
250+ )
251+ }
252+ IconButton(onClick = onDelete) {
253+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
254+ }
255+ }
256+ if (!preview.isNullOrBlank()) {
257+ Spacer(Modifier.size(4.dp))
258+ Text(
259+ preview,
260+ style = MaterialTheme.typography.bodyMedium,
261+ color = MaterialTheme.colorScheme.onSurfaceVariant,
262+ maxLines = 2,
263+ overflow = TextOverflow.Ellipsis,
264+ )
265+ }
266+ }
267+ }
268+}
269+
270+@Composable
271+private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
272+ Column(
273+ modifier = modifier.padding(32.dp),
274+ horizontalAlignment = Alignment.CenterHorizontally,
275+ ) {
276+ Icon(
277+ Icons.Filled.ChatBubbleOutline,
278+ contentDescription = null,
279+ modifier = Modifier.size(56.dp),
280+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
281+ )
282+ Spacer(Modifier.size(16.dp))
283+ Text("No sessions yet", style = MaterialTheme.typography.titleMedium)
284+ Spacer(Modifier.size(8.dp))
285+ Text(
286+ "Start a conversation with opencode.",
287+ style = MaterialTheme.typography.bodyMedium,
288+ color = MaterialTheme.colorScheme.onSurfaceVariant,
289+ )
290+ Spacer(Modifier.size(20.dp))
291+ Button(onClick = onCreate) {
292+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
293+ Text(" New session")
294+ }
295+ }
184296}