@@ -1,7 +1,16 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
3+import android.app.Activity
4+import android.content.Intent
6+import android.speech.RecognizerIntent
3import androidx.activity.compose.BackHandler
7import androidx.activity.compose.BackHandler
8+import androidx.activity.compose.rememberLauncherForActivityResult
9+import androidx.activity.result.PickVisualMediaRequest
10+import androidx.activity.result.contract.ActivityResultContracts
4import androidx.compose.animation.AnimatedVisibility
11import androidx.compose.animation.AnimatedVisibility
12+import androidx.compose.foundation.horizontalScroll
13+import androidx.compose.foundation.rememberScrollState
5import androidx.compose.foundation.clickable
14import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
15import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
16import androidx.compose.foundation.layout.Box
@@ -17,6 +26,9 @@ import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.navigationBars
26import androidx.compose.foundation.layout.navigationBars
18import androidx.compose.foundation.layout.padding
27import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
28import 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
20import androidx.compose.foundation.layout.union
32import androidx.compose.foundation.layout.union
21import androidx.compose.foundation.layout.windowInsetsPadding
33import androidx.compose.foundation.layout.windowInsetsPadding
22import androidx.compose.foundation.lazy.LazyColumn
34import androidx.compose.foundation.lazy.LazyColumn
@@ -25,12 +37,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState
25import androidx.compose.material.icons.Icons
37import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.ArrowBack
38import androidx.compose.material.icons.automirrored.filled.ArrowBack
27import androidx.compose.material.icons.automirrored.filled.Send
39import androidx.compose.material.icons.automirrored.filled.Send
40+import androidx.compose.material.icons.filled.Add
28import androidx.compose.material.icons.filled.AutoAwesome
41import androidx.compose.material.icons.filled.AutoAwesome
42+import androidx.compose.material.icons.filled.Close
29import androidx.compose.material.icons.filled.Delete
43import androidx.compose.material.icons.filled.Delete
44+import androidx.compose.material.icons.filled.Description
30import androidx.compose.material.icons.filled.Edit
45import androidx.compose.material.icons.filled.Edit
31import androidx.compose.material.icons.filled.KeyboardArrowDown
46import androidx.compose.material.icons.filled.KeyboardArrowDown
47+import androidx.compose.material.icons.filled.Mic
32import androidx.compose.material.icons.filled.MoreVert
48import androidx.compose.material.icons.filled.MoreVert
49+import androidx.compose.material.icons.filled.PhotoCamera
50+import androidx.compose.material.icons.filled.PhotoLibrary
33import androidx.compose.material.icons.filled.Refresh
51import androidx.compose.material.icons.filled.Refresh
52+import androidx.compose.material.icons.filled.Restore
34import androidx.compose.material.icons.filled.Stop
53import androidx.compose.material.icons.filled.Stop
35import androidx.compose.material3.AlertDialog
54import androidx.compose.material3.AlertDialog
36import androidx.compose.material3.Button
55import androidx.compose.material3.Button
@@ -91,6 +110,7 @@ import androidx.compose.ui.text.style.TextOverflow
91import androidx.compose.ui.unit.dp
110import androidx.compose.ui.unit.dp
92import androidx.lifecycle.compose.collectAsStateWithLifecycle
111import androidx.lifecycle.compose.collectAsStateWithLifecycle
93import androidx.lifecycle.viewmodel.compose.viewModel
112import androidx.lifecycle.viewmodel.compose.viewModel
113+import coil.compose.AsyncImage
94import kotlinx.coroutines.Dispatchers
114import kotlinx.coroutines.Dispatchers
95import kotlinx.coroutines.launch
115import kotlinx.coroutines.launch
96import kotlinx.coroutines.withContext
116import kotlinx.coroutines.withContext
@@ -107,6 +127,7 @@ import soy.iko.opencode.data.network.NetworkConfig
107import soy.iko.opencode.di.AppContainer
127import soy.iko.opencode.di.AppContainer
108import soy.iko.opencode.R
128import soy.iko.opencode.R
109import soy.iko.opencode.ui.components.ConnectionBanner
129import soy.iko.opencode.ui.components.ConnectionBanner
130+import soy.iko.opencode.ui.components.copyToClipboard
110import soy.iko.opencode.ui.components.LocalRelativeTimeTick
131import soy.iko.opencode.ui.components.LocalRelativeTimeTick
111import soy.iko.opencode.ui.components.rememberRelativeTimeTick
132import soy.iko.opencode.ui.components.rememberRelativeTimeTick
112import soy.iko.opencode.ui.components.showToast
133import soy.iko.opencode.ui.components.showToast
@@ -145,6 +166,9 @@ fun ChatScreen(
145 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
166 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
146 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
167 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
147 val draft by vm.draft.collectAsStateWithLifecycle()
168 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()
148 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
172 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
149 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
173 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
150 val isOnline by container.isOnline.collectAsStateWithLifecycle()
174 val isOnline by container.isOnline.collectAsStateWithLifecycle()
@@ -201,18 +225,18 @@ fun ChatScreen(
201 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
225 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
202 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
226 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
203 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
227 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
228+ var showShellDialog by rememberSaveable { mutableStateOf(false) }
205− // Keep the screen awake and hold a foreground priority while the agent is working,
230+ // Keep the screen awake while the agent is working in *this* session. The foreground
206− // so backgrounding mid-run doesn't let Doze choke the SSE stream.
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.
207 val currentView = LocalView.current
235 val currentView = LocalView.current
208 val appContext = LocalContext.current.applicationContext
236 val appContext = LocalContext.current.applicationContext
209 DisposableEffect(running) {
237 DisposableEffect(running) {
210 currentView.keepScreenOn = running
238 currentView.keepScreenOn = running
211− if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
213− currentView.keepScreenOn = false
239+ onDispose { currentView.keepScreenOn = false }
214− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
218 LaunchedEffect(Unit) {
242 LaunchedEffect(Unit) {
@@ -228,6 +252,104 @@ fun ChatScreen(
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)
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
270+ if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
271+ snackbar.showSnackbar(attachLimitMsg)
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)
283+ val photoPicker = rememberLauncherForActivityResult(
284+ ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
285+ ) { uris -> stageUris(uris) }
287+ val filePicker = rememberLauncherForActivityResult(
288+ ActivityResultContracts.GetMultipleContents(),
289+ ) { uris -> stageUris(uris) }
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(),
296+ val uri = pendingCaptureUri
297+ pendingCaptureUri = null
298+ if (success && uri != null) stageUris(listOf(uri))
301+ val voiceLauncher = rememberLauncherForActivityResult(
302+ ActivityResultContracts.StartActivityForResult(),
304+ if (result.resultCode == Activity.RESULT_OK) {
305+ val spoken = result.data
306+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
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))
319+ fun launchCamera() {
320+ val uri = newCameraCaptureUri(appContext)
322+ scope.launch { snackbar.showSnackbar(attachFailedMsg) }
325+ pendingCaptureUri = uri
326+ runCatching { cameraLauncher.launch(uri) }
327+ .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
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)
335+ runCatching { voiceLauncher.launch(intent) }
336+ .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
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)
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) })
231 fun doSend() {
353 fun doSend() {
232 if (vm.send(draft)) {
354 if (vm.send(draft)) {
233 // TextHandleMove (a light tick) is semantically right for a send commit;
355 // TextHandleMove (a light tick) is semantically right for a send commit;
@@ -331,6 +453,12 @@ fun ChatScreen(
331 val modelLabel = stringResource(R.string.choose_model)
453 val modelLabel = stringResource(R.string.choose_model)
332 val renameLabel = stringResource(R.string.rename_session_chat)
454 val renameLabel = stringResource(R.string.rename_session_chat)
333 val deleteLabel = stringResource(R.string.delete_session_chat)
455 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)
335 IconButton(onClick = { showOverflowMenu = true }) {
463 IconButton(onClick = { showOverflowMenu = true }) {
336 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
464 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -374,6 +502,45 @@ fun ChatScreen(
374 onClick = { showOverflowMenu = false; showModelPicker = true },
502 onClick = { showOverflowMenu = false; showModelPicker = true },
376 androidx.compose.material3.HorizontalDivider()
504 androidx.compose.material3.HorizontalDivider()
505+ // Session sharing: create a public link, copy an existing one, or revoke it.
506+ if (shareUrl == null) {
508+ text = { Text(createShareLabel) },
509+ enabled = hasMessages,
510+ onClick = { showOverflowMenu = false; vm.shareSession() },
514+ text = { Text(copyShareLabel) },
516+ showOverflowMenu = false
518+ copyToClipboard(shareContext, "share", it)
519+ scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
524+ text = { Text(stopShareLabel) },
525+ onClick = { showOverflowMenu = false; vm.unshareSession() },
529+ text = { Text(summarizeLabel) },
530+ enabled = hasMessages && !running,
531+ onClick = { showOverflowMenu = false; vm.summarize() },
534+ text = { Text(initLabel) },
536+ onClick = { showOverflowMenu = false; vm.initProject() },
539+ text = { Text(shellLabel) },
541+ onClick = { showOverflowMenu = false; showShellDialog = true },
543+ androidx.compose.material3.HorizontalDivider()
377 DropdownMenuItem(
544 DropdownMenuItem(
378 text = { Text(renameLabel) },
545 text = { Text(renameLabel) },
379 onClick = { showOverflowMenu = false; showRenameDialog = true },
546 onClick = { showOverflowMenu = false; showRenameDialog = true },
@@ -407,20 +574,35 @@ fun ChatScreen(
407 snackbarHost = { SnackbarHost(snackbar) },
574 snackbarHost = { SnackbarHost(snackbar) },
408 bottomBar = {
575 bottomBar = {
409 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
576 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
578+ AnimatedVisibility(visible = reverted) {
579+ RevertBanner(onUndo = { vm.unrevert() })
410− ChatInputBar(
581+ ChatInputBar(
411− value = draft,
582+ value = draft,
412− onValueChange = vm::updateDraft,
583+ onValueChange = vm::updateDraft,
413− running = running,
584+ running = running,
414− aborting = aborting,
585+ aborting = aborting,
415− enabled = activeConnection != null,
586+ enabled = activeConnection != null,
416− sendOnEnter = sendOnEnter,
587+ sendOnEnter = sendOnEnter,
417− onSend = ::doSend,
588+ onSend = ::doSend,
418− onAbort = { showStopConfirm = true },
589+ onAbort = { showStopConfirm = true },
419− queuedFollowUp = queuedFollowUp,
590+ queuedFollowUp = queuedFollowUp,
420− onQueueFollowUp = vm::queueFollowUp,
591+ onQueueFollowUp = vm::queueFollowUp,
421− onCancelQueue = { vm.queueFollowUp("") },
592+ onCancelQueue = { vm.queueFollowUp("") },
422− focusRequester = inputFocusRequester,
593+ focusRequester = inputFocusRequester,
594+ attachments = attachments,
595+ onRemoveAttachment = vm::removeAttachment,
598+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
601+ onPickFile = { filePicker.launch("*/*") },
602+ onCamera = { launchCamera() },
603+ onVoice = { launchVoice() },
425 ) { padding ->
607 ) { padding ->
426 // Collect messages inside the content lambda so streaming token updates
608 // Collect messages inside the content lambda so streaming token updates
@@ -647,6 +829,7 @@ fun ChatScreen(
647 imageContext = imageContext,
829 imageContext = imageContext,
648 modelLabel = modelLabel,
830 modelLabel = modelLabel,
649 onOpenFile = onOpenFile,
831 onOpenFile = onOpenFile,
832+ onRevert = { vm.revertTo(message.info.id) },
@@ -758,6 +941,16 @@ fun ChatScreen(
944+ if (showShellDialog) {
946+ onDismiss = { showShellDialog = false },
948+ showShellDialog = false
761 if (showModelPicker) {
954 if (showModelPicker) {
762 ModelPickerSheet(
955 ModelPickerSheet(
763 options = models,
956 options = models,
@@ -815,7 +1008,15 @@ private fun ChatInputBar(
815 onQueueFollowUp: (String) -> Unit,
1008 onQueueFollowUp: (String) -> Unit,
816 onCancelQueue: () -> Unit,
1009 onCancelQueue: () -> Unit,
817 focusRequester: androidx.compose.ui.focus.FocusRequester,
1010 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,
1018+ // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1019+ val hasContent = value.isNotBlank() || attachments.isNotEmpty()
819 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1020 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
820 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1021 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
821 // A queued follow-up replaces the Stop button with a "queued" chip so the
1022 // A queued follow-up replaces the Stop button with a "queued" chip so the
@@ -846,10 +1047,52 @@ private fun ChatInputBar(
1050+ // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1051+ if (attachments.isNotEmpty()) {
1053+ modifier = Modifier
1055+ .horizontalScroll(rememberScrollState())
1056+ .padding(start = 8.dp, end = 8.dp, top = 8.dp),
1057+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1059+ attachments.forEach { att ->
1060+ AttachmentChip(att, onRemove = { onRemoveAttachment(att.id) })
850 modifier = Modifier.fillMaxWidth().padding(8.dp),
1065 modifier = Modifier.fillMaxWidth().padding(8.dp),
851 verticalAlignment = Alignment.Bottom,
1066 verticalAlignment = Alignment.Bottom,
1068+ // Attach menu: photo picker, camera, or any file.
1069+ var showAttachMenu by remember { mutableStateOf(false) }
1071+ IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1072+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1074+ DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1076+ leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1077+ text = { Text(stringResource(R.string.attach_photo)) },
1078+ onClick = { showAttachMenu = false; onPickPhoto() },
1081+ leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1082+ text = { Text(stringResource(R.string.attach_camera)) },
1083+ onClick = { showAttachMenu = false; onCamera() },
1086+ leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1087+ text = { Text(stringResource(R.string.attach_file)) },
1088+ onClick = { showAttachMenu = false; onPickFile() },
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))
853 OutlinedTextField(
1096 OutlinedTextField(
854 value = value,
1097 value = value,
855 onValueChange = { v ->
1098 onValueChange = { v ->
@@ -865,11 +1108,13 @@ private fun ChatInputBar(
865 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1108 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
866 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1109 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
867 // With it off, Enter inserts a newline and Ctrl+Enter sends.
1110 // With it off, Enter inserts a newline and Ctrl+Enter sends.
868− val send = when {
1111+ val send = enterShouldSend(
869− !enabled || value.isBlank() -> false
1112+ enabled = enabled,
1113+ hasContent = hasContent,
1114+ sendOnEnter = sendOnEnter,
870− sendOnEnter -> !event.isShiftPressed
1115+ shift = event.isShiftPressed,
871− else -> event.isCtrlPressed
1116+ ctrl = event.isCtrlPressed,
873 if (send) {
1118 if (send) {
874 if (running) onQueueFollowUp(value) else onSend()
1119 if (running) onQueueFollowUp(value) else onSend()
@@ -904,40 +1149,73 @@ private fun ChatInputBar(
904 capitalization = KeyboardCapitalization.Sentences,
1149 capitalization = KeyboardCapitalization.Sentences,
906 keyboardActions = KeyboardActions(onSend = {
1151 keyboardActions = KeyboardActions(onSend = {
907− if (enabled && value.isNotBlank()) {
1152+ if (enabled && hasContent) {
908 if (running) onQueueFollowUp(value) else onSend()
1153 if (running) onQueueFollowUp(value) else onSend()
1157+ ComposerTrailingButton(
1159+ aborting = aborting,
1160+ canSend = enabled && hasContent,
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(
1173+ hasContent: Boolean,
1174+ sendOnEnter: Boolean,
1178+ !enabled || !hasContent -> false
1179+ sendOnEnter -> !shift
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. */
1186+private fun ComposerTrailingButton(
1190+ onSend: () -> Unit,
1191+ onAbort: () -> Unit,
912− if (running) {
1193+ if (running) {
913− // Show a spinner while the abort REST call is in flight so the user
1194+ // Show a spinner while the abort REST call is in flight so the user sees the stop
914− // sees the stop was sent, and disable to prevent a double-tap.
1195+ // was sent, and disable to prevent a double-tap.
915− IconButton(
1196+ IconButton(
916− onClick = onAbort,
1197+ onClick = onAbort,
917− enabled = !aborting,
1198+ enabled = !aborting,
918− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1199+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
920− if (aborting) {
1201+ if (aborting) {
921− val stopLabel = stringResource(R.string.stop)
1202+ val stopLabel = stringResource(R.string.stop)
922− CircularProgressIndicator(
1203+ CircularProgressIndicator(
923− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1204+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
924− strokeWidth = 2.dp,
1205+ strokeWidth = 2.dp,
926− } else {
1207+ } else {
927− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1208+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
933− enabled = enabled && value.isNotBlank(),
934− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
936− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
1215+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
1217+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
@@ -997,6 +1275,110 @@ private fun EmptyConversation(
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. */
1281+private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
1283+ shape = MaterialTheme.shapes.small,
1284+ color = MaterialTheme.colorScheme.surfaceVariant,
1285+ tonalElevation = 1.dp,
1287+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
1288+ if (attachment.previewModel != null) {
1290+ model = attachment.previewModel,
1291+ contentDescription = attachment.name,
1292+ contentScale = ContentScale.Crop,
1293+ modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
1297+ Icons.Filled.Description,
1298+ contentDescription = null,
1299+ modifier = Modifier.size(24.dp),
1300+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1305+ style = MaterialTheme.typography.labelMedium,
1307+ overflow = TextOverflow.Ellipsis,
1308+ modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
1310+ IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
1312+ Icons.Filled.Close,
1313+ contentDescription = stringResource(R.string.remove),
1314+ modifier = Modifier.size(16.dp),
1321+/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1323+private fun RevertBanner(onUndo: () -> Unit) {
1325+ color = MaterialTheme.colorScheme.tertiaryContainer,
1326+ tonalElevation = 2.dp,
1327+ modifier = Modifier.fillMaxWidth(),
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,
1334+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1336+ Icons.Filled.Restore,
1337+ contentDescription = null,
1338+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
1339+ modifier = Modifier.size(18.dp),
1342+ stringResource(R.string.reverted_banner),
1343+ style = MaterialTheme.typography.bodySmall,
1344+ color = MaterialTheme.colorScheme.onTertiaryContainer,
1345+ modifier = Modifier.padding(start = 8.dp),
1348+ TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
1353+/** Prompt for a one-off shell command to run in the session's worktree. */
1355+private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
1356+ var command by rememberSaveable { mutableStateOf("") }
1358+ onDismissRequest = onDismiss,
1359+ title = { Text(stringResource(R.string.run_shell_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)) },
1368+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1369+ keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
1374+ onClick = { if (command.isNotBlank()) onRun(command.trim()) },
1375+ enabled = command.isNotBlank(),
1376+ ) { Text(stringResource(R.string.run)) }
1378+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
1001 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1383 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1002 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
1384 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and