Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
443 changes: 443 additions & 0 deletions docs/conversation-view.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test:ui": "node terminal-ui.test.mjs",
"test": "node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node migration.test.mjs && node resize.test.mjs"
"test": "node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node migration.test.mjs && node resize.test.mjs"
},
"engines": {
"node": ">=20.19"
Expand Down
6 changes: 5 additions & 1 deletion server/src/traces.js
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,11 @@ function pageOf(parsed, offset, limit) {
// before the page holding it has been fetched.
const userTurns = [];
for (let i = 0; i < total; i++) if (parsed.messages[i].role === 'user') userTurns.push(i);
const from = Math.max(0, Math.min(offset | 0, total));
// A negative offset reads from the END — what any surface showing the tail of
// a conversation wants (the Overview card, RENDER mode) without first making a
// round trip just to learn `total`.
const off = offset | 0;
const from = off < 0 ? Math.max(0, total + off) : Math.max(0, Math.min(off, total));
const to = Math.min(total, from + Math.max(1, Math.min(limit | 0 || 200, 500)));
return {
harness: parsed.harness, harnessLabel: parsed.harnessLabel, sessionId: parsed.sessionId,
Expand Down
60 changes: 60 additions & 0 deletions server/test/trace-tail.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Reading the TAIL of a trace: a negative offset counts back from the end.
//
// The Overview card and RENDER mode both show the end of a conversation. Without
// this they would have to fetch once just to learn `total`, then fetch again —
// two round trips per card, on a FUSE-backed transcript. Run with:
// node test/trace-tail.test.mjs
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import assert from 'node:assert/strict';

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'trace-tail-'));
process.env.DATA_DIR = path.join(TMP, 'data');
fs.mkdirSync(process.env.DATA_DIR, { recursive: true });

const { readTraceByPath } = await import('../src/traces.js');

// A minimal Claude transcript: 12 alternating turns, each one identifiable.
const file = path.join(TMP, 'session.jsonl');
const lines = [];
for (let i = 0; i < 12; i++) {
const user = i % 2 === 0;
lines.push(JSON.stringify({
type: user ? 'user' : 'assistant',
cwd: TMP,
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
message: {
role: user ? 'user' : 'assistant',
content: [{ type: 'text', text: `turn ${i}` }],
},
}));
}
fs.writeFileSync(file, `${lines.join('\n')}\n`);

const textOf = (t) => t.blocks.filter((b) => b.type === 'text').map((b) => b.text).join('');

const all = await readTraceByPath(file, { offset: 0, limit: 200 });
assert.equal(all.total, 12, 'twelve turns were written');

// The tail: the last four turns, in order, without knowing `total` first.
const tail = await readTraceByPath(file, { offset: -4, limit: 4 });
assert.equal(tail.offset, 8, 'a negative offset resolves against the end');
assert.deepEqual(tail.turns.map(textOf), ['turn 8', 'turn 9', 'turn 10', 'turn 11']);

// Asking for more tail than exists starts at the beginning rather than wrapping.
const over = await readTraceByPath(file, { offset: -500, limit: 500 });
assert.equal(over.offset, 0, 'a too-large tail clamps to the start');
assert.equal(over.turns.length, 12);

// Positive offsets are untouched by the change.
const mid = await readTraceByPath(file, { offset: 4, limit: 2 });
assert.equal(mid.offset, 4);
assert.deepEqual(mid.turns.map(textOf), ['turn 4', 'turn 5']);

// `userTurns` indexes the WHOLE conversation, not the page — jumping to a prompt
// has to work before the page holding it has been fetched.
assert.deepEqual(tail.userTurns, [0, 2, 4, 6, 8, 10]);

fs.rmSync(TMP, { recursive: true, force: true });
console.log('trace-tail: ok');
4 changes: 3 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "node test/exchanges.test.mjs",
"preview": "vite preview"
},
"dependencies": {
Expand All @@ -38,8 +39,8 @@
"@codemirror/view": "^6.43.7",
"@lezer/highlight": "^1.2.3",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^5.5.0",
"codemirror": "^6.0.2",
"dompurify": "^3.4.11",
Expand All @@ -53,6 +54,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"playwright": "^1.62.1",
"typescript": "^5.5.3",
"vite": "^5.4.0"
}
Expand Down
32 changes: 31 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import BackupBanner from './components/BackupBanner';
import Welcome from './components/Welcome';
import * as api from './api';
import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
import { isPassive, isRemote } from './types';
import { GridGlyph, ListGlyph } from './components/icons';

