From 1f3e013e1918983cc544a233e98db018a42b36af Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Sun, 30 Aug 2026 16:34:55 +0000 Subject: [PATCH 01/30] Record the watch-mode design and refresh render-format-once's status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explores real-time watch mode for both the Markdown-on-disk and the served-HTML use cases. The measurements that shaped it are recorded alongside the decisions, notably three that changed the design: - Phase 1b (session-scoped render) is vetoed whenever the cache was updated, so it is unreachable in watch mode — every tick has new bytes. `--combined no` therefore full-loads the project per tick. Making it reachable for the touched sessions is Stage 1's real work. - The cache's 1.0s mtime tolerance silently swallows fast appends. `get_modified_files` already stats each file, so recording st_size costs nothing and fixes it for every caller. - Fragment-level patching stays blocked: fragments containing `msg-d-` are deliberately never cached, because those links are cross-tree positional. A container swap sidesteps it. Also verifies that `claude -p` writes a normal full-fidelity transcript, which subsumes the stream-json piping request without a second parser. Co-Authored-By: Claude Opus 5 --- work/render-format-once.md | 31 ++- work/watch-mode.md | 476 +++++++++++++++++++++++++++++++++++++ 2 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 work/watch-mode.md diff --git a/work/render-format-once.md b/work/render-format-once.md index 7ebe7bfd..c52567fb 100644 --- a/work/render-format-once.md +++ b/work/render-format-once.md @@ -1,14 +1,37 @@ # Render step 3: format once, assemble many -**Status:** phase 1 (serial fragment store) landed on -`perf/render-memo-and-intra-project-jobs`. Each entry-derived message is -now formatted once per conversion and reused across the combined pages -and session files — `fragment_store.py`, consumed by +**Status: merged to `main`** in two squashes — `94134e8` (#313, steps 1–2 +plus the fragment store and worker feeding) and `9a0e29a` (#315, +streaming stages 1–4, the sparse gate, and the §8 refactors). Each +entry-derived message is formatted once per conversion and reused across +the combined pages and session files — `fragment_store.py`, consumed by `_annotate_tree_for_render`, wired in `convert_jsonl_to`. Verified byte-identical on five real projects (including the 803MB / 187-file claude-code-log archive at a 50% hit rate) and by `test_render_cache_equivalence.py::test_fragment_store_render_is_byte_identical`. +**What is still open:** the *architecture* §2 proposes — a distinct, +parallel **format phase** — did not land. `RenderUnit.kind` is still only +`"page"` or `"session"`; a message's fragment is computed the first time a +tree containing it is rendered, and parallel formatting happens +incidentally (page workers format and ship deltas back), not structurally. +The flat pool (§2's third bullet, §7 item 4's tail) is likewise still +open, as are the provider bypass (codex/agy) and the fragment-text spill. + +> ⚠️ **§1 below is stale** and is kept only for its problem statement and +> measurements. Specifically: the commit shas it lists are pre-squash and +> are not in `main`'s history; "each [worker] holds a full copy of the +> transcript" is false since workers became fed (`RenderUnit.entries`); +> the §7.5 conclusion about worker counts past ~8 was superseded by the +> dispatch-gate finding (`render_dispatch._MIN_ENTRIES_FOR_RENDER_POOL`); +> the "memory cap makes the fan-out inert on 16GB" claim was re-measured +> and is not the binding constraint; the §3 seam table's line numbers are +> all shifted and two rows moved module (`_dispatch_render_units` → +> `render_dispatch.dispatch_render_units`, `_make_render_pool` → +> `render_dispatch.build_render_pool`); and the as-built reference is now +> `dev-docs/application_model.md` §§ 2.9, 2.10, **2.12, 2.13, 2.14**, not +> just §§ 2.9–2.10. + **Phase 2 progress (fed fragments):** three follow-up commits made the store process-portable and wired it through the fan-out — the hit-verification now stores a content *digest* instead of a retained diff --git a/work/watch-mode.md b/work/watch-mode.md new file mode 100644 index 00000000..4dd7c518 --- /dev/null +++ b/work/watch-mode.md @@ -0,0 +1,476 @@ +# Real-time watch mode — Design + +Status: **designed, not implemented.** Decisions below are settled; the +measurements that forced them are recorded so a later reader can tell +which choices were reasoned and which were measured. + +## Motivation + +Two use cases, deliberately different in their tolerance for latency and +complexity: + +1. **Markdown on disk.** Someone has `session-.md` open in an IDE or + Obsidian and wants it to keep up with the running session. The "client" + is the editor's own file watcher; all we owe it is a fresh, non-torn + file. Latency budget: a second or two, easily. +2. **Session page in the browser.** Someone has the generated HTML open + under `serve` and wants the page to grow as the session does, "like the + CLI". Latency budget: sub-second would be nice; loss of scroll position + or fold state would not be. + +Both reduce to the same engine (*detect change → re-render → notify*), and +differ only in how the client learns about it. That symmetry is the main +structural finding. + +## Decisions + +| # | decision | +|---|---| +| D1 | A `watch` subcommand owns the loop; `serve --watch` runs the same engine on a thread. | +| D2 | Add `source_size` to `cached_files` so the cache itself detects fast appends; the watcher then trusts `get_modified_files` rather than keeping parallel state. Detection is stat-polling. | +| D3 | Default scope is one project; `--all-projects` is opt-in. | +| D4 | Quiet-period debounce (~300 ms) with a max-latency cap (~2 s), both flags. | +| D5 | Container swap (option B) with uuid-set diffing, not full reload and not fragment patching. | +| D6 | Polling. SSE only if measurement later justifies it. | +| D7 | Route every output write through temp-file + `os.replace`. | +| D8 | Measured: the write + FTS update is fast enough. No lock-avoidance machinery needed. | +| D9 | Fix `--output` destination-aware freshness; it pairs with D7. | +| D10 | Injectable clock and file-event source; unit tests drive ticks by hand. | +| D11 | The `stream-json` piping request (#43 follow-up) is **subsumed** by watch mode. No stream-json parser. | + +--- + +## Constraints, measured + +Measured on this box, not assumed. The live transcript is this session's +own JSONL; the timing corpus is a copy of the `claude-code-log` project +archive (64 MB, 32 session files, 8 cores, warm cache). + +### C1. The source is append-only, and never rewritten in place + +168 entries, **110 distinct UUIDs, zero duplicates** — no entry is ever +rewritten. A 4.5 KB append left the first 50 KB byte-identical. + +**Consequence:** everything before the last known offset is stable. There +is nothing to diff — only a tail to consider. + +### C2. The granularity floor is one complete message. Token streaming is impossible. + +Follows from C1: an entry is written exactly once, so only when complete. +Confirmed by shape — the newest assistant entry was a whole 704-byte +`tool_use` block, on disk within the same second the message finished, not +batched to end-of-turn. + +**Consequence, and the expectation to set with users:** we can never show +tokens arriving. The best achievable is *a whole message appearing +promptly*. "Smooth" must come from presentation (a fade-in on new cards), +not from streaming. + +### C3. Appends are not in timestamp order + +The last two lines of the live file were `attachment` entries stamped +`15:42:17.930` and `15:42:26.973`, appended *after* a `user` entry stamped +`15:42:26.971`. + +**Consequence:** "append new lines to the bottom of the page" is wrong. A +newly-arrived line can belong earlier in the tree. + +### C4. `file://` cannot fetch anything. ` +

