diff --git a/CHANGELOG.md b/CHANGELOG.md index a164be54..63425ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Watch mode (`claude-code-log watch`)** — re-converts transcripts as they are + written, so Markdown open in an editor or an Obsidian vault stays current. + Defaults to the current directory's project. +- **Live page updates (`claude-code-log serve --watch`)** — an open session page + grows as messages arrive, in place: scroll position, folded sections and open + disclosures survive, new messages fade in, and a follow pill pins the page to + the newest message. Only over the server; `file://` pages are unchanged. + +### Changed + +- Generated output is now written atomically (temp file + rename), so a reader + can never observe a truncated page or document. +- The cache records each source file's size as well as its mtime (migration + 011). The 1-second mtime tolerance could hide a write landing inside it, + which stranded the last message of a turn until something else touched the + file. +- The session-scoped render path is now reachable after an append-only cache + refresh, which is what makes a watch tick cheap. + ## [1.5.0] - 2026-07-09 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ab90eac..15c26cb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -333,6 +333,31 @@ full-load refresh. Set `CLAUDE_CODE_LOG_INCREMENTAL_CACHE=0` to force the full refresh when bisecting. See [dev-docs/application_model.md § 2.14](dev-docs/application_model.md). +That refresh parses each modified file from source, and the loads that +follow it — the closure load and the session-scoped render — used to +rebuild those same entries from the rows it had just written, twice. +A per-conversion parsed-entry store +(`claude_code_log/entry_store.py`) serves the refresh's list to both +instead, taking a watch tick on an 803MB archive from 1.03s to 0.72s. +Only the incremental refresh fills it, and only with the files that +changed, so a cold conversion and the streaming path (whose bounded +residency depends on dropping each page) carry no extra memory. + +Held *across* ticks — which `watch` does, owning one for the life of the +loop — the same store also resumes. It pins its entries to a byte offset +plus a hash of the bytes below it, so a tick hashes that prefix (32ms +over 39.7MB, against 143ms to re-parse it), reads only what was +appended, and appends just the new cache rows rather than rewriting the +file's. That takes the same tick to **0.26s**. It applies only where the +rows are provably the file's own lines: a trunk's rows carry its +subagents' spliced transcripts, so a running subagent grows a block +mid-sequence and those files take the unchanged full rewrite. + +Set `CLAUDE_CODE_LOG_ENTRY_STORE=0` to disable all of it when bisecting; +a per-file memory valve declines to hold a file when available memory is +under ~6× its bytes, and an explicit `=1` overrides that valve. See +[dev-docs/application_model.md § 2.16](dev-docs/application_model.md). + To re-measure on your own hardware (core count changes the answer for the fan-out), point the benchmark at a real project: diff --git a/README.md b/README.md index 1352e463..bf5fc28a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ uvx claude-code-log@latest --open-browser ## Key Features - **Interactive TUI (Terminal User Interface)**: Browse and manage Claude Code sessions with real-time navigation, summaries, and quick actions for HTML export and session resuming +- **Watch Mode**: `claude-code-log watch` re-converts as a session is written, so Markdown in an editor or Obsidian stays current; `claude-code-log serve --watch` makes an open session page grow as messages arrive, keeping your scroll position and folded sections - **Project Hierarchy Processing**: Process entire `~/.claude/projects/` directory with linked index page - **Individual Session Files**: Generate separate HTML files for each session with navigation links - **Single File or Directory Processing**: Convert individual JSONL files or specific directories diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index 82f6c649..b9788f62 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """SQLite-based cache management for Claude Code Log.""" +import functools import hashlib import json import logging @@ -239,8 +240,40 @@ def scrub_surrogates(s: Optional[str]) -> Optional[str]: return s.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") +# Compression level for a cached entry's content blob. zlib's default +# (6) spends most of its time for the last few percent of size, and +# rewriting a file's rows is the largest item in a watch tick. +# +# The size cost is much smaller than a single-file measurement suggests. +# Over one atypical 207-entry, 40 MB session (~190 KB per entry) level 6 +# takes 183 ms to reach 2.92 MB against level 3's 81 ms to reach 3.44 MB +# — 18% more bytes. But zlib levels only diverge on large payloads, and a +# real archive is mostly small entries: across a 49 MB archive's 18,288 +# rows the blobs grow 26.37 MB -> 27.05 MB, i.e. **2.6%**, for a cold +# conversion 11% faster (6.15 s -> 5.49 s) and a tick ~20% faster. +# +# Decompression is unaffected (~50 ms either way) and level-agnostic, so +# this is backward compatible: rows written at any level still read. The +# only visible transition is that re-serialising an existing entry +# changes its blob, hence its row fingerprint, so the first incremental +# refresh over a level-6 cache declines to a full refresh once and is +# consistent thereafter. +CONTENT_COMPRESSION_LEVEL = 3 + + +@functools.lru_cache(maxsize=1) def get_library_version() -> str: - """Get the current library version from package metadata or pyproject.toml.""" + """Get the current library version from package metadata or pyproject.toml. + + Memoised because it is called *per rendered file* on the staleness + path (``is_transcript_stale`` → ``is_html_outdated``), and each call + re-parses the installed package metadata through + ``importlib.metadata``: 173 calls and 35 ms of a 1.1 s watch tick on + a 217-session project, scaling with the session count rather than + with anything that changed. An installed version cannot change inside + a process, so a one-slot cache is exact; tests that need a different + value patch this name on the module, which is unaffected. + """ # First try to get version from installed package metadata try: from importlib.metadata import version as get_version @@ -329,7 +362,10 @@ def subagents_fingerprint(jsonl_path: Path) -> str: def _cache_row_is_fresh( - row: sqlite3.Row, source_mtime: float, current_fp: Callable[[], str] + row: sqlite3.Row, + source_mtime: float, + current_fp: Callable[[], str], + source_size: Optional[int] = None, ) -> bool: """Decide whether a cached_files row is still fresh. @@ -339,13 +375,28 @@ def _cache_row_is_fresh( check passes (and get_modified_files() can plug in its scandir-optimized variant). - Cache is valid if modification times match (within 1 second - tolerance) and the sidecar inputs of spawn discovery (#213) match - too — new agent-*.meta.json files appear without touching the - source jsonl. Pre-007 rows carry NULL: accept those only when the - file has no sidecars today (nothing to miss), so legacy caches - don't mass-invalidate while sessions WITH sidecars reparse once. + Cache is valid if the size matches, modification times match + (within 1 second tolerance), and the sidecar inputs of spawn + discovery (#213) match too — new agent-*.meta.json files appear + without touching the source jsonl. Pre-007 rows carry a NULL + fingerprint: accept those only when the file has no sidecars today + (nothing to miss), so legacy caches don't mass-invalidate while + sessions WITH sidecars reparse once. + + The size check exists because the mtime tolerance — which is there + for coarse filesystem timestamp granularity — hides any write that + lands within a second of the mtime recorded at cache time. Appending + to a transcript and converting immediately alternates between seeing + and missing the change; the last message of a turn can stay stranded + indefinitely. Size is exact and free (callers already stat the + file), and the combined rule is strictly tightening: it marks more + files stale, never fewer. Pre-011 rows carry NULL and fall back to + the mtime-only check so a populated cache doesn't mass-invalidate. """ + if source_size is not None: + cached_size = row["source_size"] + if cached_size is not None and cached_size != source_size: + return False if abs(source_mtime - row["source_mtime"]) >= 1.0: return False cached_fp = row["subagents_fingerprint"] @@ -371,6 +422,66 @@ def get_cache_db_path(projects_dir: Path) -> Path: return projects_dir / "claude-code-log-cache.db" +# SQLite error text meaning "this file is damaged past the point of being +# read at all". Matched on the message because Python funnels every one of +# them into the same `sqlite3.DatabaseError` — there is no distinct class +# to catch. Deliberately narrow: "database is locked" and "database or disk +# is full" are also DatabaseErrors, and neither is a reason to delete a +# perfectly good cache. +_CORRUPTION_MARKERS = ( + # SQLITE_CORRUPT. Observed shape: a cache truncated to 2833 pages while + # its header still claimed 14189, which fails every read including + # `PRAGMA page_count`. That one came from the virtiofs mount the file + # lived on, so it is the filesystem's kind of damage, not the writer's — + # expect it to recur wherever the cache sits on a shared/virtualised + # mount. + "database disk image is malformed", + # SQLITE_NOTADB — a garbage or encrypted header. + "file is not a database", + # A virtual-table declaration in sqlite_master that no longer parses, + # e.g. an FTS5 index carrying an option this SQLite build lacks. + "malformed database schema", +) + + +def is_corrupt_database_error(exc: BaseException) -> bool: + """Whether ``exc`` says the database file itself is unusable. + + Note a zero-byte file is *not* corruption — SQLite happily adopts one as + a new database — so an interrupted create heals on its own. + """ + if not isinstance(exc, sqlite3.DatabaseError): + return False + message = str(exc).lower() + return any(marker in message for marker in _CORRUPTION_MARKERS) + + +def discard_database_files(db_path: Path) -> bool: + """Delete a cache database and its WAL sidecars. True if all are gone. + + The sidecars have to go with it: a ``-wal`` left beside a recreated + database is a mismatched log, which is its own route back into + "malformed". So a surviving sidecar is a failure just as much as a + surviving ``.db`` — the caller should run cacheless rather than + recreate a database next to a stale log. + """ + paths = ( + db_path, + db_path.with_name(db_path.name + "-wal"), + db_path.with_name(db_path.name + "-shm"), + ) + for path in paths: + try: + path.unlink() + except FileNotFoundError: + pass + except OSError as e: + # Windows refuses to unlink a file another process still holds + # open (WinError 32). Report and let the caller decide. + print(f" Warning: could not delete {path.name}: {e}") + return not any(path.exists() for path in paths) + + # ========== Cache Manager ========== @@ -436,8 +547,13 @@ def __init__( self._lookup_project_id() else: # Initialise database and ensure project exists - self._init_database() - self._ensure_project_exists() + try: + self._init_database() + self._ensure_project_exists() + except sqlite3.DatabaseError as exc: + if not is_corrupt_database_error(exc): + raise + self._rebuild_corrupt_database(exc) def _configure_connection(self, conn: sqlite3.Connection) -> None: """Apply the standard pragmas/row factory to a fresh connection.""" @@ -543,6 +659,40 @@ def _init_database(self) -> None: run_migrations(self.db_path) _migrated_db_paths.add(key) + def _rebuild_corrupt_database(self, exc: sqlite3.DatabaseError) -> None: + """Delete an unreadable cache database and build a fresh one. + + The cache is derived data, regenerable in full from the JSONL + source, so discarding it costs only the next run's rebuild time — + whereas keeping it costs every run, forever. Corruption is sticky: + `apply_migration` only records a migration that completed, so a + `CREATE INDEX` that hits a damaged page is re-attempted and re-fails + on every subsequent invocation. + + Nothing is salvaged first. In the case this was written for, the + file was truncated to a fifth of the size its own header claimed, + and *no* statement against it succeeded — not even `PRAGMA + page_count`. A partial-salvage path would be untested code guarding + against a case we have not seen. + + Only reached from the writing constructor. A ``read_only`` + instance — every spawned render worker — must never delete the + database out from under its siblings, and doesn't need to: + `_lookup_project_id` already degrades to "no cached data". + """ + print(f"Cache database is corrupt ({exc}): {self.db_path}") + if not discard_database_files(self.db_path): + # Couldn't remove it, so a retry would just fail the same way. + # Re-raise and let the caller degrade to running cacheless. + print(" Could not delete it; continuing without a cache.") + raise exc + # The memo records "migrations already checked for this path", which + # the file we just deleted is no longer evidence of. + _migrated_db_paths.discard(str(self.db_path)) + print(" Deleted it and rebuilding from scratch (this run will be slower).") + self._init_database() + self._ensure_project_exists() + def _lookup_project_id(self) -> None: """Read-only counterpart of ``_ensure_project_exists``: find, never create. @@ -688,7 +838,8 @@ def _serialize_entry(self, entry: TranscriptEntry, file_id: int) -> Dict[str, An "_level": None, "_operation": None, "content": zlib.compress( - json.dumps(entry.model_dump(), separators=(",", ":")).encode("utf-8") + json.dumps(entry.model_dump(), separators=(",", ":")).encode("utf-8"), + CONTENT_COMPRESSION_LEVEL, ), } @@ -749,7 +900,8 @@ def is_file_cached(self, jsonl_path: Path) -> bool: with self._get_connection() as conn: row = conn.execute( - "SELECT source_mtime, subagents_fingerprint FROM cached_files" + "SELECT source_mtime, source_size, subagents_fingerprint" + " FROM cached_files" " WHERE project_id = ? AND file_name = ?", (self._project_id, jsonl_path.name), ).fetchone() @@ -757,10 +909,12 @@ def is_file_cached(self, jsonl_path: Path) -> bool: if not row: return False + source_stat = jsonl_path.stat() return _cache_row_is_fresh( row, - jsonl_path.stat().st_mtime, + source_stat.st_mtime, lambda: subagents_fingerprint(jsonl_path), + source_stat.st_size, ) def load_cached_entries(self, jsonl_path: Path) -> Optional[List[TranscriptEntry]]: @@ -847,6 +1001,7 @@ def save_cached_entries( jsonl_path: Path, entries: List[TranscriptEntry], subagents_fp: Optional[str] = None, + source_stat: Optional[os.stat_result] = None, ) -> None: """Save parsed transcript entries to cache. @@ -856,11 +1011,21 @@ def save_cached_entries( next read and forces a reparse (over-invalidation; computing it here at save time would instead validate a parse that never saw the late sidecar). Falls back to computing now when omitted. + + ``source_stat`` is the file's identity as of the parse, and is + the same argument one level down: a session that grew *while* + being read would otherwise be stamped with the size and mtime it + reached, marking a cache that is missing those lines as current — + and if the file then never changes again, permanently so. Stamped + with what was actually parsed, the growth invalidates instead. """ if self._project_id is None: return - source_mtime = jsonl_path.stat().st_mtime + if source_stat is None: + source_stat = jsonl_path.stat() + source_mtime = source_stat.st_mtime + source_size = source_stat.st_size cached_mtime = datetime.now().timestamp() if subagents_fp is None: subagents_fp = subagents_fingerprint(jsonl_path) @@ -871,12 +1036,13 @@ def save_cached_entries( conn.execute( """ INSERT INTO cached_files - (project_id, file_name, file_path, source_mtime, cached_mtime, - message_count, subagents_fingerprint) - VALUES (?, ?, ?, ?, ?, ?, ?) + (project_id, file_name, file_path, source_mtime, source_size, + cached_mtime, message_count, subagents_fingerprint) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, file_name) DO UPDATE SET file_path = excluded.file_path, source_mtime = excluded.source_mtime, + source_size = excluded.source_size, cached_mtime = excluded.cached_mtime, message_count = excluded.message_count, subagents_fingerprint = excluded.subagents_fingerprint @@ -886,6 +1052,7 @@ def save_cached_entries( jsonl_path.name, str(jsonl_path), source_mtime, + source_size, cached_mtime, len(entries), subagents_fp, @@ -940,6 +1107,147 @@ def save_cached_entries( self._update_last_updated(conn) conn.commit() + def extend_cached_entries( + self, + jsonl_path: Path, + all_entries: List[TranscriptEntry], + appended: List[TranscriptEntry], + subagents_fp: Optional[str] = None, + source_stat: Optional[os.stat_result] = None, + ) -> bool: + """Insert only ``appended``, leaving the file's existing rows in place. + + ``save_cached_entries`` deletes every row for a file and rewrites + it, which costs a ``json.dumps`` + ``zlib.compress`` per entry — + 310 ms to add one line to a 39.7 MB session, and the largest item + in a watch tick (work/watch-mode.md). When the caller can show the + new entries are exactly the old ones plus a tail, only the tail + needs writing. + + The caller owns that proof (a byte-verified file prefix, plus no + agent splicing — see ``load_transcript``); this method owns the + one part the caller cannot see, which is whether the *rows* + still match what the caller thinks it wrote. Another process may + have rewritten them since. Returns False whenever the row count + disagrees, and the caller falls back to the full rewrite; that + check is why the two stores cannot silently drift apart. + + ``source_stat`` is the file's identity AS OF THE PARSE, for the + same reason as ``subagents_fp`` — see ``save_cached_entries``. + """ + if self._project_id is None or not appended: + return False + expected_existing = len(all_entries) - len(appended) + if expected_existing <= 0: + return False + + if source_stat is None: + source_stat = jsonl_path.stat() + if subagents_fp is None: + subagents_fp = subagents_fingerprint(jsonl_path) + + with self._get_connection() as conn: + # The count and the insert have to be one transaction. Python's + # sqlite3 opens one on the first *write*, not on a SELECT, so + # without this another writer sharing the cache (a second + # `watch`, or a TUI beside one) can append in the window + # between them, and both appends land — the row check would + # have refused had it seen them. `BEGIN IMMEDIATE` takes the + # write lock up front; inside a `batch()` scope the connection + # is in a transaction already and that one covers it. + own_transaction = not conn.in_transaction + if own_transaction: + conn.execute("BEGIN IMMEDIATE") + try: + return self._append_under_lock( + conn, + jsonl_path, + all_entries, + appended, + expected_existing, + source_stat, + subagents_fp, + ) + finally: + # A refusal wrote nothing, but it still holds the write + # lock this method took; an exception may have written. + # Either way, only ever unwind our own transaction — a + # rollback inside a `batch()` would discard the caller's. + if own_transaction and conn.in_transaction: + conn.rollback() + + def _append_under_lock( + self, + conn: sqlite3.Connection, + jsonl_path: Path, + all_entries: List[TranscriptEntry], + appended: List[TranscriptEntry], + expected_existing: int, + source_stat: os.stat_result, + subagents_fp: Optional[str], + ) -> bool: + """The checked append itself, run under the caller's write lock.""" + row = conn.execute( + "SELECT id FROM cached_files WHERE project_id = ? AND file_name = ?", + (self._project_id, jsonl_path.name), + ).fetchone() + if row is None: + return False + file_id = row["id"] + + count = conn.execute( + "SELECT COUNT(*) AS n FROM messages WHERE file_id = ?", (file_id,) + ).fetchone()["n"] + if count != expected_existing: + return False + + conn.execute( + """UPDATE cached_files + SET file_path = ?, source_mtime = ?, source_size = ?, + cached_mtime = ?, message_count = ?, subagents_fingerprint = ? + WHERE id = ?""", + ( + str(jsonl_path), + source_stat.st_mtime, + source_stat.st_size, + datetime.now().timestamp(), + len(all_entries), + subagents_fp, + file_id, + ), + ) + conn.executemany( + """ + INSERT INTO messages ( + project_id, file_id, type, timestamp, session_id, + _uuid, _parent_uuid, _is_sidechain, _user_type, _cwd, _version, + _is_meta, _agent_id, _request_id, + input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + _leaf_uuid, _level, _operation, content + ) VALUES ( + :project_id, :file_id, :type, :timestamp, :session_id, + :_uuid, :_parent_uuid, :_is_sidechain, :_user_type, :_cwd, :_version, + :_is_meta, :_agent_id, :_request_id, + :input_tokens, :output_tokens, :cache_creation_tokens, :cache_read_tokens, + :_leaf_uuid, :_level, :_operation, :content + ) + """, + [self._serialize_entry(entry, file_id) for entry in appended], + ) + + # The index still refreshes the whole file: `reindex_files` + # deletes and re-adds its rows, and an append-only variant + # would need its own correctness argument. Only users who have + # built an index pay it, exactly as before. + from .search import auto_index_enabled, reindex_files + + if auto_index_enabled(): + reindex_files(conn, [file_id], commit=False) + + self._update_last_updated(conn) + conn.commit() + return True + def update_session_cache(self, session_data: Dict[str, SessionCacheData]) -> None: """Update cached session information.""" if self._project_id is None: @@ -1203,23 +1511,47 @@ def get_file_states(self, file_names: List[str]) -> Dict[str, CachedFileState]: Files without cached rows simply have no key in the result — that is the "new file" signature. + + Resolves names to ``file_id`` first and filters the messages + table on that, rather than joining ``cached_files`` and filtering + on ``file_name``. The join form gives SQLite no indexed way in: + it falls back to ``idx_messages_project_timestamp`` and walks + every row in the project, which on a real archive is ~16 ms a + call to read a handful of files. Filtering on ``file_id`` uses + ``idx_messages_file`` and the same call measures 0.0 ms. """ result: Dict[str, CachedFileState] = {} if self._project_id is None or not file_names: return result with self._get_connection() as conn: + names_by_id: Dict[int, str] = {} for i in range(0, len(file_names), self._IN_CHUNK): chunk = file_names[i : i + self._IN_CHUNK] placeholders = ",".join("?" * len(chunk)) for row in conn.execute( - f"""SELECT cf.file_name, m.session_id, m._uuid, m._parent_uuid, - m._request_id, m.type, m._leaf_uuid, m.content - FROM messages m JOIN cached_files cf ON m.file_id = cf.id - WHERE m.project_id = ? AND cf.file_name IN ({placeholders}) - ORDER BY cf.file_name, m.id""", + f"""SELECT id, file_name FROM cached_files + WHERE project_id = ? AND file_name IN ({placeholders})""", (self._project_id, *chunk), ): - state = result.setdefault(row["file_name"], CachedFileState()) + names_by_id[row["id"]] = row["file_name"] + + file_ids = sorted(names_by_id) + for i in range(0, len(file_ids), self._IN_CHUNK): + chunk_ids = file_ids[i : i + self._IN_CHUNK] + placeholders = ",".join("?" * len(chunk_ids)) + # Ordering within a file is what matters — row_fingerprints + # is positional — and `file_id, id` gives it. + for row in conn.execute( + f"""SELECT file_id, session_id, _uuid, _parent_uuid, + _request_id, type, _leaf_uuid, content + FROM messages + WHERE file_id IN ({placeholders}) + ORDER BY file_id, id""", + chunk_ids, + ): + state = result.setdefault( + names_by_id[row["file_id"]], CachedFileState() + ) state.row_fingerprints.append( hashlib.sha256(bytes(row["content"])).digest() ) @@ -1242,14 +1574,13 @@ def get_file_states(self, file_names: List[str]) -> Dict[str, CachedFileState]: state.type_counts[row_type] = ( state.type_counts.get(row_type, 0) + 1 ) - # A cached file whose rows are all filtered (e.g. empty - # file) still needs a key, or it would read as "new". - for row in conn.execute( - f"""SELECT file_name FROM cached_files - WHERE project_id = ? AND file_name IN ({placeholders})""", - (self._project_id, *chunk), - ): - result.setdefault(row["file_name"], CachedFileState()) + + # A cached file whose rows are all filtered (e.g. an empty + # file) still needs a key, or it would read as "new". The + # name resolution above already enumerated exactly those + # files, so no second query is needed. + for name in names_by_id.values(): + result.setdefault(name, CachedFileState()) return result def get_parent_uuid_dependents(self, uuids: List[str]) -> Dict[str, set[str]]: @@ -1266,13 +1597,20 @@ def get_parent_uuid_dependents(self, uuids: List[str]) -> Dict[str, set[str]]: for i in range(0, len(uuids), self._IN_CHUNK): chunk = uuids[i : i + self._IN_CHUNK] placeholders = ",".join("?" * len(chunk)) + # `session_id IS NOT NULL` is filtered in Python, not SQL: + # as a SQL predicate it lets the planner satisfy the query + # with the (project_id, session_id, …) index as a range + # scan over every session-bearing row, instead of seeking + # the handful of uuids asked for. See `get_uuid_owners`. for row in conn.execute( f"""SELECT _parent_uuid, session_id FROM messages - WHERE project_id = ? AND _parent_uuid IN ({placeholders}) - AND session_id IS NOT NULL""", + WHERE project_id = ? AND _parent_uuid IN ({placeholders})""", (self._project_id, *chunk), ): - result.setdefault(row["_parent_uuid"], set()).add(row["session_id"]) + if row["session_id"] is not None: + result.setdefault(row["_parent_uuid"], set()).add( + row["session_id"] + ) return result def get_uuid_owners(self, uuids: List[str]) -> Dict[str, set[Tuple[str, str]]]: @@ -1289,15 +1627,23 @@ def get_uuid_owners(self, uuids: List[str]) -> Dict[str, set[Tuple[str, str]]]: for i in range(0, len(uuids), self._IN_CHUNK): chunk = uuids[i : i + self._IN_CHUNK] placeholders = ",".join("?" * len(chunk)) + # The `session_id IS NOT NULL` filter is applied in Python + # rather than SQL, and the difference is not cosmetic: as a + # SQL predicate SQLite can satisfy it from + # `idx_messages_project_session_ts` as a range scan — + # walking every session-bearing row in the project — and + # prefers that to seeking the uuids actually asked for. + # Measured on a 38,706-row archive, 500 uuids: **17.2 ms + # with the predicate in SQL, 0.9 ms without**. for row in conn.execute( f"""SELECT _uuid, session_id, type FROM messages - WHERE project_id = ? AND _uuid IN ({placeholders}) - AND session_id IS NOT NULL""", + WHERE project_id = ? AND _uuid IN ({placeholders})""", (self._project_id, *chunk), ): - result.setdefault(row["_uuid"], set()).add( - (row["session_id"], row["type"]) - ) + if row["session_id"] is not None: + result.setdefault(row["_uuid"], set()).add( + (row["session_id"], row["type"]) + ) return result def get_request_id_entries( @@ -1318,15 +1664,16 @@ def get_request_id_entries( for i in range(0, len(rids), self._IN_CHUNK): chunk = rids[i : i + self._IN_CHUNK] placeholders = ",".join("?" * len(chunk)) + # Filtered in Python for the reason `get_uuid_owners` gives. for row in conn.execute( f"""SELECT _request_id, _uuid, session_id FROM messages - WHERE project_id = ? AND _request_id IN ({placeholders}) - AND session_id IS NOT NULL""", + WHERE project_id = ? AND _request_id IN ({placeholders})""", (self._project_id, *chunk), ): - result.setdefault(row["_request_id"], set()).add( - (row["_uuid"] or "", row["session_id"]) - ) + if row["session_id"] is not None: + result.setdefault(row["_request_id"], set()).add( + (row["_uuid"] or "", row["session_id"]) + ) return result def get_session_request_ids(self, session_ids: List[str]) -> set[str]: @@ -1507,7 +1854,8 @@ def get_modified_files(self, jsonl_files: List[Path]) -> List[Path]: with self._get_connection() as conn: rows = conn.execute( - "SELECT file_name, source_mtime, subagents_fingerprint" + "SELECT file_name, source_mtime, source_size," + " subagents_fingerprint" " FROM cached_files WHERE project_id = ?", (self._project_id,), ).fetchall() @@ -1542,7 +1890,8 @@ def current_fp(jsonl_file: Path) -> str: continue try: - source_mtime = jsonl_file.stat().st_mtime + # One stat serves both checks — st_size rides along free. + source_stat = jsonl_file.stat() except OSError: # Missing file: same outcome as is_file_cached()'s # exists() check returning False. @@ -1550,7 +1899,10 @@ def current_fp(jsonl_file: Path) -> str: continue if not _cache_row_is_fresh( - row, source_mtime, lambda file=jsonl_file: current_fp(file) + row, + source_stat.st_mtime, + lambda file=jsonl_file: current_fp(file), + source_stat.st_size, ): modified.append(jsonl_file) @@ -1873,15 +2225,33 @@ def get_stale_sessions( if self._project_id is None: return [] + from .renderer import is_html_outdated + stale_sessions: List[tuple[str, str]] = [] + base_dir = output_dir or self.project_path with self._get_connection() as conn: - # Get all sessions + # Both tables are read whole, once, rather than per session. + # `is_transcript_stale` issues two queries per call, so a + # project with many sessions spent most of this function in + # SQLite round-trips for rows it was going to read anyway + # (work/watch-mode.md, C15). The per-session logic below is a + # faithful inline of that method — same order of checks, same + # reason strings — minus the queries. session_rows = conn.execute( - """SELECT session_id, last_timestamp FROM sessions + """SELECT session_id, message_count FROM sessions WHERE project_id = ? AND hidden = 0""", (self._project_id,), ).fetchall() + html_rows = conn.execute( + """SELECT html_path, message_count, library_version + FROM html_cache WHERE project_id = ?""", + (self._project_id,), + ).fetchall() + html_cache = { + r["html_path"]: (r["message_count"] or 0, r["library_version"]) + for r in html_rows + } for row in session_rows: session_id = row["session_id"] @@ -1894,12 +2264,26 @@ def get_stale_sessions( continue html_path = f"session-{session_id}{variant}.{ext}" - - is_stale, reason = self.is_transcript_stale( - html_path, session_id, output_dir=output_dir - ) - if is_stale: - stale_sessions.append((session_id, reason)) + cached = html_cache.get(html_path) + if cached is None: + stale_sessions.append((session_id, "not_cached")) + continue + cached_count, cached_version = cached + if cached_version != self.library_version: + stale_sessions.append((session_id, "version_mismatch")) + continue + actual_file = base_dir / html_path + if not actual_file.exists(): + stale_sessions.append((session_id, "file_missing")) + continue + if is_html_outdated(actual_file): + stale_sessions.append((session_id, "file_version_mismatch")) + continue + # `session_not_found` cannot arise here — the candidate + # list *is* the sessions table — so only the count check + # remains of `is_transcript_stale`'s session branch. + if row["message_count"] != cached_count: + stale_sessions.append((session_id, "session_updated")) return stale_sessions diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index ad7a04e4..ba654a57 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -7,12 +7,20 @@ import signal import sqlite3 import sys +import threading +import time +import traceback from pathlib import Path from typing import Any, Callable, Optional import click from git import Repo, InvalidGitRepositoryError +from .watch import ( + DEFAULT_MAX_LATENCY, + DEFAULT_POLL_INTERVAL, + DEFAULT_QUIET_PERIOD, +) from .converter import ( RegenerationReport, convert_jsonl_to, @@ -31,6 +39,7 @@ get_all_cached_projects, get_cache_db_path, get_library_version, + is_corrupt_database_error, ) from .models import RenderingDepth from .render_pool import resolve_render_jobs @@ -2104,6 +2113,16 @@ def render_provider(destination: Path) -> Path: "page will report the index as unavailable." ), ) +@click.option( + "--watch", + "watch_sources", + is_flag=True, + default=False, + help=( + "Keep the served pages current: re-convert whenever a transcript " + "changes, in the background. Reload a page to see new messages." + ), +) def serve( port: int, projects_dir: Optional[Path], @@ -2113,6 +2132,7 @@ def serve( index_fields: Optional[str], reindex: bool, no_index: bool, + watch_sources: bool, ) -> None: """Serve the projects directory over loopback, with full-archive search. @@ -2168,11 +2188,38 @@ def serve( if open_browser: click.launch(f"{server.url}/index.html") + watch_stop: Optional[threading.Event] = None + watch_thread: Optional[threading.Thread] = None + if watch_sources: + from .watch import WatchEngine + + def reconvert(_changed: set[Path]) -> None: + # The server never renders. It re-runs the ordinary conversion + # and lets the pages on disk stay canonical, so a page served + # over http and the same file opened from file:// can never + # disagree. + process_projects_hierarchy(projects_path, silent=True) + + def report(exc: BaseException) -> None: + click.echo(f" watch: conversion failed: {exc!r}", err=True) + + engine = WatchEngine([projects_path], reconvert, on_error=report) + # Prime before starting the thread so the baseline is taken at a + # known moment rather than whenever the thread gets scheduled. + engine.prime() + watch_stop = threading.Event() + watch_thread = engine.run_in_thread(watch_stop) + click.echo(" watching for changes (reload a page to see new messages)") + try: server.serve_forever() except KeyboardInterrupt: click.echo("\nStopping...") finally: + if watch_stop is not None: + watch_stop.set() + if watch_thread is not None: + watch_thread.join(timeout=5) server.stop() @@ -2215,9 +2262,27 @@ def report(done: int, total: int) -> None: bar.__enter__() bar.update(1) - status = ensure_index( - conn, index_fields=index_fields, progress=report, rebuild=rebuild - ) + try: + status = ensure_index( + conn, index_fields=index_fields, progress=report, rebuild=rebuild + ) + except sqlite3.DatabaseError as e: + if not is_corrupt_database_error(e): + raise + # The ordinary conversion heals a corrupt cache when it builds a + # CacheManager, so the only way to arrive here is --no-convert, + # which skipped it. Don't delete the database behind the user's + # back on the one flag that asked us to touch nothing; serving + # the pages without search beats refusing to start. + if bar is not None: + bar.__exit__(None, None, None) + click.echo(f"Cache database is corrupt ({e}): {db_path}", err=True) + click.echo( + " Search is unavailable. Re-run without --no-convert to " + "rebuild the cache.", + err=True, + ) + return if bar is not None: bar.__exit__(None, None, None) click.echo( @@ -2228,6 +2293,234 @@ def report(done: int, total: int) -> None: conn.close() +@main.command(name="watch") +@click.argument( + "input_path", + type=click.Path(exists=True, path_type=Path, file_okay=False), + required=False, +) +@click.option( + "-o", + "--output", + type=click.Path(path_type=Path), + help=( + "Output destination, as for `convert`. Pair with --format md to " + "keep an Obsidian vault current." + ), +) +@click.option( + "-f", + "--format", + "output_format", + type=click.Choice(["html", "md", "markdown"]), + default="html", + show_default=True, + help="Output format.", +) +@click.option( + "--combined", + type=click.Choice(["yes", "no", "only"]), + default="no", + show_default=True, + help=( + "As for `convert`, but defaulting to 'no'. Per-session files are " + "what a watch is for, and skipping the combined page is what lets " + "a tick regenerate just the changed session instead of reloading " + "the whole project." + ), +) +@click.option( + "--projects-dir", + type=click.Path(exists=True, path_type=Path, file_okay=False), + help="Projects directory (default: ~/.claude/projects/).", +) +@click.option( + "--all-projects", + is_flag=True, + default=False, + help=( + "Watch every project instead of one. Off by default: a tick over a " + "large archive is far more expensive than over a single project." + ), +) +@click.option( + "--interval", + type=float, + default=DEFAULT_POLL_INTERVAL, + show_default=True, + help="Seconds between filesystem polls.", +) +@click.option( + "--quiet-period", + type=float, + default=DEFAULT_QUIET_PERIOD, + show_default=True, + help=( + "Seconds of no further change before converting. Claude Code writes " + "several entries per turn; without this every one would trigger its " + "own render." + ), +) +@click.option( + "--max-latency", + type=float, + default=DEFAULT_MAX_LATENCY, + show_default=True, + help=( + "Convert anyway after this long, so an unbroken stream of appends " + "still surfaces instead of starving behind the quiet period." + ), +) +@click.option("--debug", is_flag=True, default=False, help="Show full tracebacks.") +def watch( + input_path: Optional[Path], + output: Optional[Path], + output_format: str, + combined: str, + projects_dir: Optional[Path], + all_projects: bool, + interval: float, + quiet_period: float, + max_latency: float, + debug: bool, +) -> None: + """Re-convert transcripts as they change, until interrupted. + + Points at one project by default -- the one for the current directory + if it has transcripts, otherwise the given INPUT_PATH. Watching the + whole archive is available via --all-projects but is rarely what you + want: a tick's cost scales with the project, and only one project is + ever being written to. + + The generated files on disk stay canonical, so anything that reloads + them picks the changes up: an editor or Obsidian for Markdown, a + browser refresh for HTML. + """ + from .watch import WatchEngine + + projects_path = projects_dir or get_default_projects_dir() + root = _resolve_watch_root(input_path, projects_path, all_projects) + if root is None: + # `raise` rather than `sys.exit` so the type checkers can see that + # `root` is a Path from here on. + raise SystemExit(1) + + fmt = "markdown" if output_format == "md" else output_format + write_combined = combined != "no" + individual = combined != "only" + + # One store for the whole watch, not one per tick: it lets each tick + # resume its parse from the bytes the previous tick already read, + # instead of re-reading a growing session's whole history every time + # a line lands (entry_store.py, work/watch-mode.md Fix B). Only the + # single-project path takes one — `--all-projects` re-converts a + # hierarchy and has no single growing file to follow. + from .entry_store import ParsedEntryStore, entry_store_enabled + + store = ParsedEntryStore() if (entry_store_enabled() and not all_projects) else None + + def convert(_changed: set[Path]) -> None: + started = time.monotonic() + if all_projects: + process_projects_hierarchy( + root, + silent=True, + output_format=fmt, + output_dir=output, + write_combined=write_combined, + generate_individual_sessions=individual, + ) + else: + convert_jsonl_to( + fmt, + root, + # `output_root` is the directory form; leaving output_path + # unset lets the converter derive the filenames, which is + # what keeps the destination-aware freshness check working. + output_root=output, + silent=True, + write_combined=write_combined, + generate_individual_sessions=individual, + entry_store=store, + ) + click.echo( + f" {time.strftime('%H:%M:%S')} converted in " + f"{time.monotonic() - started:.2f}s" + ) + + def report(exc: BaseException) -> None: + if debug: + traceback.print_exc() + click.echo(f" conversion failed: {exc!r}", err=True) + + engine = WatchEngine( + [root], + convert, + poll_interval=interval, + quiet_period=quiet_period, + max_latency=max_latency, + on_error=report, + ) + + click.echo(f"Watching {root}") + click.echo("Press Ctrl+C to stop.") + # Convert once up front so the output is current before the first + # change, then prime -- priming after means our own output writes are + # already in the baseline and can't trigger a spurious first tick. + # + # A failure here is a misconfigured watch, not a transient one: the + # root doesn't exist, or `--all-projects` was pointed at a single + # project rather than an archive. `report` is for the per-tick case + # where the loop should carry on; this one is fatal, and gets the + # same one-line diagnosis `convert` gives rather than a traceback. + try: + convert(set()) + except Exception as e: + if debug: + traceback.print_exc() + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + engine.prime() + try: + engine.run() + except KeyboardInterrupt: + click.echo("\nStopping.") + click.echo( + f"{engine.stats.conversions} conversions, " + f"{engine.stats.polls} polls, {engine.stats.errors} errors." + ) + + +def _resolve_watch_root( + input_path: Optional[Path], projects_path: Path, all_projects: bool +) -> Optional[Path]: + """Pick the directory to watch, or report why we can't. + + Order: an explicit path wins; --all-projects means the hierarchy; + otherwise the project for the current directory, which is what someone + running this alongside a live session almost always means. + """ + if input_path is not None: + return input_path + if all_projects: + return projects_path + + from .utils import real_path_to_project_dirname + + encoded = real_path_to_project_dirname(Path.cwd()) + candidate = projects_path / encoded + if candidate.is_dir(): + return candidate + + click.echo( + f"No transcripts found for the current directory ({Path.cwd()}).\n" + f" Looked for: {candidate}\n" + " Pass a project directory explicitly, or use --all-projects.", + err=True, + ) + return None + + convert.epilog = _subcommand_epilog(main) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 31597056..7a61a1ee 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -11,14 +11,15 @@ import re import time from collections import defaultdict -from collections.abc import Iterator +from collections.abc import Generator, Iterator from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass, field from datetime import datetime +from enum import Enum from pathlib import Path import traceback from urllib.parse import quote -from typing import Any, Dict, List, Optional, TYPE_CHECKING, cast +from typing import Any, Dict, List, Optional, TextIO, TYPE_CHECKING, cast import dateparser @@ -26,11 +27,14 @@ from collections.abc import Iterable from .cache import CacheManager, SessionSidecar + from .entry_store import ParsedEntryStore from .fragment_store import RenderFragmentStore from .providers.base import ProviderTokenTotals from .render_pool import RenderPool from .utils import ( + atomic_write_text, + real_path_to_project_dirname, coalesce_trunk_session_id, collect_trunk_session_ids, format_timestamp_range, @@ -282,6 +286,192 @@ def filter_messages_by_date( return filtered_messages +def _text_file_lines(f: "TextIO") -> Generator[tuple[int, str], None, None]: + """The historical line source: iterate an open text file, closing it after.""" + try: + yield from enumerate(f, 1) # Start counting from 1 + finally: + f.close() + + +@dataclass +class _ByteParse: + """A byte-level parse that may resume from a previously held prefix. + + Reading bytes rather than text is what makes resumption possible: the + store pins entries to a byte offset plus a hash of the bytes below it, + so the next tick can prove the file still *starts* with what it parsed + before and read only what was appended (work/watch-mode.md, Fix B). + + Splitting on ``b"\\n"`` and decoding per line is equivalent to the + text read it replaces: a UTF-8 continuation byte is never 0x0A, so no + multi-byte sequence spans the split, and the same + ``errors="replace"`` applies either way. + """ + + path: Path + entries: list[TranscriptEntry] + agent_ids: set[str] + tail: bytes + hasher: Any + start_offset: int + start_line: int + resumed: bool + held_count: int + # What the parse had produced when it reached the first line past the + # prefix cut — i.e. the products of complete lines only. None until + # that line is reached, which for a newline-terminated tail is the + # empty string after the last ``\n``, so the common case still holds + # everything. See :meth:`mark_incomplete` and :meth:`commit`. + complete_count: Optional[int] = None + complete_agent_ids: Optional[set[str]] = None + + @property + def mark_line(self) -> int: + """Line number of the first line whose bytes fall outside the cut.""" + return ( + self.start_line + self.tail[: self.tail.rfind(b"\n") + 1].count(b"\n") + 1 + ) + + def mark_incomplete( + self, messages: list[TranscriptEntry], agent_ids: set[str] + ) -> None: + """Snapshot what complete lines produced, before the trailing fragment.""" + if self.complete_count is None: + self.complete_count = len(messages) + self.complete_agent_ids = set(agent_ids) + + def lines(self) -> Generator[tuple[int, str], None, None]: + """Yield ``(line_no, text)`` for the bytes past the held prefix.""" + + def _iter() -> Generator[tuple[int, str], None, None]: + for offset, raw in enumerate(self.tail.split(b"\n"), self.start_line + 1): + yield offset, raw.decode("utf-8", errors="replace") + + return _iter() + + def commit( + self, + entry_store: "ParsedEntryStore", + messages: list[TranscriptEntry], + agent_ids: set[str], + ) -> None: + """Hold the parse for the next tick, up to the last *complete* line. + + A torn final line (mid-append, C12) is parsed as before — it just + fails and is skipped — but its bytes stay out of the prefix, so + the next tick re-reads and parses it properly once it lands. + + The cut is on **entries as well as bytes**, and it has to be: + a final line whose newline hasn't landed yet can be a whole, + valid record (a flush that split on the newline, or simply a file + stored without a trailing one). That parses into an entry whose + bytes are below the cut, so holding it would have the next tick + parse the same line again and hand back the entry twice. + """ + complete = self.tail.rfind(b"\n") + 1 + if complete <= 0 and self.start_offset == 0: + return + self.hasher.update(self.tail[:complete]) + consumed = self.start_offset + complete + line_count = self.start_line + self.tail[:complete].count(b"\n") + if self.complete_count is not None: + messages = messages[: self.complete_count] + agent_ids = self.complete_agent_ids or set() + entry_store.put_prefix( + self.path, + consumed, + self.hasher.digest(), + messages, + agent_ids, + line_count, + ) + + +def _appended_rows( + byte_parse: "Optional[_ByteParse]", + messages: list[TranscriptEntry], + parsed_line_count: int, + any_agent_refs: bool, + any_subagent_meta: bool, + spliced_agents: bool, +) -> Optional[list[TranscriptEntry]]: + """The entries a cache write may append, or None to rewrite the file. + + The cached rows for a transcript are not simply its lines: agent + transcripts are spliced in at their anchors, and the sidecar passes + back-patch ``spawnedAgentId`` onto entries parsed long ago. So "the + file grew by an append" does **not** by itself imply "the rows grew by + an append" — a subagent running alongside the trunk inserts rows in + the *middle* of the sequence, which is common in exactly the watch + scenario this optimises. + + Rather than track every way that can happen, this refuses unless the + row list is provably just the file's own lines: + + * the parse resumed from a byte-verified prefix, so every entry below + the resume point came from bytes that have not changed; + * the file references no agents, has no sidecars, and spliced no agent + blocks, so none of the whole-file passes could add or alter an + entry; + * and nothing appeared in the list except those parsed lines. + + On the reference archive that covers 136 of 185 trunk files. The rest + take the full rewrite, unchanged. + """ + if byte_parse is None or not byte_parse.resumed: + return None + if any_agent_refs or any_subagent_meta or spliced_agents: + return None + if len(messages) != parsed_line_count: + return None + held = byte_parse.held_count + if held <= 0 or held > len(messages): + return None + appended = messages[held:] + return appended or None + + +def _begin_byte_parse( + jsonl_path: Path, entry_store: "ParsedEntryStore" +) -> "Optional[_ByteParse]": + """Read `jsonl_path`, resuming from the store's held prefix if it still fits. + + The held prefix is accepted only when the file's first ``prefix_len`` + bytes hash to the digest recorded with it — a *stronger* check than + the row-fingerprint prefix comparison it saves, since identical bytes + imply identical rows. A mismatch (rewound session, replayed history, + a shorter file) drops the prefix and re-reads from the top, which is + what the caller would have done anyway. + """ + from .entry_store import new_hasher, read_prefix_and_tail + + held = entry_store.get_prefix(jsonl_path) + hasher = new_hasher() + tail = read_prefix_and_tail(jsonl_path, held.prefix_len if held else 0, hasher) + if held is not None and (tail is None or hasher.digest() != held.digest): + entry_store.drop_prefix(jsonl_path) + entry_store.prefix_misses += 1 + held = None + hasher = new_hasher() + tail = read_prefix_and_tail(jsonl_path, 0, hasher) + if tail is None: + return None # unreadable — let the text path report it as before + if held is not None: + entry_store.prefix_hits += 1 + return _ByteParse( + path=jsonl_path, + entries=held.entries if held else [], + agent_ids=set(held.agent_ids) if held else set(), + tail=tail, + hasher=hasher, + start_offset=held.prefix_len if held else 0, + start_line=held.line_count if held else 0, + resumed=held is not None, + held_count=len(held.entries) if held else 0, + ) + + def load_transcript( jsonl_path: Path, cache_manager: Optional["CacheManager"] = None, @@ -290,10 +480,16 @@ def load_transcript( silent: bool = False, _loaded_files: Optional[set[Path]] = None, _meta_maps: Optional[dict[Path, dict[str, str]]] = None, + entry_store: "Optional[ParsedEntryStore]" = None, ) -> list[TranscriptEntry]: """Load and parse JSONL transcript file, using cache if available. Args: + entry_store: Optional per-conversion store of entries this + conversion has already materialised (``entry_store.py``). + Consulted ahead of the cache, which it saves a + decompress + parse + validate pass over; a store miss or a + changed file falls through to exactly the path below. _loaded_files: Internal parameter to track loaded files and prevent infinite recursion. _meta_maps: Internal per-load memo of ``{dir: {toolUseId: agentId}}`` sidecar maps, so one flat ``subagents/`` family is scanned once @@ -310,6 +506,15 @@ def load_transcript( return [] _loaded_files.add(jsonl_path) + # Entries this conversion already parsed beat both the cache and the + # source. Date filtering is excluded: the store holds whole files, + # and the filtered read below returns a subset. + if entry_store is not None and not from_date and not to_date: + held = entry_store.get(jsonl_path) + if held is not None: + if not silent: + print(f"Loading {jsonl_path} from this run's parsed entries...") + return held # Try to load from cache first if cache_manager is not None: # Use filtered loading if date parameters are provided @@ -333,25 +538,58 @@ def load_transcript( from .cache import subagents_fingerprint subagents_fp = subagents_fingerprint(jsonl_path) + # The file's own identity, for the same reason and at the same moment: + # a session appended to *during* this read must leave the cache + # stamped with what we parsed, not with what the file reached, or the + # next run finds a fresh-looking cache missing those lines. Unreadable + # here means the writers stat it themselves, exactly as before. + try: + source_stat: Optional[os.stat_result] = jsonl_path.stat() + except OSError: + source_stat = None messages: list[TranscriptEntry] = [] agent_ids: set[str] = set() # Collect agentId references while parsing # Track unrecognized message types already warned about so we emit at # most one warning per distinct type per file (these tend to repeat a lot). warned_unrecognized_types: set[str | None] = set() - try: - f = open(jsonl_path, "r", encoding="utf-8", errors="replace") - except FileNotFoundError: - # Handle race condition: file may have been deleted between glob and open - # (e.g., Claude Code session cleanup) - if not silent: - print(f"Warning: File not found (may have been deleted): {jsonl_path}") - return [] + # With a store, read bytes rather than text: that yields both the + # offset and the rolling hash a later tick needs to resume from what + # it already parsed, instead of re-reading the file's whole history + # (entry_store.py). A decline — no store, date filtering, an + # unreadable file — falls through to the pre-existing text read, + # which is left exactly as it was. + byte_parse = ( + _begin_byte_parse(jsonl_path, entry_store) + if entry_store is not None and not from_date and not to_date + else None + ) + + if byte_parse is not None: + messages = byte_parse.entries + agent_ids = byte_parse.agent_ids + line_source = byte_parse.lines() + # The first line whose bytes fall outside the prefix cut: reaching + # it is what tells `commit` where the holdable entries stop. + mark_line = byte_parse.mark_line + else: + try: + f = open(jsonl_path, "r", encoding="utf-8", errors="replace") + except FileNotFoundError: + # Handle race condition: file may have been deleted between glob and open + # (e.g., Claude Code session cleanup) + if not silent: + print(f"Warning: File not found (may have been deleted): {jsonl_path}") + return [] + line_source = _text_file_lines(f) + mark_line = -1 - with f: + with contextlib.closing(line_source): if not silent: print(f"Processing {jsonl_path}...") - for line_no, line in enumerate(f, 1): # Start counting from 1 + for line_no, line in line_source: + if line_no == mark_line and byte_parse is not None: + byte_parse.mark_incomplete(messages, agent_ids) line = line.strip() if line: try: @@ -456,15 +694,28 @@ def load_transcript( f"\n{traceback.format_exc()}" ) + # Hold what we just parsed for the next tick, BEFORE the whole-file + # passes below — they mutate entries in place (back-patching + # `spawnedAgentId`) and splice agent blocks in, and a resumed parse + # must start from the raw per-line products and re-run them. + if byte_parse is not None and entry_store is not None: + byte_parse.commit(entry_store, messages, agent_ids) + + # How many entries came straight off the file's own lines, before any + # of the whole-file passes below can add or alter one. The append-only + # cache write is gated on this being the whole story (see + # `_appended_rows`). + parsed_line_count = len(messages) + had_agent_refs = bool(agent_ids) + # Sidecar-driven spawn linking (issue #213): resolve each spawning # tool_use to its sub-agent via the agent-.meta.json files. This is # what makes NESTED spawns discoverable — a sub-agent's own spawn # tool_results carry no ``toolUseResult.agentId`` (trunk-only # enrichment), and an interrupted spawn has no usable tool_result at # all; the sidecar's ``toolUseId`` covers both. - _apply_subagent_meta_links( - messages, _subagent_meta_map(jsonl_path, _meta_maps), agent_ids, jsonl_path - ) + subagent_meta = _subagent_meta_map(jsonl_path, _meta_maps) + _apply_subagent_meta_links(messages, subagent_meta, agent_ids, jsonl_path) # Prompt-hash fallback: link Task tool_results that lack a structured # agentId (common for true teammate subagents) by matching the @@ -512,6 +763,11 @@ def load_transcript( ) agent_messages_map[agent_id] = agent_messages + # `agent_messages_map` is drained by the splice below, so record now + # whether any splicing is about to happen — the append-only cache + # write needs to know, and by then the map would read as empty. + spliced_agents = bool(agent_messages_map) + # Insert agent messages at their point of use (only once per agent) if agent_messages_map: # Iterate through messages and insert agent messages after the FIRST @@ -538,11 +794,31 @@ def load_transcript( messages = result_messages - # Save to cache if cache manager is available + # Save to cache if cache manager is available. When this parse resumed + # from a verified prefix and nothing but the file's own new lines + # reached the list, the rows are the old rows plus a tail and only the + # tail needs writing — which is most of a watch tick (see + # `_appended_rows` for what has to hold, and `extend_cached_entries` + # for the row-level check that can still refuse). if cache_manager is not None: - cache_manager.save_cached_entries( - jsonl_path, messages, subagents_fp=subagents_fp + appended = _appended_rows( + byte_parse, + messages, + parsed_line_count, + had_agent_refs or bool(agent_ids), + bool(subagent_meta), + spliced_agents, ) + if appended is None or not cache_manager.extend_cached_entries( + jsonl_path, + messages, + appended, + subagents_fp=subagents_fp, + source_stat=source_stat, + ): + cache_manager.save_cached_entries( + jsonl_path, messages, subagents_fp=subagents_fp, source_stat=source_stat + ) return messages @@ -1138,6 +1414,7 @@ def _load_stale_session_transcripts( cache_manager: "CacheManager", stale_session_ids: list[str], silent: bool = False, + entry_store: "Optional[ParsedEntryStore]" = None, ) -> Optional[tuple[list[TranscriptEntry], SessionTree]]: """Load only the named trunk sessions, faithful to the full load. @@ -1203,7 +1480,12 @@ def _load_stale_session_transcripts( return None return _load_sessions_partial( - directory_path, cache_manager, sidecar, trunk_files, silent + directory_path, + cache_manager, + sidecar, + trunk_files, + silent, + entry_store=entry_store, ) @@ -1259,6 +1541,7 @@ def _load_sessions_partial( silent: bool, reelect_uuids: Optional[set[str]] = None, native_junction_uuids: Optional[set[str]] = None, + entry_store: "Optional[ParsedEntryStore]" = None, ) -> tuple[list[TranscriptEntry], SessionTree]: """Load the given trunk files into a faithful partial (entries, tree). @@ -1295,9 +1578,19 @@ def _load_sessions_partial( with cache_manager.batch(): for jsonl_file in trunk_files: all_messages.extend( - load_transcript(jsonl_file, cache_manager, None, None, silent) + load_transcript( + jsonl_file, + cache_manager, + None, + None, + silent, + entry_store=entry_store, + ) ) + # NB: this mutates entries in place and is not idempotent (it appends + # `#agent-{id}` to sessionId), which is why the store hands out deep + # copies rather than the objects it holds. _integrate_agent_entries(all_messages) # Enforce the whole-project dedup outcome: drop every duplicated @@ -1827,7 +2120,7 @@ def _enable_next_link_on_previous_page( new_content, count = _NEXT_LINK_PATTERN.subn(r"\1\2", content) if count > 0: - page_path.write_text(new_content, encoding="utf-8", errors="replace") + atomic_write_text(page_path, new_content) return True return False @@ -2274,9 +2567,7 @@ def _render_page_unit_inline( # JSONL may carry lone surrogates (issue #139); strict UTF-8 # encoding crashes here. Replace with U+FFFD so output stays # valid UTF-8. - (output_dir / unit.file_name).write_text( - html_content, encoding="utf-8", errors="replace" - ) + atomic_write_text(output_dir / unit.file_name, html_content) def _generate_paginated_html( @@ -3030,7 +3321,7 @@ def _try_current_or_session_scoped( output_path: Path, effective_output_dir: Path, cache_manager: Optional["CacheManager"], - cache_was_updated: bool, + cache_refresh: "CacheRefresh", format: str, ext: str, suffix: str, @@ -3047,6 +3338,7 @@ def _try_current_or_session_scoped( no_recaps: bool, silent: bool, report: Optional["RegenerationReport"], + entry_store: "Optional[ParsedEntryStore]" = None, ) -> Optional[Path]: """Finish from the cache alone, when the combined output is current. @@ -3062,12 +3354,33 @@ def _try_current_or_session_scoped( function rather than two: splitting them would compute ``_combined_output_is_stale`` and ``get_stale_sessions`` twice. + **On ``cache_refresh``.** This used to refuse outright whenever the + cache had been updated, which made the path unreachable for the case + it helps most: a live session gaining messages, where every run has + new bytes by definition. The refusal was really about one risk — the + staleness check here is per-session *message counts*, so a session + whose content changed without its count changing would be missed. + + An INCREMENTAL refresh rules that out. It only succeeds after + proving every modified file's cached rows are an exact prefix of its + current rows (``_incremental_cache_refresh``), i.e. the change was a + pure append; with append-only sources a changed session always + changes its count. It also keeps the cross-session sidecar current + (``merge_session_sidecar``), which is the other thing the partial + load needs. A FULL refresh carries neither guarantee, so it still + refuses. + + Note this mostly unlocks ``--combined no``: when a combined output + exists, the session's growth makes it stale and + ``_combined_output_is_stale`` bails us out to the streaming path + anyway. + Returns ``None`` when the preconditions don't hold, when the combined output is stale, or when the session-scoped load declines. """ if ( cache_manager is None - or cache_was_updated + or cache_refresh is CacheRefresh.FULL or from_date is not None or to_date is not None or force_regenerate @@ -3117,6 +3430,7 @@ def _try_current_or_session_scoped( cache_manager, [sid for sid, _reason in stale_sessions], silent, + entry_store=entry_store, ) if partial is None: return None @@ -3272,6 +3586,7 @@ def convert_jsonl_to( report: Optional["RegenerationReport"] = None, archive_search_link: Optional[str] = None, render_jobs: Optional[int] = None, + entry_store: "Optional[ParsedEntryStore]" = None, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -3360,7 +3675,8 @@ def convert_jsonl_to( # directory mode) so DAG-based ordering handles sidechain placement. _integrate_agent_entries(messages) title = f"Claude Transcript - {input_path.stem}" - cache_was_updated = False # No cache in single file mode + cache_refresh = CacheRefresh.NONE # No cache in single file mode + cache_was_updated = False # Single-file workflow support (#174 PR3): a lone ``.jsonl`` still # has its run data in the sibling ``/subagents/workflows/`` dir, so @@ -3393,10 +3709,30 @@ def convert_jsonl_to( if output_path is None: output_path = effective_output_dir / f"combined_transcripts{suffix}.{ext}" + # A store holds whatever the cache refresh parses, so Phase 1b + # doesn't rebuild it from the rows the refresh just wrote + # (entry_store.py). Deliberately not handed to the streaming path + # below, whose bounded residency depends on dropping each page's + # entries before the next page loads. + # + # A caller may own one instead — `watch` does, so that a tick can + # resume its parse from the bytes the previous tick already read. + # Ownership decides lifetime: ours dies with this call, theirs + # doesn't, which is the whole point of a resident loop having one. + caller_owned = entry_store is not None + if entry_store is None: + entry_store = _make_entry_store() + # Phase 1: Ensure cache is fresh and populated - cache_was_updated = ensure_fresh_cache( - input_path, cache_manager, from_date, to_date, silent + cache_refresh = ensure_fresh_cache_detailed( + input_path, + cache_manager, + from_date, + to_date, + silent, + entry_store=entry_store, ) + cache_was_updated = bool(cache_refresh) # Phase 1b: finish without loading the project at all, if the # cache says we can. @@ -3405,7 +3741,7 @@ def convert_jsonl_to( output_path=output_path, effective_output_dir=effective_output_dir, cache_manager=cache_manager, - cache_was_updated=cache_was_updated, + cache_refresh=cache_refresh, format=format, ext=ext, suffix=suffix, @@ -3422,10 +3758,19 @@ def convert_jsonl_to( no_recaps=no_recaps, silent=silent, report=report, + entry_store=entry_store, ) if settled is not None: return settled + # Past Phase 1b nothing reuses the store, and what it holds is a + # whole session's entries — stop referencing it before the paths + # that load the project (or stream it page by page) start + # allocating. A caller-owned store outlives us either way; this + # only drops *our* reference. + if not caller_owned: + entry_store = None + # Phase 1c: convert a paginated project page-by-page instead of # loading it whole. streamed_path = _try_streaming( @@ -3695,7 +4040,7 @@ def convert_jsonl_to( ) assert content is not None # See issue #139: errors="replace" for lone-surrogate safety. - output_path.write_text(content, encoding="utf-8", errors="replace") + atomic_write_text(output_path, content) # Update html_cache for the combined transcript. Written for the # marker-tracked formats (HTML + Markdown); JSON tracks its own @@ -3777,6 +4122,7 @@ def _incremental_cache_refresh( session_jsonl_files: list[Path], modified_files: list[Path], silent: bool, + entry_store: "Optional[ParsedEntryStore]" = None, ) -> bool: """Refresh the cache from the modified files alone, never loading whole. @@ -3815,6 +4161,7 @@ def _incremental_cache_refresh( full path's. """ from .cache import CachedFileState + from .entry_store import stamp_file if not modified_files: return False @@ -3851,7 +4198,15 @@ def _incremental_cache_refresh( old_rows = cache_manager.get_all_session_rows() for f in modified_files: - load_transcript(f, cache_manager, None, None, silent) + # Stamp BEFORE the parse: a file that grows while we read it + # must make the store decline (its stamp would then be older + # than the file), never serve a list its stamp misdescribes. + stamp = stamp_file(f) + parsed = load_transcript( + f, cache_manager, None, None, silent, entry_store=entry_store + ) + if entry_store is not None: + entry_store.put(f, stamp, parsed) new_states = cache_manager.get_file_states(modified_names) closure: set[str] = set() @@ -4037,6 +4392,7 @@ def _survives(uuid: str, sess: str) -> bool: silent, reelect_uuids=exempt_uuids, native_junction_uuids=native_junction_uuids, + entry_store=entry_store, ) # Compute every write, validate, then write — no fact lands @@ -4159,6 +4515,32 @@ def _survives(uuid: str, sess: str) -> bool: return True +class CacheRefresh(Enum): + """How `ensure_fresh_cache` brought the cache up to date. + + The distinction that matters to callers is INCREMENTAL vs FULL, not + "did anything change". The incremental path only runs after proving + that every modified file's cached rows are an exact *prefix* of its + current rows — i.e. the change was a pure append and no existing + entry was rewritten (`_incremental_cache_refresh`). That proof is + what lets the session-scoped render path trust a per-session + staleness check based on message counts: with append-only sources a + changed session always changes its count, so nothing can change + underneath a count that stayed the same. + + A FULL refresh carries no such proof — it is the fallback for + rewritten history, deleted files, and everything else hairy — so + callers that need the guarantee must treat it like a cold cache. + """ + + NONE = "none" + INCREMENTAL = "incremental" + FULL = "full" + + def __bool__(self) -> bool: + return self is not CacheRefresh.NONE + + def ensure_fresh_cache( project_dir: Path, cache_manager: Optional[CacheManager], @@ -4168,10 +4550,35 @@ def ensure_fresh_cache( ) -> bool: """Ensure cache is fresh and populated. Returns True if cache was updated. + Thin bool wrapper over `ensure_fresh_cache_detailed` for callers that + only need "did anything change". + """ + return bool( + ensure_fresh_cache_detailed( + project_dir, cache_manager, from_date, to_date, silent + ) + ) + + +def ensure_fresh_cache_detailed( + project_dir: Path, + cache_manager: Optional[CacheManager], + from_date: Optional[str] = None, + to_date: Optional[str] = None, + silent: bool = False, + entry_store: "Optional[ParsedEntryStore]" = None, +) -> CacheRefresh: + """Ensure cache is fresh and populated, reporting *how*. + This does the heavy lifting of loading and parsing files. + + ``entry_store`` is the caller's per-conversion store + (``entry_store.py``); the incremental refresh fills it with the files + it parses, so the conversion's later loads can reuse them instead of + rebuilding them from the rows just written. """ if cache_manager is None: - return False + return CacheRefresh.NONE # Check if cache needs updating # Exclude agent files from direct check - they are loaded via session references @@ -4179,7 +4586,7 @@ def ensure_fresh_cache( # This is acceptable since agent files typically change alongside their sessions. session_jsonl_files = trunk_jsonl_files(project_dir) if not session_jsonl_files: - return False + return CacheRefresh.NONE # Reuse one connection for the invalidation reads AND the whole populate # pass (per-file load + save + the session/aggregate writes) instead of @@ -4203,7 +4610,7 @@ def ensure_fresh_cache( ) if not needs_update: - return False # Cache is already fresh + return CacheRefresh.NONE # Cache is already fresh # Streaming stage 4: when the update is driven purely by changed # files over a populated cache, refresh from those files' bounded @@ -4223,8 +4630,9 @@ def ensure_fresh_cache( session_jsonl_files, modified_files, silent, + entry_store=entry_store, ): - return True + return CacheRefresh.INCREMENTAL # Load and process messages to populate cache if not silent: @@ -4244,7 +4652,7 @@ def ensure_fresh_cache( # Update cache with fresh data _update_cache_with_session_data(cache_manager, messages) - return True + return CacheRefresh.FULL def _update_cache_with_session_data( @@ -4389,6 +4797,22 @@ def build_session_title( return f"{project_title}: Session {session_id[:8]}" +def _make_entry_store() -> "Optional[ParsedEntryStore]": + """Create the per-conversion parsed-entry store, unless disabled. + + ``CLAUDE_CODE_LOG_ENTRY_STORE=0`` opts out, for bisecting. There is + no memory valve here because an empty store costs nothing and the + only thing that can fill it — ``_incremental_cache_refresh`` — checks + available memory per file at ``put`` time, where the file's size is + actually known. + """ + from .entry_store import ParsedEntryStore, entry_store_enabled + + if not entry_store_enabled(): + return None + return ParsedEntryStore() + + def _make_fragment_store( format: str, transcript_bytes: int = 0 ) -> "Optional[RenderFragmentStore]": @@ -4611,9 +5035,7 @@ def _render_session_inline(unit: RenderUnit) -> None: ) assert session_content is not None # See issue #139: errors="replace" for lone-surrogate safety. - (output_dir / unit.file_name).write_text( - session_content, encoding="utf-8", errors="replace" - ) + atomic_write_text(output_dir / unit.file_name, session_content) def _record_session(unit: RenderUnit) -> None: """Cache bookkeeping for one written session file.""" @@ -4832,7 +5254,7 @@ def generate_single_session_file( ) assert session_content is not None # See issue #139: errors="replace" for lone-surrogate safety. - output_file.write_text(session_content, encoding="utf-8", errors="replace") + atomic_write_text(output_file, session_content) return output_file @@ -4878,7 +5300,7 @@ def render_normalized_session_file( ) assert content is not None output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(content, encoding="utf-8", errors="replace") + atomic_write_text(output, content) return output @@ -4888,7 +5310,7 @@ def _provider_project_dirname(cwd: Optional[Path]) -> str: index always has a home for them (DECIDED #3).""" if cwd is None: return "no-project" - return str(cwd).replace("/", "-").replace("\\", "-") + return real_path_to_project_dirname(cwd) def _entry_timestamp_range( @@ -5330,9 +5752,7 @@ def render_provider_wholesale( ) assert combined_content is not None dest_dir.mkdir(parents=True, exist_ok=True) - (dest_dir / combined_name).write_text( - combined_content, encoding="utf-8", errors="replace" - ) + atomic_write_text(dest_dir / combined_name, combined_content) if cache is not None: cache.update_html_cache(combined_name, None, len(combined_messages)) @@ -5386,7 +5806,7 @@ def render_provider_wholesale( assert index_content is not None index_path = output_root / get_index_filename(output_format) output_root.mkdir(parents=True, exist_ok=True) - index_path.write_text(index_content, encoding="utf-8", errors="replace") + atomic_write_text(index_path, index_content) if not silent: print( @@ -6522,7 +6942,7 @@ def _convert_plan_inline( # Ensure the index root exists when projecting into a fresh dir. index_path.parent.mkdir(parents=True, exist_ok=True) # See issue #139: errors="replace" for lone-surrogate safety. - index_path.write_text(index_content, encoding="utf-8", errors="replace") + atomic_write_text(index_path, index_content) # The archive-wide search page sits next to the index. It is static and # self-contained; it only *works* when served (it needs the API to reach @@ -6531,8 +6951,8 @@ def _convert_plan_inline( if output_format == "html": from .html.renderer import generate_archive_search_html - (index_path.parent / "search.html").write_text( - generate_archive_search_html(), encoding="utf-8", errors="replace" + atomic_write_text( + index_path.parent / "search.html", generate_archive_search_html() ) # Count total sessions from project summaries diff --git a/claude_code_log/entry_store.py b/claude_code_log/entry_store.py new file mode 100644 index 00000000..14818fe5 --- /dev/null +++ b/claude_code_log/entry_store.py @@ -0,0 +1,335 @@ +"""Per-conversion store of parsed transcript entries. + +A conversion that refreshes the cache incrementally materialises the same +entries up to three times: ``_incremental_cache_refresh`` parses each +modified file from source, then the closure load +(``_load_sessions_partial``) and the session-scoped render +(``_load_stale_session_transcripts``) each rebuild those same entries +from the rows just written — ``zlib.decompress`` + ``json.loads`` + +Pydantic validation, once per consumer. On a 39.7 MB session file that +was 488 ms + 129 ms + 141 ms of a 1.1 s watch tick, all three +proportional to the *file* rather than to the handful of appended lines +(work/watch-mode.md, C14). + +This store keeps the list the first pass already produced and serves it +to the other two. + +Scope and lifetime are deliberately narrow — the same posture as +``fragment_store.py``, one layer down: + +- **One store per conversion, threaded explicitly.** Never a global and + never hung off ``CacheManager``, which long-lived hosts (the TUI) keep + across many conversions. A store cannot outlive the conversion that + made it. +- **Only the incremental-refresh path fills it.** ``put`` is called from + ``_incremental_cache_refresh`` alone, with the files it just parsed, so + a cold or full conversion never stores anything and its residency is + unchanged. The streaming page loads (application_model.md § 2.13) + deliberately do *not* get a store: their whole point is that a page's + entries are dropped before the next page loads, and a store spanning + pages would pin every one of them. What this holds is therefore bounded + by *what changed*, not by the archive. +- **Hits are verified against the file.** ``get`` re-stats and compares + ``(size, mtime_ns)`` against the stamp captured *before* the parse; any + mismatch declines to the cache. A stamp taken before the parse can only + be older than the entries describe, so a file that grew mid-parse + declines rather than serving a list that does not match its stamp. +- **Handouts are deep copies.** The pipeline mutates entries in place — + ``_integrate_agent_entries`` appends ``#agent-{id}`` to ``sessionId`` + (not idempotent) and dedup re-parents around dropped copies — and today + each consumer gets freshly deserialised objects. Serving the same + objects twice would let one consumer's mutations leak into another's + view, so ``get`` returns a ``deepcopy``. That is cheap and stays cheap + because the bulk of an entry is immutable strings, which ``deepcopy`` + shares rather than copies: measured **2.0 ms and 0.83 MB** for the + 207-entry, 39.7 MB session above, against 123 ms to rebuild it from the + cache. + +The store is a pure performance feature: a conversion with no store (or +a declined ``put``) behaves exactly as before. +""" + +from __future__ import annotations + +import copy +import os +from dataclasses import dataclass, field +from hashlib import blake2b +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from .models import TranscriptEntry + +FileStamp = tuple[int, int] +"""``(size, mtime_ns)`` — the identity a stored list is pinned to.""" + +# Prefix hashing reads the file, so the digest is chosen for speed over +# collision margin beyond what a local cache needs: 16 bytes of BLAKE2b +# runs at ~1.2 GB/s, i.e. 32 ms over a 39.7 MB session — against 143 ms +# to re-parse the same bytes, which is what it buys. +_HASH_DIGEST_SIZE = 16 +_READ_CHUNK = 1 << 20 + + +def new_hasher() -> Any: + """A fresh hasher for prefix identity.""" + return blake2b(digest_size=_HASH_DIGEST_SIZE) + + +def read_prefix_and_tail(path: Path, prefix_len: int, hasher: Any) -> Optional[bytes]: + """Feed the first ``prefix_len`` bytes into ``hasher``; return the rest. + + None when the file is shorter than ``prefix_len`` (truncated or + replaced) or unreadable — both of which mean "no usable prefix", and + the caller re-reads from the top. + """ + try: + with path.open("rb") as fh: + remaining = prefix_len + while remaining > 0: + chunk = fh.read(min(_READ_CHUNK, remaining)) + if not chunk: + return None + hasher.update(chunk) + remaining -= len(chunk) + return fh.read() + except OSError: + return None + + +@dataclass +class HeldPrefix: + """Entries covering the first ``prefix_len`` bytes of a source file. + + ``entries`` are the *pre-post-processing* parse products — before + subagent meta linking, prompt-hash linking and agent-block splicing, + all of which are whole-file functions re-run over the concatenated + list. They cost 0.1 ms together on the reference file, so re-running + them is free; reusing their *output* would not be sound, because a + new sidecar can relink an entry that was parsed ticks ago. + + ``line_count`` continues the line numbering that parse warnings quote. + """ + + prefix_len: int + digest: bytes + entries: list["TranscriptEntry"] = field(default_factory=list["TranscriptEntry"]) + agent_ids: set[str] = field(default_factory=set[str]) + line_count: int = 0 + + +# Total source bytes the store will hold before evicting in insertion +# order. It only ever holds files a refresh actually parsed, so this is a +# backstop against a pathological closure, not a tuning knob. +DEFAULT_BUDGET_BYTES = 256 * 1024 * 1024 + +# A parsed transcript costs roughly 3x its bytes on disk in RAM +# (CONTRIBUTING, "A note on memory"). Require headroom well above that +# before holding one, so a tight machine keeps today's footprint instead +# of trading into swap for 270 ms. +MIN_AVAILABLE_MEMORY_PER_FILE_BYTE = 6.0 + + +def entry_store_enabled() -> bool: + """Whether the entry store may be used, per the environment. + + ``CLAUDE_CODE_LOG_ENTRY_STORE=0`` (or ``off``/``false``) disables it, + mirroring the branch's other kill switches — for bisecting a + rendering difference, not for tuning. + """ + value = os.environ.get("CLAUDE_CODE_LOG_ENTRY_STORE", "").strip().lower() + return value not in ("0", "off", "false") + + +def entry_store_forced() -> bool: + """Whether the environment *explicitly* asks for the store. + + An explicit ``=1`` (or ``on``/``true``) overrides the memory valve in + :meth:`ParsedEntryStore.put`. Unset means "enabled, but let the valve + decide". + """ + value = os.environ.get("CLAUDE_CODE_LOG_ENTRY_STORE", "").strip().lower() + return value in ("1", "on", "true") + + +def stamp_file(path: Path) -> Optional[FileStamp]: + """``(size, mtime_ns)`` for ``path``, or None if it can't be stat'd.""" + try: + st = path.stat() + except OSError: + return None + return (st.st_size, st.st_mtime_ns) + + +class ParsedEntryStore: + """Entries a conversion has already parsed, keyed by source file.""" + + def __init__(self, budget_bytes: int = DEFAULT_BUDGET_BYTES) -> None: + self._budget = budget_bytes + self._held: dict[str, tuple[FileStamp, list["TranscriptEntry"]]] = {} + self._prefixes: dict[str, HeldPrefix] = {} + self._bytes = 0 + self.hits = 0 + self.misses = 0 + self.declines = 0 + self.prefix_hits = 0 + self.prefix_misses = 0 + + # ---- prefixes (across ticks) ----------------------------------------- + + def put_prefix( + self, + path: Path, + prefix_len: int, + digest: bytes, + entries: list["TranscriptEntry"], + agent_ids: set[str], + line_count: int, + ) -> None: + """Hold ``entries`` as the parse of ``path``'s first ``prefix_len`` bytes. + + Copied on the way in, because the caller goes on to run + post-processing that mutates entries in place (see the module + docstring) and a later tick must resume from the pre-mutation + state. + + Charged against the same budget as :meth:`put`, and for the same + reason twice over: a ``watch`` owns one store for the life of the + loop, so every trunk file touched over a long session would + otherwise pin its parsed entries forever — and the per-file + memory valve, which only ever sees one file, cannot notice the + total. + """ + if prefix_len <= 0 or not entries: + return + if prefix_len > self._budget or not self._has_memory_for(prefix_len): + self.declines += 1 + self.drop_prefix(path) + return + self.drop_prefix(path) + self._bytes += prefix_len + self._prefixes[str(path)] = HeldPrefix( + prefix_len=prefix_len, + digest=digest, + entries=copy.deepcopy(entries), + agent_ids=set(agent_ids), + line_count=line_count, + ) + self._evict_to_budget() + + def get_prefix(self, path: Path) -> Optional[HeldPrefix]: + """The held prefix for ``path``, entries copied, or None. + + The digest is **not** checked here — verifying it means hashing + the file's first ``prefix_len`` bytes, which the caller does + anyway on its way to reading the tail. The caller compares and + calls :meth:`drop_prefix` on a mismatch. + """ + held = self._prefixes.get(str(path)) + if held is None: + return None + return HeldPrefix( + prefix_len=held.prefix_len, + digest=held.digest, + entries=copy.deepcopy(held.entries), + agent_ids=set(held.agent_ids), + line_count=held.line_count, + ) + + def drop_prefix(self, path: Path) -> None: + """Forget the held prefix — its file no longer starts with those bytes.""" + dropped = self._prefixes.pop(str(path), None) + if dropped is not None: + self._bytes -= dropped.prefix_len + + # ---- writing --------------------------------------------------------- + + def put( + self, path: Path, stamp: Optional[FileStamp], entries: list["TranscriptEntry"] + ) -> None: + """Hold ``entries`` for ``path``, pinned to ``stamp``. + + ``stamp`` must be the file's identity as captured *before* the + parse (see the module docstring); None — an unstattable file — + declines, as does an empty list, since there is nothing to save. + """ + if stamp is None or not entries: + return + size = stamp[0] + if size > self._budget: + self.declines += 1 + return + if not self._has_memory_for(size): + self.declines += 1 + return + + key = str(path) + existing = self._held.pop(key, None) + if existing is not None: + self._bytes -= existing[0][0] + self._held[key] = (stamp, entries) + self._bytes += size + self._evict_to_budget() + + def _has_memory_for(self, size: int) -> bool: + """Whether the machine has room to hold a file of ``size`` bytes.""" + if entry_store_forced(): + return True + from .render_pool import available_memory_bytes + + available = available_memory_bytes() + if available is None: # unreadable probe — don't second-guess it + return True + return available >= size * MIN_AVAILABLE_MEMORY_PER_FILE_BYTE + + def _evict_to_budget(self) -> None: + """Drop oldest entries until the held source bytes fit the budget. + + Whole-file entries go first: they serve the conversion that is + running now and the cache can rebuild them, whereas a prefix is + the only thing standing between the next tick and re-parsing a + file's whole history. Both are pure performance either way. + """ + while self._bytes > self._budget and self._held: + _key, (stamp, _entries) = next(iter(self._held.items())) + self._held.pop(_key) + self._bytes -= stamp[0] + while self._bytes > self._budget and self._prefixes: + key = next(iter(self._prefixes)) + self._bytes -= self._prefixes.pop(key).prefix_len + + # ---- reading --------------------------------------------------------- + + def get(self, path: Path) -> Optional[list["TranscriptEntry"]]: + """The stored entries for ``path``, or None to fall back to the cache. + + Declines whenever the file's current ``(size, mtime_ns)`` differs + from the stamp the entries were pinned to — the file changed + under us, so the cache (which the refresh has just rewritten) is + the authority, not this. + """ + held = self._held.get(str(path)) + if held is None: + self.misses += 1 + return None + stamp, entries = held + if stamp_file(path) != stamp: + self.misses += 1 + return None + self.hits += 1 + # Deep copy, because consumers mutate: see the module docstring. + return copy.deepcopy(entries) + + # ---- introspection --------------------------------------------------- + + @property + def held_bytes(self) -> int: + """Source bytes currently pinned (the residency this store adds).""" + return self._bytes + + def stats_line(self) -> str: + return ( + f"entry store: {self.hits} hit(s), {self.misses} miss(es), " + f"{self.declines} decline(s), {self._bytes / 1e6:.1f} MB held" + ) diff --git a/claude_code_log/html/templates/components/global_styles.css b/claude_code_log/html/templates/components/global_styles.css index feef5583..d417cd4f 100644 --- a/claude_code_log/html/templates/components/global_styles.css +++ b/claude_code_log/html/templates/components/global_styles.css @@ -21,6 +21,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -361,17 +366,28 @@ body.show-raw-user .user-content:not([data-user-view="md"]) .user-raw { /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); diff --git a/claude_code_log/html/templates/components/live_update.js b/claude_code_log/html/templates/components/live_update.js new file mode 100644 index 00000000..6fc94380 --- /dev/null +++ b/claude_code_log/html/templates/components/live_update.js @@ -0,0 +1,566 @@ +// Keep this page current while the session it shows is still running. +// +// Only active over http(s): a page loaded from file:// cannot fetch +// anything at all — not itself, not a sibling, not even a HEAD (verified +// in Chromium; script tags are the only channel a file:// page has). So +// this is a `serve` feature, and the generated HTML stays exactly as +// useful from file:// as it was before. +// +// The shape, and why: +// +// * The server never renders. `serve --watch` re-runs the ordinary +// conversion and the files on disk stay canonical, so this page just +// re-fetches its own URL. A HEAD for the page's own metadata makes +// the idle case free (~1ms, no body) and needs no endpoint of its +// own; the full GET follows only when that metadata moved. +// * We never reload. A reload loses fold state and re-parses a +// document that can reach tens of MB. +// * When the new render extends the one on screen, we patch the nodes +// that changed and leave the rest alone (see "patching" below). When +// it does not, we replace #transcript wholesale — which keeps scroll +// position for free, because everything above the viewport is +// untouched, and is the fallback for every shape the patch declines: +// entries that do not belong at the end (a transcript's appends are +// not in timestamp order) and the `msg-d-N` renumbering that follows, +// which would break the fork/tool-pair links already on the page. +(function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; +})(); diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index 6e03a440..b2551878 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1832,3 +1832,90 @@ img.artifact-favicon { width: 1em; vertical-align: -0.125em; } + +/* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ +@keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } +} + +.message.live-new { + animation: live-new-in 320ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } +} + +/* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ +.follow-updates.floating-btn { + bottom: 440px; + display: none; +} + +.follow-updates.floating-btn.live-active { + display: flex; +} + +/* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ +.follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; +} + +/* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ +.follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; +} + +/* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ +body.live-following { + padding-bottom: 20px; +} diff --git a/claude_code_log/html/templates/components/timeline.html b/claude_code_log/html/templates/components/timeline.html index b7ad783f..48bbff73 100644 --- a/claude_code_log/html/templates/components/timeline.html +++ b/claude_code_log/html/templates/components/timeline.html @@ -517,7 +517,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; diff --git a/claude_code_log/html/templates/components/timezone_converter.js b/claude_code_log/html/templates/components/timezone_converter.js index 30d4e700..ed81cc99 100644 --- a/claude_code_log/html/templates/components/timezone_converter.js +++ b/claude_code_log/html/templates/components/timezone_converter.js @@ -1,8 +1,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -35,81 +41,108 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } + + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } + } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); } + if (cursor < timestampElements.length) scheduleWork(drain); } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } })(); diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index e9346a2f..41bc5fd6 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -24,6 +24,34 @@ + +

