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:
1 parent
3c159fa1e1
commit
778b2e3b04
7 files changed
+336
-93
No files matched your search
+81
-42
@@ -62,7 +62,7 @@ use axum::extract::{Path as UrlPath, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::routing::{get, post};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
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/probe", post(probe_setup))
|
||||
.route("/setups/{id}/importable", get(list_importable))
|
||||
.route(
|
||||
"/setups/{id}/importable/{session}",
|
||||
delete(delete_importable),
|
||||
)
|
||||
.route(
|
||||
"/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".
|
||||
// A batch at a time, never a session at a time -- see
|
||||
// [`delete_importable`]. There is no `{session}` route to collide
|
||||
// with, so all three of these are plain static segments.
|
||||
.route("/setups/{id}/importable/delete", post(delete_importable))
|
||||
.route("/setups/{id}/importable/import", post(start_import))
|
||||
.route("/setups/{id}/importable/events", get(importable_events))
|
||||
.route(
|
||||
"/setups/{id}",
|
||||
@@ -527,26 +522,56 @@ struct ImportableRow {
|
||||
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
|
||||
/// that conversation -- including from an ai-app session already importing
|
||||
/// it. The phone confirms before calling this; the server does not
|
||||
/// second-guess a decision somebody was shown the cost of.
|
||||
/// those conversations -- including from an ai-app session already
|
||||
/// importing one. The phone confirms before calling this; the server does
|
||||
/// 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(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, session)): UrlPath<(String, String)>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<DeleteBatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let target = session.clone();
|
||||
in_background(&manager, id, session, Operation::Deleting, async move {
|
||||
crate::session::import::delete(&transport, &target).await
|
||||
});
|
||||
for session in body.sessions {
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let target = session.clone();
|
||||
in_background(
|
||||
&manager,
|
||||
id.clone(),
|
||||
session,
|
||||
Operation::Deleting,
|
||||
async move { crate::session::import::delete(&transport, &target).await },
|
||||
);
|
||||
}
|
||||
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
|
||||
/// 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
|
||||
/// 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.
|
||||
///
|
||||
/// 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(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, session)): UrlPath<(String, String)>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<ImportRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// 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.
|
||||
setup_by_id(&manager, &id)?;
|
||||
let request = SpawnRequest {
|
||||
setup: id.clone(),
|
||||
provider: body.provider,
|
||||
// Nothing to say: `spawn` titles an import from the session it
|
||||
// continues, and the cwd comes from the same place.
|
||||
title: None,
|
||||
model: body.model,
|
||||
cwd: None,
|
||||
permission_mode: body.permission_mode,
|
||||
params: std::collections::BTreeMap::new(),
|
||||
import: Some(session.clone()),
|
||||
};
|
||||
let inner = Arc::clone(&manager);
|
||||
in_background(&manager, id, session, Operation::Importing, async move {
|
||||
spawn(&inner, request)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))
|
||||
});
|
||||
for session in body.sessions {
|
||||
let request = SpawnRequest {
|
||||
setup: id.clone(),
|
||||
provider: body.provider.clone(),
|
||||
// Nothing to say: `spawn` titles an import from the session it
|
||||
// continues, and the cwd comes from the same place.
|
||||
title: None,
|
||||
model: body.model.clone(),
|
||||
cwd: None,
|
||||
permission_mode: body.permission_mode.clone(),
|
||||
params: std::collections::BTreeMap::new(),
|
||||
import: Some(session.clone()),
|
||||
};
|
||||
let inner = Arc::clone(&manager);
|
||||
in_background(
|
||||
&manager,
|
||||
id.clone(),
|
||||
session,
|
||||
Operation::Importing,
|
||||
async move {
|
||||
spawn(&inner, request)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
|
||||
@@ -588,6 +624,9 @@ async fn start_import(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
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,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
|
||||
Reference in new issue
Block a user