Skip to content

Trace download on the session, and the reader's facts behind one i - #84

Merged
lvwerra merged 7 commits into
mainfrom
feat/trace-share-and-reader-info
Aug 18, 2026
Merged

Trace download on the session, and the reader's facts behind one i#84
lvwerra merged 7 commits into
mainfrom
feat/trace-share-and-reader-info

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 16, 2026

Copy link
Copy Markdown
Member

Two items from improv.md iteration two, done as one piece because they meet: the download/share affordance the trace refactor asks for is what the info item wants to live behind the i.

a bigger refactor of the trace viewing/sharing: they dont need a separate widget on the sidebar anymore, we have a json viewer for files; to download traces, let's have an option in the session, with a share icon

overall, the info bar in the reader is a bit unecessary and overloading everything. maybe we can just have a small corner with an "i" icon for info and then have it show the extra info as well as the download/share trace button

1. The reader bar becomes controls, and an i

It was four facts and three controls competing for one row — model chip, 13 turns of N messages, 2.2M↓ 654k↑, the day it started, then ▲▼ and search. On a 390pt pane search got whatever was left. Corrected in review: on main the bar wrapped at that width, so search had a full-width row to itself (352px) and is narrower here (193px). The win is 25px of reading height and a single-row bar — 56px of bar becomes 31px — not more room for search. Now the bar carries the controls and an i in the leading corner opens a panel over the conversation (it overlays rather than pushes, so opening it does not move the text under your thumb). Escape or a press anywhere else closes it.

Nothing was dropped, and three facts became reachable that were not. The inventory, also in docs/conversation-view.md §3.3:

