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.
This commit is contained in:
tommy0103
2026-07-14 18:11:51 +08:00
parent 2cad3b554d
commit b58c34d3af
18 changed files with 1968 additions and 913 deletions
+90 -23
View File
@@ -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', () => {
+8 -1
View File
@@ -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<SessionPatch | null> => (
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),
@@ -0,0 +1,324 @@
<script setup>
import { computed } from 'vue';
import { isTextTruncated } from '../data.js';
import { buildSessionTimelinePresentation } from '../session-timeline-presentation.mjs';
import { fmtClockTime } from '../utils.js';
const props = defineProps({
item: { type: Object, required: true },
focused: Boolean,
query: { type: String, default: '' },
disclosures: { type: Object, required: true },
expandedMessageText: { type: Object, required: true },
fullTextLoading: { type: Object, required: true },
});
const emit = defineEmits(['load-full-text', 'navigate-subagent']);
const msg = computed(() => props.item.message);
const expandedText = computed(() => props.expandedMessageText.get(msg.value.uuid));
// The expensive HTML projection is memoized by the exact inputs that can
// change its output. Focus, disclosure, nav progress, and parent scroll state
// can re-render UI chrome without re-parsing unchanged message/tool content.
const presentation = computed(() => buildSessionTimelinePresentation(props.item, {
query: props.query,
expandedText: expandedText.value,
}));
function toggleDisclosure(key, messageUuid) {
props.disclosures.toggleOpen(key, messageUuid);
}
function toggleRaw(key, messageUuid) {
props.disclosures.toggleRaw(key, messageUuid);
}
function canLoadFullText(message) {
return !props.expandedMessageText.has(message.uuid) && isTextTruncated(message.text);
}
function loadFullText(messageUuid) {
emit('load-full-text', messageUuid);
}
function navigateToSubagent(agentId, description = '') {
emit('navigate-subagent', agentId, description);
}
</script>
<template>
<template v-if="item.kind === 'meta'">
<div class="msg meta" :class="{ 'is-focused': focused }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-meta-collapsed" :class="{ open: disclosures.isOpen(`meta:${msg.uuid}`) }" :data-view-key="`meta:${msg.uuid}`">
<button class="meta-toggle" @click="toggleDisclosure(`meta:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="meta-label">System</span>
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
</button>
<div class="meta-body">
<div v-html="presentation.messageHtml"></div>
<button
v-if="canLoadFullText(msg)"
class="truncated-btn"
:disabled="fullTextLoading.has(msg.uuid)"
@click="loadFullText(msg.uuid)"
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
</div>
</div>
</div>
</template>
<template v-else-if="item.kind === 'workflow'">
<div class="wf-card" :class="{ 'is-focused': focused }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="wf-card-header">
<span class="wf-card-icon">&#x2699;</span>
<span class="wf-card-name">{{ item.workflowCall.workflow.workflow_name || 'Workflow' }}</span>
<span class="wf-card-count">{{ item.workflowCall.workflow.agents?.length || 0 }} agents</span>
<span
v-if="item.workflowCall.workflow.status"
class="wf-card-status"
:class="item.workflowCall.workflow.status"
>{{ item.workflowCall.workflow.status }}</span>
</div>
<div class="wf-card-body">
<template v-for="(phaseAgents, phase) in presentation.standaloneWorkflowGroups" :key="phase">
<div class="wf-card-phase">
<div class="wf-card-phase-title">{{ phase }}</div>
<button
v-for="agent in phaseAgents"
:key="agent.agent_id"
class="wf-card-agent"
@click="navigateToSubagent(agent.agent_id, agent.label || '')"
>
<span class="wf-card-agent-label">{{ agent.label || agent.agent_id }}</span>
<span v-if="agent.state === 'error'" class="wf-card-agent-state error">error</span>
<span class="wf-card-agent-arrow">&rarr;</span>
</button>
</div>
</template>
</div>
</div>
</template>
<template v-else-if="item.kind === 'workflow-tools'">
<div class="msg assistant" :class="{ 'is-focused': focused }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-tools">
<template v-for="tc in item.toolCalls" :key="tc.id">
<div
class="msg-tool"
:class="{ open: disclosures.isOpen(`tool:${tc.id}`), 'is-error': tc.result && tc.result.is_error }"
:data-view-key="`tool:${tc.id}`"
>
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="presentation.toolIcons.get(tc.id)" class="tool-icon" v-html="presentation.toolIcons.get(tc.id)"></span>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ presentation.toolArgPreviews.get(tc.id) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body">
<div class="toolcall-body-strip">
<span class="strip-label">{{ tc.name }}</span>
<span class="spacer"></span>
<button class="raw-toggle" :class="{ active: disclosures.isRaw(`tool:${tc.id}`) }" @click.stop="toggleRaw(`tool:${tc.id}`, msg.uuid)">{ } Raw</button>
</div>
<div class="toolcall-pretty" :class="{ hidden: disclosures.isRaw(`tool:${tc.id}`) }" v-html="presentation.toolPrettyHtml.get(tc.id)"></div>
<div class="toolcall-raw" :class="{ show: disclosures.isRaw(`tool:${tc.id}`) }">
<div class="tc-section">Input</div>
<pre>{{ presentation.toolInputText.get(tc.id) }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<template v-else-if="item.kind === 'skill'">
<div
class="skill-card"
:class="{ 'skill-md-open': disclosures.isOpen(`skill:${msg.uuid}`), 'is-focused': focused }"
:data-uuid="item.anchorUuid"
:data-message-uuid="item.messageUuid"
:data-view-key="`skill:${msg.uuid}`"
>
<div class="skill-card-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
</div>
<div class="skill-card-body">
<div class="skill-card-header">
<span class="skill-card-badge">Skill</span>
<span class="skill-card-name">{{ presentation.toolInputs.get(msg.tool_calls[0].id)?.skill || '?' }}</span>
</div>
<div class="skill-card-args">{{ presentation.toolInputs.get(msg.tool_calls[0].id)?.args || '' }}</div>
<div v-if="msg._skillMd" class="skill-card-md">
<button class="skill-md-toggle" @click="toggleDisclosure(`skill:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span>SKILL.md</span>
</button>
<div class="skill-md-body" v-html="presentation.skillHtml"></div>
</div>
</div>
</div>
</template>
<template v-else-if="item.kind === 'thinking'">
<div class="msg assistant" :class="{ 'is-focused': focused }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-thinking" :class="{ open: disclosures.isOpen(`thinking:${msg.uuid}`) }" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleDisclosure(`thinking:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="presentation.thinkingHtml"></div>
</div>
</div>
</template>
<template v-else>
<div
class="msg"
:class="[msg.type === 'user' ? 'user' : 'assistant', { 'is-focused': focused }]"
:data-uuid="item.anchorUuid"
:data-message-uuid="item.messageUuid"
>
<div class="msg-head">
<span class="role">{{ msg.type === 'user' ? 'You' : 'Assistant' }}</span>
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
</div>
<div v-if="msg._thinking" class="msg-thinking" :class="{ open: disclosures.isOpen(`thinking:${msg.uuid}`) }" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleDisclosure(`thinking:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="presentation.thinkingHtml"></div>
</div>
<template v-if="msg.text">
<div v-html="presentation.messageHtml"></div>
<button
v-if="canLoadFullText(msg)"
class="truncated-btn"
:disabled="fullTextLoading.has(msg.uuid)"
@click="loadFullText(msg.uuid)"
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
</template>
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
<div class="msg-text empty-text">(no text content)</div>
</template>
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
<template v-for="tc in msg.tool_calls" :key="tc.id">
<template v-if="tc.name === 'Skill'">
<div class="skill-badge">
<span class="skill-label">skill</span>
<span class="skill-name">{{ presentation.toolInputs.get(tc.id)?.skill || '?' }}</span>
</div>
</template>
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
<div class="msg-tool agent-call" :class="{ open: disclosures.isOpen(`tool:${tc.id}`) }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">{{ presentation.toolInputs.get(tc.id)?.subagent_type || presentation.toolInputs.get(tc.id)?.agentType || 'Agent' }}</span>
<span class="tool-arg">{{ presentation.toolInputs.get(tc.id)?.description || (presentation.toolInputs.get(tc.id)?.prompt || '').slice(0, 80) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
<button
v-if="tc.subagent?.agent_id"
class="agent-nav-btn"
@click.stop="navigateToSubagent(tc.subagent.agent_id, presentation.toolInputs.get(tc.id)?.description || '')"
>View conversation &rarr;</button>
</button>
<div class="toolcall-body" style="padding:10px 12px;">
<template v-if="presentation.toolInputs.get(tc.id)?.prompt">
<div class="tc-section">Prompt</div>
<div class="agent-prompt">{{ (presentation.toolInputs.get(tc.id)?.prompt || '').slice(0, 500) }}{{ (presentation.toolInputs.get(tc.id)?.prompt || '').length > 500 ? '...' : '' }}</div>
</template>
<template v-if="tc.result?.content">
<div class="tc-section">Result</div>
<div class="agent-result" v-html="presentation.toolResultHtml.get(tc.id)"></div>
</template>
</div>
</div>
</template>
<template v-else-if="tc.name === 'Workflow'">
<div class="msg-tool agent-call" :class="{ open: disclosures.isOpen(`tool:${tc.id}`) }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">Workflow</span>
<span class="tool-arg">{{ tc.workflow?.workflow_name || presentation.toolInputs.get(tc.id)?.name || 'Workflow' }}</span>
<span v-if="tc.workflow?.status" class="workflow-status" :class="tc.workflow.status">{{ tc.workflow.status }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body" style="padding:10px 12px;">
<template v-if="tc.workflow?.agents?.length">
<div class="tc-section">Agents &middot; {{ tc.workflow.agents.length }}</div>
<div class="workflow-agent-list">
<template v-for="(phaseAgents, phase) in presentation.workflowAgentGroups.get(tc.id)" :key="phase">
<div class="workflow-phase-group">
<div class="workflow-phase-header">{{ phase }}</div>
<div class="workflow-phase-agents">
<button
v-for="agent in phaseAgents"
:key="agent.agent_id"
class="workflow-agent-row"
@click.stop="navigateToSubagent(agent.agent_id, agent.label || '')"
>
<span class="workflow-agent-label">{{ agent.label || agent.agent_id }}</span>
<span class="workflow-agent-state" :class="agent.state || ''">{{ agent.state || '' }}</span>
</button>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<template v-else>
<div class="msg-tool" :class="{ open: disclosures.isOpen(`tool:${tc.id}`), 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="presentation.toolIcons.get(tc.id)" class="tool-icon" v-html="presentation.toolIcons.get(tc.id)"></span>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ presentation.toolArgPreviews.get(tc.id) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body">
<div class="toolcall-body-strip">
<span class="strip-label">{{ tc.name }}</span>
<span class="spacer"></span>
<button class="raw-toggle" :class="{ active: disclosures.isRaw(`tool:${tc.id}`) }" @click.stop="toggleRaw(`tool:${tc.id}`, msg.uuid)">{ } Raw</button>
</div>
<div class="toolcall-pretty" :class="{ hidden: disclosures.isRaw(`tool:${tc.id}`) }" v-html="presentation.toolPrettyHtml.get(tc.id)"></div>
<div class="toolcall-raw" :class="{ show: disclosures.isRaw(`tool:${tc.id}`) }">
<div class="tc-section">Input</div>
<pre>{{ presentation.toolInputText.get(tc.id) }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</template>
</div>
<div v-if="msg.summary" class="msg-summary" :class="{ open: disclosures.isOpen(`summary:${msg.uuid}`) }" :data-view-key="`summary:${msg.uuid}`">
<button class="summary-toggle" @click="toggleDisclosure(`summary:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="label">Session summary</span>
<span class="source">{{ msg.summary.source || '' }}</span>
</button>
<div class="summary-body" v-html="presentation.summaryHtml"></div>
</div>
</div>
</template>
</template>
+76 -268
View File
@@ -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([
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)
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
const snapshot = {
messages: assembleSessionMessages({ messages, toolCalls, toolResults, subagents, workflows }),
workflows,
};
// 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;
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];
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;
}
// Skip tool_result user messages
if (msg.content_type === 'tool_result') continue;
export function getCachedSessionDetail(sessionId) {
const current = sessionMessageSnapshots.get(sessionId);
if (!current) return null;
return commitSessionDetail(sessionId, current.snapshot, { updateStore: false });
}
// 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);
}
}
// Attach workflow data if present
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
// 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;
+5 -1
View File
@@ -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 };
}
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 `<div class="file-content">
<div class="file-content-head"><span class="label">File contents</span><span class="meta">${total} lines</span></div>
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeHtml(lines.join('\n'))}</div></div>
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
</div>`;
}
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 `<div class="diff-gutter ${line.kind}">${oldNumber.padStart(3)} ${newNumber.padStart(3)}</div><div class="diff-line ${line.kind}"> ${escapeHtml(line.text)}</div>`;
}).join('');
return `<div class="diff-view">
<div class="diff-view-head"><span class="label">Diff</span><div class="stats"><span class="stat-add">+${adds}</span><span class="stat-del">${dels}</span></div></div>
<div class="diff-body">${rows}</div>
</div>`;
}
function renderValue(value) {
if (value === null || value === undefined) return '<span class="literal-null">null</span>';
if (typeof value === 'boolean') return `<span class="literal-bool">${value}</span>`;
if (typeof value === 'number') return `<span class="literal-num">${value}</span>`;
if (typeof value === 'string') {
if (/^https?:\/\//.test(value)) return `<span class="literal-string">${escapeHtml(value)}</span>`;
if (value.length > 120) {
return `<span class="lit-string-long" onclick="this.classList.toggle('open')">"${escapeHtml(value.slice(0, 120))}<span class="long-rest">${escapeHtml(value.slice(120))}</span>"<button class="more-btn">+${value.length - 120}</button></span>`;
}
return `<span class="literal-string">"${escapeHtml(value)}"</span>`;
}
if (Array.isArray(value)) {
if (value.length === 0) return '<span class="literal-null">[]</span>';
if (value.length <= 4 && value.every(item => typeof item !== 'object')) {
return `<span class="literal-string">[${value.map(item => renderValue(item)).join(', ')}]</span>`;
}
return `<span class="literal-null">Array(${value.length})</span>`;
}
if (typeof value === 'object') return `<span class="literal-null">Object(${Object.keys(value).length})</span>`;
return `<span>${escapeHtml(String(value))}</span>`;
}
function renderFieldGrid(object) {
const entries = Object.entries(object);
if (!entries.length) return '';
const rows = entries.map(([key, value]) => (
`<div class="field-key">${escapeHtml(key)}</div><div class="field-val">${renderValue(value)}</div>`
)).join('');
return `<div class="field-grid">${rows}</div>`;
}
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 += '<div style="margin-bottom:10px;padding:8px 12px;border-left:2px solid var(--accent-soft);background:rgba(167,139,250,0.04);border-radius:0 5px 5px 0;">';
if (hero.titleKey) html += `<div style="font-size:14px;font-weight:600;color:var(--fg);margin-bottom:2px;">${escapeHtml(object[hero.titleKey])}</div>`;
const subtitle = [];
if (hero.idKey) subtitle.push(escapeHtml(object[hero.idKey]));
if (hero.urlKey) subtitle.push(escapeHtml(object[hero.urlKey]));
if (subtitle.length) html += `<div style="font-family:var(--font-mono);font-size:11px;color:var(--muted);">${subtitle.join(' · ')}</div>`;
html += '</div>';
}
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 => `<th>${escapeHtml(column)}</th>`).join('');
const body = rows.slice(0, 50).map(row => (
`<tr>${columns.map(column => {
const value = row[column];
if (value == null) return '<td><span class="literal-null">—</span></td>';
if (typeof value === 'string' && value.length > 60) return `<td title="${escapeHtml(value)}">${escapeHtml(value.slice(0, 60))}…</td>`;
if (typeof value === 'object') return `<td>${renderValue(value)}</td>`;
return `<td>${escapeHtml(String(value))}</td>`;
}).join('')}</tr>`
)).join('');
return `<div class="auto-table-wrap">
<div class="auto-table-head"><span class="h-label">Result</span><span class="h-meta">${rows.length} items · ${columns.length} columns</span></div>
<div class="auto-table-scroll"><table class="auto-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>
</div>`;
}
function renderOutput(output, isError) {
if (!output) return '<div style="padding:8px;color:var(--muted-2);font-style:italic;font-size:11px;">No output.</div>';
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 `<div class="file-content">
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeHtml(output)}</div></div>
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
</div>`;
}
return `<div class="result-chip ${isError ? 'error' : ''}">${escapeHtml(output)}</div>`;
}
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 '<div style="color:var(--muted);font-size:11px;font-style:italic;">No content returned.</div>';
return renderFileContent(output);
}
if (toolCall.name === 'Write') {
const path = args.file_path || args.path || '?';
const header = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tool-action-label">Writing</span>
<span class="file-ref">${escapeHtml(path)}</span>
</div>`;
let content = '';
if (args.content) {
const lines = args.content.split('\n');
const gutter = lines.map((_, index) => index + 1).join('\n');
content = `<div class="file-content">
<div class="file-content-head"><span class="label">New file</span><span class="meta">${lines.length} lines</span></div>
<div class="file-content-body collapsed"><div class="gutter">${gutter}</div><div class="code">${escapeHtml(args.content)}</div></div>
</div>`;
}
return header + content + `<div class="result-chip ${isError ? 'error' : ''}">${escapeHtml(output)}</div>`;
}
if (toolCall.name === 'Edit') {
const diff = args.old_string && args.new_string ? renderDiff(args.old_string, args.new_string) : '';
return diff + `<div class="result-chip ${isError ? 'error' : ''}">${escapeHtml(output)}</div>`;
}
const terminal = renderTerminalTool(toolCall.name, args, output, isError);
if (terminal !== null) return terminal;
return `<div class="body-section"><div class="body-label">Input</div>${renderFieldGrid(args)}</div>`
+ (output ? `<div class="body-section" style="margin-top:12px;"><div class="body-label">Output</div>${renderOutput(output, isError)}</div>` : '');
}
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)
: {},
};
}
+45 -581
View File
@@ -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,10 +253,29 @@ 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;
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);
@@ -261,6 +284,7 @@ async function commitSessionSnapshot(latest) {
if (!retainedMessageUuids.has(uuid)) expandedMessageText.delete(uuid);
}
}
}
if (!reconciliation.changed) {
if (state.pendingFocusUuid) await focusPendingMessage();
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 '<div style="color:var(--muted);font-size:11px;font-style:italic;">No content returned.</div>';
return renderFileContent(out);
}
if (tc.name === 'Write') {
const path = args.file_path || args.path || '?';
const header = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tool-action-label">Writing</span>
<span class="file-ref">${escapeH(path)}</span>
</div>`;
let content = '';
if (args.content) {
const lines = args.content.split('\n');
const gutter = lines.map((_, i) => i + 1).join('\n');
content = `<div class="file-content">
<div class="file-content-head"><span class="label">New file</span><span class="meta">${lines.length} lines</span></div>
<div class="file-content-body collapsed"><div class="gutter">${gutter}</div><div class="code">${escapeH(args.content)}</div></div>
</div>`;
}
const chip = `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
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 = `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
return diff + chip;
}
const terminal = renderTerminalTool(tc.name, args, out, isError);
if (terminal !== null) return terminal;
return `<div class="body-section"><div class="body-label">Input</div>${renderFieldGrid(args)}</div>` +
(out ? `<div class="body-section" style="margin-top:12px;"><div class="body-label">Output</div>${renderOutput(out, isError)}</div>` : '');
}
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 `<div class="file-content">
<div class="file-content-head"><span class="label">File contents</span><span class="meta">${total} lines</span></div>
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeH(lines.join('\n'))}</div></div>
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
</div>`;
}
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 `<div class="diff-gutter ${line.kind}">${oldN.padStart(3)} ${newN.padStart(3)}</div><div class="diff-line ${line.kind}"> ${escapeH(line.text)}</div>`;
}).join('');
return `<div class="diff-view">
<div class="diff-view-head"><span class="label">Diff</span><div class="stats"><span class="stat-add">+${adds}</span><span class="stat-del">${dels}</span></div></div>
<div class="diff-body">${rows}</div>
</div>`;
}
function renderFieldGrid(obj) {
const entries = Object.entries(obj);
if (!entries.length) return '';
const rows = entries.map(([k, v]) => {
return `<div class="field-key">${escapeH(k)}</div><div class="field-val">${renderValue(v)}</div>`;
}).join('');
return `<div class="field-grid">${rows}</div>`;
}
function renderValue(v) {
if (v === null || v === undefined) return '<span class="literal-null">null</span>';
if (typeof v === 'boolean') return `<span class="literal-bool">${v}</span>`;
if (typeof v === 'number') return `<span class="literal-num">${v}</span>`;
if (typeof v === 'string') {
if (/^https?:\/\//.test(v)) return `<span class="literal-string">${escapeH(v)}</span>`;
if (v.length > 120) {
return `<span class="lit-string-long" onclick="this.classList.toggle('open')">"${escapeH(v.slice(0, 120))}<span class="long-rest">${escapeH(v.slice(120))}</span>"<button class="more-btn">+${v.length - 120}</button></span>`;
}
return `<span class="literal-string">"${escapeH(v)}"</span>`;
}
if (Array.isArray(v)) {
if (v.length === 0) return '<span class="literal-null">[]</span>';
if (v.length <= 4 && v.every(x => typeof x !== 'object')) return `<span class="literal-string">[${v.map(x => renderValue(x)).join(', ')}]</span>`;
return `<span class="literal-null">Array(${v.length})</span>`;
}
if (typeof v === 'object') {
const keys = Object.keys(v);
return `<span class="literal-null">Object(${keys.length})</span>`;
}
return `<span>${escapeH(String(v))}</span>`;
}
function renderOutput(out, isError) {
if (!out) return '<div style="padding:8px;color:var(--muted-2);font-style:italic;font-size:11px;">No output.</div>';
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 `<div class="file-content">
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeH(out)}</div></div>
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
</div>`;
}
return `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
}
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 += `<div style="margin-bottom:10px;padding:8px 12px;border-left:2px solid var(--accent-soft);background:rgba(167,139,250,0.04);border-radius:0 5px 5px 0;">`;
if (hero.titleKey) html += `<div style="font-size:14px;font-weight:600;color:var(--fg);margin-bottom:2px;">${escapeH(obj[hero.titleKey])}</div>`;
const sub = [];
if (hero.idKey) sub.push(escapeH(obj[hero.idKey]));
if (hero.urlKey) sub.push(escapeH(obj[hero.urlKey]));
if (sub.length) html += `<div style="font-family:var(--font-mono);font-size:11px;color:var(--muted);">${sub.join(' · ')}</div>`;
html += '</div>';
}
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 => `<th>${escapeH(c)}</th>`).join('');
const body = rows.slice(0, 50).map(row =>
`<tr>${cols.map(c => {
const v = row[c];
if (v == null) return '<td><span class="literal-null">—</span></td>';
if (typeof v === 'string' && v.length > 60) return `<td title="${escapeH(v)}">${escapeH(v.slice(0, 60))}…</td>`;
if (typeof v === 'object') return `<td>${renderValue(v)}</td>`;
return `<td>${escapeH(String(v))}</td>`;
}).join('')}</tr>`
).join('');
return `<div class="auto-table-wrap">
<div class="auto-table-head"><span class="h-label">Result</span><span class="h-meta">${rows.length} items · ${cols.length} columns</span></div>
<div class="auto-table-scroll"><table class="auto-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>
</div>`;
}
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 {};
}
}
</script>
<template>
@@ -695,307 +450,16 @@ function getToolCallParsedInput(tc) {
:data-index="virtualRow.index"
:style="{ transform: `translateY(${virtualRow.start - timelineScrollMargin}px)` }"
>
<template v-for="item in [timelineItems[virtualRow.index]]" :key="item.key">
<template v-for="msg in [item.message]" :key="msg.uuid">
<!-- Meta messages: collapsed system indicator -->
<template v-if="item.kind === 'meta'">
<div class="msg meta" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-meta-collapsed" :class="{ open: disclosures.isOpen(`meta:${msg.uuid}`) }" :data-view-key="`meta:${msg.uuid}`">
<button class="meta-toggle" @click="toggleDisclosure(`meta:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="meta-label">System</span>
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
</button>
<div class="meta-body">
<div v-html="renderMarkdown(displayMessageText(msg), { variant: 'compact', query: state.query })"></div>
<button
v-if="canLoadFullText(msg)"
class="truncated-btn"
:disabled="fullTextLoading.has(msg.uuid)"
@click="handleLoadFullText(msg.uuid)"
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
</div>
</div>
</div>
</template>
<!-- Workflow card (standalone, outside assistant bubble) -->
<template v-else-if="item.kind === 'workflow'">
<div class="wf-card" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="wf-card-header">
<span class="wf-card-icon">&#x2699;</span>
<span class="wf-card-name">{{ item.workflowCall.workflow.workflow_name || 'Workflow' }}</span>
<span class="wf-card-count">{{ item.workflowCall.workflow.agents?.length || 0 }} agents</span>
<span
v-if="item.workflowCall.workflow.status"
class="wf-card-status"
:class="item.workflowCall.workflow.status"
>{{ item.workflowCall.workflow.status }}</span>
</div>
<div class="wf-card-body">
<template v-for="(phaseAgents, phase) in groupWorkflowAgents(item.workflowCall.workflow)" :key="phase">
<div class="wf-card-phase">
<div class="wf-card-phase-title">{{ phase }}</div>
<button
v-for="agent in phaseAgents"
:key="agent.agent_id"
class="wf-card-agent"
@click="navigateToSubagent(agent.agent_id, agent.label || '')"
>
<span class="wf-card-agent-label">{{ agent.label || agent.agent_id }}</span>
<span v-if="agent.state === 'error'" class="wf-card-agent-state error">error</span>
<span class="wf-card-agent-arrow">&rarr;</span>
</button>
</div>
</template>
</div>
</div>
</template>
<!-- Non-workflow tools attached to a standalone workflow card -->
<template v-else-if="item.kind === 'workflow-tools'">
<div class="msg assistant" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-tools">
<template v-for="tc in item.toolCalls" :key="tc.id">
<div
class="msg-tool"
:class="{ open: disclosures.isOpen(`tool:${tc.id}`), 'is-error': tc.result && tc.result.is_error }"
:data-view-key="`tool:${tc.id}`"
>
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body">
<div class="toolcall-body-strip">
<span class="strip-label">{{ tc.name }}</span>
<span class="spacer"></span>
<button class="raw-toggle" :class="{ active: disclosures.isRaw(`tool:${tc.id}`) }" @click.stop="toggleRaw(`tool:${tc.id}`, msg.uuid)">{ } Raw</button>
</div>
<div class="toolcall-pretty" :class="{ hidden: disclosures.isRaw(`tool:${tc.id}`) }" v-html="renderPrettyTool(tc)"></div>
<div class="toolcall-raw" :class="{ show: disclosures.isRaw(`tool:${tc.id}`) }">
<div class="tc-section">Input</div>
<pre>{{ formatToolInput(tc) }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Skill card (standalone, like workflow) -->
<template v-else-if="item.kind === 'skill'">
<div
class="skill-card"
:class="{ 'skill-md-open': disclosures.isOpen(`skill:${msg.uuid}`), 'is-focused': focusedItemKey === item.key }"
:data-uuid="item.anchorUuid"
:data-message-uuid="item.messageUuid"
:data-view-key="`skill:${msg.uuid}`"
>
<div class="skill-card-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
</div>
<div class="skill-card-body">
<div class="skill-card-header">
<span class="skill-card-badge">Skill</span>
<span class="skill-card-name">{{ getToolCallParsedInput(msg.tool_calls[0]).skill || '?' }}</span>
</div>
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
<div v-if="getSkillMd(msg)" class="skill-card-md">
<button class="skill-md-toggle" @click="toggleDisclosure(`skill:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span>SKILL.md</span>
</button>
<div class="skill-md-body" v-html="renderMarkdown(getSkillMd(msg), { variant: 'compact' })"></div>
</div>
</div>
</div>
</template>
<!-- Standalone thinking message -->
<template v-else-if="item.kind === 'thinking'">
<div class="msg assistant" :class="{ 'is-focused': focusedItemKey === item.key }" :data-uuid="item.anchorUuid" :data-message-uuid="item.messageUuid">
<div class="msg-thinking" :class="{ open: disclosures.isOpen(`thinking:${msg.uuid}`) }" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleDisclosure(`thinking:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
</div>
</div>
</template>
<!-- Normal message (user or assistant) -->
<template v-else>
<div
class="msg"
:class="[msg.type === 'user' ? 'user' : 'assistant', { 'is-focused': focusedItemKey === item.key }]"
:data-uuid="item.anchorUuid"
:data-message-uuid="item.messageUuid"
>
<!-- Message header -->
<div class="msg-head">
<span class="role">{{ msg.type === 'user' ? 'You' : 'Assistant' }}</span>
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
</div>
<!-- Attached thinking block (merged from preceding thinking messages) -->
<div v-if="msg._thinking" class="msg-thinking" :class="{ open: disclosures.isOpen(`thinking:${msg.uuid}`) }" :data-view-key="`thinking:${msg.uuid}`">
<button class="thinking-toggle" @click="toggleDisclosure(`thinking:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })"></div>
</div>
<!-- Message text body -->
<template v-if="msg.text">
<div v-html="renderMarkdown(displayMessageText(msg), { variant: 'msg', query: state.query })"></div>
<button
v-if="canLoadFullText(msg)"
class="truncated-btn"
:disabled="fullTextLoading.has(msg.uuid)"
@click="handleLoadFullText(msg.uuid)"
>{{ fullTextLoading.has(msg.uuid) ? 'Loading full text…' : 'Message truncated — click to load full text' }}</button>
</template>
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
<div class="msg-text empty-text">(no text content)</div>
</template>
<!-- Tool calls -->
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
<template v-for="tc in msg.tool_calls" :key="tc.id">
<!-- Skill loaded agent equipped a capability -->
<template v-if="tc.name === 'Skill'">
<div class="skill-badge">
<span class="skill-label">skill</span>
<span class="skill-name">{{ getToolCallParsedInput(tc).skill || '?' }}</span>
</div>
</template>
<!-- Agent/Task tool call (subagent) -->
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
<div class="msg-tool agent-call" :class="{ open: disclosures.isOpen(`tool:${tc.id}`) }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
<span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
<button
v-if="tc.subagent?.agent_id"
class="agent-nav-btn"
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
>View conversation &rarr;</button>
</button>
<div class="toolcall-body" style="padding:10px 12px;">
<template v-if="getToolCallParsedInput(tc).prompt">
<div class="tc-section">Prompt</div>
<div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</div>
</template>
<template v-if="tc.result?.content">
<div class="tc-section">Result</div>
<div class="agent-result" v-html="renderMarkdown(tc.result.content, { variant: 'compact' })"></div>
</template>
</div>
</div>
</template>
<!-- Workflow tool call (inside assistant bubble) -->
<template v-else-if="tc.name === 'Workflow'">
<div class="msg-tool agent-call" :class="{ open: disclosures.isOpen(`tool:${tc.id}`) }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">Workflow</span>
<span class="tool-arg">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span>
<span
v-if="tc.workflow?.status"
class="workflow-status"
:class="tc.workflow.status"
>{{ tc.workflow.status }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body" style="padding:10px 12px;">
<template v-if="tc.workflow?.agents?.length">
<div class="tc-section">Agents &middot; {{ tc.workflow.agents.length }}</div>
<div class="workflow-agent-list">
<template v-for="(phaseAgents, phase) in groupWorkflowAgents(tc.workflow)" :key="phase">
<div class="workflow-phase-group">
<div class="workflow-phase-header">{{ phase }}</div>
<div class="workflow-phase-agents">
<button
v-for="a in phaseAgents"
:key="a.agent_id"
class="workflow-agent-row"
@click.stop="navigateToSubagent(a.agent_id, a.label || '')"
>
<span class="workflow-agent-label">{{ a.label || a.agent_id }}</span>
<span class="workflow-agent-state" :class="a.state || ''">{{ a.state || '' }}</span>
</button>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<!-- Generic tool call -->
<template v-else>
<div class="msg-tool" :class="{ open: disclosures.isOpen(`tool:${tc.id}`), 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
<button class="toolcall-toggle" @click="toggleDisclosure(`tool:${tc.id}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body">
<div class="toolcall-body-strip">
<span class="strip-label">{{ tc.name }}</span>
<span class="spacer"></span>
<button class="raw-toggle" :class="{ active: disclosures.isRaw(`tool:${tc.id}`) }" @click.stop="toggleRaw(`tool:${tc.id}`, msg.uuid)">{ } Raw</button>
</div>
<div class="toolcall-pretty" :class="{ hidden: disclosures.isRaw(`tool:${tc.id}`) }" v-html="renderPrettyTool(tc)"></div>
<div class="toolcall-raw" :class="{ show: disclosures.isRaw(`tool:${tc.id}`) }">
<div class="tc-section">Input</div>
<pre>{{ formatToolInput(tc) }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</template>
</div>
<!-- Summary block -->
<div v-if="msg.summary" class="msg-summary" :class="{ open: disclosures.isOpen(`summary:${msg.uuid}`) }" :data-view-key="`summary:${msg.uuid}`">
<button class="summary-toggle" @click="toggleDisclosure(`summary:${msg.uuid}`, msg.uuid)">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="label">Session summary</span>
<span class="source">{{ msg.summary.source || '' }}</span>
</button>
<div class="summary-body" v-html="renderMarkdown(msg.summary.content, { variant: 'compact' })"></div>
</div>
</div>
</template>
</template>
</template>
<SessionTimelineRow
:item="timelineItems[virtualRow.index]"
:focused="focusedItemKey === timelineItems[virtualRow.index].key"
:query="state.query"
:disclosures="disclosures"
:expanded-message-text="expandedMessageText"
:full-text-loading="fullTextLoading"
@load-full-text="handleLoadFullText"
@navigate-subagent="navigateToSubagent"
/>
</div>
</div>
</template>
+24
View File
@@ -3,3 +3,27 @@ export interface SourceQueryOptions {
}
export type UsageStatsOptions = SourceQueryOptions;
export type SessionPatchTable =
| 'messages'
| 'toolCalls'
| 'toolResults'
| 'subagents'
| 'workflows'
| 'summaries';
export type SessionPatchRow = Record<string, unknown>;
export type SessionPatchSnapshot = Partial<Record<SessionPatchTable, SessionPatchRow[]>>;
export type SessionPatchCursor = Record<SessionPatchTable, Record<string, string>>;
export interface SessionPatch {
changes: Record<SessionPatchTable, SessionPatchRow[]>;
removed: Record<SessionPatchTable, string[]>;
hashes: Record<SessionPatchTable, Record<string, string>>;
positions: Record<SessionPatchTable, Record<string, number>>;
}
export interface AppliedSessionPatch {
snapshot: Record<SessionPatchTable, SessionPatchRow[]>;
cursor: SessionPatchCursor;
}
+173
View File
@@ -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<string, SessionToolResultRow>} */ (new Map());
for (const result of toolResults || []) resultsByCallId.set(result.tool_use_id, result);
const subagentsByCallId = /** @type {Map<string, SessionSubagentRow>} */ (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<string, AssembledToolCall[]>} */ (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;
}
+105
View File
@@ -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;
}
+158
View File
@@ -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<SessionPatchTable, T>}
*/
function emptyTables(factory) {
return /** @type {Record<SessionPatchTable, T>} */ (
Object.fromEntries(TABLE_NAMES.map(table => [table, factory()]))
);
}
/**
* @param {SessionPatchSnapshot} [snapshot]
* @returns {SessionPatchCursor}
*/
export function createSessionPatchCursor(snapshot = {}) {
const cursor = emptyTables(() => /** @type {Record<string, string>} */ ({}));
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<SessionPatchCursor>} [cursor]
* @returns {SessionPatch}
*/
export function createSessionPatch(snapshot = {}, cursor = {}) {
const changes = emptyTables(() => /** @type {SessionPatchRow[]} */ ([]));
const removed = emptyTables(() => /** @type {string[]} */ ([]));
const hashes = emptyTables(() => /** @type {Record<string, string>} */ ({}));
const positions = emptyTables(() => /** @type {Record<string, number>} */ ({}));
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<SessionPatchCursor>} [cursor]
* @param {Partial<SessionPatch>} [patch]
* @returns {AppliedSessionPatch}
*/
export function applySessionPatch(snapshot = {}, cursor = {}, patch = {}) {
const nextSnapshot = emptyTables(() => /** @type {SessionPatchRow[]} */ ([]));
const nextCursor = emptyTables(() => /** @type {Record<string, string>} */ ({}));
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 };
}
+364 -21
View File
@@ -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;
+1 -1
View File
@@ -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"]
}
+58
View File
@@ -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,
}]);
});
+84
View File
@@ -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);
});
+100
View File
@@ -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',
);
});
+10
View File
@@ -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);
});
+37 -5
View File
@@ -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, /<SessionTimelineRow/);
assert.match(timelineRow, /buildSessionTimelinePresentation/);
assert.doesNotMatch(sessionDetail, /renderMarkdown|renderPrettyTool/);
assert.doesNotMatch(sessionDetail, /querySelectorAll/);
assert.doesNotMatch(sessionDetail, /v-memo/);
assert.doesNotMatch(sessionDetail + timelineRow, /v-memo/);
assert.doesNotMatch(sessionDetail, /session-view-state/);
assert.doesNotMatch(sessionDetail, /outerHTML/);
assert.doesNotMatch(sessionDetail, /closest\(['"]\.msg/);
@@ -44,14 +55,35 @@ test('timeline viewport owns dynamic measurement, overscan, anchoring, and tail-
test('timeline count and disclosure classes come from renderer state rather than DOM state', () => {
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\)/);
});