@@ -1,7 +1,16 @@
11package soy.iko.opencode.ui.chat
22
3+import android.app.Activity
4+import android.content.Intent
5+import android.net.Uri
6+import android.speech.RecognizerIntent
37import androidx.activity.compose.BackHandler
8+import androidx.activity.compose.rememberLauncherForActivityResult
9+import androidx.activity.result.PickVisualMediaRequest
10+import androidx.activity.result.contract.ActivityResultContracts
411import androidx.compose.animation.AnimatedVisibility
12+import androidx.compose.foundation.horizontalScroll
13+import androidx.compose.foundation.rememberScrollState
514import androidx.compose.foundation.clickable
615import androidx.compose.foundation.layout.Arrangement
716import androidx.compose.foundation.layout.Box
@@ -17,6 +26,9 @@ import androidx.compose.foundation.layout.imePadding
1726import androidx.compose.foundation.layout.navigationBars
1827import androidx.compose.foundation.layout.padding
1928import androidx.compose.foundation.layout.size
29+import androidx.compose.foundation.layout.widthIn
30+import androidx.compose.ui.draw.clip
31+import androidx.compose.ui.layout.ContentScale
2032import androidx.compose.foundation.layout.union
2133import androidx.compose.foundation.layout.windowInsetsPadding
2234import androidx.compose.foundation.lazy.LazyColumn
@@ -25,12 +37,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState
2537import androidx.compose.material.icons.Icons
2638import androidx.compose.material.icons.automirrored.filled.ArrowBack
2739import androidx.compose.material.icons.automirrored.filled.Send
40+import androidx.compose.material.icons.filled.Add
2841import androidx.compose.material.icons.filled.AutoAwesome
42+import androidx.compose.material.icons.filled.Close
2943import androidx.compose.material.icons.filled.Delete
44+import androidx.compose.material.icons.filled.Description
3045import androidx.compose.material.icons.filled.Edit
3146import androidx.compose.material.icons.filled.KeyboardArrowDown
47+import androidx.compose.material.icons.filled.Mic
3248import androidx.compose.material.icons.filled.MoreVert
49+import androidx.compose.material.icons.filled.PhotoCamera
50+import androidx.compose.material.icons.filled.PhotoLibrary
3351import androidx.compose.material.icons.filled.Refresh
52+import androidx.compose.material.icons.filled.Restore
3453import androidx.compose.material.icons.filled.Stop
3554import androidx.compose.material3.AlertDialog
3655import androidx.compose.material3.Button
@@ -91,6 +110,7 @@ import androidx.compose.ui.text.style.TextOverflow
91110import androidx.compose.ui.unit.dp
92111import androidx.lifecycle.compose.collectAsStateWithLifecycle
93112import androidx.lifecycle.viewmodel.compose.viewModel
113+import coil.compose.AsyncImage
94114import kotlinx.coroutines.Dispatchers
95115import kotlinx.coroutines.launch
96116import kotlinx.coroutines.withContext
@@ -107,6 +127,7 @@ import soy.iko.opencode.data.network.NetworkConfig
107127import soy.iko.opencode.di.AppContainer
108128import soy.iko.opencode.R
109129import soy.iko.opencode.ui.components.ConnectionBanner
130+import soy.iko.opencode.ui.components.copyToClipboard
110131import soy.iko.opencode.ui.components.LocalRelativeTimeTick
111132import soy.iko.opencode.ui.components.rememberRelativeTimeTick
112133import soy.iko.opencode.ui.components.showToast
@@ -145,6 +166,9 @@ fun ChatScreen(
145166 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
146167 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
147168 val draft by vm.draft.collectAsStateWithLifecycle()
169+ val attachments by vm.attachments.collectAsStateWithLifecycle()
170+ val reverted by vm.reverted.collectAsStateWithLifecycle()
171+ val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
148172 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
149173 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
150174 val isOnline by container.isOnline.collectAsStateWithLifecycle()
@@ -201,18 +225,18 @@ fun ChatScreen(
201225 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
202226 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
203227 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
228+ var showShellDialog by rememberSaveable { mutableStateOf(false) }
204229
205− // Keep the screen awake and hold a foreground priority while the agent is working,
206− // so backgrounding mid-run doesn't let Doze choke the SSE stream.
230+ // Keep the screen awake while the agent is working in *this* session. The foreground
231+ // service that holds process priority during a run is managed app-wide off
232+ // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
233+ // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
234+ // SSE stream before the completion notification fires.
207235 val currentView = LocalView.current
208236 val appContext = LocalContext.current.applicationContext
209237 DisposableEffect(running) {
210238 currentView.keepScreenOn = running
211− if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
212− onDispose {
213− currentView.keepScreenOn = false
214− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
215− }
239+ onDispose { currentView.keepScreenOn = false }
216240 }
217241
218242 LaunchedEffect(Unit) {
@@ -228,6 +252,104 @@ fun ChatScreen(
228252 }
229253 }
230254
255+ // --- Attachments, voice, and share-link plumbing ---
256+ val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
257+ val attachFailedMsg = stringResource(R.string.attachment_failed)
258+ val attachLimitMsg = stringResource(R.string.attachment_limit)
259+ val noCameraMsg = stringResource(R.string.no_camera_app)
260+ val noVoiceMsg = stringResource(R.string.no_voice_app)
261+ val voicePrompt = stringResource(R.string.voice_prompt)
262+ val linkCopiedMsg = stringResource(R.string.link_copied)
263+
264+ // Convert each picked Uri to a base64 attachment off the main thread, honoring the
265+ // per-prompt count cap and surfacing per-file errors without aborting the batch.
266+ fun stageUris(uris: List<Uri>) {
267+ if (uris.isEmpty()) return
268+ scope.launch {
269+ for (uri in uris) {
270+ if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
271+ snackbar.showSnackbar(attachLimitMsg)
272+ break
273+ }
274+ when (val result = uri.toAttachmentResult(appContext)) {
275+ is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
276+ AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
277+ AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
278+ }
279+ }
280+ }
281+ }
282+
283+ val photoPicker = rememberLauncherForActivityResult(
284+ ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
285+ ) { uris -> stageUris(uris) }
286+
287+ val filePicker = rememberLauncherForActivityResult(
288+ ActivityResultContracts.GetMultipleContents(),
289+ ) { uris -> stageUris(uris) }
290+
291+ // Uri survives a config change mid-capture so the TakePicture result still resolves.
292+ var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
293+ val cameraLauncher = rememberLauncherForActivityResult(
294+ ActivityResultContracts.TakePicture(),
295+ ) { success ->
296+ val uri = pendingCaptureUri
297+ pendingCaptureUri = null
298+ if (success && uri != null) stageUris(listOf(uri))
299+ }
300+
301+ val voiceLauncher = rememberLauncherForActivityResult(
302+ ActivityResultContracts.StartActivityForResult(),
303+ ) { result ->
304+ if (result.resultCode == Activity.RESULT_OK) {
305+ val spoken = result.data
306+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
307+ ?.firstOrNull()
308+ ?.takeIf { it.isNotBlank() }
309+ if (spoken != null) {
310+ // Append to the existing draft rather than replacing, so dictation adds to
311+ // whatever the user already typed.
312+ val current = vm.draft.value
313+ val combined = if (current.isBlank()) spoken else "$current $spoken"
314+ vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
315+ }
316+ }
317+ }
318+
319+ fun launchCamera() {
320+ val uri = newCameraCaptureUri(appContext)
321+ if (uri == null) {
322+ scope.launch { snackbar.showSnackbar(attachFailedMsg) }
323+ return
324+ }
325+ pendingCaptureUri = uri
326+ runCatching { cameraLauncher.launch(uri) }
327+ .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
328+ }
329+
330+ fun launchVoice() {
331+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
332+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
333+ putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
334+ }
335+ runCatching { voiceLauncher.launch(intent) }
336+ .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
337+ }
338+
339+ // A freshly-created share link is copied to the clipboard so the user can paste it right away.
340+ LaunchedEffect(Unit) {
341+ vm.shareLinkEvents.collect { url ->
342+ copyToClipboard(shareContext, "share", url)
343+ snackbar.showSnackbar(linkCopiedMsg)
344+ }
345+ }
346+
347+ // Stage images shared into the app (via the system share sheet) as attachments, once.
348+ LaunchedEffect(Unit) {
349+ val shared = container.consumePendingSharedMedia()
350+ if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
351+ }
352+
231353 fun doSend() {
232354 if (vm.send(draft)) {
233355 // TextHandleMove (a light tick) is semantically right for a send commit;
@@ -331,6 +453,12 @@ fun ChatScreen(
331453 val modelLabel = stringResource(R.string.choose_model)
332454 val renameLabel = stringResource(R.string.rename_session_chat)
333455 val deleteLabel = stringResource(R.string.delete_session_chat)
456+ val createShareLabel = stringResource(R.string.create_share_link)
457+ val copyShareLabel = stringResource(R.string.copy_share_link)
458+ val stopShareLabel = stringResource(R.string.stop_sharing)
459+ val summarizeLabel = stringResource(R.string.summarize_conversation)
460+ val initLabel = stringResource(R.string.generate_agents_md)
461+ val shellLabel = stringResource(R.string.run_shell_command)
334462 Box {
335463 IconButton(onClick = { showOverflowMenu = true }) {
336464 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -374,6 +502,45 @@ fun ChatScreen(
374502 onClick = { showOverflowMenu = false; showModelPicker = true },
375503 )
376504 androidx.compose.material3.HorizontalDivider()
505+ // Session sharing: create a public link, copy an existing one, or revoke it.
506+ if (shareUrl == null) {
507+ DropdownMenuItem(
508+ text = { Text(createShareLabel) },
509+ enabled = hasMessages,
510+ onClick = { showOverflowMenu = false; vm.shareSession() },
511+ )
512+ } else {
513+ DropdownMenuItem(
514+ text = { Text(copyShareLabel) },
515+ onClick = {
516+ showOverflowMenu = false
517+ shareUrl?.let {
518+ copyToClipboard(shareContext, "share", it)
519+ scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
520+ }
521+ },
522+ )
523+ DropdownMenuItem(
524+ text = { Text(stopShareLabel) },
525+ onClick = { showOverflowMenu = false; vm.unshareSession() },
526+ )
527+ }
528+ DropdownMenuItem(
529+ text = { Text(summarizeLabel) },
530+ enabled = hasMessages && !running,
531+ onClick = { showOverflowMenu = false; vm.summarize() },
532+ )
533+ DropdownMenuItem(
534+ text = { Text(initLabel) },
535+ enabled = !running,
536+ onClick = { showOverflowMenu = false; vm.initProject() },
537+ )
538+ DropdownMenuItem(
539+ text = { Text(shellLabel) },
540+ enabled = !running,
541+ onClick = { showOverflowMenu = false; showShellDialog = true },
542+ )
543+ androidx.compose.material3.HorizontalDivider()
377544 DropdownMenuItem(
378545 text = { Text(renameLabel) },
379546 onClick = { showOverflowMenu = false; showRenameDialog = true },
@@ -407,20 +574,35 @@ fun ChatScreen(
407574 snackbarHost = { SnackbarHost(snackbar) },
408575 bottomBar = {
409576 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
410− ChatInputBar(
411− value = draft,
412− onValueChange = vm::updateDraft,
413− running = running,
414− aborting = aborting,
415− enabled = activeConnection != null,
416− sendOnEnter = sendOnEnter,
417− onSend = ::doSend,
418− onAbort = { showStopConfirm = true },
419− queuedFollowUp = queuedFollowUp,
420− onQueueFollowUp = vm::queueFollowUp,
421− onCancelQueue = { vm.queueFollowUp("") },
422− focusRequester = inputFocusRequester,
423− )
577+ Column {
578+ AnimatedVisibility(visible = reverted) {
579+ RevertBanner(onUndo = { vm.unrevert() })
580+ }
581+ ChatInputBar(
582+ value = draft,
583+ onValueChange = vm::updateDraft,
584+ running = running,
585+ aborting = aborting,
586+ enabled = activeConnection != null,
587+ sendOnEnter = sendOnEnter,
588+ onSend = ::doSend,
589+ onAbort = { showStopConfirm = true },
590+ queuedFollowUp = queuedFollowUp,
591+ onQueueFollowUp = vm::queueFollowUp,
592+ onCancelQueue = { vm.queueFollowUp("") },
593+ focusRequester = inputFocusRequester,
594+ attachments = attachments,
595+ onRemoveAttachment = vm::removeAttachment,
596+ onPickPhoto = {
597+ photoPicker.launch(
598+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
599+ )
600+ },
601+ onPickFile = { filePicker.launch("*/*") },
602+ onCamera = { launchCamera() },
603+ onVoice = { launchVoice() },
604+ )
605+ }
424606 },
425607 ) { padding ->
426608 // Collect messages inside the content lambda so streaming token updates
@@ -647,6 +829,7 @@ fun ChatScreen(
647829 imageContext = imageContext,
648830 modelLabel = modelLabel,
649831 onOpenFile = onOpenFile,
832+ onRevert = { vm.revertTo(message.info.id) },
650833 )
651834 }
652835 }
@@ -758,6 +941,16 @@ fun ChatScreen(
758941 )
759942 }
760943
944+ if (showShellDialog) {
945+ ShellCommandDialog(
946+ onDismiss = { showShellDialog = false },
947+ onRun = { cmd ->
948+ showShellDialog = false
949+ vm.runShell(cmd)
950+ },
951+ )
952+ }
953+
761954 if (showModelPicker) {
762955 ModelPickerSheet(
763956 options = models,
@@ -815,7 +1008,15 @@ private fun ChatInputBar(
8151008 onQueueFollowUp: (String) -> Unit,
8161009 onCancelQueue: () -> Unit,
8171010 focusRequester: androidx.compose.ui.focus.FocusRequester,
1011+ attachments: List<PendingAttachment>,
1012+ onRemoveAttachment: (String) -> Unit,
1013+ onPickPhoto: () -> Unit,
1014+ onPickFile: () -> Unit,
1015+ onCamera: () -> Unit,
1016+ onVoice: () -> Unit,
8181017) {
1018+ // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1019+ val hasContent = value.isNotBlank() || attachments.isNotEmpty()
8191020 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
8201021 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
8211022 // A queued follow-up replaces the Stop button with a "queued" chip so the
@@ -846,10 +1047,52 @@ private fun ChatInputBar(
8461047 }
8471048 }
8481049 }
1050+ // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1051+ if (attachments.isNotEmpty()) {
1052+ Row(
1053+ modifier = Modifier
1054+ .fillMaxWidth()
1055+ .horizontalScroll(rememberScrollState())
1056+ .padding(start = 8.dp, end = 8.dp, top = 8.dp),
1057+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1058+ ) {
1059+ attachments.forEach { att ->
1060+ AttachmentChip(att, onRemove = { onRemoveAttachment(att.id) })
1061+ }
1062+ }
1063+ }
8491064 Row(
8501065 modifier = Modifier.fillMaxWidth().padding(8.dp),
8511066 verticalAlignment = Alignment.Bottom,
8521067 ) {
1068+ // Attach menu: photo picker, camera, or any file.
1069+ var showAttachMenu by remember { mutableStateOf(false) }
1070+ Box {
1071+ IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1072+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1073+ }
1074+ DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1075+ DropdownMenuItem(
1076+ leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1077+ text = { Text(stringResource(R.string.attach_photo)) },
1078+ onClick = { showAttachMenu = false; onPickPhoto() },
1079+ )
1080+ DropdownMenuItem(
1081+ leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1082+ text = { Text(stringResource(R.string.attach_camera)) },
1083+ onClick = { showAttachMenu = false; onCamera() },
1084+ )
1085+ DropdownMenuItem(
1086+ leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1087+ text = { Text(stringResource(R.string.attach_file)) },
1088+ onClick = { showAttachMenu = false; onPickFile() },
1089+ )
1090+ }
1091+ }
1092+ // Voice dictation: appends recognized speech to the draft.
1093+ IconButton(onClick = onVoice, enabled = enabled) {
1094+ Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1095+ }
8531096 OutlinedTextField(
8541097 value = value,
8551098 onValueChange = { v ->
@@ -865,11 +1108,13 @@ private fun ChatInputBar(
8651108 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
8661109 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
8671110 // With it off, Enter inserts a newline and Ctrl+Enter sends.
868− val send = when {
869− !enabled || value.isBlank() -> false
870− sendOnEnter -> !event.isShiftPressed
871− else -> event.isCtrlPressed
872− }
1111+ val send = enterShouldSend(
1112+ enabled = enabled,
1113+ hasContent = hasContent,
1114+ sendOnEnter = sendOnEnter,
1115+ shift = event.isShiftPressed,
1116+ ctrl = event.isCtrlPressed,
1117+ )
8731118 if (send) {
8741119 if (running) onQueueFollowUp(value) else onSend()
8751120 true
@@ -904,40 +1149,73 @@ private fun ChatInputBar(
9041149 capitalization = KeyboardCapitalization.Sentences,
9051150 ),
9061151 keyboardActions = KeyboardActions(onSend = {
907− if (enabled && value.isNotBlank()) {
1152+ if (enabled && hasContent) {
9081153 if (running) onQueueFollowUp(value) else onSend()
9091154 }
9101155 }),
9111156 )
912− if (running) {
913− // Show a spinner while the abort REST call is in flight so the user
914− // sees the stop was sent, and disable to prevent a double-tap.
915− IconButton(
916− onClick = onAbort,
917− enabled = !aborting,
918− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
919− ) {
920− if (aborting) {
921− val stopLabel = stringResource(R.string.stop)
922− CircularProgressIndicator(
923− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
924− strokeWidth = 2.dp,
925− )
926− } else {
927− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
928− }
929− }
930− } else {
931− IconButton(
932− onClick = onSend,
933− enabled = enabled && value.isNotBlank(),
934− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
935− ) {
936− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
937− }
938− }
1157+ ComposerTrailingButton(
1158+ running = running,
1159+ aborting = aborting,
1160+ canSend = enabled && hasContent,
1161+ onSend = onSend,
1162+ onAbort = onAbort,
1163+ )
1164+ }
1165+ }
1166+ }
1167+}
1168+
1169+/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
1170+ * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
1171+private fun enterShouldSend(
1172+ enabled: Boolean,
1173+ hasContent: Boolean,
1174+ sendOnEnter: Boolean,
1175+ shift: Boolean,
1176+ ctrl: Boolean,
1177+): Boolean = when {
1178+ !enabled || !hasContent -> false
1179+ sendOnEnter -> !shift
1180+ else -> ctrl
1181+}
1182+
1183+/** The composer's trailing button: a Stop (with in-flight spinner) while a run is active,
1184+ * otherwise Send. Extracted so [ChatInputBar] stays under the complexity threshold. */
1185+@Composable
1186+private fun ComposerTrailingButton(
1187+ running: Boolean,
1188+ aborting: Boolean,
1189+ canSend: Boolean,
1190+ onSend: () -> Unit,
1191+ onAbort: () -> Unit,
1192+) {
1193+ if (running) {
1194+ // Show a spinner while the abort REST call is in flight so the user sees the stop
1195+ // was sent, and disable to prevent a double-tap.
1196+ IconButton(
1197+ onClick = onAbort,
1198+ enabled = !aborting,
1199+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1200+ ) {
1201+ if (aborting) {
1202+ val stopLabel = stringResource(R.string.stop)
1203+ CircularProgressIndicator(
1204+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1205+ strokeWidth = 2.dp,
1206+ )
1207+ } else {
1208+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
9391209 }
9401210 }
1211+ } else {
1212+ IconButton(
1213+ onClick = onSend,
1214+ enabled = canSend,
1215+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
1216+ ) {
1217+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
1218+ }
9411219 }
9421220}
9431221
@@ -997,6 +1275,110 @@ private fun EmptyConversation(
9971275 }
9981276}
9991277
1278+/** A staged attachment: an image thumbnail (or a generic file icon) with its name and a
1279+ * remove button, shown in the composer above the input field. */
1280+@Composable
1281+private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
1282+ Surface(
1283+ shape = MaterialTheme.shapes.small,
1284+ color = MaterialTheme.colorScheme.surfaceVariant,
1285+ tonalElevation = 1.dp,
1286+ ) {
1287+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
1288+ if (attachment.previewModel != null) {
1289+ AsyncImage(
1290+ model = attachment.previewModel,
1291+ contentDescription = attachment.name,
1292+ contentScale = ContentScale.Crop,
1293+ modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
1294+ )
1295+ } else {
1296+ Icon(
1297+ Icons.Filled.Description,
1298+ contentDescription = null,
1299+ modifier = Modifier.size(24.dp),
1300+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1301+ )
1302+ }
1303+ Text(
1304+ attachment.name,
1305+ style = MaterialTheme.typography.labelMedium,
1306+ maxLines = 1,
1307+ overflow = TextOverflow.Ellipsis,
1308+ modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
1309+ )
1310+ IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
1311+ Icon(
1312+ Icons.Filled.Close,
1313+ contentDescription = stringResource(R.string.remove),
1314+ modifier = Modifier.size(16.dp),
1315+ )
1316+ }
1317+ }
1318+ }
1319+}
1320+
1321+/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1322+@Composable
1323+private fun RevertBanner(onUndo: () -> Unit) {
1324+ Surface(
1325+ color = MaterialTheme.colorScheme.tertiaryContainer,
1326+ tonalElevation = 2.dp,
1327+ modifier = Modifier.fillMaxWidth(),
1328+ ) {
1329+ Row(
1330+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1331+ verticalAlignment = Alignment.CenterVertically,
1332+ horizontalArrangement = Arrangement.SpaceBetween,
1333+ ) {
1334+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1335+ Icon(
1336+ Icons.Filled.Restore,
1337+ contentDescription = null,
1338+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
1339+ modifier = Modifier.size(18.dp),
1340+ )
1341+ Text(
1342+ stringResource(R.string.reverted_banner),
1343+ style = MaterialTheme.typography.bodySmall,
1344+ color = MaterialTheme.colorScheme.onTertiaryContainer,
1345+ modifier = Modifier.padding(start = 8.dp),
1346+ )
1347+ }
1348+ TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
1349+ }
1350+ }
1351+}
1352+
1353+/** Prompt for a one-off shell command to run in the session's worktree. */
1354+@Composable
1355+private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
1356+ var command by rememberSaveable { mutableStateOf("") }
1357+ AlertDialog(
1358+ onDismissRequest = onDismiss,
1359+ title = { Text(stringResource(R.string.run_shell_command)) },
1360+ text = {
1361+ OutlinedTextField(
1362+ value = command,
1363+ onValueChange = { command = it },
1364+ modifier = Modifier.fillMaxWidth(),
1365+ placeholder = { Text(stringResource(R.string.shell_command_hint)) },
1366+ label = { Text(stringResource(R.string.shell_command_label)) },
1367+ singleLine = true,
1368+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1369+ keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
1370+ )
1371+ },
1372+ confirmButton = {
1373+ TextButton(
1374+ onClick = { if (command.isNotBlank()) onRun(command.trim()) },
1375+ enabled = command.isNotBlank(),
1376+ ) { Text(stringResource(R.string.run)) }
1377+ },
1378+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
1379+ )
1380+}
1381+
10001382/**
10011383 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
10021384 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and