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
9 changes: 9 additions & 0 deletions .changeset/fix-claude-response-viewer.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

**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.

Expand Down
286 changes: 192 additions & 94 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,180 @@ export function registerSessionRoutes(
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) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
Expand Down Expand Up @@ -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;
let transcriptText = '';
let transcriptTimestamp = '';

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');

// 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 {
// projects dir doesn't exist
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
if (!transcript) {
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 */
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(() => {});
}
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
}

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

function isCodexInjectedContext(text: string): boolean {
Expand Down
Loading