Expand Down Expand Up @@ -78,6 +79,11 @@ export default function App() {
// stored — flipping the setting instantly (un)archives.
const [showArchived, setShowArchived] = useState(false);
const [archiveAfter, setArchiveAfter] = useState<'week' | 'month' | 'never'>('month');
// How every pane is read — the terminal itself, or reader mode over the same
// session. App-wide, like zoom, and remembered the same way.
const [paneMode, setPaneMode] = useState(readPaneMode);
useEffect(() => onPaneMode(setPaneMode), []);
const showPaneMode = (m: 'terminal' | 'reader') => { setPaneMode(m); writePaneMode(m); };
const [zoom, setZoom] = useState<number>(() => {
const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
return Number.isFinite(z) ? z : 100;
Expand Down Expand Up @@ -515,7 +521,21 @@ export default function App() {
setActiveRef(`g:${g.id}`);
} catch (e) { showErr('Couldn’t create the group')(e); }
};
const doMove = (ref: string, to: MoveTarget) => api.move(ref, to).then(refresh).catch(showErr('Couldn’t move that'));
// Merging two agents, or dropping one into a group, changes what the pane you
// are looking at IS — it is now part of a grid. Follow it there rather than
// leaving you on a single view of a session that has moved.
const doMove = (ref: string, to: MoveTarget) => api.move(ref, to)
.then(async () => {
const next = await api.getTree().catch(() => null);
if (!next) return refresh();
setTree(next);
const watching = activeRef?.startsWith('s:') ? activeRef.slice(2) : null;
if (!watching) return undefined;
const home = next.groups.find((g) => g.sessionIds.includes(watching));
if (home) setActiveRef(`g:${home.id}`);
return undefined;
})
.catch(showErr('Couldn’t move that'));
const renameGroup = (id: string, name: string) => api.renameGroup(id, name).then(refresh).catch(showErr('Couldn’t rename'));
const renameSession = (id: string, name: string) => { if (name.trim()) api.renameSession(id, name.trim()).then(refresh).catch(showErr('Couldn’t rename')); };
const deleteGroup = (id: string) => api.deleteGroup(id).then(() => { if (activeRef === `g:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the group'));
Expand Down Expand Up @@ -718,6 +738,7 @@ export default function App() {
cli={cliMap[s.cli]}
theme={theme}
zoom={zoom}
mode={paneMode}
focused={shown && sessions.length > 1 && s.id === focusedId}
visible={shown && deckVisible}
active={shown && deckVisible && s.id === focusedId}
Expand Down Expand Up @@ -965,6 +986,15 @@ export default function App() {
</span>
)}
<span className="spacer" />
{/* Reader mode sits with zoom because it is the same kind of
setting: how you are looking at everything, not what any one
pane is. The content is identical either way — this is form. */}
<span className="seg modebar">
<button className={paneMode === 'terminal' ? 'on' : ''} title="The terminal itself"
onClick={() => showPaneMode('terminal')}>terminal</button>
<button className={paneMode === 'reader' ? 'on' : ''} title="Reader mode — the same session, laid out"
onClick={() => showPaneMode('reader')}>reader</button>
</span>
<button className="zbtn" title="Zoom out" onClick={() => setZoom((z) => Math.max(50, z - 10))}>−</button>
<button className="zlvl" title="Reset to 100%" onClick={() => setZoom(100)}>{zoom}%</button>
<button className="zbtn" title="Zoom in" onClick={() => setZoom((z) => Math.min(200, z + 10))}>+</button>
Expand Down
Loading