diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 0b888e4..dbfa692 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, clipboard, dialog, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, clipboard, dialog, shell, type IpcMainInvokeEvent } from 'electron'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; @@ -10,7 +10,22 @@ import { createIndexerService } from './indexer-service.ts'; import { createWorkerBuildIndex } from './indexer-worker-client.ts'; import { buildRecapExportQuery } from './recap-capture-query.ts'; import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts'; -import type { SourceQueryOptions } from '../shared/ipc-types.ts'; +import type { + SessionPatchCursor, + SessionPatchSnapshot, + SourceQueryOptions, +} from '../shared/ipc-types.ts'; +import type { + SessionDetailAssemblyInput, + SessionMessageRow, + SessionSubagentRow, + SessionSummaryRow, + SessionToolCallRow, + SessionToolResultRow, + SessionWorkflowRow, +} from '../shared/session-detail-types.ts'; +import { createSessionPatch } from '../shared/session-patch.mjs'; +import { assembleSessionMessages } from '../shared/session-detail-assembly.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -390,6 +405,64 @@ app.on('window-all-closed', () => { // --- IPC Handlers --- +function querySessionMessages(sessionId: string): SessionMessageRow[] { + if (!db) return []; + return db.prepare(` + SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model, + m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms, + m.content_type, m.is_meta, m.source + FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp, m.uuid + `).all(sessionId) as SessionMessageRow[]; +} + +function querySessionToolCalls(sessionId: string): SessionToolCallRow[] { + if (!db) return []; + return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId) as SessionToolCallRow[]; +} + +function querySessionToolResults(sessionId: string): SessionToolResultRow[] { + if (!db) return []; + return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId) as SessionToolResultRow[]; +} + +function querySessionSubagents(sessionId: string): SessionSubagentRow[] { + if (!db) return []; + return db.prepare(`SELECT * FROM subagents WHERE session_id = ?`).all(sessionId) as SessionSubagentRow[]; +} + +function querySessionWorkflows(sessionId: string): SessionWorkflowRow[] { + if (!db) return []; + const workflows = db.prepare(`SELECT * FROM workflows WHERE session_id = ?`).all(sessionId) as SessionWorkflowRow[]; + for (const workflow of workflows) { + workflow.agents = db.prepare(`SELECT * FROM workflow_agents WHERE run_id = ?`).all(workflow.run_id) as SessionWorkflowRow['agents']; + } + return workflows; +} + +function querySessionSummaries(sessionId: string): SessionSummaryRow[] { + if (!db) return []; + return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId) as SessionSummaryRow[]; +} + +function querySessionSnapshot(sessionId: string): SessionDetailAssemblyInput { + return { + messages: querySessionMessages(sessionId), + toolCalls: querySessionToolCalls(sessionId), + toolResults: querySessionToolResults(sessionId), + subagents: querySessionSubagents(sessionId), + workflows: querySessionWorkflows(sessionId), + summaries: querySessionSummaries(sessionId), + }; +} + +function querySessionDisplaySnapshot(sessionId: string): SessionPatchSnapshot { + const snapshot = querySessionSnapshot(sessionId); + return { + messages: assembleSessionMessages(snapshot), + workflows: snapshot.workflows, + }; +} + ipcMain.handle('db:getSessions', (_, opts = {}) => { if (!db) return []; const { project, limit = 200 } = opts; @@ -407,37 +480,32 @@ ipcMain.handle('db:getSessions', (_, opts = {}) => { }); ipcMain.handle('db:getSessionMessages', (_, sessionId) => { - if (!db) return []; - return db.prepare(` - SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model, - m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms, - m.content_type, m.is_meta, m.source - FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp, m.uuid - `).all(sessionId); + return querySessionMessages(sessionId); }); ipcMain.handle('db:getSessionToolCalls', (_, sessionId) => { - if (!db) return []; - return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId); + return querySessionToolCalls(sessionId); }); ipcMain.handle('db:getSessionToolResults', (_, sessionId) => { - if (!db) return []; - return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId); + return querySessionToolResults(sessionId); }); ipcMain.handle('db:getSessionSubagents', (_, sessionId) => { - if (!db) return []; - return db.prepare(`SELECT * FROM subagents WHERE session_id = ?`).all(sessionId); + return querySessionSubagents(sessionId); }); ipcMain.handle('db:getSessionWorkflows', (_, sessionId) => { - if (!db) return []; - const workflows = db.prepare(`SELECT * FROM workflows WHERE session_id = ?`).all(sessionId); - for (const wf of workflows) { - wf.agents = db.prepare(`SELECT * FROM workflow_agents WHERE run_id = ?`).all(wf.run_id); - } - return workflows; + return querySessionWorkflows(sessionId); +}); + +ipcMain.handle('db:getSessionPatch', ( + _event: IpcMainInvokeEvent, + sessionId: string, + cursor: SessionPatchCursor, +) => { + if (!db) return null; + return createSessionPatch(querySessionDisplaySnapshot(sessionId), cursor); }); ipcMain.handle('db:getSubagentMessages', (_, agentId) => { @@ -469,8 +537,7 @@ ipcMain.handle('db:getSubagentToolResults', (_, agentId) => { }); ipcMain.handle('db:getSessionSummaries', (_, sessionId) => { - if (!db) return []; - return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId); + return querySessionSummaries(sessionId); }); ipcMain.handle('db:getMemories', () => { diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index f0cdd1a..3ee5097 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -1,11 +1,18 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; -import type { UsageStatsOptions } from '../shared/ipc-types.ts'; +import type { + SessionPatch, + SessionPatchCursor, + UsageStatsOptions, +} from '../shared/ipc-types.ts'; contextBridge.exposeInMainWorld('obelisk', { getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts), getSessionMessages: (id: string) => ipcRenderer.invoke('db:getSessionMessages', id), getSessionToolCalls: (id: string) => ipcRenderer.invoke('db:getSessionToolCalls', id), getSessionToolResults: (id: string) => ipcRenderer.invoke('db:getSessionToolResults', id), + getSessionPatch: (id: string, cursor: SessionPatchCursor): Promise => ( + ipcRenderer.invoke('db:getSessionPatch', id, cursor) + ), getSessionSubagents: (id: string) => ipcRenderer.invoke('db:getSessionSubagents', id), getSessionWorkflows: (id: string) => ipcRenderer.invoke('db:getSessionWorkflows', id), getSubagentMessages: (agentId: string) => ipcRenderer.invoke('db:getSubagentMessages', agentId), diff --git a/app/src/renderer/src/components/SessionTimelineRow.vue b/app/src/renderer/src/components/SessionTimelineRow.vue new file mode 100644 index 0000000..7128adc --- /dev/null +++ b/app/src/renderer/src/components/SessionTimelineRow.vue @@ -0,0 +1,324 @@ + + + diff --git a/app/src/renderer/src/data.js b/app/src/renderer/src/data.js index 01863cc..9b9b8a5 100644 --- a/app/src/renderer/src/data.js +++ b/app/src/renderer/src/data.js @@ -3,6 +3,27 @@ import { markRaw } from 'vue'; import { state } from './store.js'; +import { + applySessionPatch, + createSessionPatchCursor, +} from '../../shared/session-patch.mjs'; +import { assembleSessionMessages } from '../../shared/session-detail-assembly.mjs'; + +const sessionMessageSnapshots = new Map(); +const MAX_SESSION_MESSAGE_SNAPSHOTS = 3; + +function rememberSessionMessageSnapshot(sessionId, entry) { + sessionMessageSnapshots.delete(sessionId); + sessionMessageSnapshots.set(sessionId, entry); + while (sessionMessageSnapshots.size > MAX_SESSION_MESSAGE_SNAPSHOTS) { + sessionMessageSnapshots.delete(sessionMessageSnapshots.keys().next().value); + } +} + +function invalidateStoredSessionMessages(sessionId) { + const session = state.sessions.find(candidate => candidate.id === sessionId); + if (session?.messages?.length) session.messages = markRaw([]); +} /** * Load initial data from the DB and populate state.memories, state.sessions, @@ -48,213 +69,72 @@ export async function loadInitialData() { * Returns the assembled session object (also updates state.sessions entry). */ export async function loadSessionDetail(sessionId) { - const [messages, toolCalls, toolResults, subagents, workflows, summaries] = - await Promise.all([ - window.obelisk.getSessionMessages(sessionId), - window.obelisk.getSessionToolCalls(sessionId), - window.obelisk.getSessionToolResults(sessionId), - window.obelisk.getSessionSubagents(sessionId), - window.obelisk.getSessionWorkflows(sessionId), - window.obelisk.getSessionSummaries(sessionId) - ]); - - // Index tool results by tool_use_id for fast lookup - const resultsByCallId = {}; - for (const r of (toolResults || [])) { - resultsByCallId[r.tool_use_id] = r; - } - - // Index subagents by parent_tool_use_id - const subagentsByCallId = {}; - for (const sa of (subagents || [])) { - if (sa.parent_tool_use_id) { - subagentsByCallId[sa.parent_tool_use_id] = sa; - } - } - - // Group tool_calls by message_uuid, attaching result and subagent inline - const callsByMessageUuid = {}; - for (const tc of (toolCalls || [])) { - const call = { - id: tc.id, - name: tc.name, - input_json: tc.input_json, - result: resultsByCallId[tc.id] || null - }; - - // Attach subagent data if present - const sa = subagentsByCallId[tc.id]; - if (sa) { - call.subagent = { - agent_id: sa.agent_id, - agent_type: sa.agent_type, - description: sa.description - }; - } - - const msgUuid = tc.message_uuid; - if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; - callsByMessageUuid[msgUuid].push(call); - } - - // Attach workflow data to Workflow tool calls - for (const wf of (workflows || [])) { - for (const calls of Object.values(callsByMessageUuid)) { - for (const call of calls) { - if (call.name === 'Workflow' && !call.workflow) { - const resultText = call.result?.content || ''; - if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) { - call.workflow = { - run_id: wf.run_id, - workflow_name: wf.workflow_name, - status: wf.status, - duration_ms: wf.duration_ms, - total_tokens: wf.total_tokens, - agent_count: wf.agent_count, - agents: (wf.agents || []).map(a => ({ - agent_id: a.agent_id, - phase: a.phase, - label: a.label, - state: a.state, - tokens: a.tokens, - duration_ms: a.duration_ms, - })) - }; - } - } - } - } - } - - // Index summaries by session - const sessionSummaries = (summaries || []).map(s => ({ - source: s.source, - content: s.content, - timestamp: s.timestamp - })); - - // Assemble messages with tool_calls inline - const META_RE = /^\s*<(task-notification|command-name|local-command|system-reminder)/; - const rawAssembled = (messages || []).map(msg => { - const assembled = { - uuid: msg.uuid, - type: msg.type || msg.role, - timestamp: msg.timestamp, - text: msg.text, - content_type: msg.content_type || null, - is_meta: msg.is_meta || (msg.text && META_RE.test(msg.text) ? 1 : 0) - }; - - const calls = callsByMessageUuid[msg.uuid]; - if (calls && calls.length > 0) { - assembled.tool_calls = calls; - } - - return assembled; + const [messages, toolCalls, toolResults, subagents, workflows, summaries] = await Promise.all([ + window.obelisk.getSessionMessages(sessionId), + window.obelisk.getSessionToolCalls(sessionId), + window.obelisk.getSessionToolResults(sessionId), + window.obelisk.getSessionSubagents(sessionId), + window.obelisk.getSessionWorkflows(sessionId), + window.obelisk.getSessionSummaries(sessionId), + ]); + const snapshot = { + messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }), + workflows, + }; + rememberSessionMessageSnapshot(sessionId, { + snapshot, + cursor: createSessionPatchCursor(snapshot), }); + return commitSessionDetail(sessionId, snapshot, { updateStore: true }); +} - // Merge adjacent assistant messages: - // - tool_result user messages are skipped (results shown inside tool_call panels) - // - consecutive tool_use messages (separated by tool_results) merge into one - // - thinking messages merge into the next non-thinking assistant message - const assembledMessages = []; - for (let i = 0; i < rawAssembled.length; i++) { - const msg = rawAssembled[i]; - - // Skip tool_result user messages - if (msg.content_type === 'tool_result') continue; - - // For thinking messages, collect consecutive thinking blocks and attach to the next assistant - if (msg.type === 'assistant' && msg.content_type === 'thinking') { - const thinkingParts = [msg.text || '']; - let j = i + 1; - while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { - thinkingParts.push(rawAssembled[j].text || ''); - j++; - } - if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { - rawAssembled[j]._thinking = thinkingParts.join('\n\n'); - i = j - 1; - continue; - } - assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); - i = j - 1; - continue; - } - - // For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results and skill meta) - if (msg.type === 'assistant' && msg.content_type === 'tool_use') { - const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; - if (msg._thinking) merged._thinking = msg._thinking; - - // If this is a Skill-only message, don't merge with subsequent tool_use — keep it standalone - const isSkillOnly = merged.tool_calls.length === 1 && merged.tool_calls[0].name === 'Skill'; - - let j = i + 1; - while (j < rawAssembled.length) { - const next = rawAssembled[j]; - if (next.content_type === 'tool_result') { j++; continue; } - // Absorb skill.md meta message into the skill tool call - if (next.is_meta && next.text && next.text.includes('Base directory for this skill')) { - merged._skillMd = next.text; - j++; - continue; - } - if (!isSkillOnly && next.type === 'assistant' && next.content_type === 'tool_use') { - if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); - if (next.text && !merged.text) merged.text = next.text; - j++; - continue; - } - break; - } - assembledMessages.push(merged); - i = j - 1; - } else { - const out = { ...msg }; - if (msg._thinking) out._thinking = msg._thinking; - // For text assistant messages, absorb following tool_use messages (Codex pattern) - if (msg.type === 'assistant' && msg.content_type !== 'tool_use' && msg.content_type !== 'thinking') { - if (!out.tool_calls) out.tool_calls = []; - let j = i + 1; - while (j < rawAssembled.length) { - const next = rawAssembled[j]; - if (next.content_type === 'tool_result') { j++; continue; } - if (next.type === 'assistant' && next.content_type === 'tool_use') { - if (next.tool_calls) out.tool_calls.push(...next.tool_calls); - j++; - continue; - } - break; - } - if (!out.tool_calls.length) delete out.tool_calls; - i = j - 1; - } - assembledMessages.push(out); - } +export async function loadSessionDetailPatch(sessionId) { + const current = sessionMessageSnapshots.get(sessionId); + if (!current || typeof window.obelisk.getSessionPatch !== 'function') { + return loadSessionDetail(sessionId); } + const patch = await window.obelisk.getSessionPatch(sessionId, current.cursor); + if (!patch) return loadSessionDetail(sessionId); + const next = applySessionPatch(current.snapshot, current.cursor, patch); + const latest = commitSessionDetail(sessionId, next.snapshot, { updateStore: false }); + latest.acceptMessagePatch = () => { + if (sessionMessageSnapshots.get(sessionId) !== current) return false; + rememberSessionMessageSnapshot(sessionId, next); + invalidateStoredSessionMessages(sessionId); + return true; + }; + latest.messagePatch = { + changedIds: (patch.changes?.messages || []).map(message => message.uuid), + removedIds: patch.removed?.messages || [], + tailOnly: (patch.removed?.messages || []).length === 0 + && (patch.changes?.messages || []).length > 0 + && (patch.changes?.messages || []).every((message, offset) => ( + !Object.hasOwn(current.cursor.messages || {}, message.uuid) + && patch.positions?.messages?.[message.uuid] === current.snapshot.messages.length + offset + )), + }; + return latest; +} - // Attach workflow data if present - const workflow = (workflows && workflows.length > 0) ? workflows[0] : null; +export function getCachedSessionDetail(sessionId) { + const current = sessionMessageSnapshots.get(sessionId); + if (!current) return null; + return commitSessionDetail(sessionId, current.snapshot, { updateStore: false }); +} - // Build assembled session object - const session = state.sessions.find(s => s.id === sessionId); +function commitSessionDetail(sessionId, { messages, workflows = [] }, { updateStore }) { + const session = state.sessions.find(candidate => candidate.id === sessionId); const assembled = { ...(session || {}), id: sessionId, - messages: markRaw(assembledMessages) + messages: markRaw(messages), }; + if (workflows.length > 0) assembled.workflow = workflows[0]; - if (workflow) { - assembled.workflow = workflow; + if (updateStore) { + const index = state.sessions.findIndex(candidate => candidate.id === sessionId); + if (index !== -1) state.sessions[index] = assembled; } - - // Update in-place in state.sessions - const idx = state.sessions.findIndex(s => s.id === sessionId); - if (idx !== -1) { - state.sessions[idx] = assembled; - } - return assembled; } @@ -268,85 +148,13 @@ export async function loadSubagentDetail(agentId) { window.obelisk.getSubagentToolCalls(agentId), window.obelisk.getSubagentToolResults(agentId), ]); - - const resultsByCallId = {}; - for (const r of (toolResults || [])) { - resultsByCallId[r.tool_use_id] = r; - } - - const callsByMessageUuid = {}; - for (const tc of (toolCalls || [])) { - const call = { - id: tc.id, - name: tc.name, - input_json: tc.input_json, - result: resultsByCallId[tc.id] || null - }; - const msgUuid = tc.message_uuid; - if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; - callsByMessageUuid[msgUuid].push(call); - } - - const rawAssembled = (messages || []).map(msg => { - const assembled = { - uuid: msg.uuid, - type: msg.type || msg.role, - timestamp: msg.timestamp, - text: msg.text, - content_type: msg.content_type || null, - is_meta: msg.is_meta || 0 - }; - const calls = callsByMessageUuid[msg.uuid]; - if (calls && calls.length > 0) assembled.tool_calls = calls; - return assembled; + return assembleSessionMessages({ + messages, + toolCalls, + toolResults, + subagents: [], + workflows: [], }); - - // Same merging logic as session detail - const assembledMessages = []; - for (let i = 0; i < rawAssembled.length; i++) { - const msg = rawAssembled[i]; - if (msg.content_type === 'tool_result') continue; - if (msg.type === 'assistant' && msg.content_type === 'thinking') { - const thinkingParts = [msg.text || '']; - let j = i + 1; - while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { - thinkingParts.push(rawAssembled[j].text || ''); - j++; - } - if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { - rawAssembled[j]._thinking = thinkingParts.join('\n\n'); - i = j - 1; - continue; - } - assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); - i = j - 1; - continue; - } - if (msg.type === 'assistant' && msg.content_type === 'tool_use') { - const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; - if (msg._thinking) merged._thinking = msg._thinking; - let j = i + 1; - while (j < rawAssembled.length) { - const next = rawAssembled[j]; - if (next.content_type === 'tool_result') { j++; continue; } - if (next.type === 'assistant' && next.content_type === 'tool_use') { - if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); - if (next.text && !merged.text) merged.text = next.text; - j++; - continue; - } - break; - } - assembledMessages.push(merged); - i = j - 1; - } else { - const out = { ...msg }; - if (msg._thinking) out._thinking = msg._thinking; - assembledMessages.push(out); - } - } - - return assembledMessages; } const TEXT_LIMIT = 10000; diff --git a/app/src/renderer/src/session-live.mjs b/app/src/renderer/src/session-live.mjs index a969469..766e5fa 100644 --- a/app/src/renderer/src/session-live.mjs +++ b/app/src/renderer/src/session-live.mjs @@ -6,13 +6,17 @@ export function createSessionLiveState() { export const sessionLiveState = createSessionLiveState(); +export function markSessionDirty(sessionId, live = sessionLiveState) { + if (sessionId) live.dirtySessions.add(sessionId); +} + export function noteSessionUpdated(live, sessionId, currentSessionId = null) { if (!sessionId) return { reload: false, sessionId: null }; if (sessionId === currentSessionId) { live.dirtySessions.delete(sessionId); return { reload: true, sessionId }; } - live.dirtySessions.add(sessionId); + markSessionDirty(sessionId, live); return { reload: false, sessionId }; } diff --git a/app/src/renderer/src/session-timeline-presentation.mjs b/app/src/renderer/src/session-timeline-presentation.mjs new file mode 100644 index 0000000..cc28087 --- /dev/null +++ b/app/src/renderer/src/session-timeline-presentation.mjs @@ -0,0 +1,294 @@ +import { getArgPreview, getToolIcon, renderTerminalTool } from './tool-renderer.js'; +import { renderMarkdown } from './utils.js'; + +function escapeHtml(value) { + return String(value).replace(/&/g, '&').replace(//g, '>'); +} + +function parseToolInput(toolCall) { + try { + return JSON.parse(toolCall.input_json || '{}'); + } catch { + return {}; + } +} + +function formatToolInput(toolCall) { + try { + return JSON.stringify(JSON.parse(toolCall.input_json || '{}'), null, 2); + } catch { + return toolCall.input_json || ''; + } +} + +function renderFileContent(text) { + let lines = text.split('\n'); + const hasLineNums = lines.length > 1 && lines.slice(0, 5).every(line => /^\s*\d+\t/.test(line) || line === ''); + let gutter; + if (hasLineNums) { + const parsed = lines.map(line => { + const match = line.match(/^\s*(\d+)\t(.*)$/); + return match ? { num: match[1], code: match[2] } : { num: '', code: line }; + }); + gutter = parsed.map(line => line.num).join('\n'); + lines = parsed.map(line => line.code); + } else { + gutter = lines.map((_, index) => index + 1).join('\n'); + } + const total = lines.length; + const collapsed = total > 12; + return `
+
File contents${total} lines
+
${gutter}
${escapeHtml(lines.join('\n'))}
+ ${collapsed ? `` : ''} +
`; +} + +function renderDiff(oldString, newString) { + const oldLines = oldString.split('\n'); + const newLines = newString.split('\n'); + let prefix = 0; + while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++; + let suffix = 0; + while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++; + + const result = []; + for (let index = 0; index < prefix; index++) result.push({ kind: 'context', text: oldLines[index], oldNo: index + 1, newNo: index + 1 }); + for (let index = prefix; index < oldLines.length - suffix; index++) result.push({ kind: 'del', text: oldLines[index], oldNo: index + 1, newNo: null }); + for (let index = prefix; index < newLines.length - suffix; index++) result.push({ kind: 'add', text: newLines[index], oldNo: null, newNo: index + 1 }); + for (let index = 0; index < suffix; index++) { + result.push({ + kind: 'context', + text: oldLines[oldLines.length - suffix + index], + oldNo: oldLines.length - suffix + index + 1, + newNo: newLines.length - suffix + index + 1, + }); + } + + const adds = result.filter(line => line.kind === 'add').length; + const dels = result.filter(line => line.kind === 'del').length; + const rows = result.map(line => { + const oldNumber = line.oldNo == null ? ' ' : String(line.oldNo); + const newNumber = line.newNo == null ? ' ' : String(line.newNo); + return `
${oldNumber.padStart(3)} ${newNumber.padStart(3)}
${escapeHtml(line.text)}
`; + }).join(''); + + return `
+
Diff
+${adds}−${dels}
+
${rows}
+
`; +} + +function renderValue(value) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean') return `${value}`; + if (typeof value === 'number') return `${value}`; + if (typeof value === 'string') { + if (/^https?:\/\//.test(value)) return `${escapeHtml(value)}`; + if (value.length > 120) { + return `"${escapeHtml(value.slice(0, 120))}${escapeHtml(value.slice(120))}"`; + } + return `"${escapeHtml(value)}"`; + } + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + if (value.length <= 4 && value.every(item => typeof item !== 'object')) { + return `[${value.map(item => renderValue(item)).join(', ')}]`; + } + return `Array(${value.length})`; + } + if (typeof value === 'object') return `Object(${Object.keys(value).length})`; + return `${escapeHtml(String(value))}`; +} + +function renderFieldGrid(object) { + const entries = Object.entries(object); + if (!entries.length) return ''; + const rows = entries.map(([key, value]) => ( + `
${escapeHtml(key)}
${renderValue(value)}
` + )).join(''); + return `
${rows}
`; +} + +function extractHero(object) { + if (!object || typeof object !== 'object') return null; + const titleKey = ['title', 'name', 'summary'].find(key => typeof object[key] === 'string'); + const urlKey = ['url', 'permalink', 'href', 'link'].find(key => typeof object[key] === 'string' && /^https?:/.test(object[key])); + const idKey = ['id', 'identifier', 'uuid', 'key'].find(key => typeof object[key] === 'string'); + if (!titleKey && !urlKey && !idKey) return null; + return { titleKey, urlKey, idKey }; +} + +function renderObjectOutput(object) { + const hero = extractHero(object); + let rest = object; + if (hero) { + rest = { ...object }; + if (hero.titleKey) delete rest[hero.titleKey]; + if (hero.urlKey) delete rest[hero.urlKey]; + if (hero.idKey) delete rest[hero.idKey]; + } + let html = ''; + if (hero) { + html += '
'; + if (hero.titleKey) html += `
${escapeHtml(object[hero.titleKey])}
`; + const subtitle = []; + if (hero.idKey) subtitle.push(escapeHtml(object[hero.idKey])); + if (hero.urlKey) subtitle.push(escapeHtml(object[hero.urlKey])); + if (subtitle.length) html += `
${subtitle.join(' · ')}
`; + html += '
'; + } + if (Object.keys(rest).length) html += renderFieldGrid(rest); + return html; +} + +function renderAutoTable(rows) { + const sample = rows.slice(0, 5); + const allKeys = new Set(); + for (const row of sample) Object.keys(row).forEach(key => allKeys.add(key)); + const columns = Array.from(allKeys); + const head = columns.map(column => `${escapeHtml(column)}`).join(''); + const body = rows.slice(0, 50).map(row => ( + `${columns.map(column => { + const value = row[column]; + if (value == null) return ''; + if (typeof value === 'string' && value.length > 60) return `${escapeHtml(value.slice(0, 60))}…`; + if (typeof value === 'object') return `${renderValue(value)}`; + return `${escapeHtml(String(value))}`; + }).join('')}` + )).join(''); + return `
+
Result${rows.length} items · ${columns.length} columns
+
${head}${body}
+
`; +} + +function renderOutput(output, isError) { + if (!output) return '
No output.
'; + + let parsed = null; + try { parsed = JSON.parse(output); } catch {} + if (parsed !== null && typeof parsed === 'object') { + if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(item => item && typeof item === 'object' && !Array.isArray(item))) { + return renderAutoTable(parsed); + } + if (Array.isArray(parsed)) return renderFieldGrid(Object.fromEntries(parsed.map((item, index) => [index, item]))); + return renderObjectOutput(parsed); + } + + if (output.includes('\n')) { + const lines = output.split('\n'); + const total = lines.length; + const collapsed = total > 10; + const gutter = lines.map((_, index) => index + 1).join('\n'); + return `
+
${gutter}
${escapeHtml(output)}
+ ${collapsed ? `` : ''} +
`; + } + + return `
${escapeHtml(output)}
`; +} + +function renderPrettyTool(toolCall) { + const args = parseToolInput(toolCall); + const result = toolCall.result || {}; + const isError = Boolean(result.is_error); + const output = result.content || ''; + + if (toolCall.name === 'Read') { + if (!output) return '
No content returned.
'; + return renderFileContent(output); + } + + if (toolCall.name === 'Write') { + const path = args.file_path || args.path || '?'; + const header = `
+ Writing + ${escapeHtml(path)} +
`; + let content = ''; + if (args.content) { + const lines = args.content.split('\n'); + const gutter = lines.map((_, index) => index + 1).join('\n'); + content = `
+
New file${lines.length} lines
+ +
`; + } + return header + content + `
${escapeHtml(output)}
`; + } + + if (toolCall.name === 'Edit') { + const diff = args.old_string && args.new_string ? renderDiff(args.old_string, args.new_string) : ''; + return diff + `
${escapeHtml(output)}
`; + } + + const terminal = renderTerminalTool(toolCall.name, args, output, isError); + if (terminal !== null) return terminal; + return `
Input
${renderFieldGrid(args)}
` + + (output ? `
Output
${renderOutput(output, isError)}
` : ''); +} + +function groupWorkflowAgents(workflow) { + const phases = {}; + for (const agent of (workflow?.agents || [])) { + const phase = agent.phase || 'Other'; + if (!phases[phase]) phases[phase] = []; + phases[phase].push(agent); + } + return phases; +} + +export function buildSessionTimelinePresentation(item, { query = '', expandedText } = {}) { + const message = item?.message || {}; + const toolCalls = item?.kind === 'workflow-tools' + ? (item.toolCalls || []) + : (message.tool_calls || []); + const toolInputs = new Map(); + const toolInputText = new Map(); + const toolPrettyHtml = new Map(); + const toolResultHtml = new Map(); + const toolArgPreviews = new Map(); + const toolIcons = new Map(); + const workflowAgentGroups = new Map(); + + for (const toolCall of toolCalls) { + const input = parseToolInput(toolCall); + toolInputs.set(toolCall.id, input); + toolInputText.set(toolCall.id, formatToolInput(toolCall)); + toolArgPreviews.set(toolCall.id, getArgPreview(toolCall)); + toolIcons.set(toolCall.id, getToolIcon(toolCall.name)); + if (item?.kind === 'workflow-tools' || !['Skill', 'Agent', 'Task', 'Workflow'].includes(toolCall.name)) { + toolPrettyHtml.set(toolCall.id, renderPrettyTool(toolCall)); + } + if ((toolCall.name === 'Agent' || toolCall.name === 'Task') && toolCall.result?.content) { + toolResultHtml.set(toolCall.id, renderMarkdown(toolCall.result.content, { variant: 'compact' })); + } + if (toolCall.name === 'Workflow') workflowAgentGroups.set(toolCall.id, groupWorkflowAgents(toolCall.workflow)); + } + + const effectiveText = expandedText ?? message.text; + return { + messageHtml: message.text + ? renderMarkdown(effectiveText, { variant: item?.kind === 'meta' ? 'compact' : 'msg', query }) + : '', + thinkingHtml: message._thinking + ? renderMarkdown(message._thinking, { variant: 'msg', query }) + : (item?.kind === 'thinking' ? renderMarkdown(message.text, { variant: 'msg', query }) : ''), + skillHtml: message._skillMd ? renderMarkdown(message._skillMd, { variant: 'compact' }) : '', + summaryHtml: message.summary?.content + ? renderMarkdown(message.summary.content, { variant: 'compact' }) + : '', + toolInputs, + toolInputText, + toolPrettyHtml, + toolResultHtml, + toolArgPreviews, + toolIcons, + workflowAgentGroups, + standaloneWorkflowGroups: item?.kind === 'workflow' + ? groupWorkflowAgents(item.workflowCall?.workflow) + : {}, + }; +} diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index 6637174..cc69073 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -2,20 +2,17 @@ import { ref, shallowRef, computed, reactive, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue'; import { useRouter, useRoute } from 'vue-router'; import { state, FOLDER_SVG } from '../store.js'; -import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js'; -import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs'; +import { getCachedSessionDetail, loadSessionDetail, loadSessionDetailPatch, loadFullText } from '../data.js'; +import { clearSessionDirty, consumeGlobalSessionDirty, markSessionDirty } from '../session-live.mjs'; import { applySnapshot } from '../session-timeline.mjs'; import { reconcileTimelineItems } from '../session-timeline-items.mjs'; import { createSessionDisclosureState } from '../session-disclosures.mjs'; import { createSessionLiveReloadCoordinator } from '../session-live-reload.mjs'; import { useSessionTimelineViewport } from '../session-timeline-viewport.mjs'; -import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js'; import FlapNumber from '../components/FlapNumber.vue'; +import SessionTimelineRow from '../components/SessionTimelineRow.vue'; import { - escapeHTML, fmtRelative, - fmtClockTime, - renderMarkdown, formatProjectLabel } from '../utils.js'; @@ -223,6 +220,8 @@ async function loadMessages({ force = false } = {}) { } async function fetchSessionSnapshot(sessionId, { force = false } = {}) { + const messageSnapshot = force ? null : getCachedSessionDetail(sessionId); + if (messageSnapshot) return messageSnapshot; const cached = state.sessions.find(session => session.id === sessionId); if (cached && (force || !cached.messages || cached.messages.length === 0)) { return loadSessionDetail(sessionId); @@ -234,14 +233,19 @@ async function loadLiveSnapshot() { const sessionId = props.id; if (!sessionId) return null; const revision = ++loadRevision; - const latest = await fetchSessionSnapshot(sessionId, { force: true }); - clearSessionDirty(sessionId); + const latest = await loadSessionDetailPatch(sessionId); return { sessionId, revision, latest }; } async function commitLiveSnapshot(snapshot) { - if (snapshot.revision !== loadRevision || snapshot.sessionId !== props.id) return; + if (snapshot.revision !== loadRevision || snapshot.sessionId !== props.id) { + markSessionDirty(snapshot.sessionId); + return; + } await commitSessionSnapshot(snapshot.latest); + const accepted = snapshot.latest?.acceptMessagePatch?.() ?? true; + if (accepted) clearSessionDirty(snapshot.sessionId); + else markSessionDirty(snapshot.sessionId); } async function commitSessionSnapshot(latest) { @@ -249,16 +253,36 @@ async function commitSessionSnapshot(latest) { // first-snapshot tail following disabled until an actual session exists. if (!latest) return; const incoming = latest?.messages || []; - const reconciliation = applySnapshot(messages.value, incoming); + const tailPatch = latest.messagePatch?.tailOnly + ? { + messages: incoming, + addedIds: latest.messagePatch.changedIds, + updatedIds: [], + removedIds: [], + changed: true, + tailOnly: true, + } + : null; + const reconciliation = tailPatch || applySnapshot(messages.value, incoming); const restoreTail = reconciliation.tailOnly && timelineViewport.isFollowingTail(); if (reconciliation.changed) { messages.value = reconciliation.messages; - timelineItems.value = reconcileTimelineItems(timelineItems.value, reconciliation.messages); - const retainedMessageUuids = new Set(reconciliation.messages.map(message => message.uuid)); - disclosures.retainMessages(retainedMessageUuids); - for (const uuid of reconciliation.updatedIds) expandedMessageText.delete(uuid); - for (const uuid of expandedMessageText.keys()) { - if (!retainedMessageUuids.has(uuid)) expandedMessageText.delete(uuid); + if (tailPatch) { + const addedMessages = reconciliation.messages.slice( + reconciliation.messages.length - reconciliation.addedIds.length, + ); + timelineItems.value = [ + ...timelineItems.value, + ...reconcileTimelineItems([], addedMessages), + ]; + } else { + timelineItems.value = reconcileTimelineItems(timelineItems.value, reconciliation.messages); + const retainedMessageUuids = new Set(reconciliation.messages.map(message => message.uuid)); + disclosures.retainMessages(retainedMessageUuids); + for (const uuid of reconciliation.updatedIds) expandedMessageText.delete(uuid); + for (const uuid of expandedMessageText.keys()) { + if (!retainedMessageUuids.has(uuid)) expandedMessageText.delete(uuid); + } } } @@ -348,20 +372,7 @@ function navTo(target) { }, 50); } -// --- Toggle helpers --- -function toggleDisclosure(key, messageUuid) { - disclosures.toggleOpen(key, messageUuid); -} - // --- Full text loading --- -function displayMessageText(message) { - return expandedMessageText.get(message.uuid) ?? message.text; -} - -function canLoadFullText(message) { - return !expandedMessageText.has(message.uuid) && isTextTruncated(message.text); -} - async function handleLoadFullText(uuid) { if (fullTextLoading.has(uuid)) return; fullTextLoading.add(uuid); @@ -376,269 +387,13 @@ async function handleLoadFullText(uuid) { } // --- Subagent navigation --- -function navigateToSubagent(agentId, description) { +function navigateToSubagent(agentId) { router.push({ name: 'SubagentDetail', params: { id: props.id, agentId } }); } -function groupWorkflowAgents(workflow) { - const phases = {}; - for (const agent of (workflow?.agents || [])) { - const phase = agent.phase || 'Other'; - if (!phases[phase]) phases[phase] = []; - phases[phase].push(agent); - } - return phases; -} - -// --- Render helpers (produce raw HTML strings like the vanilla version) --- - -function formatToolInput(tc) { - try { - const j = JSON.parse(tc.input_json || '{}'); - return JSON.stringify(j, null, 2); - } catch { - return tc.input_json || ''; - } -} - -function escapeH(s) { - return String(s).replace(/&/g, '&').replace(//g, '>'); -} - -function renderPrettyTool(tc) { - let args; - try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; } - const result = tc.result || {}; - const isError = !!result.is_error; - const out = result.content || ''; - - if (tc.name === 'Read') { - const path = args.file_path || args.path || '?'; - if (!out) return '
No content returned.
'; - return renderFileContent(out); - } - - if (tc.name === 'Write') { - const path = args.file_path || args.path || '?'; - const header = `
- Writing - ${escapeH(path)} -
`; - let content = ''; - if (args.content) { - const lines = args.content.split('\n'); - const gutter = lines.map((_, i) => i + 1).join('\n'); - content = `
-
New file${lines.length} lines
- -
`; - } - const chip = `
${escapeH(out)}
`; - return header + content + chip; - } - - if (tc.name === 'Edit') { - let diff = ''; - if (args.old_string && args.new_string) diff = renderDiff(args.old_string, args.new_string); - const chip = `
${escapeH(out)}
`; - return diff + chip; - } - - const terminal = renderTerminalTool(tc.name, args, out, isError); - if (terminal !== null) return terminal; - - return `
Input
${renderFieldGrid(args)}
` + - (out ? `
Output
${renderOutput(out, isError)}
` : ''); -} - -function renderFileContent(text) { - let lines = text.split('\n'); - // Detect if content already has line numbers (e.g. " 1\tcode" from cat -n / Read tool) - const hasLineNums = lines.length > 1 && lines.slice(0, 5).every(l => /^\s*\d+\t/.test(l) || l === ''); - let gutter; - if (hasLineNums) { - const parsed = lines.map(l => { - const m = l.match(/^\s*(\d+)\t(.*)$/); - return m ? { num: m[1], code: m[2] } : { num: '', code: l }; - }); - gutter = parsed.map(p => p.num).join('\n'); - lines = parsed.map(p => p.code); - } else { - gutter = lines.map((_, i) => i + 1).join('\n'); - } - const total = lines.length; - const collapsed = total > 12; - return `
-
File contents${total} lines
-
${gutter}
${escapeH(lines.join('\n'))}
- ${collapsed ? `` : ''} -
`; -} - -function renderDiff(oldStr, newStr) { - const oldLines = oldStr.split('\n'); - const newLines = newStr.split('\n'); - let prefix = 0; - while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++; - let suffix = 0; - while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++; - - const result = []; - for (let i = 0; i < prefix; i++) result.push({ kind: 'context', text: oldLines[i], oldNo: i + 1, newNo: i + 1 }); - for (let i = prefix; i < oldLines.length - suffix; i++) result.push({ kind: 'del', text: oldLines[i], oldNo: i + 1, newNo: null }); - for (let i = prefix; i < newLines.length - suffix; i++) result.push({ kind: 'add', text: newLines[i], oldNo: null, newNo: i + 1 }); - for (let i = 0; i < suffix; i++) { - result.push({ kind: 'context', text: oldLines[oldLines.length - suffix + i], oldNo: oldLines.length - suffix + i + 1, newNo: newLines.length - suffix + i + 1 }); - } - - const adds = result.filter(d => d.kind === 'add').length; - const dels = result.filter(d => d.kind === 'del').length; - - const rows = result.map(line => { - const oldN = line.oldNo == null ? ' ' : String(line.oldNo); - const newN = line.newNo == null ? ' ' : String(line.newNo); - return `
${oldN.padStart(3)} ${newN.padStart(3)}
${escapeH(line.text)}
`; - }).join(''); - - return `
-
Diff
+${adds}−${dels}
-
${rows}
-
`; -} - -function renderFieldGrid(obj) { - const entries = Object.entries(obj); - if (!entries.length) return ''; - const rows = entries.map(([k, v]) => { - return `
${escapeH(k)}
${renderValue(v)}
`; - }).join(''); - return `
${rows}
`; -} - -function renderValue(v) { - if (v === null || v === undefined) return 'null'; - if (typeof v === 'boolean') return `${v}`; - if (typeof v === 'number') return `${v}`; - if (typeof v === 'string') { - if (/^https?:\/\//.test(v)) return `${escapeH(v)}`; - if (v.length > 120) { - return `"${escapeH(v.slice(0, 120))}${escapeH(v.slice(120))}"`; - } - return `"${escapeH(v)}"`; - } - if (Array.isArray(v)) { - if (v.length === 0) return '[]'; - if (v.length <= 4 && v.every(x => typeof x !== 'object')) return `[${v.map(x => renderValue(x)).join(', ')}]`; - return `Array(${v.length})`; - } - if (typeof v === 'object') { - const keys = Object.keys(v); - return `Object(${keys.length})`; - } - return `${escapeH(String(v))}`; -} - -function renderOutput(out, isError) { - if (!out) return '
No output.
'; - - let parsed = null; - try { parsed = JSON.parse(out); } catch {} - - if (parsed !== null && typeof parsed === 'object') { - if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => x && typeof x === 'object' && !Array.isArray(x))) { - return renderAutoTable(parsed); - } - if (Array.isArray(parsed)) { - return renderFieldGrid(Object.fromEntries(parsed.map((x, i) => [i, x]))); - } - return renderObjectOutput(parsed); - } - - if (out.includes('\n')) { - const lines = out.split('\n'); - const total = lines.length; - const collapsed = total > 10; - const gutter = lines.map((_, i) => i + 1).join('\n'); - return `
-
${gutter}
${escapeH(out)}
- ${collapsed ? `` : ''} -
`; - } - - return `
${escapeH(out)}
`; -} - -function renderObjectOutput(obj) { - const hero = extractHero(obj); - let rest = obj; - if (hero) { - rest = { ...obj }; - if (hero.titleKey) delete rest[hero.titleKey]; - if (hero.urlKey) delete rest[hero.urlKey]; - if (hero.idKey) delete rest[hero.idKey]; - } - let html = ''; - if (hero) { - html += `
`; - if (hero.titleKey) html += `
${escapeH(obj[hero.titleKey])}
`; - const sub = []; - if (hero.idKey) sub.push(escapeH(obj[hero.idKey])); - if (hero.urlKey) sub.push(escapeH(obj[hero.urlKey])); - if (sub.length) html += `
${sub.join(' · ')}
`; - html += '
'; - } - if (Object.keys(rest).length) html += renderFieldGrid(rest); - return html; -} - -function extractHero(obj) { - if (!obj || typeof obj !== 'object') return null; - const titleKey = ['title', 'name', 'summary'].find(k => typeof obj[k] === 'string'); - const urlKey = ['url', 'permalink', 'href', 'link'].find(k => typeof obj[k] === 'string' && /^https?:/.test(obj[k])); - const idKey = ['id', 'identifier', 'uuid', 'key'].find(k => typeof obj[k] === 'string'); - if (!titleKey && !urlKey && !idKey) return null; - return { titleKey, urlKey, idKey }; -} - -function renderAutoTable(rows) { - const sample = rows.slice(0, 5); - const allKeys = new Set(); - for (const row of sample) Object.keys(row).forEach(k => allKeys.add(k)); - const cols = Array.from(allKeys); - const head = cols.map(c => `${escapeH(c)}`).join(''); - const body = rows.slice(0, 50).map(row => - `${cols.map(c => { - const v = row[c]; - if (v == null) return ''; - if (typeof v === 'string' && v.length > 60) return `${escapeH(v.slice(0, 60))}…`; - if (typeof v === 'object') return `${renderValue(v)}`; - return `${escapeH(String(v))}`; - }).join('')}` - ).join(''); - return `
-
Result${rows.length} items · ${cols.length} columns
-
${head}${body}
-
`; -} - -function toggleRaw(key, messageUuid) { - disclosures.toggleRaw(key, messageUuid); -} - -function getSkillMd(msg) { - return msg?._skillMd || null; -} - -function getToolCallParsedInput(tc) { - try { - return JSON.parse(tc.input_json || '{}'); - } catch { - return {}; - } -} diff --git a/app/src/shared/ipc-types.ts b/app/src/shared/ipc-types.ts index 19c0a67..0bb5b3f 100644 --- a/app/src/shared/ipc-types.ts +++ b/app/src/shared/ipc-types.ts @@ -3,3 +3,27 @@ export interface SourceQueryOptions { } export type UsageStatsOptions = SourceQueryOptions; + +export type SessionPatchTable = + | 'messages' + | 'toolCalls' + | 'toolResults' + | 'subagents' + | 'workflows' + | 'summaries'; + +export type SessionPatchRow = Record; +export type SessionPatchSnapshot = Partial>; +export type SessionPatchCursor = Record>; + +export interface SessionPatch { + changes: Record; + removed: Record; + hashes: Record>; + positions: Record>; +} + +export interface AppliedSessionPatch { + snapshot: Record; + cursor: SessionPatchCursor; +} diff --git a/app/src/shared/session-detail-assembly.mjs b/app/src/shared/session-detail-assembly.mjs new file mode 100644 index 0000000..9b11b47 --- /dev/null +++ b/app/src/shared/session-detail-assembly.mjs @@ -0,0 +1,173 @@ +// @ts-check + +/** @typedef {import('./session-detail-types.ts').AssembledMessage} AssembledMessage */ +/** @typedef {import('./session-detail-types.ts').AssembledToolCall} AssembledToolCall */ +/** @typedef {import('./session-detail-types.ts').SessionDetailAssemblyInput} SessionDetailAssemblyInput */ +/** @typedef {import('./session-detail-types.ts').SessionSubagentRow} SessionSubagentRow */ +/** @typedef {import('./session-detail-types.ts').SessionToolResultRow} SessionToolResultRow */ + +/** + * @param {SessionDetailAssemblyInput} input + * @returns {AssembledMessage[]} + */ +export function assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }) { + const resultsByCallId = /** @type {Map} */ (new Map()); + for (const result of toolResults || []) resultsByCallId.set(result.tool_use_id, result); + + const subagentsByCallId = /** @type {Map} */ (new Map()); + for (const subagent of subagents || []) { + if (subagent.parent_tool_use_id) subagentsByCallId.set(subagent.parent_tool_use_id, subagent); + } + + const callsByMessageUuid = /** @type {Map} */ (new Map()); + for (const toolCall of toolCalls || []) { + const call = /** @type {AssembledToolCall} */ ({ + id: toolCall.id, + name: toolCall.name, + input_json: toolCall.input_json, + result: resultsByCallId.get(toolCall.id) || null, + }); + const subagent = subagentsByCallId.get(toolCall.id); + if (subagent) { + call.subagent = { + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, + description: subagent.description, + }; + } + const messageUuid = toolCall.message_uuid; + const calls = callsByMessageUuid.get(messageUuid) || []; + calls.push(call); + callsByMessageUuid.set(messageUuid, calls); + } + + for (const workflow of workflows || []) { + for (const calls of callsByMessageUuid.values()) { + for (const call of calls) { + if (call.name !== 'Workflow' || call.workflow) continue; + const resultText = call.result?.content || ''; + if (!resultText.includes(workflow.run_id) && !resultText.includes(workflow.workflow_name || '___none___')) continue; + call.workflow = { + run_id: workflow.run_id, + workflow_name: workflow.workflow_name, + status: workflow.status, + duration_ms: workflow.duration_ms, + total_tokens: workflow.total_tokens, + agent_count: workflow.agent_count, + agents: (workflow.agents || []).map(agent => ({ + agent_id: agent.agent_id, + phase: agent.phase, + label: agent.label, + state: agent.state, + tokens: agent.tokens, + duration_ms: agent.duration_ms, + })), + }; + } + } + } + + const metaPattern = /^\s*<(task-notification|command-name|local-command|system-reminder)/; + const rawAssembled = (messages || []).map(message => { + const assembled = /** @type {AssembledMessage} */ ({ + uuid: message.uuid, + type: message.type || message.role, + timestamp: message.timestamp, + text: message.text, + content_type: message.content_type || null, + is_meta: message.is_meta || (message.text && metaPattern.test(message.text) ? 1 : 0), + }); + const calls = callsByMessageUuid.get(message.uuid); + if (calls?.length) assembled.tool_calls = calls; + return assembled; + }); + + const assembledMessages = /** @type {AssembledMessage[]} */ ([]); + for (let index = 0; index < rawAssembled.length; index++) { + const message = rawAssembled[index]; + if (message.content_type === 'tool_result') continue; + + if (message.type === 'assistant' && message.content_type === 'thinking') { + const thinkingParts = [message.text || '']; + let nextIndex = index + 1; + while ( + nextIndex < rawAssembled.length + && rawAssembled[nextIndex].type === 'assistant' + && rawAssembled[nextIndex].content_type === 'thinking' + ) { + thinkingParts.push(rawAssembled[nextIndex].text || ''); + nextIndex++; + } + if ( + nextIndex < rawAssembled.length + && rawAssembled[nextIndex].type === 'assistant' + && rawAssembled[nextIndex].content_type !== 'thinking' + ) { + rawAssembled[nextIndex]._thinking = thinkingParts.join('\n\n'); + index = nextIndex - 1; + continue; + } + assembledMessages.push({ ...message, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); + index = nextIndex - 1; + continue; + } + + if (message.type === 'assistant' && message.content_type === 'tool_use') { + const merged = /** @type {AssembledMessage} */ ({ + ...message, + tool_calls: [...(message.tool_calls || [])], + }); + const mergedCalls = merged.tool_calls || []; + if (message._thinking) merged._thinking = message._thinking; + const skillOnly = mergedCalls.length === 1 && mergedCalls[0].name === 'Skill'; + let nextIndex = index + 1; + while (nextIndex < rawAssembled.length) { + const next = rawAssembled[nextIndex]; + if (next.content_type === 'tool_result') { + nextIndex++; + continue; + } + if (next.is_meta && next.text && next.text.includes('Base directory for this skill')) { + merged._skillMd = next.text; + nextIndex++; + continue; + } + if (!skillOnly && next.type === 'assistant' && next.content_type === 'tool_use') { + if (next.tool_calls) mergedCalls.push(...next.tool_calls); + if (next.text && !merged.text) merged.text = next.text; + nextIndex++; + continue; + } + break; + } + assembledMessages.push(merged); + index = nextIndex - 1; + continue; + } + + const output = /** @type {AssembledMessage} */ ({ ...message }); + if (message._thinking) output._thinking = message._thinking; + if (message.type === 'assistant' && message.content_type !== 'tool_use' && message.content_type !== 'thinking') { + if (!output.tool_calls) output.tool_calls = []; + let nextIndex = index + 1; + while (nextIndex < rawAssembled.length) { + const next = rawAssembled[nextIndex]; + if (next.content_type === 'tool_result') { + nextIndex++; + continue; + } + if (next.type === 'assistant' && next.content_type === 'tool_use') { + if (next.tool_calls) output.tool_calls.push(...next.tool_calls); + nextIndex++; + continue; + } + break; + } + if (!output.tool_calls.length) delete output.tool_calls; + index = nextIndex - 1; + } + assembledMessages.push(output); + } + + return assembledMessages; +} diff --git a/app/src/shared/session-detail-types.ts b/app/src/shared/session-detail-types.ts new file mode 100644 index 0000000..00073e4 --- /dev/null +++ b/app/src/shared/session-detail-types.ts @@ -0,0 +1,105 @@ +export interface SessionMessageRow { + [key: string]: unknown; + uuid: string; + type?: string | null; + role?: string | null; + timestamp?: string | null; + text?: string | null; + content_type?: string | null; + is_meta?: number | boolean | null; +} + +export interface SessionToolResultRow { + [key: string]: unknown; + tool_use_id: string; + content?: string | null; +} + +export interface SessionToolCallRow { + [key: string]: unknown; + id: string; + message_uuid: string; + name: string; + input_json?: string | null; +} + +export interface SessionSubagentRow { + [key: string]: unknown; + agent_id: string; + parent_tool_use_id?: string | null; + agent_type?: string | null; + description?: string | null; +} + +export interface SessionWorkflowAgentRow { + [key: string]: unknown; + agent_id: string; + phase?: string | null; + label?: string | null; + state?: string | null; + tokens?: number | null; + duration_ms?: number | null; +} + +export interface SessionWorkflowRow { + [key: string]: unknown; + run_id: string; + workflow_name?: string | null; + status?: string | null; + duration_ms?: number | null; + total_tokens?: number | null; + agent_count?: number | null; + agents?: SessionWorkflowAgentRow[] | null; +} + +export interface SessionSummaryRow { + [key: string]: unknown; + id: string | number; +} + +export interface SessionDetailAssemblyInput { + messages?: SessionMessageRow[]; + toolCalls?: SessionToolCallRow[]; + toolResults?: SessionToolResultRow[]; + subagents?: SessionSubagentRow[]; + workflows?: SessionWorkflowRow[]; + summaries?: SessionSummaryRow[]; +} + +export interface AssembledToolCall { + [key: string]: unknown; + id: string; + name: string; + input_json?: string | null; + result: SessionToolResultRow | null; + subagent?: { + agent_id: string; + agent_type?: string | null; + description?: string | null; + }; + workflow?: { + run_id: string; + workflow_name?: string | null; + status?: string | null; + duration_ms?: number | null; + total_tokens?: number | null; + agent_count?: number | null; + agents: Array<{ + agent_id: string; + phase?: string | null; + label?: string | null; + state?: string | null; + tokens?: number | null; + duration_ms?: number | null; + }>; + }; +} + +export interface AssembledMessage extends SessionMessageRow { + type?: string | null; + content_type?: string | null; + is_meta?: number | boolean | null; + tool_calls?: AssembledToolCall[]; + _thinking?: string; + _skillMd?: string; +} diff --git a/app/src/shared/session-patch.mjs b/app/src/shared/session-patch.mjs new file mode 100644 index 0000000..15fad92 --- /dev/null +++ b/app/src/shared/session-patch.mjs @@ -0,0 +1,158 @@ +// @ts-check + +/** @typedef {import('./ipc-types.ts').AppliedSessionPatch} AppliedSessionPatch */ +/** @typedef {import('./ipc-types.ts').SessionPatch} SessionPatch */ +/** @typedef {import('./ipc-types.ts').SessionPatchCursor} SessionPatchCursor */ +/** @typedef {import('./ipc-types.ts').SessionPatchRow} SessionPatchRow */ +/** @typedef {import('./ipc-types.ts').SessionPatchSnapshot} SessionPatchSnapshot */ +/** @typedef {import('./ipc-types.ts').SessionPatchTable} SessionPatchTable */ + +const TABLES = Object.freeze({ + messages: 'uuid', + toolCalls: 'id', + toolResults: 'tool_use_id', + subagents: 'agent_id', + workflows: 'run_id', + summaries: 'id', +}); + +const TABLE_NAMES = /** @type {SessionPatchTable[]} */ (Object.keys(TABLES)); + +/** + * @param {SessionPatchTable} table + * @param {SessionPatchRow} row + */ +function rowId(table, row) { + const id = row?.[TABLES[table]]; + if (id === undefined || id === null || id === '') { + throw new Error(`Session patch row in ${table} is missing ${TABLES[table]}`); + } + return String(id); +} + +/** @param {SessionPatchRow} row */ +function rowHash(row) { + const serialized = JSON.stringify(row); + let first = 0x811c9dc5; + let second = 0x9e3779b9; + for (let index = 0; index < serialized.length; index++) { + const code = serialized.charCodeAt(index); + first = Math.imul(first ^ code, 0x01000193); + second = Math.imul(second ^ code, 0x85ebca6b); + } + return `${serialized.length.toString(16)}:${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`; +} + +/** + * @param {SessionPatchRow} row + * @param {number} position + */ +function rowFingerprint(row, position) { + return `${position.toString(36)}@${rowHash(row)}`; +} + +/** + * @template T + * @param {() => T} factory + * @returns {Record} + */ +function emptyTables(factory) { + return /** @type {Record} */ ( + Object.fromEntries(TABLE_NAMES.map(table => [table, factory()])) + ); +} + +/** + * @param {SessionPatchSnapshot} [snapshot] + * @returns {SessionPatchCursor} + */ +export function createSessionPatchCursor(snapshot = {}) { + const cursor = emptyTables(() => /** @type {Record} */ ({})); + for (const table of TABLE_NAMES) { + for (const [position, row] of (snapshot[table] || []).entries()) { + cursor[table][rowId(table, row)] = rowFingerprint(row, position); + } + } + return cursor; +} + +/** + * @param {SessionPatchSnapshot} [snapshot] + * @param {Partial} [cursor] + * @returns {SessionPatch} + */ +export function createSessionPatch(snapshot = {}, cursor = {}) { + const changes = emptyTables(() => /** @type {SessionPatchRow[]} */ ([])); + const removed = emptyTables(() => /** @type {string[]} */ ([])); + const hashes = emptyTables(() => /** @type {Record} */ ({})); + const positions = emptyTables(() => /** @type {Record} */ ({})); + + for (const table of TABLE_NAMES) { + const previous = cursor[table] || {}; + const currentIds = new Set(); + for (const [index, row] of (snapshot[table] || []).entries()) { + const id = rowId(table, row); + const hash = rowFingerprint(row, index); + currentIds.add(id); + if (previous[id] !== hash) { + changes[table].push(row); + hashes[table][id] = hash; + positions[table][id] = index; + } + } + for (const id of Object.keys(previous)) { + if (!currentIds.has(id)) removed[table].push(id); + } + } + + return { changes, removed, hashes, positions }; +} + +/** + * @param {SessionPatchSnapshot} [snapshot] + * @param {Partial} [cursor] + * @param {Partial} [patch] + * @returns {AppliedSessionPatch} + */ +export function applySessionPatch(snapshot = {}, cursor = {}, patch = {}) { + const nextSnapshot = emptyTables(() => /** @type {SessionPatchRow[]} */ ([])); + const nextCursor = emptyTables(() => /** @type {Record} */ ({})); + for (const table of TABLE_NAMES) { + const currentRows = snapshot[table] || []; + const tableChanges = patch.changes?.[table] || []; + const tableRemoved = patch.removed?.[table] || []; + const previousHashes = cursor[table] || {}; + const appendOnly = tableRemoved.length === 0 && tableChanges.every((row, offset) => { + const id = rowId(table, row); + return !Object.hasOwn(previousHashes, id) + && patch.positions?.[table]?.[id] === currentRows.length + offset; + }); + + if (appendOnly) { + nextSnapshot[table] = tableChanges.length > 0 + ? [...currentRows, ...tableChanges] + : currentRows; + } else { + const removedIds = new Set(tableRemoved.map(String)); + const changedIds = new Set(tableChanges.map(row => rowId(table, row))); + const nextRows = currentRows + .filter(row => !removedIds.has(rowId(table, row)) && !changedIds.has(rowId(table, row))); + const positionedRows = [...tableChanges] + .sort((left, right) => ( + (patch.positions?.[table]?.[rowId(table, left)] ?? Number.MAX_SAFE_INTEGER) + - (patch.positions?.[table]?.[rowId(table, right)] ?? Number.MAX_SAFE_INTEGER) + )); + for (const row of positionedRows) { + const position = patch.positions?.[table]?.[rowId(table, row)] ?? nextRows.length; + nextRows.splice(position, 0, row); + } + nextSnapshot[table] = nextRows; + } + + const tableCursor = { ...(cursor[table] || {}) }; + for (const id of patch.removed?.[table] || []) delete tableCursor[String(id)]; + Object.assign(tableCursor, patch.hashes?.[table] || {}); + nextCursor[table] = tableCursor; + } + return { snapshot: nextSnapshot, cursor: nextCursor }; +} diff --git a/app/tests/electron-session-virtualization.mjs b/app/tests/electron-session-virtualization.mjs index 500368f..c1538f0 100644 --- a/app/tests/electron-session-virtualization.mjs +++ b/app/tests/electron-session-virtualization.mjs @@ -4,18 +4,29 @@ import { app, BrowserWindow, ipcMain } from 'electron'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { setTimeout as delay } from 'node:timers/promises'; +import { createSessionPatch } from '../src/shared/session-patch.mjs'; +import { assembleSessionMessages } from '../src/shared/session-detail-assembly.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const appRoot = join(here, '..'); const sessionId = 'test-session'; +const messageCount = Number(process.env.OBELISK_TIMELINE_MESSAGE_COUNT || 2000); +const focusMessageIndex = Math.floor(messageCount * 0.75); +const focusMessageUuid = `message-${focusMessageIndex}`; +const stationaryAppendRuns = 3; +const firstStationaryAppendIndex = messageCount; +const scrollingAppendIndex = messageCount + stationaryAppendRuns; +const tailAppendIndex = scrollingAppendIndex + 1; const channels = [ 'db:getSessions', 'db:getSessionMessages', 'db:getSessionToolCalls', 'db:getSessionToolResults', + 'db:getSessionPatch', 'db:getSessionSubagents', 'db:getSessionWorkflows', 'db:getSessionSummaries', + 'db:getMessageFullText', 'db:getMemories', 'db:getProjects', 'db:getStats', @@ -24,7 +35,17 @@ const channels = [ let failures = 0; let firstSessionListRead = true; -const messages = Array.from({ length: 2000 }, (_, index) => ({ +const ipcReads = { + messages: 0, + toolCalls: 0, + toolResults: 0, + subagents: 0, + workflows: 0, + summaries: 0, + patches: 0, + patchMessageRows: [], +}; +const messages = Array.from({ length: messageCount }, (_, index) => ({ uuid: `message-${index}`, type: index % 2 === 0 ? 'user' : 'assistant', timestamp: new Date(Date.UTC(2026, 6, 14, 0, 0, index)).toISOString(), @@ -34,16 +55,33 @@ const messages = Array.from({ length: 2000 }, (_, index) => ({ content_type: index === 1 ? 'tool_use' : 'text', is_meta: 0, })); +messages[focusMessageIndex].type = 'assistant'; +messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repeat(700)}`; +const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`; +const codexExecSource = 'const result = { ok: true };\nreturn result;'; +let codexExecOutput = JSON.stringify([{ + type: 'input_text', + text: 'Script completed\nWall time 0.1 seconds\nOutput:\n{"ok":true}', +}]); const toolCalls = [{ id: 'call-1', message_uuid: 'message-1', name: 'Bash', input_json: JSON.stringify({ command: 'printf virtualized' }), +}, { + id: 'call-codex-exec', + message_uuid: focusMessageUuid, + name: 'exec', + input_json: JSON.stringify(codexExecSource), }]; const toolResults = [{ tool_use_id: 'call-1', content: `${'virtualized output\n'.repeat(80)}`, is_error: 0, +}, { + tool_use_id: 'call-codex-exec', + content: codexExecOutput, + is_error: 0, }]; function sessionSummary() { @@ -77,6 +115,99 @@ async function waitFor(webContents, expression, message, timeoutMs = 8000) { throw new Error(`Timed out waiting for ${message}`); } +async function startRendererTrace(win) { + const traceEvents = []; + let completeTrace; + const traceComplete = new Promise(resolve => { completeTrace = resolve; }); + const onMessage = (_event, method, params = {}) => { + if (method === 'Tracing.dataCollected') traceEvents.push(...(params.value || [])); + if (method === 'Tracing.tracingComplete') completeTrace(); + }; + win.webContents.debugger.attach('1.3'); + win.webContents.debugger.on('message', onMessage); + await win.webContents.debugger.sendCommand('Tracing.start', { + categories: 'devtools.timeline,disabled-by-default-devtools.timeline,blink.user_timing,toplevel', + options: 'record-as-much-as-possible', + transferMode: 'ReportEvents', + }); + return async () => { + await win.webContents.debugger.sendCommand('Tracing.end'); + await traceComplete; + win.webContents.debugger.removeListener('message', onMessage); + win.webContents.debugger.detach(); + return traceEvents; + }; +} + +function rendererTaskMetrics(traceEvents, startMark, endMark) { + const start = traceEvents.find(event => event.name === startMark); + const end = [...traceEvents].reverse().find(event => event.name === endMark); + if (!start || !end) throw new Error(`Missing renderer trace marks: ${startMark}, ${endMark}`); + const tasks = traceEvents + .filter(event => ( + /RunTask$/.test(event.name || '') + && event.ph === 'X' + && event.pid === start.pid + && event.tid === start.tid + && event.ts >= start.ts + && event.ts <= end.ts + )); + const taskDurations = tasks.map(event => event.dur / 1000); + if (taskDurations.length === 0) throw new Error('Renderer trace contained no RunTask events'); + const slowest = tasks.reduce((best, task) => !best || task.dur > best.dur ? task : best, null); + const slowestChildren = slowest + ? traceEvents + .filter(event => ( + event.ph === 'X' + && event.pid === slowest.pid + && event.tid === slowest.tid + && event !== slowest + && event.ts >= slowest.ts + && event.ts + (event.dur || 0) <= slowest.ts + slowest.dur + )) + .sort((a, b) => (b.dur || 0) - (a.dur || 0)) + .slice(0, 8) + .map(event => ({ name: event.name, durationMs: (event.dur || 0) / 1000 })) + : []; + return { + tasks: taskDurations.length, + maxTaskMs: Math.max(0, ...taskDurations), + slowestChildren, + }; +} + +async function traceStationaryAppend(win, index, expectedTotal, runIndex) { + const startMark = `obelisk-live-commit-${runIndex}-start`; + const endMark = `obelisk-live-commit-${runIndex}-end`; + const stopRendererTrace = await startRendererTrace(win); + await win.webContents.executeJavaScript(`(() => { + const expected = ${JSON.stringify(String(expectedTotal))}; + const counter = document.querySelector('.flap-number'); + performance.mark(${JSON.stringify(startMark)}); + window.__obeliskLiveCommitObserved = new Promise(resolve => { + const finish = () => requestAnimationFrame(() => { + performance.mark(${JSON.stringify(endMark)}); + resolve(true); + }); + if (counter?.getAttribute('aria-label') === expected) { + finish(); + return; + } + const observer = new MutationObserver(() => { + if (counter?.getAttribute('aria-label') !== expected) return; + observer.disconnect(); + finish(); + }); + observer.observe(counter, { attributes: true, attributeFilter: ['aria-label'] }); + }); + return true; + })()`, true); + appendMessage(win, index); + await win.webContents.executeJavaScript('window.__obeliskLiveCommitObserved', true); + await win.webContents.executeJavaScript('delete window.__obeliskLiveCommitObserved', true); + return rendererTaskMetrics(await stopRendererTrace(), startMark, endMark); +} + function registerHandlers() { ipcMain.handle('db:getSessions', async () => { if (firstSessionListRead) { @@ -85,12 +216,22 @@ function registerHandlers() { } return [sessionSummary()]; }); - ipcMain.handle('db:getSessionMessages', () => messages); - ipcMain.handle('db:getSessionToolCalls', () => toolCalls); - ipcMain.handle('db:getSessionToolResults', () => toolResults); - ipcMain.handle('db:getSessionSubagents', () => []); - ipcMain.handle('db:getSessionWorkflows', () => []); - ipcMain.handle('db:getSessionSummaries', () => []); + ipcMain.handle('db:getSessionMessages', () => { ipcReads.messages++; return messages; }); + ipcMain.handle('db:getSessionToolCalls', () => { ipcReads.toolCalls++; return toolCalls; }); + ipcMain.handle('db:getSessionToolResults', () => { ipcReads.toolResults++; return toolResults; }); + ipcMain.handle('db:getSessionPatch', (_event, _sessionId, cursor) => { + ipcReads.patches++; + const patch = createSessionPatch({ + messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents: [], workflows: [] }), + workflows: [], + }, cursor); + ipcReads.patchMessageRows.push(patch.changes.messages.length); + return patch; + }); + ipcMain.handle('db:getSessionSubagents', () => { ipcReads.subagents++; return []; }); + ipcMain.handle('db:getSessionWorkflows', () => { ipcReads.workflows++; return []; }); + ipcMain.handle('db:getSessionSummaries', () => { ipcReads.summaries++; return []; }); + ipcMain.handle('db:getMessageFullText', (_event, uuid) => uuid === focusMessageUuid ? fullTextSentinel : null); ipcMain.handle('db:getMemories', () => []); ipcMain.handle('db:getProjects', () => [{ project: 'quiet-zero', count: 1 }]); ipcMain.handle('db:getStats', () => ({})); @@ -109,6 +250,20 @@ function appendMessage(win, index) { win.webContents.send('obelisk:session-updated', { sessionId }); } +function replaceMessageText(win, uuid, text) { + const index = messages.findIndex(message => message.uuid === uuid); + if (index < 0) throw new Error(`Cannot update missing message ${uuid}`); + messages[index] = { ...messages[index], text }; + win.webContents.send('obelisk:session-updated', { sessionId }); +} + +function replaceToolResult(win, toolUseId, content) { + const index = toolResults.findIndex(result => result.tool_use_id === toolUseId); + if (index < 0) throw new Error(`Cannot update missing tool result ${toolUseId}`); + toolResults[index] = { ...toolResults[index], content }; + win.webContents.send('obelisk:session-updated', { sessionId }); +} + async function run() { registerHandlers(); const win = new BrowserWindow({ @@ -127,7 +282,7 @@ async function run() { }); await waitFor( win.webContents, - `document.querySelector('.flap-number')?.getAttribute('aria-label') === '2000'`, + `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount}'`, 'the cold-start session snapshot', ); @@ -140,7 +295,7 @@ async function run() { scrollHeight: document.querySelector('.detail-wrap')?.scrollHeight, }))()`, true); assert(initial.scrollTop < 2 && initial.current < 100, `cold start stays at the beginning (scrollTop ${initial.scrollTop}, item ${initial.current})`); - assert(initial.total === 2000, `timeline exposes all 2000 items (got ${initial.total})`); + assert(initial.total === messageCount, `timeline exposes all ${messageCount} items (got ${initial.total})`); assert(initial.rows < 60 && initial.roots === initial.rows, `only ${initial.rows} virtual rows are mounted`); const disclosure = await win.webContents.executeJavaScript(`(async () => { @@ -169,17 +324,23 @@ async function run() { await win.webContents.executeJavaScript(`window.location.hash = '#/sessions'`, true); await waitFor(win.webContents, `!document.querySelector('.virtual-timeline')`, 'session detail deactivation'); + await win.webContents.executeJavaScript(`(async () => { + const search = document.querySelector('#search'); + search.value = 'SENTINEL'; + search.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 300)); + })()`, true); await win.webContents.executeJavaScript( - `window.location.hash = '#/sessions/${sessionId}?focus=message-1500'`, + `window.location.hash = '#/sessions/${sessionId}?focus=${focusMessageUuid}'`, true, ); await waitFor( win.webContents, - `document.querySelector('[data-uuid="message-1500"].is-focused')`, + `document.querySelector('[data-uuid="${focusMessageUuid}"].is-focused')`, 'offscreen UUID focus', ); const focusState = await win.webContents.executeJavaScript(`(() => { - const target = document.querySelector('[data-uuid="message-1500"].is-focused'); + const target = document.querySelector('[data-uuid="${focusMessageUuid}"].is-focused'); const wrap = document.querySelector('.detail-wrap'); const targetRect = target.getBoundingClientRect(); const wrapRect = wrap.getBoundingClientRect(); @@ -188,9 +349,143 @@ async function run() { visible: targetRect.bottom > wrapRect.top && targetRect.top < wrapRect.bottom, }; })()`, true); - assert(focusState.visible, `UUID navigation mounts and reveals message-1500 (viewport ends at item ${focusState.current})`); + assert(focusState.visible, `UUID navigation mounts and reveals ${focusMessageUuid} (viewport ends at item ${focusState.current})`); + const codexDisplayState = await win.webContents.executeJavaScript(`(async () => { + const tool = document.querySelector('[data-view-key="tool:call-codex-exec"]'); + tool?.querySelector('.toolcall-toggle')?.click(); + tool?.querySelector('.raw-toggle')?.click(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { + open: tool?.classList.contains('open'), + raw: tool?.querySelector('.raw-toggle')?.classList.contains('active'), + }; + })()`, true); + assert(codexDisplayState.open && codexDisplayState.raw, 'Codex exec disclosure and Raw state update without rebuilding its presentation'); + codexExecOutput = JSON.stringify([{ + type: 'input_text', + text: 'Script completed\nWall time 0.1 seconds\nOutput:\n{"ok":true,"revision":2}', + }]); + replaceToolResult(win, 'call-codex-exec', codexExecOutput); + await waitFor( + win.webContents, + `document.querySelector('[data-view-key="tool:call-codex-exec"] .toolcall-raw')?.textContent.includes('revision')`, + 'updated Codex exec result', + ); + const updatedCodexDisplayState = await win.webContents.executeJavaScript(`(() => { + const tool = document.querySelector('[data-view-key="tool:call-codex-exec"]'); + return { + open: tool?.classList.contains('open'), + raw: tool?.querySelector('.raw-toggle')?.classList.contains('active'), + }; + })()`, true); + assert(updatedCodexDisplayState.open && updatedCodexDisplayState.raw, 'Codex exec disclosure and Raw state survive a result update'); - setTimeout(() => appendMessage(win, 2000), 250); + await win.webContents.executeJavaScript( + `document.querySelector('[data-uuid="${focusMessageUuid}"] .truncated-btn')?.click()`, + true, + ); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid="${focusMessageUuid}"]')?.textContent.includes('FULL TEXT SENTINEL')`, + 'expanded full message text', + ); + const fullTextSearchState = await win.webContents.executeJavaScript(`(() => { + const target = document.querySelector('[data-uuid="${focusMessageUuid}"]'); + return { + highlighted: [...target.querySelectorAll('mark')].some(mark => mark.textContent === 'SENTINEL'), + truncatedButtonRemoved: !target.querySelector('.truncated-btn'), + }; + })()`, true); + assert( + fullTextSearchState.highlighted && fullTextSearchState.truncatedButtonRemoved, + 'full-text expansion re-renders the row and preserves search highlighting', + ); + await delay(250); + + await win.webContents.executeJavaScript(`(() => { + const original = window.marked.parse; + const originalJsonParse = JSON.parse; + const codexExecOutput = ${JSON.stringify(codexExecOutput)}; + const trackedPrefixes = [...document.querySelectorAll('.virtual-timeline-row [data-uuid]')] + .map(element => element.getAttribute('data-uuid')) + .filter(uuid => /^message-\d+$/.test(uuid)) + .map(uuid => 'Message ' + uuid.slice('message-'.length) + ' '); + let calls = 0; + let codexExecCalls = 0; + window.marked.parse = function timelineMarkdownProbe(...args) { + const text = String(args[0] || ''); + if (trackedPrefixes.some(prefix => text.startsWith(prefix))) calls++; + return original.apply(this, args); + }; + JSON.parse = function timelineJsonProbe(value, ...args) { + if (value === codexExecOutput) codexExecCalls++; + return originalJsonParse.call(this, value, ...args); + }; + window.__timelineMarkdownProbe = { + calls: () => calls, + codexExecCalls: () => codexExecCalls, + restore: () => { + window.marked.parse = original; + JSON.parse = originalJsonParse; + }, + }; + })()`, true); + const stationaryAnchorBefore = await win.webContents.executeJavaScript(`(() => { + const wrap = document.querySelector('.detail-wrap'); + const wrapRect = wrap.getBoundingClientRect(); + const anchorRow = [...document.querySelectorAll('.virtual-timeline-row')] + .find(row => { + const rect = row.getBoundingClientRect(); + return rect.bottom > wrapRect.top && rect.top < wrapRect.bottom; + }); + const anchorElement = anchorRow?.querySelector('[data-uuid]'); + return anchorElement && { + uuid: anchorElement.getAttribute('data-uuid'), + offset: anchorRow.getBoundingClientRect().top - wrapRect.top, + }; + })()`, true); + const stationaryTraces = []; + for (let runIndex = 0; runIndex < stationaryAppendRuns; runIndex++) { + stationaryTraces.push(await traceStationaryAppend( + win, + firstStationaryAppendIndex + runIndex, + messageCount + runIndex + 1, + runIndex, + )); + await delay(250); + } + const stationaryAnchorSelector = `[data-uuid="${stationaryAnchorBefore?.uuid}"]`; + const stationaryAnchorAfter = await win.webContents.executeJavaScript(`(() => { + const wrap = document.querySelector('.detail-wrap'); + const target = document.querySelector(${JSON.stringify(stationaryAnchorSelector)}); + const row = target?.closest('.virtual-timeline-row'); + return target && { + uuid: target.getAttribute('data-uuid'), + offset: row.getBoundingClientRect().top - wrap.getBoundingClientRect().top, + }; + })()`, true); + const unchangedRowRenderCalls = await win.webContents.executeJavaScript(`(() => { + const calls = { + markdown: window.__timelineMarkdownProbe.calls(), + codexExec: window.__timelineMarkdownProbe.codexExecCalls(), + }; + window.__timelineMarkdownProbe.restore(); + delete window.__timelineMarkdownProbe; + return calls; + })()`, true); + assert(unchangedRowRenderCalls.markdown === 0, `three tail appends perform zero Markdown formatting calls for unchanged mounted rows (got ${unchangedRowRenderCalls.markdown})`); + assert(unchangedRowRenderCalls.codexExec === 0, `three tail appends perform zero Codex exec JSON decodes for an unchanged mounted row (got ${unchangedRowRenderCalls.codexExec})`); + assert( + stationaryAnchorBefore?.uuid === stationaryAnchorAfter?.uuid + && Math.abs(stationaryAnchorBefore.offset - stationaryAnchorAfter.offset) < 2, + `stationary live commits preserve reader anchor ${stationaryAnchorBefore?.uuid}`, + ); + for (const [runIndex, trace] of stationaryTraces.entries()) { + if (trace.maxTaskMs >= 8.33) console.log(`SLOWEST RENDERER TASK ${runIndex + 1}: ${JSON.stringify(trace.slowestChildren)}`); + assert(trace.maxTaskMs < 8.33, `stationary live commit ${runIndex + 1} stays inside a 120Hz renderer task budget (${trace.maxTaskMs.toFixed(2)}ms across ${trace.tasks} tasks)`); + } + + setTimeout(() => appendMessage(win, scrollingAppendIndex), 250); const scrollProbe = await win.webContents.executeJavaScript(`new Promise(resolve => { const wrap = document.querySelector('.detail-wrap'); const gaps = []; @@ -225,7 +520,7 @@ async function run() { })`, true); await waitFor( win.webContents, - `document.querySelector('.flap-number')?.getAttribute('aria-label') === '2001'`, + `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 1}'`, 'reader-position live update', ); const readerState = await win.webContents.executeJavaScript(`(() => { @@ -246,17 +541,65 @@ async function run() { assert(scrollProbe.anchor, 'reader anchor is captured before the deferred live commit'); assert( scrollProbe.distanceFromTail > 1000 - && readerState.current < 2001 + && readerState.current < messageCount + stationaryAppendRuns + 1 && readerState.anchor?.uuid === scrollProbe.anchor?.uuid && Math.abs(readerState.anchor.offset - scrollProbe.anchor.offset) < 2, `live append preserves reader anchor ${scrollProbe.anchor?.uuid} (${scrollProbe.anchor?.offset}px -> ${readerState.anchor?.offset}px)`, ); assert(scrollProbe.maxFrameGap < 250, `live scroll has no catastrophic long frame (${scrollProbe.maxFrameGap.toFixed(1)}ms)`); + const updatedReaderText = `Updated ${scrollProbe.anchor.uuid} ${'content identity '.repeat(20)}`; + await win.webContents.executeJavaScript(`(() => { + const original = window.marked.parse; + const targetUuid = ${JSON.stringify(scrollProbe.anchor.uuid)}; + const targetText = ${JSON.stringify(updatedReaderText)}; + const unchangedPrefixes = [...document.querySelectorAll('.virtual-timeline-row [data-uuid]')] + .map(element => element.getAttribute('data-uuid')) + .filter(uuid => uuid !== targetUuid && /^message-\d+$/.test(uuid)) + .map(uuid => 'Message ' + uuid.slice('message-'.length) + ' '); + let targetCalls = 0; + let unchangedCalls = 0; + window.marked.parse = function timelineContentIdentityProbe(value, ...args) { + const text = String(value || ''); + if (text === targetText) targetCalls++; + if (unchangedPrefixes.some(prefix => text.startsWith(prefix))) unchangedCalls++; + return original.call(this, value, ...args); + }; + window.__timelineContentIdentityProbe = { + calls: () => ({ target: targetCalls, unchanged: unchangedCalls }), + restore: () => { window.marked.parse = original; }, + }; + })()`, true); + replaceMessageText(win, scrollProbe.anchor.uuid, updatedReaderText); + await waitFor( + win.webContents, + `document.querySelector('[data-uuid=${JSON.stringify(scrollProbe.anchor.uuid)}]')?.textContent.includes(${JSON.stringify(updatedReaderText.slice(0, 40))})`, + 'visible message content update', + ); + const contentIdentityCalls = await win.webContents.executeJavaScript(`(() => { + const calls = window.__timelineContentIdentityProbe.calls(); + window.__timelineContentIdentityProbe.restore(); + delete window.__timelineContentIdentityProbe; + return calls; + })()`, true); + assert(contentIdentityCalls.target === 1, `updated mounted row recomputes its Markdown once (got ${contentIdentityCalls.target})`); + assert(contentIdentityCalls.unchanged === 0, `updated mounted row leaves other mounted Markdown cached (got ${contentIdentityCalls.unchanged})`); + assert( + ipcReads.messages === 1 + && ipcReads.toolCalls === 1 + && ipcReads.toolResults === 1 + && ipcReads.subagents === 1 + && ipcReads.workflows === 1 + && ipcReads.summaries === 1 + && ipcReads.patches === 6 + && ipcReads.patchMessageRows.every(count => count === 1), + `live updates use six single-message patches after one full snapshot (${JSON.stringify(ipcReads)})`, + ); + await win.webContents.executeJavaScript(`document.querySelector('button[title="Last"]')?.click()`, true); await waitFor( win.webContents, - `document.querySelector('.msg-nav-current')?.textContent === '2001'`, + `document.querySelector('.msg-nav-current')?.textContent === '${messageCount + stationaryAppendRuns + 1}'`, 'last-item navigation', ); await waitFor( @@ -264,10 +607,10 @@ async function run() { `(() => { const wrap = document.querySelector('.detail-wrap'); return wrap.scrollHeight - wrap.clientHeight - wrap.scrollTop < 2; })()`, 'last-item scroll settlement', ); - appendMessage(win, 2001); + appendMessage(win, tailAppendIndex); await waitFor( win.webContents, - `document.querySelector('.flap-number')?.getAttribute('aria-label') === '2002'`, + `document.querySelector('.flap-number')?.getAttribute('aria-label') === '${messageCount + stationaryAppendRuns + 2}'`, 'tail-follow total update', ); await delay(1000); @@ -282,8 +625,8 @@ async function run() { }; })()`, true); assert( - tailState.current === 2002 && tailState.distanceFromTail < 2, - `tail follow reaches item 2002 (${JSON.stringify(tailState)})`, + tailState.current === messageCount + stationaryAppendRuns + 2 && tailState.distanceFromTail < 2, + `tail follow reaches item ${messageCount + stationaryAppendRuns + 2} (${JSON.stringify(tailState)})`, ); const reduction = (1 - initial.rows / initial.total) * 100; diff --git a/app/tsconfig.json b/app/tsconfig.json index 5d183db..6983432 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -17,6 +17,6 @@ "resolveJsonModule": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/main/**/*", "src/preload/**/*"], + "include": ["src/main/**/*", "src/preload/**/*", "src/shared/**/*"], "exclude": ["node_modules", "out", "dist", "release", "src/renderer"] } diff --git a/tests/session-detail-assembly.test.mjs b/tests/session-detail-assembly.test.mjs new file mode 100644 index 0000000..4e7519b --- /dev/null +++ b/tests/session-detail-assembly.test.mjs @@ -0,0 +1,58 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs'; + +test('session assembly preserves thinking and attaches tool result and subagent evidence', () => { + const messages = [ + { uuid: 'thinking-1', type: 'assistant', content_type: 'thinking', text: 'reasoning' }, + { uuid: 'answer-1', type: 'assistant', content_type: 'text', text: 'answer' }, + { uuid: 'tool-1', type: 'assistant', content_type: 'tool_use', text: '' }, + { uuid: 'result-1', type: 'user', content_type: 'tool_result', text: '' }, + ]; + const assembled = assembleSessionMessages({ + messages, + toolCalls: [{ id: 'call-1', message_uuid: 'tool-1', name: 'Agent', input_json: '{"description":"inspect"}' }], + toolResults: [{ tool_use_id: 'call-1', message_uuid: 'result-1', content: 'done', is_error: 0 }], + subagents: [{ agent_id: 'agent-1', parent_tool_use_id: 'call-1', agent_type: 'reviewer', description: 'inspect' }], + workflows: [], + }); + + assert.equal(assembled.length, 1); + assert.equal(assembled[0].uuid, 'answer-1'); + assert.equal(assembled[0]._thinking, 'reasoning'); + assert.deepEqual(assembled[0].tool_calls[0].result.content, 'done'); + assert.equal(assembled[0].tool_calls[0].subagent.agent_id, 'agent-1'); +}); + +test('session assembly keeps Skill evidence standalone and embeds matching workflow agents', () => { + const assembled = assembleSessionMessages({ + messages: [ + { uuid: 'skill-1', type: 'assistant', content_type: 'tool_use', text: '' }, + { uuid: 'skill-md', type: 'user', content_type: 'text', is_meta: 1, text: 'Base directory for this skill\n# Skill' }, + { uuid: 'workflow-1', type: 'assistant', content_type: 'tool_use', text: '' }, + ], + toolCalls: [ + { id: 'call-skill', message_uuid: 'skill-1', name: 'Skill', input_json: '{"skill":"obelisk"}' }, + { id: 'call-workflow', message_uuid: 'workflow-1', name: 'Workflow', input_json: '{}' }, + ], + toolResults: [{ tool_use_id: 'call-workflow', content: 'run-1 complete', is_error: 0 }], + subagents: [], + workflows: [{ + run_id: 'run-1', + workflow_name: 'review', + status: 'complete', + agents: [{ agent_id: 'agent-1', phase: 'review', label: 'Reviewer', state: 'complete' }], + }], + }); + + assert.equal(assembled[0]._skillMd, 'Base directory for this skill\n# Skill'); + assert.equal(assembled[1].tool_calls[0].workflow.run_id, 'run-1'); + assert.deepEqual(assembled[1].tool_calls[0].workflow.agents, [{ + agent_id: 'agent-1', + phase: 'review', + label: 'Reviewer', + state: 'complete', + tokens: undefined, + duration_ms: undefined, + }]); +}); diff --git a/tests/session-live-patch.test.mjs b/tests/session-live-patch.test.mjs new file mode 100644 index 0000000..0e76c42 --- /dev/null +++ b/tests/session-live-patch.test.mjs @@ -0,0 +1,84 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + applySessionPatch, + createSessionPatch, + createSessionPatchCursor, +} from '../app/src/shared/session-patch.mjs'; + +function snapshot(overrides = {}) { + return { + messages: [ + { uuid: 'message-1', timestamp: '2026-07-14T00:00:01Z', text: 'one' }, + { uuid: 'message-2', timestamp: '2026-07-14T00:00:02Z', text: 'two' }, + ], + toolCalls: [{ id: 'call-1', message_uuid: 'message-1', name: 'exec', input_json: '"return 1"' }], + toolResults: [{ tool_use_id: 'call-1', message_uuid: 'message-1', content: 'running', is_error: 0 }], + subagents: [], + workflows: [{ run_id: 'workflow-1', status: 'running', agents: [{ agent_id: 'agent-1', state: 'running' }] }], + summaries: [], + ...overrides, + }; +} + +test('session patch returns only appended and updated rows, then reconstructs the new snapshot', () => { + const current = snapshot(); + const cursor = createSessionPatchCursor(current); + const next = snapshot({ + messages: [...current.messages, { uuid: 'message-3', timestamp: '2026-07-14T00:00:03Z', text: 'three' }], + toolResults: [{ ...current.toolResults[0], content: 'complete' }], + }); + + const patch = createSessionPatch(next, cursor); + + assert.deepEqual(patch.changes.messages.map(row => row.uuid), ['message-3']); + assert.deepEqual(patch.changes.toolResults.map(row => row.tool_use_id), ['call-1']); + assert.deepEqual(patch.changes.toolCalls, []); + assert.deepEqual(patch.removed.messages, []); + assert.equal(patch.positions.messages['message-3'], 2); + assert.deepEqual(applySessionPatch(current, cursor, patch), { + snapshot: next, + cursor: createSessionPatchCursor(next), + }); +}); + +test('session patch reports removals and nested workflow updates', () => { + const current = snapshot(); + const cursor = createSessionPatchCursor(current); + const next = snapshot({ + messages: [current.messages[1]], + workflows: [{ run_id: 'workflow-1', status: 'complete', agents: [{ agent_id: 'agent-1', state: 'complete' }] }], + }); + + const patch = createSessionPatch(next, cursor); + + assert.deepEqual(patch.removed.messages, ['message-1']); + assert.deepEqual(patch.changes.workflows, next.workflows); + assert.deepEqual(applySessionPatch(current, cursor, patch).snapshot, next); +}); + +test('session patch repositions existing rows when their content is unchanged', () => { + const current = snapshot(); + const cursor = createSessionPatchCursor(current); + const next = snapshot({ + messages: [current.messages[1], current.messages[0]], + }); + + const patch = createSessionPatch(next, cursor); + + assert.deepEqual(applySessionPatch(current, cursor, patch).snapshot, next); +}); + +test('session patch cursor is compact and never carries transcript content', () => { + const largeText = 'private transcript content '.repeat(1000); + const current = snapshot({ + messages: [{ uuid: 'message-large', timestamp: '2026-07-14T00:00:00Z', text: largeText }], + toolResults: [{ tool_use_id: 'call-large', message_uuid: 'message-large', content: largeText, is_error: 0 }], + }); + + const cursor = createSessionPatchCursor(current); + const serializedCursor = JSON.stringify(cursor); + + assert.equal(serializedCursor.includes('private transcript content'), false); + assert.ok(serializedCursor.length < JSON.stringify(current).length / 20); +}); diff --git a/tests/session-live-reload.test.mjs b/tests/session-live-reload.test.mjs index 5cc6119..a4e2b29 100644 --- a/tests/session-live-reload.test.mjs +++ b/tests/session-live-reload.test.mjs @@ -2,6 +2,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createSessionLiveReloadCoordinator } from '../app/src/renderer/src/session-live-reload.mjs'; +import { state } from '../app/src/renderer/src/store.js'; +import { + getCachedSessionDetail, + loadSessionDetail, + loadSessionDetailPatch, +} from '../app/src/renderer/src/data.js'; +import { assembleSessionMessages } from '../app/src/shared/session-detail-assembly.mjs'; +import { createSessionPatch } from '../app/src/shared/session-patch.mjs'; test('live snapshots coalesce while scrolling and commit once after scroll end', async () => { let scrolling = true; @@ -87,3 +95,95 @@ test('scrolling that starts during IPC defers the loaded snapshot commit', async assert.equal(loads, 1, 'the already-loaded snapshot is reused'); assert.deepEqual(commits, ['loaded-before-scroll-ended']); }); + +test('a skipped live patch does not advance the visible patch baseline', async t => { + const sessionId = 'coalesced-patch-session'; + const previousSessions = state.sessions; + t.after(() => { + state.sessions = previousSessions; + delete globalThis.window; + }); + let rows = [ + { uuid: 'message-1', type: 'user', timestamp: '2026-07-14T00:00:01Z', text: 'one' }, + ]; + let patchCalls = 0; + let releaseFirstPatch; + let firstPatchStarted; + const firstPatchGate = new Promise(resolve => { releaseFirstPatch = resolve; }); + const firstPatchReady = new Promise(resolve => { firstPatchStarted = resolve; }); + + globalThis.window = { + obelisk: { + getSessionMessages: async () => rows, + getSessionToolCalls: async () => [], + getSessionToolResults: async () => [], + getSessionSubagents: async () => [], + getSessionWorkflows: async () => [], + getSessionSummaries: async () => [], + getSessionPatch: async (_id, cursor) => { + const snapshotAtCall = { messages: assembleSessionMessages({ + messages: rows, + toolCalls: [], + toolResults: [], + subagents: [], + workflows: [], + }), workflows: [] }; + patchCalls++; + if (patchCalls === 1) { + firstPatchStarted(); + await firstPatchGate; + } + return createSessionPatch(snapshotAtCall, cursor); + }, + }, + }; + state.sessions = [{ id: sessionId, messages: [] }]; + await loadSessionDetail(sessionId); + + const commits = []; + const coordinator = createSessionLiveReloadCoordinator({ + isScrolling: () => false, + load: () => loadSessionDetailPatch(sessionId), + commit: async latest => { + commits.push({ + messages: latest.messages.map(message => message.uuid), + changedIds: latest.messagePatch.changedIds, + }); + latest.acceptMessagePatch?.(); + }, + }); + + rows = [...rows, { uuid: 'message-2', type: 'assistant', timestamp: '2026-07-14T00:00:02Z', text: 'two' }]; + const first = coordinator.request(); + await firstPatchReady; + rows = [...rows, { uuid: 'message-3', type: 'assistant', timestamp: '2026-07-14T00:00:03Z', text: 'three' }]; + const second = coordinator.request(); + releaseFirstPatch(); + await Promise.all([first, second]); + + assert.deepEqual(commits, [{ + messages: ['message-1', 'message-2', 'message-3'], + changedIds: ['message-2', 'message-3'], + }]); + assert.deepEqual( + getCachedSessionDetail(sessionId).messages.map(message => message.uuid), + ['message-1', 'message-2', 'message-3'], + 'accepted patches become the reusable session-detail snapshot', + ); + assert.deepEqual( + state.sessions.find(session => session.id === sessionId).messages, + [], + 'the stale full-snapshot copy is invalidated after patch acceptance', + ); + + const evictionSessionIds = ['eviction-session-1', 'eviction-session-2', 'eviction-session-3']; + state.sessions.push(...evictionSessionIds.map(id => ({ id, messages: [] }))); + for (const id of evictionSessionIds) await loadSessionDetail(id); + + assert.equal(getCachedSessionDetail(sessionId), null, 'the oldest accepted snapshot is evicted by the bounded cache'); + assert.deepEqual( + state.sessions.find(session => session.id === sessionId).messages, + [], + 'an evicted session cannot fall back to stale initial messages and must reload', + ); +}); diff --git a/tests/session-live.test.mjs b/tests/session-live.test.mjs index 3f0dda4..d95a201 100644 --- a/tests/session-live.test.mjs +++ b/tests/session-live.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { createSessionLiveState, consumeSessionDirty, + markSessionDirty, noteSessionUpdated, } from '../app/src/renderer/src/session-live.mjs'; @@ -25,3 +26,12 @@ test('session live state reloads the visible session without leaving it dirty', assert.deepEqual(action, { reload: true, sessionId: 'session-1' }); assert.equal(consumeSessionDirty(live, 'session-1'), false); }); + +test('a rejected visible commit can put the session back into the dirty set', () => { + const live = createSessionLiveState(); + + noteSessionUpdated(live, 'session-1', 'session-1'); + markSessionDirty('session-1', live); + + assert.equal(consumeSessionDirty(live, 'session-1'), true); +}); diff --git a/tests/session-timeline-virtualization.test.mjs b/tests/session-timeline-virtualization.test.mjs index facf1a9..71e73a8 100644 --- a/tests/session-timeline-virtualization.test.mjs +++ b/tests/session-timeline-virtualization.test.mjs @@ -6,6 +6,14 @@ const sessionDetail = readFileSync( new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8', ); +const timelineRow = readFileSync( + new URL('../app/src/renderer/src/components/SessionTimelineRow.vue', import.meta.url), + 'utf8', +); +const timelinePresentation = readFileSync( + new URL('../app/src/renderer/src/session-timeline-presentation.mjs', import.meta.url), + 'utf8', +); const viewportModule = readFileSync( new URL('../app/src/renderer/src/session-timeline-viewport.mjs', import.meta.url), 'utf8', @@ -20,8 +28,11 @@ test('SessionDetail renders a measured virtual window instead of the complete ti assert.match(sessionDetail, /v-for="virtualRow in virtualRows"/); assert.match(sessionDetail, /:data-index="virtualRow\.index"/); assert.match(sessionDetail, /:ref="measureElement"/); + assert.match(sessionDetail, / { assert.match(sessionDetail, /const totalMsgs = computed\(\(\) => timelineItems\.value\.length\)/); - assert.match(sessionDetail, /disclosures\.isOpen/); - assert.match(sessionDetail, /disclosures\.isRaw/); - assert.doesNotMatch(sessionDetail, /function toggleDisclosure[\s\S]{0,200}classList/); - assert.doesNotMatch(sessionDetail, /function toggleRaw[\s\S]{0,200}classList/); + assert.match(timelineRow, /disclosures\.isOpen/); + assert.match(timelineRow, /disclosures\.isRaw/); + assert.doesNotMatch(timelineRow, /function toggleDisclosure[\s\S]{0,200}classList/); + assert.doesNotMatch(timelineRow, /function toggleRaw[\s\S]{0,200}classList/); assert.doesNotMatch(sessionDetail, /createSessionDisclosureRegistry/); }); +test('timeline row memoizes derived HTML behind stable content dependencies', () => { + assert.match(timelineRow, /const presentation = computed/); + assert.match(timelineRow, /query: props\.query/); + assert.match(timelineRow, /expandedText: expandedText\.value/); + assert.match(timelinePresentation, /toolPrettyHtml/); + assert.match(timelinePresentation, /toolResultHtml/); + assert.match(timelinePresentation, /renderMarkdown/); +}); + test('cold startup does not enable append-follow before a real session snapshot exists', () => { assert.match(sessionDetail, /if \(!latest\) return/); assert.match(sessionDetail, /timelineViewport\.completeInitialSnapshot\(\)/); }); + +test('live patch state advances only after the visible snapshot commit is accepted', () => { + const loadLiveSnapshot = sessionDetail.match(/async function loadLiveSnapshot\(\) \{([\s\S]*?)\n\}/)?.[1] || ''; + const commitLiveSnapshot = sessionDetail.match(/async function commitLiveSnapshot\(snapshot\) \{([\s\S]*?)\n\}/)?.[1] || ''; + + assert.doesNotMatch(loadLiveSnapshot, /clearSessionDirty|acceptMessagePatch/); + assert.match( + commitLiveSnapshot, + /await commitSessionSnapshot\(snapshot\.latest\);[\s\S]*acceptMessagePatch[\s\S]*clearSessionDirty/, + ); + assert.match(commitLiveSnapshot, /markSessionDirty\(snapshot\.sessionId\)/); +});