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
+24 -3
View File
@@ -8,7 +8,10 @@ import { persist } from './persist.ts';
import { nodeSqliteTransactionAdapter } from './tx.ts';
import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts';
import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from './write-coordinator.ts';
import { parse as claudeParse } from './providers/claude.ts';
import {
CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER,
parse as claudeParse,
} from './providers/claude.ts';
import { parse as codexParse } from './providers/codex.ts';
import type { Cursor, IndexRecord } from './providers/types.ts';
import type { ClaudeJsonlFile } from './parsing.ts';
@@ -210,7 +213,17 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
const db = openDb();
const txDb = nodeSqliteTransactionAdapter(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());
const skippedFiles: SkippedFile[] = [];
let claudeInputMigrationFailed = false;
try {
try {
if (force) {
@@ -261,9 +274,10 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
// (docs/adr/0001). needsReindex keeps the "skip unchanged file" fast path;
// the cursor's line count drives incremental resume inside parse().
const { needed, skip } = needsReindex(db, f.path);
if (needed) {
if (needed || claudeInputSemanticsOutdated) {
const unit = { key: f.path, sessionId: f.sessionId, project: f.project, isSubagent: f.isSubagent, agentId: f.agentId };
persist(db, unit, claudeParse(unit, skip > 0 ? `0:${skip}` : null));
const cursor = !claudeInputSemanticsOutdated && skip > 0 ? `0:${skip}` : null;
persist(db, unit, claudeParse(unit, cursor));
}
indexSubagentMeta(db, f);
}
@@ -273,6 +287,9 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles };
}
if (hasUnusableTransaction(e)) throw e;
if (claudeInputSemanticsOutdated && f.source !== 'codex') {
claudeInputMigrationFailed = true;
}
// A per-file failure is skippable: log and move on.
const error = e as { message?: unknown; obelisk?: unknown } | null;
const message = errorMessage(e);
@@ -291,6 +308,10 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
rebuildMemoryFts(db);
db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now());
if (!claudeInputSemanticsOutdated || !claudeInputMigrationFailed) {
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)')
.run(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER, Date.now());
}
}, { label: 'finalize' });
} catch (error) {
if (isBeginBusyFailure(error)) {
+19 -1
View File
@@ -27,6 +27,24 @@ function cursorToSkip(cursor: Cursor): number {
}
export const name = 'claude';
export const CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER = '__claude_input_tokens_include_cache_v1__';
function totalInputTokens(usage: Record<string, unknown>): number | null {
const fields = [
'input_tokens',
'cache_creation_input_tokens',
'cache_read_input_tokens',
];
let seen = false;
let total = 0;
for (const field of fields) {
const value = usage[field];
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
seen = true;
total += value;
}
return seen ? total : null;
}
export function discover(_ctx: DiscoverContext): IndexUnit[] {
return discoverJsonlFiles().map((f: any) => ({
@@ -92,7 +110,7 @@ export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord,
parent_uuid: obj.parentUuid || null, timestamp: ts, role: msg.role || obj.type,
text, content_type: contentType, is_meta: (isMeta ? 1 : 0), model: msg.model || null,
is_sidechain: obj.isSidechain ? 1 : 0, agent_id: aid,
input_tokens: usage.input_tokens || null, output_tokens: usage.output_tokens || null,
input_tokens: totalInputTokens(usage), output_tokens: usage.output_tokens || null,
cwd: obj.cwd || null, skill: obj.attributionSkill || null, source: 'claude',
});
}
+1
View File
@@ -77,6 +77,7 @@ export interface MessageRecord {
model: string | null;
is_sidechain: 0 | 1;
agent_id: string | null;
/** Provider-normalized total input, including provider-reported cached input. */
input_tokens: number | null;
output_tokens: number | null;
cwd: string | null;