From 84e4578d1f07b85abfcc06cae3862c39cd374cfb Mon Sep 17 00:00:00 2001 From: Mohammad Alsayyedan Date: Sun, 20 Sep 2026 21:30:09 -0500 Subject: [PATCH 1/3] Load inline screenshots from Claude Code sessions The Coding Moments gallery counts image-bearing Claude requests but can never display them: every session shows "These sessions referenced images, but the raw screenshots could not be loaded". extractSessionImages dispatches on the .jsonl extension and assumes the VS Code chat-session shape (kind/k/v envelopes, requests[].variableData .variables). Claude Code also writes .jsonl, so its sessions take that branch, match nothing and return an empty array. Claude stores images inline on the user turn instead, as message.content[].source = { type: 'base64', media_type, data }. Add extractClaudeImagesFromJsonl and fall back to it when the VS Code shape yields nothing. Matching is exact rather than heuristic: the Claude parser already uses the entry uuid as the request id, so the originating line can be located directly. The existing four-image cap per request is preserved. Verified against a real log set: 247 of 247 image-bearing requests now resolve, where none did before. --- src/core/parser-claude.test.ts | 46 ++++++++++++++++++++++++++++++++- src/core/parser-claude.ts | 33 +++++++++++++++++++++++ src/core/parser-vscode-files.ts | 6 ++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index 588b33bf..f92c2c04 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -13,7 +13,7 @@ import * as path from 'path'; import { execSync } from 'child_process'; import { describe, it, expect } from 'vitest'; import { EditLocIndex } from './edit-loc-diff'; -import { parseClaudeSessions } from './parser-claude'; +import { parseClaudeSessions, extractClaudeImagesFromJsonl } from './parser-claude'; /** os.tmpdir() on Windows often returns 8.3 short names (e.g. TAMASB~1) * that don't match readdirSync output. Resolve to the long form so @@ -384,6 +384,50 @@ describe('parseClaudeSessions', () => { } }); + + // ---- inline image extraction ---- + + it('extracts base64 images from a Claude user turn by request id', () => { + const png = 'iVBORw0KGgoAAAANSUhEUg=='; + const line = JSON.stringify({ + type: 'user', + timestamp: '2025-06-15T10:00:00Z', + sessionId: 'sess-1', + uuid: 'req-with-image', + message: { + role: 'user', + content: [ + { type: 'text', text: 'what is wrong here?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }, + ], + }, + }); + const other = JSON.stringify({ type: 'user', uuid: 'other-req', message: { role: 'user', content: [] } }); + + const images = extractClaudeImagesFromJsonl([other, line].join('\n'), 'req-with-image'); + expect(images).toEqual([`data:image/png;base64,${png}`]); + }); + + it('returns no images for a request id that has none', () => { + const line = JSON.stringify({ + type: 'user', + uuid: 'text-only', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }); + expect(extractClaudeImagesFromJsonl(line, 'text-only')).toEqual([]); + expect(extractClaudeImagesFromJsonl(line, 'missing-id')).toEqual([]); + }); + + it('caps extracted images at four per request', () => { + const img = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }; + const line = JSON.stringify({ + type: 'user', + uuid: 'many-images', + message: { role: 'user', content: [img, img, img, img, img, img] }, + }); + expect(extractClaudeImagesFromJsonl(line, 'many-images')).toHaveLength(4); + }); + // ---- workspace name resolution with spaces ---- it('resolves workspace name when path contains a folder with spaces', { timeout: 30_000 }, () => { diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 73e0ab1f..59e8f194 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -230,6 +230,39 @@ function countClaudeImages(line: ClaudeLine): number { .filter(block => block.type === 'image').length; } +/** + * Extract inline screenshots for one Claude request. Claude Code stores images + * as base64 inside the user turn, and the request id is the entry uuid, so the + * matching line can be found directly. Capped at 4 images to bound memory, the + * same limit the VS Code extractor uses. + */ +export function extractClaudeImagesFromJsonl(raw: string, requestId: string): string[] { + const images: string[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + // Quick reject before the parse: most lines are not the one we want. + if (!trimmed || !trimmed.includes(requestId)) continue; + + let entry: ClaudeLine; + try { + entry = JSON.parse(trimmed) as ClaudeLine; + } catch { + continue; + } + if (entry.uuid !== requestId) continue; + + for (const block of toContentArray(entry.message?.content)) { + if (block.type !== 'image') continue; + const source = (block as { source?: { type?: string; media_type?: string; data?: string } }).source; + if (source?.type !== 'base64' || !source.data) continue; + images.push(`data:${source.media_type || 'image/png'};base64,${source.data}`); + if (images.length >= 4) return images; + } + return images; + } + return images; +} + function applyClaudeToolBlock( block: ClaudeContentBlock, data: Pick, diff --git a/src/core/parser-vscode-files.ts b/src/core/parser-vscode-files.ts index 06a2e578..08aaaf46 100644 --- a/src/core/parser-vscode-files.ts +++ b/src/core/parser-vscode-files.ts @@ -10,6 +10,7 @@ import { StringDecoder } from 'string_decoder'; import { assertTrustedPath, prefetchCache, readFileSafe, recordFailedFile, recordSkippedLines } from './parser-shared'; import { fileUriToPath } from './helpers'; import { debugCore, warnCore } from './log'; +import { extractClaudeImagesFromJsonl } from './parser-claude'; export function readFile(fpath: string): string { assertTrustedPath(fpath); @@ -480,7 +481,10 @@ export function extractSessionImages(filePath: string, requestId: string): strin if (raw === null) return []; if (filePath.endsWith('.jsonl')) { - return extractImagesFromJsonl(raw, requestId); + const images = extractImagesFromJsonl(raw, requestId); + // Claude Code also uses .jsonl but stores images as base64 in the user + // turn rather than in VS Code's variableData shape. + return images.length > 0 ? images : extractClaudeImagesFromJsonl(raw, requestId); } return extractImagesFromJson(raw, requestId); } catch (e) { From 841e9de68a3beb31c7e5c5c43c163ea7d5bb96a3 Mon Sep 17 00:00:00 2001 From: Mohammad Alsayyedan Date: Sun, 20 Sep 2026 21:39:04 -0500 Subject: [PATCH 2/3] Register Claude session sources so the image gallery can read them --- src/core/parser-claude.ts | 28 +++++++++++++++++++------- src/core/parser-harnesses.ts | 39 ++++++++++++++++++++++++++++++------ src/core/parser-main.test.ts | 3 +++ src/core/parser.ts | 13 ++++++------ 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 59e8f194..e4372d16 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -500,11 +500,20 @@ function projectNameFromEncoded(encoded: string, _projectsDir: string): string { return path.basename(resolved); } +/** One Claude project directory's sessions, plus the file each came from. */ +export interface ClaudeProjectResult { + sessions: Session[]; + workspaceId: string; + workspaceName: string; + /** sessionId -> absolute path of the .jsonl the session was parsed from. */ + sessionFiles: Map; +} + function parseClaudeProjectSessions( projectsDir: string, dirName: string, editLocIndex?: EditLocIndex, -): { sessions: Session[]; workspaceId: string; workspaceName: string } | null { +): ClaudeProjectResult | null { const projPath = path.join(projectsDir, dirName); const workspaceId = `claude-${dirName}`; const workspaceName = projectNameFromEncoded(dirName, projectsDir); @@ -521,12 +530,17 @@ function parseClaudeProjectSessions( // can be merged into the right parent in pass 2. const sessionsById = new Map(); const sessions: Session[] = []; + // sessionId -> originating file, so the image gallery can read screenshots + // back out of the raw log. + const sessionFiles = new Map(); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; - const session = parseClaudeSessionFile(path.join(projPath, entry.name), workspaceId, workspaceName, editLocIndex); + const filePath = path.join(projPath, entry.name); + const session = parseClaudeSessionFile(filePath, workspaceId, workspaceName, editLocIndex); if (!session) continue; sessions.push(session); sessionsById.set(session.sessionId, session); + sessionFiles.set(session.sessionId, filePath); } // Pass 2: walk `/subagents/agent-*.jsonl` directories. Each @@ -613,14 +627,14 @@ function parseClaudeProjectSessions( session.requestCount = session.requests.length; } - return sessions.length > 0 ? { sessions, workspaceId, workspaceName } : null; + return sessions.length > 0 ? { sessions, workspaceId, workspaceName, sessionFiles } : null; } export function parseClaudeSessions( projectsDir: string, editLocIndex?: EditLocIndex, -): { sessions: Session[]; workspaceId: string; workspaceName: string }[] { - const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = []; +): ClaudeProjectResult[] { + const results: ClaudeProjectResult[] = []; let projectDirs: fs.Dirent[]; try { @@ -641,8 +655,8 @@ export async function parseClaudeSessionsAsync( projectsDir: string, onProject?: (idx: number, total: number, name: string) => void, editLocIndex?: EditLocIndex, -): Promise<{ sessions: Session[]; workspaceId: string; workspaceName: string }[]> { - const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = []; +): Promise { + const results: ClaudeProjectResult[] = []; let projectDirs: string[]; try { diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 17714319..b30cbee7 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -11,6 +11,7 @@ import { findClaudeDirs, parseClaudeSessions, parseClaudeSessionsAsync } from '. import { findCodexDirs, parseCodexSessions } from './parser-codex'; import { findOpenCodeDirs, parseOpenCodeSessions } from './parser-opencode'; import { EditLocIndex } from './edit-loc-diff'; +import { SessionSource } from './cache'; type WorkspaceMap = Map; @@ -18,6 +19,28 @@ interface HarnessCollectionContext { workspaces: WorkspaceMap; sessions: Session[]; editLocIndex: EditLocIndex; + /** Lets the image gallery read raw screenshots back out of a session file. */ + sessionSourceIndex?: Map; +} + +/** Register where each session was parsed from, when the caller tracks it. */ +function recordSources( + ctx: HarnessCollectionContext, + result: { sessions: Session[]; workspaceId: string; workspaceName: string; sessionFiles: Map }, + harness: string, +): void { + if (!ctx.sessionSourceIndex) return; + for (const session of result.sessions) { + const filePath = result.sessionFiles.get(session.sessionId); + if (!filePath) continue; + ctx.sessionSourceIndex.set(session.sessionId, { + kind: 'cli-events', + filePath, + workspaceId: result.workspaceId, + workspaceName: result.workspaceName, + harness, + }); + } } interface ExternalHarnessCollector { @@ -39,8 +62,9 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ name: 'Claude Code', collectSync(ctx) { for (const claudeDir of findClaudeDirs()) { - for (const { sessions } of parseClaudeSessions(claudeDir, ctx.editLocIndex)) { - for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + for (const result of parseClaudeSessions(claudeDir, ctx.editLocIndex)) { + for (const session of result.sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + recordSources(ctx, result, 'Claude'); } } }, @@ -49,8 +73,9 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ const results = await parseClaudeSessionsAsync(claudeDir, (idx, total, name) => { reportDetail?.(`${idx}/${total}: ${name}`); }, ctx.editLocIndex); - for (const { sessions } of results) { - for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + for (const result of results) { + for (const session of result.sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + recordSources(ctx, result, 'Claude'); } } }, @@ -97,8 +122,9 @@ export function collectExternalHarnessesSync( workspaces: WorkspaceMap, sessions: Session[], editLocIndex: EditLocIndex, + sessionSourceIndex?: Map, ): void { - const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex }; + const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex, sessionSourceIndex }; for (const harness of EXTERNAL_HARNESSES) { harness.collectSync(ctx); } @@ -119,8 +145,9 @@ export async function collectExternalHarnessesAsync( sessions: Session[], editLocIndex: EditLocIndex, handlers: ExternalHarnessProgressHandlers = {}, + sessionSourceIndex?: Map, ): Promise { - const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex }; + const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex, sessionSourceIndex }; const total = EXTERNAL_HARNESSES.length; for (let index = 0; index < EXTERNAL_HARNESSES.length; index++) { diff --git a/src/core/parser-main.test.ts b/src/core/parser-main.test.ts index 9cb1cad5..e57f8ba8 100644 --- a/src/core/parser-main.test.ts +++ b/src/core/parser-main.test.ts @@ -235,6 +235,7 @@ describe('parseAllLogs', () => { result.workspaces, result.sessions, result.editLocIndex, + result.sessionSourceIndex, ); }); @@ -293,6 +294,7 @@ describe('parseAllLogsAsyncDetailed', () => { cachedResult.sessions, cachedResult.editLocIndex, expect.any(Object), + cachedResult.sessionSourceIndex, ); }); @@ -435,6 +437,7 @@ describe('parseAllLogsAsyncDetailed', () => { parsed.result.sessions, parsed.result.editLocIndex, expect.any(Object), + parsed.result.sessionSourceIndex, ); }); diff --git a/src/core/parser.ts b/src/core/parser.ts index ab23988f..3bf9107f 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -348,7 +348,7 @@ async function tryMemoryCache( removeRefreshableExternalEditLocs(mem.result.sessions, mem.result.editLocIndex); mem.result.sessions = mem.result.sessions.filter(s => !EXTERNAL_HARNESS_SET.has(s.harness)); await collectVolatileHarnesses(logsDirs, mem.result, onProgress); - await collectExternalHarnesses(mem.result.workspaces, mem.result.sessions, mem.result.editLocIndex, onProgress); + await collectExternalHarnesses(mem.result.workspaces, mem.result.sessions, mem.result.editLocIndex, onProgress, mem.result.sessionSourceIndex); report({ phase: 1, detail: 'Loaded from memory', pct: pct(1, 1), sessions: mem.result.sessions.length, @@ -373,7 +373,7 @@ async function tryDiskCache( removeRefreshableExternalEditLocs(cached.result.sessions, cached.result.editLocIndex); cached.result.sessions = cached.result.sessions.filter(s => !EXTERNAL_HARNESS_SET.has(s.harness)); await collectVolatileHarnesses(logsDirs, cached.result, onProgress); - await collectExternalHarnesses(cached.result.workspaces, cached.result.sessions, cached.result.editLocIndex, onProgress); + await collectExternalHarnesses(cached.result.workspaces, cached.result.sessions, cached.result.editLocIndex, onProgress, cached.result.sessionSourceIndex); setMemoryCache(cached.result, currentMetas); report({ phase: 1, detail: 'Loaded from cache', pct: pct(1, 1), @@ -580,6 +580,7 @@ async function collectExternalHarnesses( sessions: import('./types').Session[], editLocIndex: EditLocIndex, onProgress?: ProgressCallback, + sessionSourceIndex?: Map, ): Promise { await collectExternalHarnessesAsync(workspaces, sessions, editLocIndex, { onHarnessStart: (name, index, total, sessionCount) => { @@ -592,7 +593,7 @@ async function collectExternalHarnesses( warnCore('parser', `${name} scan failed`, error); }, yieldToLoop, - }); + }, sessionSourceIndex); } export function parseAllLogs(logsDirs: string[]): ParseResult { @@ -619,7 +620,7 @@ export function parseAllLogs(logsDirs: string[]): ParseResult { } } - collectExternalHarnessesSync(workspaces, sessions, editLocIndex); + collectExternalHarnessesSync(workspaces, sessions, editLocIndex, sessionSourceIndex); stripSessionsForMemory(sessions); return { workspaces, sessions, editLocIndex, sessionSourceIndex }; @@ -737,7 +738,7 @@ export async function parseAllLogsAsyncDetailed( editLocIndex, sessionSourceIndex: freshSessionSourceIndex, }, onProgress); - await collectExternalHarnesses(workspaces, freshSessions, editLocIndex, onProgress); + await collectExternalHarnesses(workspaces, freshSessions, editLocIndex, onProgress, freshSessionSourceIndex); const result: ParseResult = { workspaces, sessions: freshSessions, editLocIndex, sessionSourceIndex: freshSessionSourceIndex }; stripSessionsForMemory(result.sessions); @@ -760,7 +761,7 @@ export async function parseAllLogsAsyncDetailed( await processWorkspaces(entries, totalDirs, ctx, onProgress); await collectXcode(xcodeDirs, workspaces, sessions, editLocIndex, onProgress); - await collectExternalHarnesses(workspaces, sessions, editLocIndex, onProgress); + await collectExternalHarnesses(workspaces, sessions, editLocIndex, onProgress, sessionSourceIndex); const result: ParseResult = { workspaces, sessions, editLocIndex, sessionSourceIndex }; stripSessionsForMemory(result.sessions); From 6fa72503c77c8270dd2914b1cd5b36818fea382a Mon Sep 17 00:00:00 2001 From: Mohammad Alsayyedan Date: Sun, 27 Sep 2026 19:13:27 -0500 Subject: [PATCH 3/3] Group image extraction tests under their own describe block extractClaudeImagesFromJsonl is a standalone function, so its tests do not belong inside the parseClaudeSessions suite. Moving them also keeps this PR from editing the same lines as #270. --- src/core/parser-claude.test.ts | 87 +++++++++++++++++----------------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index f92c2c04..3c891219 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -384,50 +384,6 @@ describe('parseClaudeSessions', () => { } }); - - // ---- inline image extraction ---- - - it('extracts base64 images from a Claude user turn by request id', () => { - const png = 'iVBORw0KGgoAAAANSUhEUg=='; - const line = JSON.stringify({ - type: 'user', - timestamp: '2025-06-15T10:00:00Z', - sessionId: 'sess-1', - uuid: 'req-with-image', - message: { - role: 'user', - content: [ - { type: 'text', text: 'what is wrong here?' }, - { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }, - ], - }, - }); - const other = JSON.stringify({ type: 'user', uuid: 'other-req', message: { role: 'user', content: [] } }); - - const images = extractClaudeImagesFromJsonl([other, line].join('\n'), 'req-with-image'); - expect(images).toEqual([`data:image/png;base64,${png}`]); - }); - - it('returns no images for a request id that has none', () => { - const line = JSON.stringify({ - type: 'user', - uuid: 'text-only', - message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - }); - expect(extractClaudeImagesFromJsonl(line, 'text-only')).toEqual([]); - expect(extractClaudeImagesFromJsonl(line, 'missing-id')).toEqual([]); - }); - - it('caps extracted images at four per request', () => { - const img = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }; - const line = JSON.stringify({ - type: 'user', - uuid: 'many-images', - message: { role: 'user', content: [img, img, img, img, img, img] }, - }); - expect(extractClaudeImagesFromJsonl(line, 'many-images')).toHaveLength(4); - }); - // ---- workspace name resolution with spaces ---- it('resolves workspace name when path contains a folder with spaces', { timeout: 30_000 }, () => { @@ -638,3 +594,46 @@ describe('parseClaudeSessions', () => { fs.rmSync(cwd, { recursive: true, force: true }); }); }); + +describe('extractClaudeImagesFromJsonl', () => { + it('extracts base64 images from a Claude user turn by request id', () => { + const png = 'iVBORw0KGgoAAAANSUhEUg=='; + const line = JSON.stringify({ + type: 'user', + timestamp: '2025-06-15T10:00:00Z', + sessionId: 'sess-1', + uuid: 'req-with-image', + message: { + role: 'user', + content: [ + { type: 'text', text: 'what is wrong here?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }, + ], + }, + }); + const other = JSON.stringify({ type: 'user', uuid: 'other-req', message: { role: 'user', content: [] } }); + + const images = extractClaudeImagesFromJsonl([other, line].join('\n'), 'req-with-image'); + expect(images).toEqual([`data:image/png;base64,${png}`]); + }); + + it('returns no images for a request id that has none', () => { + const line = JSON.stringify({ + type: 'user', + uuid: 'text-only', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }); + expect(extractClaudeImagesFromJsonl(line, 'text-only')).toEqual([]); + expect(extractClaudeImagesFromJsonl(line, 'missing-id')).toEqual([]); + }); + + it('caps extracted images at four per request', () => { + const img = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }; + const line = JSON.stringify({ + type: 'user', + uuid: 'many-images', + message: { role: 'user', content: [img, img, img, img, img, img] }, + }); + expect(extractClaudeImagesFromJsonl(line, 'many-images')).toHaveLength(4); + }); +});