{{ title }}

{% if page_info %} @@ -289,6 +317,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'); diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 62da808b..c8c33701 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -2240,6 +2240,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -4168,6 +4210,34 @@ + +

Async Agents Fixture

@@ -4692,7 +4762,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -5101,8 +5195,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; @@ -5212,6 +5312,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -9311,6 +9652,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -11239,6 +11622,34 @@ + +

Async Agents Fixture (LOW)

@@ -11763,7 +12174,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -12067,8 +12502,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; @@ -12178,6 +12619,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -16519,8 +17201,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; @@ -16630,6 +17318,14 @@ // 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); + } })(); }); @@ -18878,6 +19574,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -20806,6 +21544,34 @@ + +

Test Session

@@ -21330,7 +22096,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -21857,8 +22647,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; @@ -21968,6 +22764,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -26067,6 +27104,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -27995,6 +29074,34 @@ + +

Teammates Fixture

@@ -28519,7 +29626,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -29326,8 +30457,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; @@ -29437,6 +30574,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -33536,6 +34914,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -35464,6 +36884,34 @@ + +

Edge Cases

@@ -35988,7 +37436,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -36742,8 +38214,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; @@ -36853,6 +38331,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -40952,6 +42671,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -42880,6 +44641,34 @@ + +

Claude Transcripts - test_multi_session_html0

