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');