@@ -203,10 +203,12 @@ fun ChatScreen(
203 onOpenFile: ((String) -> Unit)? = null,
203 onOpenFile: ((String) -> Unit)? = null,
204 onOpenSession: ((String) -> Unit)? = null,
204 onOpenSession: ((String) -> Unit)? = null,
205 focusMessageId: String? = null,
205 focusMessageId: String? = null,
206+ onEditProfile: ((String) -> Unit)? = null,
207 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
208 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
208 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
209 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
209 val running by vm.running.collectAsStateWithLifecycle()
210 val running by vm.running.collectAsStateWithLifecycle()
211+ val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
210 val aborting by vm.aborting.collectAsStateWithLifecycle()
212 val aborting by vm.aborting.collectAsStateWithLifecycle()
211 val loading by vm.loading.collectAsStateWithLifecycle()
213 val loading by vm.loading.collectAsStateWithLifecycle()
212 val loadError by vm.loadError.collectAsStateWithLifecycle()
214 val loadError by vm.loadError.collectAsStateWithLifecycle()
@@ -217,6 +219,7 @@ fun ChatScreen(
217 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
219 val modelsError by vm.modelsError.collectAsStateWithLifecycle()
218 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
220 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
219 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
221 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
222+ val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
220 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
223 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
221 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
224 val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
222 val agents by vm.agents.collectAsStateWithLifecycle()
225 val agents by vm.agents.collectAsStateWithLifecycle()
@@ -296,7 +299,6 @@ fun ChatScreen(
296 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
299 var showTitleMenu by rememberSaveable { mutableStateOf(false) }
297 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
300 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
298 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
301 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
299− var showStopConfirm by rememberSaveable { mutableStateOf(false) }
300 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
302 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
301 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
303 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
302 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
304 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
@@ -363,6 +365,20 @@ fun ChatScreen(
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()
366 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
382 // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
367 // window on the container; show the snackbar over the conversation the user is viewing so
383 // window on the container; show the snackbar over the conversation the user is viewing so
368 // they can cancel without being navigated to the session list first. If the window expires
384 // they can cancel without being navigated to the session list first. If the window expires
@@ -547,8 +563,8 @@ fun ChatScreen(
547 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
563 ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
548 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
564 ev.key == Key.Escape && showPalette -> { showPalette = false; true }
549 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
565 ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
550− ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
566+ ev.key == Key.Escape && running && !showExitConfirm -> {
551− showStopConfirm = true; true
567+ vm.abort(); true
553 else -> false
569 else -> false
@@ -893,7 +909,7 @@ fun ChatScreen(
893 enabled = activeConnection != null,
909 enabled = activeConnection != null,
894 sendOnEnter = sendOnEnter,
910 sendOnEnter = sendOnEnter,
895 onSend = ::doSend,
911 onSend = ::doSend,
896− onAbort = { showStopConfirm = true },
912+ onAbort = { vm.abort() },
897 queuedFollowUp = queuedFollowUp,
913 queuedFollowUp = queuedFollowUp,
898 onQueueFollowUp = vm::queueFollowUp,
914 onQueueFollowUp = vm::queueFollowUp,
899 onCancelQueue = { vm.queueFollowUp("") },
915 onCancelQueue = { vm.queueFollowUp("") },
@@ -1155,7 +1171,21 @@ fun ChatScreen(
1155 // content padding accounts for the bottomBar's raised height, so the message
1171 // content padding accounts for the bottomBar's raised height, so the message
1156 // list is already above the keyboard. Adding imePadding here would double-apply
1172 // list is already above the keyboard. Adding imePadding here would double-apply
1157 // the IME inset and push messages too far up.
1173 // the IME inset and push messages too far up.
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(
1158− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
1181+ modifier = Modifier.fillMaxSize().padding(padding),
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)
1159 if (activeConnection == null) {
1189 if (activeConnection == null) {
1161 modifier = Modifier.align(Alignment.Center).padding(24.dp),
1191 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -1187,6 +1217,10 @@ fun ChatScreen(
1187 // fires while the banner is composed (visible), so gate the inset on
1217 // fires while the banner is composed (visible), so gate the inset on
1188 // bannerVisible and fall back to 44dp until the first measurement arrives.
1218 // bannerVisible and fall back to 44dp until the first measurement arrives.
1189 var bannerHeightPx by remember { mutableIntStateOf(0) }
1219 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) }
1190 val density = LocalDensity.current
1224 val density = LocalDensity.current
1191 val topPad = if (bannerVisible) {
1225 val topPad = if (bannerVisible) {
1192 if (bannerHeightPx > 0) {
1226 if (bannerHeightPx > 0) {
@@ -1196,17 +1230,29 @@ fun ChatScreen(
1197 } else {
1231 } else {
1233+ } + if (searchActive && searchbarHeightPx > 0) {
1234+ with(density) { searchbarHeightPx.toDp() } + 4.dp
1200 ConnectionBanner(
1238 ConnectionBanner(
1201 state = connectionState,
1239 state = connectionState,
1202 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1240 modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1203 isOnline = isOnline,
1241 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
1205 // re-seeds from REST). refreshMessages is the right recovery path
1243 // re-seeds from REST). refreshMessages is the right recovery path
1206 // when the connection is present but the stream died; reconnect()
1244 // when the connection is present but the stream died; reconnect()
1207 // is the path when the whole connection is gone (handled by the
1245 // is the path when the whole connection is gone (handled by the
1208 // separate "Not connected" state below).
1246 // separate "Not connected" state below).
1209 onRetry = { vm.refreshMessages() },
1247 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)
1255+ reconnectAttempts = reconnectAttempts,
1211 // Persistent inline error banner for a mid-conversation load failure.
1257 // Persistent inline error banner for a mid-conversation load failure.
1212 // Unlike the one-shot snackbar (which fires once per streak then goes
1258 // Unlike the one-shot snackbar (which fires once per streak then goes
@@ -1286,6 +1332,27 @@ fun ChatScreen(
1286 Button(onClick = { vm.refreshMessages() }) {
1332 Button(onClick = { vm.refreshMessages() }) {
1287 Text(stringResource(R.string.retry))
1333 Text(stringResource(R.string.retry))
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.
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,
1350+ style = MaterialTheme.typography.labelSmall,
1351+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1352+ modifier = Modifier.padding(start = 6.dp),
1290 } else if (messages.isEmpty() && running) {
1357 } else if (messages.isEmpty() && running) {
1291 // A run just started but no parts have arrived yet — the empty list with
1358 // A run just started but no parts have arrived yet — the empty list with
@@ -1391,8 +1458,10 @@ fun ChatScreen(
1392 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1459 // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1393 // snapping back (the message isn't dismissed — the quote fills the composer).
1460 // 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
1461+ // Enabled for non-text messages too (image/tool-only), but the confirm
1395− // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
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)
1396 val swipeState = rememberSwipeToDismissBoxState(
1465 val swipeState = rememberSwipeToDismissBoxState(
1397 confirmValueChange = { value ->
1466 confirmValueChange = { value ->
1398 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1467 val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
@@ -1400,10 +1469,18 @@ fun ChatScreen(
1400 } else {
1469 } else {
1401 SwipeToDismissBoxValue.StartToEnd
1470 SwipeToDismissBoxValue.StartToEnd
1403− if (value == replyValue && quoteText != null) {
1472+ if (value == replyValue) {
1473+ if (quoteText != null) {
1404− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1474+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1405− vm.quoteReply(quoteText)
1475+ vm.quoteReply(quoteText)
1406− runCatching { inputFocusRequester.requestFocus() }
1476+ runCatching { inputFocusRequester.requestFocus() }
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) }
@@ -1415,8 +1492,8 @@ fun ChatScreen(
1415 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1492 val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1416 SwipeToDismissBox(
1493 SwipeToDismissBox(
1417 state = swipeState,
1494 state = swipeState,
1418− enableDismissFromEndToStart = !replyFromStartToEnd && quoteText != null,
1495+ enableDismissFromEndToStart = !replyFromStartToEnd,
1419− enableDismissFromStartToEnd = replyFromStartToEnd && quoteText != null,
1496+ enableDismissFromStartToEnd = replyFromStartToEnd,
1420 backgroundContent = {
1497 backgroundContent = {
1422 Modifier.fillMaxSize().padding(horizontal = 16.dp),
1499 Modifier.fillMaxSize().padding(horizontal = 16.dp),
@@ -1528,6 +1605,11 @@ fun ChatScreen(
1528 if (running) {
1605 if (running) {
1529 item(key = "__typing") {
1606 item(key = "__typing") {
1530 val workingText = stringResource(R.string.working)
1607 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
1531 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1613 // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1532 // survives LazyColumn recycling — a local remember reset to 0:00 when
1614 // survives LazyColumn recycling — a local remember reset to 0:00 when
1533 // the row was disposed and scrolled back into view. The VM stamps the
1615 // the row was disposed and scrolled back into view. The VM stamps the
@@ -1574,11 +1656,18 @@ fun ChatScreen(
1575 verticalAlignment = Alignment.CenterVertically,
1657 verticalAlignment = Alignment.CenterVertically,
1576 modifier = Modifier.semantics(mergeDescendants = true) {
1658 modifier = Modifier.semantics(mergeDescendants = true) {
1577− contentDescription = "$workingText $elapsedText"
1659+ contentDescription = "$interruptedLabel $elapsedText"
1662+ // Dim the spinner when interrupted so the row reads as
1663+ // "paused/reconnecting" rather than actively progressing.
1580− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1664+ CircularProgressIndicator(
1665+ Modifier.size(16.dp),
1666+ strokeWidth = 2.dp,
1667+ color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1668+ else MaterialTheme.colorScheme.primary,
1581− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1670+ Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1582 Spacer(Modifier.size(8.dp))
1671 Spacer(Modifier.size(8.dp))
1584 Icons.Filled.Schedule,
1673 Icons.Filled.Schedule,
@@ -1600,7 +1689,7 @@ fun ChatScreen(
1600 Spacer(Modifier.weight(1f))
1689 Spacer(Modifier.weight(1f))
1601 val stopLabel = stringResource(R.string.stop)
1690 val stopLabel = stringResource(R.string.stop)
1602 IconButton(
1691 IconButton(
1603− onClick = { showStopConfirm = true },
1692+ onClick = { vm.abort() },
1604 modifier = Modifier.semantics { contentDescription = stopLabel },
1693 modifier = Modifier.semantics { contentDescription = stopLabel },
1606 Icon(Icons.Filled.Stop, contentDescription = null)
1695 Icon(Icons.Filled.Stop, contentDescription = null)
@@ -1618,7 +1707,9 @@ fun ChatScreen(
1618 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1707 visible = !isPinnedToBottom && listItems.isNotEmpty(),
1619 enter = fabMotion.enter,
1708 enter = fabMotion.enter,
1620 exit = fabMotion.exit,
1709 exit = fabMotion.exit,
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).
1621− modifier = Modifier.align(Alignment.BottomEnd),
1712+ modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
1623 // Badge the FAB when new content arrived while the user was scrolled up, so
1714 // Badge the FAB when new content arrived while the user was scrolled up, so
1624 // they know there's something new to jump to (cleared once back at bottom).
1715 // they know there's something new to jump to (cleared once back at bottom).
@@ -1669,7 +1760,7 @@ fun ChatScreen(
1669 visible = scrolledDown && listItems.size > 4,
1760 visible = scrolledDown && listItems.size > 4,
1670 enter = fabMotion.enter,
1761 enter = fabMotion.enter,
1671 exit = fabMotion.exit,
1762 exit = fabMotion.exit,
1672− modifier = Modifier.align(Alignment.BottomStart),
1763+ modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1674 SmallFloatingActionButton(
1765 SmallFloatingActionButton(
1675 onClick = {
1766 onClick = {
@@ -1690,13 +1781,16 @@ fun ChatScreen(
1690 visible = searchActive,
1781 visible = searchActive,
1691 enter = searchMotion.enter,
1782 enter = searchMotion.enter,
1692 exit = searchMotion.exit,
1783 exit = searchMotion.exit,
1784+ // Inset by sideMargin on wide screens so the search bar spans the capped
1785+ // list's width, not the full screen width.
1693− modifier = Modifier.align(Alignment.TopCenter),
1786+ modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
1695 Surface(
1788 Surface(
1696 tonalElevation = 3.dp,
1789 tonalElevation = 3.dp,
1697 modifier = Modifier
1790 modifier = Modifier
1698 .padding(horizontal = 12.dp, vertical = 4.dp)
1791 .padding(horizontal = 12.dp, vertical = 4.dp)
1699− .fillMaxWidth(),
1792+ .fillMaxWidth()
1793+ .onSizeChanged { searchbarHeightPx = it.height },
1701 // Focus the search field when the bar appears (from the overflow menu's
1795 // Focus the search field when the bar appears (from the overflow menu's
1702 // "Find in conversation" or Ctrl+F), so the user can start typing
1796 // "Find in conversation" or Ctrl+F), so the user can start typing
@@ -1728,7 +1822,12 @@ fun ChatScreen(
1728 } else "",
1822 } else "",
1729 style = MaterialTheme.typography.labelSmall,
1823 style = MaterialTheme.typography.labelSmall,
1730 color = MaterialTheme.colorScheme.onSurfaceVariant,
1824 color = MaterialTheme.colorScheme.onSurfaceVariant,
1825+ modifier = Modifier
1731− modifier = Modifier.padding(horizontal = 4.dp),
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 },
1733 // Step through matches (Previous / Next), disabled when there are
1832 // Step through matches (Previous / Next), disabled when there are
1734 // fewer than two to navigate. Wrap around at the ends so repeated
1833 // fewer than two to navigate. Wrap around at the ends so repeated
@@ -1798,24 +1897,6 @@ fun ChatScreen(
1801− if (showStopConfirm) {
1803− onDismissRequest = { showStopConfirm = false },
1804− title = { Text(stringResource(R.string.stop_run_title)) },
1805− text = { Text(stringResource(R.string.stop_run_text)) },
1807− TextButton(onClick = {
1808− showStopConfirm = false
1809− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1811− }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
1814− TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
1819 if (showSummarizeConfirm) {
1900 if (showSummarizeConfirm) {
1820 AlertDialog(
1901 AlertDialog(
1821 onDismissRequest = { showSummarizeConfirm = false },
1902 onDismissRequest = { showSummarizeConfirm = false },
@@ -1899,6 +1980,10 @@ fun ChatScreen(
1899 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
1980 container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
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")
1902 ModelPickerSheet(
1987 ModelPickerSheet(
1903 options = models,
1988 options = models,
1904 selected = selectedModel,
1989 selected = selectedModel,
@@ -1912,7 +1997,18 @@ fun ChatScreen(
1912 onDismiss = { showModelPicker = false },
1997 onDismiss = { showModelPicker = false },
1913 preferredModelId = preferredModelId,
1998 preferredModelId = preferredModelId,
1914 onSetPreferredModel = { id ->
1999 onSetPreferredModel = { id ->
1915− scope.launch { runCatchingCancellable { container.settingsStore.setPreferredModelId(id) } }
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
2007+ val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2008+ defaultModelSetFmt.format(label)
2010+ snackbar.showSnackbar(msg)
1917 recent = recentOptions,
2013 recent = recentOptions,
@@ -2294,6 +2390,16 @@ private fun ChatInputBar(
2294 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2390 color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2295 else MaterialTheme.colorScheme.onSurfaceVariant,
2391 else MaterialTheme.colorScheme.onSurfaceVariant,
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.
2399+ stringResource(R.string.open_full_editor_hint),
2400+ style = MaterialTheme.typography.labelSmall,
2401+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2299 keyboardOptions = KeyboardOptions(
2405 keyboardOptions = KeyboardOptions(