{{ title }}

{% if page_info %} @@ -249,13 +277,33 @@

🔍 Search & Filter

{% endif %} {% endmacro %} - {% for root in roots %}{{ render_message(root) }}{% endfor %} + {# + The message tree lives in one addressable container so a client can + replace it wholesale — that is what `serve --watch` needs to update + a page in place without a navigation, and it keeps scroll position + because everything above the viewport is untouched. + + Deliberately unstyled: `body` is already the 1200px centred column, + so a plain block wrapper is layout-neutral (verified by comparing + full-page screenshots and the first message's box before and after + adding it). + #} +
{% for root in roots %}{{ render_message(root) }}{% endfor %}
{% if resume_command %} {% endif %} + {# + Follow toggle for `serve --watch`. Rendered unconditionally but + hidden by CSS until live_update.js finds it can actually poll, so + the markup stays identical between a served page and the same file + opened from disk. + #} + @@ -278,6 +326,9 @@

🔍 Search & Filter

// Timezone conversion (included as component) {% include 'components/timezone_converter.js' %} + // Live update (no-op unless served over http -- see the file) + {% include 'components/live_update.js' %} + // Debug UUID toggle debugButton.addEventListener('click', function () { document.body.classList.toggle('show-debug-info'); @@ -862,24 +913,30 @@

🔍 Search & Filter

// Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -1005,6 +1062,42 @@

🔍 Search & Filter

// Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and diff --git a/claude_code_log/migrations/011_cached_file_size.sql b/claude_code_log/migrations/011_cached_file_size.sql new file mode 100644 index 00000000..1b9d0413 --- /dev/null +++ b/claude_code_log/migrations/011_cached_file_size.sql @@ -0,0 +1,24 @@ +-- Detect appends the mtime tolerance hides +-- Migration: 011 +-- Description: Add a `source_size` column to `cached_files`. +-- Freshness compared source mtimes with a 1.0s tolerance, which exists +-- because filesystem timestamp granularity varies (and is coarse on +-- some network filesystems). The cost is that a write landing within a +-- second of the mtime recorded at cache time is invisible: appending to +-- a transcript and converting immediately alternates between seeing and +-- missing the change. It fails in the worst shape for a watch loop -- +-- the last message of a turn, landing just after a tick and followed by +-- silence, stays stranded until something else touches the file. +-- +-- Size is exact, and free: get_modified_files() already stats every +-- file, so st_size costs no extra syscall. The rule becomes "stale if +-- the size differs OR the mtime moved past tolerance", which is +-- strictly tightening -- it can only mark more files stale, never +-- fewer -- so it cannot invalidate anything the old rule accepted for +-- good reason. +-- +-- Backward-compatible in the same shape as 007: existing rows get NULL +-- and fall back to the mtime-only check, so a populated cache does not +-- mass-invalidate. Rows written from here on carry the size. + +ALTER TABLE cached_files ADD COLUMN source_size INTEGER; diff --git a/claude_code_log/migrations/012_message_lookup_indexes.sql b/claude_code_log/migrations/012_message_lookup_indexes.sql new file mode 100644 index 00000000..3bbe60c2 --- /dev/null +++ b/claude_code_log/migrations/012_message_lookup_indexes.sql @@ -0,0 +1,81 @@ +-- Let the incremental refresh's lookups use an index +-- Migration: 012 +-- Description: Composite indexes on `messages` for the lookups the +-- incremental cache refresh makes on every watch tick. +-- +-- All of them were scanning the whole project. `EXPLAIN QUERY PLAN` on a +-- real 38,706-row archive showed each as +-- SEARCH m USING INDEX idx_messages_project_timestamp (project_id=?) +-- i.e. walking every row the project has, to read a handful. Measured +-- per call on that archive, before -> after: +-- +-- get_uuid_owners 17.2 ms -> 0.9 ms (project_id, _uuid) +-- get_parent_uuid_dependents 21.5 ms -> 0.6 ms (project_id, _parent_uuid) +-- get_request_id_entries 17.0 ms -> 0.3 ms (project_id, _request_id) +-- get_metadata_target_files 14.5 ms -> 0.2 ms partial, on type +-- get_session_file_map 16.8 ms -> 2.5 ms (project_id, session_id, …) +-- +-- `get_uuid_owners` is the instructive one: an `idx_messages_uuid(_uuid)` +-- index has existed since 001, but the query filters `project_id = ? AND +-- _uuid IN (...)` and SQLite uses one index per table reference, so it +-- took the project one and scanned. The composite covers both terms. +-- +-- The session index below is double-edged and its callers know it: as +-- well as serving `get_session_file_map` (which must touch every session +-- anyway, and now does so as an index-only scan), it gives the planner a +-- way to satisfy a bare `session_id IS NOT NULL` as a range scan over +-- every session-bearing row — which it will happily prefer to seeking +-- the handful of uuids a query actually asked for. Three queries were +-- measurably *slower* with this index until that predicate moved out of +-- SQL and into Python; see the comment on `get_uuid_owners`. +-- +-- The metadata index is partial (288 of 38,706 rows here), which is why +-- it costs 0.01% rather than the ~1% a full index on `type` would. +-- +-- Cost, measured on a 49 MB archive: the cache grows 39.5 MB -> 42.8 MB +-- (**+8.4%**), and the write path — where rewriting a file's rows is a +-- watch tick's largest item — does not regress: a cold conversion goes +-- 5.85 s -> 5.79 s, because five more indexes on an INSERT are small +-- next to compressing the row's content blob. Ticks over the same +-- archive: 0.379 s -> 0.317 s (full rewrite), 0.267 s -> 0.228 s +-- (resumed). +-- +-- Pure performance: no column changes, nothing to backfill, and an older +-- library reading this database simply ignores them. + +CREATE INDEX IF NOT EXISTS idx_messages_project_uuid + ON messages(project_id, _uuid); + +CREATE INDEX IF NOT EXISTS idx_messages_project_parent_uuid + ON messages(project_id, _parent_uuid); + +CREATE INDEX IF NOT EXISTS idx_messages_project_request_id + ON messages(project_id, _request_id); + +CREATE INDEX IF NOT EXISTS idx_messages_project_metadata_type + ON messages(project_id, type) WHERE type IN ('summary', 'ai-title'); + +-- `timestamp` sits before `file_id` deliberately, and it is what makes +-- this index serve a second caller outside the refresh entirely: +-- `load_session_entries` / `export_session_to_jsonl` (the TUI, and +-- rendering an archived session) filter `project_id AND session_id` but +-- order by `timestamp`. Given only (project_id, session_id, file_id) the +-- planner takes the *timestamp* index instead — the sort comes free and +-- it filters session_id row by row — and walks the whole project to load +-- one session: 84.5 ms for the twelve busiest sessions of a 19k-row +-- archive. With `timestamp` in the index the seek and the ordering are +-- both satisfied, no sort is needed, and the same work takes **7.2 ms**. +-- End to end the method also decompresses and validates its entries, so +-- what a caller sees is 21.7 ms -> 15.0 ms per session; the extra column +-- costs 0.4 MB on a 45 MB cache. +-- +-- (Statistics were the other candidate fix and are worse: `ANALYZE` gets +-- to 15.8 ms, `PRAGMA optimize` writes partial stats that do not change +-- the plan at all, and either way the plan then depends on when stats +-- were last gathered. An index that dominates needs no stats.) +-- +-- Verified not to change the order rows come back in — 234 sessions, +-- 29,605 rows sharing a timestamp with another row, 1,237 NULL +-- timestamps, zero ordering differences. +CREATE INDEX IF NOT EXISTS idx_messages_project_session_ts + ON messages(project_id, session_id, timestamp, file_id); diff --git a/claude_code_log/render_pool.py b/claude_code_log/render_pool.py index 4d6f90cb..c35781a4 100644 --- a/claude_code_log/render_pool.py +++ b/claude_code_log/render_pool.py @@ -66,6 +66,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional +from .utils import atomic_write_text + if TYPE_CHECKING: from .models import RenderingDepth, TranscriptEntry @@ -567,9 +569,7 @@ def _render_unit_worker( ) # errors="replace" for lone-surrogate safety — see issue #139. - (output_dir / unit.file_name).write_text( - content, encoding="utf-8", errors="replace" - ) + atomic_write_text(output_dir / unit.file_name, content) except Exception: return unit.kind, unit.key, traceback.format_exc(), None delta = ( diff --git a/claude_code_log/server.py b/claude_code_log/server.py index ee323e9d..4576c920 100644 --- a/claude_code_log/server.py +++ b/claude_code_log/server.py @@ -21,16 +21,24 @@ exists, not after. * **The search core is HTTP-free.** Everything interesting lives in `search.py` as plain functions; this module is the adapter. +* **File responses carry `X-Content-Revision`.** The live page asks "did + this change?" with a HEAD, and the stock validators cannot always + answer: `Last-Modified` has one-second granularity and `Content-Length` + is blind to an edit that keeps the size. See `_content_revision`. """ from __future__ import annotations import functools +import hashlib import http.server import json +import os +import stat import threading +import time from pathlib import Path -from typing import Any, Callable, Optional +from typing import Any, BinaryIO, Callable, Optional from .cache import get_library_version @@ -38,6 +46,23 @@ # Reserved so a project directory literally named `api` can't shadow it. API_PREFIX = "/api/" +# The header the live page adds to its change comparison (see +# `_content_revision` and `live_update.js`). Deliberately *not* `ETag`: +# the stock `send_head` skips its `If-Modified-Since` check whenever the +# request carries an `If-None-Match`, and it never evaluates one — so +# advertising an ETag would make browsers stop getting 304s on the +# multi-MB pages that make 304s worth having. +REVISION_HEADER = "X-Content-Revision" + +# How long a file's mtime must have been in the past before a cached +# digest for it can be trusted. A write landing after we hashed can only +# reuse the same (mtime_ns, size) key if the filesystem's timestamp +# resolution is coarse enough to give it the same mtime — so once the +# recorded mtime is a full second old, no later write can hide behind it, +# whatever the resolution. Below that, re-hash: an actively-written page +# is exactly the case this header exists for. +_REVISION_SETTLE_NS = 1_000_000_000 + class ArchiveHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): """Static file serving for the projects dir, plus the `/api/` routes.""" @@ -48,6 +73,16 @@ class ArchiveHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): server_version = f"claude-code-log/{get_library_version()}" sys_version = "" + # Digest of the file this request is answering with, computed in + # `send_head` and emitted by `end_headers`. `None` for everything + # that is not a file response (the API, errors, directory listings). + _revision: Optional[str] = None + + # (path, mtime_ns, size) -> digest, shared by every request. Written + # from several server threads: dict get/set are atomic, and the worst + # a race can do is hash the same file twice. + _revision_cache: dict[tuple[str, int, int], str] = {} + # ---- security ------------------------------------------------------- def _host_is_allowed(self) -> bool: @@ -93,6 +128,67 @@ def _send_json(self, payload: Any, status: int = 200) -> None: self.end_headers() self.wfile.write(body) + # ---- content revision ----------------------------------------------- + + def _content_revision(self, path: str) -> Optional[str]: + """A validator derived from the bytes, not from the metadata. + + `live_update.js` polls a HEAD of its own URL and re-fetches when + the response's identity moves. `Last-Modified` alone misses two + conversions inside one second (HTTP dates are second-granular), + and adding `Content-Length` still misses a rewrite that changes + content without changing size — a re-render where a counter, a + status word or a timestamp keeps its width. Watch mode produces + rewrites a few hundred ms apart, so both gaps are reachable. + + Hashing the file closes them for any filesystem. It is not free + on a 27 MB page, so a settled file (see `_REVISION_SETTLE_NS`) + answers from the cache and only a file being written right now + is re-read — which is the case that has to be exact. + """ + try: + st = os.stat(path) + except OSError: + return None + if not stat.S_ISREG(st.st_mode): + return None + + key = (path, st.st_mtime_ns, st.st_size) + settled = time.time_ns() - st.st_mtime_ns > _REVISION_SETTLE_NS + if settled: + cached = self._revision_cache.get(key) + if cached is not None: + return cached + + digest = hashlib.blake2b(digest_size=16) + try: + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + except OSError: + return None + revision = digest.hexdigest() + + if settled: + # One entry per served file; a long-running server over a + # whole archive would otherwise accumulate them forever. + if len(self._revision_cache) > 256: + self._revision_cache.clear() + self._revision_cache[key] = revision + return revision + + def send_head(self) -> Optional[BinaryIO]: + self._revision = None + path = self.translate_path(self.path) + if not os.path.isdir(path): + self._revision = self._content_revision(path) + return super().send_head() + + def end_headers(self) -> None: + if self._revision is not None: + self.send_header(REVISION_HEADER, self._revision) + super().end_headers() + def _split_query(self) -> tuple[str, dict[str, str]]: from urllib.parse import parse_qs, urlparse diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index dd11fad2..c4ef922b 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -36,7 +36,7 @@ load_directory_transcripts, ) from .renderer import get_renderer -from .utils import get_project_display_name +from .utils import atomic_write_text, get_project_display_name class ProjectSelector(App[Path]): @@ -1876,7 +1876,7 @@ def _ensure_session_file( # is the worst outcome (worse than the CLI's loud crash, # which is how #139 surfaced in the first place). scrubbed = scrub_surrogates(session_content) or session_content - session_file.write_text(scrubbed, encoding="utf-8", errors="replace") + atomic_write_text(session_file, scrubbed) return session_file except Exception: return None diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index 273e87ef..6094b317 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Utility functions for message filtering and processing.""" +import os import re +import time from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -306,6 +308,16 @@ def _split_real_path_for_join(real_path_str: str) -> list[str]: return list(p_posix.parts) +def real_path_to_project_dirname(cwd: Path) -> str: + """Encode a real path the way Claude Code names its project directory. + + ``/home/joe/proj`` → ``-home-joe-proj``. The inverse, + `project_dir_to_real_path`, is lossy and needs the cache to + disambiguate; this direction is not. + """ + return str(cwd).replace("/", "-").replace("\\", "-") + + def project_dir_to_real_path( project_dir: Path, cached_working_directories: Optional[list[str]] = None, @@ -942,3 +954,77 @@ def generate_unified_diff(old_string: str, new_string: str) -> str: diff_lines = diff_lines[2:] return "".join(diff_lines).rstrip("\n") + + +# Windows refuses to replace a file another process holds open unless +# that process opened it with FILE_SHARE_DELETE, which Python's `open` +# does not — so a reader (an editor, a vault indexer, the browser poll +# this helper exists for) makes `os.replace` fail with PermissionError +# until it closes, rather than the write being torn. A read of even a +# 27 MB page is short, so a brief retry outlasts one; POSIX never takes +# this path, where the replace succeeds with readers mid-read. +_REPLACE_ATTEMPTS = 10 +_REPLACE_BACKOFF_S = 0.02 + + +def _replace_with_retry(tmp_path: Path, path: Path) -> None: + """`os.replace`, retried briefly past a concurrent reader on Windows.""" + for attempt in range(_REPLACE_ATTEMPTS): + try: + os.replace(tmp_path, path) + return + except PermissionError: + if attempt == _REPLACE_ATTEMPTS - 1: + raise + time.sleep(_REPLACE_BACKOFF_S) + + +def atomic_write_text( + path: Path, content: str, *, encoding: str = "utf-8", errors: str = "replace" +) -> None: + """Write ``content`` to ``path`` so a concurrent reader never sees a partial file. + + ``Path.write_text`` truncates and then writes, so anything reading the + file during the write gets a torn document. That is a narrow race for + a one-shot conversion, but watch mode rewrites the same file every few + seconds while an editor, a vault indexer, or a browser poll re-reads + it, which makes it routine — and a 27 MB session page is a wide window + to be caught in. + + The fix is the one ``image_export.export_image`` already uses: write a + uniquely-named temp file beside the target, then ``os.replace`` it into + position. ``os.replace`` is atomic on POSIX and on Windows, and "beside + the target" matters — a temp file on another filesystem would make the + replace a copy, which is not atomic. + + The temp name carries the pid so concurrent render workers writing the + same path (the fan-out can, harmlessly, since they write identical + bytes) cannot clobber each other's partial file. It is dot-prefixed so + a crash between write and replace leaves something obviously + disposable rather than a plausible-looking output file. + + Falls back to a plain write when the target exists but is not a + regular file. ``os.replace`` would *replace* a symlink rather than + write through it, and would clobber a fifo or device node outright — + both worse surprises than a non-atomic write to something the user + deliberately put there. + + On Windows the replace is retried briefly, because a reader holding + the target open blocks it there — see `_replace_with_retry`. + """ + if path.is_symlink() or (path.exists() and not path.is_file()): + path.write_text(content, encoding=encoding, errors=errors) + return + + tmp_path = path.parent / f".{path.name}.{os.getpid()}.tmp" + try: + tmp_path.write_text(content, encoding=encoding, errors=errors) + _replace_with_retry(tmp_path, path) + except BaseException: + # Includes KeyboardInterrupt: a half-written temp file left in an + # output directory is litter that the next run would not clean up. + try: + tmp_path.unlink() + except OSError: + pass + raise diff --git a/claude_code_log/watch.py b/claude_code_log/watch.py new file mode 100644 index 00000000..7087f60d --- /dev/null +++ b/claude_code_log/watch.py @@ -0,0 +1,239 @@ +"""Watch a project's transcripts and re-convert as they grow. + +The engine is deliberately small and knows nothing about HTTP, the CLI, +or rendering. It answers one question on a timer — *might something have +changed?* — debounces the answer, and calls a callback. Everything else +is the callback's problem. + +That division is the point. The watcher's scan is a cheap trigger, not a +source of truth: a false positive costs one no-op conversion (~0.2s on a +warm cache), while the conversion itself already knows precisely what is +stale. Duplicating the cache's freshness semantics out here would mean +two implementations that could disagree, and the cache is the one that +gets it right — since migration 011 it compares size as well as mtime, +so it no longer misses an append that lands inside the mtime tolerance. + +Why polling rather than inotify/FSEvents: no dependency, works on network +filesystems, and at watch scope (one project, a few dozen files) a scan +is one `scandir` per directory. The latency floor that matters is the +conversion's, not the detector's. + +Testability comes from injecting the clock and from splitting "poll" +(`tick`) from "wait" (`run`). Unit tests call `tick()` by hand against a +fake clock and never sleep; a watcher tested with real timing is a +flaky-test generator. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Iterable, Optional + +# One poll every quarter second. Below the debounce quiet period, so the +# debounce (not the poll rate) decides latency. +DEFAULT_POLL_INTERVAL = 0.25 + +# Claude Code appends several entries per turn, each landing within a +# second or so of the last. Without a quiet period every one of them +# would trigger its own conversion, and most of a turn would be spent +# rendering states nobody sees. +DEFAULT_QUIET_PERIOD = 0.3 + +# ...but a long unbroken stream of appends must still surface. This caps +# how long a change can sit undelivered while its neighbours keep +# resetting the quiet period. +DEFAULT_MAX_LATENCY = 2.0 + +# Files whose content feeds a render. Agent sidecars are included because +# spawn discovery reads them, and one can appear without the trunk +# transcript being touched at all (#213). +WATCHED_GLOBS = ("**/*.jsonl", "**/agent-*.meta.json") + +# The watcher writes generated output into the tree it is watching, and +# atomic writes leave a `.name.pid.tmp` file there for an instant. Seeing +# our own output as a change would make the loop feed itself forever. +IGNORED_PREFIXES = (".",) + + +FileStamp = tuple[int, int] +"""(size, mtime_ns) — the pair a change has to preserve to go unnoticed.""" + + +def scan(roots: Iterable[Path]) -> dict[Path, FileStamp]: + """Stamp every watched file under `roots`. + + Missing files are simply absent from the result, which makes deletion + a change like any other. A file that vanishes mid-scan is skipped + rather than raising: the next tick will see the settled state. + """ + stamps: dict[Path, FileStamp] = {} + for root in roots: + for pattern in WATCHED_GLOBS: + for path in root.glob(pattern): + if path.name.startswith(IGNORED_PREFIXES): + continue + try: + st = path.stat() + except OSError: + continue + stamps[path] = (st.st_size, st.st_mtime_ns) + return stamps + + +@dataclass +class WatchStats: + """Counters worth surfacing when someone asks why nothing happened.""" + + polls: int = 0 + changes_seen: int = 0 + conversions: int = 0 + errors: int = 0 + + +@dataclass +class _Pending: + paths: set[Path] = field(default_factory=set[Path]) + first_seen: float = 0.0 + last_seen: float = 0.0 + + +class WatchEngine: + """Poll `roots`, debounce, and call `on_change` with the changed paths. + + `on_change` receives the set of paths that changed since the last + delivery. It is called on the engine's own thread, one call at a + time — never concurrently with itself — so it does not need to be + reentrant, and a slow conversion simply delays the next poll rather + than piling up. + """ + + def __init__( + self, + roots: Iterable[Path], + on_change: Callable[[set[Path]], None], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + quiet_period: float = DEFAULT_QUIET_PERIOD, + max_latency: float = DEFAULT_MAX_LATENCY, + clock: Callable[[], float] = time.monotonic, + on_error: Optional[Callable[[BaseException], None]] = None, + ) -> None: + self.roots = [Path(r) for r in roots] + self.on_change = on_change + self.poll_interval = poll_interval + self.quiet_period = quiet_period + self.max_latency = max_latency + self._clock = clock + self._on_error = on_error + self.stats = WatchStats() + self._stamps: dict[Path, FileStamp] = {} + self._pending = _Pending() + self._primed = False + + # ---- state ---------------------------------------------------------- + + def prime(self) -> None: + """Adopt the current tree as the baseline, without firing. + + Called once before the loop so an existing archive isn't reported + as one enormous change on the first tick. + """ + self._stamps = scan(self.roots) + self._primed = True + + @property + def pending_paths(self) -> frozenset[Path]: + return frozenset(self._pending.paths) + + # ---- the unit of work ----------------------------------------------- + + def tick(self) -> Optional[set[Path]]: + """Poll once. Returns the delivered paths, or None if nothing fired. + + Separating "poll" from "wait" is what makes this testable: tests + drive `tick()` against a fake clock and never sleep. + """ + if not self._primed: + self.prime() + + self.stats.polls += 1 + now = self._clock() + + stamps = scan(self.roots) + changed = { + path for path, stamp in stamps.items() if self._stamps.get(path) != stamp + } + changed |= set(self._stamps) - set(stamps) # deletions + self._stamps = stamps + + if changed: + self.stats.changes_seen += len(changed) + if not self._pending.paths: + self._pending.first_seen = now + self._pending.paths |= changed + self._pending.last_seen = now + + if not self._pending.paths: + return None + + quiet_enough = now - self._pending.last_seen >= self.quiet_period + waited_long_enough = now - self._pending.first_seen >= self.max_latency + if not (quiet_enough or waited_long_enough): + return None + + delivered = self._pending.paths + self._pending = _Pending() + self.stats.conversions += 1 + try: + self.on_change(set(delivered)) + except (KeyboardInterrupt, SystemExit): + # Asking the watch to stop is not a conversion failing. The + # `watch` command runs this loop on the main thread, so a + # Ctrl+C lands wherever the thread is — on an active project + # that is usually inside the conversion rather than the sleep + # below — and swallowing it here would leave the operator + # pressing Ctrl+C until one happened to hit `stop.wait`. + raise + except BaseException as exc: # noqa: BLE001 - reported, never fatal + # One bad conversion must not end the watch. A transcript can + # be mid-write, a disk can fill, a plugin can throw; the next + # tick usually succeeds, and a dead watcher is worse than a + # skipped render. + self.stats.errors += 1 + if self._on_error is None: + raise + self._on_error(exc) + return set(delivered) + + # ---- the loop ------------------------------------------------------- + + def run(self, stop: Optional[threading.Event] = None) -> None: + """Tick until `stop` is set (or forever). + + Primes only if the caller hasn't. Priming unconditionally here + would make the baseline moment depend on when this thread got + scheduled, so a change landing between `run_in_thread` returning + and this line would be absorbed into the baseline and never + reported. Callers that care — anything that starts the watch and + then does something observable — should `prime()` first. + """ + stop = stop or threading.Event() + if not self._primed: + self.prime() + while not stop.is_set(): + self.tick() + # Event.wait doubles as the sleep so a stop is instant rather + # than up to one poll interval late. + if stop.wait(self.poll_interval): + break + + def run_in_thread(self, stop: threading.Event) -> threading.Thread: + """Start `run` on a daemon thread and return it.""" + thread = threading.Thread( + target=self.run, args=(stop,), name="claude-code-log-watch", daemon=True + ) + thread.start() + return thread diff --git a/dev-docs/application_model.md b/dev-docs/application_model.md index 7c0bcabf..16106d11 100644 --- a/dev-docs/application_model.md +++ b/dev-docs/application_model.md @@ -37,6 +37,7 @@ for user-facing operations docs see [`docs/`](../docs/). | Performance profiling | [`renderer_timings.py`](../claude_code_log/renderer_timings.py) | inlined below (§ 2.8) | | Intra-project render fan-out | [`render_pool.py`](../claude_code_log/render_pool.py) (mechanism) + [`render_dispatch.py`](../claude_code_log/render_dispatch.py) (policy) | inlined below (§ 2.10) | | Diagnosing hangs (SIGUSR1) | [`cli.py`](../claude_code_log/cli.py) `_install_stack_dump_signal` | inlined below (§ 2.11) | +| Watch mode / live page updates | [`watch.py`](../claude_code_log/watch.py), `html/templates/components/live_update.js` | inlined below (§ 2.15); design in [`work/watch-mode.md`](../work/watch-mode.md); user-facing in [`docs/live-updates.md`](../docs/live-updates.md) | | Adding a new tool renderer | [`factories/tool_factory.py`](../claude_code_log/factories/tool_factory.py), `html/tool_formatters.py` | [implementing-a-tool-renderer.md](implementing-a-tool-renderer.md) (how-to) | | Which tools have a specialized renderer or provider adapter | `TOOL_INPUT_MODELS` / `TOOL_OUTPUT_PARSERS` in [`factories/tool_factory.py`](../claude_code_log/factories/tool_factory.py), plus provider adapters | [tools-coverage.md](tools-coverage.md) (Claude and Codex status vs. upstream references) | | Plugin system (third-party message transformers) | [`plugins.py`](../claude_code_log/plugins.py), [`factories/priorities.py`](../claude_code_log/factories/priorities.py), `Renderer._dispatch_format` | [plugins.md](plugins.md) | @@ -139,7 +140,13 @@ at `~/.claude/projects/claude-code-log-cache.db` (or per-role token totals, `team_name` (added in migration 005). - Per-message: a denormalised view used by archived-session restoration (the cache holds enough to re-render even after the - source JSONL is deleted). + source JSONL is deleted). Each row's `content` is the entry as + zlib-compressed JSON, written at `CONTENT_COMPRESSION_LEVEL` (3, not + zlib's default 6: levels only diverge on large payloads, so across a + real 18,288-row archive it costs 2.6% more bytes and makes a cold + conversion 11% faster — compressing rows is the largest item in a + watch tick). Reading is level-agnostic, so rows written at any level + still load. - Per-rendered-HTML: the HTML output itself, indexed by source file mtime + depth + compact flag (migrations 002–004) — so re-runs with unchanged inputs serve the cached HTML directly. @@ -156,6 +163,32 @@ cache row, the session is reparsed. The schema-version row also invalidates the entire HTML cache when migrations bump the version, since rendered output may have changed even when source data hasn't. +**Corruption is recovered by discarding, not repairing.** A damaged +cache file is unusable and, left alone, permanently so: `apply_migration` +records only a migration that completed, so a `CREATE INDEX` that hits a +damaged page re-fails on every subsequent run. The writing +`CacheManager.__init__` therefore catches SQLite's corruption errors +(`is_corrupt_database_error` — narrowly matched on message text, since +Python funnels "malformed", "not a database", "locked" and "disk full" +into the same exception classes), deletes the `.db` with its `-wal`/`-shm` +sidecars, and rebuilds. Everything in the cache is regenerable from the +JSONL source, so the cost is one slow run. + +Two boundaries matter. A `read_only` manager — every spawned render +worker (§ 2.10) — never deletes: several run concurrently against one +file, and `_lookup_project_id` already degrades to "no cached data". +And a zero-byte file is *not* corruption; SQLite adopts one as a new +database, so an interrupted create heals without intervention. + +Note the cause is usually outside this tool. The case this was built +for was a cache truncated to 2833 pages while its own header still +claimed 14189 — traced to the virtiofs mount it lived on, not to +anything the writer did. It had also been corrupt for some time +*before* it was noticed: migration 012's index build is the first +operation that full-scans `messages`, so earlier versions read around +the damage and reported success. Assume corruption can recur on such +filesystems, and that recovery, not prevention, is the tool's job. + Paginated output carries an extra invalidation axis. `--page-size` assigns sessions to pages chronologically, and that assignment is recomputed from scratch on every run, so a page's *membership* can @@ -221,6 +254,25 @@ Current migrations: needs to recompute `projects.total_message_count` by delta (§ 2.14). Deliberately NULLable — a NULL means "unknown basis", and the refresh declines rather than compute a delta from it. +- `011_cached_file_size.sql` — adds `source_size` to cached files, so + freshness no longer misses an append that lands inside the mtime + tolerance (§ 2.15). NULL on pre-011 rows falls back to mtime alone. +- `012_message_lookup_indexes.sql` — composite indexes for the + refresh's per-tick lookups, each of which was walking every row in + the project (17–22 ms a call on a 38,706-row archive, 0.2–2.5 ms + after). Pure performance: no columns, nothing to backfill. Two + things about it are worth knowing: + - `(project_id, session_id, timestamp, file_id)` carries `timestamp` + for a caller *outside* the refresh: `load_session_entries` (the TUI, + and rendering an archived session) filters on session but orders by + timestamp, and without that column the planner takes the timestamp + index and walks the whole project to load one session. With it, the + seek and the ordering come from one index and no sort is needed. + - That same index is double-edged — it also lets the planner satisfy + a bare `session_id IS NOT NULL` as a range scan over every + session-bearing row and *prefer* that to seeking the uuids a query + asked for, so three queries had to move that predicate out of SQL + and into Python to keep it from making them slower. Recreating-tables migrations toggle `PRAGMA foreign_keys = OFF/ON` around the rebuild to avoid losing rows to cascade-deletes during the @@ -985,6 +1037,272 @@ peak — which is what this section removes, completing "no archive too big for the machine". `CLAUDE_CODE_LOG_INCREMENTAL_CACHE=0` is the kill switch. + +### 2.15 Watch mode and live page updates + +Two commands keep output current while a session is still being written: +`claude-code-log watch` (a resident loop) and `claude-code-log serve +--watch` (the same loop on a thread beside the HTTP server). See +[`work/watch-mode.md`](../work/watch-mode.md) for the design and the +measurements behind it. + +**The engine never renders.** `claude_code_log/watch.py` polls +`(size, mtime_ns)` over `**/*.jsonl` and `**/agent-*.meta.json` under +the watched roots, debounces, and calls a callback; the callback runs the +ordinary conversion. The scan is a *trigger*, not a source of truth — a +false positive costs one no-op conversion, while the conversion already +knows precisely what is stale. Two file classes are excluded because both +land in the watched tree and would make the loop feed itself: dot-prefixed +atomic-write temp files, and generated output. + +Debounce is a quiet period (`--quiet-period`, 300ms) with a max-latency +cap (`--max-latency`, 2s): a turn writes several entries in quick +succession, so without the quiet period most of a turn is spent rendering +states nobody sees; without the cap a long unbroken stream would never +surface. `tick()` (poll once) is split from `run()` (wait) and the clock +is injectable, so tests drive ticks by hand against a fake clock. + +**What makes a tick cheap** is §2.12's session-scoped path, which is +reachable here only because `ensure_fresh_cache_detailed` reports *how* +it refreshed (`CacheRefresh.NONE`/`INCREMENTAL`/`FULL`). Phase 1b's +staleness test is per-session message counts, so it refuses a FULL +refresh — which carries no guarantee that a session's content didn't +change while its count stayed the same. An INCREMENTAL refresh does carry +it: §2.14's ladder only succeeds after proving each modified file's +cached rows are an exact prefix of its current rows, i.e. a pure append. +`--combined` therefore defaults to `no` for `watch`: with a combined +output present, the session's growth makes it stale and the conversion +falls to the streaming path instead. + +**What the tick then spends its time on** was, once §2.12 was reachable, +no longer the render (12% of it) but the cache refresh. Profiled on the +803MB / 217-file reference archive appending to its largest session file +(39.7MB, 207 entries), a 1.03s tick materialised *the same entries three +times*: §2.14's refresh parsed the file from source (488ms, of which +307ms re-serialising and re-inserting every row), then the closure load +rebuilt them from those rows (129ms), then the session-scoped render +rebuilt them again (141ms). Two changes removed most of that: + +- **A per-conversion parsed-entry store** (`entry_store.py`, §2.16) + serves the refresh's list to the other two consumers: closure load + 255ms → 7ms. +- **The staleness sweep stopped scaling with the session count.** + `get_stale_sessions` ran two SQLite queries *per session* through + `is_transcript_stale`, and each of those called `get_library_version()`, + which re-parsed installed package metadata every time — 173 calls and + 35ms a tick. The version lookup is now `lru_cache`d (it cannot change + inside a process) and the two tables are read once and joined in + Python: 85ms → 4.5ms. + +Tick: **1.03s → 0.717s**. `load_transcript`'s full re-parse and full row +rewrite of the modified file (483ms) was then the remaining bulk, and +§2.16's cross-tick resumption takes it to ~35ms: 0.257s steady state in a +resident `watch`. What remained after that was the refresh's own cache +queries, every one of which was scanning the whole project — fixed by +migration 012's indexes plus one query rewrite (`get_file_states` joined +`cached_files` and filtered on `file_name`, which no index could serve; +resolving names to `file_id` first made it 15.9ms → 0.0ms). + +**A steady-state watch tick on the 803MB reference archive is 0.145s**, +from 1.03s — with rendering, which was where this began, now a rounding +error against it. + +**The served page updates itself** via +`html/templates/components/live_update.js`, active only over `http(s)` — +a `file://` page cannot fetch anything, not even its own URL, so the +poller notices and does nothing. It polls a HEAD of its own URL and +compares `Last-Modified`, `Content-Length` **and `X-Content-Revision`**. +Each covers the one before it: HTTP dates have one-second granularity, so +two updates inside the same second are otherwise invisible (the same trap +as the cache's mtime tolerance, which §2.3 solves the same way), and size +is blind to a rewrite that keeps it — a counter or a status word changing +width-for-width. `X-Content-Revision` is a digest of the served bytes, +added by `server.py` to every file response; it is deliberately not an +`ETag`, because the stock `send_head` skips its `If-Modified-Since` check +whenever a request carries an `If-None-Match` and never evaluates one, so +advertising an ETag would cost the 304s on multi-MB pages. Hashing is +cached per `(path, mtime_ns, size)` but only once the file's mtime is a +second old — a file being written right now is re-read, which is exactly +the case the header exists for. On a change the page re-fetches and +either **patches** or **swaps**. + +**Patching** applies when the new render's node-key sequence *extends* +the one on screen: the nodes whose own markup changed are replaced, new +ones are inserted, and everything else is left alone — keeping its +scroll, fold, `
` and localised timestamps because it is +literally the same DOM. Change detection is a per-node hash of the +node's own markup, taken from the **freshly parsed document, never from +the live DOM**: decoration rewrites the live tree (timestamp +localisation replaces `innerHTML`), so a hash taken from it never +matches one taken from server bytes. Nothing is emitted server-side for +this. + +A node's own markup is its `:scope > .message` *plus* every +non-`.message-node` child of its `.children` — a fork point renders +inside `.children` so folding hides it with the subtree, and on a +fork-only slot it is the node's only content and carries its id. + +**Swapping** (replace `#transcript` wholesale) is the fallback for +everything else: renumbered or reordered ids, deletions, a node with no +key, more than 40 changed nodes, and the first update of a session, +which is where the hashes are first taken. It is also what every update +did before patching existed. Measured across three real sessions +replayed through the renderer, 45 of 47 growth steps are pure +extensions; the other 2 are out-of-order arrivals that renumber the +positional `msg-d-N` ids and take the swap. + +A swap destroys anything that decorated the old markup, so components +register with `window.claudeLogOnRehydrate(fn)` (defined at the top of +``, before every component include) and the poller calls +`window.claudeLogRehydrate(root)` — over the whole container after a +swap, and over *only the elements it placed* after a patch. Passing the +containing node instead is a live trap: the session header's fold bar +counts its descendants, so it is replaced on every append, and its node +is the entire page. Currently registered: timestamp localisation (scoped +to a subtree) and the timeline rebuild. Delegated listeners and +everything bound to the toolbar or floating buttons survive untouched +and must **not** register. On the swap path only, fold state and +`
` are captured and restored by the poller, keyed `data-uuid` → +`data-session-id` → positional `id` — session headers and fork points +carry no uuid, and on a single-session page the header is the only +foldable node. + +Measured: ~1s from append to visible. On a 2.4MB / 896-card page, a swap +touches 897 cards, re-localises 896 timestamps and blocks the main +thread 107ms; a patch of the same append touches **3 cards, 2 +timestamps, 61ms**. The remaining 61ms is fetch plus `DOMParser` of the +whole page, which only a server-shipped delta would remove. Idle polls +cost ~1ms. + +**Timestamp localisation drains against the idle deadline**, not in +fixed batches. `timezone_converter.js` used 25 elements per +`requestIdleCallback`, which made the *callback count* the cost: a 4MB +page's 1,180 timestamps are 8ms of work but took 766ms of wall clock, +and a 27MB page's 5,010 took 3.3s — in document order, so a live +update's new cards were localised last and the fade-in played over a raw +ISO string. Draining while `timeRemaining()` allows (checked per 32 +elements, `{timeout: 200}`, first slice on the current task under a 24ms +budget) takes those to **13ms and 35ms**. + + +### 2.16 Parsed-entry store + +`entry_store.py`. A conversion that refreshes the cache incrementally +(§2.14) parses each modified file from source, and then loads those same +entries back out of the rows it has just written — once for the closure +load and once for the session-scoped render (§2.12). Each rebuild is a +`zlib.decompress` + `json.loads` + Pydantic validation pass over the +whole file, so a one-line append to a 39.7MB session paid it twice at +~130ms each. The store holds the list the first pass produced and serves +it to the other two. + +Four properties keep the invalidation surface at zero, and they are the +reason it is a parameter rather than a memo: + +- **One store per conversion, threaded explicitly.** Never a global, and + never hung off `CacheManager` — the TUI keeps one of those across many + conversions. +- **Only `_incremental_cache_refresh` fills it**, with the files it just + parsed, and `convert_jsonl_to` drops it after Phase 1b. A cold or full + conversion therefore stores nothing, and the streaming path (§2.13) is + deliberately never handed one: its bounded residency depends on + dropping each page's entries before the next page loads, which a store + spanning pages would defeat. What it holds is bounded by *what + changed*, not by the archive. +- **Hits are verified against the file.** `get` re-stats and compares + `(size, mtime_ns)` against the stamp captured *before* the parse. A + stamp taken before the parse can only be older than the entries + describe, so a file that grew mid-parse declines to the cache rather + than serving a list its stamp misdescribes. +- **Handouts are deep copies**, because the pipeline mutates entries in + place: `_integrate_agent_entries` appends `#agent-{id}` to `sessionId` + and is *not* idempotent, and dedup re-parents around dropped copies. + Today each consumer gets freshly deserialised objects; serving the same + objects twice would render `…#agent-X#agent-X`. The copy stays cheap + because the bulk of an entry is immutable strings, which `deepcopy` + shares rather than copies — 2.0ms and 0.83MB for the 207-entry, 39.7MB + session, against 123ms to rebuild it from the cache. + +Held to byte-identity with the store disabled, over repeated appends on a +fixture whose 170 sidechain entries exercise the mutation above +(`test/test_entry_store.py`), with the copy isolation pinned by a test +that fails with exactly the doubled suffix when the copy is removed. +`CLAUDE_CODE_LOG_ENTRY_STORE=0` is the kill switch; a per-file memory +valve at `put` time declines to hold a file when available memory is +under 6x its bytes, and `=1` overrides that valve. + +**Owned across ticks, a store does more.** `watch` keeps one for the life +of the loop and passes it to every conversion (`convert_jsonl_to` takes +an optional store; a caller-owned one outlives the call, an internally +made one doesn't). That turns the store from a within-tick cache into a +*resumption* point, via a second, prefix-pinned mode: + +- **The parse resumes.** `put_prefix` records the byte offset the parse + consumed plus a BLAKE2b digest of the bytes below it, and the next tick + hashes that prefix and reads only the tail. The digest is a *stronger* + check than the row-fingerprint prefix comparison it saves — identical + bytes imply identical rows — and it costs 32 ms over 39.7 MB against + 143 ms to re-parse them. A mismatch (rewound session, replayed history) + drops the prefix and re-reads from the top. Byte reading replaces text + reading only when a store is present; every other caller keeps the + identical text path, and the held entries are the *pre*-post-processing + parse products, since the whole-file passes (sidecar linking, + prompt-hash linking, agent splicing — 0.1 ms together) must re-run over + the concatenated list. The cut is on **entries as well as bytes**: a + final line whose newline hasn't landed — a torn append, or a file + simply stored without a trailing one — parses and is returned like any + other, but neither its bytes nor its entry is held, so the next tick + re-reads that line instead of handing its entry back a second time. + Prefixes are charged against the same budget as whole-file entries and + evicted alongside them, since a `watch` store never goes out of scope. +- **The cache write appends.** `CacheManager.extend_cached_entries` + inserts only the new rows instead of `save_cached_entries`' delete-and- + rewrite, which re-runs `json.dumps` + `zlib.compress` over every entry + (310 ms of a tick, for one added line). + +The write needs a proof the read doesn't, and it is the subtle part: + +> **A file being append-only does not make its rows append-only.** A +> trunk's cached rows carry its subagents' transcripts, spliced in at +> their anchors, so a subagent still running — the normal case under +> `watch` — grows a block in the *middle* of the row sequence while the +> trunk file only gained lines at the end. + +So `_appended_rows` offers rows only when the list is provably just the +file's own parsed lines: resumed from a verified prefix, no agent +references, no sidecars, nothing spliced, and no length change from the +whole-file passes. That covers 136 of the reference archive's 185 trunk +files; the 26% that reference subagents take the unchanged full rewrite. +Beneath it, `extend_cached_entries` independently refuses when the table +no longer holds the row count the caller thinks it wrote — the guard +against another process having rewritten them. With the gates removed the +caller offers a *wrong 96-entry slice* and that check catches it, so the +tests assert on the offer rather than only on the write. That count and +the insert run inside one `BEGIN IMMEDIATE`, because Python's sqlite3 +opens a transaction on the first write and not on a `SELECT`: without +the explicit lock a second writer sharing the cache could append in +between, and the check would let both appends land. + +Both cache writers are stamped with the source file's `(mtime, size)` as +captured **before** the parse — the same discipline as the sidecar +fingerprint beside it, and as the entry store's own stamp. A session +appended to mid-read would otherwise be recorded at the size it reached +while the rows are only what was parsed, marking an incomplete cache +current; stamped with what was parsed, the growth invalidates instead. + +Steady-state tick on the 803MB reference archive: **0.717s → 0.257s** +(cumulatively 1.03s → 0.26s). Equivalence is held at three levels — parse +output against the text path (162 fixture files whole-file, 90 resumed), +**cache DB state** against a full-rewrite run over 6 ticks with 3 files +growing, and rendered HTML throughout. DB state is the bar for the same +reason as §2.14: the first bug of this kind is invisible in the rendered +bytes. + +Resumption only helps a resident loop — a one-shot run, the TUI, and +every tick-one still parse whole. Persisting `(prefix_len, prefix_hash)` +in `cached_files` would extend it across processes; that migration was +considered and not needed for the case that motivated it. + --- ## 3. Data lifecycle diff --git a/docs/live-updates.md b/docs/live-updates.md new file mode 100644 index 00000000..849f4e1e --- /dev/null +++ b/docs/live-updates.md @@ -0,0 +1,80 @@ +# Watching a session as it runs + +Two commands keep generated output current while Claude Code is still +writing to a transcript. + +## Markdown (or HTML) on disk + +```bash +claude-code-log watch +``` + +With no arguments this watches the project for the current directory — +which is what you want when running it alongside a live session. It +converts once up front, then re-converts whenever a transcript changes. +Anything that reloads files picks the changes up: an editor, Obsidian, a +browser refresh. + +For an Obsidian vault: + +```bash +claude-code-log watch -f md -o ~/vault/claude +``` + +A tick regenerates only the session that changed, so a vault indexer sees +one file move, not the whole projection. + +## The served page, updating itself + +```bash +claude-code-log serve --watch +``` + +Open a session page and it grows as messages arrive — roughly a second +behind the CLI, without a reload. Your scroll position, folded sections +and open disclosures all survive, and new messages fade in. + +A **follow** button (⏬) joins the buttons down the right-hand edge once +the page is being served. Click it to keep the page pinned to the newest +message; while you are not following, it shows a count of how many have +arrived since you last looked. + +This works only over the server. A page opened from `file://` cannot +fetch anything at all — not even itself — so it stays static, exactly as +before. Nothing about the generated HTML changes. + +## What "real-time" can and cannot mean here + +**It cannot show tokens arriving.** Claude Code writes a transcript entry +exactly once, when the message is complete — it never rewrites a line, so +there is no partial message on disk to display. The finest granularity +available anywhere in this tool is *one whole message*, appearing +promptly after it finishes. The fade-in is there to make that arrival +legible, not to imitate streaming. + +Everything else follows from that. A message typically appears within a +second or so of completing: the watcher waits briefly for the burst of +entries in a turn to settle (a turn writes several), converts, and the +page notices on its next poll. + +## Tuning + +| Flag | Default | What it does | +|---|---|---| +| `--interval` | `0.25` | Seconds between filesystem polls | +| `--quiet-period` | `0.3` | Wait for changes to settle before converting | +| `--max-latency` | `2.0` | Convert anyway after this long, so a long unbroken stream still surfaces | +| `--all-projects` | off | Watch the whole archive instead of one project | +| `--combined` | `no` | `no` keeps ticks cheap: only the changed session is regenerated | + +Raise `--quiet-period` if conversions feel too frequent on a large +project; lower `--interval` if you want changes noticed sooner. + +## Cost + +A tick re-converts only what changed. On a 319 MB, 217-file archive a +tick is about a second; on a small project it is hundredths of a second. +While nothing is changing, the browser's poll is a `HEAD` for the page's +own metadata — about a millisecond, and no body — and the watcher is a +`stat` of the project directory. The page is re-fetched in full only +once that metadata says it moved. diff --git a/foldyard.toml b/foldyard.toml index e2850245..bdc44f07 100644 --- a/foldyard.toml +++ b/foldyard.toml @@ -119,11 +119,42 @@ or a clone of the project, which would describe a different version: the `foldya SKILL in .claude/skills/ (the model — posture, egress, why a change didn't take), and `fy docs` for the manual (`fy docs` lists topics; `fy docs modes`, `fy docs adr-0007`). """ +[claude.settings] +showThinkingSummaries = true # OpenAI Codex CLI (`fy codex`). Declaring [codex] installs it on box-up + mounts ~/.codex. # Needs node in the box. Keyless like Claude — the proxy injects host-side. [codex] keyless = "chatgpt" # your ChatGPT subscription: the minter refreshes ~/.codex/auth.json +system_prompt = """ +You are root in this project's foldyard dev box, started by `fy codex` with permission +prompts skipped. Orientation, so you don't have to re-derive it: + +`fy` works in here for the read and box verbs: `fy verify`, `fy box shell|ps|down`, and +`fy mode` to SEE the credential posture — posture is SET from the host, not from in here, +and so are egress grants. A second checkout beside this one is `fy worktree add `, +then `WORKTREE= fy `. + +Git: read and commit locally, but you CANNOT push — no credential in here reaches the +origin, by design. Push from the host. + +Egress that fails is the wall doing its job, not a bug to route around. Report the +blocked hostname and ask the operator to `fy allow add `; the grant store +lives outside this box, out of your reach. + +You may not be alone: several sessions can attach to this one box and checkout. You run +as root, so files you create land root-owned. + +Depth on demand, both version-matched to the foldyard running here — never a web search +or a clone of the project, which would describe a different version: the `foldyard` +SKILL in .claude/skills/ (the model — posture, egress, why a change didn't take), and +`fy docs` for the manual (`fy docs` lists topics; `fy docs modes`, `fy docs adr-0007`). +""" +[codex.config] +hide_agent_reasoning = false +show_raw_agent_reasoning = true +model_reasoning_summary = "auto" +tui = { raw_output_mode = false } # ── The rest — uncomment as you grow (step 3) ──────────────────────────────────────────── # Host-published ports, keyed by the env var your compose file reads. In-container ports diff --git a/mkdocs.yml b/mkdocs.yml index eed95601..decde05e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - Example output: example.md - User Guide: - Searching the whole archive: archive-search.md + - Watching a session as it runs: live-updates.md - Restoring archived sessions: restoring-archived-sessions.md - Reference: - CLI: reference/cli.md diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 00ad3453..c9ec3d6e 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -34,6 +34,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -374,17 +379,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -2240,6 +2256,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -4168,6 +4271,34 @@ + +

Async Agents Fixture

@@ -4692,7 +4823,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -4821,6 +4996,7 @@ +
@@ -5070,13 +5246,17 @@
- + + + @@ -5100,8 +5280,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -5134,83 +5320,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -5797,24 +6578,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -5940,6 +6727,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -7104,6 +7927,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -7444,17 +8272,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -9310,6 +10149,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -11238,6 +12164,34 @@ + +

Async Agents Fixture (LOW)

@@ -11762,7 +12716,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -11891,6 +12889,7 @@ +
@@ -12035,13 +13034,17 @@
- + + + @@ -12065,8 +13068,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -12099,83 +13108,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -12762,24 +14366,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -12905,6 +14515,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -14069,6 +15715,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -14409,17 +16060,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -16517,8 +18179,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -16551,83 +18219,110 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } })(); }); @@ -16670,6 +18365,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -17010,17 +18710,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -18876,6 +20587,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -20804,6 +22602,34 @@ + +

