Hold the transcript still under a scroll, and parse replies before drawing
Both faults needed a real conversation to see, so `app/debug-transcript.sh`
now puts one on the emulator: it copies a Claude Code transcript into /tmp,
gives an ai-server a HOME of its own so the import can only see the copy, and
enrols the app against it. The transcript itself never enters this repository
-- those files hold whatever was said, read and written in a session. Beside
it, `ai-server --delay MS` holds every response back, because a phone's
requests take tens to hundreds of milliseconds over the tunnel and several
faults live entirely in what the app does while one is outstanding.
**Scrolling up threw the reader back to the newest end, once.** `followTail`
is deliberately a remembered answer, rewritten only when a scroll settles, so
for the whole of a fling it still reports the newest end -- where the reader
was when they threw it. A page of history landing during that fling is a
change in the item count, and the correction written for an insertion at the
newest end fired for one at the oldest. Captured on the emulator:
scrolling=true atNewest=false followTail=true
history: START last=20 total=28
history: page of 80 events -> rows now 36
countChanged count=36 followTail=true scrolling=true
>>> scrollToItem(0) SNAP
It could happen only once, which is what made it look arbitrary rather than
mechanical: the snap settles the scroll at the newest end, so the next fling
gets far enough to settle away from it, and from then on `followTail` is
false. So the list is no longer moved while a scroll is running, which is a
rule of its own rather than a refinement of that condition -- and skipping
the correction outright is right rather than merely safe, because the count
can only grow at the newest end while the reader is already there, `record`
holding everything else until they come back.
**A page of history stalled the frame it appeared in.** Parsing is the
expensive half of drawing a reply and costs in proportion to what was
written: against this transcript one message took 51ms and several took
10-25ms, where the synthetic replies this was tuned on took 4.6ms. So each
page's replies are parsed on a background thread as the page arrives --
after the join, since a boundary falling through a reply leaves a message
made of both halves whose text has existed for no time at all, and warming
the page alone warmed the two halves and missed the one thing drawn. A row
with no answer waiting still parses inline: a row measured at nothing before
it is measured at its real height collapses the transcript above it. Misses
are not stored, so a reply still streaming cannot fill the map with copies
of itself on the way to being finished.
Measured over the same twelve flings: 13.5ms average per composed reply
before, 7us after, the remaining parse being one message at session open.
Verified with ui-trace at 1kHz: with a page landing mid-drag the suppression
fires and the row the reader is on moves monotonically down, 266 -> 1063,
with no step backwards; at rest 0 of 65 elements move. 86 server tests pass,
ktfmt/lint/clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ed6e29bc6
commit
fd616361eb
7 files changed
+323
-18
No files matched your search
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/sh
|
||||
# Puts a real Claude Code conversation on the emulator, for looking at the
|
||||
# transcript screen under content it was not written against.
|
||||
#
|
||||
# The echo driver's fixtures (`/mixed`, `/stream`) are the right rig for most
|
||||
# things and the wrong one for anything whose cost scales with what was
|
||||
# actually written: a real reply is longer, is real markdown, and carries tool
|
||||
# calls whose input and output are kilobytes rather than a word. Two faults
|
||||
# were invisible until a real transcript was loaded -- a page of history
|
||||
# landing mid-fling threw the reader back to the newest end, and parsing one
|
||||
# real reply took 51ms against 4.6ms for a synthetic one.
|
||||
#
|
||||
# ./debug-transcript.sh # newest transcript in ~/.claude/projects
|
||||
# ./debug-transcript.sh dev-updater # newest one whose project path matches
|
||||
# ./debug-transcript.sh -b dev-updater # the biggest one instead of the newest
|
||||
# ./debug-transcript.sh -d 350 # hold every response back 350ms
|
||||
#
|
||||
# **The transcript never enters the repository.** These files are private --
|
||||
# they hold whatever was said, read and written in that session -- so this
|
||||
# copies one into /tmp and points an isolated server at it. Nothing it makes
|
||||
# is committed, and ~/repos is shared with the host besides.
|
||||
#
|
||||
# What it builds, all of it disposable:
|
||||
# /tmp/ai-app-debug/home a HOME holding only the copied transcript, so
|
||||
# the import cannot see or resume a live session
|
||||
# /tmp/ai-app-debug/sessions that server's own data directory
|
||||
# a server on PORT, with its own config and the real CA (so the installed
|
||||
# APK, which pins the CA of the machine that built it, still trusts it)
|
||||
set -eu
|
||||
|
||||
PORT="${PORT:-8455}"
|
||||
DELAY=0
|
||||
MATCH=""
|
||||
BIGGEST=""
|
||||
STOP=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-d|--delay) DELAY="$2"; shift 2 ;;
|
||||
-b|--biggest) BIGGEST=yes; shift ;;
|
||||
--stop) STOP=yes; shift ;;
|
||||
-h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) MATCH="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "$SCRIPT_DIR"
|
||||
REPO=$(dirname "$SCRIPT_DIR")
|
||||
WORK=/tmp/ai-app-debug
|
||||
PROJECTS="$HOME/.claude/projects"
|
||||
|
||||
# Whatever the last run left, before this one takes the port again.
|
||||
#
|
||||
# Importing spawns `claude --resume` so the conversation can be continued, and
|
||||
# those outlive the server that started them: twelve accumulated over one
|
||||
# afternoon of re-running this. They are found by the scratch HOME and nothing
|
||||
# else, because every other `claude` on this machine is somebody's live session
|
||||
# -- including the one that may be running this script.
|
||||
stop_previous() {
|
||||
for pid in $(pgrep -x claude 2>/dev/null); do
|
||||
home=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^HOME=//p')
|
||||
if [ "$home" = "$WORK/home" ]; then kill "$pid" 2>/dev/null || true; fi
|
||||
done
|
||||
pkill -f "[a]i-server --bind 127.0.0.1 --port $PORT" 2>/dev/null || true
|
||||
# Gone, not merely signalled: the next start binds the same port.
|
||||
while pgrep -f "[a]i-server --bind 127.0.0.1 --port $PORT" >/dev/null 2>&1; do sleep 1; done
|
||||
}
|
||||
|
||||
stop_previous
|
||||
if [ -n "$STOP" ]; then
|
||||
echo "Stopped the debug server on port $PORT and anything it spawned."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Newest first, so with no argument you get the conversation you were just in.
|
||||
# `--biggest` is the other question worth asking of this directory, and the one
|
||||
# a scrolling test wants: the longest conversation on the machine is the one
|
||||
# with enough rows to page backwards through, and the newest is routinely a
|
||||
# session five minutes old with nothing in it.
|
||||
if [ -n "$BIGGEST" ]; then
|
||||
SRC=$(ls -S "$PROJECTS"/*"$MATCH"*/*.jsonl 2>/dev/null | head -1)
|
||||
else
|
||||
SRC=$(ls -t "$PROJECTS"/*"$MATCH"*/*.jsonl 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$SRC" ]; then
|
||||
echo "No Claude Code transcript under $PROJECTS matching '${MATCH:-anything}'." >&2
|
||||
echo "Sessions are written there as <encoded-cwd>/<session-id>.jsonl." >&2
|
||||
exit 1
|
||||
fi
|
||||
ID=$(basename "$SRC" .jsonl)
|
||||
PROJECT=$(basename "$(dirname "$SRC")")
|
||||
echo "==> Using $PROJECT/$ID ($(wc -l < "$SRC") lines, $(du -h "$SRC" | cut -f1))"
|
||||
|
||||
# A HOME of its own is the isolation: `import::list` enumerates
|
||||
# "$HOME"/.claude/projects/*/*.jsonl through the transport, so a server started
|
||||
# with this one can only ever see the copy. That matters for more than tidiness
|
||||
# -- importing spawns `claude --resume <id>`, and against the real file that
|
||||
# would be a second CLI writing to a conversation somebody may still be in.
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/home/.claude/projects/$PROJECT"
|
||||
cp "$SRC" "$WORK/home/.claude/projects/$PROJECT/$ID.jsonl"
|
||||
|
||||
CERTS="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs"
|
||||
if [ ! -f "$CERTS/ca.pem" ]; then
|
||||
echo "No CA at $CERTS/ca.pem -- start ai-server once normally first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER="$REPO/server/target/debug/ai-server"
|
||||
[ -x "$SERVER" ] || (cd "$REPO/server" && cargo build)
|
||||
|
||||
echo "==> Starting server on port $PORT (delay ${DELAY}ms)"
|
||||
HOME="$WORK/home" setsid nohup "$SERVER" \
|
||||
--bind 127.0.0.1 --port "$PORT" \
|
||||
--config "$WORK/config.ron" --data-dir "$WORK/sessions" --certs "$CERTS" \
|
||||
--delay "$DELAY" >"$WORK/server.log" 2>&1 </dev/null &
|
||||
until grep -q "serving https" "$WORK/server.log" 2>/dev/null; do sleep 1; done
|
||||
TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f2)
|
||||
|
||||
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||
|
||||
echo "==> Importing"
|
||||
SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
SESSION=$(api -H 'Content-Type: application/json' -X POST \
|
||||
"https://127.0.0.1:$PORT/sessions" \
|
||||
-d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
|
||||
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
|
||||
|
||||
# 10.0.2.2 is the emulator's route to this VM's loopback. The `&` are quoted
|
||||
# on the *device* side: adb runs its argument through a shell there, which
|
||||
# would otherwise cut the URI at the first one and enrol with no token.
|
||||
if command -v adb >/dev/null 2>&1 && [ -n "$(adb devices | awk '$2=="device"{print $1}')" ]; then
|
||||
echo "==> Enrolling the app"
|
||||
adb shell "am start -a android.intent.action.VIEW \
|
||||
-d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'" >/dev/null
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Ready. Open "$PROJECT" in the app; press Refresh if the list is stale.
|
||||
|
||||
token $TOKEN
|
||||
log $WORK/server.log
|
||||
stop $SCRIPT_DIR/debug-transcript.sh --stop
|
||||
|
||||
The imported session is a claude-cli one, so **do not send it a message**:
|
||||
that continues a real conversation on a real account. It is for reading.
|
||||
EOF
|
||||
Reference in new issue
Block a user