Skip to content
Open
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
15 changes: 15 additions & 0 deletions .changeset/fix-claude-viewer-brief-last-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"aicodeman": patch
---

fix(web): show the whole last turn in the Claude response viewer's brief view

The eye button rendered `text`, which is one row — the last assistant row — while a
Claude answer is a median of 3 model messages (p90 11) split around tool calls, so the
brief view usually showed the tail of an answer ("Done.") and the substance only after
More. The brief view now asks `GET /api/sessions/:id/last-response?context=turn`, which
returns the assistant messages of the last answered turn, and renders them the way the
full view renders that turn: one badge, then continuation segments. `text` stays the last
assistant row in every mode (agent pollers hash it), a prompt queued after the answer does
not blank the view, and readers without turns (Codex, the pane parser, an older server)
keep their single card.
2 changes: 1 addition & 1 deletion docs/architecture-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se

⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.

⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.

### Session list layout (header strip vs. left sidebar)
Expand Down
1 change: 1 addition & 0 deletions skills/codeman/reference/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ than into an existing checkout.
| send input | `POST /api/v1/sessions/:id/input` |
| **read a worker's answer** (claude/codex/deepseek) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
| read the whole conversation | `GET /api/v1/sessions/:id/last-response?context=full` → `.data.messages[]`. ⚠️ **Only `{role,text}` is present for every mode.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane parser (which also emit `status`/`tool`) but NOT from codex; `timestamp` from claude and codex but not deepseek/pane; `turn` and `queued:true` (a prompt typed while the agent was working) from claude only. `.data.text` is unchanged by `context=full` — it stays the last assistant message, never `messages[-1]` |
| read the last **answered turn** (claude only) | `GET /api/v1/sessions/:id/last-response?context=turn` → `.data.messages[]` holds every assistant message of the most recent turn that has one (the whole answer, not just its final row); `.data.text` is still the last assistant row. Other modes answer `text` only, with no `messages` |
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer`, for *diagnosis* (unsubmitted prompt?), not for reading answers |
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
Expand Down
28 changes: 25 additions & 3 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2325,10 +2325,19 @@ class CodemanApp {

if (!this.activeSessionId) return;
try {
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
// Source 1: Transcript JSONL (best quality — clean structured text from Claude).
// `context=turn` asks for the last ANSWERED turn as messages: a Claude
// answer is a median of 3 model messages (p90 11), and `text` alone is
// only the final one — usually a "Done." tail with the substance in the
// rows before it. Readers that know no `turn` context (Codex, the pane
// parser, an older server) answer with `text` only, and that path is
// unchanged below.
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=turn`);
const data = (await res.json())?.data ?? {};
let lastResponse = data.text || '';
const turnMessages = (Array.isArray(data.messages) ? data.messages : []).filter(
(msg) => msg && msg.role === 'assistant' && typeof msg.text === 'string' && msg.text.trim()
);

// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
// Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
Expand All @@ -2345,7 +2354,20 @@ class CodemanApp {
}

const body = document.getElementById('responseViewerBody');
if (lastResponse) {
if (turnMessages.length > 0) {
// The whole last turn, rendered exactly as the full view renders that
// turn: one badge, then badge-less continuation segments. The same
// numeric-`turn` gate as loadFullContext, never same-role adjacency.
const agentLabel = this._getResponseViewerAgentLabel();
body.innerHTML = '';
let previous = null;
for (const msg of turnMessages) {
const continuation = !!previous && typeof msg.turn === 'number' && previous.turn === msg.turn;
body.appendChild(this._buildResponseViewerMessage(msg.text, 'assistant', agentLabel, { ...msg, continuation }));
previous = msg;
}
this._bindResponseViewerInteractions(body);
} else if (lastResponse) {
// Keep the brief view inside the same message wrapper as the full
// conversation view. The wrapper supplies the card, role badge and
// descendant markdown styles that direct body children do not get.
Expand Down
27 changes: 27 additions & 0 deletions src/web/response-viewer-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,30 @@ export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[
}
return '';
}

/**
* The messages of the most recent turn that has an answer: every assistant
* message whose `turn` matches the highest turn any assistant message carries.
*
* This is what the viewer's brief ("Last Response") view renders for Claude.
* The brief `text` is one row — the last assistant row — and a Claude turn is
* a median of 3 rows (p90 11), so that row alone was usually the tail of the
* answer ("Done.") with the substance in the rows before it. Reading the whole
* turn gives the same cards the full view shows for it, and no more.
*
* Deliberately NOT "everything after the last user message": a prompt queued
* while the agent works opens a new, still-unanswered turn, and the honest
* brief view is then the previous, answered one — exactly the row `text`
* already points at. Messages without a numeric `turn` (Codex, the pane
* parser, an older reader) yield an empty list so callers fall back to `text`.
*/
export function selectLastAnsweredTurn<T extends { role: string; turn?: number }>(messages: T[]): T[] {
let latest = -1;
for (const message of messages) {
if (message.role === 'assistant' && typeof message.turn === 'number' && message.turn > latest) {
latest = message.turn;
}
}
if (latest < 0) return [];
return messages.filter((message) => message.role === 'assistant' && message.turn === latest);
}
19 changes: 16 additions & 3 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import {
getLastTranscriptResponse,
isExternalCliTranscriptMode,
parseExternalCliTranscript,
selectLastAnsweredTurn,
} from '../response-viewer-transcript.js';
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';

Expand Down Expand Up @@ -2214,10 +2215,14 @@ export function registerSessionRoutes(
}

const query = req.query as { context?: string };
// `turn` is the brief view's context: the last ANSWERED turn's assistant
// messages, so a multi-row answer is not reduced to its final row. `text`
// stays the last assistant row in every mode (agent pollers hash it).
const wantsMessages = query.context === 'full' || query.context === 'turn';
const claudeSessionId = session.claudeSessionId || session.id;
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
if (!transcript) {
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
}

if (transcript.sessionId !== session.claudeSessionId && transcript.sessionId !== session.id) {
Expand All @@ -2233,9 +2238,17 @@ export function registerSessionRoutes(

try {
const content = await fs.readFile(transcript.path, 'utf8');
return parseClaudeResponseTranscript(content, query.context === 'full');
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
if (query.context === 'turn') {
return {
text: parsed.text,
timestamp: parsed.timestamp,
messages: selectLastAnsweredTurn(parsed.messages ?? []),
};
}
return parsed;
} catch {
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
}
});

Expand Down
Loading