Test Session

@@ -21328,7 +23154,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -21457,6 +23327,7 @@ +
@@ -21824,13 +23695,17 @@
- + + + @@ -21854,8 +23729,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -21888,83 +23769,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -22551,24 +25027,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -22694,6 +25176,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -23858,6 +26376,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -24198,17 +26721,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -26064,6 +28598,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -27992,6 +30613,34 @@ + +

Teammates Fixture

@@ -28516,7 +31165,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -28645,6 +31338,7 @@ +
@@ -29292,13 +31986,17 @@
- + + + @@ -29322,8 +32020,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -29356,83 +32060,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -30019,24 +33318,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -30162,6 +33467,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -31326,6 +34667,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -31666,17 +35012,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -33532,6 +36889,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -35460,6 +38904,34 @@ + +

Edge Cases

@@ -35984,7 +39456,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -36113,6 +39629,7 @@ +
@@ -36711,9 +40228,13 @@
- + + + @@ -36737,8 +40258,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -36771,83 +40298,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -37434,24 +41556,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -37577,6 +41705,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -38741,6 +42905,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -39081,17 +43250,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -40947,6 +45127,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -42875,6 +47142,34 @@ + +

Claude Transcripts - test_multi_session_html0

@@ -43399,7 +47694,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -43589,6 +47928,7 @@ +
@@ -44071,9 +48411,13 @@
- + + + @@ -44097,8 +48441,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -44131,83 +48481,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -44794,24 +49739,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -44937,6 +49888,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -46101,6 +51088,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -46441,17 +51433,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -48307,6 +53310,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -50235,6 +55325,34 @@ + +

