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:
@@ -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');
|
||||
|
||||
@@ -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">"<script>"</);
|
||||
assert.match(html, /codeact-token comment">\/\/ not markup</);
|
||||
assert.match(html, /codeact-json-token string">"<script>result<\/script>"</);
|
||||
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'));
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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*=/);
|
||||
});
|
||||
Reference in New Issue
Block a user