diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e732cb8 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +Developers and coding-agent users who need to revisit past work, understand what an agent did, verify why a decision was made, and manage durable memory without leaving the evidence behind. + +## Product Purpose + +Obelisk is explicit memory infrastructure for coding agents. Its core indexes local agent history into a queryable evidence layer; the desktop app is the human observability and memory-control surface for inspecting sessions, auditing evidence, managing the index, and reviewing or revoking durable memories. Success means a person can move from a remembered question to the exact historical evidence with low friction and high confidence. + +## Brand Personality + +Calm, exact, auditable. Obelisk should feel trustworthy around private local history, dense without being hostile, and confident without hiding uncertainty or provenance. + +## Anti-references + +Obelisk should not resemble an undifferentiated chat-log browser, a terminal log wall, an opaque AI-summary dashboard, or a decorative analytics product that separates conclusions from their source evidence. + +## Design Principles + +1. Evidence before assertion: every interpretation keeps a clear path back to the raw session record. +2. Human-readable by default: expose protocol and storage detail on demand, not as the primary reading experience. +3. Preserve uncertainty: never present inferred structure as observed execution fact. +4. Progressive density: make long sessions scannable without discarding the depth experts need. +5. Local trust is visible: controls, provenance, and failure states should reinforce that the user remains in charge of their history and memories. + +## Accessibility & Inclusion + +Target WCAG AA contrast for essential text and controls. Core session navigation and disclosure controls should work from the keyboard, focus must remain visible, state cannot rely on color alone, and motion should respect reduced-motion preferences. diff --git a/README.md b/README.md index eea8ba2..3363a62 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,11 @@ For live app refresh, Obelisk watches `~/.claude/projects` and `~/.codex/session You can use obelisk like: ``` -/obelisk-skill 上次 auth bug 最后到底改了哪些文件,为什么这么改 -/obelisk-skill 这个文件最近在哪些 sessions 里被反复修改 -/obelisk-skill 找出最近失败的 tool calls,它们分别发生在哪些任务里 -/obelisk-skill 那个 review workflow 的 subagents 各自结论是什么 -/obelisk-skill recap this week +/obelisk 上次 auth bug 最后到底改了哪些文件,为什么这么改 +/obelisk 这个文件最近在哪些 sessions 里被反复修改 +/obelisk 找出最近失败的 tool calls,它们分别发生在哪些任务里 +/obelisk 那个 review workflow 的 subagents 各自结论是什么 +/obelisk recap this week ``` ### Install @@ -60,7 +60,7 @@ Or manually: copy `obelisk-skill/` into your project's `.claude/skills/` Then in any Claude Code session: ``` -/obelisk-skill +/obelisk ``` First run builds the index (~5 seconds for 100 sessions). After that it rebuilds incrementally. diff --git a/app/src/main/indexer.ts b/app/src/main/indexer.ts index 56fe626..8ce7db6 100644 --- a/app/src/main/indexer.ts +++ b/app/src/main/indexer.ts @@ -3,7 +3,10 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; -import { parse as claudeParse } from '../../../packages/core/src/providers/claude.ts'; +import { + CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER, + parse as claudeParse, +} from '../../../packages/core/src/providers/claude.ts'; import { parse as codexParse } from '../../../packages/core/src/providers/codex.ts'; import { persist } from '../../../packages/core/src/persist.ts'; import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts'; @@ -307,9 +310,9 @@ function needsReindex(db, fp) { // Index one Claude transcript via the shared provider + persist core. // Returns { sessionId, path } when reindexed, undefined when skipped. -function indexClaudeFile(db, file) { +function indexClaudeFile(db, file, { forceFull = false } = {}) { const { needed, skip, mtime } = needsReindex(db, file.path); - if (!needed) return undefined; + if (!needed && !forceFull) return undefined; const unit = { key: file.path, sessionId: file.sessionId, @@ -317,7 +320,7 @@ function indexClaudeFile(db, file) { isSubagent: file.isSubagent, agentId: file.agentId, }; - const cursor = skip > 0 ? `${mtime}:${skip}` : null; + const cursor = !forceFull && skip > 0 ? `${mtime}:${skip}` : null; persist(db, unit, claudeParse(unit, cursor)); return { sessionId: file.sessionId, path: file.path }; } @@ -617,13 +620,25 @@ function buildIndex({ try { const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl }); const txDb = betterSqliteTransactionAdapter(db); + const claudeInputMarkerMissing = !db.prepare( + 'SELECT jsonl_path FROM index_state WHERE jsonl_path = ?', + ).get(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER); + const claudeInputSemanticsOutdated = claudeInputMarkerMissing && Boolean(db.prepare(` + SELECT 1 FROM messages + WHERE COALESCE(source, 'claude') = 'claude' + AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL) + LIMIT 1 + `).get()); let messageFtsTriggersDropped = false; try { if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) { copyMemoriesFromDb(db, preserveDbPath); } const files = [ - ...discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths }), + ...discoverJsonlFiles({ + projectsDir, + changedPaths: force || claudeInputSemanticsOutdated ? undefined : changedPaths, + }), ...discoverCodexJsonlFiles({ codexDir, changedPaths: force ? undefined : changedPaths }), ]; const latestSourceMtime = files.reduce((latest, file) => { @@ -682,12 +697,15 @@ function buildIndex({ } } const skipped: SkippedFile[] = []; + let claudeInputMigrationFailed = false; for (const file of files) { try { // The write is committed before affectedSessionIds is updated, so a // failed/rolled-back file never reports a phantom updated session. const indexed = runRetryableWriteTransaction(txDb, () => { - const result = file.source === 'codex' ? indexCodexFile(db, file) : indexClaudeFile(db, file); + const result = file.source === 'codex' + ? indexCodexFile(db, file) + : indexClaudeFile(db, file, { forceFull: claudeInputSemanticsOutdated }); const metaIndexed = file.source !== 'codex' && indexSubagentMeta(db, file); if (!result?.sessionId && metaIndexed && changedMetaJsonlPaths.has(file.path)) { return { sessionId: file.sessionId, path: file.path }; @@ -706,6 +724,9 @@ function buildIndex({ }); } if (hasUnusableTransaction(error)) throw error; + if (claudeInputSemanticsOutdated && file.source !== 'codex') { + claudeInputMigrationFailed = true; + } skipped.push({ path: file.path, error: (error as Error).message, diagnostics: (error as { obelisk?: unknown }).obelisk }); console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`); } @@ -724,6 +745,9 @@ function buildIndex({ writeIndexMarker(db, '__last_build__'); writeIndexMarker(db, '__app_last_successful_build__'); writeIndexMarker(db, '__indexer_owner_app__'); + if (!claudeInputSemanticsOutdated || !claudeInputMigrationFailed) { + writeIndexMarker(db, CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER); + } if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime); }, { label: 'finalize' }); } catch (error) { diff --git a/app/src/renderer/src/session-view-state.mjs b/app/src/renderer/src/session-view-state.mjs new file mode 100644 index 0000000..03600d2 --- /dev/null +++ b/app/src/renderer/src/session-view-state.mjs @@ -0,0 +1,108 @@ +const DISCLOSURE_CLASSES = ['open', 'skill-md-open']; +const SCROLL_ITEM_SELECTOR = '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]'; + +function arrayFrom(value) { + return value ? Array.from(value) : []; +} + +function scrollItems(detail) { + return arrayFrom(detail?.querySelectorAll?.(SCROLL_ITEM_SELECTOR)); +} + +export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = {}) { + if (!wrap) return null; + const distanceFromBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight; + const followTail = distanceFromBottom < bottomThreshold; + const wrapTop = wrap.getBoundingClientRect?.().top || 0; + const anchorElement = followTail + ? null + : scrollItems(detail).find(element => element.getBoundingClientRect().bottom > wrapTop); + + const disclosures = []; + for (const element of arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))) { + const key = element.dataset?.viewKey; + if (!key) continue; + const classes = DISCLOSURE_CLASSES.filter(className => element.classList?.contains(className)); + const rawOpen = Boolean(element.querySelector?.('.toolcall-raw')?.classList?.contains('show')); + if (classes.length || rawOpen) disclosures.push({ key, classes, rawOpen }); + } + + return { + followTail, + scrollTop: wrap.scrollTop, + anchor: anchorElement?.dataset?.uuid + ? { + uuid: anchorElement.dataset.uuid, + offset: anchorElement.getBoundingClientRect().top - wrapTop, + } + : null, + disclosures, + }; +} + +export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll = true } = {}) { + if (!snapshot || !wrap) return; + + const disclosuresByKey = new Map( + arrayFrom(detail?.querySelectorAll?.('[data-view-key]')) + .filter(element => element.dataset?.viewKey) + .map(element => [element.dataset.viewKey, element]), + ); + + for (const disclosure of snapshot.disclosures || []) { + const element = disclosuresByKey.get(disclosure.key); + if (!element) continue; + element.classList?.add(...disclosure.classes); + if (!disclosure.rawOpen) continue; + element.querySelector?.('.toolcall-raw')?.classList?.add('show'); + element.querySelector?.('.toolcall-pretty')?.classList?.add('hidden'); + element.querySelector?.('.raw-toggle')?.classList?.add('active'); + } + + if (!restoreScroll) return; + + if (snapshot.followTail) { + wrap.scrollTop = wrap.scrollHeight; + return; + } + + wrap.scrollTop = snapshot.scrollTop; + if (!snapshot.anchor) return; + const wrapTop = wrap.getBoundingClientRect?.().top || 0; + const anchorElement = scrollItems(detail).find( + element => element.dataset?.uuid === snapshot.anchor.uuid, + ); + if (!anchorElement) return; + const currentOffset = anchorElement.getBoundingClientRect().top - wrapTop; + wrap.scrollTop += currentOffset - snapshot.anchor.offset; +} + +export function reconcileSessionMessages(current = [], incoming = []) { + const currentByUuid = new Map( + current.filter(message => message?.uuid).map(message => [message.uuid, message]), + ); + return incoming.map(message => { + if (!message?.uuid) return message; + const existing = currentByUuid.get(message.uuid); + if (!existing) return message; + Object.assign(existing, message); + return existing; + }); +} + +export function findLastMessageAtOrAbove(messages, bottomLine) { + if (!messages?.length) return -1; + let low = 0; + let high = messages.length - 1; + let result = 0; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + if (messages[middle].getBoundingClientRect().bottom <= bottomLine) { + result = middle; + low = middle + 1; + } else { + high = middle - 1; + } + } + return result; +} diff --git a/app/src/renderer/src/tool-renderer.js b/app/src/renderer/src/tool-renderer.js new file mode 100644 index 0000000..f1968b8 --- /dev/null +++ b/app/src/renderer/src/tool-renderer.js @@ -0,0 +1,402 @@ +function escapeHTML(value) { + return String(value).replace(/&/g, '&').replace(//g, '>'); +} + +const JAVASCRIPT_KEYWORDS = new Set([ + 'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', + 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', + 'for', 'from', 'function', 'get', 'if', 'implements', 'import', 'in', + 'instanceof', 'interface', 'let', 'new', 'of', 'package', 'private', 'protected', + 'public', 'return', 'set', 'static', 'super', 'switch', 'throw', 'try', 'typeof', + 'var', 'void', 'while', 'with', 'yield', +]); +const JAVASCRIPT_LITERALS = new Set(['false', 'Infinity', 'NaN', 'null', 'true', 'undefined']); +const CODEACT_GLOBALS = new Set([ + 'ALL_TOOLS', 'Array', 'Boolean', 'Date', 'Error', 'JSON', 'Map', 'Math', 'Number', + 'Object', 'Promise', 'RegExp', 'Set', 'String', 'clearTimeout', 'generatedImage', + 'image', 'load', 'notify', 'setTimeout', 'store', 'text', 'tools', 'yield_control', +]); + +function isIdentifierStart(char) { + return /[A-Za-z_$]/.test(char); +} + +function isIdentifierPart(char) { + return /[\w$]/.test(char); +} + +function highlightJavaScript(source) { + const code = String(source); + let html = ''; + let plain = ''; + let index = 0; + + const flushPlain = () => { + if (!plain) return; + html += escapeHTML(plain); + plain = ''; + }; + const token = (kind, value) => { + flushPlain(); + html += `${escapeHTML(value)}`; + }; + + while (index < code.length) { + const char = code[index]; + const next = code[index + 1]; + + if (char === '/' && next === '/') { + const start = index; + index += 2; + while (index < code.length && code[index] !== '\n') index += 1; + token('comment', code.slice(start, index)); + continue; + } + + if (char === '/' && next === '*') { + const start = index; + index += 2; + while (index < code.length && !(code[index] === '*' && code[index + 1] === '/')) index += 1; + if (index < code.length) index += 2; + token('comment', code.slice(start, index)); + continue; + } + + if (char === '"' || char === "'" || char === '`') { + const start = index; + const quote = char; + index += 1; + while (index < code.length) { + if (code[index] === '\\') { + index = Math.min(index + 2, code.length); + continue; + } + if (code[index] === quote) { + index += 1; + break; + } + index += 1; + } + token('string', code.slice(start, index)); + continue; + } + + if (/\d/.test(char) || (char === '.' && /\d/.test(next))) { + const match = code.slice(index).match(/^(?:0[xX][\dA-Fa-f](?:_?[\dA-Fa-f])*n?|0[bB][01](?:_?[01])*n?|0[oO][0-7](?:_?[0-7])*n?|(?:\d(?:_?\d)*)?(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?n?)/); + const value = match?.[0]; + if (value) { + token('number', value); + index += value.length; + continue; + } + } + + if (isIdentifierStart(char)) { + const start = index; + index += 1; + while (index < code.length && isIdentifierPart(code[index])) index += 1; + const value = code.slice(start, index); + if (JAVASCRIPT_KEYWORDS.has(value)) token('keyword', value); + else if (JAVASCRIPT_LITERALS.has(value)) token('literal', value); + else if (CODEACT_GLOBALS.has(value)) token('global', value); + else plain += value; + continue; + } + + plain += char; + index += 1; + } + + flushPlain(); + return html; +} + +const TERMINAL_ICON = ''; +const TOOL_ICONS = { + Bash: TERMINAL_ICON, + exec: TERMINAL_ICON, + Read: '', + Edit: '', + Write: '', +}; + +export function getToolIcon(name) { + return TOOL_ICONS[name] || ''; +} + +export function getArgPreview(toolCall) { + try { + const input = JSON.parse(toolCall.input_json || '{}'); + if (typeof input === 'string') return input.slice(0, 90); + if (input.file_path) return input.file_path; + if (input.command) return input.command; + if (input.path) return input.path; + if (input.query) return input.query; + if (input.description) return input.description; + if (input.pattern) return input.pattern; + if (input.url) return input.url; + if (input.name) return input.name; + if (input.title) return input.title; + for (const key of Object.keys(input)) { + if (typeof input[key] === 'string' && input[key].length < 90) return input[key]; + } + return JSON.stringify(input).slice(0, 90); + } catch { + return (toolCall.input_json || '').slice(0, 90); + } +} + +function renderTerminal(command, output, isError) { + let formatted = escapeHTML(output); + formatted = formatted.replace(/(✓[^\n]*)/g, '$1'); + formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '$1'); + return `
+
$${escapeHTML(command)}
+ ${output ? `
${formatted}
` : ''} +
`; +} + +function decodeJsonStringPrefix(source, start) { + let value = ''; + let index = start; + let complete = false; + while (index < source.length) { + const char = source[index++]; + if (char === '"') { + complete = true; + break; + } + if (char !== '\\') { + value += char; + continue; + } + if (index >= source.length) break; + const escaped = source[index++]; + if (escaped === 'n') value += '\n'; + else if (escaped === 'r') value += '\r'; + else if (escaped === 't') value += '\t'; + else if (escaped === 'b') value += '\b'; + else if (escaped === 'f') value += '\f'; + else if (escaped === 'u') { + const hex = source.slice(index, index + 4); + if (/^[0-9a-fA-F]{4}$/.test(hex)) { + value += String.fromCharCode(Number.parseInt(hex, 16)); + index += 4; + } + } else { + value += escaped; + } + } + return { value, next: index, complete }; +} + +function extractInputTextBlocks(raw) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const texts = parsed + .filter(item => item?.type === 'input_text' && typeof item.text === 'string') + .map(item => item.text); + if (texts.length) { + return { + texts, + unwrapped: true, + truncated: false, + hasOtherBlocks: texts.length !== parsed.length, + }; + } + } + } catch {} + + const marker = '"text":"'; + const texts = []; + let cursor = 0; + while (raw.includes('"type":"input_text"', cursor)) { + const markerIndex = raw.indexOf(marker, cursor); + if (markerIndex === -1) break; + const decoded = decodeJsonStringPrefix(raw, markerIndex + marker.length); + texts.push(decoded.value); + cursor = Math.max(decoded.next, markerIndex + marker.length); + if (!decoded.complete) break; + } + if (texts.length) { + return { texts, unwrapped: true, truncated: true, hasOtherBlocks: false }; + } + return { texts: [raw], unwrapped: false, truncated: false, hasOtherBlocks: false }; +} + +function parseScriptHeader(text, isError) { + const match = String(text).match(/^Script (completed|failed|running)(?: with cell ID ([^\n]+))?\nWall time ([^\n]+)\nOutput:\n?/); + if (!match) { + return { + status: isError ? 'failed' : 'complete', + cellId: null, + rest: String(text), + matched: false, + }; + } + return { + status: match[1] === 'completed' ? 'complete' : match[1], + cellId: match[2] || null, + rest: String(text).slice(match[0].length), + matched: true, + }; +} + +function tryFormatJson(text) { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return null; + } +} + +function highlightJson(json) { + let html = ''; + let plain = ''; + let index = 0; + + const flushPlain = () => { + if (!plain) return; + html += escapeHTML(plain); + plain = ''; + }; + const token = (kind, value) => { + flushPlain(); + html += `${escapeHTML(value)}`; + }; + + while (index < json.length) { + const char = json[index]; + + if (char === '"') { + const start = index; + index += 1; + while (index < json.length) { + if (json[index] === '\\') { + index = Math.min(index + 2, json.length); + continue; + } + if (json[index] === '"') { + index += 1; + break; + } + index += 1; + } + let lookahead = index; + while (/\s/.test(json[lookahead])) lookahead += 1; + token(json[lookahead] === ':' ? 'key' : 'string', json.slice(start, index)); + continue; + } + + if (char === '-' || /\d/.test(char)) { + const match = json.slice(index).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/); + if (match) { + token('number', match[0]); + index += match[0].length; + continue; + } + } + + const literal = ['true', 'false', 'null'].find(value => json.startsWith(value, index)); + if (literal) { + token('literal', literal); + index += literal.length; + continue; + } + + plain += char; + index += 1; + } + + flushPlain(); + return html; +} + +function formatResultBlocks(blocks) { + return blocks.filter(block => block !== '').map(block => { + const formatted = tryFormatJson(block); + return formatted === null + ? { text: block, html: escapeHTML(block), isJson: false } + : { text: formatted, html: highlightJson(formatted), isJson: true }; + }); +} + +function decodeCodeActOutput(raw, isError) { + const extracted = extractInputTextBlocks(String(raw || '')); + const first = extracted.texts[0] || ''; + const header = parseScriptHeader(first, isError); + const bodyBlocks = header.matched + ? [header.rest, ...extracted.texts.slice(1)] + : extracted.texts; + return { + ...header, + blocks: formatResultBlocks(bodyBlocks), + truncated: extracted.truncated || String(raw || '').length >= 10000, + hasOtherBlocks: extracted.hasOtherBlocks, + }; +} + +function renderCodeAct(source, output, isError) { + const code = String(source || ''); + const lines = code.split('\n'); + const gutter = lines.map((_, index) => index + 1).join('\n'); + const result = decodeCodeActOutput(output, isError); + const statusLabel = result.status === 'failed' ? 'Failed' : 'Running'; + const emptyText = result.status === 'running' + ? 'Execution was still running when this event was captured.' + : result.status === 'failed' + ? 'No failure details were captured.' + : 'No result returned.'; + const cell = result.cellId ? `Cell ${escapeHTML(result.cellId)}` : ''; + const status = result.status === 'complete' + ? '' + : `${statusLabel}`; + const metadata = status || cell + ? `
${status}${cell}
` + : ''; + const notes = [ + result.truncated ? '
Indexed output truncated. Open Raw to inspect the captured envelope.
' : '', + result.hasOtherBlocks ? '
Additional structured blocks are available in Raw.
' : '', + ].join(''); + const resultContent = result.blocks.length + ? `
${result.blocks.map((block, index) => ` +
${block.html}
`).join('')} +
` + : `
${escapeHTML(emptyText)}
`; + + return `
+
+
+ +
+
+ +
${highlightJavaScript(code)}
+
+
+
+
+ + ${metadata} +
+ ${resultContent} + ${notes} +
+
`; +} + +export function renderTerminalTool(name, input, output, isError) { + if (name === 'Bash') { + const description = input?.description + ? `
${escapeHTML(input.description)}
` + : ''; + return description + renderTerminal(input?.command || '', output, isError); + } + if (name === 'exec') { + return renderCodeAct(typeof input === 'string' ? input : '', output, isError); + } + return null; +} diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index cf6d027..3b85e68 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -4,7 +4,14 @@ 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 { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js'; import FlapNumber from '../components/FlapNumber.vue'; +import { + captureSessionViewState, + findLastMessageAtOrAbove, + reconcileSessionMessages, + restoreSessionViewState, +} from '../session-view-state.mjs'; import { escapeHTML, fmtRelative, @@ -27,6 +34,7 @@ const progressPct = ref(0); const active = ref(false); let removeSessionUpdated = null; let keydownAttached = false; +let scrollRevision = 0; // DOM refs const wrapRef = ref(null); @@ -119,6 +127,8 @@ onDeactivated(() => { onUnmounted(() => { active.value = false; detachKeydown(); + if (scrollFrame !== null) cancelAnimationFrame(scrollFrame); + scrollFrame = null; removeSessionUpdated?.(); removeSessionUpdated = null; }); @@ -135,33 +145,36 @@ watch(() => props.id, async (newId, oldId) => { async function loadMessages({ force = false } = {}) { if (!props.id) return; const hadContent = messages.value.length > 0; - const wasAtBottom = hadContent && wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50; - const prevScrollTop = wrapRef.value?.scrollTop || 0; + const viewState = hadContent + ? captureSessionViewState({ wrap: wrapRef.value, detail: detailRef.value }) + : null; + const scrollRevisionAtLoad = scrollRevision; - loading.value = true; + loading.value = !hadContent; try { const s = state.sessions.find(x => x.id === props.id); if (s && (force || !s.messages || s.messages.length === 0)) { - const loaded = await loadSessionDetail(props.id); - if (loaded) Object.assign(s, loaded); + await loadSessionDetail(props.id); } const latest = state.sessions.find(x => x.id === props.id); - messages.value = latest?.messages || []; + const incoming = latest?.messages || []; + messages.value = hadContent + ? reconcileSessionMessages(messages.value, incoming) + : incoming; } finally { loading.value = false; } - nextTick(() => { - if (!wrapRef.value) return; - if (!state.pendingFocusUuid) { - if (wasAtBottom) { - wrapRef.value.scrollTop = wrapRef.value.scrollHeight; - } else { - wrapRef.value.scrollTop = prevScrollTop; - } - } + await nextTick(); + syncTotalMessages(); + if (!state.pendingFocusUuid) { + restoreSessionViewState(viewState, { + wrap: wrapRef.value, + detail: detailRef.value, + restoreScroll: scrollRevision === scrollRevisionAtLoad, + }); onScroll(); - }); + } // Focus pending uuid if any if (state.pendingFocusUuid) { @@ -192,22 +205,36 @@ async function focusPendingMessage() { const currentMsgIdx = ref(0); const totalMsgs = ref(0); let navLock = false; +let scrollFrame = null; -function onScroll() { +function syncTotalMessages() { + const msgs = detailRef.value?.querySelectorAll('.msg, .wf-card, .skill-card'); + totalMsgs.value = msgs?.length || 0; +} + +function onScroll(event) { + if (event) scrollRevision++; if (navLock) return; + if (scrollFrame !== null) return; + scrollFrame = requestAnimationFrame(() => { + scrollFrame = null; + updateScrollProgress(); + }); +} + +function updateScrollProgress() { if (!wrapRef.value || !detailRef.value) return; const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card'); - if (!msgs.length) return; - totalMsgs.value = msgs.length; + if (!msgs.length) { + currentMsgIdx.value = 0; + progressPct.value = 0; + return; + } const el = wrapRef.value; const navHeight = 52; const bottomLine = el.getBoundingClientRect().bottom - navHeight; - let bottomMsgIdx = 0; - for (let i = 0; i < msgs.length; i++) { - if (msgs[i].getBoundingClientRect().bottom <= bottomLine) bottomMsgIdx = i; - else break; - } + const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine); currentMsgIdx.value = bottomMsgIdx; const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100); progressPct.value = pct; @@ -283,27 +310,6 @@ function navigateToSubagent(agentId, description) { // --- Render helpers (produce raw HTML strings like the vanilla version) --- -function getArgPreview(tc) { - try { - const j = JSON.parse(tc.input_json || '{}'); - if (j.file_path) return j.file_path; - if (j.command) return j.command; - if (j.path) return j.path; - if (j.query) return j.query; - if (j.description) return j.description; - if (j.pattern) return j.pattern; - if (j.url) return j.url; - if (j.name) return j.name; - if (j.title) return j.title; - for (const k of Object.keys(j)) { - if (typeof j[k] === 'string' && j[k].length < 90) return j[k]; - } - return JSON.stringify(j).slice(0, 90); - } catch { - return (tc.input_json || '').slice(0, 90); - } -} - function formatToolInput(tc) { try { const j = JSON.parse(tc.input_json || '{}'); @@ -317,17 +323,6 @@ function escapeH(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } -const TOOL_ICONS = { - Bash: '', - Read: '', - Edit: '', - Write: '', -}; - -function getToolIcon(name) { - return TOOL_ICONS[name] || ''; -} - function renderPrettyTool(tc) { let args; try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; } @@ -367,10 +362,8 @@ function renderPrettyTool(tc) { return diff + chip; } - if (tc.name === 'Bash') { - const desc = args.description ? `
${escapeH(args.description)}
` : ''; - return desc + renderTerminal(args.command || '', out, isError); - } + const terminal = renderTerminalTool(tc.name, args, out, isError); + if (terminal !== null) return terminal; return `
Input
${renderFieldGrid(args)}
` + (out ? `
Output
${renderOutput(out, isError)}
` : ''); @@ -431,16 +424,6 @@ function renderDiff(oldStr, newStr) { `; } -function renderTerminal(command, output, isError) { - let formatted = escapeH(output); - formatted = formatted.replace(/(✓[^\n]*)/g, '$1'); - formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '$1'); - return `
-
$${escapeH(command)}
- ${output ? `
${formatted}
` : ''} -
`; -} - function renderFieldGrid(obj) { const entries = Object.entries(obj); if (!entries.length) return ''; @@ -642,7 +625,7 @@ function getToolCallParsedInput(tc) {