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
+336 -93

No files matched your search

@@ -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.
*/
/**
* 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
* 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.
*
* 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) {
requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {}
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
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
* 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
* 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(
settings: ServerSettings,
setup: String,
sessionId: String,
sessionIds: List<String>,
provider: String,
permissionMode: String? = null,
model: String? = null,
) {
val body =
JSONObject().apply {
put("sessions", JSONArray(sessionIds))
put("provider", provider)
permissionMode?.let { put("permissionMode", it) }
model?.let { put("model", it) }
}
requestFromServer(
settings,
"/setups/$setup/importable/$sessionId/import",
"/setups/$setup/importable/import",
method = "POST",
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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
* is nothing here to wait for and nothing to sequence: each row is marked, its request goes,
* and everything after that arrives as an event.
* is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and
* everything after that arrives as an event.
*
* 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
@@ -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
* 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()
running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet()
val setup = chosen
val ids = targets.map { it.id }
scope.launch {
// All at once rather than a loop that awaits each: the handover is what has to
// survive leaving the screen, so it should take one round trip rather than one per
// row. What each request starts is already safe once the server has it.
targets
.map { target ->
async {
try {
withContext(Dispatchers.IO) { send(target) }
} catch (err: Exception) {
// The server never took it, so nothing is running and no event will
// arrive to say so. This is the one failure the screen must report
// itself.
running = running - target.id
rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't ask"))
}
}
}
.awaitAll()
// One request for the whole batch, not one per row. Sent row by row, a handover was
// only as atomic as the network: the fourth of six could fail, or the screen could be
// left with two still unsent, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked. Now
// either the server has the batch or it has none of it, and this is the one place
// that can be true.
try {
withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) {
// The server never took it, so nothing is running and no event will arrive to say
// so. This is the one failure the screen must report itself -- and it is now the
// whole batch's failure, which is the point: no row was singled out.
running = running - ids.toSet()
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch
}
// 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>) {
val setup = chosen ?: return
val useProvider = provider ?: return
handOver(targets) { session ->
handOver(targets) { ids ->
startImport(
settings,
setup = setup.id,
sessionId = session.id,
sessionIds = ids,
provider = useProvider.name,
permissionMode = permissionMode,
)
@@ -464,9 +461,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
onClick = {
val setup = chosen ?: return@TextButton
confirming = null
handOver(targets) { session ->
deleteImportable(settings, setup.id, session.id)
}
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
}
) {
// 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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
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.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.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.parseMarkdown
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode
/**
* 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()
),
),
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,
)
}
/**
* 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
* afterwards.