feat(app): live session update + tool renderer + input_tokens migration

SessionDetail live update:
- Extract session-view-state.mjs: capture scroll position, disclosure
  (open/skill-md-open) state, and visible-UUID anchor before refresh;
  reconcile messages by UUID (in-place update, append tail only); restore
  scroll and disclosure state after DOM patch. findLastMessageAtOrAbove uses
  binary search (O(log n)) instead of linear scan.
- scrollRevision tracks user scrolls during refresh to avoid stale anchors
  overriding manual navigation.
- Throttle onScroll to one rAF per frame.

Tool renderer:
- Extract tool-renderer.js: standalone module for rendering tool call cards
  (Read/Write/Edit diffs, Bash terminal output, search results, JS/TS
  syntax highlighting). Replaces inline rendering in SessionDetail.
- tests/app-tool-renderer.test.mjs covers escaping, highlighting, and
  terminal formatting.

Input tokens semantics migration:
- Claude provider now sums input_tokens + cache_creation_input_tokens +
  cache_read_input_tokens into a single input_tokens value (was previously
  only the raw field, undercounting when cache tokens are present).
- One-time index-wide re-parse triggered when the marker
  __claude_input_tokens_include_cache_v1__ is absent and the DB already
  has token data (self-healing on first build after upgrade).
- App indexer.ts carries the same marker check for the app's build path.

Also:
- PRODUCT.md: product register (users, purpose, brand, design principles,
  accessibility targets).
- README.md: minor wording updates.

