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
+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.