@@ -203,10 +203,12 @@ fun ChatScreen(
203203 onOpenFile: ((String) -> Unit)? = null,
204204 onOpenSession: ((String) -> Unit)? = null,
205205 focusMessageId: String? = null,
206+ onEditProfile: ((String) -> Unit)? = null,
206207) {
207208 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
208209 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
209210 val running by vm.running.collectAsStateWithLifecycle()
211+ val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
210212 val aborting by vm.aborting.collectAsStateWithLifecycle()
211213 val loading by vm.loading.collectAsStateWithLifecycle()
212214 val loadError by vm.loadError.collectAsStateWithLifecycle()
@@ -217,6 +219,7 @@ fun ChatScreen(
217219 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
218220 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
219221 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
222+ val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
220223 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
221224 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
222225 val agents by vm.agents.collectAsStateWithLifecycle()
@@ -296,7 +299,6 @@ fun ChatScreen(
296299 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
297300 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
298301 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
299− var showStopConfirm by rememberSaveable { mutableStateOf(false) }
300302 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
301303 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
302304 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
@@ -363,6 +365,20 @@ fun ChatScreen(
363365 }
364366 }
365367
368+ // Stop-undo: abort() emits the last-sent prompt here so this collector can show a
369+ // "Stopped — Undo" snackbar. Tapping Undo re-sends the prompt, recovering an accidental
370+ // Stop without a confirmation dialog (Stop now happens on first tap).
371+ val undoStopLabel = stringResource(R.string.undo)
372+ val stoppedLabel = stringResource(R.string.run_stopped_undo)
373+ LaunchedEffect(Unit) {
374+ vm.stopUndoEvents.collect { prompt ->
375+ if (prompt != null) {
376+ val result = snackbar.showSnackbar(message = stoppedLabel, actionLabel = undoStopLabel)
377+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.resendLastPrompt()
378+ }
379+ }
380+ }
381+
366382 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
367383 // window on the container; show the snackbar over the conversation the user is viewing so
368384 // they can cancel without being navigated to the session list first. If the window expires
@@ -547,8 +563,8 @@ fun ChatScreen(
547563 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
548564 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
549565 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
550− ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
551− showStopConfirm = true; true
566+ ev.key == Key.Escape && running && !showExitConfirm -> {
567+ vm.abort(); true
552568 }
553569 else -> false
554570 }
@@ -893,7 +909,7 @@ fun ChatScreen(
893909 enabled = activeConnection != null,
894910 sendOnEnter = sendOnEnter,
895911 onSend = ::doSend,
896− onAbort = { showStopConfirm = true },
912+ onAbort = { vm.abort() },
897913 queuedFollowUp = queuedFollowUp,
898914 onQueueFollowUp = vm::queueFollowUp,
899915 onCancelQueue = { vm.queueFollowUp("") },
@@ -1155,7 +1171,21 @@ fun ChatScreen(
11551171 // content padding accounts for the bottomBar's raised height, so the message
11561172 // list is already above the keyboard. Adding imePadding here would double-apply
11571173 // the IME inset and push messages too far up.
1158− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
1174+ //
1175+ // BoxWithConstraints so the FABs and search bar can align to the capped (800dp)
1176+ // message list on wide screens instead of floating at the screen edges — on a
1177+ // tablet the list is a centered column, so edge-aligned FABs are visually detached.
1178+ // The horizontal margin below is applied to the FAB/search containers so they
1179+ // track the list's left/right edges.
1180+ androidx.compose.foundation.layout.BoxWithConstraints(
1181+ modifier = Modifier.fillMaxSize().padding(padding),
1182+ ) {
1183+ val screenMaxWidth = maxWidth
1184+ val listMaxWidth = NetworkConfig.chatContentMaxWidthDp.dp
1185+ // Horizontal margin between the capped list's edge and the screen edge (0 on
1186+ // screens narrower than the cap). Applied as start/end padding so the FABs and
1187+ // search bar sit at the list's edges, not the screen's.
1188+ val sideMargin = ((screenMaxWidth - listMaxWidth) / 2).coerceAtLeast(0.dp)
11591189 if (activeConnection == null) {
11601190 Column(
11611191 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -1187,6 +1217,10 @@ fun ChatScreen(
11871217 // fires while the banner is composed (visible), so gate the inset on
11881218 // bannerVisible and fall back to 44dp until the first measurement arrives.
11891219 var bannerHeightPx by remember { mutableIntStateOf(0) }
1220+ // Track the search bar height so the list's top padding can grow when search
1221+ // is active — otherwise a scrolled-to match lands at index 0, hidden behind
1222+ // the floating search bar (the "scroll-under-the-search-bar" bug).
1223+ var searchbarHeightPx by remember { mutableIntStateOf(0) }
11901224 val density = LocalDensity.current
11911225 val topPad = if (bannerVisible) {
11921226 if (bannerHeightPx > 0) {
@@ -1196,17 +1230,29 @@ fun ChatScreen(
11961230 }
11971231 } else {
11981232 16.dp
1233+ } + if (searchActive && searchbarHeightPx > 0) {
1234+ with(density) { searchbarHeightPx.toDp() } + 4.dp
1235+ } else {
1236+ 0.dp
11991237 }
12001238 ConnectionBanner(
12011239 state = connectionState,
12021240 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
12031241 isOnline = isOnline,
1204− // On a hard failure, retry by forcing an SSE reconnect (which
1242+ // On a hard endpoint failure, retry by forcing an SSE reconnect (which
12051243 // re-seeds from REST). refreshMessages is the right recovery path
12061244 // when the connection is present but the stream died; reconnect()
12071245 // is the path when the whole connection is gone (handled by the
12081246 // separate "Not connected" state below).
12091247 onRetry = { vm.refreshMessages() },
1248+ // On an auth failure (401/403), retrying with the same bad credentials
1249+ // is futile — offer a one-tap path to edit the active profile's
1250+ // credentials instead of making the user hunt for the server list.
1251+ onEditCredentials = {
1252+ val profileId = activeConnection?.profile?.id
1253+ if (profileId != null) onEditProfile?.invoke(profileId)
1254+ },
1255+ reconnectAttempts = reconnectAttempts,
12101256 )
12111257 // Persistent inline error banner for a mid-conversation load failure.
12121258 // Unlike the one-shot snackbar (which fires once per streak then goes
@@ -1286,6 +1332,27 @@ fun ChatScreen(
12861332 Button(onClick = { vm.refreshMessages() }) {
12871333 Text(stringResource(R.string.retry))
12881334 }
1335+ // When the VM's retryWhen loop is actively retrying (loading is true
1336+ // on top of the error state), surface a small "retrying…" indicator so
1337+ // the user knows the system is working — without it, the static error
1338+ // + Retry button reads as "broken, tap to fix", and the user may not
1339+ // realize an automatic retry is already in progress.
1340+ if (loading) {
1341+ Spacer(Modifier.size(12.dp))
1342+ val retryingLabel = stringResource(R.string.retrying_ellipsis)
1343+ Row(verticalAlignment = Alignment.CenterVertically) {
1344+ CircularProgressIndicator(
1345+ Modifier.size(14.dp),
1346+ strokeWidth = 2.dp,
1347+ )
1348+ Text(
1349+ retryingLabel,
1350+ style = MaterialTheme.typography.labelSmall,
1351+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1352+ modifier = Modifier.padding(start = 6.dp),
1353+ )
1354+ }
1355+ }
12891356 }
12901357 } else if (messages.isEmpty() && running) {
12911358 // A run just started but no parts have arrived yet — the empty list with
@@ -1391,8 +1458,10 @@ fun ChatScreen(
13911458 }
13921459 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
13931460 // snapping back (the message isn't dismissed — the quote fills the composer).
1394− // Only enabled when there's text to quote, so non-text messages don't
1395− // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1461+ // Enabled for non-text messages too (image/tool-only), but the confirm
1462+ // callback gives a haptic without triggering reply — so the user gets a
1463+ // perceptible "can't quote this" signal instead of a silently dead gesture.
1464+ val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
13961465 val swipeState = rememberSwipeToDismissBoxState(
13971466 confirmValueChange = { value ->
13981467 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
@@ -1400,10 +1469,18 @@ fun ChatScreen(
14001469 } else {
14011470 SwipeToDismissBoxValue.StartToEnd
14021471 }
1403− if (value == replyValue && quoteText != null) {
1404− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1405− vm.quoteReply(quoteText)
1406− runCatching { inputFocusRequester.requestFocus() }
1472+ if (value == replyValue) {
1473+ if (quoteText != null) {
1474+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1475+ vm.quoteReply(quoteText)
1476+ runCatching { inputFocusRequester.requestFocus() }
1477+ } else {
1478+ // Non-text message: give a longer haptic so the user
1479+ // feels the gesture was received but can't quote it,
1480+ // and surface a brief toast so the reason is clear.
1481+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1482+ scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1483+ }
14071484 }
14081485 false
14091486 },
@@ -1415,8 +1492,8 @@ fun ChatScreen(
14151492 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
14161493 SwipeToDismissBox(
14171494 state = swipeState,
1418− enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1419− enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
1495+ enableDismissFromEndToStart = !replyFromStartToEnd,
1496+ enableDismissFromStartToEnd = replyFromStartToEnd,
14201497 backgroundContent = {
14211498 Box(
14221499 Modifier.fillMaxSize().padding(horizontal = 16.dp),
@@ -1528,6 +1605,11 @@ fun ChatScreen(
15281605 if (running) {
15291606 item(key = "__typing") {
15301607 val workingText = stringResource(R.string.working)
1608+ // When the SSE stream dropped mid-run, show a "reconnecting" label
1609+ // instead of the plain "working" text so the user understands the
1610+ // run is still active but the stream is reconnecting (not finished).
1611+ val reconnectingText = stringResource(R.string.reconnecting)
1612+ val interruptedLabel = if (streamInterrupted) reconnectingText else workingText
15311613 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
15321614 // survives LazyColumn recycling — a local remember reset to 0:00 when
15331615 // the row was disposed and scrolled back into view. The VM stamps the
@@ -1574,11 +1656,18 @@ fun ChatScreen(
15741656 Row(
15751657 verticalAlignment = Alignment.CenterVertically,
15761658 modifier = Modifier.semantics(mergeDescendants = true) {
1577− contentDescription = "$workingText $elapsedText"
1659+ contentDescription = "$interruptedLabel $elapsedText"
15781660 },
15791661 ) {
1580− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1581− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1662+ // Dim the spinner when interrupted so the row reads as
1663+ // "paused/reconnecting" rather than actively progressing.
1664+ CircularProgressIndicator(
1665+ Modifier.size(16.dp),
1666+ strokeWidth = 2.dp,
1667+ color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1668+ else MaterialTheme.colorScheme.primary,
1669+ )
1670+ Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
15821671 Spacer(Modifier.size(8.dp))
15831672 Icon(
15841673 Icons.Filled.Schedule,
@@ -1600,7 +1689,7 @@ fun ChatScreen(
16001689 Spacer(Modifier.weight(1f))
16011690 val stopLabel = stringResource(R.string.stop)
16021691 IconButton(
1603− onClick = { showStopConfirm = true },
1692+ onClick = { vm.abort() },
16041693 modifier = Modifier.semantics { contentDescription = stopLabel },
16051694 ) {
16061695 Icon(Icons.Filled.Stop, contentDescription = null)
@@ -1618,7 +1707,9 @@ fun ChatScreen(
16181707 visible = !isPinnedToBottom && listItems.isNotEmpty(),
16191708 enter = fabMotion.enter,
16201709 exit = fabMotion.exit,
1621− modifier = Modifier.align(Alignment.BottomEnd),
1710+ // Inset by sideMargin on wide screens so the FAB tracks the capped list's
1711+ // edge instead of floating at the screen edge (visually detached on tablets).
1712+ modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
16221713 ) {
16231714 // Badge the FAB when new content arrived while the user was scrolled up, so
16241715 // they know there's something new to jump to (cleared once back at bottom).
@@ -1669,7 +1760,7 @@ fun ChatScreen(
16691760 visible = scrolledDown && listItems.size > 4,
16701761 enter = fabMotion.enter,
16711762 exit = fabMotion.exit,
1672− modifier = Modifier.align(Alignment.BottomStart),
1763+ modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
16731764 ) {
16741765 SmallFloatingActionButton(
16751766 onClick = {
@@ -1690,13 +1781,16 @@ fun ChatScreen(
16901781 visible = searchActive,
16911782 enter = searchMotion.enter,
16921783 exit = searchMotion.exit,
1693− modifier = Modifier.align(Alignment.TopCenter),
1784+ // Inset by sideMargin on wide screens so the search bar spans the capped
1785+ // list's width, not the full screen width.
1786+ modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
16941787 ) {
16951788 Surface(
16961789 tonalElevation = 3.dp,
16971790 modifier = Modifier
16981791 .padding(horizontal = 12.dp, vertical = 4.dp)
1699− .fillMaxWidth(),
1792+ .fillMaxWidth()
1793+ .onSizeChanged { searchbarHeightPx = it.height },
17001794 ) {
17011795 // Focus the search field when the bar appears (from the overflow menu's
17021796 // "Find in conversation" or Ctrl+F), so the user can start typing
@@ -1728,7 +1822,12 @@ fun ChatScreen(
17281822 } else "",
17291823 style = MaterialTheme.typography.labelSmall,
17301824 color = MaterialTheme.colorScheme.onSurfaceVariant,
1731− modifier = Modifier.padding(horizontal = 4.dp),
1825+ modifier = Modifier
1826+ .padding(horizontal = 4.dp)
1827+ // Live region so a TalkBack user hears "3 of 12" / "No
1828+ // matches" announce as they step through results, not just
1829+ // the sighted user seeing the counter update.
1830+ .semantics { liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite },
17321831 )
17331832 // Step through matches (Previous / Next), disabled when there are
17341833 // fewer than two to navigate. Wrap around at the ends so repeated
@@ -1798,24 +1897,6 @@ fun ChatScreen(
17981897 )
17991898 }
18001899
1801− if (showStopConfirm) {
1802− AlertDialog(
1803− onDismissRequest = { showStopConfirm = false },
1804− title = { Text(stringResource(R.string.stop_run_title)) },
1805− text = { Text(stringResource(R.string.stop_run_text)) },
1806− confirmButton = {
1807− TextButton(onClick = {
1808− showStopConfirm = false
1809− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1810− vm.abort()
1811− }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
1812− },
1813− dismissButton = {
1814− TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
1815− },
1816− )
1817− }
1818−
18191900 if (showSummarizeConfirm) {
18201901 AlertDialog(
18211902 onDismissRequest = { showSummarizeConfirm = false },
@@ -1899,6 +1980,10 @@ fun ChatScreen(
18991980 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
19001981 }
19011982 }
1983+ // Resolve snackbar strings in the composable scope; the onSetPreferredModel lambda
1984+ // below isn't @Composable, so stringResource can't be called inside it.
1985+ val defaultModelClearedMsg = stringResource(R.string.default_model_cleared)
1986+ val defaultModelSetFmt = stringResource(R.string.default_model_set, "%s")
19021987 ModelPickerSheet(
19031988 options = models,
19041989 selected = selectedModel,
@@ -1912,7 +1997,18 @@ fun ChatScreen(
19121997 onDismiss = { showModelPicker = false },
19131998 preferredModelId = preferredModelId,
19141999 onSetPreferredModel = { id ->
1915− scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
2000+ scope.launch {
2001+ runCatchingCancellable { container.settingsStore.setPreferredModelId(id) }
2002+ // Confirm the change with a snackbar so the user sees it took effect —
2003+ // the toggle's label flip can be missed, especially when clearing.
2004+ val msg = if (id.isEmpty()) {
2005+ defaultModelClearedMsg
2006+ } else {
2007+ val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2008+ defaultModelSetFmt.format(label)
2009+ }
2010+ snackbar.showSnackbar(msg)
2011+ }
19162012 },
19172013 recent = recentOptions,
19182014 )
@@ -2294,6 +2390,16 @@ private fun ChatInputBar(
22942390 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
22952391 else MaterialTheme.colorScheme.onSurfaceVariant,
22962392 )
2393+ } else if (value.lines().size > 6) {
2394+ // The inline field caps at 6 lines; once the draft crosses that,
2395+ // hint that a bigger full-screen editor exists (the Expand icon
2396+ // above is easy to miss among the icon row). A tappable label
2397+ // would be ideal, but a plain hint is enough to draw attention.
2398+ Text(
2399+ stringResource(R.string.open_full_editor_hint),
2400+ style = MaterialTheme.typography.labelSmall,
2401+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2402+ )
22972403 }
22982404 },
22992405 keyboardOptions = KeyboardOptions(