diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index da7a1bf..abade29 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -165,6 +165,10 @@ fun ImportScreen( settings, setup = setup.id, provider = useProvider.name, + // Nothing to say: the + // server titles it from + // the session it is + // continuing. title = "", permissionMode = permissionMode, import = session.id, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 47e03c9..828be93 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -405,47 +406,64 @@ fun SessionScreen( // it: the first frame is already the newest message, and older // ones are composed only as somebody scrolls back to them, which // is also what makes history cheap on a long conversation. - LazyColumn( - state = listState, - reverseLayout = true, - modifier = Modifier.weight(1f).fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Reversed to match the layout, so index 0 is the newest and - // the reader still sees them in the order they happened. - items(items.asReversed()) { item -> - when (item) { - is TranscriptItem.UserMsg -> UserBubble(item.text) - is TranscriptItem.AssistantMsg -> - Text(item.text, style = MaterialTheme.typography.bodyLarge) - is TranscriptItem.ToolRun -> - ToolCard( - tool = item, - expanded = item.id in expandedTools, - onToggle = { - expandedTools = - if (item.id in expandedTools) expandedTools - item.id - else expandedTools + item.id - }, - ) - is TranscriptItem.QuestionCard -> - QuestionRow(item) { answer -> - act { answerQuestion(settings, summary.id, item.id, answer) } - } - is TranscriptItem.ErrorMsg -> - Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref) - is TranscriptItem.Note -> - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box(Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Reversed to match the layout, so index 0 is the newest and + // the reader still sees them in the order they happened. + items(items.asReversed()) { item -> + when (item) { + is TranscriptItem.UserMsg -> UserBubble(item.text) + is TranscriptItem.AssistantMsg -> + Text(item.text, style = MaterialTheme.typography.bodyLarge) + is TranscriptItem.ToolRun -> + ToolCard( + tool = item, + expanded = item.id in expandedTools, + onToggle = { + expandedTools = + if (item.id in expandedTools) expandedTools - item.id + else expandedTools + item.id + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answer -> + act { answerQuestion(settings, summary.id, item.id, answer) } + } + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref) + is TranscriptItem.Note -> + Text( + item.text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Only while the newest message is off-screen. Reading back + // through a conversation is a place to be, not a state to be + // rescued from, so this waits to be wanted -- and says where it + // goes rather than drawing an arrow, since an arrow in a list + // that is laid out upside down is the one thing nobody should + // have to reason about. + if (!followTail) { + FilledTonalButton( + onClick = { scope.launch { listState.animateScrollToItem(0) } }, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp), + ) { + Text("Jump to latest", style = MaterialTheme.typography.bodySmall) } } } diff --git a/server/src/routes.rs b/server/src/routes.rs index 0feb55c..92ea059 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -489,11 +489,21 @@ async fn spawn_session( provider: body.provider, // An imported session is recognised by what it was about, so its // opening message is the title unless one was typed. - title: body.title.or_else(|| { - seed.as_ref() - .map(|(chosen, _)| chosen.title.clone()) - .filter(|title| !title.is_empty()) - }), + // Blank normalised to absent here rather than trusted as a + // choice. A client with nothing to say sends `""`, which is + // `Some` and so satisfied `or_else` -- the imported session's real + // title was computed, then discarded in favour of the " + // session" fallback, so every import arrived called "claude-cli + // session". Absent and empty mean the same thing to a person and + // have to mean the same thing here. + title: body + .title + .filter(|title| !title.trim().is_empty()) + .or_else(|| { + seed.as_ref() + .map(|(chosen, _)| chosen.title.clone()) + .filter(|title| !title.trim().is_empty()) + }), model: body.model, // Resumed where it was working, so the CLI picks up the same tree. cwd: body.cwd.or_else(|| {