Skip to content
Merged
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
10 changes: 10 additions & 0 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -249,6 +250,15 @@ 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'))
const generatedImageDirectories = session.assetURLs.filter((url) => dirname(url) === locations.generatedImages)
return readSessionTranscript([...session.segmentURLs, session.fileURL], generatedImageDirectories)
})

handle('sessions:repairLeftovers', () => {
if (codexIsRunning()) throw new MessageError(message('error.codexRunningForRepair'))
const report = deleteOrphanSessionRecords(locations.home, stateBackupDirectory())
Expand Down
14 changes: 10 additions & 4 deletions electron/main/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
196 changes: 196 additions & 0 deletions electron/main/session-transcript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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 { GeneratedImagePreview, 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
/** 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 IMAGE_TYPES: Record<string, string> = { 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 {
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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
}
function stringValue(value: unknown): string | null { return typeof value === 'string' && value.length ? value : null }

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 `<image name=… path=…>` / `</image>` 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)
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'], 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<K extends string>(item: TranscriptMessage, budgets: Record<K, number>, 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 }
}

/**
* 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.
*/
function parseTranscriptLine(line: string, into: SegmentMessages): void {
if (!line || line.length > MAX_LINE_CHARS) return
let root: Record<string, unknown>
try { root = JSON.parse(line) as Record<string, unknown> } 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 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', { 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'], 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
}

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<SegmentMessages> {
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
}

/** 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<SessionTranscript> {
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
}
const shown = messages.slice(0, MAX_MESSAGES)
const budget = { images: MAX_IMAGE_CHARS }
for (const [index, item] of shown.entries()) shown[index] = withinBudget(item, budget, 'images')
const generated = readGeneratedImages(generatedImageDirectories, budget)
return {
messages: shown,
toolCalls,
truncated: messages.length > MAX_MESSAGES,
unreadableSegments,
generatedImages: generated.images,
omittedGeneratedImages: generated.omitted
}
}
4 changes: 3 additions & 1 deletion electron/preload/index.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<SessionTranscript> => ipcRenderer.invoke('sessions:transcript', id),
revealPath: (path: string): Promise<void> => ipcRenderer.invoke('path:reveal', path),
openPath: (path: string): Promise<void> => ipcRenderer.invoke('path:open', path),
/** This app's own log folder, created on demand so the settings entry can open it. */
Expand Down
33 changes: 33 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,39 @@ export const SessionTagLabel: Record<SessionTag, string> = {
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
/** `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
}

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
/** Images Codex generated for this thread (`generated_images/<thread>/`), 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 {
id: string
threadID: string
Expand Down
26 changes: 24 additions & 2 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1131,6 +1131,28 @@ 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-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); }
.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; }
Expand Down Expand Up @@ -1355,7 +1377,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; }
}

Expand Down
6 changes: 6 additions & 0 deletions src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export function FolderIcon() {
</svg>
}

export function PreviewIcon() {
return <svg className="folder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/>
</svg>
}

export function BackIcon() {
return <svg className="navigation-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m15 5-7 7 7 7"/>
Expand Down
Loading
Loading