Co-Authored-By: Codex (GPT-5) <noreply@openai.com>
This commit is contained in:
tommy0103
2026-07-13 21:02:38 +08:00
co-authored by Codex
parent 78aacfac6c
commit a9687ba8b7
15 changed files with 1187 additions and 97 deletions
+56
View File
@@ -7,6 +7,7 @@ import { join } from 'node:path';
const require = createRequire(import.meta.url);
import { buildIndex } from '../app/src/main/indexer.ts';
import { CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER } from '../packages/core/src/providers/claude.ts';
const { DatabaseSync } = require('node:sqlite');
class TestDatabase {
@@ -89,6 +90,61 @@ test('app indexer records build success without claiming daemon ownership', () =
db2.close();
});
test('app indexer refreshes unchanged Claude usage when input token semantics change', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-token-semantics-'));
const claudeDir = join(home, '.claude');
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
mkdirSync(projectDir, { recursive: true });
const sessionId = 'session-token-semantics-1';
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
uuid: 'msg-token-semantics-1',
type: 'assistant',
timestamp: '2026-06-13T10:00:00Z',
cwd: '/tmp/obelisk-app',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'cached response' }],
usage: {
input_tokens: 10,
output_tokens: 5,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
}),
'',
].join('\n'));
const dbPath = join(claudeDir, 'obelisk.sqlite');
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
const stale = new TestDatabase(dbPath);
stale.prepare('UPDATE messages SET input_tokens = 10 WHERE uuid = ?').run('msg-token-semantics-1');
stale.prepare('DELETE FROM index_state WHERE jsonl_path = ?')
.run(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER);
stale.close();
buildIndex({
claudeDir,
dbPath,
DatabaseImpl: TestDatabase,
changedPaths: [`-tmp-obelisk-app/${sessionId}.jsonl`],
});
const refreshed = new TestDatabase(dbPath);
assert.equal(
refreshed.prepare('SELECT input_tokens FROM messages WHERE uuid = ?').get('msg-token-semantics-1').input_tokens,
60,
);
assert.ok(
refreshed.prepare('SELECT jsonl_path FROM index_state WHERE jsonl_path = ?')
.get(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER),
);
refreshed.close();
});
test('force rebuild ignores stale JSONL index_state rows after session tables were cleared', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-'));
const claudeDir = join(home, '.claude');
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getArgPreview, getToolIcon, renderTerminalTool } from '../app/src/renderer/src/tool-renderer.js';
test('Codex exec renders source and decoded result instead of a Bash terminal', () => {
const output = JSON.stringify([
{ type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' },
{ type: 'input_text', text: 'first line\nsecond line' },
]);
const html = renderTerminalTool('exec', 'const value = await tools.read();', output, false);
assert.match(html, /class="codeact-view is-complete"/);
assert.match(html, />Source</);
assert.match(html, /codeact-token keyword">const</);
assert.match(html, /codeact-token keyword">await</);
assert.match(html, /codeact-token global">tools</);
assert.doesNotMatch(html, />JavaScript</);
assert.match(html, />Result</);
assert.match(html, /first line\nsecond line/);
assert.doesNotMatch(html, />Completed</);
assert.doesNotMatch(html, /codeact-status-dot/);
assert.doesNotMatch(html, /0\.1 seconds/);
assert.doesNotMatch(html, /terminal-view|input_text|Script completed/);
});
test('Codex exec formats consecutive JSON result blocks independently', () => {
const output = JSON.stringify([
{ type: 'input_text', text: 'Script completed\nWall time 0.2 seconds\nOutput:\n' },
{ type: 'input_text', text: JSON.stringify({ exit_code: 0, output: 'first\nline' }) },
{ type: 'input_text', text: JSON.stringify({ exit_code: 1, output: 'second\nline' }) },
]);
const html = renderTerminalTool('exec', 'await task();', output, false);
const visibleText = html.replace(/<[^>]+>/g, '');
const resultBlockCount = html.match(/class="codeact-result-block/g)?.length || 0;
assert.equal(resultBlockCount, 2);
assert.match(visibleText, /\{\n {2}"exit_code": 0,\n {2}"output": "first\\nline"\n\}/);
assert.match(visibleText, /\{\n {2}"exit_code": 1,\n {2}"output": "second\\nline"\n\}/);
assert.match(html, /codeact-json-token key">"exit_code"</);
assert.match(html, /codeact-json-token number">0</);
assert.doesNotMatch(visibleText, /\{"exit_code":/);
});
test('Claude Bash continues to use terminal rendering', () => {
const html = renderTerminalTool('Bash', { command: 'echo hello' }, 'hello\n', false);
assert.match(html, /class="terminal-view"/);
assert.match(html, /echo hello/);
});
test('Codex exec decodes a truncated input_text envelope without inventing missing output', () => {
const truncated = '[{"type":"input_text","text":"Script completed\\nWall time 0.2 seconds\\nOutput:\\n"},{"type":"input_text","text":"line 1\\nline 2';
const html = renderTerminalTool('exec', 'text(result);', truncated, false);
assert.match(html, /line 1\nline 2/);
assert.match(html, /Indexed output truncated/);
assert.doesNotMatch(html, /\\n|input_text/);
});
test('Codex exec derives failed and running states from the captured result', () => {
const failed = JSON.stringify([
{ type: 'input_text', text: 'Script failed\nWall time 9.4 seconds\nOutput:\n' },
{ type: 'input_text', text: 'Script error:\nExit code: 1' },
]);
const running = 'Script running with cell ID 128\nWall time 10.0 seconds\nOutput:\n';
const failedHtml = renderTerminalTool('exec', 'await task();', failed, false);
const runningHtml = renderTerminalTool('exec', 'await task();', running, false);
assert.match(failedHtml, /class="codeact-view is-failed"/);
assert.match(failedHtml, />Failed</);
assert.match(runningHtml, /class="codeact-view is-running"/);
assert.match(runningHtml, />Running</);
});
test('Codex exec keeps large payloads in a bounded DOM structure', () => {
const source = 'const value = await tools.read();\n'.repeat(300);
const output = JSON.stringify([
{ type: 'input_text', text: 'Script completed\nWall time 0.3 seconds\nOutput:\n' },
{ type: 'input_text', text: 'x'.repeat(9_000) },
]);
const html = renderTerminalTool('exec', source, output, false);
const spanCount = html.match(/<span\b/g)?.length || 0;
const preCount = html.match(/<pre\b/g)?.length || 0;
assert.ok(spanCount < source.length / 4, `expected token-level markup, received ${spanCount} spans`);
assert.equal(preCount, 3);
assert.doesNotMatch(html, /code-char|ansi-char/);
});
test('Codex exec syntax highlighting escapes source content', () => {
const source = 'const value = "<script>"; // not markup';
const output = JSON.stringify([
{ type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' },
{ type: 'input_text', text: JSON.stringify({ html: '<script>result</script>' }) },
]);
const html = renderTerminalTool('exec', source, output, false);
assert.match(html, /codeact-token string">"&lt;script&gt;"</);
assert.match(html, /codeact-token comment">\/\/ not markup</);
assert.match(html, /codeact-json-token string">"&lt;script&gt;result&lt;\/script&gt;"</);
assert.doesNotMatch(html, /<script>/);
});
test('Codex exec preview uses its string input', () => {
assert.equal(getArgPreview({ input_json: JSON.stringify('echo hello') }), 'echo hello');
});
test('Codex exec uses the Claude Bash terminal icon', () => {
assert.equal(getToolIcon('exec'), getToolIcon('Bash'));
});
+7 -1
View File
@@ -16,7 +16,7 @@ function writeFixture() {
const lines = [
{ type: 'ai-title', aiTitle: 'My Session' },
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', gitBranch: 'main', message: { role: 'user', content: 'hi' } },
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'claude-opus', content: [{ type: 'text', text: 'ok' }, { type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }] } },
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'claude-opus', content: [{ type: 'text', text: 'ok' }, { type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }], usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 20, cache_read_input_tokens: 30 } } },
{ type: 'system', subtype: 'turn_duration', parentUuid: 'a1', durationMs: 1234 },
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'file body', is_error: false }] } },
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'a summary' },
@@ -42,6 +42,12 @@ test('claude parse() yields the expected record stream for a main session', () =
// Three user/assistant messages, correct order and fields.
assert.deepEqual(byKind('message').map(m => m.uuid), ['u1', 'a1', 'u2']);
assert.equal(byKind('message').find(m => m.uuid === 'a1').model, 'claude-opus');
assert.deepEqual(
(({ input_tokens, output_tokens }) => ({ input_tokens, output_tokens }))(
byKind('message').find(m => m.uuid === 'a1'),
),
{ input_tokens: 60, output_tokens: 5 },
);
assert.equal(byKind('message').every(m => m.source === 'claude'), true);
// Tool call + tool result extracted.
+4 -1
View File
@@ -22,7 +22,7 @@ function fixtureUnit() {
const lines = [
{ type: 'ai-title', aiTitle: 'Persist Session' },
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', message: { role: 'user', content: 'hi' } },
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'm', content: [{ type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }] } },
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'm', content: [{ type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }], usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 20, cache_read_input_tokens: 30 } } },
{ type: 'system', subtype: 'turn_duration', parentUuid: 'a1', durationMs: 999 },
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'body', is_error: false }] } },
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'sum' },
@@ -56,6 +56,9 @@ test('persist writes all record kinds from one claude parse', () => {
// turn_duration applied via targeted UPDATE.
assert.equal(db.prepare('SELECT turn_duration_ms FROM messages WHERE uuid=?').get('a1').turn_duration_ms, 999);
const usage = db.prepare('SELECT input_tokens, output_tokens FROM messages WHERE uuid=?').get('a1');
assert.equal(usage.input_tokens, 60);
assert.equal(usage.output_tokens, 5);
// Cursor persisted into index_state (mtime:lines → two columns).
const state = db.prepare('SELECT lines_processed FROM index_state WHERE jsonl_path=?').get(unit.key);
+188
View File
@@ -0,0 +1,188 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
captureSessionViewState,
findLastMessageAtOrAbove,
reconcileSessionMessages,
restoreSessionViewState,
} from '../app/src/renderer/src/session-view-state.mjs';
class FakeClassList {
constructor(classes = []) { this.classes = new Set(classes); }
add(...classes) { for (const value of classes) this.classes.add(value); }
contains(value) { return this.classes.has(value); }
}
function disclosure(key, classes = [], { rawOpen = false } = {}) {
const raw = { classList: new FakeClassList(rawOpen ? ['show'] : []) };
const pretty = { classList: new FakeClassList() };
const button = { classList: new FakeClassList() };
return {
dataset: { viewKey: key },
classList: new FakeClassList(classes),
querySelector(selector) {
if (selector === '.toolcall-raw') return raw;
if (selector === '.toolcall-pretty') return pretty;
if (selector === '.raw-toggle') return button;
return null;
},
raw,
pretty,
button,
};
}
function scrollItem(uuid, top, bottom) {
return {
dataset: { uuid },
getBoundingClientRect: () => ({ top, bottom }),
};
}
function detail(disclosures, scrollItems) {
return {
querySelectorAll(selector) {
if (selector === '[data-view-key]') return disclosures;
if (selector === '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]') return scrollItems;
return [];
},
};
}
function wrap({ scrollTop, scrollHeight, clientHeight, top = 0 }) {
return {
scrollTop,
scrollHeight,
clientHeight,
getBoundingClientRect: () => ({ top }),
};
}
function functionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const signatureEnd = source.indexOf(') {', start);
assert.notEqual(signatureEnd, -1, `${name} should have a function body`);
const bodyStart = signatureEnd + 2;
let depth = 0;
for (let index = bodyStart; index < source.length; index++) {
if (source[index] === '{') depth++;
if (source[index] === '}') depth--;
if (depth === 0) return source.slice(start, index + 1);
}
assert.fail(`${name} should have a complete function body`);
}
test('session refresh restores disclosure state and the visible scroll anchor', () => {
const oldTool = disclosure('tool:call-1', ['open'], { rawOpen: true });
const oldWrap = wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({
wrap: oldWrap,
detail: detail([oldTool], [scrollItem('msg-1', -20, 180)]),
});
const newTool = disclosure('tool:call-1');
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
detail: detail([newTool], [scrollItem('msg-1', 80, 280)]),
});
assert.equal(newTool.classList.contains('open'), true);
assert.equal(newTool.raw.classList.contains('show'), true);
assert.equal(newTool.pretty.classList.contains('hidden'), true);
assert.equal(newTool.button.classList.contains('active'), true);
assert.equal(newWrap.scrollTop, 600, '100 px inserted above the anchor is compensated');
});
test('session refresh follows appended content only when already at the tail', () => {
const oldWrap = wrap({ scrollTop: 1390, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({
wrap: oldWrap,
detail: detail([], [scrollItem('msg-last', 300, 590)]),
});
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2400, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
detail: detail([], [scrollItem('msg-last', 300, 590)]),
});
assert.equal(newWrap.scrollTop, 2400);
});
test('session refresh never restores an old anchor over newer user scrolling', () => {
const oldTool = disclosure('tool:call-1', ['open']);
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
detail: detail([oldTool], [scrollItem('msg-1', -20, 180)]),
});
const newTool = disclosure('tool:call-1');
const userScrolledWrap = wrap({ scrollTop: 800, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: userScrolledWrap,
detail: detail([newTool], [scrollItem('msg-1', 80, 280)]),
restoreScroll: false,
});
assert.equal(newTool.classList.contains('open'), true, 'disclosure state still restores');
assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state');
});
test('message reconciliation preserves existing identities and appends the tail', () => {
const first = { uuid: 'm1', text: 'old', tool_calls: [{ id: 't1' }] };
const current = [first];
const incoming = [
{ uuid: 'm1', text: 'updated', tool_calls: [{ id: 't1', result: { content: 'progress' } }] },
{ uuid: 'm2', text: 'new tail' },
];
const reconciled = reconcileSessionMessages(current, incoming);
assert.equal(reconciled[0], first);
assert.equal(reconciled[0].text, 'updated');
assert.equal(reconciled[0].tool_calls[0].result.content, 'progress');
assert.equal(reconciled[1], incoming[1]);
});
test('scroll progress locates the visible message without scanning the full session', () => {
let layoutReads = 0;
const messages = Array.from({ length: 2048 }, (_, index) => ({
getBoundingClientRect() {
layoutReads++;
return { bottom: (index + 1) * 20 };
},
}));
assert.equal(findLastMessageAtOrAbove(messages, 20100), 1004);
assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`);
});
test('SessionDetail integrates view-state capture and restore into live reloads', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
assert.match(source, /captureSessionViewState/);
assert.match(source, /reconcileSessionMessages/);
assert.match(source, /restoreSessionViewState/);
assert.match(source, /loading\.value\s*=\s*!hadContent/);
assert.match(source, /scrollRevision/);
assert.match(source, /restoreScroll:\s*scrollRevision\s*===\s*scrollRevisionAtLoad/);
assert.match(source, /requestAnimationFrame/);
assert.match(source, /findLastMessageAtOrAbove/);
});
test('live totals and scroll position remain isolated across interleaved updates', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const loadMessages = functionSource(source, 'loadMessages');
const syncTotalMessages = functionSource(source, 'syncTotalMessages');
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
assert.match(loadMessages, /await nextTick\(\);[\s\S]*syncTotalMessages\(\)/);
assert.match(syncTotalMessages, /totalMsgs\.value\s*=/);
assert.doesNotMatch(syncTotalMessages, /currentMsgIdx\.value\s*=/);
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
});