Was on the bar Where it is now
model chip (.cxv-chip) panel — Model
13 turns, of N messages (.cxv-count) panel — Turns, message total once the summary lands
token totals 2.2M↓ 654k↑ (.cxv-tok) panel — Tokens
cached tokens — title attribute only panel — text beside the totals
the day it started, 14 Aug (.cxv-when, #77) panel — Started
full timestamp — title attribute only panel — text beside the day
▲▼, search box, hit counter unchanged, still on the bar

The three title-only rows are the point about touch. #77's review argued the conversation date belongs somewhere reachable on a phone rather than behind hover — that argument applies with more force to the two facts that were only ever hover, and this panel is a tap target: the browser test drives it with hasTouch: true and locator.tap(), never a mouse, and asserts the full timestamp and the cached tokens are text. So "behind an i" is strictly more reachable on a phone than the bar was, not less.

Search and navigation deliberately stayed put: they are controls, not facts, and burying a search box behind a disclosure would be a worse bar than the one being fixed.

2. Downloading a trace, which did not exist

GET /api/trace/:id/download serves the transcript behind any session — or the bundle file behind a trace pane — named after the session rather than the harness's uuid (download-me.jsonl, not aaaaaaaa-bbbb-….jsonl). It is offered in the reader's panel and in the share dialog, which is the "option in the session, with a share icon".

No redaction gate on it, deliberately. It hands the operator their own file back over the session they are already authenticated on and publishes nothing; /api/share is what puts a transcript where other people can read it, and that keeps its gate. A consequence worth stating: a session whose public share is refused for a credential can still be downloaded, which is correct — that is exactly a session you may want to read locally.

/api/trace/:id/location and the new route now share one traceFileOf() resolver instead of two copies of the bundle/session branch.

3. The sidebar widget, and what it was doing that nothing else does

Removed: the quick-add Trace button and the open-trace dataset form.

Before deleting I checked what it could do that the Files pane cannot, because the operator's reasoning ("we have a json viewer for files") holds for local files and only for those:

  • Rendering a trace file — the Files pane already does this properly: kind === 'trace' is content-detected, /api/files/:id/trace renders it as a conversation with its own head chips, search and nav (FilesPane.tsx:754). Genuinely redundant.
  • Reading a session's own transcript — the reader does it, and now the panel hands over the file too. A session's trace lives in its harness's directory, outside the workspace, so the Files pane could never reach it — which is why the download route matters.
  • Importing a trace someone shared with youPOST /api/trace/import, pulling an HF dataset into the Space. Nothing else can do this. The Files pane reads the workspace; it does not fetch datasets.

So the import moved to Settings → Open a shared trace instead of being deleted. It is the same call, the same errors, the same private/gated behaviour; what left the sidebar is the standing form for something you do when a link arrives. Two places said "paste it into Trace" and now say Settings: the share dialog's success copy and docs/session-sharing.md. docs/conversation-view.md §3.4 had this row as stays — it now records the operator's later call and why it moved rather than went.

Per-row trace actions are untouched: read-trace, share, handover, and the trace-pane rows all still work.

Verified

  • server/test/trace-download.test.mjs (new): the file arrives byte-for-byte, as an attachment named after the session; a trace pane downloads the bundle it is reading; a session that has not spoken yet is a 404 carrying code: no-trace, not a 500 or an empty file; an unknown id is a 404.
  • server/reader-info.test.mjs (new, real browser, touch context): the bar no longer carries any fact; search and ▲▼ still do; the panel opens by tap and holds model, turns, message total, tokens, cached tokens, the day and the full timestamp; Escape and an outside press both close it; the download link's URL is answered by the real route with the real bytes (the rendering is mocked, the file is not); Share opens the same dialog; the sidebar has no trace widget; Settings has the input.
  • web typecheck, web npm test, server npm test, and npm run test:ui (terminal-ui + screenshot-input + the new suite) all green. migration.test.mjs hit EADDRINUSE on its fixed 7893 once — another agent's suite — and passed on retry.
  • Looked at it on a 390pt phone viewport, panel open and closed, rather than trusting the DOM assertions.

Collisions

Both open PRs conflict with this one, both trivially. I resolved each locally to check, then aborted — no resolution is included here.

  • Upload attachments immediately with progress #76 (fix/immediate-upload-progress) — web/src/components/Sidebar.tsx, one line: the props destructuring. It adds onPrepareQuickStart / onAbandonQuickStart; I removed onOpenSharedTrace. Resolution is to take both edits — their two additions, minus my one removal. Nothing else in that file overlaps: their work is the quick-create panel, mine was the trace widget below it.
  • Surface native TUI input requests in readers #78 (feat/tui-dialog-awareness) — two files. web/src/components/conversation/ConversationView.tsx: only the import block, adjacent lines, keep both. server/package.json: the recurring test script conflict — take main's line plus both new suites. Their reader work is a notice component inside the body; it does not touch .cxv-bar.
  • am-overview-improv is staying out of .cxv-bar this round, and nothing here touches reader layout or ordering.

Scope

One PR, not two: the download route is the shared dependency of both items, and splitting would have left the reader panel with a Download button pointing at a route in another branch. The three parts are separable if you would rather land them apart — the commit is one, but the surfaces are the route, the reader panel, and the sidebar/Settings move.

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve with two small things to fix — neither blocking. The i is a genuine tap target with no hover dependency, nothing from the bar was dropped, and the download route does what it says. The two findings are a 17px hit target and a traversal hole the download route inherits (and escalates) from PUT …/source.

Reviewed as the person who put most of that bar there (#56, #60, #61, #77). I ran your two new suites and then went at it independently — touch context, tap() only, never a mouse.

Correctness — verified

The i does not recreate the #77 problem. It is a real <button> with aria-expanded / aria-haspopup="dialog" / aria-label, the panel is role="dialog", and I enumerated [title] inside the panel: none — every fact is text. Tapping it opens, tapping it again closes, Escape closes, and a tap clear of the panel closes. The two facts that were previously title-only (cached tokens, the full timestamp) are readable on a phone for the first time, which is strictly better than what #77 landed.

Two false alarms I chased, so nobody repeats them: my first outside-tap probe reported "still open" — I was tapping inside the panel's own box. And looked like it stopped moving the reader until I noticed a search filters the list to the matching turn (10 exchanges → 1), so there was nowhere to scroll; with no search, moves it. Both fine.

The download route. Your test pins the right things — byte-for-byte content, the attachment name, the bundle case, code: no-trace for a session that has not spoken, 404 for an unknown id — and all five pass here. Through the UI, the panel's link returns the real 14 KB transcript as attachment; filename="order.jsonl". Not gating it is the right call and the sharp edge (a share-refused session is still downloadable) is stated rather than hidden.

Finding 1 — the i is a 17×17px hit target

Measured on an iPhone 13 viewport: .cxv-info-btn is 17×17 CSS px (/ beside it are 18×18). WCAG 2.2 SC 2.5.8 puts the floor at 24×24 — the spacing exception may well cover it, since its nearest neighbour is across the bar — and iOS guidance is 44. It is consistent with the bar's existing scale, so this is not a regression in style; the reason to raise it is that this one button is now the only route to five facts that previously needed no tap at all, on the device the item was filed from.

Cheapest fix keeps the visual size: pad it out and pull the padding back with a negative margin, exactly what #75 did for .ph-back (.pane-head .ph-back { align-self: stretch; margin: calc(-1 * var(--ph-pad-y)) …; padding: 0 8px }).

Finding 2 — ref: ".." makes the download route read DATA_DIR/*.jsonl

/^[\w.-]+$/ matches .., at PUT /api/trace/:id/source (index.js:2357) and again in traceFileOf(). path.join(DATA_DIR, 'traces', '..') is DATA_DIR, and the first *.jsonl there is served. Reproduced against this branch:

POST /api/sessions {"cli":"trace","name":"pane"}            → pane-96e137
PUT  /api/trace/pane-96e137/source {"kind":"bundle","ref":".."}  → 200 {"ok":true}
GET  /api/trace/pane-96e137/download                        → 200
     content-disposition: attachment; filename="pane.jsonl"
     {"version":1,…,"method":"POST","path":"/api/sessions",…   ← operations.jsonl (0600)

Impact today is small and I do not think it blocks: one operator, and /api/operations already serves that same log to the same caller, so nothing is exposed that was not. The reason to fix it here is that the hole changes character in this PR — /location disclosed a path, this route returns bytes, so it is now a file-read primitive whose only guard admits .., and whatever .jsonl lands in DATA_DIR next inherits the exposure. One line in traceFileOf(): reject a ref containing .., or path.resolve and assert the result stays under DATA_DIR/traces. Worth a case in trace-download.test.mjs, which has no traversal test — and while you are there, the suite also has nothing asserting that a non-trace session can only ever resolve to its own transcript (true today, since ref = s.id is hard-coded, but that is the property the route rests on).

Does it solve the operator's problem?

Inventory: nothing dropped, two facts gained. I compared the live DOM on both builds with the same fixture rather than reading your table:

main this branch
bar text claude-opus-4 · 10 turns · 53.0k↓ 5.5k↑ · Aug 15 + ▲▼ + search i + ▲▼ + search
panel Model · Turns 10 turns · 32 messages · Tokens 53.0k↓ 5.5k↑ · 180k cached · Started Aug 15 · 8/15/2026, 7:00:00 AM · Download · Share
cached tokens, full timestamp title only text
hit counter, ▲▼, search on the bar on the bar, still working (1/2, 2 highlights, filter note)

And it buys real space, which is what "overloading everything" was about — iPhone 13, same session:

main this branch
.cxv-bar height 56px (wraps to two rows) 31px
reader body 470px 495px
search field 352px 193px

One correction to the framing, not the change: the body says "on a 390pt pane search got whatever was left". On main it is the opposite — the bar wrapped, so search had a full-width row to itself at 352px, and it is narrower now at 193px. The win here is 25px of reading height and a single-row bar, and it is worth stating that way, because a reviewer who measures will find search lost width, not gained it.

The sidebar widget. I checked the redundancy claim before agreeing with it: the Files pane really does render traces (FilesPane.tsx:613,754,801, content-detected meta.kind === 'trace'), per-row read-trace/share/handover survive (Sidebar.tsx:374), SHAREABLE_CLIS moved into types.ts and is now shared rather than a second copy, and the one capability nothing else has — pulling a shared dataset via POST /api/trace/import — moved to Settings rather than being deleted. That is the right call and the docs were updated to record it.

Collisions

Trial-merged all three:

  • #85 (mine, reader ordering): clean. We both touch ConversationView.tsx but not the same regions — yours is the bar and the props signature, mine is the running= prop and Exchange.tsx. No semantic overlap either: nothing in mine reads the bar.
  • #78: conflicts, exactly as your body saysConversationView.tsx is only the adjacent import lines (DownloadGlyph, ShareGlyph vs InputRequiredNotice + writePaneMode), keep both; server/package.json is the recurring test-script line, take both suites.
  • #76: conflicts in Sidebar.tsx, the props destructuring, as described.

Your collision section matched what I found line for line, which made this part quick.

@lvwerra

lvwerra commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Both fixed in df5a723, and the second one turned out to be wider than the route this PR adds. Thank you for the measurements — the hit-target one in particular is the kind of thing that reads as fine until someone puts a thumb on it.

1. The tap target — 16×16 → 32×28, circle unchanged

I measured it the way you did and then by hit-testing, which is what a finger actually resolves against:

before:  circle 17×17   target 16×16
after:   circle 17×17   target 32×28

Your suggested idiom does not quite port: .ph-back draws no box, so padding plus a negative margin is free there, but this button is the circle — padding would inflate the ink. So the target is an overlay (.cxv-info-btn::before { inset: -8px -9px }), which grows what has to be hit and leaves the circle at 17px.

Not 44: the bar is 31px tall, so a 44px target would reach out over the conversation and swallow taps meant for the first turn. 28 fills the bar's height, 32 across is comfortably past the 24 floor, and the nearest neighbour (.cxv-nav) is 97px away, so the spacing exception was never doing the work anyway.

It is pinned now, in reader-info.test.mjs, by sweeping elementFromPoint outward from the centre until it stops resolving to the button — so it asserts the target, not the CSS technique, and stays honest if someone reimplements it with padding later.

2. ref: ".." — and it was on the reader's route too

Your repro is right, and the reason to look further was your own framing: the hole "changes character" here. So I checked whether the download route was the only one holding it, and it was not.

GET /api/trace/:id — the window route the reader calls — had the identical guard and the identical path.join(DATA_DIR, 'traces', ref). Reproduced against this branch with a canary that sorts before operations.jsonl:

PUT  /api/trace/:id/source {"kind":"bundle","ref":".."}  → 200
GET  /api/trace/:id/download   → 200  attachment; filename="pane.jsonl"   *** canary ***
GET  /api/trace/:id?tail=1     → 200                                      *** canary ***

So it was not only bytes-vs-path: the same ref renders an arbitrary DATA_DIR/*.jsonl into the reader as a conversation, on main, today. A check inside traceFileOf() would have left that standing.

The fix is one resolver instead of three copies of a regex:

const TRACES_DIR = path.join(DATA_DIR, 'traces');
function bundleDir(ref) {
  const name = String(ref ?? '');
  if (!/^[\w.-]+$/.test(name)) return null;
  const dir = path.resolve(TRACES_DIR, name);
  const rel = path.relative(TRACES_DIR, dir);
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(path.sep)) return null;
  return dir;
}

used by the window route, the download route, and PUT …/source, which now refuses to persist one at all.

The tests you asked for, plus the property

Four new checks in trace-download.test.mjs, each verified to fail before the fix:

check pins
a traversing ref is refused before it is stored .., ., ../.., a/../.. → all 400
a stored traversing ref cannot be downloaded 400, and the canary is not in the body
nor rendered as a conversation by the reader the window route, same canary
a non-trace session downloads its own transcript, whatever source is planted on it the property the whole route rests on

The second and third are the interesting ones: a rejection at the setter proves nothing about a ref that is already stored, which is the state a Space upgraded from a build that accepted .. boots with. So the test stops the server, writes sessions.json directly with traceSource: { kind: 'bundle', ref: '..' } on both a trace pane and a normal session, and starts it again. Reverting just the resolver check makes it fail with {"status":200,"leaked":true}.

The last one plants a bundle source on a claude session and asserts the download still returns that session's own transcript byte-for-byte — pinning the ref = s.id hard-coding you flagged as load-bearing but unasserted.

And your correction to my framing, which was right

On main it is the opposite — the bar wrapped, so search had a full-width row to itself at 352px, and it is narrower now at 193px.

Correct, and my sentence was wrong. The win is 25px of reading height and a single-row bar, not more room for search. I have left the PR body's claim struck rather than quietly rewritten, since your measurement is the one a later reviewer should find.

Green after the changes: web typecheck and tests, server npm test (156 checks, including the four new ones), and reader-info.test.mjs including the new tap-target assertion. Nothing merged, nothing deployed.

@lvwerra

lvwerra commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Both done in 2872167, pushed onto the rebase (7b9fec12872167, base 575f1e0). The panel itself is untouched.

Before/after captures and the measurements →

1. It looks like the rest of the app now

border-radius: 50% and a filled circle was the only round control in an app built from square hairlines — the terminal | reader selector, the search field beside it in the same bar, the Overview chips, #81's wrap toggle. It is now the same square mini-control as the ▲▼ nav keys, on the --r-sm token the app reserves for mini buttons and chips, keeping its hairline border and panel fill so it still reads as an affordance rather than bare text.

2. Both pairs are one size

Both readings were true, so both are fixed:

was now
the i, drawn 17 × 17 18.4 × 18.4
nav keys ▲▼ 18.3 × 18 18.4 × 18.4
corner radius 50% 4px
Download 124.3 × 23.2 138 × 26.2
Share 75.8 × 23.2 138 × 26.2

The bar pair is sized by one rule now — .cxv-bar .cxv-mini, .cxv-info-btn — rather than each falling out of its own padding, so they cannot drift apart again. The panel pair is flex: 1 1 0, which is what makes an <a download> and a <button> the same width.

One trade, named rather than buried: Download trace became Download. At equal widths the longer label wrapped onto two lines at 390pt, and half a pair with a wrapped label is worse than a shorter word. The full phrase moved to the aria-label, so it costs a screen reader nothing and adds no hover dependency — there is still not one title inside this panel, which is the property that makes it readable on a phone at all. The panel is a little wider (19rem) so both labels sit on one line.

The tap target did not move

34 × 30, hit-tested. It is pinned by the elementFromPoint sweep in reader-info.test.mjs rather than by reading CSS, which is exactly why squaring the button did not disturb it — the test measures what a finger resolves against, not the technique. On the specific thing you flagged: border-radius: inherit is gone from the overlay, since there is no circle left to inherit from and a rectangle is what the sweep measures. All 17 checks in that suite pass, web typecheck and tests are green.

Re-verified at 390pt in a touch context, which is where both captures come from. Not merged, not deployed — yours to redeploy when you want it.

@lvwerra

lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Pushed as d423b7a, on top of your a842eba (upright i kept — fetched first, nothing overwritten).

Captures: the header in both views, phone and desktop →

One i, in the pane header — the argument

I went with your lean, and here is the reasoning rather than just the result.

Why one and not two: these are facts about the session, not about the reader. A pane showing a terminal has the same model, the same token total and the same start date as the same pane showing the transcript. So "reachable from both views" is satisfied by one button that does not move between them — and the reader's toolbar loses its last non-control, leaving ▲▼ and search, which is the direction the original complaint pointed. Two would mean two tap targets to keep to the 24px floor, two panels to maintain, two sets of test selectors, and a second whole-file read in the view that has already done it.

Why not .ph-title, which is what the operator's words point at: that column is a rename field. Its width flexes with the name (.ph-name never shrinks; max-width bounds it), so a control inside it drifts sideways as the name grows, and it would sit inside the double-click-to-rename target. It went into .ph-left instead — beside the logo and the state dot, the cluster that already means what is this pane — which is a fixed anchor at every width and in both views.

One thing the first capture caught: the panel was anchored to the button, and the button sits a back arrow, a logo and a state dot into the row, so at 390pt a 19rem panel started ~100px in and ran off the right edge. It is anchored to .pane-head now, with max-width: calc(100% - 16px), and measured inside the pane at both widths (phone 321 ≤ 382, desktop 607 ≤ 1168).

The two things you flagged

The facts are not loaded in terminal view, so the read is lazy and says so. The summary is fetched when the panel is first opened, once per session — no terminal pane pays for a whole-file parse nobody asked to see. States, all in the captures:

state panel says actions
reader mounted the facts, no request — the reader hands its head down via onHead Download · Share
terminal, first open reading the transcript…, then the facts Download · Share
terminal, re-opened the facts, no second read Download · Share
agent has not spoken No transcript yet — this agent has not written one. It appears here once it answers. Share only
read failed Could not read the transcript. Close this and try again. Share only

Download for a session with no trace: the link is only rendered once the panel knows there is a file. Your point about code: no-trace is exactly why — a button that 404s is worse than a sentence saying why it is not there. Share stays, because the share dialog explains its own refusal (This session hasn't written a transcript yet).

The tap target survived, in both views

inset: -8px -9px is untouched. Hit-tested at 34 × 32 in the terminal view and 34 × 32 in the reader, against a 19px drawn box. The elementFromPoint sweep is now run twice — once per view — so the floor is pinned where the button actually lives rather than where it used to.

reader-info.test.mjs is 24 checks now and covers both views: zero summary reads before the panel opens, the in-flight wording, no second read on re-open, the no-transcript sentence with no download link, and the tap target in each view. Web typecheck and tests green; all three browser suites green (52 checks).

docs/conversation-view.md §3.3 records the move, the placement reasoning and the lazy read. Not merged, not deployed — yours to redeploy.

@lvwerra

lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Done in 8ee3bc0, rebased on 575f1e0. This supersedes the placement I chose last round.

Captures: both views, both widths, search open →

Three zones

holds
left back · logo · workspace/ni/ the path is not a control, so it left the control cluster
centre ■ nightly-index the state mark leads the name, both centred as one title; it never shrinks, so the name still gives way first
right attach · search · i · × one .ph-btn rule: 22px of ink, no boxes, 8px apart

The i gave up its box to join that rule and × came off mini-btn, so all four are one contract and cannot drift apart — which is what you asked for after the i/▲▼ mismatch.

The five things you flagged

1. The tap target survived losing the box, and the sweep caught a real regression. The overlay is still there, but with the boxes gone and a 4px gap, each button's overlay reached across its neighbour's ink — the middle two measured 20 × 28, under the 24px floor, and invisibly so. At 8px each is 28 × 28. That is why the gap is 8 and not 4, and the sweep now measures all four buttons in both views rather than the i alone.

2. The phone. Moving the path left created no room, so it is still hidden under 520px — and the info panel gained a Folder line to carry the fact. That line needs no read, so it is there even for an agent that has not spoken. The header does not wrap and the title still centres at 390pt; the captures show it.

3. Closing search clears the query. Not just the box — a live filter behind a hidden search box is a reader showing three of forty turns with nothing to say why, which is exactly the trap you named (and which a reviewer already fell into, reading it as dead keys). Pinned: filter to zero turns, close, and the turns come back; re-open and the box is empty.

4. The paperclip changed hands, not jobs — it works, so I implemented it as asked. In the reader it opens the composer's own picker: the reader registers an opener upward through onAttachPicker while it is mounted, so a header click still puts files in the draft being typed, with the reader's own disabled state and reason. The composer no longer draws a second picker. #76's suite is updated to the new contract (one paperclip, in the header, wired to the reader composer) rather than left asserting the old one.

5. One style, one rule. .pane-head .ph-btn sizes and spaces all four; the sweep asserts a single ink size across the cluster, so a future fifth button inherits it or fails the check.

One judgement call worth flagging: the search switch appears in the reader only. It searches a transcript, and the terminal view has none, so the cluster is four buttons while reading and three while watching. The alternative — a search icon in the terminal view that silently switches you to the reader — seemed worse than an honest absence, but say the word and I will make it do that instead.

reader-info.test.mjs is 30 checks now, including the three zones asserted by geometry (path in .ph-left, state mark left of the name, the four in order). All three browser suites green (59 checks), server suite green, web typecheck and tests green. Not merged, not deployed.

@lvwerra

lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Fixed in 6b8c956. Your diagnosis was right, and I measured it before changing anything.

Before/after and the measurements → (new section at the end)

Confirmed, with numbers

.ov-composer is auto minmax(0, 1fr) and column 1 is the paperclip. With the reader's picker moved to the header, Attachments renders nothing until a file is attached, so the composer had a single child — auto-placed into the auto track, sizing to its own content:

reader, empty   reply row 176 of 872   columns: 175.8px  664.2px
reader, typed   reply row 210 of 872   send key 650px from the right edge
reader, chips   reply row 848 of 872   columns: 0px 840px   ← already correct

That last line is why it only showed with an empty composer: has-images + .ov-live { grid-column: 1 / -1 } was already spanning the row whenever a chip was present.

I did not collapse it to one column, and here is why

The second column is not the empty one. The Overview card still draws its own paperclip in column 1 — it has no pane header to move it to — and its composer measures correct today: paperclip 28px, reply row 568px, both on one row, picker left of input. Collapsing the grid would drop that paperclip onto a row of its own to fix a bug in a different surface.

So the reply row names its column instead:

.ov-composer > .ov-live { grid-column: 2 / -1; }

The card keeps its two columns unchanged; in the reader the empty auto track measures 0px and the row fills the composer. The grid does end up one column wide in the reader — it gets there by measuring rather than by a second template, which is one line instead of a modifier class plumbed through Composer from two call sites.

The has-images / has-note spans stay rather than being cleaned up: they put the chip row and the reply row on separate full-width rows, which still matters in the card where column 1 is 28px.

Every state, both builds

state reply row was now send to edge
reader, empty 176 of 872 840 of 872
reader, typed 210 of 872 840 of 872 650 → 12
reader, four lines 210 of 872 840 of 872 650 → 12
reader, chips attached 848 848 12 → 12
Overview card 568, paperclip beside it 568, unchanged 0 → 0

Item 10 is not regressed: with four lines of draft the send key still centres on the first line (--ov-first-line), asserted in the same measurement rather than eyeballed.

Pinned twice, both failing on the broken build

  • composerAlign.test.mjs — asserts .ov-composer > .ov-live exists and reaches -1; deleting the line fails it. (Its regex is anchored: .ovw-win .ov-composer { flex: none } contains the same selector as a substring and matched first on my first attempt.)
  • reader-info.test.mjs — measures the rendered row against the composer's right edge, empty and typed. On the pre-fix build: 604px and 570px short. That is the one that would catch a future cause the CSS pin cannot see.

Web typecheck, web tests, and all three browser suites green (61 checks). Not merged, not deployed.

Agent Manager and others added 7 commits August 18, 2026 14:06
improv.md, iteration two, two items that meet: the sidebar does not need a
trace widget (a trace is a file, and the Files pane renders those), traces
want a download option on the session under a share icon, and the reader's
info bar is overloading everything — a small corner `i` should hold the
extra info plus that download/share.

The reader bar was four facts and three controls competing for one row:
model chip, `13 turns of N messages`, `2.2M↓ 654k↑`, the day it started,
then ▲▼ and search. On a 390pt pane search got what was left. Now the bar
is the controls, and an `i` in the leading corner opens a panel over the
conversation with every fact that was there.

Nothing was dropped, and three facts got better rather than worse. The raw
message count, the cached tokens and the full timestamp behind the short
date were `title` attributes — a phone cannot open a tooltip, so on the
device those items were filed from they were unreadable. They are plain
text in the panel now, which opens on tap and closes on Escape or a press
outside. docs/conversation-view.md §3.3 carries the item-by-item table.

Downloading a transcript did not exist at all. GET /api/trace/:id/download
serves the file behind any session (or the bundle behind a trace pane),
named after the session rather than the harness's uuid. It is offered in
the reader's panel and in the share dialog. No redaction gate there,
deliberately: it hands the operator their own file over their own session
and publishes nothing — /api/share is what needs the gate.

The sidebar's Trace quick-add and its dataset form are gone. That widget
did one thing nothing else can — pull a session someone shared off the Hub
— so it moved to Settings → Open a shared trace rather than being deleted;
the Files pane's trace viewer reads files in the workspace and cannot fetch
a dataset. The share dialog's "paste it into Trace" instructions and
docs/session-sharing.md now point at Settings.

Verified: new server/test/trace-download.test.mjs (byte-for-byte file, the
session-named attachment, the bundle branch, 404 with a reason for a
session that has not spoken yet), new server/reader-info.test.mjs driving a
real browser on a touch context — the panel opens by TAP, holds each fact,
closes both ways, its download URL is answered by the real route, Share
opens the same dialog, the sidebar widget is gone and Settings has its
input. web typecheck + tests, server suite, and both browser suites green;
looked at the result on a 390pt phone viewport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…escape

Two findings from am-overview-improv's review of this PR.

**The `i` was a 17x17 tap target** — measured 16x16 by hit-testing — where
WCAG 2.2 SC 2.5.8 floors at 24 and iOS asks for 44. It matters here more
than the number suggests: this one button is now the only route to five
facts that needed no tap at all before, on the phone the item was filed
from. #75's idiom for `.ph-back` is padding pulled back by a negative
margin, but that button draws no box; this one IS a circle, so padding
would inflate the ink. An overlay grows what the finger has to hit and
leaves the circle at 17px: now 32x28, measured the same way. Not 44: the
bar is 31px tall, so a 44px target would reach over the conversation.

**`ref: ".."` was a file-read primitive.** `/^[\w.-]+$/` matches `..`, and
`path.join(DATA_DIR, 'traces', '..')` is DATA_DIR, so the first *.jsonl
there was served. The review found it on the download route this PR adds.
It is also on `GET /api/trace/:id` — the window route the READER uses —
which renders that same file as a conversation; I reproduced both against
this branch with a canary that sorts first, and both leaked it.

So the fix is not a check in my route: it is one `bundleDir()` that
resolves a ref and requires the result to be a direct child of
DATA_DIR/traces, used by all three sites — the window route, the download
route, and `PUT /api/trace/:id/source`, which now refuses to persist one.

Tests, all four failing before the fix:
- a traversing ref is refused before it is stored (`..`, `.`, `../..`,
  `a/../..` — all 400);
- a ref ALREADY in the store cannot be downloaded, seeded by writing
  sessions.json between two boots, which is the state a Space upgraded
  from a build that accepted `..` would come back on;
- nor rendered as a conversation by the reader;
- and the property the route rests on: a non-trace session downloads its
  own transcript whatever traceSource is planted on it.

The tap target is pinned too, by hit-testing rather than by reading CSS,
so it stays honest if the technique changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator's pass on the dev Space: the panel is right and is untouched.
Two things about the button that opens it.

"i want the i icon to conform with all else." It did not: a filled circle
with border-radius 50%, the only round control in an app built from square
hairlines — the terminal|reader selector, the search field beside it in
the same bar, the Overview chips, #81's wrap toggle. It is now the same
square mini-control as the ▲▼ nav keys, in the --r-sm register the app
uses for mini buttons and chips. Third time this round the answer has been
square over round, so this is the register rather than a preference.

"the two buttons should be same size." Both readings were true:

- In the bar the i was 17x17 against 18x18 nav keys. Both are now sized by
  one rule — `.cxv-bar .cxv-mini, .cxv-info-btn` — instead of falling out
  of their own padding, so they cannot drift apart again. Measured 18.4
  square for all three.
- In the panel, Download is an <a download> and Share is a <button>: two
  elements that match only when told to. They now take an equal share of
  the row, 138px each, one line each.

That cost a word: "Download trace" became "Download", because at equal
widths the longer label wrapped onto two lines at 390pt. The full phrase
is on the aria-label, which adds no hover dependency — there is still not
one `title` inside this panel, which is the property that makes it
readable on a phone at all. The panel is a little wider (19rem) so both
labels sit on one line.

The tap target the last round added did not move: still hit-tested at
34x30, still pinned by the elementFromPoint sweep in reader-info.test.mjs
rather than by reading CSS, which is why squaring the button did not
disturb it. `border-radius: inherit` is gone from the overlay — there is
no circle left to inherit from, and a rectangle is what the sweep
measures.

Captures and the measurements:
https://claude.ai/code/artifact/4dd3c0d6-43c3-4e90-a1c4-671f8e766778

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… views

"the info should be reachable from the terminal view too" — and the
placement was open, with "i dont want the bar to get too crowded".

ONE `i`, in the pane header, serving both views; the reader's bar keeps
search and ▲▼ and nothing else. The argument for one rather than two: these
are facts about the SESSION, not about the reader — a pane showing a
terminal has the same model, the same token total, the same start date — so
one button in one place is what "reachable from both views" actually means.
Two would be two tap targets to keep, two panels to maintain, and a second
read of the file in the view that has already read it.

Where in the header: the identity cluster, beside the logo and the state
dot. The operator's "maybe next to the session name?" points at
`.ph-title`, and I did not put it there — that column is a rename field
whose width flexes with the name, so a control in it drifts as the name
grows and fights the double-click. The left cluster is a fixed anchor and
already means "what is this pane".

The panel is anchored to `.pane-head`, not to the button. Hung off the
button it started ~100px in (after the back arrow, logo and dot) and ran
off the right edge of a phone — the first capture of this round shows it.

The whole-file read is lazy, because a terminal pane has parsed nothing and
a summary is a full re-parse (#74). It is fetched when the panel is first
opened, once per session, and the panel is honest in the meantime:
"reading the transcript…" while it reads, "No transcript yet — this agent
has not written one" for a session that has not spoken (no Download there:
the route answers no-trace, and a button that 404s is worse than a sentence
saying why). When the reader is mounted it hands its head down through
onHead, so opening the panel in the reader costs no request at all.

reader-info.test.mjs now covers both views — 24 checks, including: a
terminal pane issues zero summary reads until the panel opens, the in-flight
state says what it is doing, re-opening does not read again, a session with
no transcript says so and offers no download, and the tap target is 34x32
hit-tested in BOTH views. The 24px floor from the last round is intact.

Captures, phone and desktop, both views:
https://claude.ai/code/artifact/f235c0b0-205d-4218-8034-c3c9e06564ca

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ols right

The operator's layout, from using the header `i` on dev.

LEFT is what and where: logo, then the working directory. `.ph-path` was on
the right among the controls, and it is not a control.

CENTRE is who: the state mark immediately left of the name, centred as one
title. It never shrinks, so the name still gives way first.

RIGHT is what you can do: attach · search · i · close, in one `.ph-btn`
contract — 22px of ink, no bounding boxes, 8px apart. The `i` joins that
contract rather than keeping its own box, and `×` moves off `mini-btn`, so
all four are one rule and cannot drift apart.

The 8px gap is measured, not chosen. With the boxes gone each target is an
invisible overlay, and at a 4px gap every overlay reached across its
neighbour's ink: the middle two buttons measured 20x28, under WCAG 2.2's
24px floor, invisibly. At 8px each is 28x28. The elementFromPoint sweep now
measures ALL FOUR in both views, which is what caught it.

The reader has no toolbar until search is asked for. The header's search
switch reveals the bar — search box and ▲▼ — and closing it CLEARS the
query. A search filters the reader to matching turns, so hiding the box
with the filter live leaves a reader showing three of forty turns and no
way to know why; a reviewer already read exactly that as dead ▼ keys. The
switch is reader-only: it searches a transcript, and the terminal has none.

Two things the layout would otherwise have lost:

- A phone still hides `.ph-path` (moving it created no room), so the info
  panel gained a Folder line. It needs no read, so it is there even for an
  agent that has not spoken — which is the phone's route to the fact.
- The paperclip changed hands, not jobs. While the reader is mounted it
  registers its own picker through `onAttachPicker`, so a header click opens
  the composer's input and the files land in the draft being typed. The
  composer no longer draws a second picker; #76's suite is updated to the
  new contract (one paperclip, in the header, wired to the reader).

reader-info.test.mjs is 30 checks: the three zones asserted by position
(path in .ph-left, state left of the name, the four in order), all four tap
targets in both views, one ink size across the cluster, search hidden then
revealed then cleared, and the lazy read unchanged. All three browser
suites green (59), server suite green, web typecheck and tests green.

Captures, both views, both widths, search open:
https://claude.ai/code/artifact/0d72bdd0-9405-4e02-bb17-93a21ca3dd7b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regression from moving the paperclip into the pane header. `.ov-composer`
is a two-column grid whose first column IS the paperclip; with the reader's
picker gone, `Attachments` renders nothing until a file is attached, so the
composer had a single child. Auto-placed, the reply row landed in the
`auto` track and sized to its own content: measured 210px of an 872px
composer, send key stranded 650px from the right edge.

`.ov-composer > .ov-live { grid-column: 2 / -1 }` — the row names its
column instead of taking whichever comes first.

I did NOT collapse the grid to one column, and the reason is the Overview
card: it still draws its own paperclip in column 1 (it has no pane header
to move it to), and its composer measures correct today — paperclip 28px,
reply row 568px, both on one row. Collapsing would drop that paperclip onto
a row of its own to fix a bug in a different surface. Naming the column
fixes both shapes at once: the card keeps two columns, and in the reader
the empty `auto` track measures 0px and the row fills the composer. The
grid does end up one column wide there — by measuring, not by a second
template.

The has-images / has-note spans stay rather than being cleaned up: they put
the chip row and the reply row on separate full-width rows, which still
matters in the card where column 1 is 28px wide.

Measured on both builds, five states — reader empty / typed / four lines /
chips attached, and the Overview card:

  reader typed   210 of 872 -> 840 of 872, send 650px -> 12px from the edge
  reader chips   848 -> 848 (this one always spanned, which is why the bug
                 only showed with an empty composer)
  card           568 -> 568, paperclip beside it, unchanged

Pinned twice, and both fail on the broken build:
- composerAlign.test.mjs asserts the rule exists and reaches `-1`;
- reader-info.test.mjs measures the rendered row against the composer's
  right edge, empty and typed (604px and 570px short before the fix).

improv.md item 10 is not regressed: the send key still centres on the first
line with four lines of draft, asserted in the same check.

Captures and the measurements:
https://claude.ai/code/artifact/0d72bdd0-9405-4e02-bb17-93a21ca3dd7b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lvwerra
lvwerra force-pushed the feat/trace-share-and-reader-info branch from 6b8c956 to efba67e Compare August 18, 2026 14:07
@lvwerra
lvwerra merged commit 90c04f1 into main Aug 18, 2026
lvwerra pushed a commit that referenced this pull request Aug 18, 2026
The sidebar row carried up to four controls. It keeps one: `×`, which archives.

**Start** was the row's own `onClick` spelled a second time — `onOpenSession(...)`
either way. **Stop** went because an idle CLI costs nothing, so "pause this" is
not a real case; the real one is a runaway agent, which is urgent and rare, and
in that moment you open the pane, where Ctrl-C interrupts with the CLI's own
semantics. Its last UI consumer is gone, but the capability stays: archiving
calls it.

**Archiving is now stored, and it stops the agent.** It had to become stored:
"I am finished with this one" cannot be expressed as an absence of activity,
which is all the idle window can measure. `archivedAt` lives on the session
record, so it survives a reload and means the same thing on every device.

The idle window stays exactly as it was — still derived, still hiding a quiet
session from the working list. Both roads lead to the archived view, but they
are NOT the same road, and the PR argues why: only the deliberate one unlocks
delete.

**Delete only exists in the archived view**, and only for a session the operator
archived. The server enforces it (409 `not-archived`) rather than the sidebar
alone, so the rule holds for any caller, and it leaves an obvious hook for #76's
`?ifNeverStarted=1`.

**Trace actions moved to the trace.** #84 did not remove the per-row trace
buttons — its own body says so — so this does. Share and "continue in a new
agent" are now on the trace pane's header, which is the thing they act on. Not
the reader's `i`: that panel is `TraceInfo`, and a trace pane does not use it.
Reading a session's own transcript is what reader mode already is, so
`openTrace` and its dead pane-spawning path go.

The remote reconnect/disconnect pair is untouched: it is a line to another
machine, not a local process. Group rows are untouched.

`server/test/archive.test.mjs`, 16 checks against a real server: a live session
refuses to delete with a code and a sentence, an archived one deletes, the flag
is on disk rather than in a browser, restore puts it back and makes it
undeletable again, archiving a running agent stops it, and `stopped` is still
the state it lands in.

This completes step 7 of `docs/conversation-view.md`'s own sequence — "the
sidebar loses its trace buttons and `openTrace`" — so §3.4's table now records
where each control went rather than where it was going, and no longer says
agent rows keep stop/play and delete. `reader-info.test.mjs` gains two checks
in the block that already asserted what the sidebar no longer carries: the row
holds exactly one control, and it archives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant