diff --git a/docs/conversation-view.md b/docs/conversation-view.md index 644eedd..d8593f3 100644 --- a/docs/conversation-view.md +++ b/docs/conversation-view.md @@ -250,11 +250,65 @@ pane with nothing to render (a shell) simply stays a terminal. (`HANDOFF_CODE`, `TerminalPane.tsx`). **Verify** this before shipping: xterm needs layout to fit, so "cover, don't unmount" is the low-risk option, and a refit on return is required. - The reader's toolbar is a second header row, not a squeeze into the first — on a phone the - first row has no spare width. It carries only what is true of the whole session and said - nowhere else: the model, `13 turns`, the token totals abbreviated (`2.2M↓ 654k↑`), `▲▼`, and - the search box. The harness is the logo in the row above; the raw message count and the cached - tokens are details, so they live in `title` attributes. There is no "expand everything" — each - turn folds itself, and search opens what it needs to. + first row has no spare width. It carries the reader's **controls**: `▲▼` and the search box. + There is no "expand everything" — each turn folds itself, and search opens what it needs to. +- **The facts live behind one `i` in the PANE HEADER** (2026-08-16, moved out of the reader's bar 2026-08-18). They were spread + across the bar — model chip, `13 turns`, `2.2M↓ 654k↑`, the day it started — which is a lot of + standing furniture for things you read once, and on a narrow pane they pushed search to the + edge. Tapping the `i` opens a small panel over the conversation (it overlays, so opening it + does not move the text under your thumb), and it closes on Escape or a press anywhere else. + Nothing was dropped, and two things got **better**: the raw message count and the cached + tokens used to be `title` attributes, and so did the full timestamp behind the short date — + a phone cannot open any of those. They are plain text in the panel. The panel is also where + the session's transcript is offered as a **file** (`/api/trace/:id/download`) and where + **Share…** opens the same dialog the sidebar row opens. + + | Was on the bar | Now | + |---|---| + | model chip | panel — `Model` | + | `13 turns` + `of N messages` | panel — `Turns`, with the message total once the summary lands | + | token totals `2.2M↓ 654k↑` | panel — `Tokens` | + | cached tokens (`title` only) | panel — text beside the totals | + | day started, e.g. `14 Aug` | panel — `Started` | + | full timestamp (`title` only) | panel — text beside the day | + | `▲▼`, search box, hit counter | **unchanged, still on the bar** | + + **The header's own layout (2026-08-18).** Left: the agent's logo, then the + working directory (`.ph-path`, which used to sit among the controls on the + right — it is not a control). Centre: the state mark immediately left of the + name, both centred as one title. Right: attach, search, `i`, close, in one + `.ph-btn` contract — 22px of ink, no boxes, 8px apart, and a `::before` + overlay that makes each a 28×28 target. The 8px is measured, not chosen: at + 4px each overlay reached over its neighbour's ink and the middle two measured + 20px across, under the 24px floor and invisibly so. + + **The reader has no toolbar until search is asked for.** The header's search + switch reveals the bar (search box + `▲▼`) and closing it CLEARS the query — + a search filters the reader to matching turns, so hiding the box while the + filter stands leaves a reader showing three of forty turns with nothing to say + why. A phone gets a row of reading height back when it is closed. + + **The paperclip is the header's, in both views.** While the reader is mounted + it registers its own picker upward (`onAttachPicker`), so the files still land + in the composer's draft; the composer no longer draws a picker of its own. The + search switch is reader-only: it searches a transcript, and the terminal view + has none. + + It sits in the header rather than the reader's toolbar because the operator + asked for it from the terminal view too, and these are facts about the + SESSION: a pane showing a terminal has the same model, the same token total + and the same start date. One instance, in the pane's identity cluster beside + the close button, in the right-hand cluster. The panel is anchored to + `.pane-head`, not to the button, and hangs from its right edge, so it opens + inside the pane at any width. It carries a `Folder` line: the working + directory is hidden on a phone, and this is where the fact stays reachable. + + **The whole-file read is lazy.** A terminal pane has parsed nothing, so the + summary is fetched when the panel is first opened and once per session; while + it is in flight the panel says `reading the transcript…`, and a session that + has not spoken says so instead of showing zeros — with no Download, since the + route would answer `no-trace`. When the reader IS mounted it hands its head + down (`onHead`), so opening the panel there costs no request at all. - **Search has to be followable.** Filtering to matching turns is not finding: the term is highlighted wherever it lands, a turn whose only match is inside its folded work *unfolds it* (and opens the step holding it, body included), the box reports `3/17`, and `▲▼` switch from @@ -427,12 +481,17 @@ pane with nothing to render (a shell) simply stays a terminal. | A `trace` **session row per read** (`App.tsx:openTrace`) | **gone** — no duplicate rows | | Trace row's `Share` (`Sidebar.tsx:237`) | trace pane header (imported traces keep a pane) | | Trace row's `Handover` (`Sidebar.tsx:238`) | reader / trace-pane footer | -| Quick-add **Trace** = open a shared dataset (`Sidebar.tsx:482`) | **stays** | +| Quick-add **Trace** = open a shared dataset (`Sidebar.tsx:482`) | **Settings → Open a shared trace** (2026-08-16) | -The last row is deliberate: an **imported** trace has no session behind it, so it is a genuine -object that needs a row of its own. Same for a transcript opened from the Files pane -(`getFileTracePage`). What disappears is the *local* trace pane — a session's own history is -now a mode of its own pane, not a second entity. +The last row was `stays` until the operator asked for the sidebar's trace widget to go +(improv.md, iteration two). It moved rather than went away, because it is the one trace +affordance nothing else replaces: the Files pane's trace viewer reads files **in the +workspace**, and this pulls a dataset **off the Hub**. An imported trace still gets a pane and +a row of its own once it is here — what left the sidebar is the standing *form* for typing a +dataset id, which is furniture for something you do when a link arrives. + +What disappears is the *local* trace pane — a session's own history is now a mode of its own +pane, not a second entity. Agent rows keep stop/play and delete. Three glyphs less per row, which is most visible exactly where the sidebar is worst: on a phone. diff --git a/docs/session-sharing.md b/docs/session-sharing.md index c8509eb..bfea09c 100644 --- a/docs/session-sharing.md +++ b/docs/session-sharing.md @@ -8,7 +8,8 @@ own Agent Manager and keep working. > **Scope decision, 2026-07-29 — no in-app notification.** The banner, accept/decline, and > the sender allowlist described in §5 and §9 are **not built and not planned for now**. The > flow is simpler: share the session, send the person the dataset URL, and they open it with -> the **Trace** button in their own Space. That path is built and tested end to end, works +> **Settings → Open a shared trace** in their own Space (the sidebar's Trace widget until +> 2026-08-16). That path is built and tested end to end, works > for private and gated repos (the viewer is blocked there, an authenticated download is > not), and needs no polling, no consent UI and no second repo. > @@ -37,6 +38,7 @@ own Agent Manager and keep working. | Viewer | **Our own trace panel** (`cli: 'trace'`), visually inspired by the Hub viewer, built on `traces.js`. *Reverses an earlier decision — see §5.* | | Cross-harness | **Briefing handoff**, not transcript translation, wrapped in a data envelope that is never auto-fed to an agent. | | Recipient | **A teammate with their own Agent Manager Space.** | +| **Getting the file itself** | `GET /api/trace/:id/download` (2026-08-16) — the transcript, as a file, for an archive or an issue report. Offered in the reader's `i` panel and in the share dialog. **No redaction gate**: it returns the operator's own file to the operator over their own session, and publishes nothing. Sharing is what needs the gate. | The access-control decision collapses what used to be two flows into one: a share **always** produces a payload dataset and the only difference between public and private is that diff --git a/server/package.json b/server/package.json index 0ec01cb..9cddacd 100644 --- a/server/package.json +++ b/server/package.json @@ -13,10 +13,10 @@ "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js", - "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs", + "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs", "test:screenshots": "node screenshot-input.test.mjs", "test:mobile": "node mobile.test.mjs", - "test": "node test/attachments.test.mjs && node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs" + "test": "node test/trace-download.test.mjs && node test/attachments.test.mjs && node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs" }, "engines": { "node": ">=20.19" diff --git a/server/reader-info.test.mjs b/server/reader-info.test.mjs new file mode 100644 index 0000000..5c8d69e --- /dev/null +++ b/server/reader-info.test.mjs @@ -0,0 +1,395 @@ +#!/usr/bin/env node +/** + * The reader's info panel, in a real browser: + * - the bar carries controls, not facts: no model chip, count or date inline; + * - the `i` opens on a TAP (not hover) and holds every fact the bar used to, + * including the two that were title attributes a phone cannot open; + * - it closes on Escape and on a press outside it; + * - it offers the transcript as a file, and that URL really answers; + * - it offers Share, which is the same dialog the sidebar opens; + * - the sidebar no longer carries a trace widget, and Settings does. + * + * Set READER_INFO_PUBLIC_DIR to a prebuilt web/dist to skip the build, and + * READER_INFO_PORT to move off the default when suites run in parallel. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { chromiumLaunchOptions } from '../scripts/test-chromium.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.dirname(HERE); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'am-reader-info-')); +const DATA_DIR = path.join(TMP, 'data'); +const HOME = path.join(TMP, 'home'); +const PUBLIC_DIR = process.env.READER_INFO_PUBLIC_DIR || path.join(TMP, 'public'); +const PORT = process.env.READER_INFO_PORT || '7899'; +const API = `http://127.0.0.1:${PORT}`; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +fs.mkdirSync(DATA_DIR, { recursive: true }); +fs.mkdirSync(path.join(HOME, '.claude', 'projects', 'am'), { recursive: true }); + +let failures = 0; +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`); + if (!ok) failures += 1; +}; +const waitFor = async (fn, timeout = 15_000) => { + const until = Date.now() + timeout; + while (Date.now() < until) { + try { if (await fn()) return true; } catch { /* not yet */ } + await sleep(100); + } + return false; +}; + +if (!process.env.READER_INFO_PUBLIC_DIR) { + const build = spawnSync('npm', ['run', 'build', '--', '--outDir', PUBLIC_DIR], { + cwd: path.join(ROOT, 'web'), encoding: 'utf8', + }); + if (build.status !== 0) throw new Error(`web build failed:\n${build.stdout}\n${build.stderr}`); +} + +// A test server must not publish skills — see migration.test.mjs for why. +const { SPACE_ID, AM_DISTRIBUTE_SKILLS, ...BASE_ENV } = process.env; +const backend = spawn('node', ['src/index.js'], { + cwd: HERE, + env: { + ...BASE_ENV, + PORT, DATA_DIR, PUBLIC_DIR, HOME, CLAUDE_CONFIG_DIR: path.join(HOME, '.claude'), + AM_BASHRC: '/nonexistent', SPACE_HOST: '', AM_ALLOW_MISSING_ORIGIN: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let logs = ''; +backend.stdout.on('data', (d) => { logs += d; }); +backend.stderr.on('data', (d) => { logs += d; }); + +let browser; +try { + if (!await waitFor(() => fetch(`${API}/api/health`).then((r) => r.ok).catch(() => false), 60_000)) { + throw new Error(`server did not start:\n${logs.slice(-2000)}`); + } + await fetch(`${API}/api/welcome/seen`, { method: 'POST' }); + + const created = await (await fetch(`${API}/api/sessions`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cli: 'claude', name: 'reader-info-e2e', path: 'ri' }), + })).json(); + const id = created.id; + + // A real transcript on disk, so the download link is answered by the real + // route rather than a mock: the rendering is mocked below, the file is not. + const workdir = path.join(DATA_DIR, 'workspaces', 'ri'); + const transcript = path.join(HOME, '.claude', 'projects', 'am', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl'); + fs.writeFileSync(transcript, `${JSON.stringify({ + type: 'user', cwd: workdir, timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + })}\n`); + + browser = await chromium.launch(chromiumLaunchOptions()); + // A touch device with no mouse: `tap()` here is a real touch sequence, which + // is the point — the facts this panel holds were unreachable on a phone when + // they lived in title attributes. + const context = await browser.newContext({ viewport: { width: 1100, height: 800 }, hasTouch: true }); + const page = await context.newPage(); + + const startedAt = Date.UTC(2026, 0, 14, 9, 30); + const traceTurns = [ + { role: 'user', ts: startedAt, blocks: [{ type: 'text', text: 'fixture prompt' }] }, + { role: 'assistant', kind: 'final', ts: startedAt + 1000, blocks: [{ type: 'text', text: 'fixture answer' }] }, + ]; + let summaryCalls = 0; + let releaseSummary = null; + const holdSummary = () => new Promise((resolve) => { releaseSummary = resolve; }); + let summaryGate = null; + await page.route(`**/api/trace/${id}?*`, async (route) => { + const common = { + harness: 'claude', harnessLabel: 'Claude Code', sessionId: id, + title: 'reader-info-e2e', model: 'claude-fixture-5', cwd: '.', firstTs: startedAt, + lastTs: startedAt + 1000, usage: { in: 12_000, out: 3_400, cacheRead: 8_000 }, + note: null, total: 2, truncated: false, userTurns: [0], + }; + if (new URL(route.request().url()).searchParams.has('summary')) { + summaryCalls += 1; + if (summaryGate) await summaryGate; + return route.fulfill({ json: common }); + } + return route.fulfill({ json: { + ...common, turns: traceTurns, offset: 0, limit: 2, + window: { mode: 'index', start: 0, end: 2, atStart: true, atEnd: true }, + } }); + }); + + // Panes are retained, so once a second session is opened `.pane-head` matches + // more than one. Scope every header lookup to the pane that owns the name. + const paneOf = (name) => page.locator('.slot', { has: page.locator('.ph-name', { hasText: name }) }); + const infoOf = (name) => paneOf(name).locator('.pane-head .tinfo-btn'); + + await page.goto(API); + await page.locator('.sidebar .row[title^="reader-info-e2e"]').first().click(); + + // ---- the terminal view, which is where this had to become reachable ------- + // The pane opens on the terminal. The `i` is in the header, so it is here + // too — and nothing has read the transcript yet, which is the point of the + // lazy load: a whole-file parse for a panel nobody opened is a tax on every + // pane. (docs/conversation-view.md §5) + await infoOf('reader-info-e2e').waitFor({ state: 'visible', timeout: 20_000 }); + await sleep(1_200); + check('a terminal pane reads nothing until the panel is opened', + summaryCalls === 0, JSON.stringify({ summaryCalls })); + check('the reader is not mounted, so this is the terminal view', + await page.locator('.cxv-bar').count() === 0); + + // Every button in the header, not only the `i`: they lost their boxes, so the + // target is invisible and the only way to know it is there is to hit-test it. + // A neighbour's overlay can also steal an edge — at a 4px gap each of these + // measured 20 across, under the floor — so this measures all of them. + const sweep = () => page.evaluate(() => [...document.querySelectorAll('.pane-head .ph-btn')].map((btn) => { + const r = btn.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const hits = (dx, dy) => { + const el = document.elementFromPoint(cx + dx, cy + dy); + return !!el && (el === btn || btn.contains(el)); + }; + let hw = 0; + while (hw < 40 && hits(hw + 1, 0) && hits(-(hw + 1), 0)) hw += 1; + let hh = 0; + while (hh < 40 && hits(0, hh + 1) && hits(0, -(hh + 1))) hh += 1; + return { cls: btn.className.replace('ph-btn ', ''), w: hw * 2, h: hh * 2, ink: Math.round(r.width) }; + })); + const termTargets = await sweep(); + check('every header button is a 24px-plus tap target in the terminal view', + termTargets.length >= 3 && termTargets.every((b) => b.w >= 24 && b.h >= 24), + JSON.stringify(termTargets)); + check('and they are all one size, with no boxes', + new Set(termTargets.map((b) => b.ink)).size === 1, JSON.stringify(termTargets.map((b) => b.ink))); + + // Hold the read so the in-flight state is observable: it must say what it is + // doing rather than show a panel of blanks or a confident row of zeros. + summaryGate = holdSummary(); + await infoOf('reader-info-e2e').tap(); + await page.locator('.tinfo').waitFor({ state: 'visible' }); + const loadingText = (await page.locator('.tinfo').innerText()).trim(); + check('while it reads, the panel says so instead of showing blanks', + /reading the transcript/i.test(loadingText) && !loadingText.includes('Model'), + JSON.stringify({ loadingText })); + releaseSummary(); + summaryGate = null; + // NOT `.tinfo-facts` — the Folder line renders that list before the read + // returns, so waiting for it would prove nothing. Wait for a fact the file + // has to answer for. + await waitFor(async () => /Model/.test(await page.locator('.tinfo').innerText()), 8_000); + const termFacts = await page.locator('.tinfo').innerText(); + check('and then the terminal view has the same facts the reader does', + termFacts.includes('claude-fixture-5') && termFacts.includes('2 messages') + && /8\.0k\s+cached/i.test(termFacts) && termFacts.includes('Jan 14'), + JSON.stringify({ termFacts })); + check('with the transcript offered from here as well', + await page.locator('.tinfo-actions a[download]').getAttribute('href') === `/api/trace/${id}/download`); + const afterFirstOpen = summaryCalls; + await page.keyboard.press('Escape'); + await infoOf('reader-info-e2e').tap(); + await page.locator('.tinfo-facts').waitFor({ state: 'visible' }); + await page.keyboard.press('Escape'); + check('re-opening it does not read the file again', summaryCalls === afterFirstOpen, + JSON.stringify({ afterFirstOpen, summaryCalls })); + + // ---- and the reader, which hands its own head to the same panel ---------- + await page.locator('.modebar button', { hasText: 'reader' }).click(); + // The reader has no toolbar until search is asked for, so wait for the body. + await page.locator('.cxv-body').waitFor({ state: 'visible', timeout: 20_000 }); + + // The rearrangement the operator asked for: the working directory beside the + // agent icon, the state mark immediately left of the centred name, and the + // four controls together on the right. + const layout = await page.evaluate(() => ({ + pathInLeft: !!document.querySelector('.pane-head .ph-left .ph-path'), + pathInRight: !!document.querySelector('.pane-head .ph-right .ph-path'), + statusInTitle: !!document.querySelector('.pane-head .ph-title .status'), + statusLeftOfName: (() => { + const dot = document.querySelector('.pane-head .ph-title .status'); + const name = document.querySelector('.pane-head .ph-name'); + return !!dot && !!name && dot.getBoundingClientRect().right <= name.getBoundingClientRect().left + 1; + })(), + rightOrder: [...document.querySelectorAll('.pane-head .ph-right .ph-btn')] + .map((b) => b.className.replace('ph-btn ', '').split(' ')[0]), + })); + check('the working directory sits with the agent icon, not with the controls', + layout.pathInLeft && !layout.pathInRight, JSON.stringify(layout)); + check('the state mark leads the centred name', + layout.statusInTitle && layout.statusLeftOfName, JSON.stringify(layout)); + check('attach, search, info and close are one cluster in that order', + JSON.stringify(layout.rightOrder) === JSON.stringify(['ph-image', 'ph-search', 'tinfo-btn', 'ph-close']), + JSON.stringify(layout.rightOrder)); + + // 1. The reader opens with no bar at all: search is a tool the header's switch + // reveals, and the facts left for the header's `i` (2026-08-18). + check('the reader opens with no toolbar of its own', + await page.locator('.cxv-bar').count() === 0); + await page.locator('.pane-head .ph-search').tap(); + await page.locator('.cxv-bar .cxv-search').waitFor({ state: 'visible', timeout: 5_000 }); + const barText = (await page.locator('.cxv-bar').innerText()).trim(); + check('the revealed bar carries search and the turn keys, and no facts', + await page.locator('.cxv-bar .cxv-nav').isVisible() + && !barText.includes('claude-fixture-5') && !barText.includes('Jan'), + JSON.stringify({ barText })); + check('the search box takes focus when it appears', + await page.evaluate(() => document.activeElement?.className?.includes('cxv-search'))); + + // Closing search CLEARS the query. A live filter with no visible box is a + // reader that looks broken — which is how a reviewer read it last round. + await page.locator('.cxv-search').fill('nothing-matches-this'); + await waitFor(async () => await page.locator('.cxv-body .cx').count() === 0, 4_000); + const filteredAway = await page.locator('.cxv-body .cx').count(); + await page.locator('.pane-head .ph-search').tap(); + await waitFor(async () => await page.locator('.cxv-bar').count() === 0, 4_000); + await waitFor(async () => await page.locator('.cxv-body .cx').count() > 0, 4_000); + const backAgain = await page.locator('.cxv-body .cx').count(); + check('closing search clears the filter as well as the box', + filteredAway === 0 && backAgain > 0, + JSON.stringify({ filteredAway, backAgain })); + await page.locator('.pane-head .ph-search').tap(); + await page.locator('.cxv-bar .cxv-search').waitFor({ state: 'visible' }); + check('and it comes back empty, not with the old query', + (await page.locator('.cxv-search').inputValue()) === ''); + await page.locator('.pane-head .ph-search').tap(); + + // 2. The tap target is bigger than the circle it draws. WCAG 2.2 SC 2.5.8 + // floors a target at 24x24, and this button is the only route to five + // facts that used to need no tap at all. Measured by hit-testing rather + // than by reading the CSS: an overlay counts as its own element, so this + // stays honest if the technique changes. + const readerTargets = await sweep(); + check('every header button keeps its target in the reader, search included', + readerTargets.length >= 4 && readerTargets.every((b) => b.w >= 24 && b.h >= 24), + JSON.stringify(readerTargets)); + + // 3. It opens by touch, and holds what the bar used to. + check('the info panel is shut until asked', await page.locator('.tinfo').count() === 0); + await page.locator('.tinfo-btn').tap(); + await page.locator('.tinfo').waitFor({ state: 'visible' }); + // The whole-file summary lands a moment after the first paint; the panel + // shows what is loaded until then, and the message total once it has it. + await waitFor(async () => (await page.locator('.tinfo').innerText()).includes('2 messages'), 5_000); + const facts = await page.locator('.tinfo').innerText(); + check('the model is in the panel', facts.includes('claude-fixture-5'), JSON.stringify({ facts })); + check('the turn and message counts are in the panel', + /\b1 turn\b/.test(facts) && facts.includes('2 messages'), JSON.stringify({ facts })); + check('the token counts are in the panel', /12(\.0)?k/i.test(facts) && /3(\.4)?k/i.test(facts), + JSON.stringify({ facts })); + // The two that used to be title attributes: on a touch device they could not + // be read at all before, so they are plain text here. + check('cached tokens are text, not a tooltip', /8[,.]?0?0?0?k?\s+cached/i.test(facts), JSON.stringify({ facts })); + check('the start date is text, and the full timestamp with it', + facts.includes('14 Jan') || facts.includes('Jan 14'), + JSON.stringify({ facts })); + const startedLine = facts.split('\n').find((l) => /14 Jan|Jan 14/.test(l)) || ''; + check('the full timestamp is spelled out rather than hovered', + /\d{1,2}:\d{2}/.test(startedLine), JSON.stringify({ startedLine })); + + // 4. The transcript, as a file — and the URL really answers. + const href = await page.locator('.tinfo-actions a[download]').getAttribute('href'); + const downloaded = await fetch(`${API}${href}`); + const downloadedText = await downloaded.text(); + check('the panel offers the transcript and that URL answers with the file', + href === `/api/trace/${id}/download` && downloaded.status === 200 + && downloadedText === fs.readFileSync(transcript, 'utf8'), + JSON.stringify({ href, status: downloaded.status, bytes: downloadedText.length })); + + // 5. Dismissal, both ways. + await page.keyboard.press('Escape'); + check('Escape closes the panel', await page.locator('.tinfo').count() === 0); + await page.locator('.tinfo-btn').tap(); + await page.locator('.tinfo').waitFor({ state: 'visible' }); + await page.locator('.cxv-body').tap({ position: { x: 20, y: 320 } }); + check('a press outside closes the panel', + await waitFor(async () => await page.locator('.tinfo').count() === 0, 2_000)); + + // 6. Share, from the reader — the same dialog the sidebar row opens. + await page.locator('.tinfo-btn').tap(); + await page.locator('.tinfo-actions button', { hasText: 'Share' }).tap(); + const shareOpen = await waitFor(async () => await page.locator('.share-card').isVisible(), 5_000); + check('Share in the panel opens the share dialog', shareOpen); + check('the share dialog offers the same download', + await page.locator('.share-card a[download]').getAttribute('href') === `/api/trace/${id}/download`); + await page.keyboard.press('Escape'); + + // The reply row fills the composer. It stopped when the paperclip left column + // 1 of that grid: auto-placed, the row took the `auto` track and sized to its + // own content, leaving the send key stranded ~650px from the right edge. A + // rendered measurement, because the CSS pin in composerAlign.test.mjs cannot + // see what the browser actually laid out. + const reach = async (label) => page.evaluate(() => { + const comp = document.querySelector('.pane-reader .ov-composer'); + const live = comp?.querySelector('.ov-live'); + const send = comp?.querySelector('.ov-send'); + if (!comp || !live) return null; + const c = comp.getBoundingClientRect(); + const l = live.getBoundingClientRect(); + const sd = send?.getBoundingClientRect(); + return { toEdge: Math.round(c.right - l.right), sendToEdge: sd ? Math.round(c.right - sd.right) : null, + width: Math.round(l.width), of: Math.round(c.width) }; + }).then((v) => ({ label, ...v })); + const emptyReach = await reach('empty'); + await page.locator('.pane-reader .ov-live textarea').fill('hello'); + await sleep(300); + const typedReach = await reach('typed'); + check('the reply row reaches the composer edge, empty and typed', + emptyReach.toEdge <= 24 && typedReach.toEdge <= 24 + && emptyReach.width > emptyReach.of / 2 && typedReach.width > typedReach.of / 2, + JSON.stringify([emptyReach, typedReach])); + check('so the send key sits at that edge, not in the middle of the row', + typedReach.sendToEdge != null && typedReach.sendToEdge <= 24, + JSON.stringify(typedReach)); + await page.locator('.pane-reader .ov-live textarea').fill(''); + + // An agent that has not spoken has no transcript, and the panel's actions are + // about a file that does not exist yet. Say that, and do not offer a link the + // route would answer with `no-trace` (server/test/trace-download.test.mjs). + const quiet = await (await fetch(`${API}/api/sessions`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cli: 'claude', name: 'not-spoken-yet', path: 'ri2' }), + })).json(); + await page.locator('.sidebar .row[title^="not-spoken-yet"]').first() + .waitFor({ state: 'visible', timeout: 15_000 }); + await page.locator('.sidebar .row[title^="not-spoken-yet"]').first().click(); + await infoOf('not-spoken-yet').waitFor({ state: 'visible', timeout: 15_000 }); + await infoOf('not-spoken-yet').tap(); + await page.locator('.tinfo').waitFor({ state: 'visible' }); + const quietText = await waitFor(async () => + /no transcript yet/i.test(await page.locator('.tinfo').innerText()), 8_000); + const quietFacts = (await page.locator('.tinfo').innerText()).trim(); + check('a session that has not spoken says so instead of showing zeros', + quietText && !/Turns|Tokens|Started/.test(quietFacts) && /Folder/.test(quietFacts), + JSON.stringify({ text: quietFacts, id: quiet.id })); + check('and offers no download for a file that is not there', + await page.locator('.tinfo-actions a[download]').count() === 0); + await page.keyboard.press('Escape'); + + // 7. The sidebar widget is gone, and its one irreplaceable function is not. + check('the sidebar has no trace widget left', + await page.locator('.quick-add button', { hasText: 'Trace' }).count() === 0 + && await page.locator('.open-trace').count() === 0); + await page.locator('.sidebar .set-btn, .sidebar button[title="Settings"]').first().click(); + const traceRow = page.locator('.setting-row', { hasText: 'Open a shared trace' }); + await traceRow.waitFor({ state: 'visible', timeout: 10_000 }); + check('opening a shared trace moved to Settings, with its own input', + await traceRow.locator('input').isVisible()); +} catch (e) { + check('no exceptions', false, String(e && e.message ? e.message : e)); + console.log(`--- server log tail ---\n${logs.slice(-1200)}`); +} finally { + try { await browser?.close(); } catch { /* already gone */ } + backend.kill('SIGKILL'); + // The server writes repin hooks into this HOME as it dies, so the tree can + // grow back under the walk. A leftover temp dir is not worth a failed run. + try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* the OS will */ } +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed'); +process.exit(failures ? 1 : 0); diff --git a/server/screenshot-input.test.mjs b/server/screenshot-input.test.mjs index c7071bf..0b9d54e 100644 --- a/server/screenshot-input.test.mjs +++ b/server/screenshot-input.test.mjs @@ -260,9 +260,13 @@ try { await page.locator('.modebar button', { hasText: 'reader' }).click(); const readerPicker = page.locator('.pane-reader .image-file-input'); await readerPicker.waitFor({ state: 'attached' }); - check('reader owns the visible attachment picker instead of the hidden terminal', - await page.locator('.pane-reader .image-pick').isVisible() - && await page.locator('.pane-head .ph-image').count() === 0); + // The pane header owns ONE paperclip in both views now (2026-08-18): in the + // reader it opens the composer's own picker, which is the input used below, so + // the files still land in the draft. The composer no longer draws its own. + check('one paperclip, in the header, wired to the reader composer', + await page.locator('.pane-head .ph-image').isVisible() + && await page.locator('.pane-reader .image-pick').count() === 0 + && await page.locator('.pane-reader .image-file-input').count() === 1); await readerPicker.setInputFiles({ name: 'reader.png', mimeType: 'image/png', buffer: png }); await page.locator('.pane-reader .image-chip').waitFor({ state: 'visible' }); let readerInput; diff --git a/server/src/index.js b/server/src/index.js index 34b3a61..0120e99 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -2292,28 +2292,79 @@ app.get('/api/trace/bundles', async (_req, res) => { // Resolve the concrete local source behind a trace pane. Handover uses this to // seed the next agent with a path it can inspect directly, whether the pane // points at one of this Manager's sessions or at an imported Hub bundle. +// Which directory a bundle ref names — the ONE place that decides. The shape +// check alone admits `..`, and `path.join(DATA_DIR, 'traces', '..')` is +// DATA_DIR: a pane whose ref is `..` served the first *.jsonl in the data +// directory, as a file from /download and as a rendered conversation from +// /api/trace/:id. These refs arrive from the browser, so resolve and require +// the result to be a direct child of the bundle root. +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; +} + +// Where the transcript behind a pane actually lives. A trace pane reads someone +// else's file (an imported bundle) or another session's; any other session reads +// its own. Reports "no trace" as a value rather than throwing, because an agent +// that has not spoken yet is an ordinary state, not a failure. +async function traceFileOf(s) { + const source = s.cli === 'trace' + ? (s.traceSource || { kind: 'session', ref: s.id }) + : { kind: 'session', ref: s.id }; + if (source.kind === 'bundle') { + const dir = bundleDir(source.ref); + if (!dir) return { status: 400, error: 'bad bundle ref' }; + const names = (await fs.promises.readdir(dir)).filter((n) => n.endsWith('.jsonl')); + if (!names.length) return { status: 404, error: 'bundle has no trace file', code: 'no-trace' }; + return { path: path.join(dir, names[0]), sessionId: null, source }; + } + const target = store.get(source.ref); + if (!target) return { status: 404, error: 'source session is gone', code: 'no-trace' }; + const hit = await findTrace(target, store.list()); + if (!hit) return { status: 404, error: 'no trace found for this session', code: 'no-trace' }; + return { path: hit.src, sessionId: hit.sessionId || null, source }; +} + app.get('/api/trace/:id/location', async (req, res) => { const pane = store.get(req.params.id); if (!pane || pane.cli !== 'trace') return res.status(404).json({ error: 'not a trace pane' }); - const source = pane.traceSource || { kind: 'session', ref: pane.id }; try { - if (source.kind === 'bundle') { - if (!/^[\w.-]+$/.test(String(source.ref))) return res.status(400).json({ error: 'bad bundle ref' }); - const dir = path.join(DATA_DIR, 'traces', source.ref); - const names = (await fs.promises.readdir(dir)).filter((n) => n.endsWith('.jsonl')); - if (!names.length) return res.status(404).json({ error: 'bundle has no trace file', code: 'no-trace' }); - return res.json({ path: path.join(dir, names[0]), source }); - } - const target = store.get(source.ref); - if (!target) return res.status(404).json({ error: 'source session is gone', code: 'no-trace' }); - const hit = await findTrace(target, store.list()); - if (!hit) return res.status(404).json({ error: 'no trace found for this session', code: 'no-trace' }); - return res.json({ path: hit.src, sessionId: hit.sessionId || null, source }); + const found = await traceFileOf(pane); + if (found.error) return res.status(found.status).json({ error: found.error, code: found.code }); + return res.json({ path: found.path, sessionId: found.sessionId, source: found.source }); } catch (e) { res.status(500).json({ error: (e && e.message) || 'could not resolve trace path' }); } }); +// The transcript itself, as a file. The reader and the Files pane RENDER a +// trace; this hands over the bytes — for an archive, an issue report, another +// tool. Any session, not only a trace pane: a session's own transcript lives in +// its harness's directory, OUTSIDE the workspace, so the Files pane cannot +// reach it and a download is the only way to hold the file. +// +// No redaction gate, and that is deliberate: this returns the operator's own +// file to the operator, over the session they are already authenticated on. +// Publishing is /api/share, which does gate, because that is what puts a +// transcript somewhere other people can read it. +app.get('/api/trace/:id/download', async (req, res) => { + const s = store.get(req.params.id); + if (!s) return res.status(404).json({ error: 'not found' }); + try { + const found = await traceFileOf(s); + if (found.error) return res.status(found.status).json({ error: found.error, code: found.code }); + const stem = slugify(s.name || '') || 'trace'; + res.download(found.path, `${stem}${path.extname(found.path) || '.jsonl'}`); + } catch (e) { + res.status(500).json({ error: (e && e.message) || 'could not read that trace' }); + } +}); + // Paginated on purpose: a single session here is 6.15 MB and the panel only ever // shows a window of it. `limit` is clamped in readTrace(). app.get('/api/trace/:id', async (req, res) => { @@ -2325,8 +2376,9 @@ app.get('/api/trace/:id', async (req, res) => { try { if (source.kind === 'bundle') { - if (!/^[\w.-]+$/.test(String(source.ref))) return res.status(400).json({ error: 'bad bundle ref' }); - return res.json(await readTraceBundle(path.join(DATA_DIR, 'traces', source.ref), opts)); + const dir = bundleDir(source.ref); + if (!dir) return res.status(400).json({ error: 'bad bundle ref' }); + return res.json(await readTraceBundle(dir, opts)); } const target = store.get(source.ref); if (!target) return res.status(404).json({ error: 'source session is gone', code: 'no-trace' }); @@ -2354,7 +2406,7 @@ app.put('/api/trace/:id/source', (req, res) => { if (!ref) return res.status(400).json({ error: 'ref required' }); // A bundle ref becomes a path segment under DATA_DIR/traces — validate it here // too, so a traversal attempt never gets persisted in the session record. - if (kind === 'bundle' && !/^[\w.-]+$/.test(ref)) return res.status(400).json({ error: 'bad bundle ref' }); + if (kind === 'bundle' && !bundleDir(ref)) return res.status(400).json({ error: 'bad bundle ref' }); if (kind === 'session' && !store.get(ref)) return res.status(404).json({ error: 'no such session' }); store.update(pane.id, { traceSource: { kind, ref } }); res.json({ ok: true, traceSource: { kind, ref } }); diff --git a/server/test/trace-download.test.mjs b/server/test/trace-download.test.mjs new file mode 100644 index 0000000..38ff76f --- /dev/null +++ b/server/test/trace-download.test.mjs @@ -0,0 +1,189 @@ +// Downloading the transcript behind a session. The reader and the Files pane +// RENDER a trace; this route hands over the file, which is the only way to hold +// a session's own transcript: it lives in the harness's directory, outside the +// workspace, so the Files pane cannot reach it. Run with: +// node test/trace-download.test.mjs +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.dirname(HERE); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'am-trace-download-')); +const DATA_DIR = path.join(TMP, 'data'); +const HOME = path.join(TMP, 'home'); +const PORT = process.env.TRACE_DOWNLOAD_PORT || '7898'; +const API = `http://127.0.0.1:${PORT}`; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +fs.mkdirSync(DATA_DIR, { recursive: true }); +fs.mkdirSync(path.join(HOME, '.claude', 'projects', 'am'), { recursive: true }); + +let failures = 0; +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`); + if (!ok) failures += 1; +}; + +// A test server must not publish skills — the same strip migration.test.mjs and +// the browser suites do: `SPACE_ID` is set when this runs inside the Space, and +// generateEnvSkill() then fans this checkout's environment skill into every live +// agent's skills dir, which comes from $HOME rather than DATA_DIR. +const { SPACE_ID, AM_DISTRIBUTE_SKILLS, ...BASE_ENV } = process.env; +let logs = ''; +let backend = null; +const start = async () => { + backend = spawn('node', ['src/index.js'], { + cwd: ROOT, + env: { + ...BASE_ENV, + PORT, DATA_DIR, HOME, CLAUDE_CONFIG_DIR: path.join(HOME, '.claude'), + AM_BASHRC: '/nonexistent', SPACE_HOST: '', AM_ALLOW_MISSING_ORIGIN: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + backend.stdout.on('data', (d) => { logs += d; }); + backend.stderr.on('data', (d) => { logs += d; }); + for (let i = 0; i < 300; i += 1) { + if (await fetch(`${API}/api/health`).then((r) => r.ok).catch(() => false)) return; + await sleep(200); + } + throw new Error(`server did not start:\n${logs.slice(-2000)}`); +}; +const stop = async () => { + if (!backend) return; + const dead = new Promise((r) => backend.on('exit', r)); + backend.kill('SIGTERM'); + await Promise.race([dead, sleep(4_000)]); + backend = null; +}; + +const post = (url, body) => fetch(`${API}${url}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), +}); + +try { + await start(); + + const made = await (await post('/api/sessions', { cli: 'claude', name: 'Download me', path: 'dl' })).json(); + + // No transcript yet is an ordinary state for an agent that has not spoken — + // it must be a clean 404 with a reason, not a 500 or an empty file. + const missing = await fetch(`${API}/api/trace/${made.id}/download`); + const missingBody = await missing.json().catch(() => ({})); + check('a session with no transcript answers 404 and says so', + missing.status === 404 && missingBody.code === 'no-trace', + JSON.stringify({ status: missing.status, body: missingBody })); + + // Now give it one, the way Claude Code would: a .jsonl under the config dir + // whose `cwd` is this session's workspace folder. + const workdir = path.join(DATA_DIR, 'workspaces', 'dl'); + const line = (i) => JSON.stringify({ + type: i % 2 === 0 ? 'user' : 'assistant', + cwd: workdir, + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(), + message: { + role: i % 2 === 0 ? 'user' : 'assistant', + ...(i % 2 ? { id: `m${i}`, model: 'claude-test', usage: { input_tokens: 10, output_tokens: 5 } } : {}), + content: [{ type: 'text', text: `turn ${i}` }], + }, + }); + const transcript = path.join(HOME, '.claude', 'projects', 'am', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl'); + const body = `${[0, 1, 2, 3].map(line).join('\n')}\n`; + fs.writeFileSync(transcript, body); + + const got = await fetch(`${API}/api/trace/${made.id}/download`); + const text = await got.text(); + const disposition = got.headers.get('content-disposition') || ''; + check('the transcript downloads byte-for-byte', got.status === 200 && text === body, + JSON.stringify({ status: got.status, bytes: text.length, expected: body.length })); + // The name is the session's, not the harness's uuid: a folder of downloads + // named 'aaaaaaaa-bbbb-…jsonl' tells the operator nothing. + check('it arrives as an attachment named after the session', + /attachment/i.test(disposition) && disposition.includes('download-me.jsonl'), + JSON.stringify({ disposition })); + + // A trace pane over an imported bundle downloads the bundle's file, not the + // pane's own (a trace pane has no transcript of its own at all). + const bundleRef = 'user__shared-session'; + const bundleDir = path.join(DATA_DIR, 'traces', bundleRef); + fs.mkdirSync(bundleDir, { recursive: true }); + const bundleBody = `${line(0)}\n`; + fs.writeFileSync(path.join(bundleDir, 'session.jsonl'), bundleBody); + const pane = await (await post('/api/sessions', { cli: 'trace', name: 'Shared: demo', path: '.' })).json(); + await fetch(`${API}/api/trace/${pane.id}/source`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ kind: 'bundle', ref: bundleRef }), + }); + const fromBundle = await fetch(`${API}/api/trace/${pane.id}/download`); + const bundleText = await fromBundle.text(); + check('a trace pane downloads the file it is reading', + fromBundle.status === 200 && bundleText === bundleBody, + JSON.stringify({ status: fromBundle.status, bytes: bundleText.length })); + + const unknown = await fetch(`${API}/api/trace/does-not-exist/download`); + check('an unknown id is a 404, not a crash', unknown.status === 404); + + // ---- a bundle ref is a directory NAME, never a path ----------------------- + // `/^[\w.-]+$/` matches `..`, and `path.join(DATA_DIR, 'traces', '..')` is + // DATA_DIR: without a containment check this route serves the first *.jsonl + // in the data directory — the operations log, whatever lands there next — as + // an attachment, and `GET /api/trace/:id` renders the same file as a + // conversation. A canary that sorts first is what `readdir` reaches for. + fs.writeFileSync(path.join(DATA_DIR, 'aa-canary.jsonl'), `${JSON.stringify({ + type: 'user', cwd: workdir, timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'CANARY-NOT-A-TRACE' }] }, + })}\n`); + const escapes = ['..', '.', '../..', 'a/../..']; + const refused = []; + for (const ref of escapes) { + const put = await fetch(`${API}/api/trace/${pane.id}/source`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ kind: 'bundle', ref }), + }); + refused.push({ ref, status: put.status }); + } + check('a traversing bundle ref is refused before it is stored', + refused.every((r) => r.status === 400), JSON.stringify(refused)); + + // And independently of that gate: a ref already in the store must not resolve + // either. Written straight into sessions.json, which is the state a Space + // upgraded from a build that accepted `..` would boot with. + await stop(); + const store = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'sessions.json'), 'utf8')); + const paneRecord = store.find((entry) => entry.id === pane.id); + paneRecord.traceSource = { kind: 'bundle', ref: '..' }; + // The property the whole route rests on: a NON-trace session reads its own + // transcript and nothing else. `traceFileOf` hard-codes `ref = s.id` for + // those, so a planted source must be ignored rather than followed. + store.find((entry) => entry.id === made.id).traceSource = { kind: 'bundle', ref: '..' }; + fs.writeFileSync(path.join(DATA_DIR, 'sessions.json'), JSON.stringify(store, null, 2)); + await start(); + + const escaped = await fetch(`${API}/api/trace/${pane.id}/download`); + const escapedBody = await escaped.text(); + check('a stored traversing ref cannot be downloaded', + escaped.status === 400 && !escapedBody.includes('CANARY-NOT-A-TRACE'), + JSON.stringify({ status: escaped.status, leaked: escapedBody.includes('CANARY-NOT-A-TRACE') })); + const escapedWindow = await fetch(`${API}/api/trace/${pane.id}?tail=1`); + const escapedWindowBody = await escapedWindow.text(); + check('nor rendered as a conversation by the reader', + escapedWindow.status === 400 && !escapedWindowBody.includes('CANARY-NOT-A-TRACE'), + JSON.stringify({ status: escapedWindow.status, leaked: escapedWindowBody.includes('CANARY-NOT-A-TRACE') })); + + const own = await fetch(`${API}/api/trace/${made.id}/download`); + const ownBody = await own.text(); + check('a non-trace session downloads its own transcript, whatever source is planted on it', + own.status === 200 && ownBody === body && !ownBody.includes('CANARY-NOT-A-TRACE'), + JSON.stringify({ status: own.status, bytes: ownBody.length, expected: body.length })); +} catch (e) { + check('no exceptions', false, String(e && e.message ? e.message : e)); + console.log(`--- server log tail ---\n${logs.slice(-1200)}`); +} finally { + backend?.kill('SIGTERM'); + fs.rmSync(TMP, { recursive: true, force: true }); +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed'); +process.exit(failures ? 1 : 0); diff --git a/web/src/App.tsx b/web/src/App.tsx index b73d14e..02a4578 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,7 +20,7 @@ import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode'; import { hiddenSessionIds } from './lib/overviewHidden'; import { useReaderBatch } from './lib/readerBatch'; import { paneOwnsBack } from './lib/mobileBack'; -import { isPassive, isRemote } from './types'; +import { isPassive, isRemote, isShareable } from './types'; import { EyeGlyph, EyeOffGlyph, GridGlyph, ListGlyph, SortGlyph } from './components/icons'; import { uploadPendingAttachments } from './lib/attachments'; @@ -898,6 +898,7 @@ export default function App() { > setShareId(s.id) : undefined} cli={cliMap[s.cli]} theme={theme} zoom={zoom} @@ -995,6 +996,7 @@ export default function App() { clis={clis} info={info} onShowWelcome={openWelcome} + onOpenSharedTrace={openSharedTrace} demoMode={!!info?.demoMode} onToggleDemo={toggleDemo} /> @@ -1051,7 +1053,6 @@ export default function App() { onShareTrace={shareTrace} onTraceHandover={api.getTraceLocation} onOpenTrace={openTrace} - onOpenSharedTrace={openSharedTrace} onNewGroup={newGroup} onRenameGroup={renameGroup} onRenameSession={renameSession} diff --git a/web/src/api.ts b/web/src/api.ts index c231e7a..13782ed 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -251,6 +251,10 @@ export interface SessionTraces extends TraceStats { id: string; name: string; cl export interface Traces { sessions: SessionTraces[]; totals: TraceStats; generatedAt: string; } export const getTraces = (): Promise => fetch('/api/traces').then(json); +/** The transcript file behind a session or trace pane. A URL, not a fetch: the + * browser saves it, so a 6 MB transcript never lands in a JS string first. */ +export const traceDownloadUrl = (id: string) => `/api/trace/${encodeURIComponent(id)}/download`; + // ---- files ---- // 'trace' is content-detected, not name-detected: a transcript is a .jsonl like // any other until /preview reads a line of it (see the route). diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx index 25cb233..dff7813 100644 --- a/web/src/components/SettingsView.tsx +++ b/web/src/components/SettingsView.tsx @@ -119,6 +119,7 @@ function PushRow() { export default function SettingsView({ page, onPage, onClose, theme, onToggleTheme, clis, info, onShowWelcome, demoMode, onToggleDemo, + onOpenSharedTrace, }: { page: Page; onPage: (p: Page) => void; @@ -128,10 +129,32 @@ export default function SettingsView({ clis: Cli[]; info: Info | null; onShowWelcome?: () => void; + /** Pull a session someone shared as a Hub dataset and open it as a trace. */ + onOpenSharedTrace?: (repo: string) => Promise; demoMode?: boolean; onToggleDemo?: () => void; }) { const [relaunch, setRelaunch] = useState<{ busy?: boolean; msg?: string; confirm?: boolean }>({}); + const [sharedTrace, setSharedTrace] = useState(''); + const [sharedTraceBusy, setSharedTraceBusy] = useState(false); + const [sharedTraceErr, setSharedTraceErr] = useState(null); + // Failures here are ordinary and specific (no access, not a share, no token), + // so show what the server said: the fix is usually a different id. + const openSharedTrace = async () => { + const repo = sharedTrace.trim(); + if (!repo || !onOpenSharedTrace) return; + setSharedTraceBusy(true); + setSharedTraceErr(null); + try { + await onOpenSharedTrace(repo); + setSharedTrace(''); + onClose(); + } catch (e) { + setSharedTraceErr(e instanceof Error ? e.message : 'could not open that trace'); + } finally { + setSharedTraceBusy(false); + } + }; const [secretKeys, setSecretKeys] = useState([]); const [notes, setNotes] = useState>({}); const [savedNotes, setSavedNotes] = useState>({}); @@ -269,6 +292,38 @@ export default function SettingsView({ + {/* Opening someone else's shared session. This used to be a widget + in the sidebar, which is a lot of standing furniture for + something you do when a link arrives — and the sidebar is for + agents that exist. It is the one thing the Files pane's trace + viewer cannot do (that reads files in the workspace; this pulls a + dataset off the Hub), so it moved rather than went away. */} + {onOpenSharedTrace && ( +
+
+
Open a shared trace
+
+ Paste the dataset id or URL someone sent you. Private and gated shares work + too — this Space downloads with its own token. +
+ { setSharedTrace(e.target.value); setSharedTraceErr(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') openSharedTrace(); }} + /> + {sharedTraceErr &&
{sharedTraceErr}
} +
+ +
+ )} + {onShowWelcome && (
Welcome guide
A quick tour of how the tool works.
diff --git a/web/src/components/ShareDialog.tsx b/web/src/components/ShareDialog.tsx index 2aa8474..8796f50 100644 --- a/web/src/components/ShareDialog.tsx +++ b/web/src/components/ShareDialog.tsx @@ -133,6 +133,19 @@ export default function ShareDialog({ session, onClose }: { session: Session; on
+ {/* Publishing is not the only reason to want the transcript. This + saves the file to the operator's own device and puts it + nowhere else, so it is offered even when sharing is refused — + a session with a credential in it is exactly one you may want + to read locally. */} + {info?.reason !== 'no-transcript' && ( + Download transcript + )} : } - {SHAREABLE_CLIS.includes(s.cli) && ( + {isShareable(s.cli) && ( <> @@ -716,40 +690,8 @@ export default function Sidebar({ - {/* A trace pane can't be created blank, so it isn't a quick-add: this - asks WHICH shared trace, then opens the pane on it. */} -
- {openTraceRepo !== null && ( -
-
- Dataset - setOpenTraceRepo(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') submitSharedTrace(); if (e.key === 'Escape') setOpenTraceRepo(null); }} - /> -
- {openTraceErr &&
{openTraceErr}
} -
- Private and gated shares work too — this Space downloads with its own token. -
-
- - -
-
- )} -
working idle diff --git a/web/src/components/TerminalPane.tsx b/web/src/components/TerminalPane.tsx index f1d1bd7..a951999 100644 --- a/web/src/components/TerminalPane.tsx +++ b/web/src/components/TerminalPane.tsx @@ -6,13 +6,14 @@ import { ClipboardAddon, Base64 } from '@xterm/addon-clipboard'; import { WebLinksAddon } from '@xterm/addon-web-links'; import '@xterm/xterm/css/xterm.css'; import type { Cli, Session } from '../types'; -import { STATE_LABEL } from '../types'; +import { STATE_LABEL, isRemote } from '../types'; import Logo from './Logo'; +import TraceInfo from './TraceInfo'; import ConversationView from './conversation/ConversationView'; import { isPassive } from '../types'; import type { PaneMode } from '../lib/paneMode'; import { groupLabel, sessionTitle } from '../lib/sessionTitle'; -import { BackGlyph, CloseGlyph, RefreshGlyph } from './icons'; +import { BackGlyph, CloseGlyph, RefreshGlyph , SearchGlyph } from './icons'; import * as api from '../api'; import type { Attachment } from '../api'; import { @@ -190,6 +191,7 @@ if (typeof window !== 'undefined') { export default function TerminalPane({ session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', readerEnabled, readerReadyKey, onReaderReady, dragId, isMobile, groupName, onBack, onDragActive, onFocus, onRename, onClose, + onShare, }: { session: Session; cli?: Cli; @@ -203,6 +205,7 @@ export default function TerminalPane({ readerEnabled?: boolean; // focused reader paints before visible followers readerReadyKey?: string; // visible batch whose first paint is being awaited onReaderReady?: () => void; + onShare?: () => void; // reader info panel: publish this session dragId?: string; // set when the pane can be rearranged (group view) isMobile?: boolean; // show the on-screen control-key bar onBack?: () => void; // mobile: leave the pane for the list (see .ph-back) @@ -258,10 +261,36 @@ export default function TerminalPane({ const [imageDrop, setImageDrop] = useState(false); const [imageStatus, setImageStatus] = useState<{ kind: 'uploading' | 'success' | 'error'; text: string } | null>(null); const [imageUploadBusy, setImageUploadBusy] = useState(false); + // The reader's search bar is hidden until asked for; the header owns the + // switch because the icon that reveals it lives there. + const [searchOpen, setSearchOpen] = useState(false); + // Who the header's paperclip talks to. The reader registers its own opener + // (its files go into the composer's draft); otherwise it is the terminal's. + const [readerAttach, setReaderAttach] = useState<{ open: () => void; disabled: boolean; reason?: string } | null>(null); + + // What the reader already knows about this conversation. The header's `i` + // takes it as a gift when the reader is mounted, and reads the file itself + // only when it is not (a terminal pane has parsed nothing). + const [readerFacts, setReaderFacts] = useState(null); + const [readerLoaded, setReaderLoaded] = useState(undefined); const [pendingInsert, setPendingInsert] = useState([]); const supportsAttachments = session.cli !== 'shell'; const canAttachFiles = supportsAttachments && conn === 'connected' && hasInputControl && !imageUploadBusy && pendingInsert.length === 0; + // One paperclip, whichever view is showing. The reader's registration wins + // while it is mounted: its files land in the composer's draft, which is the + // thing the button is for. + const attach = reading + ? readerAttach + : (supportsAttachments ? { + open: () => imagePickerRef.current?.click(), + disabled: !canAttachFiles, + reason: conn === 'connected' + ? (!hasInputControl + ? 'Interact with the terminal to take control before attaching files' + : (pendingInsert.length ? 'Retry the saved file first' : 'Attach files')) + : 'Restart or reconnect the agent to attach files', + } : null); const commitName = () => { const v = draft.trim(); if (v && v !== session.name) onRename?.(v); @@ -1037,7 +1066,11 @@ export default function TerminalPane({ )} - + {/* Where the agent runs, beside what it is. It was on the right, in + among the controls; it is not a control. Still hidden on a phone — + moving it did not create room — where the `i` panel carries it + instead (see TraceInfo's Folder line). */} + {pathLabel}
{editing ? ( { setDraft(session.name); setEditing(true); }} > + {/* The state mark reads as part of the name now: what this agent is + doing, immediately left of who it is, both centred together. */} + {group && [{group}]} {session.name} )}
- {pathLabel} + {/* One style for all four: no boxes, one size, even spacing (.ph-btn). + The attachment picker belongs to whichever view is showing — the + terminal's insert flow, or the reader's composer, which registers + its own opener below. */} + {attach && ( + + )} {supportsAttachments && !reading && ( - <> - - { - uploadImagesRef.current(Array.from(event.currentTarget.files || [])); - event.currentTarget.value = ''; - }} - /> - + { + uploadImagesRef.current(Array.from(event.currentTarget.files || [])); + event.currentTarget.value = ''; + }} + /> + )} + {/* Search searches the TRANSCRIPT, so it is offered where there is one + to search. Closing it clears the query — a search filters the + reader to matching turns, and leaving that filter in place with no + visible search box is a reader that looks broken. */} + {reading && ( + + )} + {!isRemote(session.cli) && ( + )} - +
{/* `reading` releases the frame's touch-action: the phone rule pins it to @@ -1118,7 +1179,16 @@ export default function TerminalPane({ > {readerEnabled === false ?
reading the trace…
- : } + : setSearchOpen(false)} + onAttachPicker={setReaderAttach} + onHead={(head) => { setReaderFacts(head); setReaderLoaded(head?.loaded); }} + onReady={onReaderReady} + readyKey={readerReadyKey} + />} )} diff --git a/web/src/components/TraceInfo.tsx b/web/src/components/TraceInfo.tsx new file mode 100644 index 0000000..591781d --- /dev/null +++ b/web/src/components/TraceInfo.tsx @@ -0,0 +1,180 @@ +import { useEffect, useRef, useState } from 'react'; +import * as api from '../api'; +import type { Session } from '../types'; +import { fmtTok } from './conversation/exchanges'; +import { DownloadGlyph, ShareGlyph } from './icons'; + +/** + * What is true of this conversation, and what you can do with the file behind + * it — behind one `i` in the pane header, so it is in the same place whether you + * are watching the terminal or reading the transcript. + * + * It lives in the header rather than in the reader's toolbar because these are + * facts about the SESSION, not about the reader: a pane that is showing a + * terminal has the same model, the same token total and the same start date, and + * the operator asked for the info to be reachable from both views. One instance + * also means one place to look, one tap target to keep, and — because the reader + * hands its already-loaded head down as `facts` — no second read of the + * transcript when the reader is the view you are in. + */ +const fmtNum = (n: number) => n.toLocaleString(); +const fmtUsage = (u?: { in: number; out: number } | null) => + (u ? `${fmtTok(u.in)}↓ ${fmtTok(u.out)}↑` : ''); +/** The day it started: "14 Aug", with the year when it is not this one. */ +const fmtStarted = (ms: number) => { + const d = new Date(ms); + return d.toLocaleDateString(undefined, d.getFullYear() === new Date().getFullYear() + ? { month: 'short', day: 'numeric' } + : { year: 'numeric', month: 'short', day: 'numeric' }); +}; + +type Load = 'idle' | 'loading' | 'ready' | 'none' | 'error'; + +export default function TraceInfo({ session, facts, turnsLoaded, folder, onShare }: { + session: Session; + /** What the reader already holds. Present only while the reader is the view. */ + facts?: api.TraceSummary | null; + /** Turns the reader is holding, for the window-only state before its summary. */ + turnsLoaded?: number; + /** Where the agent runs. The header shows this beside the logo on a desktop + * and hides it on a phone, where this panel is the only place it is legible. */ + folder?: string; + /** Publish this session — the same dialog the sidebar row opens. */ + onShare?: () => void; +}) { + const [open, setOpen] = useState(false); + const [summary, setSummary] = useState(null); + const [load, setLoad] = useState('idle'); + const wrap = useRef(null); + const abort = useRef(null); + + // A pane switching to another session keeps this component; its facts must not. + useEffect(() => { + abort.current?.abort(); + abort.current = null; + setSummary(null); + setLoad('idle'); + setOpen(false); + }, [session.id]); + useEffect(() => () => abort.current?.abort(), []); + + // The whole-file read happens when the panel is OPENED, and once. A terminal + // pane has no reason to have parsed the transcript, and that parse is the + // expensive one (docs/conversation-view.md §5) — every pane paying for a + // summary nobody asked to see is exactly what the reader's own delay avoids. + const show = () => { + setOpen(true); + if (facts || summary || load === 'loading') return; + setLoad('loading'); + const controller = new AbortController(); + abort.current = controller; + api.getTraceSummary(session.id, controller.signal) + .then((s) => { setSummary(s); setLoad('ready'); }) + .catch((e) => { + if (controller.signal.aborted) return; + setLoad(e instanceof api.TraceUnavailable && e.code === 'no-trace' ? 'none' : 'error'); + }) + .finally(() => { if (abort.current === controller) abort.current = null; }); + }; + + useEffect(() => { + if (!open) return undefined; + const onDown = (e: PointerEvent) => { + if (!wrap.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('pointerdown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('pointerdown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const known = facts ?? summary; + const prompts = known?.userTurns?.length ?? null; + const messages = known?.total ?? null; + // Reading the transcript, or there is nothing to read: say which, rather than + // showing a panel of blanks or a confident row of zeros. + const pending = !known && (load === 'loading' || load === 'idle'); + + return ( +
e.stopPropagation()}> + + {open && ( +
+ {(known || folder) && ( +
+ {folder && (<>
Folder
{folder}
)} + {known?.model && (<>
Model
{known.model}
)} + {known && <>
Turns
+
+ {prompts != null ? `${fmtNum(prompts)} turn${prompts === 1 ? '' : 's'}` : '—'} + {messages != null + ? ` · ${fmtNum(messages)} message${messages === 1 ? '' : 's'}` + : (turnsLoaded != null ? ` · ${fmtNum(turnsLoaded)} loaded` : '')} +
} + {known?.usage && ( + <> +
Tokens
+
+ {fmtUsage(known.usage)} + {known.usage.cacheRead ? ` · ${fmtTok(known.usage.cacheRead)} cached` : ''} +
+ + )} + {!!known?.firstTs && ( + <> +
Started
+
+ {fmtStarted(known.firstTs)} + {' · '}{new Date(known.firstTs).toLocaleString()} +
+ + )} +
+ )} + {pending &&
reading the transcript…
} + {!known && load === 'none' && ( +
+ No transcript yet — this agent has not written one. It appears here once it answers. +
+ )} + {!known && load === 'error' && ( +
Could not read the transcript. Close this and try again.
+ )} + {/* The file itself. Offered once we know there IS one: a session that + has not spoken would answer this link with `no-trace`, and a button + that 404s is worse than a sentence saying why it is not there. */} +
+ {known && ( + setOpen(false)} + > Download + )} + {onShare && ( + + )} +
+
+ )} +
+ ); +} diff --git a/web/src/components/conversation/ConversationView.tsx b/web/src/components/conversation/ConversationView.tsx index ed2f6a3..6541e08 100644 --- a/web/src/components/conversation/ConversationView.tsx +++ b/web/src/components/conversation/ConversationView.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import * as api from '../../api'; import type { TraceTurn } from '../../api'; -import { useTraceWindows, type TraceSource } from '../../lib/traceWindows'; +import { useTraceWindows, type TraceHeadInfo, type TraceSource } from '../../lib/traceWindows'; import type { Session } from '../../types'; import { isRemote } from '../../types'; import { @@ -45,7 +45,10 @@ const fmtStarted = (ms: number) => { : { year: 'numeric', month: 'short', day: 'numeric' }); }; -export default function ConversationView({ session, paused, isMobile, readOnly, onHandover, onReady, readyKey }: { +export default function ConversationView({ + session, paused, isMobile, readOnly, onHandover, searchOpen, onCloseSearch, onAttachPicker, onHead, + onReady, readyKey, +}: { session: Session; /** The pane is off-screen: stop asking the server for a trace nobody sees. */ paused?: boolean; @@ -53,12 +56,23 @@ export default function ConversationView({ session, paused, isMobile, readOnly, /** A trace with no agent behind it — a shared file, an import. Read-only. */ readOnly?: boolean; onHandover?: () => void; + /** The header's search switch. Hidden by default: it is a tool, not furniture. */ + searchOpen?: boolean; + /** Escape in the search box closes it from this side. */ + onCloseSearch?: () => void; + /** Lend the header's paperclip this composer's file picker while mounted. */ + onAttachPicker?: (picker: { open: () => void; disabled: boolean; reason?: string } | null) => void; + /** Hand the conversation's facts to the pane header, whose `i` shows them in + * both views: the reader has already read them, so the header must not. */ + onHead?: (head: TraceHeadInfo | null) => void; /** Called after the first tail page (or its terminal error) has painted. */ onReady?: () => void; /** The visible batch this paint should release. */ readyKey?: string; }) { const [query, setQuery] = useState(''); + const searchBox = useRef(null); + const filePicker = useRef(null); const [hits, setHits] = useState(0); const [hit, setHit] = useState(0); const scroller = useRef(null); @@ -112,6 +126,14 @@ export default function ConversationView({ session, paused, isMobile, readOnly, }); }; + // Closing the search CLEARS it. A query filters the reader to matching turns, + // so a hidden box with a live filter is a reader that looks broken — a + // reviewer read exactly that as the ▼ key being dead. + useEffect(() => { + if (searchOpen) searchBox.current?.focus(); + else setQuery(''); + }, [searchOpen]); + const live = session.state === 'working' && !paused; const src = useMemo(() => ({ @@ -411,43 +433,63 @@ export default function ConversationView({ session, paused, isMobile, readOnly, // anchor effect, so this has the last word on scrollTop. useLayoutEffect(() => { trySeek(); }, [version, trySeek]); + // The paperclip in the header opens THIS input while the reader is mounted: + // the files belong to the composer's draft, which is where the operator is + // typing, so the control moved but the thing it acts on did not. + useEffect(() => { + onAttachPicker?.({ + open: () => filePicker.current?.click(), + disabled: sending || !allowAttachments, + reason: !allowAttachments + ? 'Files are not available for remote agents yet — that agent cannot read files stored on this Space.' + : (sending ? 'Wait for this message to send' : 'Attach files'), + }); + return () => onAttachPicker?.(null); + }, [onAttachPicker, sending, allowAttachments]); + + // The header's `i` shows these; while this reader is mounted it is the one + // that has them, and it keeps them current as the summary and windows land. + useEffect(() => { onHead?.(head); }, [head, onHead]); + useEffect(() => () => onHead?.(null), [onHead]); + if (!head) return
{error || 'reading the trace…'}
; return (
{/* The reader's own controls, on their own row: on a phone the pane header above has no spare width. */} -
- {head.model && {head.model}} - - {fmtNum(exchanges.length)} turn{exchanges.length === 1 ? '' : 's'} - {head.total != null && head.loaded < head.total ? ` of ${fmtNum(head.total)} messages` : ''} - - {head.usage && ( - - {fmtUsage(head.usage)} - - )} - {/* When the conversation started. It used to be printed under the - composer, and briefly lived on the tooltip above — which a phone - cannot open at all, so on the device these items were filed from the - fact was nowhere. Here it is text among the other conversation-level - facts, and the bar wraps at any width, so it costs no height: the - reader body measures the same at 320 and 390 with it as without. */} - {head.firstTs != null && ( - - {fmtStarted(head.firstTs)} + {/* Search, and the turn keys that walk its hits — revealed by the header's + search switch, and gone otherwise. The conversation's facts left this + bar for the header's `i`; with search closed there is no bar at all, + which is a row of reading height back on a phone. */} + {searchOpen && ( +
+ + + - )} - - - - - - setQuery(e.target.value)} /> - {q && {hits ? `${hit + 1}/${hits}` : '0'}} -
+ setQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') onCloseSearch?.(); }} + /> + {q && {hits ? `${hit + 1}/${hits}` : '0'}} +
+ )} + { + addAttachments(Array.from(event.currentTarget.files || [])); + event.currentTarget.value = ''; + }} + />
0} above={ ( ); +/** The header's search switch: a lens, at the same weight as the close cross. */ +export const SearchGlyph = ({ className }: { className?: string }) => ( + + + + +); + export const CloseGlyph = ({ className }: { className?: string }) => ( diff --git a/web/src/conversation.css b/web/src/conversation.css index 37bf7be..da4916f 100644 --- a/web/src/conversation.css +++ b/web/src/conversation.css @@ -236,11 +236,16 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); } font-size: 0.81em; color: var(--muted); } .cxv-bar .spacer { flex: 1; } -.cxv-chip { padding: 0 5px; border: 1px solid var(--border); border-radius: var(--r-sm); white-space: nowrap; } -.cxv-count, .cxv-tok, .cxv-when { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } -/* the day it started — the last of the bar's facts to give up room */ -.cxv-when { flex: 0 1 auto; } -.cxv-tok { flex: 0 1 auto; } +.cxv-when { font-variant-numeric: tabular-nums; } + +/* The facts panel. Anchored to the `i` in the bar's leading corner, and it + OVERLAYS the conversation instead of pushing it down: opening it must not + move the text under the reader's thumb. */ +.cxv-bar .cxv-mini { + flex: none; min-width: 1.75em; height: 1.75em; + display: inline-flex; align-items: center; justify-content: center; + padding: 0 4px; border-radius: var(--r-sm); +} .cxv-mini { flex: none; white-space: nowrap; background: none; border: 1px solid transparent; border-radius: var(--r-sm); padding: 1px 5px; font: inherit; font-size: 1em; color: var(--muted); cursor: pointer; diff --git a/web/src/styles.css b/web/src/styles.css index c72e183..a4034eb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -462,6 +462,16 @@ body { .ov-composer .image-attachments.has-note { grid-column: 1 / -1; } .ov-composer .image-attachments.has-images + .ov-live, .ov-composer .image-attachments.has-note + .ov-live { grid-column: 1 / -1; } +/* The reply row takes the LAST column, always — it is never the thing column 1 + is for. Column 1 holds the paperclip, and only the Overview card still draws + one: the reader's moved to the pane header (2026-08-18), so `Attachments` + renders nothing there until a file is attached and the composer had a single + child. Auto-placed, that child landed in the `auto` track and sized itself to + its own content — a 210px reply line in an 872px composer, with the send key + marooned 650px from the right edge. Naming the column fixes both shapes at + once: in the card it sits beside the paperclip as before, and in the reader + the empty `auto` track measures 0 and this fills the row. */ +.ov-composer > .ov-live { grid-column: 2 / -1; } .ov-composer .ov-live { border-top: none; padding-top: 0; } .ov-live { display: flex; gap: 8px; align-items: flex-start; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; } /* Each control is centred on the first line rather than on the whole box. The @@ -758,13 +768,7 @@ body { already reachable with a long enough agent name; putting the group in front of the name made it reachable at ordinary ones. With the floor the title is capped at what is actually free and ellipsises instead. */ -/* Both paddings are variables because .ph-back is laid out against them: it - cancels the vertical one to reach the header's full height, and it spends the - horizontal one as its own left padding so its hit area fills the gutter. A - bleed that restated either number would drift the first time this padding - changed — the same way a hardcoded 14px against an 8px gutter gave the phone - a sideways scroll. */ -.pane-head { --ph-pad-y: 7px; --ph-pad-x: 11px; container-type: inline-size; display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(0, auto) minmax(min-content, 1fr); align-items: center; gap: 9px; padding: var(--ph-pad-y) var(--ph-pad-x); background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; flex: none; } +.pane-head { --ph-pad-y: 7px; --ph-pad-x: 11px; position: relative; container-type: inline-size; display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(0, auto) minmax(min-content, 1fr); align-items: center; gap: 9px; padding: var(--ph-pad-y) var(--ph-pad-x); background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; flex: none; } .pane-head .ph-left { grid-column: 1; justify-self: start; display: inline-flex; align-items: center; gap: 7px; } .pane-head .ph-title { grid-column: 2; min-width: 0; max-width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 5px; font-family: var(--font-mono); font-weight: 600; white-space: nowrap; overflow: hidden; cursor: text; } /* `[Group] name` is one title in two parts, and the group is the only part that @@ -779,10 +783,79 @@ body { bounds it: it can fill the whole title but no more, which is what puts the ellipsis on the name itself when the name alone is too long for the header. */ .pane-head .ph-name { flex: 0 0 auto; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 6px; } +.pane-head .ph-right { grid-column: 3; min-width: 0; display: inline-flex; align-items: center; justify-self: end; gap: 8px; } +/* Attach, search, info, close: ONE contract, so they cannot drift apart the way + the `i` and the reader's nav keys did. No box — the operator asked for the + bounding box off the `i`, and a row where one of four is boxed is worse than + either — so the hit area is an overlay instead (below), which is why these are + 22px of ink in a 28px target. The 8px gap is what makes that target real: at + 4px the overlays of neighbouring buttons met over each other's ink and each + one measured 20px across, under the floor. Measured, not guessed. */ +.pane-head .ph-btn { + position: relative; flex: none; width: 22px; height: 22px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + border: 0; border-radius: var(--r-sm); background: none; color: var(--muted); + font: 700 12px/1 var(--font-mono); cursor: pointer; +} +.pane-head .ph-btn > svg { width: 14px; height: 14px; } +.pane-head .ph-btn:hover:not(:disabled) { color: var(--text); background: var(--border); } +.pane-head .ph-btn:disabled { opacity: 0.38; cursor: not-allowed; } +.pane-head .ph-btn.on { color: var(--accent); } +/* The tap target, which is not the ink. WCAG 2.2 SC 2.5.8 floors a target at + 24x24 and iOS asks for 44; these are the only route to attach, search, the + conversation's facts and close, on a phone. Pinned by hit-testing in + server/reader-info.test.mjs — all four, in both views. */ +.pane-head .ph-btn::before { content: ''; position: absolute; inset: -4px -4px; } .pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; } +/* The state mark leads the name, inside the centred title: it never shrinks and + never wraps, so the name gives way first (see .ph-name). */ +.pane-head .ph-title > .status { flex: none; margin-right: 1px; } .pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; } .pane-head .ph-close { justify-self: end; } +/* The conversation's facts, behind one `i` in the pane header — the same place + in the terminal view and the reader (docs/conversation-view.md §3.3). A square + mini-control in the app's --r-sm register, sized like the header's other mini + buttons rather than falling out of its own padding. */ +/* NOT the positioning context for the panel — the header is. The `i` sits a + back-button, a logo and a state dot into the row, so a panel anchored to the + button itself starts ~100px in and runs off the right edge of a phone. */ +/* NOT the positioning context for the panel — the header is. The `i` sits in the + right-hand cluster, so a panel anchored to the button itself would hang off + the far edge of the pane. */ +.tinfo-wrap { flex: none; display: inline-flex; } +.tinfo { + /* Anchored to the pane header — not to the button — so it opens inside the + pane at any width. It hangs from the RIGHT edge now, under the cluster the + `i` belongs to, which is also the side with room on a phone: the left of + that row is a back arrow and a path. */ + position: absolute; z-index: 12; top: calc(100% + 4px); right: 8px; left: auto; + /* Wide enough that the two actions sit side by side on one line each — at a + phone width that is what 78vw buys, and below it they stack rather than + wrap a label in half. */ + min-width: min(19rem, calc(100% - 16px)); max-width: min(23rem, calc(100% - 16px)); + display: flex; flex-direction: column; gap: 8px; padding: 9px 10px; + border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.18); +} +/* Reading the transcript, or nothing to read: the panel says which. A terminal + pane has parsed nothing, so opening this is the first read — and a whole-file + one — which is exactly why it must not pretend to have the answer already. */ +.tinfo-state { color: var(--muted); font-size: 11px; line-height: 1.45; } +.tinfo-facts { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 3px 10px; margin: 0; } +.tinfo-facts dt { color: var(--muted); } +.tinfo-facts dd { margin: 0; color: var(--text); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.tinfo-actions { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 7px; border-top: 1px solid var(--border); } +/* Download is an and Share is a