@@ -43404,7 +45193,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -44103,8 +45916,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; @@ -44214,6 +46033,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -48313,6 +50373,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -50241,6 +52343,34 @@ + +

Test Transcript

@@ -50765,7 +52895,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -51292,8 +53446,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; @@ -51403,6 +53563,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -55502,6 +57903,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -57430,6 +59873,34 @@ + +

Claude Transcripts - test_steering_chronological_or0

@@ -57954,7 +60425,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -58286,8 +60781,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; @@ -58397,6 +60898,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle @@ -62496,6 +65238,48 @@ 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; + } + } + + .live-update-pill { + bottom: 20px; + left: 20px; + width: auto; + min-width: 3em; + padding: 0 0.9em; + border-radius: 999px; + font-size: 0.85em; + white-space: nowrap; + } + + .live-update-pill[data-following="yes"] { + background-color: var(--highlight-light); + font-weight: 600; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -64424,6 +67208,34 @@ + +

System Reminders

@@ -64948,7 +67760,31 @@ }); } + // 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(); + } + // Export functions to global scope + window.rebuildTimeline = rebuildTimeline; + if (window.claudeLogOnRehydrate) { + window.claudeLogOnRehydrate(rebuildTimeline); + } window.toggleTimeline = toggleTimeline; window.applyTimelineFilters = applyFilters; window.applyTimelineSearchFilter = applySearchFilter; @@ -65229,8 +68065,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; @@ -65340,6 +68182,247 @@ // 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 conditional GET makes the idle case free + // (304 in ~1ms) and needs no endpoint of its own. + // * We swap #transcript rather than reloading. A reload loses fold + // state and re-parses a document that can reach tens of MB; a swap + // keeps scroll position for free, because everything above the + // viewport is untouched. + // * We do not patch in individual messages. New entries do not always + // belong at the end (a transcript's appends are not in timestamp + // order), one entry can render as several cards, and the `msg-d-N` + // anchors are positional — inserting anywhere but the tail renumbers + // them and breaks the fork/tool-pair links already on the page. + // Replacing the whole container sidesteps all three. + (function () { + 'use strict'; + + if (location.protocol !== 'http:' && location.protocol !== 'https:') return; + + // A conditional GET costs ~1ms and 304s 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.) + 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; + } + + // ---- the update ------------------------------------------------------ + + function announce(added) { + let pill = document.getElementById('live-update-pill'); + if (!pill) { + pill = document.createElement('button'); + pill.id = 'live-update-pill'; + pill.className = 'floating-btn live-update-pill'; + pill.addEventListener('click', () => { + following = !following; + if (following) scrollToEnd(); + render(); + }); + document.body.appendChild(pill); + } + pill.dataset.following = following ? 'yes' : 'no'; + pill.unseen = (pill.unseen || 0) + added; + render(); + + function render() { + if (following) pill.unseen = 0; + pill.textContent = following + ? '⏬ following' + : (pill.unseen ? `⏬ ${pill.unseen} new` : '⏬ follow'); + pill.title = following + ? 'Following new messages — click to stop' + : 'Scroll to new messages as they arrive'; + } + } + + function scrollToEnd() { + const c = container(); + if (c) c.lastElementChild?.scrollIntoView({ block: 'end', behavior: 'smooth' }); + } + + async function applyUpdate(html) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const next = doc.getElementById('transcript'); + const current = container(); + if (!next || !current) return; + + 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); + + // 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(); + } + + async function poll() { + if (stopped) return; + try { + const head = await fetch(location.href, { method: 'HEAD', cache: 'no-store' }); + const stamp = [ + head.headers.get('Last-Modified') || '', + head.headers.get('Content-Length') || '', + head.headers.get('ETag') || '', + ].join('|'); + if (lastStamp === null) { + lastStamp = stamp; + } else if (stamp !== lastStamp) { + lastStamp = stamp; + const res = await fetch(location.href, { cache: 'no-store' }); + if (res.ok) await applyUpdate(await res.text()); + } + } 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); + } + } + + // 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(v) { following = !!v; }, + }; })(); // Debug UUID toggle diff --git a/test/test_live_update.py b/test/test_live_update.py new file mode 100644 index 00000000..8a06ef77 --- /dev/null +++ b/test/test_live_update.py @@ -0,0 +1,237 @@ +"""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 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" + ) + + assert page.locator("#live-update-pill").count() == 1 + + 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" + + 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_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 == [] + assert page.locator("#live-update-pill").count() == 0 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 ( - "