Test Transcript

@@ -50759,7 +55877,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -50888,6 +56050,7 @@ +
@@ -51255,13 +56418,17 @@
- + + + @@ -51285,8 +56452,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -51319,83 +56492,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -51982,24 +57750,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -52125,6 +57899,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -53289,6 +59099,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -53629,17 +59444,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -55495,6 +61321,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -57423,6 +63336,34 @@ + +

Claude Transcripts - test_steering_chronological_or0

@@ -57947,7 +63888,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -58076,6 +64061,7 @@ +
@@ -58248,13 +64234,17 @@
- + + + @@ -58278,8 +64268,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -58312,83 +64308,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -58975,24 +65566,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -59118,6 +65715,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and @@ -60282,6 +66915,11 @@ --session-bg-dimmed: #e8f4fd66; --ide-notification-dimmed: #d2d6d966; + /* Where the resume-session button sits in the floating stack. Named + * because its toast is positioned beside it and must follow it when + * the stack is reordered. */ + --resume-btn-bottom: 380px; + /* Fully transparent variants (88 = ~53% opacity) */ --highlight-semi: #e3f2fd88; --error-semi: #ffebee88; @@ -60622,17 +67260,28 @@ /* Resume-session button (single-session pages only): copies the * `pushd … && claude -r ` command to the clipboard. */ .resume-session.floating-btn { - bottom: 380px; + bottom: var(--resume-btn-bottom); } /* Transient confirmation shown after the resume command is copied. * Opaque background (not the `…-dimmed` variant the buttons use) so - * the transcript text underneath doesn't bleed through the message. */ + * the transcript text underneath doesn't bleed through the message. + * + * Sits to the *left* of its own button, centred on it: stacking it above + * the buttons meant every new one added to the stack pushed the toast up + * too, over buttons it has nothing to do with. Anchoring it beside the + * button it belongs to keeps that a one-number change + * (`--resume-btn-bottom`), and the column to the left is empty. + * + * The centring is height-agnostic — bottom edge at the button's middle, + * then shifted down by half the toast's own height — because the message + * wraps to one or two lines depending on the viewport. */ .resume-toast { position: fixed; - right: 20px; - bottom: 440px; - max-width: 320px; + right: calc(20px + 50px + 12px); /* button right + width + gap */ + bottom: calc(var(--resume-btn-bottom) + 25px); + transform: translateY(50%); + max-width: min(320px, calc(100vw - 120px)); padding: 8px 12px; background-color: #e8f4fd; color: var(--text-muted); @@ -62488,6 +69137,93 @@ width: 1em; vertical-align: -0.125em; } + + /* Live update (serve --watch): a message that arrived since the last + poll. The fade is the whole "streaming" illusion — transcripts record + one complete message at a time, never partial tokens, so a card can + only ever appear whole. Announcing that arrival is the most honest + thing the page can do. */ + @keyframes live-new-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } + } + + .message.live-new { + animation: live-new-in 320ms ease-out; + } + + @media (prefers-reduced-motion: reduce) { + .message.live-new { + animation: none; + } + } + + /* Follow toggle (`serve --watch`): a member of the floating stack, at the + top of it. It is rendered on every transcript page but stays hidden + until the poller actually starts — a `file://` page cannot poll at all + (see live_update.js), and a visible control there would promise + something it can never do. + + It was previously built in JS as a wide `.live-update-pill`, which set + `left: 20px` on top of `.floating-btn`'s `right: 20px`: with `width: + auto`, a fixed box with both insets stretches, and the "pill" was + measured at 1360px across a 1400px viewport. */ + .follow-updates.floating-btn { + bottom: 440px; + display: none; + } + + .follow-updates.floating-btn.live-active { + display: flex; + } + + /* Opaque, as `.debug-toggle.active` is. The old pill signalled + "following" with `--highlight-light`, which computes to + rgba(227,242,253,0.333) against an idle `--session-bg-dimmed` of + rgba(232,244,253,0.4) — i.e. the engaged state rendered *fainter* + than the disengaged one. */ + .follow-updates.floating-btn.following { + background-color: #d4e8f7; + color: #333; + } + + /* Unseen-message count, as a corner badge so the button keeps the round + 50px footprint the rest of the stack has. */ + .follow-updates[data-unseen]:not([data-unseen="0"])::after { + content: attr(data-unseen); + position: absolute; + top: 0; + right: 0; + box-sizing: border-box; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 9px; + background-color: #d64545; + color: #fff; + font-family: 'SFMono-Regular', Consolas, monospace; + font-size: 10px; + font-weight: 600; + line-height: 17px; + } + + /* Room under the last card while following, so a newly-arrived message + lands clear of the viewport edge instead of flush against it. + Measured: with neither, the gap is 0px — the last card's bottom is + exactly the viewport bottom. The padding is what supplies the + scrollable space; `scrollToEnd` then scrolls the document to its end + rather than aligning the card, and the gap becomes this much. Kept + small deliberately — enough to read as breathing room, not enough to + leave the newest message stranded above a band of empty page. */ + body.live-following { + padding-bottom: 20px; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -64416,6 +71152,34 @@ + +

