fix(web): normalize Claude response viewer turns

This commit is contained in:
codeman-local
2026-07-25 15:26:34 +08:00
parent 86c634959d
commit bca56b4273
4 changed files with 381 additions and 96 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"aicodeman": patch
---
fix(web): normalize Claude response viewer conversations
Reconnect recovered tmux placeholders to their Claude transcript, filter synthetic
Claude rows, merge multi-block assistant turns, and remove replayed snapshots so
the response viewer shows one clean card per real conversation turn.
+1 -1
View File
@@ -220,7 +220,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`. **Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.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. **Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Claude JSONL is normalized at real-human boundaries so tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed snapshots, and multi-block assistant output do not create duplicate/broken cards; a recovered `restored-<uuid8>` tmux placeholder can rebind to the matching top-level Claude transcript even when its recovered cwd is wrong (`test/routes/session-routes-claude-last-response.test.ts`). Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup (`test/routes/session-routes-codex-last-response.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 likewise **hidden by default**: enable under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, also in the per-device `displayKeys` set). Purely client-side like the response viewer: the template ships `btn-file-viewer--hidden` and `applyHeaderVisibilitySettings()` toggles the marker class after settings load. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). 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 → Display → Header Displays); cron jobs themselves are unaffected. **File Viewer button** (header, 1.4.1) is likewise **hidden by default**: enable under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, also in the per-device `displayKeys` set). Purely client-side like the response viewer: the template ships `btn-file-viewer--hidden` and `applyHeaderVisibilitySettings()` toggles the marker class after settings load. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). 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 → Display → Header Displays); cron jobs themselves are unaffected.
+193 -95
View File
@@ -1016,6 +1016,180 @@ export function registerSessionRoutes(
return candidateSid; return candidateSid;
} }
interface ClaudeResponseMessage {
role: 'user' | 'assistant';
text: string;
timestamp?: string;
}
interface ClaudeTranscriptEntry {
type?: string;
timestamp?: string;
isMeta?: boolean;
isSidechain?: boolean;
isCompactSummary?: boolean;
message?: { content?: unknown };
}
function extractClaudeText(content: unknown, separator: string): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.filter(
(block): block is { type: string; text: string } =>
!!block &&
typeof block === 'object' &&
(block as { type?: string }).type === 'text' &&
typeof (block as { text?: string }).text === 'string'
)
.map((block) => block.text)
.join(separator);
}
function isClaudeSyntheticUserMessage(entry: ClaudeTranscriptEntry, text: string): boolean {
if (entry.isMeta || entry.isCompactSummary) return true;
return /^(?:<local-command|<command-name>|<task-notification>|<system-reminder>|<teammate-message\b|Another Claude session sent a message:|Base directory for this skill:)/i.test(
text
);
}
/**
* Claude writes one logical turn as many JSONL rows: text, thinking and tool
* blocks share message ids, while tool results are represented as user rows.
* Build viewer cards from real user boundaries instead of treating every row
* as a separate chat message.
*/
function parseClaudeResponseTranscript(
content: string,
full: boolean
): { text: string; timestamp: string; messages?: ClaudeResponseMessage[] } {
let lastText = '';
let lastTimestamp = '';
const messages: ClaudeResponseMessage[] = [];
let currentUserFragments = new Set<string>();
let currentAssistantFragments = new Set<string>();
for (const line of content.split('\n')) {
if (!line) continue;
let entry: ClaudeTranscriptEntry;
try {
entry = JSON.parse(line) as ClaudeTranscriptEntry;
} catch {
continue;
}
// Sidechains belong to agents/forks, not the main conversation. Meta user
// rows include repeated image dimensions and other UI-generated context.
if (entry.isSidechain) continue;
if (entry.type === 'user') {
const text = extractClaudeText(entry.message?.content, '\n').trim();
// A tool_result block has no text block and naturally drops out here.
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
if (!full) continue;
const previous = messages.at(-1);
if (previous?.role === 'user') {
// Claude can replay the initial user row while restoring a transcript.
// Only collapse duplicates within the same unanswered user turn; the
// same prompt after an assistant response remains a legitimate turn.
if (currentUserFragments.has(text)) continue;
previous.text += `\n\n${text}`;
currentUserFragments.add(text);
} else {
messages.push({ role: 'user', text, timestamp: entry.timestamp });
currentUserFragments = new Set([text]);
}
currentAssistantFragments.clear();
continue;
}
if (entry.type !== 'assistant') continue;
const text = extractClaudeText(entry.message?.content, '\n\n').trim();
if (!text) continue;
lastText = text;
lastTimestamp = entry.timestamp || '';
if (!full) continue;
const previous = messages.at(-1);
if (previous?.role === 'assistant') {
// Replayed snapshots sometimes repeat an identical text block. Distinct
// progress/final blocks are kept, but remain inside one Claude card.
if (currentAssistantFragments.has(text)) continue;
previous.text += `\n\n${text}`;
previous.timestamp = entry.timestamp || previous.timestamp;
currentAssistantFragments.add(text);
} else {
messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
currentAssistantFragments = new Set([text]);
}
currentUserFragments.clear();
}
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
}
/** Locate a top-level Claude transcript, including recovered tmux sessions. */
async function findClaudeTranscript(
projectsDir: string,
conversationId: string,
codemanSessionId: string
): Promise<{ sessionId: string; path: string } | null> {
let projectDirs: import('node:fs').Dirent[];
try {
projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
} catch {
return null;
}
const safeIds = [...new Set([conversationId, codemanSessionId])].filter((value) => /^[a-zA-Z0-9._-]+$/.test(value));
for (const candidateId of safeIds) {
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue;
const jsonlPath = join(projectsDir, projectDir.name, `${candidateId}.jsonl`);
try {
const stat = await fs.stat(jsonlPath);
if (stat.isFile()) return { sessionId: candidateId, path: jsonlPath };
} catch {
/* continue */
}
}
}
// If mux-sessions.json was lost or stale, reconcileSessions() historically
// recovered `codeman-40568a29` as `restored-40568a29` and used the server cwd.
// The tmux name still carries the first eight UUID characters, which safely
// reconnects the viewer when exactly one matching top-level transcript exists.
const restoredMatch = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId);
if (!restoredMatch) return null;
const fragment = restoredMatch[1].toLowerCase();
const candidates: Array<{ sessionId: string; path: string; mtimeMs: number }> = [];
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue;
const dirPath = join(projectsDir, projectDir.name);
let files: import('node:fs').Dirent[];
try {
files = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
continue;
}
for (const file of files) {
if (!file.isFile() || !file.name.endsWith('.jsonl')) continue;
const candidateId = file.name.slice(0, -'.jsonl'.length);
if (!candidateId.toLowerCase().startsWith(fragment) || !uuidPattern.test(candidateId)) continue;
const path = join(dirPath, file.name);
const stat = await fs.stat(path).catch(() => null);
if (stat) candidates.push({ sessionId: candidateId, path, mtimeMs: stat.mtimeMs });
}
}
const candidateIds = new Set(candidates.map((candidate) => candidate.sessionId));
if (candidateIds.size !== 1) return null;
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates[0] ?? null;
}
app.get('/api/sessions/:id/last-response', async (req) => { app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req); const session = findSessionOrFail(ctx, id, req);
@@ -1045,106 +1219,30 @@ export function registerSessionRoutes(
} }
} }
// The Claude conversation ID (used as JSONL filename) const query = req.query as { context?: string };
const claudeSessionId = session.claudeSessionId || session.id; const claudeSessionId = session.claudeSessionId || session.id;
let transcriptText = ''; const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
let transcriptTimestamp = ''; if (!transcript) {
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
}
if (transcript.sessionId !== session.claudeSessionId && transcript.sessionId !== session.id) {
session.adoptClaudeSessionId(transcript.sessionId);
if (session.docker) {
void persistDockerCaseClaudeSessionId(
CODEMAN_CONFIG_DIR,
session.docker.containerName,
transcript.sessionId
).catch(() => {});
}
}
try { try {
const projectDirs = await fs.readdir(projectsDir); const content = await fs.readFile(transcript.path, 'utf8');
for (const projDir of projectDirs) { return parseClaudeResponseTranscript(content, query.context === 'full');
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
try {
const content = await fs.readFile(jsonlPath, 'utf8');
const lines = content.trim().split('\n');
// Search from end for last assistant message with text
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.type === 'assistant' && entry.message?.content) {
const blocks = Array.isArray(entry.message.content)
? entry.message.content
: [{ type: 'text', text: String(entry.message.content) }];
const textBlocks = blocks
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
.map((b: { type: string; text?: string }) => b.text);
if (textBlocks.length > 0) {
transcriptText = textBlocks.join('\n\n');
transcriptTimestamp = entry.timestamp || '';
break;
}
}
} catch {
// Skip unparseable lines
}
}
if (transcriptText) break; // Found it, stop scanning directories
} catch {
// File doesn't exist in this project dir, continue
}
}
} catch { } catch {
// projects dir doesn't exist return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
} }
// If ?context=full, return all user+assistant messages for conversation view
const query = req.query as { context?: string };
if (query.context === 'full' && transcriptText) {
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
try {
const content = await fs.readFile(jsonlPath, 'utf8');
const lines = content.trim().split('\n');
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
const text =
typeof entry.message.content === 'string'
? entry.message.content
: (entry.message.content as Array<{ type: string; text?: string }>)
.filter((b) => b.type === 'text' && b.text)
.map((b) => b.text)
.join('\n');
// Skip system/command messages
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
}
} else if (entry.type === 'assistant' && entry.message?.content) {
const blocks = Array.isArray(entry.message.content)
? entry.message.content
: [{ type: 'text', text: String(entry.message.content) }];
const text = blocks
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
.map((b: { type: string; text?: string }) => b.text)
.join('\n\n');
if (text) {
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
}
}
} catch {
/* skip */
}
}
if (allMessages.length > 0) break;
} catch {
/* continue */
}
}
} catch {
/* ignore */
}
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
}
return {
text: transcriptText,
timestamp: transcriptTimestamp,
};
}); });
function isCodexInjectedContext(text: string): boolean { function isCodexInjectedContext(text: string): boolean {
@@ -0,0 +1,178 @@
/**
* @fileoverview Claude transcript normalization tests for the response viewer.
*
* Uses app.inject() with a temporary HOME; no real ports or user transcripts.
* Port: N/A
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createEnvelopeHarness(): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') {
return done(null, payload);
}
const response = payload as { success?: unknown; errorCode?: unknown };
if (response.success === false) {
if (reply.statusCode === 200 && typeof response.errorCode === 'string') {
reply.code(httpStatusForErrorCode(response.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (response.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
const userEntry = (text: string, extras: Record<string, unknown> = {}) => ({
type: 'user',
timestamp: '2026-07-21T00:00:00Z',
message: { content: [{ type: 'text', text }] },
...extras,
});
const assistantEntry = (text: string, timestamp: string) => ({
type: 'assistant',
timestamp,
message: { content: [{ type: 'text', text }] },
});
describe('GET /api/sessions/:id/last-response (claude)', () => {
let harness: LocalHarness;
let testHome: string;
let previousHome: string | undefined;
beforeEach(async () => {
testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-rv-'));
previousHome = process.env.HOME;
process.env.HOME = testHome;
harness = await createEnvelopeHarness();
});
afterEach(async () => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
rmSync(testHome, { recursive: true, force: true });
await harness.app.close();
});
function writeTranscript(sessionId: string, entries: unknown[]): void {
const projectDir = join(testHome, '.claude', 'projects', '-workspace');
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, `${sessionId}.jsonl`), entries.map((entry) => JSON.stringify(entry)).join('\n'));
}
async function getLastResponse(sessionId: string, full = false) {
const response = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/last-response${full ? '?context=full' : ''}`,
});
return { response, body: JSON.parse(response.body) };
}
it('recovers a placeholder tmux session by UUID prefix and groups JSONL fragments into turns', async () => {
const restoredId = 'restored-40568a29';
const conversationId = '40568a29-d4eb-4eb6-b671-8401428e4f39';
const session = harness.ctx._session as typeof harness.ctx._session & {
claudeSessionId: string;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
harness.ctx.sessions.delete(session.id);
session.id = restoredId;
session.mode = 'claude';
session.workingDir = '/wrong/recovered/cwd';
session.claudeSessionId = restoredId;
session.adoptClaudeSessionId = vi.fn((newId: string) => {
session.claudeSessionId = newId;
});
harness.ctx.sessions.set(restoredId, session);
writeTranscript(conversationId, [
userEntry('first prompt'),
userEntry('first prompt'), // restore replay before any assistant output
{ type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'hidden' }] } },
assistantEntry('Checking the files.', '2026-07-21T00:00:01Z'),
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-1' }] } },
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tool-1' }] } },
assistantEntry('Checking the files.', '2026-07-21T00:00:02Z'), // replayed snapshot
assistantEntry('The first result is ready.', '2026-07-21T00:00:03Z'),
userEntry('[Image dimensions generated by the CLI]', { isMeta: true }),
userEntry('<command-name>/status</command-name>'),
userEntry('Another Claude session sent a message: <teammate-message>done</teammate-message>'),
userEntry('<task-notification>background agent completed</task-notification>'),
userEntry('This session is being continued from a previous conversation', { isCompactSummary: true }),
userEntry('second prompt'),
assistantEntry('First half.', '2026-07-21T00:00:04Z'),
{ ...assistantEntry('sidechain text', '2026-07-21T00:00:05Z'), isSidechain: true },
assistantEntry('Second half.', '2026-07-21T00:00:06Z'),
]);
const full = await getLastResponse(restoredId, true);
expect(full.response.statusCode).toBe(200);
expect(full.body.data).toEqual({
text: 'Second half.',
timestamp: '2026-07-21T00:00:06Z',
messages: [
{ role: 'user', text: 'first prompt', timestamp: '2026-07-21T00:00:00Z' },
{
role: 'assistant',
text: 'Checking the files.\n\nThe first result is ready.',
timestamp: '2026-07-21T00:00:03Z',
},
{ role: 'user', text: 'second prompt', timestamp: '2026-07-21T00:00:00Z' },
{ role: 'assistant', text: 'First half.\n\nSecond half.', timestamp: '2026-07-21T00:00:06Z' },
],
});
expect(session.adoptClaudeSessionId).toHaveBeenCalledWith(conversationId);
const brief = await getLastResponse(restoredId);
expect(brief.body.data).toEqual({ text: 'Second half.', timestamp: '2026-07-21T00:00:06Z' });
});
it('keeps an identical user prompt when it occurs again after an assistant response', async () => {
const sessionId = harness.ctx._session.id;
const session = harness.ctx._session as typeof harness.ctx._session & {
claudeSessionId: string;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
session.claudeSessionId = sessionId;
session.adoptClaudeSessionId = vi.fn();
writeTranscript(sessionId, [
userEntry('continue'),
assistantEntry('First answer.', '2026-07-21T00:00:01Z'),
userEntry('continue'),
assistantEntry('Second answer.', '2026-07-21T00:00:02Z'),
]);
const { body } = await getLastResponse(sessionId, true);
expect(body.data.messages.map((message: { role: string; text: string }) => [message.role, message.text])).toEqual([
['user', 'continue'],
['assistant', 'First answer.'],
['user', 'continue'],
['assistant', 'Second answer.'],
]);
});
});