Wrap table cells instead of cutting them off, and hand a batch over in one request

The renderer draws every table cell at one line with an ellipsis, so most
of a table was unreadable on a phone -- and an elided cell looks exactly
like a short one, so nothing said anything had been cut. Cells now take as
many lines as they need and align to the top of the row. Width is the other
half: a column narrows to 136dp and no further, and past that the table
scrolls sideways rather than squeezing. 136 is the widest floor that still
fits three columns across a phone, measured rather than picked; four and up
scroll, which is the right answer for genuinely too many columns.

The import screen used to send one request per selected row, so a handover
was only as atomic as the network: some rows started and the rest were
never asked for, and a row nobody asked for looks exactly like a row nobody
picked. `POST /setups/{id}/importable/delete` and `.../import` now take the
whole list, and every id is registered as in flight before the 202 goes
back. Only the registering is atomic -- the work settles per row, since six
deletes that all roll back together is not something a filesystem offers.

The echo driver grows `/table N`, with cells long enough to have been
truncated: a fixture of tidy one-word values renders fine whether or not
the bug is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 21:27:20 -04:00
1 parent 3c159fa1e1
commit 778b2e3b04
7 files changed
+317 -74

No files matched your search

+30 -7
View File
@@ -207,13 +207,22 @@ first if a remote spawn ever mangles an argument.
and says which operation in a word** -- `BusyItem`, used by both the and says which operation in a word** -- `BusyItem`, used by both the
session list and the import list so the appearance is learned once. The session list and the import list so the appearance is learned once. The
word rather than a bare spinner because "deleting" and "importing" differ word rather than a bare spinner because "deleting" and "importing" differ
in kind, and the inertness is the overlay consuming pointer events rather in kind. It dims and desaturates but does **not** make the row inert: the
than each caller remembering to disable its own click handler. caller disables its own click handler while it passes a label. An overlay
- **Importing and deleting run on the server, not in the request.** `DELETE consuming pointer events was tried and swallowed the drag along with the
/setups/{id}/importable/{session}` and `POST tap, so a list could not be scrolled while anything in it was busy.
/setups/{id}/importable/{session}/import` both answer 202 and do the work - **Importing and deleting run on the server, not in the request, and a
in a spawned task, because the phone that asked is free to leave and used batch is handed over in one call.** `POST
to cancel its own batch by doing so. What replaces the reply is /setups/{id}/importable/delete` and `POST /setups/{id}/importable/import`
each take a list of session ids, answer 202, and do the work in spawned
tasks -- because the phone that asked is free to leave and used to cancel
its own batch by doing so. A list rather than a route per session because
one request per row made a handover only as atomic as the network: some
rows started and the rest were never asked for, and a row nobody asked
for looks exactly like a row nobody picked. Every id is registered as in
flight before the 202 goes back. Only the *registering* is atomic; the
work itself settles per row, since six deletes that all roll back
together is not something a filesystem offers. What replaces the reply is
`session::pending`: every row of the listing carries `pending` and `session::pending`: every row of the listing carries `pending` and
`error`, and `GET /setups/{id}/importable/events` streams the changes. `error`, and `GET /setups/{id}/importable/events` streams the changes.
**Both, not either.** The stream is a broadcast with no memory, so an **Both, not either.** The stream is a broadcast with no memory, so an
@@ -246,6 +255,20 @@ first if a remote spawn ever mangles an argument.
be there to import again" is exactly the one the switch makes false. The be there to import again" is exactly the one the switch makes false. The
server deletes the machine's copy *first*, so a machine it cannot reach server deletes the machine's copy *first*, so a machine it cannot reach
leaves the session where it was instead of half-deleted. leaves the session where it was instead of half-deleted.
- **A markdown table wraps its cells and never cuts one off.** The
renderer's own defaults draw every cell at one line with an ellipsis,
which on a phone loses most of a table -- and an elided cell looks
exactly like a short one, so nothing on screen says anything was cut.
`Markdown.kt` supplies its own header and row blocks with `maxLines =
Int.MAX_VALUE` and `TextOverflow.Clip`, cells aligned to the top of the
row so a two-line cell does not re-centre its neighbours. Width is the
other half: a column narrows to 136dp and no further, and past that the
whole table scrolls sideways rather than squeezing -- 136 because it is
the widest floor that still fits three columns across a phone, which is
the commonest table there is. Exercise it with the echo driver's
`/table N` (default six columns), which writes long cells on purpose:
a fixture of tidy one-word values renders fine whether or not the
truncation is fixed.
- **Android Lint is not optional and is not run by a build.** It found a - **Android Lint is not optional and is not run by a build.** It found a
crash that had been shipping: `java.time` on a minSdk-24 app with crash that had been shipping: `java.time` on a minSdk-24 app with
desugaring off — and later a permission check that silently dropped every desugaring off — and later a permission check that silently dropped every
@@ -629,47 +629,55 @@ fun startSession(settings: ServerSettings, sessionId: String) {
} }
/** /**
* Removes a Claude Code session from the machine. * Asks the machine to delete Claude Code sessions, and returns as soon as it has accepted the lot.
* *
* The transcript *is* the session, so this ends any chance of resuming that conversation. The * The transcript *is* the session, so this ends any chance of resuming those conversations. The
* caller confirms first; see ImportScreen. * caller confirms first; see ImportScreen.
*/
/**
* Asks the machine to delete a Claude Code session, and returns as soon as it has accepted.
* *
* The work runs on the server, so this returning is not the same as it being done -- what says that * The work runs on the server, so this returning is not the same as it being done -- what says that
* is the row's own state, through [fetchImportable] and the change stream. That is the point: * is each row's own state, through [fetchImportable] and the change stream. That is the point:
* leaving the screen used to cancel the delete it had started. * leaving the screen used to cancel the delete it had started.
*
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were
* missed looked exactly like two that had not been picked.
*/ */
fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) { fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {} requestFromServer(
settings,
"/setups/$setup/importable/delete",
method = "POST",
jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(),
) {}
} }
/** /**
* Continues a Claude Code session in the background, returning once the server has accepted. * Continues Claude Code sessions in the background, returning once the server has accepted them.
* *
* Separate from [spawnSession] because the two are asked different questions. That one means "start * Separate from [spawnSession] because the two are asked different questions. That one means "start
* this and take me to it", so it waits and answers with the session. This is the import list's * this and take me to it", so it waits and answers with the session. This is the import list's
* batch: several at once, nobody waiting on any particular one, and the result arrives as a row * batch: several at once, nobody waiting on any particular one, and the result arrives as a row
* changing rather than as a reply -- which is what lets the screen be left. * changing rather than as a reply -- which is what lets the screen be left. One request for all of
* them, for the reason [deleteImportable] gives.
*/ */
fun startImport( fun startImport(
settings: ServerSettings, settings: ServerSettings,
setup: String, setup: String,
sessionId: String, sessionIds: List<String>,
provider: String, provider: String,
permissionMode: String? = null, permissionMode: String? = null,
model: String? = null, model: String? = null,
) { ) {
val body = val body =
JSONObject().apply { JSONObject().apply {
put("sessions", JSONArray(sessionIds))
put("provider", provider) put("provider", provider)
permissionMode?.let { put("permissionMode", it) } permissionMode?.let { put("permissionMode", it) }
model?.let { put("model", it) } model?.let { put("model", it) }
} }
requestFromServer( requestFromServer(
settings, settings,
"/setups/$setup/importable/$sessionId/import", "/setups/$setup/importable/import",
method = "POST", method = "POST",
jsonBody = body.toString(), jsonBody = body.toString(),
) {} ) {}
@@ -39,8 +39,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -167,12 +165,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
/** /**
* Hands [targets] to the server, marking each row as it goes. * Hands [targets] to the server in one request, marking every row it covers.
* *
* The requests only *start* the work now -- the server runs it and says how it went on the * The request only *starts* the work -- the server runs it and says how each row went on the
* change stream, which is what lets this screen be left while a batch is still going. So there * change stream, which is what lets this screen be left while a batch is still going. So there
* is nothing here to wait for and nothing to sequence: each row is marked, its request goes, * is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and
* and everything after that arrives as an event. * everything after that arrives as an event.
* *
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between * Marked [WAITING] rather than with the operation's own word until the server confirms. Between
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it * the request leaving and the `started` event coming back, "we have asked" is the truth and "it
@@ -181,30 +179,29 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* The selection is dropped as the work is handed over, not when it finishes: the screen goes * The selection is dropped as the work is handed over, not when it finishes: the screen goes
* back to how it started, and what says the work is happening is the rows it is happening to. * back to how it started, and what says the work is happening is the rows it is happening to.
*/ */
fun handOver(targets: List<Importable>, send: suspend (Importable) -> Unit) { fun handOver(targets: List<Importable>, send: suspend (List<String>) -> Unit) {
selected = emptySet() selected = emptySet()
running = running + targets.associate { it.id to WAITING } running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet() rowErrors = rowErrors - targets.map { it.id }.toSet()
val setup = chosen val setup = chosen
val ids = targets.map { it.id }
scope.launch { scope.launch {
// All at once rather than a loop that awaits each: the handover is what has to // One request for the whole batch, not one per row. Sent row by row, a handover was
// survive leaving the screen, so it should take one round trip rather than one per // only as atomic as the network: the fourth of six could fail, or the screen could be
// row. What each request starts is already safe once the server has it. // left with two still unsent, and what came back was some rows running and some
targets // untouched -- indistinguishable, on the list, from rows nobody had picked. Now
.map { target -> // either the server has the batch or it has none of it, and this is the one place
async { // that can be true.
try { try {
withContext(Dispatchers.IO) { send(target) } withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) { } catch (err: Exception) {
// The server never took it, so nothing is running and no event will // The server never took it, so nothing is running and no event will arrive to say
// arrive to say so. This is the one failure the screen must report // so. This is the one failure the screen must report itself -- and it is now the
// itself. // whole batch's failure, which is the point: no row was singled out.
running = running - target.id running = running - ids.toSet()
rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't ask")) rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch
} }
}
}
.awaitAll()
// Then ask what actually happened, if anything still looks outstanding. // Then ask what actually happened, if anything still looks outstanding.
// //
@@ -240,11 +237,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
fun importAll(targets: List<Importable>) { fun importAll(targets: List<Importable>) {
val setup = chosen ?: return val setup = chosen ?: return
val useProvider = provider ?: return val useProvider = provider ?: return
handOver(targets) { session -> handOver(targets) { ids ->
startImport( startImport(
settings, settings,
setup = setup.id, setup = setup.id,
sessionId = session.id, sessionIds = ids,
provider = useProvider.name, provider = useProvider.name,
permissionMode = permissionMode, permissionMode = permissionMode,
) )
@@ -464,9 +461,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
onClick = { onClick = {
val setup = chosen ?: return@TextButton val setup = chosen ?: return@TextButton
confirming = null confirming = null
handOver(targets) { session -> handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
deleteImportable(settings, setup.id, session.id)
}
} }
) { ) {
// Coloured by consequence: this takes something away, wherever it appears. // Coloured by consequence: this takes something away, wherever it appears.
@@ -6,19 +6,31 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.State import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.parseMarkdown import com.mikepenz.markdown.model.parseMarkdown
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode
/** /**
* An assistant's reply, rendered as the markdown it is written in. * An assistant's reply, rendered as the markdown it is written in.
@@ -107,10 +119,100 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
.toSpanStyle() .toSpanStyle()
), ),
), ),
dimens =
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words
// -- and the narrowest column is where the wrapping below has the least room.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table -- the commonest shape there is -- scroll, while
// 136dp fits three across the phone this app is read on. Four and up still scroll,
// which is the right answer for genuinely too many columns: squeezing six columns
// into a phone would give every cell one word per line.
//
// Narrower would fit more, and stop being readable. This is the widest minimum
// that keeps three columns on screen, which is the trade the number is making.
tableCellWidth = 136.dp,
),
components =
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member of it the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
table = {
MarkdownTable(
it.content,
it.node,
style = it.typography.table,
headerBlock = ::WrappingTableHeader,
rowBlock = ::WrappingTableRow,
)
},
),
modifier = modifier, modifier = modifier,
) )
} }
/**
* A table header, and a table row, whose cells wrap rather than being cut off.
*
* The renderer draws every cell at `maxLines = 1` with an ellipsis, which on a phone means most of
* a table is simply not readable: a column is 160dp at its narrowest, so anything past about twenty
* characters ends in "..." with no way to see the rest. Nothing about the value says it was cut,
* either -- an elided cell looks like a short one, so a table of measurements reads as a table of
* plausible shorter measurements.
*
* So: as many lines as the cell needs, and [TextOverflow.Clip] rather than an ellipsis, which now
* never has anything to hide since the height grows to fit. Cells align to the top of the row,
* because a two-line cell beside a one-line one centred the short one against the middle of the
* tall one and lost the line the reader was reading across.
*
* What the wrapping does *not* do is make a wide table fit. The renderer already gives each column
* a 160dp minimum and scrolls the whole table sideways when they do not fit the screen, which is
* the right answer for too many columns -- wrapping a six-column table into the width of a phone
* would give every cell one word per line. The two work together: the width is what the columns
* need, and the wrapping is what fills the space that width provides.
*
* Two functions rather than one because the renderer's header and row are separate composables --
* the header is bold and sizes itself to its tallest cell -- and the parameters that matter here
* are the same three in both.
*/
@Composable
private fun WrappingTableHeader(
content: String,
header: ASTNode,
tableWidth: Dp,
style: TextStyle,
) {
MarkdownTableHeader(
content = content,
header = header,
tableWidth = tableWidth,
style = style,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
}
@Composable
private fun WrappingTableRow(content: String, row: ASTNode, tableWidth: Dp, style: TextStyle) {
MarkdownTableRow(
content = content,
header = row,
tableWidth = tableWidth,
style = style,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
}
/** /**
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time * [text] parsed: on the composing thread the first time this row is drawn, and off it every time
* afterwards. * afterwards.
+65 -26
View File
@@ -62,7 +62,7 @@ use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post}; use axum::routing::{get, post};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
@@ -78,16 +78,11 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/setups", get(list_setups).post(add_setup)) .route("/setups", get(list_setups).post(add_setup))
.route("/setups/probe", post(probe_setup)) .route("/setups/probe", post(probe_setup))
.route("/setups/{id}/importable", get(list_importable)) .route("/setups/{id}/importable", get(list_importable))
.route( // A batch at a time, never a session at a time -- see
"/setups/{id}/importable/{session}", // [`delete_importable`]. There is no `{session}` route to collide
delete(delete_importable), // with, so all three of these are plain static segments.
) .route("/setups/{id}/importable/delete", post(delete_importable))
.route( .route("/setups/{id}/importable/import", post(start_import))
"/setups/{id}/importable/{session}/import",
post(start_import),
)
// Static segment, so this wins over `{session}` above rather than
// being read as a session called "events".
.route("/setups/{id}/importable/events", get(importable_events)) .route("/setups/{id}/importable/events", get(importable_events))
.route( .route(
"/setups/{id}", "/setups/{id}",
@@ -527,26 +522,56 @@ struct ImportableRow {
error: Option<String>, error: Option<String>,
} }
/// Removes a Claude Code session from a machine. /// Removes Claude Code sessions from a machine.
/// ///
/// The transcript *is* the session, so this ends any chance of resuming /// The transcript *is* the session, so this ends any chance of resuming
/// that conversation -- including from an ai-app session already importing /// those conversations -- including from an ai-app session already
/// it. The phone confirms before calling this; the server does not /// importing one. The phone confirms before calling this; the server does
/// second-guess a decision somebody was shown the cost of. /// not second-guess a decision somebody was shown the cost of.
///
/// A batch and never a single session, which is the whole reason this is a
/// POST with a body rather than a `DELETE` on each id. The phone used to
/// send one request per row, and a handover was then only as atomic as the
/// network was reliable: leave the screen, lose signal, or have the fourth
/// of six requests fail, and some rows are being deleted while the rest are
/// untouched, with nothing anywhere that knows the difference. Here every
/// id is registered as in flight before the 202 goes back, so the answer to
/// "did my batch start" is one answer for the batch.
///
/// Registering is what has to be atomic; the work itself does not. Each id
/// runs on its own task and settles on its own event, because six deletes
/// that must all succeed or all roll back is not something a filesystem
/// offers, and pretending otherwise would mean holding five sessions
/// hostage to the one that failed.
async fn delete_importable( async fn delete_importable(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
UrlPath((id, session)): UrlPath<(String, String)>, UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<DeleteBatch>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let setup = setup_by_id(&manager, &id)?; let setup = setup_by_id(&manager, &id)?;
for session in body.sessions {
let transport = crate::session::transport::Transport::for_setup(&setup); let transport = crate::session::transport::Transport::for_setup(&setup);
let target = session.clone(); let target = session.clone();
in_background(&manager, id, session, Operation::Deleting, async move { in_background(
crate::session::import::delete(&transport, &target).await &manager,
}); id.clone(),
session,
Operation::Deleting,
async move { crate::session::import::delete(&transport, &target).await },
);
}
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
/// Continues a Claude Code session, in the background. /// Which sessions to delete. See [`delete_importable`] for why it is a list.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct DeleteBatch {
sessions: Vec<String>,
}
/// Continues Claude Code sessions, in the background.
/// ///
/// Separate from `POST /sessions` because the two are asked different /// Separate from `POST /sessions` because the two are asked different
/// questions. That one means "start this and take me to it", so it waits /// questions. That one means "start this and take me to it", so it waits
@@ -554,33 +579,44 @@ async fn delete_importable(
/// several at once, nobody waiting on any particular one, and the answer /// several at once, nobody waiting on any particular one, and the answer
/// arrives as a row changing rather than as a reply -- which is the whole /// arrives as a row changing rather than as a reply -- which is the whole
/// point, since the screen it was started from may well be gone by then. /// point, since the screen it was started from may well be gone by then.
///
/// A list for the same reason [`delete_importable`] takes one: the batch is
/// handed over in a single request, so it cannot half-arrive.
async fn start_import( async fn start_import(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
UrlPath((id, session)): UrlPath<(String, String)>, UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<ImportRequest>, axum::Json(body): axum::Json<ImportRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// Checked before accepting, so an unknown machine is still an error the // Checked before accepting, so an unknown machine is still an error the
// caller sees rather than a failure it has to go and read off a row. // caller sees rather than a failure it has to go and read off a row.
setup_by_id(&manager, &id)?; setup_by_id(&manager, &id)?;
for session in body.sessions {
let request = SpawnRequest { let request = SpawnRequest {
setup: id.clone(), setup: id.clone(),
provider: body.provider, provider: body.provider.clone(),
// Nothing to say: `spawn` titles an import from the session it // Nothing to say: `spawn` titles an import from the session it
// continues, and the cwd comes from the same place. // continues, and the cwd comes from the same place.
title: None, title: None,
model: body.model, model: body.model.clone(),
cwd: None, cwd: None,
permission_mode: body.permission_mode, permission_mode: body.permission_mode.clone(),
params: std::collections::BTreeMap::new(), params: std::collections::BTreeMap::new(),
import: Some(session.clone()), import: Some(session.clone()),
}; };
let inner = Arc::clone(&manager); let inner = Arc::clone(&manager);
in_background(&manager, id, session, Operation::Importing, async move { in_background(
&manager,
id.clone(),
session,
Operation::Importing,
async move {
spawn(&inner, request) spawn(&inner, request)
.await .await
.map(|_| ()) .map(|_| ())
.map_err(|err| anyhow::anyhow!("{err}")) .map_err(|err| anyhow::anyhow!("{err}"))
}); },
);
}
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
@@ -588,6 +624,9 @@ async fn start_import(
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct ImportRequest { struct ImportRequest {
/// The sessions to continue, all with the settings below -- they were
/// picked together on one screen, so there is nothing to say per row.
sessions: Vec<String>,
provider: String, provider: String,
#[serde(default)] #[serde(default)]
model: Option<String>, model: Option<String>,
+78
View File
@@ -398,6 +398,14 @@ impl EchoDriver {
let mixed = text let mixed = text
.strip_prefix("/mixed") .strip_prefix("/mixed")
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400)); .map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
// How many columns wide a fixture table should be, default six.
// The count is the parameter because it is the thing the phone
// has to react to: a narrow table lays itself out across the
// screen and a wide one has to start scrolling sideways, and the
// boundary between the two is where the layout is wrong.
let table = text
.strip_prefix("/table")
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
let linger = text.strip_prefix("/slow").map(|rest| { let linger = text.strip_prefix("/slow").map(|rest| {
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600)) Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
}); });
@@ -501,6 +509,14 @@ impl EchoDriver {
return; return;
} }
if let Some(columns) = table {
send(Event::AssistantText {
delta: markdown_table(columns),
});
finish();
return;
}
if let Some(beats) = mixed { if let Some(beats) = mixed {
for beat in 1..=beats { for beat in 1..=beats {
write_beat(&sink, &dir, beat).await; write_beat(&sink, &dir, beat).await;
@@ -687,6 +703,68 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
/// mention it. /// mention it.
type Held = (String, String, Vec<ImageRef>); type Held = (String, String, Vec<ImageRef>);
/// A markdown table [columns] wide, with cells too long for one line.
///
/// Both halves of that matter. Long cells are what the renderer used to cut
/// off with an ellipsis, and a cut cell looks exactly like a short one, so
/// a fixture of tidy one-word values would have rendered perfectly while
/// the defect was still there. The column count is what decides whether
/// the table fits the screen or has to scroll sideways.
///
/// Written out as markdown rather than assembled from a grid type because
/// what is being tested is the renderer's parse of the syntax a model
/// actually writes, pipes and alignment row included.
fn markdown_table(columns: usize) -> String {
let headings = [
"What it is",
"Where it lives",
"What it costs",
"Who asked for it",
"When it changed",
"Why it is here",
"What replaces it",
"What it breaks",
"How it fails",
"What to run",
"Where to look",
"What it assumes",
];
let values = [
"a value long enough that a single line of a narrow column cannot hold all of it",
"~/.config/ai-app/config.ron",
"about four gigabytes resident, measured rather than estimated",
"somebody who could not read the last one of these",
"2026-08-31, in the same change as the wrapping",
"because a cell that ends in an ellipsis says nothing about what it left out",
"nothing yet",
"the alignment of every row beside it",
"silently, which is the expensive way",
"cargo test -p ai-server",
"server/src/session/echo.rs",
"that the reader can scroll sideways",
];
let mut out = String::from("Here is what that produced:\n\n");
let row = |cells: &mut dyn Iterator<Item = &str>| {
let mut line = String::from("|");
for cell in cells {
line.push(' ');
line.push_str(cell);
line.push_str(" |");
}
line.push('\n');
line
};
out.push_str(&row(&mut headings.iter().take(columns).copied()));
out.push_str(&row(&mut std::iter::repeat_n("---", columns)));
for offset in 0..4 {
out.push_str(&row(
&mut (0..columns).map(|column| values[(column + offset * 5) % values.len()])
));
}
out.push_str("\nAnd that is the table.");
out
}
/// Ending a turn is also when anything held during it is taken up -- the /// Ending a turn is also when anything held during it is taken up -- the
/// moment a real CLI would have injected it. One place, because a turn has /// moment a real CLI would have injected it. One place, because a turn has
/// several ways to end (a reply, an interrupt, a compaction) and every one /// several ways to end (a reply, an interrupt, a compaction) and every one
-2
View File
@@ -875,8 +875,6 @@ mod tests {
assert!(!is_session_id(&"a".repeat(65))); assert!(!is_session_id(&"a".repeat(65)));
} }
use super::*;
/// A 1x1 PNG, base64 -- the smallest thing with a real header. /// A 1x1 PNG, base64 -- the smallest thing with a real header.
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk\ const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk\
YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";