System Reminders

@@ -64940,7 +71704,51 @@ }); } + // Rebuild from the current DOM after a live update swapped the + // transcript. The timeline reads message types out of CSS classes, + // so new cards are invisible to it until this runs. + // + // A timeline that was never opened needs nothing: it is built + // lazily, and will read the new DOM when it is. + function rebuildTimeline() { + if (!timeline || !itemsDataSet) return; + const { timelineItems, timelineGroups } = buildTimelineData(); + items = timelineItems; + groups = timelineGroups; + // Replace the contents rather than the DataSet so the user's + // current zoom/pan window survives the update. + itemsDataSet.clear(); + itemsDataSet.add(items); + timeline.setGroups(new vis.DataSet(groups)); + applyFilters(); + applySearchFilter(); + } + + // The rehydrate contract passes a subtree, and calls the hooks once + // per changed element — which is what the other two hooks want, + // since they only touch what they are given. This one is the + // exception: it reads the whole document, so a patch touching a + // dozen cards would mean a dozen whole-page rebuilds, per poll, of + // exactly the work the patch path exists to avoid. Collapse a + // burst into one rebuild after the current task instead. + let rebuildScheduled = false; + function scheduleRebuild() { + if (!timeline || !itemsDataSet) return; // never opened: nothing to do + if (rebuildScheduled) return; + rebuildScheduled = true; + const run = function () { + rebuildScheduled = false; + rebuildTimeline(); + }; + if (window.queueMicrotask) window.queueMicrotask(run); + else setTimeout(run, 0); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(scheduleRebuild); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -65069,6 +71877,7 @@ +
@@ -65190,13 +71999,17 @@
- + + + @@ -65220,8 +72033,14 @@ // Convert timestamps to user's timezone // This function can be called directly or will auto-run on DOMContentLoaded if included standalone (function() { - function convertTimestampsToLocalTimezone() { - const timestampElements = Array.from(document.querySelectorAll('.timestamp[data-timestamp]')); + // `root` scopes the work to a subtree. A live update replaces only the + // transcript container, and its new cards carry raw ISO timestamps; + // re-converting the whole document would redo every card that is + // already localised (and `innerHTML` has been rewritten on those, so + // they no longer match `[data-timestamp]`'s original text anyway). + function convertTimestampsToLocalTimezone(root) { + const scope = root || document; + const timestampElements = Array.from(scope.querySelectorAll('.timestamp[data-timestamp]')); if (timestampElements.length === 0) return; @@ -65254,83 +72073,678 @@ timeZone: userTimezone }); - // Process timestamps in batches to keep page responsive - const batchSize = 25; - const scheduleWork = window.requestIdleCallback || function(cb) { setTimeout(cb, 16); }; - - function processBatch(startIndex) { - const endIndex = Math.min(startIndex + batchSize, timestampElements.length); - - for (let i = startIndex; i < endIndex; i++) { - const element = timestampElements[i]; - const rawTimestamp = element.getAttribute('data-timestamp'); - const rawTimestampEnd = element.getAttribute('data-timestamp-end'); - const duration = element.getAttribute('data-duration'); - - if (!rawTimestamp) continue; - - try { - // Parse the ISO timestamp - const date = new Date(rawTimestamp); - if (isNaN(date.getTime())) continue; // Invalid date - - const localTime = localFormatter.format(date).replace(/, /g, ' '); - const utcTime = utcFormatter.format(date).replace(/, /g, ' '); - - // Get timezone abbreviation (reuse formatter) - const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; - - // Handle time ranges (earliest to latest) - if (rawTimestampEnd) { - const dateEnd = new Date(rawTimestampEnd); - if (!isNaN(dateEnd.getTime())) { - const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); - const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); - - // Update the element with range - if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { - element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } else { - // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; - } - } - } else { - // Single timestamp - if (localTime !== utcTime) { - element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = duration ? duration : 'UTC: ' + utcTime; + function localizeOne(element) { + const rawTimestamp = element.getAttribute('data-timestamp'); + const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); + + if (!rawTimestamp) return; + + try { + // Parse the ISO timestamp + const date = new Date(rawTimestamp); + if (isNaN(date.getTime())) return; // Invalid date + + const localTime = localFormatter.format(date).replace(/, /g, ' '); + const utcTime = utcFormatter.format(date).replace(/, /g, ' '); + + // Get timezone abbreviation (reuse formatter) + const timezoneName = tzNameFormatter.formatToParts(date).find(part => part.type === 'timeZoneName')?.value || userTimezone; + + // Handle time ranges (earliest to latest) + if (rawTimestampEnd) { + const dateEnd = new Date(rawTimestampEnd); + if (!isNaN(dateEnd.getTime())) { + const localTimeEnd = localFormatter.format(dateEnd).replace(/, /g, ' '); + const utcTimeEnd = utcFormatter.format(dateEnd).replace(/, /g, ' '); + + // Update the element with range + if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { + element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC - element.innerHTML = utcTime + ' (UTC)'; - element.title = duration ? duration : 'UTC: ' + utcTime; + element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } } - - } catch (error) { - // If conversion fails, leave the original timestamp - console.warn('Failed to convert timestamp:', rawTimestamp, error); + } else { + // Single timestamp + if (localTime !== utcTime) { + element.innerHTML = localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } else { + // If they're the same (user is in UTC), just show UTC + element.innerHTML = utcTime + ' (UTC)'; + element.title = duration ? duration : 'UTC: ' + utcTime; + } } - } - // Schedule next batch if there are more timestamps - if (endIndex < timestampElements.length) { - scheduleWork(function() { - processBatch(endIndex); - }); + } catch (error) { + // If conversion fails, leave the original timestamp + console.warn('Failed to convert timestamp:', rawTimestamp, error); } } - // Start processing the first batch - scheduleWork(function() { - processBatch(0); + // Drain the queue against the idle deadline rather than a fixed batch + // size. The work itself is cheap — a whole 4MB page's 1,180 timestamps + // cost ~8ms of CPU — so a fixed 25-per-callback made the *callback + // count* the cost: 48 idle turns for that page, measured at 766ms of + // wall clock, and 3.3s for a 27MB one. Worse, the queue is in document + // order, so cards appended by a live update localise last and the + // fade-in plays over a raw ISO string. + // + // Draining on the deadline instead takes those to 13ms and 35ms — + // within a few ms of a straight synchronous pass, while still handing + // the main thread back whenever the browser wants it. + const scheduleWork = window.requestIdleCallback + ? function(cb) { window.requestIdleCallback(cb, { timeout: 200 }); } + // No requestIdleCallback (Safari < 16): a macrotask still yields + // between slices, and the synthetic deadline keeps them bounded. + : function(cb) { setTimeout(function() { cb({ timeRemaining: function() { return 8; }, didTimeout: false }); }, 0); }; + + let cursor = 0; + function drain(deadline) { + // timeRemaining() is not free, so check it per chunk rather than + // per element; 32 conversions cost well under a millisecond. + const chunk = 32; + while (cursor < timestampElements.length) { + if (!deadline.didTimeout && deadline.timeRemaining() <= 1) break; + const end = Math.min(cursor + chunk, timestampElements.length); + for (; cursor < end; cursor++) localizeOne(timestampElements[cursor]); + } + if (cursor < timestampElements.length) scheduleWork(drain); + } + + // The first slice runs on the current task, so a live update's new + // cards are localised before the browser paints them rather than an + // idle turn later. It gets a real budget rather than an unbounded + // one, so the largest pages yield instead of blocking on load. + const firstSliceEnds = performance.now() + 24; + drain({ + timeRemaining: function() { return Math.max(0, firstSliceEnds - performance.now()); }, + didTimeout: false }); } // Execute immediately - assumes this is included within a DOMContentLoaded handler convertTimestampsToLocalTimezone(); + + // Exposed so a live update can re-run it over freshly swapped-in + // markup. Registered with the rehydrate contract when one is present + // (transcript pages); harmless on pages without it (the index). + window.claudeLogLocalizeTimestamps = convertTimestampsToLocalTimezone; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(convertTimestampsToLocalTimezone); + } + })(); + + // Live update (no-op unless served over http -- see the file) + // Keep this page current while the session it shows is still running. + // + // Only active over http(s): a page loaded from file:// cannot fetch + // anything at all — not itself, not a sibling, not even a HEAD (verified + // in Chromium; script tags are the only channel a file:// page has). So + // this is a `serve` feature, and the generated HTML stays exactly as + // useful from file:// as it was before. + // + // The shape, and why: + // + // * The server never renders. `serve --watch` re-runs the ordinary + // conversion and the files on disk stay canonical, so this page just + // re-fetches its own URL. A HEAD for the page's own metadata makes + // the idle case free (~1ms, no body) and needs no endpoint of its + // own; the full GET follows only when that metadata moved. + // * We never reload. A reload loses fold state and re-parses a + // document that can reach tens of MB. + // * When the new render extends the one on screen, we patch the nodes + // that changed and leave the rest alone (see "patching" below). When + // it does not, we replace #transcript wholesale — which keeps scroll + // position for free, because everything above the viewport is + // untouched, and is the fallback for every shape the patch declines: + // entries that do not belong at the end (a transcript's appends are + // not in timestamp order) and the `msg-d-N` renumbering that follows, + // which would break the fork/tool-pair links already on the page. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A metadata HEAD costs ~1ms and carries no body while nothing + // changes, so the interval is set by how fresh the page should feel, + // not by load. + const POLL_MS = 1000; + const container = () => document.getElementById('transcript'); + if (!container()) return; + + // The page's identity as of the last poll. `Last-Modified` alone is + // not enough: HTTP dates have **one-second granularity**, so two + // conversions inside the same second produce an identical header and + // the second update is invisible. Observed directly — a third append + // never arrived until length was added to the comparison. + // + // (This is the same trap as the cache's mtime tolerance, one layer up, + // and it has the same fix: compare the size too. `Content-Length` is + // exact, free, and already on the HEAD response.) + // + // Size closes most of that gap but not all of it: a re-render can + // change content without changing length — a counter, a status word + // or a timestamp keeping its width — and inside one `Last-Modified` + // second such a rewrite is invisible to both headers. So `serve` also + // sends `X-Content-Revision`, a digest of the bytes themselves + // (server.py), and it joins the comparison. `ETag` stays in the list + // for any other server that sets one; ours deliberately does not. + let lastStamp = null; + let stopped = false; + let following = false; + + // ---- state that a swap would otherwise destroy ----------------------- + + // A key that survives a re-render, for every card that can hold state. + // + // `data-uuid` is stable but is NOT unique per card (one entry can + // render as sibling text + tool_use cards), so it is paired with its + // ordinal among cards sharing it. Two kinds of card have no uuid at + // all and are exactly the ones that fold: **session headers** (keyed + // by `data-session-id`) and fork points. Missing the session header + // is not a corner case — on a single-session page it is the only + // foldable node there is. + // + // The `id` (`msg-d-N`) is unique but positional, so it is the last + // resort rather than the first choice: it is correct for appends at + // the tail and wrong the moment something lands earlier in the tree. + function stableKeys(root) { + const seen = new Map(); + const keys = new Map(); + root.querySelectorAll('.message, .fork-point').forEach(el => { + const uuid = el.getAttribute('data-uuid'); + const session = el.getAttribute('data-session-id'); + let base; + if (uuid) base = 'u:' + uuid; + else if (session) base = 's:' + session; + else base = 'p:' + (el.id || 'anon'); + const n = seen.get(base) || 0; + seen.set(base, n + 1); + keys.set(el, base + '#' + n); + }); + return keys; + } + + // The children container a card's fold bar controls: a *sibling* of + // the card inside the shared `.message-node`, not a descendant. + function childrenOf(el) { + const node = el.closest('.message-node'); + return node ? node.querySelector(':scope > .children') : null; + } + + function captureState(root) { + const folds = new Map(); + const keys = stableKeys(root); + keys.forEach((key, el) => { + const children = childrenOf(el); + if (children) folds.set(key, children.style.display); + }); + const details = new Map(); + root.querySelectorAll('details').forEach((d, i) => details.set(i, d.open)); + return { folds, details, keys: new Set(keys.values()) }; + } + + function restoreState(root, state) { + stableKeys(root).forEach((key, el) => { + if (!state.folds.has(key)) return; + const children = childrenOf(el); + if (!children) return; + children.style.display = state.folds.get(key); + // Keep the fold bar's arrows honest about what it is showing. + const bar = el.querySelector(':scope > .fold-bar'); + if (!bar) return; + const folded = children.style.display === 'none'; + bar.querySelectorAll('.fold-bar-section').forEach(section => { + section.classList.toggle('folded', folded); + }); + }); + // `
` has no stable identity of its own; index order is the + // best available and is exact for the common case (appends at the + // tail leave every earlier disclosure at the same index). + const all = root.querySelectorAll('details'); + state.details.forEach((open, i) => { + if (all[i]) all[i].open = open; + }); + } + + function markNew(root, previousKeys) { + let count = 0; + stableKeys(root).forEach((key, el) => { + if (previousKeys.has(key) || !el.classList.contains('message')) return; + el.classList.add('live-new'); + count += 1; + }); + return count; + } + + // ---- patching, for the case that is almost always the real one ------- + // + // Replacing #transcript wholesale costs work proportional to the *page* + // for a change proportional to the *append*: on a 4MB session page, + // ~97ms of DOM work plus re-localising all 1,180 timestamps, to show two + // new cards. It also reconstructs fold and disclosure state from a + // heuristic key rather than keeping the nodes that already hold it. + // + // So when the new render is a pure *extension* of the one on screen — + // the same cards, in the same order, followed by new ones — we patch + // instead: replace the handful of cards whose own markup actually + // changed, insert the new ones, and leave every other node untouched. + // Measured on the same page: 2 cards inserted, 2 timestamps localised. + // + // Anything else falls back to the swap, which is unchanged and stays the + // definition of correct. Replaying three real sessions through the + // renderer, 45 of 47 growth steps were pure extensions; the other 2 were + // out-of-order arrivals that renumbered the positional `msg-d-N` ids, so + // they take the swap. That ratio is why the fallback is acceptable and + // why patching the general case is not worth its complexity yet. + + // The hashes the cards on screen were rendered from, keyed by card id. + // Taken from pristine parsed markup, never from the live DOM: by update + // time the live tree has been rewritten by decoration (timestamp + // localisation replaces innerHTML), so a hash taken from it would never + // match one taken from the server's bytes. + let cardHashes = null; + + // FNV-1a. A collision would show one stale card, not break the page, and + // needs a *changed* card to land on its own previous value: 1 in 2^32. + function hashOf(s) { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + } + return h.toString(36); + } + + // What belongs to a node itself rather than to its descendants. That is + // the card — but not only the card: a fork point renders as a box inside + // `.children` so that folding hides it with the subtree, and on a + // fork-only slot that box is the node's *only* content and carries its + // id. Both kinds also hold positional `#msg-d-N` branch links, so + // treating them as part of the node is what keeps a changed fork point + // from being missed. + // + // The template always emits them after the child nodes, which is what + // lets `applyOwn` below put replacements back by appending. + function ownParts(node) { + const parts = []; + const card = node.querySelector(':scope > .message'); + if (card) parts.push(card); + const kids = node.querySelector(':scope > .children'); + if (kids) { + Array.from(kids.children).forEach(el => { + if (!el.classList.contains('message-node')) parts.push(el); + }); + } + return parts; + } + + // A node's identity: its card's id, or — for a fork-only slot, which has + // no card — the fork-point box's. + function nodeKey(node) { + const card = node.querySelector(':scope > .message'); + if (card && card.id) return card.id; + const fork = node.querySelector(':scope > .children > .fork-point[id]'); + return fork ? fork.id : null; + } + + // Node keys in document order, plus a hash of each node's own markup. + // A node with no key at all makes the whole update unpatchable, because + // the extension test below is only meaningful over a complete sequence. + // `withHashes` is off for the live tree: only its key sequence is + // wanted there, and hashing it would serialise the whole page — the + // very cost this is here to avoid. Its hashes would be meaningless + // anyway, having been taken after decoration rewrote the markup. + function scanTree(root, withHashes) { + const ids = []; + const hashes = new Map(); + let ok = true; + root.querySelectorAll('.message-node').forEach(node => { + const key = nodeKey(node); + if (!key) { ok = false; return; } + ids.push(key); + if (withHashes) { + hashes.set(key, hashOf(ownParts(node).map(el => el.outerHTML).join(''))); + } + }); + return { ids, hashes, ok }; + } + + // Swap a node's own markup for the new render's, leaving its children + // alone. The card is replaced in place; the trailing parts are dropped + // and re-appended, which is correct because the template emits them + // after the child nodes. + // + // Returns the elements it actually put on the page, or null if the node + // is not a shape it can handle. Returning *those* rather than the node + // matters: the caller rehydrates what comes back, and a node's subtree + // is not what changed. The session header is the case that makes this + // sharp — its fold bar counts descendants, so it is replaced on every + // single append, and its node is the whole page. + function applyOwn(liveNode, newNode) { + const liveCard = liveNode.querySelector(':scope > .message'); + const newCard = newNode.querySelector(':scope > .message'); + if (!!liveCard !== !!newCard) return null; + + const placed = []; + if (liveCard && newCard) { + const imported = document.importNode(newCard, true); + liveCard.replaceWith(imported); + placed.push(imported); + } + + const newTrailing = ownParts(newNode).filter(el => !el.classList.contains('message')); + const liveKids = liveNode.querySelector(':scope > .children'); + if (!liveKids) return newTrailing.length === 0 ? placed : null; + Array.from(liveKids.children).forEach(el => { + if (!el.classList.contains('message-node')) el.remove(); + }); + newTrailing.forEach(el => { + const imported = document.importNode(el, true); + liveKids.appendChild(imported); + placed.push(imported); + }); + return placed; + } + + // Where a new node belongs in the live tree: inside its parent's + // `.children`, after the last card already there. Because the id + // sequence is an extension, every new card follows every existing one in + // document order, so appending after the last `.message-node` is the + // right place — and going through `.message-node` rather than the + // container's last child keeps any trailing junction-link markup last. + function liveNodeFor(key) { + const el = document.getElementById(key); + return el ? el.closest('.message-node') : null; + } + + function insertNode(newNode, imported) { + const parentNode = newNode.parentElement + && newNode.parentElement.closest('.message-node'); + let liveKids; + if (!parentNode) { + liveKids = container(); + } else { + const key = nodeKey(parentNode); + const holder = key && liveNodeFor(key); + if (!holder) return false; + liveKids = holder.querySelector(':scope > .children'); + if (!liveKids) { + // The parent had no children until now, so it has no + // container to put them in; take the new one wholesale. + const newKids = parentNode.querySelector(':scope > .children'); + if (!newKids) return false; + holder.appendChild(document.importNode(newKids, true)); + return true; + } + } + if (!liveKids) return false; + const existing = liveKids.querySelectorAll(':scope > .message-node'); + if (existing.length) existing[existing.length - 1].after(imported); + else liveKids.prepend(imported); + return true; + } + + // Returns the number of cards added, or null if this update is not a + // shape we patch — in which case the caller swaps. + function tryPatch(nextRoot, next) { + if (!cardHashes || !next.ok) return null; + const live = scanTree(container(), false); + if (!live.ok) return null; + + // A pure extension: every node on screen is still there, with the + // same key, in the same order. This is what fails when an + // out-of-order arrival renumbers the positional ids, and it is + // deliberately an all-or-nothing test — a single mismatch means the + // ids no longer mean what they meant, so nothing keyed on them is + // trustworthy. + if (next.ids.length < live.ids.length) return null; + for (let i = 0; i < live.ids.length; i++) { + if (next.ids[i] !== live.ids[i]) return null; + } + + const changed = []; + for (const key of live.ids) { + if (cardHashes.get(key) !== next.hashes.get(key)) changed.push(key); + } + // A broad edit is cheaper to apply wholesale than node by node. An + // append moves only the ancestors' descendant counts, so this stays + // in single digits in practice. + if (changed.length > 40) return null; + + // Resolve everything before touching the live DOM, so a shape we + // cannot handle leaves the page untouched for the swap to redo. + const edits = []; + for (const key of changed) { + const liveNode = liveNodeFor(key); + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!liveNode || !newNode) return null; + edits.push([liveNode, newNode]); + } + + const known = new Set(live.ids); + const additions = []; + for (const key of next.ids) { + if (known.has(key)) continue; + const newAnchor = nextRoot.querySelector('[id="' + CSS.escape(key) + '"]'); + const newNode = newAnchor && newAnchor.closest('.message-node'); + if (!newNode) return null; + // A new node nested inside another new node arrives with it; + // `next.ids` is in document order, so the outer one comes first. + if (additions.some(([, outer]) => outer.contains(newNode))) continue; + additions.push([key, newNode]); + } + + const fresh = []; + // Nodes already on screen whose own markup legitimately changed: an + // ancestor's descendant count, or a `pair_first` class arriving with + // the other half of a pair. The subtree underneath is kept, and with + // it every bit of state the card holds. + for (const [liveNode, newNode] of edits) { + const placed = applyOwn(liveNode, newNode); + if (!placed) return null; + placed.forEach(el => fresh.push(el)); + } + + // New nodes. These carry the fade-in; the ones replaced above + // deliberately do not, since they were already on screen. + let added = 0; + for (const [key, newNode] of additions) { + const imported = document.importNode(newNode, true); + if (!insertNode(newNode, imported)) return null; + added += imported.querySelectorAll('.message').length; + imported.querySelectorAll('.message').forEach(el => el.classList.add('live-new')); + fresh.push(imported); + } + + // Rehydrate over what actually changed, not over the whole tree. + if (window.claudeLogRehydrate) { + fresh.forEach(el => window.claudeLogRehydrate(el)); + } + return added; + } + + // ---- the update ------------------------------------------------------ + + // The toggle is part of the page's floating-button stack rather than + // something this script builds, so it is styled with the rest of the + // toolbar and cannot drift from it. It is revealed only here, because + // reaching this point is the proof that polling is possible at all. + const followBtn = document.getElementById('followUpdates'); + let unseen = 0; + + function renderFollowBtn() { + if (!followBtn) return; + if (following) unseen = 0; + followBtn.classList.toggle('following', following); + followBtn.setAttribute('aria-pressed', following ? 'true' : 'false'); + followBtn.dataset.unseen = String(unseen); + followBtn.title = following + ? 'Following new messages — click to stop' + : (unseen + ? `${unseen} new message${unseen === 1 ? '' : 's'} — click to follow` + : 'Follow new messages as they arrive'); + document.body.classList.toggle('live-following', following); + } + + function setFollowing(next) { + following = !!next; + renderFollowBtn(); + if (following) scrollToEnd(); + } + + if (followBtn) { + followBtn.classList.add('live-active'); + followBtn.addEventListener('click', () => setFollowing(!following)); + renderFollowBtn(); + } + + function announce(added) { + unseen += added; + renderFollowBtn(); + } + + // Scroll the document to its end rather than aligning the last card, + // which is what `scrollIntoView({block: 'end'})` did: that puts the + // card's bottom edge *exactly* on the viewport's, measured at a 0px + // gap. `body.live-following`'s padding supplies the space this then + // scrolls into. Both halves are needed — measured on a real page, the + // padding alone still gives 0px (the alignment ignores it) and a + // scroll-margin alone gives 25px (there is no room left to give). + function scrollToEnd() { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: 'smooth', + }); + } + + function swapIn(next, current) { + const before = captureState(current); + current.replaceWith(next); + restoreState(next, before); + const added = markNew(next, before.keys); + // Everything that decorated the old markup after load. + if (window.claudeLogRehydrate) window.claudeLogRehydrate(next); + return added; + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + // Hashes come from the parsed bytes, before anything is put on the + // page, and are kept whichever route the update took — the swap is a + // valid starting point for the next patch. + const scan = scanTree(next, true); + let added = tryPatch(next, scan); + if (added === null) added = swapIn(next, current); + cardHashes = scan.hashes; + + // The title carries the message/token counts, and the session nav + // its summaries; both go stale otherwise. + const nextTitle = doc.getElementById('title'); + const title = document.getElementById('title'); + if (nextTitle && title) title.innerHTML = nextTitle.innerHTML; + + if (added) announce(added); + if (following) scrollToEnd(); + } + + // One poll at a time. The interval keeps firing while a full GET is in + // flight, and a page slow enough to fetch — which is exactly the large + // page all of this is for — would then have two updates racing: + // whichever *response* lands last wins, so an older render overwrites a + // newer one and the page loses messages it had already shown. Measured + // by holding one response for 3s: the newest message appeared at 2.0s, + // vanished at 4.0s when the stale body landed, and came back at 5.0s. + // + // Serialising is what stops it, and skipping a tick costs nothing: + // `lastStamp` only advances once an update has actually been applied, + // so the next tick still sees the change. (That ordering is also what + // bounds the damage above to one second rather than forever — the + // stale apply rewinds `lastStamp` to its own older value, so the next + // HEAD finds a difference again. Recording the stamp before the GET + // instead leaves the page wrong until something else changes.) + let polling = false; + + // How many bytes this document actually was, as the browser received + // it. The first poll cannot happen until the document has loaded, and + // that takes as long as it takes — tens of MB on the pages this + // feature is for. A conversion completing in that window would be + // adopted as the baseline and never applied, leaving the page + // permanently one update behind if the session then went quiet. + // + // The navigation timing entry is the one thing that knows what we were + // served, so the first poll compares against it rather than trusting + // whatever the server holds by then. Responses are not + // content-encoded, so this is directly comparable to `Content-Length`; + // anything that makes it unavailable (or zero) falls back to adopting + // the baseline, which is where this started. + function loadedLength() { + try { + const nav = performance.getEntriesByType('navigation')[0]; + return (nav && nav.encodedBodySize) || null; + } catch (err) { + return null; + } + } + + async function poll() { + if (stopped || polling) return; + polling = true; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const length = head.headers.get('Content-Length') || ''; + const stamp = [ + head.headers.get('Last-Modified') || '', + length, + head.headers.get('ETag') || '', + head.headers.get('X-Content-Revision') || '', + ].join('|'); + const served = lastStamp === null ? loadedLength() : null; + const missedOnLoad = !!served && !!length && Number(length) !== served; + if (lastStamp === null && !missedOnLoad) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) { + await applyUpdate(await res.text()); + lastStamp = stamp; + } + } + } catch (err) { + // A dropped server is the normal end of a watch session, not an + // error worth shouting about. Keep polling: `serve` may come back. + console.debug('live update poll failed', err); + } finally { + polling = false; + } + } + + // Don't poll a page nobody is looking at. + function schedule() { + if (document.hidden) return; + poll(); + } + setInterval(schedule, POLL_MS); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) poll(); + }); + poll(); + + window.claudeLogLiveUpdate = { + poll, + stop() { stopped = true; }, + setFollowing, + }; })(); // Debug UUID toggle @@ -65917,24 +73331,30 @@ // Apply all filters on page load applyFilter(); - // Fold/unfold functionality with horizontal fold bars - const foldBarSections = document.querySelectorAll('.fold-bar-section'); - - foldBarSections.forEach(section => { - section.addEventListener('click', function(e) { - e.stopPropagation(); - const action = this.getAttribute('data-action'); - const targetId = this.getAttribute('data-target'); - const isFolded = this.classList.contains('folded'); - - if (action === 'fold-one') { - // Fold/unfold immediate children only - handleFoldOne(targetId, isFolded, this); - } else if (action === 'fold-all') { - // Fold/unfold all descendants recursively - handleFoldAll(targetId, isFolded, this); - } - }); + // Fold/unfold functionality with horizontal fold bars. + // + // Delegated on `document` rather than bound per section, because + // a live update (`serve --watch`) replaces fold bars: a card's + // bar carries its descendant count, so every append re-renders + // the ancestors' bars, and the container swap replaces all of + // them. Bound directly, those listeners died with the elements + // and the fold controls silently stopped responding — measured: + // one update was enough to leave every bar on the page inert. + document.addEventListener('click', function (event) { + const section = event.target.closest('.fold-bar-section'); + if (!section) return; + event.stopPropagation(); + const action = section.getAttribute('data-action'); + const targetId = section.getAttribute('data-target'); + const isFolded = section.classList.contains('folded'); + + if (action === 'fold-one') { + // Fold/unfold immediate children only + handleFoldOne(targetId, isFolded, section); + } else if (action === 'fold-all') { + // Fold/unfold all descendants recursively + handleFoldAll(targetId, isFolded, section); + } }); // Update tooltip based on fold state @@ -66060,6 +73480,42 @@ // Apply initial fold state setInitialFoldState(); + // Re-sync a fold bar to what its children container is actually + // doing. A live update re-renders a card whenever its descendant + // count changes — which is every ancestor of every append — and + // the replacement arrives with the server's default icons, not + // the state the user left it in. The children container is never + // replaced, so its own `display` is the truth; without this the + // bar claims "unfolded" over a hidden subtree, and the next + // click folds what is already folded and appears to do nothing. + function syncFoldBar(card) { + const foldBar = card.querySelector(':scope > .fold-bar'); + if (!foldBar) return; + const cc = getChildrenContainer(card); + const oneSection = foldBar.querySelector('.fold-one-level'); + const allSection = foldBar.querySelector('.fold-all-levels'); + if (!cc || cc.style.display === 'none') { + setSectionState(oneSection, true, '⏵'); + setSectionState(allSection, true, '⏵⏵'); + return; + } + // Immediate children are visible; `fold-all` reads as open + // only when their own subtrees are open too. + const kids = getImmediateChildMessages(card); + const allOpen = kids.every(child => { + const childCc = getChildrenContainer(child); + return !childCc || childCc.style.display !== 'none'; + }); + setSectionState(oneSection, false, '⏷'); + setSectionState(allSection, !allOpen, allOpen ? '⏷⏷' : '⏵⏵'); + } + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(function (root) { + if (root.matches && root.matches('.message')) syncFoldBar(root); + root.querySelectorAll('.message').forEach(syncFoldBar); + }); + } + // Unfold any folded ancestors so an anchor target (e.g. a tool_use // jumped to from the session index) is actually visible. In the // dissociated DOM a card's ancestors are .message-node and diff --git a/test/conftest.py b/test/conftest.py index 189c11c3..2ffc72e3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -215,7 +215,32 @@ def _browser_user_data_dir(worker_id): @pytest.fixture(scope="session") -def _persistent_context(playwright, browser_type_launch_args, _browser_user_data_dir): +def _browser_expect_timeout(): + """Give web-first assertions room for a starved CI machine. + + Playwright's default is 5 s, which is a budget for *the page*, not for + the machine — and browser tests run under ``-n auto``, so a 4-core + Windows runner hosts four Chromiums at once. A confirmed failure there + (``test_search_unfolds_matches_in_folded_subtrees``, Windows/3.13, + green on 3.11 in the same matrix) had Playwright resolving its locator + only three times inside the five seconds — ~1.6 s per poll — so the + assertion expired while the page was still catching up rather than + because anything was wrong with it. Raising the ceiling costs nothing + on a passing run: an assertion that will pass still returns as soon as + it does. + """ + from playwright.sync_api import expect + + expect.set_options(timeout=20_000) + + +@pytest.fixture(scope="session") +def _persistent_context( + playwright, + browser_type_launch_args, + _browser_user_data_dir, + _browser_expect_timeout, +): """Create a persistent browser context that shares HTTP cache across tests. This solves flaky CDN loading issues by caching resources like vis-timeline diff --git a/test/test_atomic_write.py b/test/test_atomic_write.py new file mode 100644 index 00000000..fb8f663b --- /dev/null +++ b/test/test_atomic_write.py @@ -0,0 +1,161 @@ +"""Tests for `utils.atomic_write_text`. + +The property that matters: a reader never observes a partial file. Watch +mode rewrites the same output every few seconds while an editor, a vault +indexer or a browser poll re-reads it, so the truncate-then-write window +that `Path.write_text` opens stops being theoretical. +""" + +import os +import sys +import threading +from pathlib import Path + +import pytest + +from claude_code_log.utils import atomic_write_text + + +def test_writes_content(tmp_path: Path) -> None: + target = tmp_path / "out.html" + atomic_write_text(target, "hello") + assert target.read_text() == "hello" + + +def test_overwrites_existing(tmp_path: Path) -> None: + target = tmp_path / "out.html" + target.write_text("old content that is longer") + atomic_write_text(target, "new") + assert target.read_text() == "new" + + +def test_leaves_no_temp_file(tmp_path: Path) -> None: + atomic_write_text(tmp_path / "out.html", "x") + assert [p.name for p in tmp_path.iterdir()] == ["out.html"] + + +def test_temp_file_is_removed_on_failure(tmp_path: Path, monkeypatch) -> None: + """A crash between write and replace must not litter the output dir.""" + target = tmp_path / "out.html" + + def boom(_src, _dst): + raise OSError("no space left on device") + + monkeypatch.setattr(os, "replace", boom) + with pytest.raises(OSError): + atomic_write_text(target, "x") + + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.skipif( + sys.platform == "win32", + reason=( + "Windows blocks the replace while a reader holds the target open " + "(Python's `open` does not grant FILE_SHARE_DELETE), so this " + "reader loop makes the writer fail rather than tear — a different " + "property from the one under test. The retry that covers it there " + "is `test_the_replace_is_retried_past_a_holding_reader`." + ), +) +def test_reader_never_sees_a_partial_file(tmp_path: Path) -> None: + """The point of the helper, exercised against a concurrent reader. + + A 4 MB payload makes the truncate-then-write window wide enough that + a plain `write_text` is caught in it reliably; the assertion is that + the atomic path never is. + """ + target = tmp_path / "page.html" + small = "a" * 4_000_000 + large = "b" * 4_000_000 + target.write_text(small) + + observed: set[int] = set() + stop = threading.Event() + + def reader() -> None: + while not stop.is_set(): + try: + observed.add(len(target.read_text())) + except FileNotFoundError: + observed.add(-1) + + th = threading.Thread(target=reader, daemon=True) + th.start() + try: + for i in range(40): + atomic_write_text(target, large if i % 2 else small) + finally: + stop.set() + th.join(timeout=5) + + # Only the two complete sizes are ever observable — never a torn + # prefix, never a missing file. + assert observed <= {len(small), len(large)}, sorted(observed) + + +def test_the_replace_is_retried_past_a_holding_reader(tmp_path, monkeypatch) -> None: + """What a reader costs on Windows: a refusal, not a torn file. + + A reader with the target open makes `os.replace` raise + `PermissionError` there until it closes — so the write has to wait it + out rather than fail the conversion. Simulated, because the platform + that does this is not the one the suite runs on. + """ + target = tmp_path / "out.html" + target.write_text("old") + real_replace = os.replace + attempts: list[int] = [] + + def busy(src, dst): + attempts.append(1) + if len(attempts) < 3: + raise PermissionError(5, "Access is denied") + real_replace(src, dst) + + monkeypatch.setattr(os, "replace", busy) + monkeypatch.setattr("claude_code_log.utils._REPLACE_BACKOFF_S", 0.001) + + atomic_write_text(target, "new") + assert target.read_text() == "new" + assert len(attempts) == 3, "the replace was not retried" + + +def test_a_reader_that_never_lets_go_still_reports(tmp_path, monkeypatch) -> None: + """Retrying forever would hang a watch; the error still surfaces.""" + target = tmp_path / "out.html" + + def always_busy(_src, _dst): + raise PermissionError(5, "Access is denied") + + monkeypatch.setattr(os, "replace", always_busy) + monkeypatch.setattr("claude_code_log.utils._REPLACE_BACKOFF_S", 0.001) + + with pytest.raises(PermissionError): + atomic_write_text(target, "x") + assert list(tmp_path.iterdir()) == [], "the temp file outlived the failure" + + +def test_symlink_is_written_through_not_replaced(tmp_path: Path) -> None: + """`os.replace` would swap the link itself; users who linked meant it.""" + real = tmp_path / "real.md" + real.write_text("old") + link = tmp_path / "link.md" + link.symlink_to(real) + + atomic_write_text(link, "new") + + assert link.is_symlink() + assert real.read_text() == "new" + + +def test_concurrent_writers_do_not_clobber_each_others_temp(tmp_path: Path) -> None: + """The render fan-out can write the same path from several processes. + + They write identical bytes, so the outcome is fine — but only because + the temp names differ. This pins that the pid is in the name. + """ + target = tmp_path / "out.html" + atomic_write_text(target, "x") + # The helper's temp name for this process, reconstructed. + assert not (tmp_path / f".out.html.{os.getpid()}.tmp").exists() diff --git a/test/test_cache_size_freshness.py b/test/test_cache_size_freshness.py new file mode 100644 index 00000000..bbecc0a9 --- /dev/null +++ b/test/test_cache_size_freshness.py @@ -0,0 +1,182 @@ +"""The cached file-size check (migration 011). + +Freshness used to compare mtimes with a 1.0s tolerance and nothing else, +so a write landing within a second of the mtime recorded at cache time +was invisible. These tests pin the fix and its backward-compatible +fallback. +""" + +import json +import sqlite3 +from pathlib import Path + +import pytest + +from claude_code_log.cache import CacheManager + + +def _entry(uuid: str, text: str) -> str: + return ( + json.dumps( + { + "type": "user", + "timestamp": "2025-07-03T16:15:00Z", + "parentUuid": None, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp", + "sessionId": "s1", + "version": "1.0.0", + "uuid": uuid, + "message": { + "role": "user", + "content": [{"type": "text", "text": text}], + }, + } + ) + + "\n" + ) + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + d = tmp_path / "proj" + d.mkdir() + (d / "s1.jsonl").write_text(_entry("u1", "first"), encoding="utf-8") + return d + + +def _cache(project: Path) -> CacheManager: + cm = CacheManager(project, "test-version") + cm.save_cached_entries(project / "s1.jsonl", []) + return cm + + +def test_append_the_mtime_check_cannot_see_is_detected(project: Path) -> None: + """The case the tolerance used to hide, and the reason for 011. + + The mtime is restored after the append so the mtime term provably + reports "fresh" — the detection can only be coming from the size. + (Left to real timing this would depend on whether the append landed + inside the 1.0s tolerance, which under parallel test execution goes + either way.) + """ + import os + + cm = _cache(project) + jsonl = project / "s1.jsonl" + assert cm.is_file_cached(jsonl), "sanity: freshly cached" + before = jsonl.stat() + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("u2", "second")) + os.utime(jsonl, (before.st_atime, before.st_mtime)) + + assert not cm.is_file_cached(jsonl) + assert cm.get_modified_files([jsonl]) == [jsonl] + + +def test_untouched_file_stays_cached(project: Path) -> None: + """The rule only tightens; it must not invalidate a stable file.""" + cm = _cache(project) + jsonl = project / "s1.jsonl" + assert cm.is_file_cached(jsonl) + assert cm.get_modified_files([jsonl]) == [] + + +def test_pre_011_rows_fall_back_to_the_mtime_check(project: Path) -> None: + """A populated cache from before the migration must not mass-invalidate. + + Rows written before 011 carry NULL, matching what the ALTER TABLE + gives existing rows. + """ + cm = _cache(project) + jsonl = project / "s1.jsonl" + + with sqlite3.connect(cm.db_path) as conn: + conn.execute("UPDATE cached_files SET source_size = NULL") + conn.commit() + + # NULL size => mtime-only rule => an untouched file is still fresh. + assert cm.is_file_cached(jsonl) + assert cm.get_modified_files([jsonl]) == [] + + +def test_size_is_recorded_on_save(project: Path) -> None: + cm = _cache(project) + jsonl = project / "s1.jsonl" + with sqlite3.connect(cm.db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT source_size FROM cached_files WHERE file_name = ?", ("s1.jsonl",) + ).fetchone() + assert row["source_size"] == jsonl.stat().st_size + + +def test_pre_011_fallback_cannot_see_an_append_the_mtime_hides( + project: Path, +) -> None: + """The negative control that proves 011 does something. + + With the size NULLed out the rule is exactly what it was before the + migration, and it cannot see an append the mtime doesn't report. + Also an honest note on the fallback's limit: a cache populated before + 011 keeps the old blind spot until its rows are rewritten. + + The mtime is restored explicitly rather than relying on the append + landing inside the 1.0s tolerance — under parallel test execution + that race resolves either way, and a timing-dependent test is worth + less than the thing it pins. + """ + import os + + cm = _cache(project) + jsonl = project / "s1.jsonl" + before = jsonl.stat() + + with sqlite3.connect(cm.db_path) as conn: + conn.execute("UPDATE cached_files SET source_size = NULL") + conn.commit() + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("u2", "second")) + os.utime(jsonl, (before.st_atime, before.st_mtime)) + + assert cm.is_file_cached(jsonl), ( + "pre-011 rows fall back to mtime-only, which cannot see this" + ) + + +def test_a_file_that_grows_mid_parse_does_not_look_cached( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The stamp has to describe the parse, not the moment after it. + + Watch mode reads files that are being appended to, so "the file grew + while we were reading it" is routine rather than theoretical. Stamped + at save time, the cache would claim the size and mtime the file + reached while holding only the rows we parsed — and if the session + then ends, that truncated view is what every later run trusts. + """ + from claude_code_log import converter + from claude_code_log.converter import load_transcript + + jsonl = project / "s1.jsonl" + cm = CacheManager(project, "test-version") + grew: list[int] = [] + original = converter.create_transcript_entry + + def _grow_during_parse(entry_dict): + if not grew: + grew.append(1) + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("u2", "landed while we were reading")) + return original(entry_dict) + + monkeypatch.setattr(converter, "create_transcript_entry", _grow_during_parse) + load_transcript(jsonl, cm, silent=True) + assert grew, "the fixture never exercised the growth" + + assert not cm.is_file_cached(jsonl), ( + "the cache is stamped with a size it does not hold the rows for" + ) diff --git a/test/test_cache_sqlite_integrity.py b/test/test_cache_sqlite_integrity.py index b17e3559..c4ee75cb 100644 --- a/test/test_cache_sqlite_integrity.py +++ b/test/test_cache_sqlite_integrity.py @@ -10,7 +10,13 @@ import pytest -from claude_code_log.cache import CacheManager, SessionCacheData +from claude_code_log.cache import ( + CacheManager, + SessionCacheData, + _migrated_db_paths, + discard_database_files, + is_corrupt_database_error, +) from claude_code_log.models import ( AssistantMessageModel, AssistantTranscriptEntry, @@ -1154,3 +1160,223 @@ def test_migration_applied_only_once(self, isolated_cache_dir, isolated_db_path) ).fetchone()[0] assert initial_count == final_count + + +class TestCorruptDatabaseRecovery: + """A cache file damaged past reading is discarded and rebuilt. + + Written against two real shapes. The first is the reported one: a cache + truncated to 11.6 MB while its header still claimed 58.1 MB, on which no + statement at all succeeded. The second is what made it *visible* — + migration 012's `CREATE INDEX` is the first operation to full-scan + `messages`, so damage confined to that table's leaf pages had been sitting + unnoticed under earlier versions, which never read those pages. + """ + + @staticmethod + def _populate(cache_dir: Path, db_path: Path, user_entry, assistant_entry) -> None: + """Build a real, healthy cache with enough rows to damage.""" + cache_manager = CacheManager(cache_dir, "1.0.0", db_path=db_path) + jsonl_file = cache_dir / "test.jsonl" + jsonl_file.write_text( + json.dumps(user_entry.model_dump()) + + "\n" + + json.dumps(assistant_entry.model_dump()) + + "\n", + encoding="utf-8", + ) + cache_manager.save_cached_entries(jsonl_file, [user_entry, assistant_entry]) + + def test_truncated_database_is_discarded_and_rebuilt( + self, + isolated_cache_dir, + isolated_db_path, + sample_user_entry, + sample_assistant_entry, + capsys, + ): + """The reported failure: header claims more pages than the file holds.""" + self._populate( + isolated_cache_dir, + isolated_db_path, + sample_user_entry, + sample_assistant_entry, + ) + original_size = isolated_db_path.stat().st_size + with open(isolated_db_path, "r+b") as f: + f.truncate(original_size // 4) + + # Constructing over the damaged file must succeed, not raise. + cache_manager = CacheManager( + isolated_cache_dir, "1.0.0", db_path=isolated_db_path + ) + + assert "corrupt" in capsys.readouterr().out.lower() + with cache_manager._get_connection() as conn: + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + # Rebuilt empty rather than carrying damaged rows forward: the project + # row exists again, but the messages the old file held are gone, so the + # next conversion re-ingests them from the JSONL source. + assert cache_manager._project_id is not None + with cache_manager._get_connection() as conn: + assert conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM cached_files").fetchone()[0] == 0 + + def test_corrupt_messages_pages_surface_and_heal_during_migration( + self, + isolated_cache_dir, + isolated_db_path, + sample_user_entry, + sample_assistant_entry, + capsys, + ): + """Damage only a full table scan reaches still heals. + + Models the reported sequence exactly: a cache written by a version + that lacked migration 012, damaged in `messages`, then opened by one + that has it. The pending `CREATE INDEX` scans the damaged pages and + raises where nothing else would. + """ + self._populate( + isolated_cache_dir, + isolated_db_path, + sample_user_entry, + sample_assistant_entry, + ) + # Roll the schema back to pre-012 so the index build is pending again. + conn = sqlite3.connect(isolated_db_path) + conn.execute("PRAGMA journal_mode=DELETE") + conn.execute("DELETE FROM _schema_version WHERE version >= 12") + for name in ( + "idx_messages_project_uuid", + "idx_messages_project_parent_uuid", + "idx_messages_project_request_id", + "idx_messages_project_metadata_type", + "idx_messages_project_session_ts", + ): + conn.execute(f"DROP INDEX IF EXISTS {name}") + conn.commit() + pages = [ + r[0] + for r in conn.execute( + "SELECT pageno FROM dbstat WHERE name='messages' AND pagetype='leaf'" + ) + ] + # `dbstat.pageno` counts pages of whatever size this database was + # built with, so read it rather than assuming SQLite's 4096 default. + page_size = conn.execute("PRAGMA page_size").fetchone()[0] + conn.close() + assert pages, "expected messages to occupy at least one leaf page" + # Overwrite the whole page, not a slice of it. A page holding two small + # rows is mostly free space, and cells are laid out from the *end*, so + # scribbling near the start damages nothing SQLite reads. + with open(isolated_db_path, "r+b") as f: + for pageno in pages: + f.seek((pageno - 1) * page_size) + f.write(b"\xff" * page_size) + # `_populate` left this path in the process-level "migrations already + # checked" memo, which would skip the pending migration. Real runs meet + # this across processes; drop the entry to stand in for a fresh one. + _migrated_db_paths.discard(str(isolated_db_path)) + + cache_manager = CacheManager( + isolated_cache_dir, "1.0.0", db_path=isolated_db_path + ) + + assert "corrupt" in capsys.readouterr().out.lower() + with cache_manager._get_connection() as conn2: + assert conn2.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + applied = [ + r[0] for r in conn2.execute("SELECT version FROM _schema_version") + ] + # The migration that failed is applied on the rebuilt database, so the + # failure cannot repeat on every subsequent run. + assert 12 in applied + + def test_garbage_file_is_replaced( + self, isolated_cache_dir, isolated_db_path, capsys + ): + """A non-database file at the cache path is replaced, not fatal.""" + isolated_db_path.write_bytes(b"this is not a database" * 500) + + cache_manager = CacheManager( + isolated_cache_dir, "1.0.0", db_path=isolated_db_path + ) + + assert "corrupt" in capsys.readouterr().out.lower() + assert cache_manager._project_id is not None + + def test_wal_sidecars_are_removed_with_the_database(self, isolated_db_path): + """A stale -wal beside a fresh database is its own route to malformed.""" + isolated_db_path.write_bytes(b"x") + wal = isolated_db_path.with_name(isolated_db_path.name + "-wal") + shm = isolated_db_path.with_name(isolated_db_path.name + "-shm") + wal.write_bytes(b"x") + shm.write_bytes(b"x") + + assert discard_database_files(isolated_db_path) is True + + assert not isolated_db_path.exists() + assert not wal.exists() + assert not shm.exists() + + def test_empty_file_is_not_treated_as_corruption( + self, isolated_cache_dir, isolated_db_path, capsys + ): + """SQLite adopts a zero-byte file as a new database; don't panic.""" + isolated_db_path.write_bytes(b"") + + cache_manager = CacheManager( + isolated_cache_dir, "1.0.0", db_path=isolated_db_path + ) + + assert "corrupt" not in capsys.readouterr().out.lower() + assert cache_manager._project_id is not None + + def test_read_only_manager_never_deletes_the_database( + self, + isolated_cache_dir, + isolated_db_path, + sample_user_entry, + sample_assistant_entry, + ): + """Render workers open read-only and must not delete a shared file. + + Several run concurrently against one database; a worker that reacted + to corruption by unlinking it would pull the file out from under its + siblings. Degrading to "no cached data" is the contract. + """ + self._populate( + isolated_cache_dir, + isolated_db_path, + sample_user_entry, + sample_assistant_entry, + ) + with open(isolated_db_path, "r+b") as f: + f.truncate(isolated_db_path.stat().st_size // 4) + size_before = isolated_db_path.stat().st_size + + cache_manager = CacheManager( + isolated_cache_dir, "1.0.0", db_path=isolated_db_path, read_only=True + ) + + assert cache_manager._project_id is None + assert isolated_db_path.exists() + assert isolated_db_path.stat().st_size == size_before + + @pytest.mark.parametrize( + "exc,expected", + [ + (sqlite3.DatabaseError("database disk image is malformed"), True), + (sqlite3.DatabaseError("file is not a database"), True), + (sqlite3.DatabaseError("malformed database schema (message_fts)"), True), + # Transient and environmental failures must never delete a cache + # that is merely busy, or on a disk that is merely full. + (sqlite3.OperationalError("database is locked"), False), + (sqlite3.OperationalError("database or disk is full"), False), + (sqlite3.OperationalError("no such table: messages"), False), + (OSError("permission denied"), False), + ], + ) + def test_only_corruption_triggers_a_rebuild(self, exc, expected): + assert is_corrupt_database_error(exc) is expected diff --git a/test/test_entry_store.py b/test/test_entry_store.py new file mode 100644 index 00000000..b9a2d790 --- /dev/null +++ b/test/test_entry_store.py @@ -0,0 +1,908 @@ +"""The per-conversion parsed-entry store (``entry_store.py``). + +A watch tick materialises the same entries up to three times: the +incremental cache refresh parses each modified file from source, then the +closure load and the session-scoped render each rebuild them from the +rows the refresh has just written. The store keeps the first pass's list +and serves it to the other two (work/watch-mode.md, C14). + +A store owned *across* ticks (which ``watch`` does) goes further: it pins +its entries to a byte offset plus a hash of the bytes below it, so a tick +can prove the file still starts with what it already parsed and read only +what was appended — and, when the rows are provably just those lines, +write only the new ones instead of rewriting every row. + +Three layers of coverage, and the last two are the ones that matter: + +- Unit tests for the store's own contract — stamp verification, copy + isolation, budget, kill switch. +- An end-to-end equivalence test over a watch-shaped append, with the + store on and off, on a fixture whose 170 sidechain entries exercise + ``_integrate_agent_entries``. That transformation mutates entries **in + place and is not idempotent** (it appends ``#agent-{id}`` to + ``sessionId``), and today each consumer gets freshly deserialised + objects; serving the same objects to two consumers would apply it + twice. That is exactly what the copy isolation exists to prevent, so + the equivalence test is its real proof. +- Parse and write equivalence for the resumable path: the byte reader + against the text reader it replaces, a resumed parse against a fresh + one, and — the bar a write-path change has to clear — the **cache + rows** an append-only write leaves behind against the rows a full + rewrite leaves, since the first bug of that kind is invisible in the + rendered HTML. +""" + +import json +import re +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from claude_code_log import converter +from claude_code_log.converter import convert_jsonl_to, load_transcript +from claude_code_log.entry_store import ( + ParsedEntryStore, + entry_store_enabled, + entry_store_forced, + stamp_file, +) + +FIXTURE_ROOT = Path(__file__).parent / "test_data" / "real_projects" +# Multi-session, and the one fixture with a substantial population of +# sidechain agent entries (170) — i.e. the mutation hazard above. +AGENT_PROJECT = FIXTURE_ROOT / "-Users-dain-workspace-coderabbit-review-helper" + + +def _copy_project(tmp_path: Path, source: Path = AGENT_PROJECT) -> Path: + """Copy a fixture project, keeping its directory name (titles derive from it).""" + work_dir = tmp_path / source.name + shutil.copytree(source, work_dir) + return work_dir + + +def _session_files(work_dir: Path) -> dict[str, bytes]: + files = {p.name: p.read_bytes() for p in sorted(work_dir.glob("session-*.html"))} + assert files, "conversion produced no session files" + return files + + +def _append_entry(jsonl: Path, uuid: str, text: str) -> None: + entry = { + "type": "user", + "timestamp": "2025-07-03T18:00:00Z", + "parentUuid": None, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp", + "sessionId": jsonl.stem, + "version": "1.0.0", + "uuid": uuid, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}, + } + with jsonl.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +def _spy_stores(monkeypatch: pytest.MonkeyPatch) -> list[ParsedEntryStore]: + """Collect the stores a conversion creates, without changing behaviour.""" + created: list[ParsedEntryStore] = [] + original = converter._make_entry_store + + def _spy() -> Any: + store = original() + if store is not None: + created.append(store) + return store + + monkeypatch.setattr(converter, "_make_entry_store", _spy) + return created + + +def _busiest_trunk(project: Path) -> Path: + """The fixture's substantive trunk file (several others are empty).""" + trunk = [p for p in project.glob("*.jsonl") if not p.name.startswith("agent-")] + return max(trunk, key=lambda p: p.stat().st_size) + + +@pytest.fixture +def entries() -> list[Any]: + """A real parsed entry list to put in the store.""" + parsed = load_transcript(_busiest_trunk(AGENT_PROJECT), silent=True) + assert parsed, "fixture produced no entries" + return parsed + + +class TestStoreContract: + def test_round_trip_serves_the_held_entries( + self, tmp_path: Path, entries: list[Any] + ) -> None: + path = tmp_path / "a.jsonl" + path.write_text("x", encoding="utf-8") + store = ParsedEntryStore() + store.put(path, stamp_file(path), entries) + + served = store.get(path) + assert served is not None + assert len(served) == len(entries) + assert store.hits == 1 + + def test_handouts_are_independent_copies( + self, tmp_path: Path, entries: list[Any] + ) -> None: + """The `_integrate_agent_entries` hazard, at the unit level. + + One consumer's in-place mutation must not be visible to the next, + or a non-idempotent transformation would be applied twice. + """ + path = tmp_path / "a.jsonl" + path.write_text("x", encoding="utf-8") + store = ParsedEntryStore() + store.put(path, stamp_file(path), entries) + + first = store.get(path) + second = store.get(path) + assert first is not None and second is not None + assert first[0] is not second[0], "consumers share an object" + + before = getattr(second[0], "sessionId", None) + first[0].sessionId = "mutated#agent-x" # type: ignore[union-attr] + assert getattr(second[0], "sessionId", None) == before + assert getattr(entries[0], "sessionId", None) == before + + third = store.get(path) + assert third is not None + assert getattr(third[0], "sessionId", None) == before + + def test_declines_when_the_file_changed_since_the_parse( + self, tmp_path: Path, entries: list[Any] + ) -> None: + path = tmp_path / "a.jsonl" + path.write_text("x", encoding="utf-8") + store = ParsedEntryStore() + store.put(path, stamp_file(path), entries) + + path.write_text("xy", encoding="utf-8") # a size change is enough + assert store.get(path) is None + assert store.misses == 1 + + def test_declines_a_stale_stamp_taken_before_a_mid_parse_append( + self, tmp_path: Path, entries: list[Any] + ) -> None: + """A file that grew during the parse must not be served. + + The stamp is captured *before* the parse precisely so this case + mismatches rather than serving a list its stamp misdescribes. + """ + path = tmp_path / "a.jsonl" + path.write_text("x", encoding="utf-8") + stamp = stamp_file(path) + path.write_text("x-grown-during-the-parse", encoding="utf-8") + + store = ParsedEntryStore() + store.put(path, stamp, entries) + assert store.get(path) is None + + def test_put_declines_without_a_stamp_or_entries( + self, tmp_path: Path, entries: list[Any] + ) -> None: + path = tmp_path / "a.jsonl" + path.write_text("x", encoding="utf-8") + store = ParsedEntryStore() + + store.put(path, None, entries) + assert store.get(path) is None + + store.put(path, stamp_file(path), []) + assert store.get(path) is None + assert store.held_bytes == 0 + + def test_get_of_an_unknown_path_is_a_miss_not_an_error( + self, tmp_path: Path + ) -> None: + store = ParsedEntryStore() + assert store.get(tmp_path / "never-stored.jsonl") is None + assert store.misses == 1 + + def test_budget_evicts_in_insertion_order( + self, tmp_path: Path, entries: list[Any] + ) -> None: + first = tmp_path / "first.jsonl" + second = tmp_path / "second.jsonl" + first.write_text("a" * 100, encoding="utf-8") + second.write_text("b" * 100, encoding="utf-8") + + store = ParsedEntryStore(budget_bytes=150) + store.put(first, stamp_file(first), entries) + store.put(second, stamp_file(second), entries) + + assert store.get(first) is None, "oldest should have been evicted" + assert store.get(second) is not None + assert store.held_bytes <= 150 + + def test_held_prefixes_are_charged_and_evicted_too( + self, tmp_path: Path, entries: list[Any] + ) -> None: + """`watch` owns one store for hours — prefixes cannot grow forever.""" + first = tmp_path / "first.jsonl" + second = tmp_path / "second.jsonl" + + store = ParsedEntryStore(budget_bytes=150) + store.put_prefix(first, 100, b"d1", entries, set(), 1) + assert store.held_bytes == 100 + + store.put_prefix(second, 100, b"d2", entries, set(), 1) + assert store.get_prefix(first) is None, "oldest should have been evicted" + assert store.get_prefix(second) is not None + assert store.held_bytes <= 150 + + # Re-holding the same file replaces its charge rather than adding + # one: a watched file is re-held on every tick that touches it. + store.put_prefix(second, 100, b"d3", entries, set(), 1) + assert store.held_bytes == 100 + store.drop_prefix(second) + assert store.held_bytes == 0 + + def test_a_file_larger_than_the_budget_is_declined( + self, tmp_path: Path, entries: list[Any] + ) -> None: + path = tmp_path / "big.jsonl" + path.write_text("a" * 500, encoding="utf-8") + store = ParsedEntryStore(budget_bytes=100) + store.put(path, stamp_file(path), entries) + + assert store.get(path) is None + assert store.declines == 1 + + +class TestEnvironment: + @pytest.mark.parametrize("value", ["0", "off", "false", "OFF"]) + def test_kill_switch(self, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv("CLAUDE_CODE_LOG_ENTRY_STORE", value) + assert not entry_store_enabled() + assert converter._make_entry_store() is None + + @pytest.mark.parametrize("value", ["", "1", "on", "anything"]) + def test_enabled_by_default( + self, monkeypatch: pytest.MonkeyPatch, value: str + ) -> None: + monkeypatch.setenv("CLAUDE_CODE_LOG_ENTRY_STORE", value) + assert entry_store_enabled() + assert converter._make_entry_store() is not None + + def test_forced_only_on_an_explicit_yes( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CLAUDE_CODE_LOG_ENTRY_STORE", "1") + assert entry_store_forced() + monkeypatch.setenv("CLAUDE_CODE_LOG_ENTRY_STORE", "") + assert not entry_store_forced() + + +class TestWatchTick: + """The shape the store exists for: a pure append over a warm cache.""" + + def _grown_project(self, tmp_path: Path) -> tuple[Path, Path]: + work_dir = _copy_project(tmp_path) + convert_jsonl_to("html", work_dir, silent=True, write_combined=False) + return work_dir, _busiest_trunk(work_dir) + + def test_the_store_is_used_on_an_append( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without this, the equivalence test below would pass vacuously.""" + work_dir, jsonl = self._grown_project(tmp_path) + _append_entry(jsonl, "entry-store-append-1", "a new message arrives") + + stores = _spy_stores(monkeypatch) + convert_jsonl_to("html", work_dir, silent=True, write_combined=False) + + assert stores, "no store was created" + assert sum(s.hits for s in stores) > 0, ( + "the appended file was re-materialised from the cache instead of " + "being served from the store" + ) + + def test_a_cold_conversion_stores_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Residency is bounded by what changed, not by the project. + + Only ``_incremental_cache_refresh`` fills the store, so a cold + run — and by the same token the streaming page loads, which are + never handed one — carries no extra memory. + """ + work_dir = _copy_project(tmp_path) + stores = _spy_stores(monkeypatch) + convert_jsonl_to("html", work_dir, silent=True, write_combined=False) + + assert stores, "no store was created" + assert all(s.held_bytes == 0 for s in stores) + assert all(s.hits == 0 for s in stores) + + def test_output_is_byte_identical_with_and_without_the_store( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two copies advance through the same append; bytes must match.""" + on_dir, on_jsonl = self._grown_project(tmp_path / "on") + monkeypatch.setenv("CLAUDE_CODE_LOG_ENTRY_STORE", "0") + off_dir, off_jsonl = self._grown_project(tmp_path / "off") + + for jsonl in (on_jsonl, off_jsonl): + _append_entry(jsonl, "entry-store-append-1", "a new message arrives") + + convert_jsonl_to("html", off_dir, silent=True, write_combined=False) + monkeypatch.delenv("CLAUDE_CODE_LOG_ENTRY_STORE") + convert_jsonl_to("html", on_dir, silent=True, write_combined=False) + + assert _session_files(on_dir) == _session_files(off_dir) + + def test_agent_session_ids_are_not_double_suffixed( + self, tmp_path: Path, entries: list[Any] + ) -> None: + """The concrete failure a shared (uncopied) list would produce. + + ``_integrate_agent_entries`` appends ``#agent-{id}`` to + ``sessionId`` unconditionally, so one list handed to two + consumers — which is precisely what the closure load and the + session-scoped render are — comes out as ``…#agent-X#agent-X`` + for the second. Asserted where the transformation happens rather + than in the rendered page, which does not surface the synthetic + id verbatim. + """ + path = tmp_path / "a.jsonl" + path.write_text("x", encoding="utf-8") + store = ParsedEntryStore() + store.put(path, stamp_file(path), entries) + + doubled = re.compile(r"#agent-.*#agent-") + suffixed = 0 + for consumer in range(2): + served = store.get(path) + assert served is not None + converter._integrate_agent_entries(served) + ids = [getattr(e, "sessionId", None) for e in served] + suffixed = sum(1 for sid in ids if sid and "#agent-" in sid) + assert suffixed, ( + "fixture yielded no agent-suffixed session ids — this test " + "would pass vacuously" + ) + for sid in ids: + assert sid is None or not doubled.search(sid), ( + f"consumer {consumer} saw a doubly-suffixed id: {sid}" + ) + + def test_repeated_ticks_stay_identical( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Several appends in a row, store on vs off, byte-for-byte.""" + on_dir, on_jsonl = self._grown_project(tmp_path / "on") + monkeypatch.setenv("CLAUDE_CODE_LOG_ENTRY_STORE", "0") + off_dir, off_jsonl = self._grown_project(tmp_path / "off") + monkeypatch.delenv("CLAUDE_CODE_LOG_ENTRY_STORE") + + for tick in range(3): + for jsonl in (on_jsonl, off_jsonl): + _append_entry(jsonl, f"tick-{tick}", f"message {tick}") + monkeypatch.setenv("CLAUDE_CODE_LOG_ENTRY_STORE", "0") + convert_jsonl_to("html", off_dir, silent=True, write_combined=False) + monkeypatch.delenv("CLAUDE_CODE_LOG_ENTRY_STORE") + convert_jsonl_to("html", on_dir, silent=True, write_combined=False) + + assert _session_files(on_dir) == _session_files(off_dir), ( + f"diverged on tick {tick}" + ) + + +# --------------------------------------------------------------------------- +# Resumable parsing and append-only writes +# --------------------------------------------------------------------------- + + +def _dump(entries: list[Any]) -> list[dict[str, Any]]: + return [e.model_dump() for e in entries] + + +def _synthetic_project(tmp_path: Path, sessions: int = 2, lines: int = 6) -> Path: + """A project with no subagents — the shape the append-only write covers.""" + project = tmp_path / "-Users-dain-workspace-synthetic" + project.mkdir(parents=True) + for s in range(sessions): + sid = f"5eaf00d0-0000-4000-8000-00000000000{s}" + jsonl = project / f"{sid}.jsonl" + with jsonl.open("w", encoding="utf-8") as f: + parent = None + for i in range(lines): + uuid = f"{sid[:-2]}{s}{i}" + f.write( + json.dumps( + { + "type": "user" if i % 2 == 0 else "assistant", + "timestamp": f"2025-07-03T18:0{i}:00Z", + "parentUuid": parent, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp", + "sessionId": sid, + "version": "1.0.0", + "uuid": uuid, + "message": ( + { + "role": "user", + "content": [{"type": "text", "text": f"q{i}"}], + } + if i % 2 == 0 + else { + "role": "assistant", + "model": "claude-opus-5", + "content": [{"type": "text", "text": f"a{i}"}], + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + ), + } + ) + + "\n" + ) + parent = uuid + return project + + +def _message_rows(project: Path) -> list[tuple[Any, ...]]: + """Every cached message row, in table order, content included.""" + import sqlite3 + + db = project.parent / "claude-code-log-cache.db" + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: + return [ + tuple(r) + for r in con.execute( + """SELECT cf.file_name, m.type, m.timestamp, m.session_id, + m._uuid, m._parent_uuid, m.content + FROM messages m JOIN cached_files cf ON m.file_id = cf.id + ORDER BY cf.file_name, m.id""" + ) + ] + finally: + con.close() + + +class TestByteParseEquivalence: + """The byte reader must produce exactly what the text reader produced.""" + + @pytest.mark.parametrize( + "fixture", + [p.name for p in sorted(AGENT_PROJECT.glob("*.jsonl")) if p.stat().st_size], + ) + def test_whole_file(self, fixture: str) -> None: + path = AGENT_PROJECT / fixture + text_path = _dump(load_transcript(path, silent=True)) + byte_path = _dump( + load_transcript(path, silent=True, entry_store=ParsedEntryStore()) + ) + assert text_path == byte_path + + def test_resumed_parse_equals_a_fresh_one(self, tmp_path: Path) -> None: + source = _busiest_trunk(AGENT_PROJECT) + raw = source.read_bytes() + body = [ln for ln in raw.split(b"\n") if ln.strip()] + work = tmp_path / source.name + + work.write_bytes(b"\n".join(body[:-3]) + b"\n") + store = ParsedEntryStore() + load_transcript(work, silent=True, entry_store=store) + + work.write_bytes(b"\n".join(body) + b"\n") # the append + resumed = _dump(load_transcript(work, silent=True, entry_store=store)) + fresh = _dump(load_transcript(work, silent=True)) + + assert store.prefix_hits == 1 + assert resumed == fresh + + def test_rewritten_history_drops_the_prefix(self, tmp_path: Path) -> None: + """A rewound/replayed session must not be resumed onto.""" + source = _busiest_trunk(AGENT_PROJECT) + body = [ln for ln in source.read_bytes().split(b"\n") if ln.strip()] + work = tmp_path / source.name + + work.write_bytes(b"\n".join(body[:-3]) + b"\n") + store = ParsedEntryStore() + load_transcript(work, silent=True, entry_store=store) + + # Same length, different history: the size/mtime check alone would + # miss this; the prefix hash is what catches it. + rewritten = list(body[:-3]) + rewritten[1] = rewritten[-1] + work.write_bytes(b"\n".join(rewritten + body[-3:]) + b"\n") + + resumed = _dump(load_transcript(work, silent=True, entry_store=store)) + fresh = _dump(load_transcript(work, silent=True)) + assert store.prefix_misses == 1 + assert resumed == fresh + + def test_a_torn_final_line_is_picked_up_next_time(self, tmp_path: Path) -> None: + """Mid-append bytes stay out of the prefix (C12).""" + work = tmp_path / "torn.jsonl" + entry = { + "type": "user", + "timestamp": "2025-07-03T18:00:00Z", + "parentUuid": None, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp", + "sessionId": "torn", + "version": "1.0.0", + "uuid": "torn-1", + "message": {"role": "user", "content": [{"type": "text", "text": "one"}]}, + } + complete = json.dumps(entry) + "\n" + second = json.dumps({**entry, "uuid": "torn-2"}) + "\n" + + work.write_text(complete + second[:20], encoding="utf-8") # torn tail + store = ParsedEntryStore() + first = load_transcript(work, silent=True, entry_store=store) + assert len(first) == 1 + + work.write_text(complete + second, encoding="utf-8") # it lands + resumed = _dump(load_transcript(work, silent=True, entry_store=store)) + assert _dump(load_transcript(work, silent=True)) == resumed + assert len(resumed) == 2 + + def test_an_unterminated_final_line_is_not_parsed_twice( + self, tmp_path: Path + ) -> None: + """A final line whose newline hasn't landed yet parses, but isn't held. + + The other half of C12: the torn tail above fails to parse, so + holding it would be harmless. A *complete* record whose trailing + newline hasn't been flushed yet parses fine — and its bytes are + still below the prefix cut, so holding its entry would make the + next tick parse the same line a second time. Two of this repo's + own fixtures end without a trailing newline, so this is not only + a mid-append shape. + """ + work = tmp_path / "unterminated.jsonl" + entry = { + "type": "user", + "timestamp": "2025-07-03T18:00:00Z", + "parentUuid": None, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp", + "sessionId": "unterminated", + "version": "1.0.0", + "uuid": "u-1", + "message": {"role": "user", "content": [{"type": "text", "text": "one"}]}, + } + lines = [json.dumps({**entry, "uuid": f"u-{n}"}) for n in (1, 2, 3)] + fourth = json.dumps({**entry, "uuid": "u-4"}) + + # Ends mid-line: the third record is whole, its newline is not there. + work.write_text("\n".join(lines), encoding="utf-8") + store = ParsedEntryStore() + first = load_transcript(work, silent=True, entry_store=store) + assert [e.uuid for e in first] == ["u-1", "u-2", "u-3"] # type: ignore[union-attr] + + work.write_text("\n".join(lines + [fourth]) + "\n", encoding="utf-8") + resumed = _dump(load_transcript(work, silent=True, entry_store=store)) + assert _dump(load_transcript(work, silent=True)) == resumed + + +def _count_append_proposals(monkeypatch: pytest.MonkeyPatch) -> list[int]: + """Record every time the *caller's* gate offered rows for appending. + + Separate from :func:`_count_append_only_writes` on purpose: the two + are different layers. This one is the parse-side proof ("the rows are + just this file's own new lines"); that one is the row-side check + ("the table still holds what we think we wrote"). Asserting only on + the write would let a broken gate hide behind the check. + """ + proposals: list[int] = [] + original = converter._appended_rows + + def _spy(*args: Any, **kwargs: Any) -> Any: + out = original(*args, **kwargs) + if out is not None: + proposals.append(len(out)) + return out + + monkeypatch.setattr(converter, "_appended_rows", _spy) + return proposals + + +def _count_append_only_writes(monkeypatch: pytest.MonkeyPatch) -> list[int]: + """Record every append-only cache write that actually succeeded.""" + from claude_code_log.cache import CacheManager + + calls: list[int] = [] + original = CacheManager.extend_cached_entries + + def _spy(self: Any, jsonl_path: Path, all_entries: Any, appended: Any, **kw: Any): + result = original(self, jsonl_path, all_entries, appended, **kw) + if result: + calls.append(len(appended)) + return result + + monkeypatch.setattr(CacheManager, "extend_cached_entries", _spy) + return calls + + +class TestAppendOnlyWrites: + """Only the new rows are written — and the table must not tell.""" + + def _tick(self, project: Path, store: Any) -> None: + convert_jsonl_to( + "html", + project, + silent=True, + write_combined=False, + generate_individual_sessions=True, + entry_store=store, + ) + + def test_rows_match_a_full_rewrite_over_repeated_appends( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The bar for a write-path change: DB state, not just HTML.""" + extended = _count_append_only_writes(monkeypatch) + appended = _synthetic_project(tmp_path / "appended") + rewritten = _synthetic_project(tmp_path / "rewritten") + store = ParsedEntryStore() + self._tick(appended, store) + self._tick(rewritten, None) + + for tick in range(4): + for project in (appended, rewritten): + target = sorted(project.glob("*.jsonl"))[0] + _append_entry(target, f"grow-{tick}", f"message {tick}") + self._tick(appended, store) + self._tick(rewritten, None) + + assert _message_rows(appended) == _message_rows(rewritten), ( + f"cache rows diverged on tick {tick}" + ) + assert _session_files(appended).keys() == _session_files(rewritten).keys() + + assert store.prefix_hits > 0, "the resumable path never engaged" + assert extended, ( + "no append-only write happened — this equivalence test would " + "have compared two full rewrites and proved nothing" + ) + + def test_rows_match_when_a_tick_lands_on_an_unterminated_line( + self, tmp_path: Path + ) -> None: + """A tick that sees a whole record without its newline yet. + + The parse-side twin of this is in + ``TestByteParseEquivalence``; this is the half that would show up + in the cache, where a re-parsed line becomes a duplicate *row* + rather than a transient duplicate entry. + """ + appended = _synthetic_project(tmp_path / "appended") + rewritten = _synthetic_project(tmp_path / "rewritten") + store = ParsedEntryStore() + + def both(mutate: Any) -> None: + for project, held in ((appended, store), (rewritten, None)): + mutate(sorted(project.glob("*.jsonl"))[0]) + self._tick(project, held) + + both(lambda _target: None) # cold: nothing is held yet + both(lambda target: _append_entry(target, "grow", "a first append")) + + def torn(target: Path) -> None: # a whole record, newline not yet + _append_entry(target, "whole", "flushed without its newline") + target.write_bytes(target.read_bytes().rstrip(b"\n")) + + both(torn) + + def lands(target: Path) -> None: + with target.open("a", encoding="utf-8") as f: + f.write("\n") + _append_entry(target, "after", "the write that follows it") + + both(lands) + + assert store.prefix_hits > 0, "the resumable path never engaged" + assert _message_rows(appended) == _message_rows(rewritten) + + def test_a_growing_agent_file_inserts_rows_mid_sequence( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Why the append-only write refuses agent-bearing transcripts. + + A trunk's cached rows are not its lines: each referenced agent's + transcript is spliced in at its anchor. When a subagent is still + running — the normal case under ``watch`` — that block *grows*, so + rows land in the middle of the sequence even though the trunk file + itself only gained lines at the end. Treating that as an append + would leave the agent's new entries out of the cache. + + Three ticks, because the hazard needs a resumable prefix to exist + first: cold, an append that establishes one, then an append that + lands alongside a growing agent file. + """ + extended = _count_append_only_writes(monkeypatch) + proposed = _count_append_proposals(monkeypatch) + work_dir = _copy_project(tmp_path) + control = _copy_project(tmp_path / "control") + + def advance(project: Path, store: Any, tick: int, grow_agent: bool) -> None: + _append_entry( + _busiest_trunk(project), f"trunk-{tick}", f"trunk message {tick}" + ) + if grow_agent: + agent = project / "agent-668b5ac2.jsonl" + assert agent.exists(), "fixture lost its agent transcript" + _append_entry(agent, f"agent-{tick}", f"agent message {tick}") + self._tick(project, store) + + store = ParsedEntryStore() + self._tick(work_dir, store) + self._tick(control, None) + advance(work_dir, store, 0, grow_agent=False) # establishes a prefix + advance(control, None, 0, grow_agent=False) + advance(work_dir, store, 1, grow_agent=True) # the hazard + advance(control, None, 1, grow_agent=True) + + assert store.prefix_hits > 0, "no prefix was ever resumed from" + assert _message_rows(work_dir) == _message_rows(control) + # The gate must refuse to *offer* these rows. Asserting only on + # the write below would pass even with the gate gone, because the + # row-count check then catches the bad offer — measured: it + # proposes a wrong 96-entry slice and the check refuses it. Good + # defence in depth, useless as a test of the gate. + assert not proposed, ( + f"the gate offered {proposed} rows for an agent-bearing file, " + "whose cached rows carry spliced agent blocks" + ) + assert not extended, "an agent-bearing file took the append-only path" + + def test_extend_refuses_when_the_rows_are_not_what_we_wrote( + self, tmp_path: Path + ) -> None: + """The cross-process guard: another writer changed the row count.""" + from claude_code_log.cache import CacheManager, get_library_version + + project = _synthetic_project(tmp_path) + cache = CacheManager(project, get_library_version()) + target = sorted(project.glob("*.jsonl"))[0] + entries = load_transcript(target, cache, silent=True) + + # Pretend the file has one more entry than the cache holds *and* + # that only that one is new — i.e. a count the table disagrees with. + assert not cache.extend_cached_entries(target, entries[:-2], entries[-1:]) + # And the honest case still works. + assert cache.extend_cached_entries(target, entries + entries[-1:], entries[-1:]) + + def test_the_count_and_the_insert_hold_one_write_lock( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Why that guard needs a transaction to be a guard at all. + + Python's sqlite3 opens a transaction on the first write, not on a + SELECT, so between the row count and the insert another writer + sharing the cache — a second `watch`, a TUI beside one — could + append rows the count would have refused, and both appends would + land. Asserted by proving the lock is held *while* the append + runs: a second connection cannot write at that moment. + """ + import sqlite3 + + from claude_code_log.cache import CacheManager, get_library_version + + project = _synthetic_project(tmp_path) + cache = CacheManager(project, get_library_version()) + target = sorted(project.glob("*.jsonl"))[0] + entries = load_transcript(target, cache, silent=True) + + db = project.parent / "claude-code-log-cache.db" + outcome: list[str] = [] + original = CacheManager._append_under_lock + + def _spy(self: Any, *args: Any, **kwargs: Any) -> Any: + other = sqlite3.connect(db, timeout=0) + try: + other.execute("BEGIN IMMEDIATE") + outcome.append("wrote") + other.rollback() + except sqlite3.OperationalError as exc: + outcome.append(str(exc)) + finally: + other.close() + return original(self, *args, **kwargs) + + monkeypatch.setattr(CacheManager, "_append_under_lock", _spy) + assert cache.extend_cached_entries(target, entries + entries[-1:], entries[-1:]) + assert outcome and "locked" in outcome[0], ( + f"another writer got in during the checked append: {outcome}" + ) + + +class TestSessionLoadOrdering: + """`load_session_entries` must not reorder when its index changes. + + Migration 012's session index carries `timestamp` before `file_id` + specifically so the planner can satisfy this query's filter *and* its + `ORDER BY` from one index, instead of walking the whole project to + load a single session (84.5 ms -> 7.2 ms for the twelve busiest + sessions of a 19k-row archive). + + An index that changes the returned order would be a silent rendering + change, and ties are not rare — real archives have tens of thousands + of rows sharing a timestamp with another row. This pins the property + against a fixture built to be hostile: duplicate timestamps, rows + split across two files within one session, and NULL timestamps. + """ + + @staticmethod + def _entry(sid: str, uuid: str, ts: Any, text: str) -> dict[str, Any]: + entry: dict[str, Any] = { + "type": "user", + "parentUuid": None, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp", + "sessionId": sid, + "version": "1.0.0", + "uuid": uuid, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}, + } + if ts is not None: + entry["timestamp"] = ts + return entry + + def test_order_matches_an_explicit_timestamp_sort(self, tmp_path: Path) -> None: + from claude_code_log.cache import CacheManager, get_library_version + + project = tmp_path / "-Users-dain-workspace-ordering" + project.mkdir(parents=True) + sid = "5eaf00d0-0000-4000-8000-000000000099" + + # One session, two files, interleaved and duplicated timestamps. + first = project / f"{sid}.jsonl" + second = project / "5eaf00d0-0000-4000-8000-000000000100.jsonl" + with first.open("w", encoding="utf-8") as f: + for i, ts in enumerate( + ["2025-07-03T18:00:00Z", "2025-07-03T18:00:00Z", "2025-07-03T18:02:00Z"] + ): + f.write(json.dumps(self._entry(sid, f"a{i}", ts, f"a{i}")) + "\n") + with second.open("w", encoding="utf-8") as f: + for i, ts in enumerate( + ["2025-07-03T18:00:00Z", "2025-07-03T18:01:00Z", "2025-07-03T18:02:00Z"] + ): + f.write(json.dumps(self._entry(sid, f"b{i}", ts, f"b{i}")) + "\n") + + convert_jsonl_to("html", project, silent=True, write_combined=False) + cache = CacheManager(project, get_library_version()) + + entries = cache.load_session_entries(sid) + assert len(entries) == 6, "fixture did not land in one session" + + stamps = [getattr(e, "timestamp", None) for e in entries] + non_null = [s for s in stamps if s] + assert non_null == sorted(non_null), ( + f"session load came back out of timestamp order: {stamps}" + ) + assert len({getattr(e, "uuid", None) for e in entries}) == 6 + + # The index must be the one serving it — otherwise this test would + # keep passing while the query silently went back to scanning. + with cache._get_connection() as conn: + plan = [ + r[-1] + for r in conn.execute( + "EXPLAIN QUERY PLAN SELECT content FROM messages " + "WHERE project_id = ? AND session_id = ? " + "ORDER BY timestamp NULLS LAST", + (cache._project_id, sid), + ) + ] + assert any("idx_messages_project_session_ts" in p for p in plan), ( + f"session load is not using its index — plan was {plan}" + ) + assert not any("TEMP B-TREE" in p.upper() for p in plan), ( + f"the index no longer satisfies the ORDER BY — plan was {plan}" + ) diff --git a/test/test_html_regeneration.py b/test/test_html_regeneration.py index 0184529c..b8536a45 100644 --- a/test/test_html_regeneration.py +++ b/test/test_html_regeneration.py @@ -7,6 +7,7 @@ from unittest.mock import patch +from claude_code_log import converter as converter_module from claude_code_log.converter import ( convert_jsonl_to_html, process_projects_hierarchy, @@ -184,11 +185,17 @@ def test_projects_index_regeneration_on_jsonl_change(self, tmp_path): # no-op run produces byte-identical content, and an mtime # comparison can falsely fail (or pass) within the filesystem's # timestamp granularity. - real_write_text = Path.write_text + # + # The seam is `atomic_write_text`, not `Path.write_text`: output + # writes go to a uniquely-named temp file and are then renamed + # into place, so the destination path only appears at this call. + real_atomic_write = converter_module.atomic_write_text with ( patch("builtins.print") as mock_print, patch.object( - Path, "write_text", autospec=True, side_effect=real_write_text + converter_module, + "atomic_write_text", + side_effect=real_atomic_write, ) as write_spy, ): process_projects_hierarchy(projects_dir, silent=False) diff --git a/test/test_live_update.py b/test/test_live_update.py new file mode 100644 index 00000000..0d6351ff --- /dev/null +++ b/test/test_live_update.py @@ -0,0 +1,697 @@ +"""The served page updating itself while a session is still being written. + +These are live-server browser tests by necessity: the feature only +activates over http, because a `file://` page cannot fetch anything — +not a sibling, not even itself. The rest of the browser suite runs from +`file://`, so it cannot cover this. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +from pathlib import Path + +import pytest + +from claude_code_log.converter import process_projects_hierarchy +from claude_code_log.watch import WatchEngine + +SESSION_ID = "dddddddd-eeee-ffff-0000-111111111111" + + +def _entry(uuid: str, text: str) -> str: + return ( + json.dumps( + { + "type": "user", + "timestamp": "2026-08-30T21:00:00Z", + "parentUuid": None, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp/live", + "sessionId": SESSION_ID, + "version": "1.0.0", + "uuid": uuid, + "message": { + "role": "user", + "content": [{"type": "text", "text": text}], + }, + } + ) + + "\n" + ) + + +@pytest.fixture +def live_archive(tmp_path: Path): + """A served project with a watcher, and a handle to append to it.""" + projects = tmp_path / "projects" + project = projects / "-tmp-live" + project.mkdir(parents=True) + jsonl = project / f"{SESSION_ID}.jsonl" + # Enough content that the page scrolls, so scroll preservation is + # actually being tested rather than trivially true. + jsonl.write_text( + "".join( + _entry(f"seed-{i}", f"seed message {i} " + ("padding " * 40)) + for i in range(40) + ), + encoding="utf-8", + ) + process_projects_hierarchy(projects, silent=True) + + from claude_code_log.server import ArchiveServer + + engine = WatchEngine( + [projects], + lambda _paths: process_projects_hierarchy(projects, silent=True), + quiet_period=0.1, + max_latency=0.5, + poll_interval=0.05, + on_error=lambda exc: pytest.fail(f"watch conversion failed: {exc!r}"), + ) + engine.prime() + stop = threading.Event() + thread = engine.run_in_thread(stop) + + server = ArchiveServer(projects, port=0) + server.start() + try: + yield server.url, project, jsonl + finally: + stop.set() + thread.join(timeout=10) + server.stop() + + +def _wait_for(page, expression: str, timeout: int = 30000) -> None: + page.wait_for_function(expression, timeout=timeout) + + +@pytest.mark.browser +class TestLiveUpdate: + def _open(self, page, base: str, project: Path): + errors: list[str] = [] + page.on( + "console", lambda m: errors.append(m.text) if m.type == "error" else None + ) + page.on("pageerror", lambda e: errors.append(str(e))) + page.goto(f"{base}/{project.name}/session-{SESSION_ID}.html") + page.wait_for_selector("#transcript") + return errors + + def test_a_new_message_appears_without_navigating(self, page, live_archive) -> None: + """The whole point: the page updates in place, not by reloading.""" + base, project, jsonl = live_archive + errors = self._open(page, base, project) + + # A navigation would wipe this; a container swap must not. + page.evaluate("window.__stillHere = 'yes'") + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("live-1", "LIVE-MARKER-ONE")) + + _wait_for(page, "() => document.body.innerText.includes('LIVE-MARKER-ONE')") + assert page.evaluate("window.__stillHere") == "yes", "the page navigated" + assert errors == [] + + def test_scroll_position_survives_an_update(self, page, live_archive) -> None: + base, project, jsonl = live_archive + self._open(page, base, project) + page.evaluate("window.scrollTo(0, 600)") + before = page.evaluate("window.scrollY") + assert before > 0, "fixture is not tall enough to test scrolling" + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("live-2", "LIVE-MARKER-TWO")) + _wait_for(page, "() => document.body.innerText.includes('LIVE-MARKER-TWO')") + + assert page.evaluate("window.scrollY") == before + + def test_new_cards_are_tagged_for_the_fade_in(self, page, live_archive) -> None: + """Transcripts record whole messages, never partial tokens, so a + card can only ever appear complete. Announcing that arrival is the + most honest 'streaming' the page can offer.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("live-3", "LIVE-MARKER-THREE")) + _wait_for( + page, "() => document.querySelectorAll('.message.live-new').length > 0" + ) + + # The follow toggle belongs to the page's floating-button stack and + # is revealed once polling starts, so it carries the unseen count + # rather than being built from scratch on first arrival. + follow = page.locator("#followUpdates") + assert follow.count() == 1 + assert "live-active" in (follow.get_attribute("class") or "") + assert follow.get_attribute("data-unseen") not in (None, "0") + + def test_timestamps_on_new_cards_are_localised(self, page, live_archive) -> None: + """The rehydrate contract, end to end: timestamp localisation + rewrites innerHTML after load, so swapped-in markup would keep raw + ISO strings unless the hook re-runs over it.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("live-4", "LIVE-MARKER-FOUR")) + _wait_for(page, "() => document.body.innerText.includes('LIVE-MARKER-FOUR')") + + shown = page.evaluate( + "() => { const t = [...document.querySelectorAll('.timestamp[data-timestamp]')];" + " const last = t[t.length - 1];" + " return last && {text: last.textContent.trim()," + " raw: last.getAttribute('data-timestamp')}; }" + ) + assert shown, "no timestamp element found" + assert shown["text"] != shown["raw"], "the new card kept its raw ISO timestamp" + + def test_fold_state_survives_an_update(self, page, live_archive) -> None: + """Session headers fold but carry no `data-uuid` — on a + single-session page the header is the *only* foldable node, so a + uuid-keyed capture would silently preserve nothing.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + bar = page.locator(".fold-bar-section.fold-one-level").first + assert bar.count() > 0, "fixture has nothing foldable" + bar.click() + page.wait_for_timeout(200) + folded = page.evaluate( + "() => [...document.querySelectorAll('.message-node > .children')]" + ".filter(c => c.style.display === 'none').length" + ) + assert folded > 0, "clicking the fold bar did not fold anything" + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("live-5", "LIVE-MARKER-FIVE")) + # innerText cannot see inside a display:none container, so assert + # on DOM presence. + _wait_for(page, "() => !!document.querySelector('[data-uuid=\"live-5\"]')") + + still_folded = page.evaluate( + "() => [...document.querySelectorAll('.message-node > .children')]" + ".filter(c => c.style.display === 'none').length" + ) + assert still_folded == folded, "fold state was lost across the update" + + # Toggle the first session header's fold bar, reporting the children + # container's `display` either side of the click. A working control + # changes it; a dead listener leaves it exactly as it was. + _CLICK_FOLD = ( + "() => { const bar = document.querySelector('#transcript .message" + ".session-header > .fold-bar');" + " const section = bar && bar.querySelector('.fold-bar-section');" + " if (!section) return null;" + " const children = section.closest('.message-node')" + ".querySelector(':scope > .children');" + " const before = children.style.display;" + " section.click();" + " return { before, after: children.style.display," + " folded: section.classList.contains('folded') }; }" + ) + + def test_the_fold_control_still_works_after_an_update( + self, page, live_archive + ) -> None: + """The fold bars were bound per element at load, and a live update + replaces them: every append re-renders the bar of every ancestor + (it carries their descendant count), and the swap replaces all of + them at once. The listeners died with the elements, so one update + was enough to leave every fold control on the page inert — while + still *looking* exactly right, which is why nothing caught it. + + Asserting that the state survives an update is not the same + assertion and passed throughout. + """ + base, project, jsonl = live_archive + self._open(page, base, project) + + first = page.evaluate(self._CLICK_FOLD) + assert first is not None, "fixture has nothing foldable" + assert first["before"] != first["after"], "the control was dead on load" + page.evaluate(self._CLICK_FOLD) # back to unfolded + + # Update one: the swap. + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("fold-1", "FOLD-ONE")) + _wait_for(page, "() => !!document.querySelector('[data-uuid=\"fold-1\"]')") + after_swap = page.evaluate(self._CLICK_FOLD) + assert after_swap["before"] != after_swap["after"], ( + "the fold control stopped responding after the container swap" + ) + page.evaluate(self._CLICK_FOLD) + + # Update two: the patch, which replaces the header card on its own + # rather than the whole container. + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("fold-2", "FOLD-TWO")) + _wait_for(page, "() => !!document.querySelector('[data-uuid=\"fold-2\"]')") + after_patch = page.evaluate(self._CLICK_FOLD) + assert after_patch["before"] != after_patch["after"], ( + "the fold control stopped responding after the patch" + ) + + def test_an_update_during_page_load_is_not_absorbed( + self, page, live_archive + ) -> None: + """The first poll cannot adopt whatever the server holds by then. + + It only runs once the document has loaded, and that is exactly the + window this feature's pages are slow in — tens of MB. A conversion + finishing in it would become the baseline, so the page would sit + one update behind until something *else* changed, which for a + session that has just gone quiet is forever. + """ + base, project, jsonl = live_archive + url = f"{base}/{project.name}/session-{SESSION_ID}.html" + page_file = project / f"session-{SESSION_ID}.html" + served_stale: list[bool] = [] + + def hold(route): + # Only the navigation: the page's own HEAD and GET share this + # URL and must reach the server as usual. + if route.request.resource_type != "document" or served_stale: + route.continue_() + return + # Take the document as it is now, then let the session move on + # and the watch reconvert *before* handing it to the browser. + response = route.fetch() + body = response.body() + served_stale.append(b"LOAD-RACE-MARKER" not in body) + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("load-race", "LOAD-RACE-MARKER")) + deadline = time.time() + 20 + while time.time() < deadline: + if "LOAD-RACE-MARKER" in page_file.read_text(encoding="utf-8"): + break + time.sleep(0.05) + route.fulfill(status=response.status, headers=response.headers, body=body) + + page.route(url, hold) + self._open(page, base, project) + assert served_stale == [True], "the browser was served the new page after all" + + _wait_for(page, "() => document.body.innerText.includes('LOAD-RACE-MARKER')") + + def test_an_open_timeline_picks_up_new_cards(self, page, live_archive) -> None: + """The timeline is the one rehydrate hook that reads the whole page. + + It is therefore called once per changed element and coalesced to a + single rebuild per update — so this asserts the rebuild still + happens at all, which the coalescing is the only thing standing + between the timeline and. + """ + base, project, jsonl = live_archive + self._open(page, base, project) + page.locator("#toggleTimeline").click() + # The library is fetched from a CDN on first open. + page.wait_for_selector(".vis-item", timeout=30000) + before = page.locator(".vis-item").count() + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("timeline-1", "TIMELINE-MARKER")) + _wait_for(page, "() => document.body.innerText.includes('TIMELINE-MARKER')") + + _wait_for( + page, + "() => [...document.querySelectorAll('.vis-item')]" + ".some(el => el.innerText.includes('TIMELINE-MARKER'))", + timeout=10000, + ) + assert page.locator(".vis-item").count() > before + + def test_a_replaced_fold_bar_still_describes_its_own_subtree( + self, page, live_archive + ) -> None: + """The card carries the fold bar; the children container carries the + fold *state*. An update replaces the first and not the second, so + the bar comes back with the server's default "unfolded" icons over + a subtree that is still hidden. The next click then folds what is + already folded and appears to do nothing at all. + """ + base, project, jsonl = live_archive + self._open(page, base, project) + + folded = page.evaluate(self._CLICK_FOLD) + assert folded["after"] == "none", "the first click should fold" + assert folded["folded"], "the bar did not mark itself folded" + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("desync-1", "DESYNC-ONE")) + _wait_for(page, "() => !!document.querySelector('[data-uuid=\"desync-1\"]')") + + state = page.evaluate( + "() => { const bar = document.querySelector('#transcript .message" + ".session-header > .fold-bar');" + " const section = bar.querySelector('.fold-bar-section');" + " const children = section.closest('.message-node')" + ".querySelector(':scope > .children');" + " return { hidden: children.style.display === 'none'," + " folded: section.classList.contains('folded')," + " icon: section.querySelector('.fold-icon').textContent }; }" + ) + assert state["hidden"], "the subtree unfolded itself across the update" + assert state["folded"], "the replaced bar forgot it was folded" + assert state["icon"] == "⏵", f"the icon disagrees with the subtree: {state}" + + # And it is still a working toggle, not merely a correct label. + again = page.evaluate(self._CLICK_FOLD) + assert again["after"] != "none", "the next click did not unfold" + + # ---- patching ---------------------------------------------------- + # + # The first update of a session always swaps: the hashes a patch + # compares against are taken from parsed markup, and the first update + # is where they are first taken. So every test below appends twice — + # once to seed, once to exercise the patch. Asserting only that the + # new message arrived would pass just as well with the patch disabled + # entirely, so these assert on *element identity*, which the swap + # necessarily destroys and the patch necessarily keeps. + + _TAG_CARDS = ( + "() => { let n = 0;" + " document.querySelectorAll('#transcript .message').forEach(el => {" + " el.__probe = true; n++; });" + " return n; }" + ) + _COUNT_TAGGED = ( + "() => { const els = [...document.querySelectorAll('#transcript .message')];" + " return { total: els.length, kept: els.filter(e => e.__probe).length }; }" + ) + + def test_an_append_patches_rather_than_rebuilding_the_page( + self, page, live_archive + ) -> None: + """A swap replaces every node in the container, so no element on + screen survives it. A patch touches only what changed.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("patch-seed", "PATCH-SEED")) + _wait_for(page, "() => document.body.innerText.includes('PATCH-SEED')") + + tagged = page.evaluate(self._TAG_CARDS) + assert tagged > 10, "fixture too small to distinguish patch from swap" + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("patch-two", "PATCH-TWO")) + _wait_for(page, "() => document.body.innerText.includes('PATCH-TWO')") + + after = page.evaluate(self._COUNT_TAGGED) + assert after["total"] == tagged + 1, "the appended card did not arrive" + # A swap scores exactly 0. A patch keeps everything but the few + # cards whose own markup really changed — in practice the ancestors + # whose fold bar counts their descendants. + assert after["kept"] > 0, "every card was rebuilt: the patch did not run" + assert after["kept"] >= tagged - 5, ( + f"patch replaced more than expected: {tagged - after['kept']} cards" + ) + + def test_a_patch_leaves_existing_timestamps_localised( + self, page, live_archive + ) -> None: + """Timestamp localisation rewrites innerHTML, so a rebuilt card + comes back with a raw ISO string and has to be converted again. + Across a growing session that is the whole page, every update.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("ts-seed", "TS-SEED")) + _wait_for(page, "() => document.body.innerText.includes('TS-SEED')") + _wait_for( + page, + "() => { const t = document.querySelector('#transcript .timestamp" + "[data-timestamp]'); return t && t.childElementCount > 0; }", + ) + page.evaluate( + "() => { document.querySelector('#transcript .timestamp[data-timestamp]')" + ".__probe = true; }" + ) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("ts-two", "TS-TWO")) + _wait_for(page, "() => document.body.innerText.includes('TS-TWO')") + + first = page.evaluate( + "() => { const t = document.querySelector('#transcript .timestamp" + "[data-timestamp]');" + " return t && { kept: !!t.__probe, localised: t.childElementCount > 0 }; }" + ) + assert first["kept"], "the first timestamp's element was rebuilt" + assert first["localised"], "the first timestamp lost its localisation" + + def test_the_swap_is_the_fallback_when_the_ids_move( + self, page, live_archive + ) -> None: + """The patch is only valid while the card ids still mean what they + meant. Out-of-order arrivals renumber the positional `msg-d-N` + ids — measured at 2 of 47 growth steps across three real sessions — + and the update must fall back rather than patch against stale + identities.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("fallback-seed", "FALLBACK-SEED")) + _wait_for(page, "() => document.body.innerText.includes('FALLBACK-SEED')") + + # Control: with the ids intact, this same append patches. Without + # it, `kept == 0` below would prove only that *something* swapped — + # which is also what a permanently broken patch looks like. + page.evaluate(self._TAG_CARDS) + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("fallback-ctl", "FALLBACK-CTL")) + _wait_for(page, "() => document.body.innerText.includes('FALLBACK-CTL')") + assert page.evaluate(self._COUNT_TAGGED)["kept"] > 0, ( + "control failed: the patch did not run even with the ids intact" + ) + + # Renumbering, simulated at its effect: the live tree's id sequence + # no longer matches the one the next render will carry. + page.evaluate(self._TAG_CARDS) + page.evaluate( + "() => { const els = [...document.querySelectorAll('#transcript .message')];" + " els[Math.floor(els.length / 2)].id = 'msg-d-999999'; }" + ) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("fallback-two", "FALLBACK-TWO")) + _wait_for(page, "() => document.body.innerText.includes('FALLBACK-TWO')") + + after = page.evaluate(self._COUNT_TAGGED) + assert after["kept"] == 0, "the patch ran against a renumbered tree" + # And the fallback did the job properly, not just safely. + assert page.evaluate( + "() => !!document.querySelector('#transcript .message.live-new')" + ), "the swap did not tag the new card" + + def test_following_leaves_the_newest_card_clear_of_the_viewport_edge( + self, page, live_archive + ) -> None: + """`scrollIntoView({block: 'end'})` aligns the card's bottom edge + with the viewport's, which measures as a 0px gap and reads as the + message being cut off. The padding under `body.live-following` is + what gives the scroll somewhere to go — 20px of it, which lands the + card 36px clear once the container's own margin is counted.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + page.click("#followUpdates") + assert page.evaluate( + "() => document.body.classList.contains('live-following')" + ), "clicking the toggle did not engage follow mode" + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("follow-1", "FOLLOW-ONE")) + _wait_for(page, "() => document.body.innerText.includes('FOLLOW-ONE')") + # The scroll is smooth, so let it land. + page.wait_for_timeout(1200) + + gap = page.evaluate( + "() => { const c = document.getElementById('transcript');" + " const r = c.lastElementChild.getBoundingClientRect();" + " return Math.round(window.innerHeight - r.bottom); }" + ) + assert gap > 20, f"newest card sits {gap}px from the viewport bottom" + + # And turning it off puts the page back the way it was. + page.click("#followUpdates") + assert not page.evaluate( + "() => document.body.classList.contains('live-following')" + ) + + def test_two_updates_inside_one_second_are_both_seen( + self, page, live_archive + ) -> None: + """HTTP dates have one-second granularity, so `Last-Modified` + alone makes the second of two rapid updates invisible. Observed + for real before `Content-Length` joined the comparison.""" + base, project, jsonl = live_archive + self._open(page, base, project) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("rapid-1", "RAPID-ONE")) + _wait_for(page, "() => document.body.innerText.includes('RAPID-ONE')") + # Immediately, inside the same second as the update just applied. + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("rapid-2", "RAPID-TWO")) + _wait_for(page, "() => document.body.innerText.includes('RAPID-TWO')") + + def test_a_same_size_rewrite_inside_one_second_is_seen( + self, page, tmp_path: Path + ) -> None: + """`Last-Modified` + `Content-Length` are both blind to a rewrite + that changes content without changing size — a re-render where a + counter, a status word or a timestamp keeps its width — and + inside one second neither header moves at all. The page must + still notice, via the server's content digest. + + The page is rewritten directly rather than through a conversion: + the point is a specific pair of bytes on the wire, and the two + mtimes are set half a second apart inside one whole second so + this *is* the same-second case rather than usually being it. + """ + projects = tmp_path / "projects" + project = projects / "-tmp-samesize" + project.mkdir(parents=True) + (project / f"{SESSION_ID}.jsonl").write_text( + _entry("only", "MARKER-AAAA"), encoding="utf-8" + ) + process_projects_hierarchy(projects, silent=True) + rendered = project / f"session-{SESSION_ID}.html" + + second_ns = 1_700_000_000_000_000_000 + os.utime(rendered, ns=(second_ns, second_ns)) + size_before = rendered.stat().st_size + + from claude_code_log.server import ArchiveServer + + with ArchiveServer(projects, port=0) as server: + self._open(page, server.url, project) + _wait_for(page, "() => document.body.innerText.includes('MARKER-AAAA')") + + html = rendered.read_text(encoding="utf-8") + assert "MARKER-AAAA" in html + rendered.write_text( + html.replace("MARKER-AAAA", "MARKER-BBBB"), encoding="utf-8" + ) + assert rendered.stat().st_size == size_before, ( + "the rewrite has to keep the size for this to test anything" + ) + os.utime(rendered, ns=(second_ns + 500_000_000, second_ns + 500_000_000)) + + _wait_for(page, "() => document.body.innerText.includes('MARKER-BBBB')") + + def test_a_slow_response_cannot_overwrite_a_newer_one( + self, page, live_archive + ) -> None: + """The interval keeps firing while a full GET is in flight, so on a + page slow enough to fetch — the large page all of this is for — two + updates can be in the air at once and the *last response* wins. + + Asserting on the *end* state is not enough and was measured to be + not enough: unserialised, the newest message appeared at 2.0s, + vanished at 4.0s when the held body landed, and was restored at + 5.0s by the following poll. So this watches for the regression + itself — a message that was on screen and then was not. + + Simulated at the only thing that matters — an older response + landing after a newer one — by holding the first full GET the page + makes and appending again while it is held. + """ + base, project, jsonl = live_archive + self._open(page, base, project) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("race-seed", "RACE-SEED")) + _wait_for(page, "() => document.body.innerText.includes('RACE-SEED')") + + # Hold the *next* full GET's response for 3s (well past POLL_MS) + # while letting its HEADs through, so the page keeps noticing + # changes while the older body is still in the air. Sample the + # rendered text throughout, so a message that comes and goes is + # caught rather than averaged away by a final check. + page.evaluate( + "() => { const orig = window.fetch;" + " window.__held = false;" + " window.__lost = [];" + " window.fetch = function (input, init) {" + " const p = orig.apply(this, arguments);" + " if (init && init.method === 'HEAD') return p;" + " if (window.__held) return p;" + " window.__held = true;" + " return p.then(res => new Promise(r => setTimeout(() => r(res), 3000)));" + " };" + " const seen = new Set();" + " setInterval(() => { const t = document.body.innerText;" + " for (const m of ['RACE-ONE', 'RACE-TWO']) {" + " if (t.includes(m)) seen.add(m);" + " else if (seen.has(m)) window.__lost.push(m);" + " } }, 100); }" + ) + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("race-one", "RACE-ONE")) + # The held GET has been issued: its body is the render carrying + # RACE-ONE but not RACE-TWO. + _wait_for(page, "() => window.__held === true") + + with jsonl.open("a", encoding="utf-8") as f: + f.write(_entry("race-two", "RACE-TWO")) + + _wait_for( + page, + "() => document.body.innerText.includes('RACE-ONE')" + " && document.body.innerText.includes('RACE-TWO')", + ) + # Both are on screen. Wait past the held response, and past the + # poll after it that would paper over the damage. + page.wait_for_timeout(4000) + + text = page.evaluate("() => document.body.innerText") + assert "RACE-ONE" in text, "the newer render dropped an earlier message" + assert "RACE-TWO" in text, "a stale response overwrote the newer page" + assert page.evaluate("() => window.__lost") == [], ( + "a message left the page after arriving: a stale response was applied" + ) + + def test_the_poller_is_inert_over_file_urls(self, page, tmp_path: Path) -> None: + """The generated HTML must stay exactly as useful from `file://`. + + A `file://` page cannot fetch anything at all, so the poller has + to notice and do nothing rather than throw on every interval. + """ + projects = tmp_path / "projects" + project = projects / "-tmp-static" + project.mkdir(parents=True) + (project / f"{SESSION_ID}.jsonl").write_text( + _entry("only", "static page"), encoding="utf-8" + ) + process_projects_hierarchy(projects, silent=True) + + errors: list[str] = [] + page.on( + "console", lambda m: errors.append(m.text) if m.type == "error" else None + ) + page.on("pageerror", lambda e: errors.append(str(e))) + page.goto((project / f"session-{SESSION_ID}.html").as_uri()) + page.wait_for_selector("#transcript") + time.sleep(2) # several poll intervals, had it been active + + assert errors == [] + # The follow toggle is in the markup of every transcript page, so + # that a served page and the same file on disk render identically. + # It must stay hidden here: `file://` can never poll, and a visible + # control would promise something it cannot do. + follow = page.locator("#followUpdates") + assert follow.count() == 1 + assert "live-active" not in (follow.get_attribute("class") or "") + assert not follow.is_visible() diff --git a/test/test_resume_session_browser.py b/test/test_resume_session_browser.py index ff96ea9c..7b5e3cff 100644 --- a/test/test_resume_session_browser.py +++ b/test/test_resume_session_browser.py @@ -120,6 +120,45 @@ def test_click_copies_command_and_shows_toast(self, page: Page) -> None: expect(toast).to_be_visible() expect(toast).to_contain_text("Paste into your terminal") + @pytest.mark.browser + def test_the_toast_sits_beside_its_button_and_clears_the_stack( + self, page: Page + ) -> None: + """It used to stack *above* the floating buttons, which meant every + button added to the stack pushed the toast further up, over + controls it has nothing to do with. Beside its own button, the + column is empty and stays empty.""" + html = self._render( + [_user_entry("u1", "hello", session_id="ab12cd34", cwd="/tmp/project")] + ) + self._goto_with_clipboard_stub(page, html) + page.locator("#resumeSession").click() + page.wait_for_selector("#resumeToast.visible") + + geometry = page.evaluate( + "() => {" + " const toast = document.getElementById('resumeToast')" + " .getBoundingClientRect();" + " const btn = document.getElementById('resumeSession')" + " .getBoundingClientRect();" + " const hits = [...document.querySelectorAll('.floating-btn')]" + " .filter(el => { const r = el.getBoundingClientRect();" + " return !(r.right < toast.left || r.left > toast.right" + " || r.bottom < toast.top || r.top > toast.bottom); })" + " .map(el => el.id);" + " return { leftOf: toast.right <= btn.left," + " onScreen: toast.left >= 0," + " centred: Math.abs((toast.top + toast.bottom) / 2" + " - (btn.top + btn.bottom) / 2) < 2," + " hits }; }" + ) + assert geometry["leftOf"], "the toast is not to the left of its button" + assert geometry["centred"], "the toast is not centred on its button" + assert geometry["onScreen"], "the toast runs off the left edge" + assert geometry["hits"] == [], ( + f"the toast covers other floating buttons: {geometry['hits']}" + ) + @pytest.mark.browser def test_no_button_on_multi_session_page(self, page: Page) -> None: """Combined pages spanning several sessions render no button.""" diff --git a/test/test_search_browser.py b/test/test_search_browser.py index b04e8628..aabc1133 100644 --- a/test/test_search_browser.py +++ b/test/test_search_browser.py @@ -64,11 +64,25 @@ def _open_transcript( return temp_file def _search_for(self, page: Page, query: str): - """Open the search toolbar via `/` and type a query.""" + """Open the search toolbar via `/`, type a query, and let it run. + + The input handler debounces by 300 ms, so filling returns before + anything has been searched. Without waiting here, every later + assertion spends part of its own budget on that debounce — and on + a CI box with four browsers on four cores, "part of" has been the + whole of it (a Windows run resolved the locator three times in + five seconds, i.e. ~1.6 s per poll, and never saw the filter + applied). The counter is the component's own "I have run" signal: + idle it reads "No results", and after a search it reads either + "N of M matches" or "No matches". + """ page.keyboard.press("/") search_input = page.locator("#searchInput") expect(search_input).to_be_focused() search_input.fill(query) + expect(page.locator("#searchResultCount")).to_have_text( + re.compile(r"match", re.IGNORECASE) + ) @pytest.mark.browser def test_slash_opens_search(self, page: Page): diff --git a/test/test_server.py b/test/test_server.py index 6a09696d..2bf1e466 100644 --- a/test/test_server.py +++ b/test/test_server.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import socket import urllib.error import urllib.request @@ -15,7 +16,7 @@ import pytest -from claude_code_log.server import ArchiveServer +from claude_code_log.server import REVISION_HEADER, ArchiveServer @pytest.fixture @@ -184,6 +185,68 @@ def test_conditional_get_returns_304(server: ArchiveServer) -> None: assert body == b"" +def test_a_same_size_rewrite_in_the_same_second_changes_the_revision( + server: ArchiveServer, + archive: Path, +) -> None: + """The header the live page polls on must follow the *bytes*. + + `Last-Modified` is second-granular and `Content-Length` cannot see an + edit that keeps the size, so a re-render where a counter or a status + word keeps its width is invisible to both — and watch mode rewrites a + few hundred ms apart. + + The two mtimes are set explicitly, half a second apart inside one + whole second, so this is exactly a same-second rewrite and not a + timing gamble. Dating them in the past also means the first digest is + genuinely cached (the cache only keeps a settled file's), so the + second response is pinned against reusing it. + """ + page = archive / "-Users-someone-project" / "session-abc123.html" + url = f"{server.url}/-Users-someone-project/session-abc123.html" + + second_ns = 1_700_000_000_000_000_000 + os.utime(page, ns=(second_ns, second_ns)) + original = os.stat(page) + + status, _, before = _get(url) + assert status == 200 + first = before[REVISION_HEADER] + + page.write_text("SESSION") + assert page.stat().st_size == original.st_size + os.utime(page, ns=(second_ns + 500_000_000, second_ns + 500_000_000)) + + status, body, after = _get(url) + assert status == 200 + assert b"SESSION" in body + assert after["Last-Modified"] == before["Last-Modified"] + assert after["Content-Length"] == before["Content-Length"] + assert after[REVISION_HEADER] != first + + +def test_the_revision_is_stable_while_the_file_is(server: ArchiveServer) -> None: + """An unchanged file must not look changed, or every poll re-fetches.""" + url = f"{server.url}/index.html" + _, _, first = _get(url) + _, _, second = _get(url) + assert first[REVISION_HEADER] == second[REVISION_HEADER] + + +def test_head_carries_the_revision(server: ArchiveServer) -> None: + """The live page polls with HEAD; the header has to be on that reply.""" + request = urllib.request.Request(f"{server.url}/index.html", method="HEAD") + with urllib.request.urlopen(request) as response: + assert response.status == 200 + assert response.headers[REVISION_HEADER] + + +def test_the_api_carries_no_revision(server: ArchiveServer) -> None: + """It describes a file response; a JSON payload has none.""" + _, _, headers = _get(f"{server.url}/api/ping") + assert REVISION_HEADER not in headers + + def test_client_disconnect_does_not_kill_the_server( server: ArchiveServer, archive: Path ) -> None: diff --git a/test/test_session_scoped_render.py b/test/test_session_scoped_render.py index 6e4a58d2..19713c5f 100644 --- a/test/test_session_scoped_render.py +++ b/test/test_session_scoped_render.py @@ -616,3 +616,100 @@ def test_branch_qualified_winner_from_old_sidecar_still_enforces( _convert(branch_replay_project) assert _session_files(branch_replay_project) == baseline + + +class TestSessionScopedAfterAppend: + """The watch-mode shape: the cache *was* updated, by a pure append. + + Phase 1b used to refuse outright whenever `ensure_fresh_cache` + reported an update, which made it unreachable for the one case it + helps most — a live session gaining messages, where every run has new + bytes by definition. It now refuses only for a FULL refresh; an + INCREMENTAL one has already proven the change was a pure append + (`_incremental_cache_refresh` requires each modified file's cached + rows to be an exact prefix of its current rows), which is exactly the + premise the per-session message-count staleness check needs. + """ + + @staticmethod + def _append_entry(jsonl: Path, uuid: str, text: str) -> None: + entry = { + "type": "user", + "timestamp": "2025-07-03T18:00:00Z", + "parentUuid": None, + "isSidechain": False, + "userType": "human", + "cwd": "/tmp", + "sessionId": jsonl.stem, + "version": "1.0.0", + "uuid": uuid, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}, + } + with jsonl.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + def _grown_project(self, tmp_path: Path) -> tuple[Path, Path]: + work_dir = _copy_project(tmp_path, MULTI_SESSION_PROJECT) + convert_jsonl_to("html", work_dir, silent=True, write_combined=False) + jsonl = sorted(work_dir.glob("*.jsonl"))[0] + return work_dir, jsonl + + def test_append_reaches_the_partial_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The regression this whole change exists to prevent.""" + work_dir, jsonl = self._grown_project(tmp_path) + self._append_entry(jsonl, "watch-append-1", "a new message arrives") + + _forbid_full_load(monkeypatch) + convert_jsonl_to("html", work_dir, silent=True, write_combined=False) + + def test_append_output_is_byte_identical_to_the_full_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two copies advance through the same append; bytes must match.""" + scoped_dir, scoped_jsonl = self._grown_project(tmp_path / "scoped") + full_dir, full_jsonl = self._grown_project(tmp_path / "full") + + for jsonl in (scoped_jsonl, full_jsonl): + self._append_entry(jsonl, "watch-append-1", "a new message arrives") + + convert_jsonl_to("html", scoped_dir, silent=True, write_combined=False) + monkeypatch.setenv("CLAUDE_CODE_LOG_SESSION_SCOPED", "0") + convert_jsonl_to("html", full_dir, silent=True, write_combined=False) + + assert _session_files(scoped_dir) == _session_files(full_dir) + + def test_the_appended_message_actually_lands_in_the_output( + self, tmp_path: Path + ) -> None: + """Guards against the failure mode where nothing is regenerated. + + A cheaper path that renders *nothing* would pass an equivalence + test against another path that also renders nothing. + """ + work_dir, jsonl = self._grown_project(tmp_path) + before = _session_files(work_dir) + + self._append_entry(jsonl, "watch-append-1", "UNIQUE-MARKER-9f3a") + convert_jsonl_to("html", work_dir, silent=True, write_combined=False) + after = _session_files(work_dir) + + assert before != after, "the append did not change any session file" + assert any(b"UNIQUE-MARKER-9f3a" in b for b in after.values()) + + def test_a_full_refresh_still_declines( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """FULL carries no append-only proof, so the veto must survive. + + The incremental refresh is disabled, forcing `ensure_fresh_cache` + down its full-load path; Phase 1b must then refuse. + """ + work_dir, jsonl = self._grown_project(tmp_path) + self._append_entry(jsonl, "watch-append-1", "a new message arrives") + + monkeypatch.setenv("CLAUDE_CODE_LOG_INCREMENTAL_CACHE", "0") + calls = _spy_full_load(monkeypatch) + convert_jsonl_to("html", work_dir, silent=True, write_combined=False) + assert calls, "a FULL cache refresh must not reach the session-scoped path" diff --git a/test/test_template_rendering.py b/test/test_template_rendering.py index 990ce6e0..019953e1 100644 --- a/test/test_template_rendering.py +++ b/test/test_template_rendering.py @@ -327,10 +327,20 @@ def test_html_escaping(self): assert "<script>" in html_content assert "&" in html_content assert """ in html_content - # Should not contain unescaped HTML - assert ( - "