Delete and import sessions in batches, and say which rows are busy

Clearing out imported sessions was one confirmation dialog per row, which
is why it was not worth doing. Holding a row on the import screen now
selects it and plain taps add more; Delete and Import act on the whole
selection from a bar along the bottom.

Submitting hands the work over and puts the screen back as it was: the
selection clears, the bar goes, and what says the work is happening is the
rows it is happening to -- the one in flight marked with its operation, the
rest marked "waiting". Both are inert, so a queued row cannot be tapped
into starting a second CLI behind the batch already coming for it. Rows
leave as each one lands rather than all at the end, because a finished row
still sitting there looks exactly like one that was never imported; the
rows below it therefore move, so a row that has just moved ignores taps for
half a second.

That busy appearance is one composable shared with the session list, which
had its own dimmed row and its own word for it. It is a word rather than a
bare spinner because deleting and importing differ in kind.

Deleting a session can now take the machine's own transcript with it, as a
switch in the confirmation and only where the driver keeps a record this
app's delete cannot otherwise reach. Off by default, since leaving that
copy is what makes an ordinary delete recoverable -- and the paragraph is
rewritten rather than appended to when it is on, because the sentence
promising the conversation is still there to import again is exactly the
one the switch makes false. The server removes the machine's copy first, so
a machine it cannot reach leaves the session where it was.

`app/ui-sandbox.sh` is how all of this was driven: a second server with its
own $HOME, invented transcripts and a two-line `claude`. Against the
ordinary server, testing delete deletes somebody's conversation and testing
import spends a turn on a real account.
This commit is contained in:
iris committed 2026-08-31 19:51:26 -04:00
1 parent 21d22f89c2
commit fd2e1d0798
8 files changed
+960 -296

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
#!/bin/sh
# An ai-server with invented sessions in it, for driving the phone UI.
#
# The import screen lists whatever Claude Code has on the machine, and in
# this VM that is real agent transcripts -- so exercising *delete* against
# the ordinary server means deleting somebody's conversation, and exercising
# *import* means starting a real `claude --resume` on Bryan's account. Both
# are the wrong price for looking at a list.
#
# So this starts a second server that can see neither. `$HOME` is pointed at
# a sandbox directory, which is the only thing the importer's own script
# consults (`$HOME/.claude/projects/*/*.jsonl`), and the config and session
# data live there too. What it lists is invented here, and deleting all of
# it costs nothing.
#
# Three things are deliberately shared with the real server, because the
# installed APK is built against them: the TLS certificates (the app pins
# that CA and would refuse a fresh one) and the port. Run it while the real
# server is down.
#
# Usage:
# ./ui-sandbox.sh start it, print the enrolment command
# ./ui-sandbox.sh stop stop it
#
# Environment: AI_SANDBOX_ROOT, AI_SANDBOX_TOKEN, AI_SANDBOX_PORT, and
# AI_SANDBOX_DELAY -- the last being the server's own `--delay`, which is
# what makes a spinner visible at all. On loopback every request is back in
# under a millisecond, so a busy state that is correct is still a busy state
# nobody can see.
set -eu
ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox}
TOKEN=${AI_SANDBOX_TOKEN:-sandbox}
PORT=${AI_SANDBOX_PORT:-8443}
DELAY=${AI_SANDBOX_DELAY:-1200}
CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR=$SCRIPT_DIR/../server
PIDFILE=$ROOT/server.pid
LOG=$ROOT/server.log
# By pid rather than by pattern: a `pkill -f` for something as generic as
# "ai-server" also matches the shell running this script, which kills the
# script mid-flight and leaves the restart never having happened.
stop_server() {
[ -f "$PIDFILE" ] || return 0
pid=$(cat "$PIDFILE")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
echo "sandbox: stopped server $pid"
fi
rm -f "$PIDFILE"
}
if [ "${1:-start}" = stop ]; then
stop_server
exit 0
fi
stop_server
rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron"
PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox
mkdir -p "$PROJECTS" "$ROOT/sessions"
# Eight of them, because the point of the screen is a list long enough that
# picking rows one at a time is the annoyance being fixed. Ids are the same
# shape the CLI writes (a uuid, and the file name *is* the session id), and
# each carries a `cwd` and a few user turns so the row has a title, a path
# and a line count to show.
i=1
while [ "$i" -le 8 ]; do
id="0000000${i}-5eed-4a11-9c0d-000000000${i}00"
file=$PROJECTS/$id.jsonl
cwd="/home/bob/repos/sandbox/project-$i"
: >"$file"
turn=1
while [ "$turn" -le $((i + 2)) ]; do
printf '{"type":"user","cwd":"%s","message":{"role":"user","content":[{"type":"text","text":"sandbox session %s, turn %s"}]}}\n' \
"$cwd" "$i" "$turn" >>"$file"
turn=$((turn + 1))
done
# A usage record on the last line, which is where the importer reads the
# context figure from. Left off two of them on purpose: "no turn has
# recorded any" is a state the row has to be able to show, and a list
# where every row has a number never exercises it.
if [ "$i" -ne 3 ] && [ "$i" -ne 6 ]; then
printf '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":%s,"output_tokens":128}}}\n' \
"$((i * 9000))" >>"$file"
fi
i=$((i + 1))
done
# A CLI that does nothing, so importing one of these is free and safe.
# Everything the spawn path cares about is here: it holds the fifo open,
# records a real pid, writes nothing, and dies on a signal. A real
# `claude --resume` against an invented session id would either fail in a
# way that tests nothing or start a turn on somebody's account.
cat >"$ROOT/fake-claude" <<'FAKE'
#!/bin/sh
cat > /dev/null
FAKE
chmod +x "$ROOT/fake-claude"
hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
cat >"$ROOT/config.ron" <<RON
tokens: [
(
name: "sandbox",
sha256: "$hash",
),
],
setups: [
(
id: "local",
name: "sandbox",
providers: [
(
name: "echo",
kind: echo,
),
(
name: "claude-cli",
kind: claude_cli,
command: "$ROOT/fake-claude",
models: [
"haiku",
],
),
],
),
],
sessions: [],
RON
echo "sandbox: building"
(cd "$SERVER_DIR" && cargo build --quiet)
# Fully detached, so it outlives the shell that started it. HOME is the
# whole isolation: the importer's script reads it, and nothing else here
# looks outside the paths passed explicitly below.
HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
--bind 127.0.0.1 \
--port "$PORT" \
--config "$ROOT/config.ron" \
--data-dir "$ROOT/sessions" \
--models-dir "$ROOT/models" \
--certs "$CERTS" \
--delay "$DELAY" \
>"$LOG" 2>&1 &
pid=$!
disown -h "$pid" 2>/dev/null || true
echo "$pid" >"$PIDFILE"
# Waited for rather than assumed: the enrolment below fails silently against
# a server that has not bound yet, and the app then shows a network error
# that has nothing to do with what is being tested.
tries=0
while [ "$tries" -lt 50 ]; do
if grep -q "listening\|Listening" "$LOG" 2>/dev/null; then break; fi
kill -0 "$pid" 2>/dev/null || { echo "sandbox: server exited; see $LOG" >&2; tail -5 "$LOG" >&2; exit 1; }
tries=$((tries + 1))
sleep 0.2
done
cat <<INFO
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
sandbox: 8 invented Claude Code sessions under $PROJECTS
enrol the emulator:
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'"
stop it:
./ui-sandbox.sh stop
INFO