From b58c34d3afaa9d4dbf07369d26d4b515511cb29e Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Tue, 14 Jul 2026 18:11:51 +0800 Subject: [PATCH] perf(app): stabilize live session timeline updates Virtualize SessionDetail rows behind stable presentation boundaries and apply typed incremental patches only after visible commits. Preserve reader state across live updates, handle coalesced and reordered patches, and verify the 120Hz append path with Electron tracing. --- app/src/main/index.ts | 113 +++- app/src/preload/index.ts | 9 +- .../src/components/SessionTimelineRow.vue | 324 +++++++++ app/src/renderer/src/data.js | 356 +++------- app/src/renderer/src/session-live.mjs | 6 +- .../src/session-timeline-presentation.mjs | 294 ++++++++ app/src/renderer/src/views/SessionDetail.vue | 638 ++---------------- app/src/shared/ipc-types.ts | 24 + app/src/shared/session-detail-assembly.mjs | 173 +++++ app/src/shared/session-detail-types.ts | 105 +++ app/src/shared/session-patch.mjs | 158 +++++ app/tests/electron-session-virtualization.mjs | 385 ++++++++++- app/tsconfig.json | 2 +- tests/session-detail-assembly.test.mjs | 58 ++ tests/session-live-patch.test.mjs | 84 +++ tests/session-live-reload.test.mjs | 100 +++ tests/session-live.test.mjs | 10 + .../session-timeline-virtualization.test.mjs | 42 +- 18 files changed, 1968 insertions(+), 913 deletions(-) create mode 100644 app/src/renderer/src/components/SessionTimelineRow.vue create mode 100644 app/src/renderer/src/session-timeline-presentation.mjs create mode 100644 app/src/shared/session-detail-assembly.mjs create mode 100644 app/src/shared/session-detail-types.ts create mode 100644 app/src/shared/session-patch.mjs create mode 100644 tests/session-detail-assembly.test.mjs create mode 100644 tests/session-live-patch.test.mjs 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\)/); +});