Give the transcript a way back, and stop losing an import's name

**Every imported session was called "claude-cli session".** The app has
nothing to say about the title -- the server names it after the session it
is continuing -- so it sent `""`. That is `Some`, which satisfied the
`or_else` meant to catch "no title given", so the imported name was
computed and then thrown away in favour of the `<provider> session`
fallback.

Normalised at the boundary instead: blank means absent, because that is
what it means to the person who left it blank. Both the client's value and
the imported one go through the same trim, so neither can be a string of
spaces standing in for a name.

**And a jump-to-latest button**, shown only while the newest message is
off-screen. Reading back through a conversation is a place to be rather
than a state to be rescued from, so it waits to be wanted and leaves once
there is nowhere to jump to.

It says where it goes instead of drawing an arrow. The list is laid out
from the bottom, so "down" in the data is up on the screen, and an arrow
would be asking the reader to hold that in their head to press a button.

Verified on screen: an import now arrives titled "ai-app" rather than
"claude-cli session", and the button appears on scrolling back, returns to
the newest message, and disappears on arrival.
This commit is contained in:
iris committed 2026-08-28 23:19:07 -04:00
1 parent dcb158ee44
commit 2fe34176c0
3 files changed
+78 -46

No files matched your search

@@ -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,
@@ -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)
}
}
}
+15 -5
View File
@@ -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 "<provider>
// 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(|| {