Also say "1 model" under Legacy models, and send the model Claude's default resolves to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 9 changed files with 35 additions and 22 deletions.
| @@ -293,8 +293,9 @@ class HostState(saved: SavedHost, private val app: AppState) { | ||
| 293 | 293 | fun refreshCatalog() = send(ClientMessage.Catalog) |
| 294 | 294 | |
| 295 | 295 | fun model(instance: String, id: String?): RemoteModel? { |
| 296 | - val models = instances.find { it.id == instance }?.models ?: return null | |
| 297 | - return models.find { it.id == id } ?: models.find { it.resolved == id } | |
| 296 | + // Threads saved before instances existed name none; any instance's model will do then. | |
| 297 | + val models = instances.find { it.id == instance }?.models ?: instances.flatMap { it.models } | |
| 298 | + return models.find { it.id == id } ?: models.find { it.resolved == id && !it.legacy } ?: models.find { it.resolved == id } | |
| 298 | 299 | } |
| 299 | 300 | |
| 300 | 301 | // ---- requests -------------------------------------------------------------- |
| @@ -156,7 +156,10 @@ fun Timeline(app: AppState, nav: Nav, thread: OpenThread, modifier: Modifier = M | ||
| 156 | 156 | snapshotFlow { list.isScrollInProgress }.collect { scrolling -> if (!scrolling) stick = atEnd } |
| 157 | 157 | } |
| 158 | 158 | LaunchedEffect(thread.id) { |
| 159 | - snapshotFlow { thread.items.size }.collect { if (stick) list.scrollToItem(0) } | |
| 159 | + // Rows come and go as turns fold and items arrive; the list keeps its place by key, so follow by hand. | |
| 160 | + snapshotFlow { list.layoutInfo.totalItemsCount to list.layoutInfo.viewportSize }.collect { | |
| 161 | + if (stick && (list.firstVisibleItemIndex != 0 || list.firstVisibleItemScrollOffset != 0)) list.scrollToItem(0) | |
| 162 | + } | |
| 160 | 163 | } |
| 161 | 164 | val scope = rememberCoroutineScope() |
| 162 | 165 | |
| @@ -4,6 +4,7 @@ package cc.vectrum.code.ui.chat | ||
| 4 | 4 | |
| 5 | 5 | import androidx.compose.foundation.background |
| 6 | 6 | import androidx.compose.foundation.clickable |
| 7 | + import androidx.compose.foundation.layout.widthIn | |
| 7 | 8 | import androidx.compose.foundation.layout.Arrangement |
| 8 | 9 | import androidx.compose.foundation.layout.Box |
| 9 | 10 | import androidx.compose.foundation.layout.Column |
| @@ -368,16 +369,22 @@ fun NoticeDivider(text: String) { | ||
| 368 | 369 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 369 | 370 | verticalAlignment = Alignment.CenterVertically, |
| 370 | 371 | ) { |
| 371 | - Spacer(Modifier.weight(1f).height(1.dp).background(p.rule)) | |
| 372 | + Spacer(Modifier.weight(1f).widthIn(min = 16.dp).height(1.dp).background(p.rule)) | |
| 372 | 373 | Row( |
| 373 | - Modifier.weight(4f, fill = false).padding(horizontal = 8.dp), | |
| 374 | + Modifier.weight(6f, fill = false).padding(horizontal = 8.dp), | |
| 374 | 375 | horizontalArrangement = Arrangement.spacedBy(6.dp), |
| 375 | 376 | verticalAlignment = Alignment.CenterVertically, |
| 376 | 377 | ) { |
| 377 | 378 | if (compacted) LucideIcon(Lucide.Minimize2, size = 12.dp, tint = p.ink3) |
| 378 | - Txt(if (compacted) "Context compacted" else text, color = p.ink3, size = 11.sp, weight = FontWeight.Medium) | |
| 379 | + Txt( | |
| 380 | + if (compacted) "Context compacted" else text, | |
| 381 | + color = p.ink3, | |
| 382 | + size = 11.sp, | |
| 383 | + weight = FontWeight.Medium, | |
| 384 | + style = androidx.compose.ui.text.TextStyle(textAlign = androidx.compose.ui.text.style.TextAlign.Center), | |
| 385 | + ) | |
| 379 | 386 | } |
| 380 | - Spacer(Modifier.weight(1f).height(1.dp).background(p.rule)) | |
| 387 | + Spacer(Modifier.weight(1f).widthIn(min = 16.dp).height(1.dp).background(p.rule)) | |
| 381 | 388 | } |
| 382 | 389 | } |
| 383 | 390 | |
| @@ -285,7 +285,7 @@ fun commandItems(commands: List<RemoteCommand>, word: CommandWord, skillsToo: Bo | ||
| 285 | 285 | // ---- the footer ---------------------------------------------------------------- |
| 286 | 286 | |
| 287 | 287 | /** About how wide a character of a footer label is. */ |
| 288 | - const val CharWidth = 7f | |
| 288 | + const val CharWidth = 7.5f | |
| 289 | 289 | |
| 290 | 290 | /** |
| 291 | 291 | * How much of the footer's left side shows: blocks (traits, then mode) |
| @@ -145,10 +145,10 @@ fun footerFit(target: PickTarget, width: Float, rightWidth: Float, planToggle: B | ||
| 145 | 145 | fun labelWidth(label: String) = label.length * CharWidth |
| 146 | 146 | val modelWidth = 52f + minOf(labelWidth(target.modelLabel), 200f) |
| 147 | 147 | val blocks = mutableListOf<Pair<Float, Float>>() |
| 148 | - if (traits != null) blocks.add(54f + labelWidth(traits) to 46f) | |
| 148 | + if (traits != null) blocks.add(54f + labelWidth(traits) to 58f) | |
| 149 | 149 | var modeWidth = 54f + labelWidth(accessLabel(target.access)) |
| 150 | 150 | if (planToggle) modeWidth += 5f + 70f |
| 151 | - blocks.add(modeWidth to 46f + if (planToggle) 37f else 0f) | |
| 151 | + blocks.add(modeWidth to 58f + if (planToggle) 37f else 0f) | |
| 152 | 152 | val layout = footerLayout(width, blocks, modelWidth, rightWidth) |
| 153 | 153 | fun shown(ix: Int) = ix < blocks.size - layout.hidden |
| 154 | 154 | fun iconOnly(ix: Int) = ix >= blocks.size - minOf(layout.iconOnly, blocks.size) |
| @@ -500,7 +500,7 @@ fun ColumnScope.ModelPicker(app: AppState, target: PickTarget, close: () -> Unit | ||
| 500 | 500 | Column(Modifier.weight(1f)) { |
| 501 | 501 | Txt("Legacy models", size = 12.sp, weight = FontWeight.Medium, maxLines = 1) |
| 502 | 502 | Txt( |
| 503 | - "${legacy.size} models", | |
| 503 | + "${legacy.size} model${if (legacy.size == 1) "" else "s"}", | |
| 504 | 504 | Modifier.padding(top = 4.dp), |
| 505 | 505 | color = p.ink3.copy(alpha = 0.7f), |
| 506 | 506 | size = 12.sp, |
| @@ -173,5 +173,11 @@ class PickTarget( | ||
| 173 | 173 | else -> permissionMode |
| 174 | 174 | } |
| 175 | 175 | |
| 176 | - val modelLabel: String get() = entry?.name ?: model ?: fallbackName ?: "Choose model" | |
| 176 | + /** On the agent's default, the model it runs, named from the catalog when the host has one. */ | |
| 177 | + val modelLabel: String | |
| 178 | + get() = entry?.name ?: model ?: fallbackName?.let { host?.model(instance, it)?.name ?: it } ?: defaultName ?: "Choose model" | |
| 179 | + | |
| 180 | + /** What the agent's default model is, as the desktop's picker names `default`. */ | |
| 181 | + private val defaultName: String? | |
| 182 | + get() = host?.instances?.find { it.id == instance }?.defaultModel?.let { host.model(instance, it)?.name } | |
| 177 | 183 | } |
| @@ -79,13 +79,9 @@ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) { | ||
| 79 | 79 | when { |
| 80 | 80 | nav.surfacePicker || surface == null -> SurfacePicker(nav) |
| 81 | 81 | surface == Surface.Subagents -> SubagentsPanel(app, nav, ref) |
| 82 | - else -> Column(Modifier.fillMaxSize()) { | |
| 83 | - val (icon, title) = if (surface == Surface.Terminal) Lucide.SquareTerminal to "Terminal" else Lucide.Smartphone to "Device" | |
| 84 | - PanelHeader(icon, title) { nav.surface = null } | |
| 85 | - Box(Modifier.weight(1f).fillMaxWidth()) { | |
| 86 | - if (surface == Surface.Terminal) TerminalPanel(app, nav, ref) else DevicePanel(app, nav, ref) | |
| 87 | - } | |
| 88 | - } | |
| 82 | + // The terminal and device panels draw their own tab bar with a close button. | |
| 83 | + surface == Surface.Terminal -> TerminalPanel(app, nav, ref) | |
| 84 | + else -> DevicePanel(app, nav, ref) | |
| 89 | 85 | } |
| 90 | 86 | } |
| 91 | 87 | } |
| @@ -285,7 +285,7 @@ pub struct RemoteInstance { | ||
| 285 | 285 | pub status: String, |
| 286 | 286 | pub account: Option<String>, |
| 287 | 287 | pub plan: Option<String>, |
| 288 | - /// The id of Claude's `default` entry, when it reports one. | |
| 288 | + /// The model Claude's `default` entry resolves to (`claude-opus-5-5`), when it reports one. | |
| 289 | 289 | pub default_model: Option<String>, |
| 290 | 290 | pub models: Vec<RemoteModel>, |
| 291 | 291 | pub commands: Vec<RemoteCommand>, |
| @@ -358,7 +358,7 @@ impl RemoteInstance { | ||
| 358 | 358 | status: status.into(), |
| 359 | 359 | account: state.account.clone(), |
| 360 | 360 | plan: state.plan.clone(), |
| 361 | - default_model: state.default.as_ref().map(|model| model.id.clone()), | |
| 361 | + default_model: state.default.as_ref().map(|model| model.resolved.clone()), | |
| 362 | 362 | models: state.models.iter().map(|model| RemoteModel::from_entry(model, catalog)).collect(), |
| 363 | 363 | commands: state |
| 364 | 364 | .commands |
| @@ -756,7 +756,7 @@ impl Render for ModelPicker { | ||
| 756 | 756 | .mt(px(4.)) |
| 757 | 757 | .text_size(px(12.)) |
| 758 | 758 | .text_color(p.ink3.opacity(0.7)) |
| 759 | - .child(format!("{legacy_count} models")), | |
| 759 | + .child(format!("{legacy_count} model{}", if legacy_count == 1 { "" } else { "s" })), | |
| 760 | 760 | ), |
| 761 | 761 | ) |
| 762 | 762 | .child( |