From 4373036179bc05ebd3a068d07f13340d02884931 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:56:11 +0000 Subject: [PATCH 1/3] Add a read-only conversation preview to the Sessions page Each session row gets a preview button that opens the conversation's user and assistant messages, read from its rollout segments (oldest first), so a conversation can be recognised before it is deleted. Codex scaffolding is stripped the same way as the list preview, tool calls are counted rather than shown, and long conversations are capped. The main process only opens paths from the latest scan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3VnHzsih7Qq4QPYwvLtBk --- electron/main/index.ts | 9 +++ electron/main/preview.ts | 14 ++-- electron/main/session-transcript.ts | 106 ++++++++++++++++++++++++++++ electron/preload/index.ts | 4 +- shared/types.ts | 18 +++++ src/App.css | 18 ++++- src/icons.tsx | 6 ++ src/views/SessionsView.tsx | 69 ++++++++++++++++-- tests/session-transcript.test.ts | 54 ++++++++++++++ 9 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 electron/main/session-transcript.ts create mode 100644 tests/session-transcript.test.ts diff --git a/electron/main/index.ts b/electron/main/index.ts index 380dbec..876dbe5 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -22,6 +22,7 @@ import { } from './session-database' import { cleanupLogPath, ensureLogDirectory, logCleanup, logDirectory, logException } from './diagnostics' import { removeCodexPlugin } from './plugins' +import { readSessionTranscript } from './session-transcript' import { newerReleaseVersion } from './release-update' import { appendAutomationLog, @@ -249,6 +250,14 @@ handle('sessions:leftovers', () => ({ logPath: cleanupLogPath() })) +/** Read-only preview of a scanned conversation; only paths from the latest scan are ever opened. */ +handle('sessions:transcript', (_event, id: string) => { + if (!latestSnapshot) throw new MessageError(message('error.scanFirst')) + const session = latestSnapshot.sessions.find((item) => item.id === id) + if (!session) throw new MessageError(message('error.invalidSelection')) + return readSessionTranscript([...session.segmentURLs, session.fileURL]) +}) + handle('sessions:repairLeftovers', () => { if (codexIsRunning()) throw new MessageError(message('error.codexRunningForRepair')) const report = deleteOrphanSessionRecords(locations.home, stateBackupDirectory()) diff --git a/electron/main/preview.ts b/electron/main/preview.ts index 7c3e955..372b435 100644 --- a/electron/main/preview.ts +++ b/electron/main/preview.ts @@ -20,13 +20,19 @@ function stripRequestPreamble(value: string): string { return value } -export function cleanPreview(text: unknown): string | null { +/** The user's own words with Codex's scaffolding removed, or null for a pure-scaffolding message. */ +export function cleanUserMessage(text: unknown): string | null { if (typeof text !== 'string' || !text) return null - let value = text.trim() - value = stripRequestPreamble(value) + const value = stripRequestPreamble(text.trim()) if (!value || value.startsWith('<')) return null const head = value.slice(0, 400).toLowerCase() if (PREAMBLE_MARKERS.some((item) => head.includes(item))) return null - value = value.replace(/\s+/g, ' ') + return value +} + +export function cleanPreview(text: unknown): string | null { + const cleaned = cleanUserMessage(text) + if (!cleaned) return null + const value = cleaned.replace(/\s+/g, ' ') return value.length > 90 ? `${value.slice(0, 90)}…` : value } \ No newline at end of file diff --git a/electron/main/session-transcript.ts b/electron/main/session-transcript.ts new file mode 100644 index 0000000..4126f87 --- /dev/null +++ b/electron/main/session-transcript.ts @@ -0,0 +1,106 @@ +import { createReadStream, readFileSync } from 'node:fs' +import { createInterface } from 'node:readline' +import { Readable } from 'node:stream' +import * as zlib from 'node:zlib' +import { cleanUserMessage } from './preview' +import type { SessionTranscript, TranscriptMessage } from '../../shared/types' + +/** + * Reads a conversation back out of its rollout segments so it can be previewed before + * it is deleted. Only user and assistant text is kept; tool calls are counted, and + * everything else a rollout carries (reasoning, tool output, context injection) is left + * out. Nothing here writes to disk. + */ + +const MAX_MESSAGES = 400 +const MAX_MESSAGE_CHARS = 8000 +/** Lines this long are tool output or embedded images, never a message worth parsing. */ +const MAX_LINE_CHARS = 4 * 1024 * 1024 +const TOOL_CALL_TYPES = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call']) + +interface SegmentMessages { items: TranscriptMessage[]; events: TranscriptMessage[]; toolCalls: number } + +function objectValue(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null +} +function stringValue(value: unknown): string | null { return typeof value === 'string' && value.length ? value : null } + +function contentText(content: unknown): string | null { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return null + const parts = content.flatMap((part) => { + const value = objectValue(part) + const text = value ? stringValue(value['text']) : null + return text ? [text] : [] + }) + return parts.length ? parts.join('\n\n') : null +} + +function message(role: TranscriptMessage['role'], raw: string | null, timestamp: number | null): TranscriptMessage | null { + const text = role === 'user' ? cleanUserMessage(raw) : raw?.trim() || null + if (!text) return null + return { role, timestamp, text: text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text } +} + +/** + * Codex records each turn twice: as a `response_item` message (what the model saw) and + * as an `event_msg` (what the UI showed). Response items are preferred; events are the + * fallback for rollouts that only carry one of them. Very old rollouts have no wrapper, + * so the line itself is the payload. + */ +export function parseTranscriptLine(line: string, into: SegmentMessages): void { + if (!line || line.length > MAX_LINE_CHARS) return + let root: Record + try { root = JSON.parse(line) as Record } catch { return } + const payload = objectValue(root['payload']) ?? root + const parsedTime = Date.parse(stringValue(root['timestamp']) ?? '') + const timestamp = Number.isNaN(parsedTime) ? null : parsedTime + const type = payload['type'] + if (root['type'] === 'event_msg') { + if (type === 'user_message') { + const item = message('user', stringValue(payload['message']), timestamp) + if (item) into.events.push(item) + } else if (type === 'agent_message') { + const item = message('assistant', stringValue(payload['message']), timestamp) + if (item) into.events.push(item) + } + return + } + if (type === 'message' && (payload['role'] === 'user' || payload['role'] === 'assistant')) { + const item = message(payload['role'], contentText(payload['content']), timestamp) + if (item) into.items.push(item) + } else if (typeof type === 'string' && TOOL_CALL_TYPES.has(type)) into.toolCalls += 1 +} + +function openSegment(path: string): Readable { + if (!path.endsWith('.zst')) return createReadStream(path, { encoding: 'utf8' }) + const decompress = (zlib as unknown as { zstdDecompressSync?: (input: Buffer) => Buffer }).zstdDecompressSync + if (!decompress) throw new Error('zstd is not available') + return Readable.from([decompress(readFileSync(path)).toString('utf8')]) +} + +async function readSegment(path: string): Promise { + const result: SegmentMessages = { items: [], events: [], toolCalls: 0 } + const lines = createInterface({ input: openSegment(path), crlfDelay: Infinity }) + for await (const line of lines) parseTranscriptLine(line, result) + return result +} + +/** `paths` are the conversation's rollout segments, oldest first. */ +export async function readSessionTranscript(paths: string[]): Promise { + const messages: TranscriptMessage[] = [] + let toolCalls = 0 + let unreadableSegments = 0 + for (const path of paths) { + let segment: SegmentMessages + try { segment = await readSegment(path) } catch { unreadableSegments += 1; continue } + messages.push(...(segment.items.length ? segment.items : segment.events)) + toolCalls += segment.toolCalls + } + return { + messages: messages.slice(0, MAX_MESSAGES), + toolCalls, + truncated: messages.length > MAX_MESSAGES, + unreadableSegments + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6741bfb..835146b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,7 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import type { ScanSnapshot, ScanProgress, AppInfo, CleanupReport, CleanupProgress, - WorkspaceSnapshot, AutomationSettings, AutomationState, CleanupSelection, CleanupPreview, CleanupRequest + WorkspaceSnapshot, AutomationSettings, AutomationState, CleanupSelection, CleanupPreview, CleanupRequest, SessionTranscript } from '../../shared/types' import type { Language, Message } from '../../shared/messages' @@ -23,6 +23,8 @@ const api = { /** Session rows Codex still lists although their rollout file is gone. */ sessionLeftovers: (): Promise<{ count: number; logPath: string }> => ipcRenderer.invoke('sessions:leftovers'), repairSessionLeftovers: (): Promise<{ threads: number; removedRows: number }> => ipcRenderer.invoke('sessions:repairLeftovers'), + /** The user and assistant messages of one scanned conversation, for previewing before deletion. */ + sessionTranscript: (id: string): Promise => ipcRenderer.invoke('sessions:transcript', id), revealPath: (path: string): Promise => ipcRenderer.invoke('path:reveal', path), openPath: (path: string): Promise => ipcRenderer.invoke('path:open', path), /** This app's own log folder, created on demand so the settings entry can open it. */ diff --git a/shared/types.ts b/shared/types.ts index a288e4a..4ae6e09 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -113,6 +113,24 @@ export const SessionTagLabel: Record = { worktree: 'Worktree' } +/** One user or assistant turn read back from a rollout file, for previewing before deletion. */ +export interface TranscriptMessage { + role: 'user' | 'assistant' + text: string + /** epoch ms, when the rollout line carried a timestamp */ + timestamp: number | null +} + +export interface SessionTranscript { + messages: TranscriptMessage[] + /** Tool invocations between the messages; counted rather than shown. */ + toolCalls: number + /** The message cap was reached, so later turns are left out. */ + truncated: boolean + /** Segments that could not be read (missing, or compressed without a decompressor). */ + unreadableSegments: number +} + export interface SessionItem { id: string threadID: string diff --git a/src/App.css b/src/App.css index f788485..ff51e41 100644 --- a/src/App.css +++ b/src/App.css @@ -633,7 +633,7 @@ label.entry-label { cursor: pointer; } /* --- session table --- */ .table-head, .session-row { display: grid; - grid-template-columns: 15px minmax(0, 1fr) 72px 116px 88px 92px 30px; + grid-template-columns: 15px minmax(0, 1fr) 72px 116px 88px 92px 60px; gap: 12px; align-items: center; padding: 0 16px; @@ -1131,6 +1131,20 @@ label.entry-label { cursor: pointer; } box-shadow: inset 3px 0 0 var(--danger); } .worktree-session-option:has(input:disabled) { cursor: default; opacity: 0.65; } +.row-actions { display: flex; justify-content: flex-end; } + +/* --- conversation preview --- */ +.transcript-dialog { width: min(720px, calc(100vw - 48px)); height: min(760px, calc(100vh - 48px)); display: flex; flex-direction: column; overflow: hidden; } +.transcript-header h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.transcript-body { flex: 1; min-height: 0; margin-top: 14px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-sunken); } +.transcript-list { list-style: none; margin: 0; padding: 6px 14px; } +.transcript-message { padding: 10px 0; border-top: 1px solid var(--line-soft); } +.transcript-message:first-child { border-top: 0; } +.transcript-meta { display: flex; align-items: baseline; gap: 8px; font-size: 11.5px; font-weight: 600; color: var(--text-secondary); } +.transcript-message.role-user .transcript-meta { color: var(--accent); } +.transcript-meta time { font-weight: 400; color: var(--text-tertiary); } +.transcript-message p { margin: 4px 0 0; font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; } +.transcript-notes { margin: 10px 0 0; color: var(--text-tertiary); font-size: 11.5px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } .confirm-dialog { width: min(430px, calc(100vw - 48px)); } .confirm-dialog .notice { margin: 14px 0 0; } @@ -1355,7 +1369,7 @@ label.entry-label { cursor: pointer; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } @media (max-width: 1120px) { - .table-head, .session-row { grid-template-columns: 15px minmax(0, 1fr) 60px 96px 84px 92px 30px; } + .table-head, .session-row { grid-template-columns: 15px minmax(0, 1fr) 60px 96px 84px 92px 60px; } .asset-head, .asset-row { grid-template-columns: 15px minmax(0, 1fr) 88px 48px 96px 76px 30px; } } diff --git a/src/icons.tsx b/src/icons.tsx index a138189..fc7f51d 100644 --- a/src/icons.tsx +++ b/src/icons.tsx @@ -8,6 +8,12 @@ export function FolderIcon() { } +export function PreviewIcon() { + return +} + export function BackIcon() { return
    - {visible.map((session) => selection.toggle(session)} locale={locale} />)} + {visible.map((session) => selection.toggle(session)} onPreview={() => setPreviewing(session)} locale={locale} />)}
{!visible.length &&

{listable.length ? t('没有符合筛选条件的会话', 'No sessions match these filters') @@ -189,10 +191,11 @@ export default function SessionsView({ snapshot, cleaning, actionsDisabled, clea cleaning={cleaning} actionsDisabled={actionsDisabled} progress={cleanProgress} onDelete={() => onCleanup({ kind: 'sessions-delete', ids: selectedSessions.map(sessionID) })} /> + {previewing && setPreviewing(null)} />} } -function SessionRow({ session, checked, locale, onToggle }: { session: SessionItem; checked: boolean; locale: string; onToggle: () => void }) { +function SessionRow({ session, checked, locale, onToggle, onPreview }: { session: SessionItem; checked: boolean; locale: string; onToggle: () => void; onPreview: () => void }) { const { t, m } = usePreferences() return

  • @@ -208,6 +211,64 @@ function SessionRow({ session, checked, locale, onToggle }: { session: SessionIt {formatShortDate(session.modifiedAt, locale)} {formatBytes(session.fileBytes)} {formatBytes(sessionTotalBytes(session))} - + + + +
  • } + +/** Read-only look at a conversation's messages, so it can be recognised before it is deleted. */ +function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; locale: string; onClose: () => void }) { + const { t, e } = usePreferences() + const [transcript, setTranscript] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + window.cleanmycodex.sessionTranscript(session.id) + .then((result) => { if (!cancelled) setTranscript(result) }) + .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) }) + return () => { cancelled = true } + }, [session.id]) + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + const notes: string[] = [] + if (transcript?.toolCalls) notes.push(t(`另有 ${transcript.toolCalls} 次工具调用未显示`, `${transcript.toolCalls} tool calls not shown`)) + if (transcript?.truncated) notes.push(t(`只显示前 ${transcript.messages.length} 条消息`, `Showing the first ${transcript.messages.length} messages`)) + if (transcript?.unreadableSegments) notes.push(t(`${transcript.unreadableSegments} 个会话文件无法读取`, `${transcript.unreadableSegments} session files could not be read`)) + if (session.childThreadCount) notes.push(t(`${session.childThreadCount} 个子代理会话未显示`, `${session.childThreadCount} subagent conversations not shown`)) + + return
    { if (event.target === event.currentTarget) onClose() }}> +
    +
    +

    {sessionDisplayName(session)}

    +

    {[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale)].filter(Boolean).join(' · ')}

    +
    +
    + {error &&

    {e(error)}

    } + {!error && !transcript &&

    {t('正在读取…', 'Loading…')}

    } + {transcript && !transcript.messages.length &&

    {t('这个会话里没有可显示的消息', 'No messages to show in this conversation')}

    } + {transcript && transcript.messages.length > 0 &&
      + {transcript.messages.map((item, index) =>
    1. + + {item.role === 'user' ? t('你', 'You') : 'Codex'} + {item.timestamp !== null && } + +

      {item.text}

      +
    2. )} +
    } +
    + {notes.length > 0 &&

    {notes.join(' · ')}

    } +
    + + +
    +
    +
    +} diff --git a/tests/session-transcript.test.ts b/tests/session-transcript.test.ts new file mode 100644 index 0000000..22798e4 --- /dev/null +++ b/tests/session-transcript.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readSessionTranscript } from '../electron/main/session-transcript' + +const roots: string[] = [] +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +function rollout(lines: unknown[]): string { + const root = mkdtempSync(join(tmpdir(), 'cleanmycodex-transcript-')); roots.push(root) + const path = join(root, `rollout-${roots.length}.jsonl`) + writeFileSync(path, lines.map((line) => JSON.stringify(line)).join('\n') + '\n') + return path +} + +const item = (role: string, text: string, timestamp = '2026-08-01T10:00:00Z') => + ({ timestamp, type: 'response_item', payload: { type: 'message', role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }] } }) + +describe('session transcript', () => { + it('keeps user and assistant messages, strips scaffolding and counts tool calls', async () => { + const path = rollout([ + { type: 'session_meta', payload: { id: 'x', cwd: '/work' } }, + item('developer', 'system rules'), + item('user', 'cwd'), + item('user', '# Files mentioned by the user:\n- a.ts\n## My request:\n修复登录 bug'), + { type: 'event_msg', payload: { type: 'user_message', message: '修复登录 bug' } }, + { type: 'response_item', payload: { type: 'reasoning', summary: [] } }, + { type: 'response_item', payload: { type: 'function_call', name: 'shell', arguments: '{}' } }, + item('assistant', '已经修好了。'), + { type: 'event_msg', payload: { type: 'agent_message', message: '已经修好了。' } } + ]) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages.map(({ role, text }) => [role, text])).toEqual([['user', '修复登录 bug'], ['assistant', '已经修好了。']]) + expect(transcript.messages[0].timestamp).toBe(Date.parse('2026-08-01T10:00:00Z')) + expect(transcript.toolCalls).toBe(1) + expect(transcript.truncated).toBe(false) + }) + + it('falls back to UI events and reads segments in order, counting unreadable ones', async () => { + const first = rollout([{ type: 'event_msg', payload: { type: 'user_message', message: 'first' } }]) + const second = rollout([item('user', 'second')]) + const transcript = await readSessionTranscript([first, join(tmpdir(), 'missing-rollout.jsonl'), second]) + expect(transcript.messages.map((message) => message.text)).toEqual(['first', 'second']) + expect(transcript.unreadableSegments).toBe(1) + }) + + it('caps long conversations', async () => { + const path = rollout(Array.from({ length: 450 }, (_, index) => item('user', `message ${index}`))) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages).toHaveLength(400) + expect(transcript.truncated).toBe(true) + }) +}) From 8fa2d677a1d009a2a20c64666e6260639f27f4fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:16:41 +0000 Subject: [PATCH 2/3] Show pasted images in the conversation preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex embeds each pasted image in the rollout as an input_image data URL framed by / text whose path points at a temporary clipboard file. The preview now drops that frame text and renders the embedded image, with click-to-zoom. Image data is capped per preview so a screenshot-heavy conversation is never loaded whole. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3VnHzsih7Qq4QPYwvLtBk --- electron/main/session-transcript.ts | 91 +++++++++++++++++++++-------- shared/types.ts | 4 ++ src/App.css | 5 ++ src/views/SessionsView.tsx | 17 +++++- tests/session-transcript.test.ts | 30 ++++++++++ 5 files changed, 121 insertions(+), 26 deletions(-) diff --git a/electron/main/session-transcript.ts b/electron/main/session-transcript.ts index 4126f87..16d602b 100644 --- a/electron/main/session-transcript.ts +++ b/electron/main/session-transcript.ts @@ -14,32 +14,73 @@ import type { SessionTranscript, TranscriptMessage } from '../../shared/types' const MAX_MESSAGES = 400 const MAX_MESSAGE_CHARS = 8000 -/** Lines this long are tool output or embedded images, never a message worth parsing. */ -const MAX_LINE_CHARS = 4 * 1024 * 1024 +/** A line with a pasted screenshot can run to many megabytes; beyond this it is tool output. */ +const MAX_LINE_CHARS = 64 * 1024 * 1024 +/** Images cross IPC as data URLs, so their total size per preview is bounded. */ +const MAX_IMAGE_CHARS = 48 * 1024 * 1024 +const IMAGE_TAG_RE = /<\/?image\b[^>]*>/gi const TOOL_CALL_TYPES = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call']) -interface SegmentMessages { items: TranscriptMessage[]; events: TranscriptMessage[]; toolCalls: number } +interface SegmentMessages { + items: TranscriptMessage[] + events: TranscriptMessage[] + toolCalls: number + /** Image characters still allowed into `items` / `events`, so a screenshot-heavy rollout is never held whole. */ + itemImageBudget: number + eventImageBudget: number +} +interface MessageContent { text: string | null; images: string[] } function objectValue(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null } function stringValue(value: unknown): string | null { return typeof value === 'string' && value.length ? value : null } -function contentText(content: unknown): string | null { - if (typeof content === 'string') return content - if (!Array.isArray(content)) return null - const parts = content.flatMap((part) => { +function imageURL(value: unknown): string | null { + const url = stringValue(value) + return url && /^data:image\/[a-z0-9.+-]+;base64,/i.test(url) ? url : null +} + +/** + * Codex stores a pasted image as an `input_image` part holding the whole picture as a + * data URL, framed by `` / `` text parts. The path is a + * temporary clipboard file that is usually gone by now, so the frame text is dropped + * and the embedded data is what gets shown. + */ +function messageContent(content: unknown): MessageContent { + if (typeof content === 'string') return { text: content, images: [] } + if (!Array.isArray(content)) return { text: null, images: [] } + const texts: string[] = [] + const images: string[] = [] + for (const part of content) { const value = objectValue(part) - const text = value ? stringValue(value['text']) : null - return text ? [text] : [] - }) - return parts.length ? parts.join('\n\n') : null + if (!value) continue + const image = imageURL(value['image_url']) + if (image) { images.push(image); continue } + const text = stringValue(value['text'])?.replace(IMAGE_TAG_RE, '').trim() + if (text) texts.push(text) + } + return { text: texts.length ? texts.join('\n\n') : null, images } } -function message(role: TranscriptMessage['role'], raw: string | null, timestamp: number | null): TranscriptMessage | null { - const text = role === 'user' ? cleanUserMessage(raw) : raw?.trim() || null - if (!text) return null - return { role, timestamp, text: text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text } +function message(role: TranscriptMessage['role'], content: MessageContent, timestamp: number | null): TranscriptMessage | null { + const raw = content.text?.replace(IMAGE_TAG_RE, '') ?? null + const text = (role === 'user' ? cleanUserMessage(raw) : raw?.trim() || null) ?? '' + // A message that is only Codex scaffolding is skipped, unless it carries an image. + if (!text && !content.images.length) return null + return { + role, timestamp, images: content.images, omittedImages: 0, + text: text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text + } +} + +function withinBudget(item: TranscriptMessage, budgets: Record, key: K): TranscriptMessage { + const kept = item.images.filter((image) => { + if (image.length > budgets[key]) return false + budgets[key] -= image.length + return true + }) + return { ...item, images: kept, omittedImages: item.omittedImages + item.images.length - kept.length } } /** @@ -48,7 +89,7 @@ function message(role: TranscriptMessage['role'], raw: string | null, timestamp: * fallback for rollouts that only carry one of them. Very old rollouts have no wrapper, * so the line itself is the payload. */ -export function parseTranscriptLine(line: string, into: SegmentMessages): void { +function parseTranscriptLine(line: string, into: SegmentMessages): void { if (!line || line.length > MAX_LINE_CHARS) return let root: Record try { root = JSON.parse(line) as Record } catch { return } @@ -58,17 +99,18 @@ export function parseTranscriptLine(line: string, into: SegmentMessages): void { const type = payload['type'] if (root['type'] === 'event_msg') { if (type === 'user_message') { - const item = message('user', stringValue(payload['message']), timestamp) - if (item) into.events.push(item) + const images = Array.isArray(payload['images']) ? payload['images'].flatMap((value) => imageURL(value) ?? []) : [] + const item = message('user', { text: stringValue(payload['message']), images }, timestamp) + if (item) into.events.push(withinBudget(item, into, 'eventImageBudget')) } else if (type === 'agent_message') { - const item = message('assistant', stringValue(payload['message']), timestamp) + const item = message('assistant', { text: stringValue(payload['message']), images: [] }, timestamp) if (item) into.events.push(item) } return } if (type === 'message' && (payload['role'] === 'user' || payload['role'] === 'assistant')) { - const item = message(payload['role'], contentText(payload['content']), timestamp) - if (item) into.items.push(item) + const item = message(payload['role'], messageContent(payload['content']), timestamp) + if (item) into.items.push(withinBudget(item, into, 'itemImageBudget')) } else if (typeof type === 'string' && TOOL_CALL_TYPES.has(type)) into.toolCalls += 1 } @@ -80,7 +122,7 @@ function openSegment(path: string): Readable { } async function readSegment(path: string): Promise { - const result: SegmentMessages = { items: [], events: [], toolCalls: 0 } + const result: SegmentMessages = { items: [], events: [], toolCalls: 0, itemImageBudget: MAX_IMAGE_CHARS, eventImageBudget: MAX_IMAGE_CHARS } const lines = createInterface({ input: openSegment(path), crlfDelay: Infinity }) for await (const line of lines) parseTranscriptLine(line, result) return result @@ -97,8 +139,11 @@ export async function readSessionTranscript(paths: string[]): Promise MAX_MESSAGES, unreadableSegments diff --git a/shared/types.ts b/shared/types.ts index 4ae6e09..d4fecbb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -117,6 +117,10 @@ export const SessionTagLabel: Record = { export interface TranscriptMessage { role: 'user' | 'assistant' text: string + /** `data:image/…` URLs of images embedded in the message, in order. */ + images: string[] + /** Embedded images left out because the preview's image budget was spent. */ + omittedImages: number /** epoch ms, when the rollout line carried a timestamp */ timestamp: number | null } diff --git a/src/App.css b/src/App.css index ff51e41..9e2b7f6 100644 --- a/src/App.css +++ b/src/App.css @@ -1144,6 +1144,11 @@ label.entry-label { cursor: pointer; } .transcript-message.role-user .transcript-meta { color: var(--accent); } .transcript-meta time { font-weight: 400; color: var(--text-tertiary); } .transcript-message p { margin: 4px 0 0; font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; } +.transcript-images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.transcript-images img { max-width: min(100%, 320px); max-height: 240px; object-fit: contain; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); cursor: zoom-in; } +.transcript-omitted { color: var(--text-tertiary); font-size: 11.5px; } +.image-zoom { position: fixed; inset: 0; z-index: 110; display: grid; place-items: center; padding: 32px; background: rgba(10, 10, 18, 0.72); cursor: zoom-out; } +.image-zoom img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); } .transcript-notes { margin: 10px 0 0; color: var(--text-tertiary); font-size: 11.5px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } .confirm-dialog { width: min(430px, calc(100vw - 48px)); } diff --git a/src/views/SessionsView.tsx b/src/views/SessionsView.tsx index 92e218f..f0bb072 100644 --- a/src/views/SessionsView.tsx +++ b/src/views/SessionsView.tsx @@ -223,6 +223,7 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; const { t, e } = usePreferences() const [transcript, setTranscript] = useState(null) const [error, setError] = useState(null) + const [zoomed, setZoomed] = useState(null) useEffect(() => { let cancelled = false @@ -233,10 +234,14 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; }, [session.id]) useEffect(() => { - const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() } + const onKey = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + if (zoomed) setZoomed(null) + else onClose() + } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [onClose]) + }, [onClose, zoomed]) const notes: string[] = [] if (transcript?.toolCalls) notes.push(t(`另有 ${transcript.toolCalls} 次工具调用未显示`, `${transcript.toolCalls} tool calls not shown`)) @@ -260,7 +265,12 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; {item.role === 'user' ? t('你', 'You') : 'Codex'} {item.timestamp !== null && }
    -

    {item.text}

    + {item.text &&

    {item.text}

    } + {item.images.length > 0 &&
    + {item.images.map((src, imageIndex) => {t(`图片 setZoomed(src)} />)} +
    } + {item.omittedImages > 0 &&

    {t(`${item.omittedImages} 张图片过大,未在预览中显示`, `${item.omittedImages} images too large to preview`)}

    } )} } @@ -270,5 +280,6 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; + {zoomed &&
    setZoomed(null)}>
    } } diff --git a/tests/session-transcript.test.ts b/tests/session-transcript.test.ts index 22798e4..38e1797 100644 --- a/tests/session-transcript.test.ts +++ b/tests/session-transcript.test.ts @@ -45,6 +45,36 @@ describe('session transcript', () => { expect(transcript.unreadableSegments).toBe(1) }) + it('shows embedded images in place of the clipboard placeholder text', async () => { + const png = 'data:image/png;base64,iVBORw0KGgo=' + const path = rollout([ + { type: 'response_item', payload: { type: 'message', role: 'user', content: [ + { type: 'input_text', text: '底色是不是不太匹配?' }, + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: png }, + { type: 'input_text', text: '' } + ] } }, + { type: 'response_item', payload: { type: 'message', role: 'user', content: [ + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: png }, + { type: 'input_image', image_url: 'https://example.com/remote.png' }, + { type: 'input_text', text: '' } + ] } } + ]) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages.map(({ text, images }) => [text, images])).toEqual([ + ['底色是不是不太匹配?', [png]], + ['', [png]] + ]) + }) + + it('keeps images from UI events when a rollout has no response items', async () => { + const png = 'data:image/jpeg;base64,/9j/4AAQ' + const path = rollout([{ type: 'event_msg', payload: { type: 'user_message', message: 'look', images: [png] } }]) + const transcript = await readSessionTranscript([path]) + expect(transcript.messages[0].images).toEqual([png]) + }) + it('caps long conversations', async () => { const path = rollout(Array.from({ length: 450 }, (_, index) => item('user', `message ${index}`))) const transcript = await readSessionTranscript([path]) From 4d7ec5e3bdefea98402af06bc6635881c90f4fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:23:14 +0000 Subject: [PATCH 3/3] Show a thread's generated images in the conversation preview Images Codex generated for a thread live in generated_images// rather than in the rollout. The preview now lists them below the messages, oldest first, sharing the same image budget as pasted images; symlinks are never followed and files over budget are counted instead of read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3VnHzsih7Qq4QPYwvLtBk --- electron/main/index.ts | 3 +- electron/main/session-transcript.ts | 55 ++++++++++++++++++++++++++--- shared/types.ts | 11 ++++++ src/App.css | 3 ++ src/views/SessionsView.tsx | 10 +++++- tests/session-transcript.test.ts | 19 +++++++++- 6 files changed, 93 insertions(+), 8 deletions(-) diff --git a/electron/main/index.ts b/electron/main/index.ts index 876dbe5..2746856 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -255,7 +255,8 @@ handle('sessions:transcript', (_event, id: string) => { if (!latestSnapshot) throw new MessageError(message('error.scanFirst')) const session = latestSnapshot.sessions.find((item) => item.id === id) if (!session) throw new MessageError(message('error.invalidSelection')) - return readSessionTranscript([...session.segmentURLs, session.fileURL]) + const generatedImageDirectories = session.assetURLs.filter((url) => dirname(url) === locations.generatedImages) + return readSessionTranscript([...session.segmentURLs, session.fileURL], generatedImageDirectories) }) handle('sessions:repairLeftovers', () => { diff --git a/electron/main/session-transcript.ts b/electron/main/session-transcript.ts index 16d602b..3b4c589 100644 --- a/electron/main/session-transcript.ts +++ b/electron/main/session-transcript.ts @@ -1,9 +1,10 @@ -import { createReadStream, readFileSync } from 'node:fs' +import { createReadStream, lstatSync, readdirSync, readFileSync } from 'node:fs' +import { extname, join } from 'node:path' import { createInterface } from 'node:readline' import { Readable } from 'node:stream' import * as zlib from 'node:zlib' import { cleanUserMessage } from './preview' -import type { SessionTranscript, TranscriptMessage } from '../../shared/types' +import type { GeneratedImagePreview, SessionTranscript, TranscriptMessage } from '../../shared/types' /** * Reads a conversation back out of its rollout segments so it can be previewed before @@ -19,6 +20,7 @@ const MAX_LINE_CHARS = 64 * 1024 * 1024 /** Images cross IPC as data URLs, so their total size per preview is bounded. */ const MAX_IMAGE_CHARS = 48 * 1024 * 1024 const IMAGE_TAG_RE = /<\/?image\b[^>]*>/gi +const IMAGE_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' } const TOOL_CALL_TYPES = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call']) interface SegmentMessages { @@ -128,8 +130,48 @@ async function readSegment(path: string): Promise { return result } -/** `paths` are the conversation's rollout segments, oldest first. */ -export async function readSessionTranscript(paths: string[]): Promise { +/** Image files under a thread's generated-images directory, oldest first; symlinks are never followed. */ +function generatedImageFiles(directory: string): Array<{ path: string; name: string; type: string; size: number; modifiedAt: number }> { + const files: Array<{ path: string; name: string; type: string; size: number; modifiedAt: number }> = [] + const stack = [directory] + while (stack.length) { + const current = stack.pop()! + let entries: string[] + try { entries = readdirSync(current) } catch { continue } + for (const name of entries) { + if (name.startsWith('.')) continue + const path = join(current, name) + let stats + try { stats = lstatSync(path) } catch { continue } + if (stats.isDirectory()) { stack.push(path); continue } + const type = IMAGE_TYPES[extname(name).slice(1).toLowerCase()] + if (stats.isFile() && type) files.push({ path, name, type, size: stats.size, modifiedAt: stats.mtimeMs }) + } + } + return files.sort((a, b) => a.modifiedAt - b.modifiedAt || a.name.localeCompare(b.name)) +} + +function readGeneratedImages(directories: string[], budget: { images: number }): { images: GeneratedImagePreview[]; omitted: number } { + const images: GeneratedImagePreview[] = [] + let omitted = 0 + for (const file of directories.flatMap(generatedImageFiles)) { + // Base64 grows a file by a third; check before reading so an oversized file is never loaded. + const encodedLength = Math.ceil(file.size / 3) * 4 + if (encodedLength > budget.images) { omitted += 1; continue } + let data: Buffer + try { data = readFileSync(file.path) } catch { omitted += 1; continue } + const src = `data:${file.type};base64,${data.toString('base64')}` + budget.images -= src.length + images.push({ name: file.name, src, modifiedAt: file.modifiedAt }) + } + return { images, omitted } +} + +/** + * `paths` are the conversation's rollout segments, oldest first; `generatedImageDirectories` + * are the thread's `generated_images` directories the scan attributed to it. + */ +export async function readSessionTranscript(paths: string[], generatedImageDirectories: string[] = []): Promise { const messages: TranscriptMessage[] = [] let toolCalls = 0 let unreadableSegments = 0 @@ -142,10 +184,13 @@ export async function readSessionTranscript(paths: string[]): Promise MAX_MESSAGES, - unreadableSegments + unreadableSegments, + generatedImages: generated.images, + omittedGeneratedImages: generated.omitted } } diff --git a/shared/types.ts b/shared/types.ts index d4fecbb..3eefb0b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -133,6 +133,17 @@ export interface SessionTranscript { truncated: boolean /** Segments that could not be read (missing, or compressed without a decompressor). */ unreadableSegments: number + /** Images Codex generated for this thread (`generated_images//`), oldest first. */ + generatedImages: GeneratedImagePreview[] + /** Generated images left out because the preview's image budget was spent. */ + omittedGeneratedImages: number +} + +export interface GeneratedImagePreview { + name: string + /** `data:image/…` URL of the file's contents. */ + src: string + modifiedAt: number // epoch ms } export interface SessionItem { diff --git a/src/App.css b/src/App.css index 9e2b7f6..63f4c59 100644 --- a/src/App.css +++ b/src/App.css @@ -1146,6 +1146,9 @@ label.entry-label { cursor: pointer; } .transcript-message p { margin: 4px 0 0; font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; } .transcript-images { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } .transcript-images img { max-width: min(100%, 320px); max-height: 240px; object-fit: contain; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); cursor: zoom-in; } +.transcript-generated { margin: 0 14px; padding: 12px 0 14px; border-top: 1px solid var(--line); } +.transcript-body > .transcript-generated:first-child { border-top: 0; } +.transcript-generated h3 { margin: 0; font-size: 11.5px; font-weight: 600; color: var(--text-secondary); } .transcript-omitted { color: var(--text-tertiary); font-size: 11.5px; } .image-zoom { position: fixed; inset: 0; z-index: 110; display: grid; place-items: center; padding: 32px; background: rgba(10, 10, 18, 0.72); cursor: zoom-out; } .image-zoom img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); } diff --git a/src/views/SessionsView.tsx b/src/views/SessionsView.tsx index f0bb072..7e7fb7b 100644 --- a/src/views/SessionsView.tsx +++ b/src/views/SessionsView.tsx @@ -258,7 +258,7 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem;
    {error &&

    {e(error)}

    } {!error && !transcript &&

    {t('正在读取…', 'Loading…')}

    } - {transcript && !transcript.messages.length &&

    {t('这个会话里没有可显示的消息', 'No messages to show in this conversation')}

    } + {transcript && !transcript.messages.length && !transcript.generatedImages.length &&

    {t('这个会话里没有可显示的消息', 'No messages to show in this conversation')}

    } {transcript && transcript.messages.length > 0 &&
      {transcript.messages.map((item, index) =>
    1. @@ -273,6 +273,14 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; {item.omittedImages > 0 &&

      {t(`${item.omittedImages} 张图片过大,未在预览中显示`, `${item.omittedImages} images too large to preview`)}

      }
    2. )}
    } + {transcript && (transcript.generatedImages.length > 0 || transcript.omittedGeneratedImages > 0) &&
    +

    {t(`生成的图片(${transcript.generatedImages.length + transcript.omittedGeneratedImages})`, `Generated images (${transcript.generatedImages.length + transcript.omittedGeneratedImages})`)}

    +
    + {transcript.generatedImages.map((image) => {image.name} setZoomed(image.src)} />)} +
    + {transcript.omittedGeneratedImages > 0 &&

    {t(`${transcript.omittedGeneratedImages} 张图片过大,未在预览中显示`, `${transcript.omittedGeneratedImages} images too large to preview`)}

    } +
    }
    {notes.length > 0 &&

    {notes.join(' · ')}

    }
    diff --git a/tests/session-transcript.test.ts b/tests/session-transcript.test.ts index 38e1797..7de6ee3 100644 --- a/tests/session-transcript.test.ts +++ b/tests/session-transcript.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { readSessionTranscript } from '../electron/main/session-transcript' @@ -75,6 +75,23 @@ describe('session transcript', () => { expect(transcript.messages[0].images).toEqual([png]) }) + it('reads generated images oldest first and skips non-images and symlinks', async () => { + const path = rollout([item('user', '画一只猫')]) + const directory = join(roots[0], 'generated_images', 'thread') + mkdirSync(join(directory, 'nested'), { recursive: true }) + writeFileSync(join(directory, 'b.png'), Buffer.from([1, 2, 3])) + writeFileSync(join(directory, 'nested', 'a.webp'), Buffer.from([4])) + writeFileSync(join(directory, 'notes.txt'), 'not an image') + symlinkSync(join(directory, 'b.png'), join(directory, 'link.png')) + utimesSync(join(directory, 'nested', 'a.webp'), new Date('2026-01-01'), new Date('2026-01-01')) + const transcript = await readSessionTranscript([path], [directory, join(roots[0], 'missing')]) + expect(transcript.generatedImages.map(({ name, src }) => [name, src])).toEqual([ + ['a.webp', 'data:image/webp;base64,BA=='], + ['b.png', 'data:image/png;base64,AQID'] + ]) + expect(transcript.omittedGeneratedImages).toBe(0) + }) + it('caps long conversations', async () => { const path = rollout(Array.from({ length: 450 }, (_, index) => item('user', `message ${index}`))) const transcript = await readSessionTranscript([path])