From 3e0cbedc5d4a82a742b120c58e0b39e2cad0df8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:42:36 +0000 Subject: [PATCH 01/17] =?UTF-8?q?WIP:=20recent=20sessions=20page=20?= =?UTF-8?q?=E2=80=94=20data,=20wiring,=20index=20links,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new recent.html next to the index lists recently active sessions across every project, newest update first. This commit has the data side and the wiring; the page template, its CSS/JS and the browser tests follow. - recent.py builds the payload from the cache (one cross-project query, then each recent session's entries): turns, tool-call times, the last prompt and message, and whose move it is (asked / error / done / busy). Transcript states only, not liveness. - The hierarchy run writes recent.html and recent-data.js after the index; a failure there never fails the run. - watch rewrites recent-data.js after each single-project tick in the default layout, so the self-refreshing page stays current. - The index links to the page in both themes (a deliberate markup change to the index in each theme; snapshots to be updated with the page). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4FqCJpueUcFNo35UXhxvS --- claude_code_log/cli.py | 22 + claude_code_log/converter.py | 35 ++ .../templates/components/minimal/pages.css | 4 + claude_code_log/html/templates/index.html | 4 +- claude_code_log/recent.py | 443 ++++++++++++++++++ dev-docs/application_model.md | 49 ++ docs/recent-sessions.md | 76 +++ mkdocs.yml | 1 + test/test_recent.py | 341 ++++++++++++++ 9 files changed, 974 insertions(+), 1 deletion(-) create mode 100644 claude_code_log/recent.py create mode 100644 docs/recent-sessions.md create mode 100644 test/test_recent.py diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index cd0159d6..12bedc52 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -2578,6 +2578,27 @@ def watch( store = ParsedEntryStore() if (entry_store_enabled() and not all_projects) else None + def refresh_recent() -> None: + """Keep the recent-sessions page's data current. + + A one-project watch never rewrites the index, so the archive's + ``recent.html`` would freeze at the last full run. Its data file + is cheap to rebuild (one cache query plus the recent sessions' + entries), so it is rewritten after every tick β€” but only in the + default layout, where the page sits next to the projects and the + watched project's cache is the archive's. + """ + if output is not None or output_format != "html": + return + if root.parent.resolve() != projects_path.resolve(): + return + from .recent import write_recent_data + + try: + write_recent_data(projects_path, projects_path) + except Exception as exc: + click.echo(f" recent sessions not refreshed: {exc!r}", err=True) + def convert(_changed: set[Path]) -> None: started = time.monotonic() if all_projects: @@ -2604,6 +2625,7 @@ def convert(_changed: set[Path]) -> None: entry_store=store, theme=resolved_theme, ) + refresh_recent() click.echo( f" {time.strftime('%H:%M:%S')} converted in " f"{time.monotonic() - started:.2f}s" diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 53c5573b..b946338b 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -6529,6 +6529,40 @@ def process_projects_hierarchy( ) +def _write_recent_page( + index_dir: Path, + projects_path: Path, + project_summaries: List[Dict[str, Any]], + theme: str, +) -> None: + """Write ``recent.html`` and its ``recent-data.js`` next to the index. + + The page lists recently active sessions across every project and + refreshes itself from ``recent-data.js`` (see ``recent.py``). Session + links come from the index's own per-session ``file`` entries, so they + follow ``--output`` / ``--expand-paths`` / variant filenames exactly + as the index does. A failure here must never fail the conversion: + the page is a convenience next to the index, not part of it. + """ + from .recent import RECENT_DATA, RECENT_PAGE, build_recent_payload, recent_data_js + + links: Dict[str, str] = {} + for summary in project_summaries: + for session in summary.get("sessions", []): + if session.get("id") and session.get("file"): + links[str(session["id"])] = str(session["file"]) + try: + from .html.renderer import generate_recent_html + + payload = build_recent_payload( + projects_path, href_for=lambda _project, sid: links.get(sid) + ) + atomic_write_text(index_dir / RECENT_DATA, recent_data_js(payload)) + atomic_write_text(index_dir / RECENT_PAGE, generate_recent_html(payload, theme)) + except Exception as exc: # pragma: no cover - defensive + print(f"Warning: could not write {RECENT_PAGE}: {exc}") + + def _process_projects_hierarchy( projects_path: Path, from_date: Optional[str] = None, @@ -7325,6 +7359,7 @@ def _convert_plan_inline( atomic_write_text( index_path.parent / "search.html", generate_archive_search_html(theme) ) + _write_recent_page(index_path.parent, projects_path, project_summaries, theme) # Count total sessions from project summaries for summary in project_summaries: diff --git a/claude_code_log/html/templates/components/minimal/pages.css b/claude_code_log/html/templates/components/minimal/pages.css index 17c8dff1..6187ee01 100644 --- a/claude_code_log/html/templates/components/minimal/pages.css +++ b/claude_code_log/html/templates/components/minimal/pages.css @@ -182,6 +182,10 @@ --mn-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'%3E%3Ccircle cx='7' cy='7' r='4.5'/%3E%3Cpath d='M10.5 10.5 14 14M5 7h4M7 5v4'/%3E%3C/svg%3E"); } +.mn-index .mn-recent-link::before { + --mn-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'%3E%3Ccircle cx='8' cy='8' r='6'/%3E%3Cpath d='M8 4.5V8l2.5 1.5'/%3E%3C/svg%3E"); +} + /* The finder's results: a list under the bar, not a card. Last in the bar, whatever the markup order, so the archive link stays on the finder's line. */ diff --git a/claude_code_log/html/templates/index.html b/claude_code_log/html/templates/index.html index fe926efc..15d75b83 100644 --- a/claude_code_log/html/templates/index.html +++ b/claude_code_log/html/templates/index.html @@ -41,7 +41,9 @@ {% include 'components/search.html' %} {% if minimal %}Search all transcripts - {% else %}{{ render_archive_search_link('search.html') }}{% endif %} + Recent sessions + {% else %}{{ render_archive_search_link('search.html') }} + {% endif %} {% if not minimal %}
diff --git a/claude_code_log/recent.py b/claude_code_log/recent.py new file mode 100644 index 00000000..815ae09d --- /dev/null +++ b/claude_code_log/recent.py @@ -0,0 +1,443 @@ +"""Recently active sessions across every project, for ``recent.html``. + +The recent-sessions page lists the sessions that changed lately, newest +update first, each with a strip of its turns over the last hours and its +last message. Everything here comes from the transcripts (via the cache): +nothing says whether an agent is still *running* β€” a transcript cannot +tell a stopped agent from one waiting quietly β€” so the states are about +whose move it is, not about liveness. + +The page is static and refreshes itself: a ``file://`` page cannot fetch +anything, but it can add a ```` (or `` + + + + + + Recent sessions + {#- One look in both themes: the minimal theme's tokens, and the page's own + stylesheet. The stored light/dark choice applies before first paint. #} + + + + + +
+ + +
+

Sessions, last update first

+
+
+ + + + +
+
updated–
+
+
+ + + +
+
+ + + + +
+
+ + +
+ +
+ +

From the transcripts only: none of this says whether an agent is still running.

+
+
+ + + + {# The data, loaded the same way every refresh reloads it. Kept out of + this page so the page is the same on every run (only the data file + changes) and no transcript text is ever inlined into it. #} + + + + diff --git a/claude_code_log/recent.py b/claude_code_log/recent.py index 815ae09d..7e206e4d 100644 --- a/claude_code_log/recent.py +++ b/claude_code_log/recent.py @@ -299,10 +299,17 @@ def _default_href(project_dir: Path, session_id: str) -> str: def _session_rows(db_path: Path, cutoff_iso: str, limit: int) -> list[sqlite3.Row]: - """The newest visible session rows across every project.""" + """The newest visible session rows across every project. + + A ``mode=ro`` reader: it never writes the shared database, so it needs + none of the write pragmas (see the connection census in + ``test/test_cache_sqlite_integrity.py``). + """ if not db_path.exists(): return [] - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + # as_uri() percent-encodes the absolute path, as the cache's own + # read-only connections do, so spaces and "?" in it stay a path. + conn = sqlite3.connect(f"{db_path.resolve().as_uri()}?mode=ro", uri=True) conn.row_factory = sqlite3.Row try: return conn.execute( diff --git a/dev-docs/application_model.md b/dev-docs/application_model.md index 2bcd1419..a5cdeea1 100644 --- a/dev-docs/application_model.md +++ b/dev-docs/application_model.md @@ -1620,14 +1620,16 @@ filenames resolve as the index's do, and writes `recent-data.js` then `recent.html`. A failure there is reported and never fails the run. **Self-refresh on `file://`.** A page opened as a file cannot `fetch` -(see `live_update.js`), but it can add a ` Search all transcripts + Recent sessions @@ -61266,6 +61276,10 @@ --mn-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'%3E%3Ccircle cx='7' cy='7' r='4.5'/%3E%3Cpath d='M10.5 10.5 14 14M5 7h4M7 5v4'/%3E%3C/svg%3E"); } + .mn-index .mn-recent-link::before { + --mn-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'%3E%3Ccircle cx='8' cy='8' r='6'/%3E%3Cpath d='M8 4.5V8l2.5 1.5'/%3E%3C/svg%3E"); + } + /* The finder's results: a list under the bar, not a card. Last in the bar, whatever the markup order, so the archive link stays on the finder's line. */ @@ -63058,6 +63072,7 @@ Search all transcripts + Recent sessions diff --git a/test/test_cache_sqlite_integrity.py b/test/test_cache_sqlite_integrity.py index 82c845d0..d5b333e2 100644 --- a/test/test_cache_sqlite_integrity.py +++ b/test/test_cache_sqlite_integrity.py @@ -695,6 +695,10 @@ class TestConnectionCensus: 1, "mode=ro reader; cannot switch journal modes", ), + ("recent.py", "_session_rows"): ( + 1, + "mode=ro reader: one SELECT across projects, then close", + ), } @staticmethod diff --git a/test/test_recent_browser.py b/test/test_recent_browser.py new file mode 100644 index 00000000..88926c3f --- /dev/null +++ b/test/test_recent_browser.py @@ -0,0 +1,270 @@ +"""Browser tests for the recent sessions page (recent.html), opened from disk. + +The page refreshes itself by re-adding a " + + +def _session( + sid: str, last_ago: int, state: str = "done", **extra: Any +) -> dict[str, Any]: + last_at = NOW - last_ago + end = {"asked": "ask", "error": "err", "busy": "open"}.get(state, "asst") + session: dict[str, Any] = { + "id": f"{sid}-0000-4000-8000-000000000000", + "short": sid, + "project": "foldyard", + "projectDir": "-home-u-foldyard", + "worktree": None, + "branch": "main", + "title": f"Session {sid}", + "href": f"-home-u-foldyard/session-{sid}.html", + "firstAt": last_at - HOUR, + "lastAt": last_at, + "state": state, + "messages": 12, + "turnCount": 2, + "toolCount": 3, + "turns": [ + [last_at - HOUR, last_at - 40 * MIN, "asst"], + [last_at - 30 * MIN, last_at, end], + ], + "earlierTurns": 0, + "tools": [last_at - 50 * MIN, last_at - 20 * MIN, last_at - 10 * MIN], + "lastPrompt": {"at": last_at - 30 * MIN, "text": f"Prompt for {sid}"}, + "last": {"at": last_at, "who": "claude", "text": f"Reply from {sid}"}, + } + session.update(extra) + return session + + +def _payload( + sessions: list[dict[str, Any]], generated_at: Optional[int] = None +) -> dict[str, Any]: + return { + "v": 1, + "generatedAt": NOW if generated_at is None else generated_at, + "windowDays": 3, + "cacheTtlMinutes": 60, + "sessions": sessions, + } + + +def _write( + tmp_path: Path, payload: dict[str, Any], data: Optional[dict[str, Any]] = None +) -> str: + (tmp_path / "recent.html").write_text(generate_recent_html(), encoding="utf-8") + (tmp_path / "recent-data.js").write_text( + recent_data_js(data or payload), encoding="utf-8" + ) + return (tmp_path / "recent.html").as_uri() + f"?now={NOW}" + + +def _row(page: Page, sid: str) -> Locator: + return page.locator(f".rc-row[data-id^='{sid}']") + + +@pytest.mark.browser +def test_reload_picks_up_new_sessions_in_place(page: Page, tmp_path: Path) -> None: + first = _payload( + [_session("aaaa1111", 4 * MIN, "asked"), _session("bbbb2222", 37 * MIN)] + ) + page.clock.install() + page.goto(_write(tmp_path, first)) + rows = page.locator(".rc-row") + expect(rows).to_have_count(2) + # Newest first, with its time since the update and its state. + expect(rows.nth(0)).to_have_attribute( + "data-id", "aaaa1111-0000-4000-8000-000000000000" + ) + expect(rows.nth(0).locator(".rc-block-n")).to_have_text("4") + expect(rows.nth(0).locator(".rc-block-status")).to_have_text("asked you") + expect(rows.nth(1).locator(".rc-block-status")).to_have_text("finished") + + # Open a row and pick a filter: both must survive the refresh. + block = _row(page, "bbbb2222").locator(".rc-block") + block.click() + expect(block).to_have_attribute("aria-expanded", "true") + expect(_row(page, "bbbb2222").locator(".rc-last-text")).to_have_text( + "Reply from bbbb2222" + ) + page.get_by_role("button", name="your move").click() + marker = page.evaluate( + "document.querySelector('.rc-row[data-id^=bbbb2222]').dataset.marker = 'kept'" + ) + assert marker == "kept" + + # The data file changes on disk; the page reloads it on its timer. + newer = _payload( + [_session("cccc3333", 1 * MIN, "error")] + first["sessions"], + ) + (tmp_path / "recent-data.js").write_text(recent_data_js(newer), encoding="utf-8") + page.clock.run_for(16000) + expect(rows).to_have_count(3) + expect(rows.nth(0)).to_have_attribute( + "data-id", "cccc3333-0000-4000-8000-000000000000" + ) + expect(rows.nth(0).locator(".rc-block-status")).to_have_text("error") + # Same page (no reload), the row element kept, still open, filter kept. + assert ( + page.evaluate( + "document.querySelector('.rc-row[data-id^=bbbb2222]').dataset.marker" + ) + == "kept" + ) + expect(block).to_have_attribute("aria-expanded", "true") + expect(page.locator("[data-filter='move']")).to_have_attribute( + "aria-pressed", "true" + ) + expect(page.locator("[data-filter='move'] .rc-n")).to_have_text("3") + # Each reload's script tag is removed again (the page's own first-load + # tag stays: it has no "?t="). + expect(page.locator("script[src^='recent-data.js?t=']")).to_have_count(0) + + +@pytest.mark.browser +def test_transcript_text_stays_text(page: Page, tmp_path: Path) -> None: + evil = _session( + "dddd4444", + 2 * MIN, + title=EVIL, + worktree=EVIL, + branch=EVIL, + href="javascript:window.__pwned=2", + lastPrompt={"at": NOW - 5 * MIN, "text": EVIL}, + last={"at": NOW - 2 * MIN, "who": "tool", "text": EVIL}, + ) + page.goto(_write(tmp_path, _payload([evil]))) + row = _row(page, "dddd4444") + expect(row.locator(".rc-title")).to_have_text(EVIL) + row.locator(".rc-block").click() + expect(row.locator(".rc-last-text")).to_have_text(EVIL) + assert page.locator(".rc-page img").count() == 0 + # A scheme in href is refused: the title is plain text, not a link. + assert row.locator("a.rc-title").count() == 0 + assert page.evaluate("window.__pwned") is None + + +@pytest.mark.browser +def test_filters_cache_and_grouping(page: Page, tmp_path: Path) -> None: + sessions = [ + _session("aaaa1111", 3 * MIN, "busy"), + _session("bbbb2222", 50 * MIN, "asked", project="tangible", projectDir="-t"), + _session("cccc3333", 2 * HOUR, "done"), + _session("dddd4444", 30 * HOUR, "done", project="tangible", projectDir="-t"), + ] + page.goto(_write(tmp_path, _payload(sessions))) + counts = { + name: page.locator(f"[data-filter='{name}'] .rc-n").inner_text() + for name in ("all", "move", "urgent", "cache") + } + assert counts == {"all": "4", "move": "3", "urgent": "1", "cache": "1"} + + # 50 min since the update: the cache has 10 min left, chip and band. + cached = _row(page, "bbbb2222") + expect(cached.locator(".rc-chip")).to_have_text("cache 10 min") + expect(cached.locator(".rc-band.rc-band-risk")).to_have_count(1) + # Under a day: H:MM h; older: a weekday, and the strip's summary. + expect(_row(page, "cccc3333").locator(".rc-block-u")).to_have_text("h") + expect(_row(page, "cccc3333").locator(".rc-block-n")).to_have_text("2:00") + old = _row(page, "dddd4444") + expect(old.locator(".rc-outside")).to_contain_text("2 turns Β· 12 messages") + expect(old.locator(".rc-bar")).to_have_count(0) + # The last turn's end and an ask always stay full markers. + expect(cached.locator(".rc-mk.rc-k-ask")).to_have_count(1) + expect(cached.locator(".rc-strip")).to_have_attribute("role", "img") + + page.get_by_role("button", name="asked / error").click() + expect(page.locator(".rc-row")).to_have_count(1) + page.get_by_role("button", name="cache expiring").click() + expect(page.locator(".rc-row")).to_have_count(1) + page.get_by_role("button", name="sessions").click() + expect(page.locator(".rc-row")).to_have_count(4) + + page.get_by_label("by project").check() + headings = page.locator(".rc-group-h") + expect(headings).to_have_count(2) + # Groups ordered by their newest session. + expect(headings.nth(0)).to_contain_text("foldyard") + expect(headings.nth(1)).to_contain_text("tangible") + expect(page.locator(".rc-group").nth(1).locator(".rc-row")).to_have_count(2) + + +@pytest.mark.browser +def test_crowded_markers_shrink_to_dots(page: Page, tmp_path: Path) -> None: + turns = [ + [NOW - 60 * MIN + i * 4 * MIN, NOW - 58 * MIN + i * 4 * MIN, "asst"] + for i in range(12) + ] + busy = _session("eeee5555", 10 * MIN, "done", turns=turns, tools=[]) + page.goto(_write(tmp_path, _payload([busy]))) + row = _row(page, "eeee5555") + expect(row.locator(".rc-mk")).not_to_have_count(0) + assert row.locator(".rc-dot").count() > 0 + last = row.locator(".rc-mk.rc-k-asst").last + expect(last).to_have_attribute( + "title", + "Claude finished Β· " + + page.evaluate( + "(() => { const d = new Date(%d); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); })()" + % turns[-1][1] + ), + ) + + +@pytest.mark.browser +def test_stale_notice_and_empty_state(page: Page, tmp_path: Path) -> None: + stale = _payload([], generated_at=NOW - 5 * MIN) + fresh = _payload([_session("ffff6666", 1 * MIN)]) + page.clock.install() + page.goto(_write(tmp_path, stale, stale)) + expect(page.locator("#rc-empty")).to_have_text( + "No sessions updated in the last 3 days." + ) + notice = page.locator("#rc-stale") + expect(notice).to_be_visible() + expect(notice).to_contain_text("Data from 5 min ago") + expect(notice).to_contain_text("claude-code-log watch") + (tmp_path / "recent-data.js").write_text(recent_data_js(fresh), encoding="utf-8") + page.clock.run_for(16000) + expect(page.locator(".rc-row")).to_have_count(1) + expect(notice).to_be_hidden() + expect(page.locator("#rc-empty")).to_be_hidden() + + +@pytest.mark.browser +def test_copy_button_and_phone_width(page: Page, tmp_path: Path) -> None: + page.set_viewport_size({"width": 375, "height": 800}) + try: + page.goto(_write(tmp_path, _payload([_session("abcd1234", 3 * MIN, "asked")]))) + widths = page.evaluate( + "[document.documentElement.scrollWidth, document.documentElement.clientWidth]" + ) + assert widths[0] <= widths[1], widths + copy = page.get_by_role( + "button", name="Copy session id abcd1234-0000-4000-8000-000000000000" + ) + expect(copy).to_have_text("abcd1234") + copy.click() + expect(copy).to_have_text("copied") + expect(copy).to_have_text("abcd1234", timeout=5000) + finally: + page.set_viewport_size({"width": 1280, "height": 720}) diff --git a/test/test_recent_page.py b/test/test_recent_page.py new file mode 100644 index 00000000..b1d4b352 --- /dev/null +++ b/test/test_recent_page.py @@ -0,0 +1,107 @@ +"""The recent sessions page (recent.html): what the renderer writes. + +The page's behaviour is covered by test_recent_browser.py; this checks the +static output β€” a page that holds no data and loads recent-data.js, a data +file transcript text can't break out of, and the refresh hook it calls. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from claude_code_log.html.renderer import generate_recent_html +from claude_code_log.recent import payload_json, recent_data_js + +COMPONENTS = ( + Path(__file__).parent.parent + / "claude_code_log" + / "html" + / "templates" + / "components" + / "recent" +) +NOW = 1793465000000 +EVIL = "" + + +def _payload(**session: Any) -> dict[str, Any]: + base = { + "id": "8b04d7c2-1111-4000-8000-000000000000", + "short": "8b04d7c2", + "project": "foldyard", + "projectDir": "-home-u-foldyard", + "worktree": None, + "branch": "main", + "title": "Pin the to-box 9p cache", + "href": "-home-u-foldyard/session-8b04d7c2.html", + "firstAt": NOW - 3600000, + "lastAt": NOW - 240000, + "state": "asked", + "messages": 9, + "turnCount": 1, + "toolCount": 2, + "turns": [[NOW - 3600000, NOW - 240000, "ask"]], + "earlierTurns": 0, + "tools": [NOW - 3000000, NOW - 2000000], + "lastPrompt": {"at": NOW - 3600000, "text": "Find out why."}, + "last": {"at": NOW - 240000, "who": "claude", "text": "Shall I?"}, + } + base.update(session) + return { + "v": 1, + "generatedAt": NOW, + "windowDays": 3, + "cacheTtlMinutes": 60, + "sessions": [base], + } + + +@pytest.mark.parametrize("theme", ["classic", "minimal"]) +def test_page_holds_no_data_and_loads_the_data_file(theme: str) -> None: + """The page is a fixed shell: its rows come from recent-data.js, on open + and on every refresh, so it is the same on every run and no transcript + text is ever inlined into it.""" + html = generate_recent_html(theme) + assert "" in html + assert "recent-initial" not in html + assert html == generate_recent_html(theme) + + +def test_message_text_cannot_break_out_of_the_data_file() -> None: + payload = _payload(title=EVIL, last={"at": NOW, "who": "claude", "text": EVIL}) + data = recent_data_js(payload) + assert "<" not in data + # The JSON keeps the text, with "<" escaped, and parses back unchanged. + assert "\\u003c/script>\\u003cimg" in data + body = payload_json(payload) + assert body in data + assert json.loads(body)["sessions"][0]["title"] == EVIL + + +def test_page_hooks_up_the_refresh() -> None: + html = generate_recent_html() + assert "window.__cclRecent = function" in html + assert "recent-data.js" in html + # The data file calls the same hook. + assert "window.__cclRecent(" in recent_data_js(_payload()) + + +def test_page_carries_its_chrome() -> None: + html = generate_recent_html("minimal") + assert "

Sessions, last update first

" in html + assert "href='index.html'" in html + assert "data-mn-theme='dark'" in html # the scheme toggle + assert "claude-code-log:theme" in html # the stored choice applied + assert "From the transcripts only" in html + assert "fonts.googleapis" not in html + + +def test_runtime_never_writes_markup() -> None: + """Transcript text reaches the page through textContent only.""" + source = (COMPONENTS / "recent.js").read_text(encoding="utf-8") + for sink in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"): + assert sink not in source, sink From 2d74cac8bfdb2c644dcdab108ca39d65bcb1ae12 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 3 Nov 2026 01:18:31 +0000 Subject: [PATCH 03/17] Recent sessions page: Markdown, background work, waiting states, phone layout Rows: - An open row renders the last prompt and message as Markdown, rendered in recent.py by the session pages' escaping pipeline. The page puts that HTML in through one helper and nowhere else (pinned by a test); tool lines and errors stay plain text. The data carries one form or the other, never both. - A chevron left of the status block opens the row (pointer only; the block stays the keyboard control). - Markers' tooltips show the start of the turn's prompt and of how it ended. The strip shows 4 h back instead of 6. - An unanswered AskUserQuestion or ExitPlanMode is "asked you": its questions and options, or the plan, are the last message. Background work (new background_work.py): - Background commands, async agents and monitors are paired with their ends: notifications in user entries, queued_command attachments and queue-operations, TaskStop/KillShell, monitor expiry. Launch ids come from toolUseResult or the harness's own launch message only (a foreground command's output can look like one). - Pending wake-ups come from ScheduleWakeup's scheduledFor and leave at the scheduled_task_fire entry. - A notification delivered while idle, or a wake-up firing, starts a turn of its own (bell / alarm marker), so one bar no longer spans an idle gap. - The page draws work as packed lanes under the turns (at most three, the rest folded into "+N more"), pending wake-ups ahead of now, and a Background list in the open row. A finished session with work running or a wake-up due is "background" and leaves "your move". Work with no end recorded counts as running for an hour after the last entry, then shows as "no end recorded"; the page applies this against its clock. Phone: Updated shares the last filter row, the list loses its box, and an open row puts Transcript beside the copy button with its counts on one line. Also: two minimal-theme tests located the search link by a class the index's new Recent sessions link shares; they now select it by href. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/background_work.py | 340 +++++++++++ .../templates/components/recent/recent.css | 364 +++++++++++- .../templates/components/recent/recent.js | 371 +++++++++++- claude_code_log/html/templates/recent.html | 9 +- claude_code_log/recent.py | 334 +++++++++-- dev-docs/application_model.md | 63 +- docs/recent-sessions.md | 43 +- test/test_minimal_pages_browser.py | 4 +- test/test_recent.py | 539 +++++++++++++++++- test/test_recent_browser.py | 441 +++++++++++++- test/test_recent_page.py | 20 +- 11 files changed, 2392 insertions(+), 136 deletions(-) create mode 100644 claude_code_log/background_work.py diff --git a/claude_code_log/background_work.py b/claude_code_log/background_work.py new file mode 100644 index 00000000..c8ab70d1 --- /dev/null +++ b/claude_code_log/background_work.py @@ -0,0 +1,340 @@ +"""Background work a session started, read from its transcript. + +For the recent sessions page (:mod:`claude_code_log.recent`): which +background commands, async agents and monitors a session launched, when +each ended and how, and which wake-ups are still to come. Like the rest +of that page it is the transcript's account only: work with no end on +record may still be running, or may have died with its process; the page +decides how long to believe it is running. + +How each kind shows up (surveyed across real transcripts): + +- **Launch.** The tool result of a ``Bash``, ``Agent``/``Task`` or + ``Monitor`` call names the id the work runs under: ``backgroundTaskId``, + ``agentId`` (with ``status: "async_launched"``) or ``taskId`` in the + structured ``toolUseResult``, or the same id in the result text. Bash + is found by its result, not its ``run_in_background`` flag: a command + that outlives its timeout is moved to the background without it. +- **End.** A ```` with that ```` and a final + ````, or a ``TaskStop`` / ``KillShell`` / ``KillBash`` on the + id. A monitor's events are notifications without a status; its expiry + is an event whose text says so. An agent can report an interim + "waiting on its own background work" before its real end. +- **Wake-ups.** ``ScheduleWakeup``'s ``toolUseResult.scheduledFor`` is + exactly when it fires (or the result text gives the delay); a + ``{stop: true}`` call cancels what is pending. The firing is a + ``system`` entry with subtype ``scheduled_task_fire``. + +Notifications arrive in a user entry when Claude is idle (and start a +turn) or in a ``queued_command`` attachment mid-turn; the user-entry +body may open with a ``[SYSTEM NOTIFICATION …]`` preamble, so they are +found by searching, not by prefix. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Optional, cast + +from .models import ToolResultContent, ToolUseContent + +#: The tools whose launch can start background work, and its kind. +_KINDS = {"Bash": "bash", "Agent": "agent", "Task": "agent", "Monitor": "monitor"} +#: The tools that stop background work, and the input key naming its id. +_STOPPERS = {"TaskStop": "task_id", "KillShell": "shell_id", "KillBash": "shell_id"} +#: Final notification statuses, as the page names them. +_FINAL = { + "completed": "completed", + "failed": "failed", + "stopped": "stopped", + "killed": "stopped", +} +#: A fire matches the pending wake-up nearest it, within this. +_FIRE_MATCH_MS = 10 * 60000 + +_NOTIFICATION_RE = re.compile( + r"\s*(?P.*?)\s*", re.DOTALL +) +_FIELD_RE = re.compile( + r"<(?Ptask-id|status|summary|event)>(?P.*?)", re.DOTALL +) +# Launch ids in result text, for results without a structured +# toolUseResult: only the harness's own launch message, at the start of +# the result (a foreground command's output can say anything). +_LAUNCH_ID_RES = { + "bash": ( + re.compile(r"\A\s*Command running in background with ID: (?P[\w-]+)"), + re.compile( + r"\A\s*Command did not complete within [^\n]*?" + r"moved to the background \(ID: (?P[\w-]+)\)" + ), + ), + "agent": ( + re.compile( + r"\A\s*Async agent launched successfully\b.*?^agentId: (?P[\w-]+)", + re.DOTALL | re.MULTILINE, + ), + ), + "monitor": (re.compile(r"\A\s*Monitor started \(task (?P[\w-]+)"),), +} +_MONITOR_TIMEOUT_RE = re.compile(r"timeout (?P\d+)ms") +_MONITOR_EXPIRES_RE = re.compile(r"expires in (?P\d+)m\b") +_WAKE_IN_RE = re.compile(r"Next wakeup scheduled for [\d:]+ \(in (?P\d+)s\)") +_MONITOR_EXPIRED_PREFIXES = ("[Monitor timed out", "[Monitor expired") +_INTERIM = "is waiting on its own background work" + + +@dataclass +class Notification: + """One ```` block's fields.""" + + task_id: Optional[str] + status: Optional[str] + summary: Optional[str] + event: Optional[str] + + @property + def label(self) -> Optional[str]: + """What it says happened: the summary, else the event.""" + return self.summary or self.event + + +def notifications(text: str) -> list[Notification]: + """Every ```` block in ``text``, in order.""" + found: list[Notification] = [] + for block in _NOTIFICATION_RE.finditer(text): + fields = { + m.group("tag"): m.group("body").strip() + for m in _FIELD_RE.finditer(block.group("body")) + } + found.append( + Notification( + task_id=fields.get("task-id") or None, + status=fields.get("status") or None, + summary=fields.get("summary") or None, + event=fields.get("event") or None, + ) + ) + return found + + +@dataclass +class _Work: + kind: str + label: str + start: int + end: Optional[int] = None + status: Optional[str] = None + expires: Optional[int] = None + + +@dataclass +class _Wake: + at: int + label: str + + +def _result_text(result: ToolResultContent) -> str: + if isinstance(result.content, str): + return result.content + return "\n".join(str(part.get("text", "")) for part in result.content) + + +def _structured(value: Any) -> dict[str, Any]: + return cast(dict[str, Any], value) if isinstance(value, dict) else {} + + +def _int(value: Any) -> Optional[int]: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return int(value) + + +def _first_line(value: Any) -> Optional[str]: + if not isinstance(value, str) or not value.strip(): + return None + return value.strip().splitlines()[0] + + +class BackgroundWork: + """Collects a session's background work, entry by entry. + + Feed it the session's main-chain entries in order: tool calls + (:meth:`tool_call`), their results (:meth:`tool_result`), the + notifications (:meth:`notified`) and wake-up fires (:meth:`fired`). + """ + + def __init__(self) -> None: + self._calls: dict[str, tuple[ToolUseContent, int]] = {} + self._work: dict[str, _Work] = {} + self._wakes: list[_Wake] = [] + + # ---- feeding ------------------------------------------------------ + def tool_call(self, item: ToolUseContent, at: int) -> None: + self._calls[item.id] = (item, at) + data = item.input or {} + key = _STOPPERS.get(item.name) + if key is not None: + self._end(data.get(key), at, "stopped") + if item.name == "ScheduleWakeup" and data.get("stop") is True: + self._wakes.clear() + + def tool_result(self, result: ToolResultContent, structured: Any, at: int) -> None: + """A tool result; ``structured`` is its entry's ``toolUseResult`` + (only when the entry holds this one result: it describes one).""" + call = self._calls.get(result.tool_use_id) + if call is None: + return + item, called_at = call + details = _structured(structured) + text = _result_text(result) + if item.name == "ScheduleWakeup": + self._scheduled(item, details, text, called_at) + return + kind = _KINDS.get(item.name) + if kind is None: + return + task_id = self._launch_id(kind, details, text) + if task_id is None: + return + data = item.input or {} + label = ( + _first_line(data.get("description")) + or _first_line(data.get("command")) + or item.name + ) + self._work[task_id] = _Work( + kind=kind, + label=label, + start=called_at, + expires=self._expiry(kind, data, details, text, called_at), + ) + + def notified(self, text: str, at: int) -> list[Notification]: + """Apply the notifications in ``text``; return them, so a turn + they start can say what reported.""" + found = notifications(text) + for note in found: + work = self._work.get(note.task_id or "") + if work is None: + continue + if note.status is None: + if (note.event or "").startswith(_MONITOR_EXPIRED_PREFIXES): + self._ended(work, at, "completed") + continue + final = _FINAL.get(note.status) + if final is None or _INTERIM in (note.summary or ""): + continue + self._ended(work, at, final) + return found + + def fired(self, at: int) -> Optional[str]: + """A wake-up fired: drop the pending one nearest ``at``, returning + its label (None when no pending wake-up is near: a cron job).""" + if not self._wakes: + return None + nearest = min(self._wakes, key=lambda w: abs(w.at - at)) + if abs(nearest.at - at) > _FIRE_MATCH_MS: + return None + self._wakes.remove(nearest) + return nearest.label + + # ---- reading ------------------------------------------------------ + def items(self, horizon: int, limit: int) -> list[list[Any]]: + """``[start, end, kind, status, label, expiresAt]`` for the work + not over before ``horizon``, oldest first, at most ``limit``.""" + shown = [w for w in self._work.values() if w.end is None or w.end >= horizon] + shown.sort(key=lambda w: w.start) + return [ + [w.start, w.end, w.kind, w.status, w.label, w.expires] + for w in shown[-limit:] + ] + + def pending_wakes(self, horizon: int) -> list[list[Any]]: + """``[at, label]`` for the wake-ups yet to fire.""" + return [ + [w.at, w.label] + for w in sorted(self._wakes, key=lambda w: w.at) + if w.at >= horizon + ] + + # ---- helpers ------------------------------------------------------ + @staticmethod + def _ended(work: _Work, at: int, status: str) -> None: + """Record an end. One notification can be on record more than + once (queued, then delivered): the earliest record of the same + end wins. A different end (an agent resumed and finished again) + replaces it.""" + if work.end is not None and work.status == status and work.end <= at: + return + work.end, work.status = at, status + + def _end(self, task_id: Any, at: int, status: str) -> None: + work = self._work.get(task_id) if isinstance(task_id, str) else None + if work is not None and work.end is None: + work.end, work.status = at, status + + @staticmethod + def _launch_id(kind: str, details: dict[str, Any], text: str) -> Optional[str]: + structured = { + "bash": details.get("backgroundTaskId"), + "agent": ( + details.get("agentId") + if details.get("status") == "async_launched" + or details.get("isAsync") is True + else None + ), + "monitor": details.get("taskId"), + }[kind] + if isinstance(structured, str) and structured: + return structured + for pattern in _LAUNCH_ID_RES[kind]: + match = pattern.search(text) + if match: + return match.group("id") + return None + + @staticmethod + def _expiry( + kind: str, + data: dict[str, Any], + details: dict[str, Any], + text: str, + start: int, + ) -> Optional[int]: + """When a monitor stops by itself (None: persistent, or not one).""" + if kind != "monitor" or details.get("persistent") is True: + return None + timeout = _int(details.get("timeoutMs")) or _int(data.get("timeout_ms")) + if timeout is None: + match = _MONITOR_TIMEOUT_RE.search(text) + if match: + timeout = int(match.group("ms")) + elif match := _MONITOR_EXPIRES_RE.search(text): + timeout = int(match.group("min")) * 60000 + return start + timeout if timeout else None + + def _scheduled( + self, + item: ToolUseContent, + details: dict[str, Any], + text: str, + called_at: int, + ) -> None: + if details.get("stopped") is True: + self._wakes.clear() + return + at = _int(details.get("scheduledFor")) + if not at: + match = _WAKE_IN_RE.search(text) + at = called_at + int(match.group("s")) * 1000 if match else None + if not at: + return + data = item.input or {} + label = ( + _first_line(data.get("reason")) + or _first_line(data.get("prompt")) + or "wake-up" + ) + self._wakes.append(_Wake(at=at, label=label)) diff --git a/claude_code_log/html/templates/components/recent/recent.css b/claude_code_log/html/templates/components/recent/recent.css index b59002f2..83a3b2db 100644 --- a/claude_code_log/html/templates/components/recent/recent.css +++ b/claude_code_log/html/templates/components/recent/recent.css @@ -280,6 +280,7 @@ body.rc-page { .rc-th-since { flex: 0 0 128px; + margin-left: 12px; /* over the rows' block, past their chevron */ } .rc-th-session { @@ -380,10 +381,36 @@ body.rc-page { flex-wrap: wrap; align-items: center; gap: 0 16px; - padding: 6px 16px; + padding: 6px 16px 6px 6px; /* the chevron sits close to the edge */ border-bottom: 1px solid var(--hair); } +/* The chevron: says the row opens, and rotates when it is open. A pointer + target only; the block next to it is the keyboard control. */ +.rc-chev { + flex: 0 0 14px; + align-self: stretch; + margin-right: -8px; /* sit close to the block it belongs to */ + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + cursor: pointer; +} + +.rc-chev svg { + transition: transform 0.12s ease; +} + +.rc-row[data-open="true"] .rc-chev svg { + transform: rotate(90deg); +} + +.rc-chev:hover, +.rc-row:has(.rc-block:hover) .rc-chev { + color: var(--fg); +} + .rc-block { flex: 0 0 128px; align-self: stretch; @@ -454,6 +481,12 @@ body.rc-page { color: var(--tool); } +/* Waiting on its own background work: calm, not your move. */ +.rc-state-waiting .rc-block { + color: var(--note); + box-shadow: inset 0 0 0 1px var(--note); +} + .rc-info { flex: 1 1 290px; min-width: 0; @@ -526,7 +559,7 @@ body.rc-page { flex: 1 1 520px; min-width: 0; position: relative; - height: 60px; + height: 64px; /* markers 16-36, background lanes 44-58 */ overflow: hidden; } @@ -619,6 +652,21 @@ body.rc-page { --rc-c: var(--err); } +.rc-k-task { + --rc-c: var(--tool); +} + +.rc-k-wake { + --rc-c: var(--note); +} + +/* A wake-up still to come: a dashed outline, not yet happened. */ +.rc-mk.rc-pending { + box-shadow: none; + outline: 1.5px dashed var(--rc-c); + outline-offset: -1.5px; +} + .rc-mk.rc-k-ask, .rc-mk.rc-k-err { background: var(--rc-c); @@ -646,7 +694,104 @@ body.rc-page { .rc-earlier { left: 2px; - top: 42px; + top: 1px; +} + +/* --------------------------------------------- background lanes */ + +/* One thin line per background command, agent or monitor, packed into + up to three lanes under the turn bars (recent.js drawLanes). */ +.rc-bg { + height: 2px; + min-width: 2px; + background: var(--dim); +} + +.rc-bg[data-lane="0"] { + top: 44px; +} + +.rc-bg[data-lane="1"] { + top: 50px; +} + +.rc-bg[data-lane="2"] { + top: 56px; +} + +/* Still running: in the tool colour, with an arrowhead at NOW. */ +.rc-bg-running { + background: var(--tool); +} + +.rc-bg-running::after { + content: ""; + position: absolute; + right: -4px; + top: -3px; + border-left: 5px solid var(--tool); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; +} + +/* No end recorded: dashed, faded. */ +.rc-bg-lost { + background: repeating-linear-gradient(90deg, var(--dim) 0 4px, transparent 4px 7px); + opacity: 0.7; +} + +/* The folded lane: several items, drawn as the union of their spans. */ +.rc-bg-more { + background: repeating-linear-gradient(90deg, var(--muted) 0 2px, transparent 2px 4px); +} + +.rc-bg-end { + --rc-lane: 44px; +} + +.rc-bg-end[data-lane="1"] { + --rc-lane: 50px; +} + +.rc-bg-end[data-lane="2"] { + --rc-lane: 56px; +} + +.rc-bg-end-completed { + top: calc(var(--rc-lane) - 3px); + width: 1.5px; + height: 8px; + margin-left: -1px; + background: var(--dim); +} + +.rc-bg-end-stopped { + top: calc(var(--rc-lane) - 3px); + width: 3px; + height: 8px; + margin-left: -1.5px; + background: var(--muted); +} + +.rc-bg-end-failed { + top: calc(var(--rc-lane) - 5px); + width: 12px; + height: 12px; + margin-left: -6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--err); +} + +.rc-bg-end-failed svg { + display: block; + stroke-width: 2.6; +} + +.rc-bg-more-n { + right: 2px; + bottom: 0; } .rc-outside { @@ -688,7 +833,7 @@ body.rc-page { display: flex; flex-wrap: wrap; gap: 16px; - padding: 10px 0 20px; + padding: 10px 0 20px 22px; /* under the block, past the chevron */ } .rc-x[hidden] { @@ -748,14 +893,16 @@ body.rc-page { padding-top: 1px; } +/* The last prompt: the time on a line of its own, the Markdown below. */ .rc-prompt { - margin: 0; max-width: 88ch; + display: flex; + flex-direction: column; + gap: 2px; font-family: var(--mono); font-size: 13px; line-height: 20px; color: var(--muted); - white-space: pre-wrap; overflow-wrap: anywhere; } @@ -799,6 +946,139 @@ body.rc-page { color: var(--err); } +/* An open row's background work: one line each, text only. */ +.rc-bgl { + display: flex; + flex-direction: column; + gap: 4px; +} + +.rc-bgl ul { + margin: 0; + padding: 0; + list-style: none; + font-family: var(--mono); + font-size: 12px; + line-height: 18px; + color: var(--muted); + overflow-wrap: anywhere; +} + +.rc-bgl-running { + color: var(--tool); +} + +.rc-bgl-wake { + color: var(--note); +} + +.rc-bgl-failed { + color: var(--err); +} + +.rc-bgl-lost { + opacity: 0.75; +} + +/* ------------------------------------------------ rendered Markdown */ + +/* The last prompt and message as recent.py rendered them. Spacing after + the minimal theme's message bodies (layout.css); code and its + highlighting from the Pygments sheets the page includes. */ +.rc-md { + white-space: normal; +} + +.rc-md > :first-child { + margin-top: 0; +} + +.rc-md p { + margin: 0; +} + +.rc-md p + p, +.rc-md :is(ul, ol, pre, .highlight, table, blockquote) + p, +.rc-md :is(ul, ol, pre, .highlight, table, blockquote) { + margin-top: 0.5em; + margin-bottom: 0; +} + +.rc-md :is(ul, ol) { + padding-left: 1.3em; +} + +.rc-md :is(h1, h2, h3, h4, h5, h6) { + margin: 0.6em 0 0.15em; + font-size: 1em; + line-height: 1.3; +} + +.rc-md :is(h1, h2) { + font-size: 1.15em; +} + +.rc-md blockquote { + margin-left: 0; + margin-right: 0; + padding-left: 10px; + border-left: 3px solid var(--rule); + color: var(--muted); +} + +.rc-md code { + font-family: var(--mono); + font-size: 0.86em; + padding: 0 3px; + border-radius: 3px; + background: var(--code); +} + +.rc-md pre, +.rc-md .highlight { + border-radius: 4px; + background: var(--code); +} + +.rc-md pre { + margin-left: 0; + margin-right: 0; + padding: 6px 10px; + font: 13px/1.55 var(--mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.rc-md pre code { + padding: 0; + background: transparent; + font-size: inherit; +} + +.rc-md .highlight pre { + margin: 0; + padding: 6px 10px; + background: transparent; +} + +.rc-md table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + font-size: 0.9em; +} + +.rc-md :is(th, td) { + padding: 2px 8px; + border: 1px solid var(--rule); +} + +/* The prompt keeps its quieter look: monospace, muted. */ +.rc-prompt-text { + color: var(--muted); +} + .rc-noreply { margin: 0; color: var(--muted); @@ -828,12 +1108,19 @@ body.rc-page { .rc-lg .rc-mk, .rc-lg .rc-tick, -.rc-lg .rc-band { +.rc-lg .rc-band, +.rc-lg .rc-bg { position: relative; top: auto; margin-left: 0; } +.rc-lg .rc-bg { + display: inline-block; + width: 18px; + margin-right: 4px; +} + .rc-lg .rc-tick { display: inline-block; } @@ -871,16 +1158,36 @@ body.rc-page { line-height: 28px; } + /* The filters and Updated wrap as one set, so Updated shares the + last filter row, pushed to the right edge. */ + .rc-stats { + flex-basis: 100%; + } + + .rc-filters { + display: contents; + } + .rc-updated { - margin-left: 0; - padding-left: 12px; + margin-left: auto; + padding: 6px 0; + border-left: 0; + align-items: flex-end; + } + + /* No box around the list: the rows sit on the page's own gutter. */ + .rc-panel { + border: 0; + border-radius: 0; + background: transparent; + overflow: visible; } .rc-row, .rc-thead, .rc-group-h { - padding-left: 10px; - padding-right: 10px; + padding-left: 0; + padding-right: 0; } .rc-row { @@ -910,7 +1217,42 @@ body.rc-page { flex-basis: 100%; } + /* An open row flattens into the row itself: Transcript beside the + copy button, the counts on one line, then the messages. */ + .rc-x:not([hidden]), .rc-x-side { + display: contents; + } + + .rc-page .rc-x-link { + order: 1; + min-height: 44px; /* the copy button's height: one line, a full touch target */ + } + + .rc-x-side dl { + order: 2; + flex-basis: 100%; + flex-direction: row; + flex-wrap: wrap; + gap: 2px 16px; + margin-top: 10px; + font-family: var(--mono); + font-size: 12px; + line-height: 18px; + } + + .rc-x-side dl div { + justify-content: flex-start; + gap: 6px; + } + + .rc-x-main { + order: 3; flex-basis: 100%; + padding: 10px 0 20px; + } + + .rc-th-since { + margin-left: 16px; /* over the block: past the chevron and gap */ } } diff --git a/claude_code_log/html/templates/components/recent/recent.js b/claude_code_log/html/templates/components/recent/recent.js index aee95d4f..45d7906c 100644 --- a/claude_code_log/html/templates/components/recent/recent.js +++ b/claude_code_log/html/templates/components/recent/recent.js @@ -11,7 +11,11 @@ // times are redrawn against the clock. // // Every string in the payload comes from a transcript: it reaches the page -// through textContent and setAttribute only, never as markup. +// through textContent and setAttribute only, never as markup. The one +// exception is the payload's `html` fields β€” the last prompt and message +// rendered as Markdown by recent.py with the session pages' pipeline, which +// escapes raw HTML in the transcript β€” and those go in through +// renderedMarkdown() alone. (function () { 'use strict'; @@ -19,7 +23,7 @@ const RELOAD_MS = 15000; const TICK_MS = 30000; const STALE_MS = 2 * 60000; - const BEFORE_MS = 6 * 3600000; // the strip shows 6 h back... + const BEFORE_MS = 4 * 3600000; // the strip shows 4 h back... const AFTER_MS = 3600000; // ...and 1 h ahead const SPAN_MS = BEFORE_MS + AFTER_MS; const NOW_PCT = (BEFORE_MS / SPAN_MS) * 100; @@ -28,19 +32,36 @@ const STORE_KEY = 'claude-code-log:recent'; const SVG_NS = 'http://www.w3.org/2000/svg'; + // The block's words for each state. The payload sends the first four + // (from the transcript); 'waiting' is the page's own: a finished turn + // with background work still running or a wake-up still to come. const STATES = { asked: 'asked you', error: 'error', done: 'finished', - busy: 'mid-turn' + busy: 'mid-turn', + waiting: 'background' + }; + const TRANSCRIPT_STATES = ['asked', 'error', 'done', 'busy']; + const BG_KINDS = { bash: 'Bash', agent: 'Agent', monitor: 'Monitor' }; + const BG_ENDS = ['completed', 'failed', 'stopped']; + const MAX_LANES = 3; // background lanes on the strip; the rest fold into the last + const LANE_GAP_PCT = 0.6; // room between two items sharing a lane + const MAX_BG_LISTED = 12; // background items in an open row's list + const WHO = { + claude: 'Claude', + question: 'Claude asked', + plan: 'Plan to approve', + tool: 'Tool call', + error: 'Error' }; - const WHO = { claude: 'Claude', tool: 'Tool call', error: 'Error' }; const FILTERS = { all: function () { return true; }, - move: function (s) { - return s.state === 'asked' || s.state === 'error' || s.state === 'done'; + move: function (s, ctx) { + const st = effectiveState(s, ctx); + return st === 'asked' || st === 'error' || st === 'done'; }, urgent: function (s) { return s.state === 'asked' || s.state === 'error'; @@ -56,7 +77,13 @@ err: 'M8 4v4.5M8 11.3v.2', branch: 'M4 5v6M12 7.5c0 2.5-3 2.5-6.5 4', clock: 'M8 4.5V8l2.5 1.5', - copy: 'M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5' + copy: 'M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5', + chevron: 'M6 3.5 10.5 8 6 12.5', + // a bell: background work reported back + task: 'M4.5 11V7.5a3.5 3.5 0 0 1 7 0V11l1 1.5h-9zM6.8 14.2h2.4', + // an alarm clock: a wake-up + wake: 'M8 4.5a4.5 4.5 0 1 0 0 9a4.5 4.5 0 1 0 0-9M8 6.8V9l1.4 1M2.8 4.3 4.6 2.8M13.2 4.3l-1.8-1.5', + x: 'M4.5 4.5l7 7M11.5 4.5l-7 7' }; // ---- clock ----------------------------------------------------- @@ -114,6 +141,52 @@ return Math.max(1, Math.ceil((s.lastAt + ctx.ttl * 60000 - ctx.now) / 60000)); } + // ---- background work ------------------------------------------- + // The transcript can't say whether work is still running, only that + // nothing reported its end. Such an item counts as running until its + // own expiry (a monitor's timeout) or bgCap after the session's last + // entry, whichever is first, judged against this page's clock so an + // old data file still ages; past that it has 'no end recorded'. + function bgLimit(item, s, ctx) { + const cap = s.lastAt + ctx.bgCap * 60000; + return item.expires !== null ? Math.min(item.expires, cap) : cap; + } + function bgStatus(item, s, ctx) { + if (item.end !== null) return item.status; + return ctx.now < bgLimit(item, s, ctx) ? 'running' : 'lost'; + } + // Where an item's line stops: its end, else now or its limit. + function bgEnd(item, s, ctx) { + return item.end !== null ? item.end : Math.min(bgLimit(item, s, ctx), ctx.now); + } + function bgRunning(s, ctx) { + return s.bg.filter(function (item) { + return bgStatus(item, s, ctx) === 'running'; + }).length; + } + // Wake-ups still to come (a missed one, past its grace, is dropped). + function pendingWakes(s, ctx) { + return s.wakes.filter(function (w) { + return ctx.now < w.at + ctx.wakeGrace * 60000; + }); + } + // The transcript state, or 'waiting' for a finished turn that is + // waiting on its own background work. A question, an error or a turn + // under way stays what it is. + function effectiveState(s, ctx) { + if (s.state !== 'done') return s.state; + return bgRunning(s, ctx) > 0 || pendingWakes(s, ctx).length > 0 ? 'waiting' : 'done'; + } + function bgName(item) { + return BG_KINDS[item.kind] + (item.label ? ' β€œ' + item.label + '”' : ''); + } + // "14:02 – running", "14:02 – no end recorded", "14:02–14:20 Β· failed" + function bgSpan(item, status) { + if (status === 'running') return hhmm(item.start) + ' – running'; + if (status === 'lost') return hhmm(item.start) + ' – no end recorded'; + return hhmm(item.start) + '–' + hhmm(item.end) + ' Β· ' + status; + } + // ---- payload --------------------------------------------------- function num(value) { const n = Number(value); @@ -142,7 +215,7 @@ title: str(raw.title) || str(raw.short), href: safeHref(raw.href), lastAt: num(raw.lastAt), - state: Object.prototype.hasOwnProperty.call(STATES, raw.state) ? raw.state : 'done', + state: TRANSCRIPT_STATES.indexOf(raw.state) >= 0 ? raw.state : 'done', messages: num(raw.messages), turnCount: num(raw.turnCount), toolCount: num(raw.toolCount), @@ -151,12 +224,49 @@ return Array.isArray(t) && t.length >= 2; }) .map(function (t) { - return [num(t[0]), num(t[1]), str(t[2]) || 'asst']; + // [start, end, how it ended, what started it, how it + // ended (snippets), who started it: you | task | wake] + const origin = t[5] === 'task' || t[5] === 'wake' ? t[5] : 'you'; + return [num(t[0]), num(t[1]), str(t[2]) || 'asst', str(t[3]), str(t[4]), origin]; + }), + // [start, end|null, kind, status|null, label, expiresAt|null] + bg: (Array.isArray(raw.bg) ? raw.bg : []) + .filter(function (b) { + return Array.isArray(b) && b.length >= 3; + }) + .map(function (b) { + const end = b[1] === null || b[1] === undefined ? null : num(b[1]); + return { + start: num(b[0]), + end: end, + kind: BG_KINDS[b[2]] ? b[2] : 'bash', + status: end !== null && BG_ENDS.indexOf(b[3]) >= 0 ? b[3] : end !== null ? 'completed' : null, + label: str(b[4]), + expires: b[5] === null || b[5] === undefined ? null : num(b[5]) + }; + }) + .sort(function (a, b) { + return a.start - b.start; + }), + // [at, label]: wake-ups not yet fired + wakes: (Array.isArray(raw.wakes) ? raw.wakes : []) + .filter(function (w) { + return Array.isArray(w) && w.length >= 1; + }) + .map(function (w) { + return { at: num(w[0]), label: str(w[1]) }; }), earlierTurns: num(raw.earlierTurns), tools: (Array.isArray(raw.tools) ? raw.tools : []).map(num), - lastPrompt: prompt ? { at: num(prompt.at), text: str(prompt.text) } : null, - last: last ? { at: num(last.at), who: WHO[last.who] ? last.who : 'claude', text: str(last.text) } : null + lastPrompt: prompt ? { at: num(prompt.at), html: str(prompt.html) } : null, + last: last + ? { + at: num(last.at), + who: WHO[last.who] ? last.who : 'claude', + text: str(last.text), + html: str(last.html) + } + : null }; } function cleanPayload(raw) { @@ -173,6 +283,8 @@ generatedAt: num(raw.generatedAt), windowDays: num(raw.windowDays) || 3, ttl: num(raw.cacheTtlMinutes) || 60, + bgCap: num(raw.bgCapMinutes) || 60, + wakeGrace: num(raw.wakeGraceMinutes) || 5, sessions: sessions }; } @@ -241,6 +353,14 @@ }); return svg; } + // The one place markup enters the page: a payload `html` field, which + // recent.py rendered with the session pages' escaping Markdown pipeline + // (never a transcript string itself; test_recent_page.py holds this). + function renderedMarkdown(cls, html) { + const node = el('div', 'rc-md ' + cls); + node.innerHTML = html; + return node; + } function markerIcon(kind) { const svg = icon([ICON[kind]], 12); svg.setAttribute('stroke-width', '1.9'); @@ -345,6 +465,9 @@ s.tools.forEach(function (ms) { if (ms >= win.start && ms <= win.end) nodes.push(span('rc-tick', win.pct(ms))); }); + drawLanes(s, ctx).forEach(function (node) { + nodes.push(node); + }); const earlier = s.earlierTurns + s.turns.filter(function (t) { @@ -354,10 +477,14 @@ nodes.push(el('span', 'rc-strip-note rc-earlier', 'β—‚ +' + plural(earlier, 'earlier turn'))); } const lastTurn = s.turns.length ? s.turns[s.turns.length - 1] : null; + const running = bgRunning(s, ctx); + const wakes = pendingWakes(s, ctx); label = plural(s.turnCount, 'turn') + (lastTurn ? ', the last ending ' + hhmm(lastTurn[1]) : '') + - (s.lastAt + ttlMs > ctx.now ? '; cache warm until ' + hhmm(s.lastAt + ttlMs) : ''); + (s.lastAt + ttlMs > ctx.now ? '; cache warm until ' + hhmm(s.lastAt + ttlMs) : '') + + (running ? '; ' + plural(running, 'background task') + ' running' : '') + + (wakes.length ? '; wakes up at ' + hhmm(wakes[0].at) : ''); } else { nodes.push( el( @@ -378,14 +505,118 @@ nodes.push(span('rc-now', NOW_PCT)); strip.replaceChildren(...nodes); strip.setAttribute('aria-label', label); - row.events = inWindow ? markerEvents(s, shown, win) : []; + row.events = inWindow ? markerEvents(s, shown, ctx) : []; row.markWidth = -1; } - function markerEvents(s, shown, win) { + // The background lanes: each item a thin line on the first lane free + // at its start (so work done one after another shares a lane), at + // most MAX_LANES of them; when more are needed, the last lane carries + // the rest as the union of their spans, with a "+N more" note. + function drawLanes(s, ctx) { + const win = ctx.win; + const laneEnds = []; + const placed = []; + s.bg.forEach(function (item) { + const status = bgStatus(item, s, ctx); + const end = bgEnd(item, s, ctx); + if (end < win.start || item.start > win.end) return; + const left = win.pct(Math.max(item.start, win.start)); + const right = win.pct(Math.min(end, win.end)); + let lane = laneEnds.findIndex(function (laneEnd) { + return laneEnd + LANE_GAP_PCT <= left; + }); + if (lane < 0) { + lane = laneEnds.length; + laneEnds.push(0); + } + laneEnds[lane] = right; + placed.push({ item: item, status: status, left: left, right: right, lane: lane, ends: end <= win.end }); + }); + const folded = laneEnds.length > MAX_LANES; + const nodes = []; + const overflow = []; + placed.forEach(function (p) { + if (folded && p.lane >= MAX_LANES - 1) { + overflow.push(p); + return; + } + const line = span('rc-bg rc-bg-' + p.status, p.left, p.right - p.left); + line.dataset.lane = String(p.lane); + line.title = bgName(p.item) + ' Β· ' + bgSpan(p.item, p.status); + nodes.push(line); + if (p.ends && BG_ENDS.indexOf(p.status) >= 0) { + const end = span('rc-bg-end rc-bg-end-' + p.status, p.right); + end.dataset.lane = String(p.lane); + if (p.status === 'failed') end.appendChild(icon([ICON.x], 11)); + nodes.push(end); + } + }); + if (overflow.length) { + const lane = String(MAX_LANES - 1); + const tip = + '+' + + overflow.length + + ' more: ' + + overflow + .slice(0, 8) + .map(function (p) { + return bgName(p.item); + }) + .join(', ') + + (overflow.length > 8 ? ', …' : ''); + overflow.sort(function (a, b) { + return a.left - b.left; + }); + let run = null; + overflow.concat([null]).forEach(function (p) { + if (p && run && p.left <= run.right + LANE_GAP_PCT) { + run.right = Math.max(run.right, p.right); + return; + } + if (run) { + const line = span('rc-bg rc-bg-more', run.left, run.right - run.left); + line.dataset.lane = lane; + line.title = tip; + nodes.push(line); + } + run = p ? { left: p.left, right: p.right } : null; + }); + const note = el('span', 'rc-strip-note rc-bg-more-n', '+' + overflow.length + ' more'); + note.title = tip; + nodes.push(note); + } + return nodes; + } + + // What started a turn: a prompt you typed, background work reporting + // back, or a wake-up. + const ORIGIN = { + you: { kind: 'prompt', tip: 'You' }, + task: { kind: 'task', tip: 'Background work reported' }, + wake: { kind: 'wake', tip: 'Woke up' } + }; + function markerEvents(s, shown, ctx) { + const win = ctx.win; const events = []; const final = s.turns[s.turns.length - 1]; + pendingWakes(s, ctx).forEach(function (w) { + if (w.at < win.start) return; + // Clamped to the strip's end; the tooltip has the time. + events.push({ + at: Math.min(w.at, win.end), + time: w.at, + kind: 'wake', + pending: true, + keep: true, + tip: 'Wakes up', + snippet: w.label + }); + }); shown.forEach(function (t) { - if (t[0] >= win.start) events.push({ at: t[0], kind: 'prompt', keep: false, tip: 'You' }); + if (t[0] >= win.start) { + const origin = ORIGIN[t[5]]; + events.push({ at: t[0], kind: origin.kind, keep: false, tip: origin.tip, snippet: t[3] }); + } const kind = t[2]; if (kind === 'open' || t[1] < win.start || t[1] > win.end) return; const isLast = t === final; @@ -401,7 +632,8 @@ at: t[1], kind: kind === 'ask' || kind === 'err' ? kind : 'asst', keep: isLast || kind === 'ask' || kind === 'err', - tip: tip + tip: tip, + snippet: t[4] }); }); events.sort(function (a, b) { @@ -422,9 +654,9 @@ const x = (pct / 100) * width; const full = e.keep || x - lastFull >= MARK_GAP_PX; if (full) lastFull = x; - const node = el('span', (full ? 'rc-mk' : 'rc-dot') + ' rc-k-' + e.kind); + const node = el('span', (full ? 'rc-mk' : 'rc-dot') + ' rc-k-' + e.kind + (e.pending ? ' rc-pending' : '')); node.style.left = pct + '%'; - node.title = e.tip + ' Β· ' + hhmm(e.at); + node.title = e.tip + ' Β· ' + hhmm(e.time || e.at) + (e.snippet ? '\n' + e.snippet : ''); if (full) node.appendChild(markerIcon(e.kind)); return node; }); @@ -473,6 +705,14 @@ row.el.setAttribute('role', 'listitem'); row.el.dataset.id = id; + // The chevron says the row opens; it is a pointer target only (the + // block is the control keyboards and screen readers use), so a row + // doesn't gain a second tab stop. + row.chev = el('span', 'rc-chev'); + row.chev.setAttribute('aria-hidden', 'true'); + row.chev.appendChild(icon([ICON.chevron], 14)); + row.chev.addEventListener('click', toggle); + row.block = el('button', 'rc-block'); row.block.type = 'button'; row.block.setAttribute('aria-controls', 'rc-x-' + id); @@ -482,12 +722,13 @@ whenEl.append(row.blockN, row.blockU); row.blockStatus = el('span', 'rc-block-status'); row.block.append(whenEl, row.blockStatus); - row.block.addEventListener('click', function () { + function toggle() { if (state.expanded.has(id)) state.expanded.delete(id); else state.expanded.add(id); save(); applyExpanded(row); - }); + } + row.block.addEventListener('click', toggle); row.info = el('div', 'rc-info'); row.strip = el('div', 'rc-strip'); @@ -518,13 +759,14 @@ row.panel.id = 'rc-x-' + id; row.panel.hidden = true; - row.el.append(row.block, row.info, row.strip, row.copy, row.panel); + row.el.append(row.chev, row.block, row.info, row.strip, row.copy, row.panel); return row; } function applyExpanded(row) { const open = state.expanded.has(row.id); setAttr(row.block, 'aria-expanded', open ? 'true' : 'false'); + setAttr(row.el, 'data-open', open ? 'true' : 'false'); if (row.panel.hidden === open) row.panel.hidden = !open; if (row.label) { setAttr(row.block, 'aria-label', (open ? 'Hide' : 'Show') + ' the last message of ' + row.label); @@ -582,7 +824,12 @@ s.href, s.lastPrompt, s.last, - new Date(ctx.now).toDateString() + new Date(ctx.now).toDateString(), + s.bg, + s.bg.map(function (item) { + return bgStatus(item, s, ctx); + }), + pendingWakes(s, ctx) ]); if (row.sigs.panel === sig) return; row.sigs.panel = sig; @@ -606,10 +853,10 @@ } const main = el('div', 'rc-x-main'); if (s.lastPrompt) { - const prompt = el('p', 'rc-prompt'); + const prompt = el('div', 'rc-prompt'); prompt.append( el('span', 'rc-prompt-at', 'β€Ί ' + when(s.lastPrompt.at, ctx.now)), - document.createTextNode(' ' + s.lastPrompt.text) + renderedMarkdown('rc-prompt-text', s.lastPrompt.html) ); main.appendChild(prompt); } @@ -617,17 +864,65 @@ const last = el('div', 'rc-last rc-who-' + s.last.who); last.append( el('span', 'rc-who', WHO[s.last.who] + ' Β· ' + when(s.last.at, ctx.now)), - el('p', 'rc-last-text', s.last.text) + s.last.html ? renderedMarkdown('rc-last-text', s.last.html) : el('p', 'rc-last-text', s.last.text) ); main.appendChild(last); } else { main.appendChild(el('p', 'rc-noreply', 'No reply yet.')); } + const background = backgroundList(s, ctx); + if (background) main.appendChild(background); refocus(row.panel, function () { row.panel.replaceChildren(side, main); }); } + // An open row's list of its background work: what is running first, + // then wake-ups still to come, then the rest by start. Text only: + // the labels are transcript text. + function backgroundList(s, ctx) { + const wakes = pendingWakes(s, ctx); + if (!s.bg.length && !wakes.length) return null; + const items = s.bg.map(function (item) { + return { item: item, status: bgStatus(item, s, ctx) }; + }); + const running = items.filter(function (x) { + return x.status === 'running'; + }); + const rest = items.filter(function (x) { + return x.status !== 'running'; + }); + const lines = running + .map(function (x) { + return bgLine(x); + }) + .concat( + wakes.map(function (w) { + return { text: 'Wakes up ' + hhmm(w.at) + (w.label ? ' Β· ' + w.label : ''), cls: 'rc-bgl-wake' }; + }), + rest.map(function (x) { + return bgLine(x); + }) + ); + const box = el('div', 'rc-bgl'); + box.appendChild(el('span', 'rc-who', 'Background')); + const list = el('ul'); + lines.slice(0, MAX_BG_LISTED).forEach(function (line) { + list.appendChild(el('li', line.cls, line.text)); + }); + if (lines.length > MAX_BG_LISTED) { + list.appendChild(el('li', 'rc-bgl-more', '+' + (lines.length - MAX_BG_LISTED) + ' more')); + } + box.appendChild(list); + return box; + } + function bgLine(x) { + const parts = [BG_KINDS[x.item.kind]]; + if (x.item.label) parts.push(x.item.label); + parts.push(bgSpan(x.item, x.status)); + return { text: parts.join(' Β· '), cls: 'rc-bgl-' + x.status }; + } + // Replace a part's content, putting focus back on its counterpart if // it was inside (the same data-focus key). function refocus(container, replace) { @@ -642,13 +937,14 @@ function updateRow(row, s, ctx) { row.short = s.short; - row.el.className = 'rc-row rc-state-' + s.state; + const shownState = effectiveState(s, ctx); + row.el.className = 'rc-row rc-state-' + shownState; const t = since(s.lastAt, ctx.now); setText(row.blockN, t.n); setText(row.blockU, t.u); row.block.classList.toggle('rc-block-long', t.u !== 'min' && t.u !== 'h'); - setText(row.blockStatus, STATES[s.state]); - row.label = s.title + ', ' + STATES[s.state] + ', updated ' + ago(s.lastAt, ctx.now); + setText(row.blockStatus, STATES[shownState]); + row.label = s.title + ', ' + STATES[shownState] + ', updated ' + ago(s.lastAt, ctx.now); applyExpanded(row); drawInfo(row, s, ctx); const stripSig = JSON.stringify([ @@ -660,7 +956,11 @@ s.tools, s.earlierTurns, s.turnCount, - s.messages + s.messages, + s.bg, + s.wakes, + ctx.bgCap, + ctx.wakeGrace ]); if (row.sigs.strip !== stripSig) { row.sigs.strip = stripSig; @@ -695,7 +995,13 @@ const payload = state.payload; if (!payload) return; const now = clock(); - const ctx = { now: now, ttl: payload.ttl, win: stripCtx(now) }; + const ctx = { + now: now, + ttl: payload.ttl, + bgCap: payload.bgCap, + wakeGrace: payload.wakeGrace, + win: stripCtx(now) + }; const scrollY = window.scrollY; filterEls.forEach(function (button) { @@ -836,7 +1142,10 @@ item(mark('ask'), 'asked you'), item(mark('err'), 'error'), item(el('span', 'rc-tick'), 'tool call'), - item(band, 'prompt cache warm') + item(band, 'prompt cache warm'), + item(el('span', 'rc-bg rc-bg-running'), 'background work'), + item(mark('task'), 'work reported'), + item(mark('wake'), 'wake-up') ); })(); diff --git a/claude_code_log/html/templates/recent.html b/claude_code_log/html/templates/recent.html index d02eb67c..30229c0d 100644 --- a/claude_code_log/html/templates/recent.html +++ b/claude_code_log/html/templates/recent.html @@ -7,9 +7,14 @@ Recent sessions {#- One look in both themes: the minimal theme's tokens, and the page's own - stylesheet. The stored light/dark choice applies before first paint. #} + stylesheet. The stored light/dark choice applies before first paint. + The Pygments sheets colour code in the rendered Markdown of an open + row; body.theme-minimal points the light one at the tokens, and is + the ancestor the dark one is scoped under. #} - +
- -
diff --git a/test/test_recent_browser.py b/test/test_recent_browser.py index db10bd6c..10609c8a 100644 --- a/test/test_recent_browser.py +++ b/test/test_recent_browser.py @@ -638,10 +638,16 @@ def test_stale_notice_and_empty_state(isolated_page: Page, tmp_path: Path) -> No expect(notice).to_be_visible() expect(notice).to_contain_text("Data from 5 min ago") expect(notice).to_contain_text("claude-code-log watch") + # It sits in the top bar, beside the theme toggle, so showing or + # hiding it doesn't move the list. + expect(page.locator(".rc-nav #rc-stale")).to_have_count(1) + before = page.locator(".rc-panel").bounding_box() (tmp_path / "recent-data.js").write_text(recent_data_js(fresh), encoding="utf-8") page.clock.run_for(16000) expect(page.locator(".rc-row")).to_have_count(1) expect(notice).to_be_hidden() + after = page.locator(".rc-panel").bounding_box() + assert before and after and after["y"] == before["y"] expect(page.locator("#rc-empty")).to_be_hidden() From e2e5b57259e4824de7681b05ec51ad569e1bd4b3 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 3 Nov 2026 14:30:13 +0000 Subject: [PATCH 11/17] Recent sessions: a project name that fails once is tried again The project's cache manager was kept before its display name was resolved, so when resolving failed the next row of that project skipped the lookup and then hit a KeyError outside the try, which took the whole page down. The manager is now kept only once the name is known. Review on #367. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/recent.py | 11 +++++++---- test/test_recent.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/claude_code_log/recent.py b/claude_code_log/recent.py index 59267b0b..2c78db73 100644 --- a/claude_code_log/recent.py +++ b/claude_code_log/recent.py @@ -767,14 +767,17 @@ def build_recent_payload( key = str(project_path) session_id = row["session_id"] try: - if key not in managers: - managers[key] = CacheManager( + manager = managers.get(key) + if manager is None: + manager = CacheManager( project_path, get_library_version(), db_path, read_only=True ) + # Kept only once named, so a failure here leaves both + # unset and the project's next row tries again. names[key] = get_project_display_name( - project_path.name, managers[key].get_working_directories() + project_path.name, manager.get_working_directories() ) - manager = managers[key] + managers[key] = manager def read(manager: CacheManager = manager) -> SessionSummary: return read_session(manager.load_session_entries(session_id)) diff --git a/test/test_recent.py b/test/test_recent.py index 6a534f33..4b813959 100644 --- a/test/test_recent.py +++ b/test/test_recent.py @@ -15,6 +15,8 @@ import pytest +import claude_code_log.recent as recent_module + from claude_code_log.converter import load_transcript, process_projects_hierarchy from claude_code_log.cache import CacheManager from claude_code_log.recent import ( @@ -1018,6 +1020,36 @@ def _project(projects: Path, name: str, session: _Session) -> Path: return project +def test_a_project_name_that_fails_once_is_tried_again( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Naming a project reads its cache; if that fails for one row, the + # next row of the same project tries again rather than taking the + # page down on a name that was never stored. + projects = tmp_path / "projects" + project = projects / "-home-u-code-foldyard" + project.mkdir(parents=True) + for sid, ago in (("aaaa1111-new", 10), ("bbbb2222-older", 20)): + s = _Session(sid, cwd="/home/u/code/foldyard") + s.prompt(ago, "Go.") + s.reply(ago - 1, "Done.") + s.write(project / f"{sid}.jsonl") + process_projects_hierarchy(projects, silent=True) + real = recent_module.get_project_display_name + failed: list[bool] = [] + + def flaky(*args: Any) -> str: + if not failed: + failed.append(True) + raise OSError("cache busy") + return real(*args) + + monkeypatch.setattr(recent_module, "get_project_display_name", flaky) + payload = build_recent_payload(projects, now=NOW) + assert [s["id"] for s in payload["sessions"]] == ["bbbb2222-older"] + assert payload["sessions"][0]["project"] == "foldyard" + + @pytest.fixture def archive(tmp_path: Path) -> Path: projects = tmp_path / "projects" From 8b871e27af2342f24c1125bd74deb227cbb96cd0 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 3 Nov 2026 14:43:32 +0000 Subject: [PATCH 12/17] Recent sessions: the old-data notice is the theme toggle's height It was 4px shorter than the toggle beside it. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/html/templates/components/recent/recent.css | 3 ++- test/test_recent_browser.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/claude_code_log/html/templates/components/recent/recent.css b/claude_code_log/html/templates/components/recent/recent.css index b8ee3d75..0cf47113 100644 --- a/claude_code_log/html/templates/components/recent/recent.css +++ b/claude_code_log/html/templates/components/recent/recent.css @@ -247,7 +247,8 @@ body.rc-page { .rc-stale { margin: 0; min-width: 0; - padding: 5px 10px; + /* 18px of line plus 14px: the theme toggle's height. */ + padding: 7px 10px; border-radius: 4px; background: var(--errbg); color: var(--err); diff --git a/test/test_recent_browser.py b/test/test_recent_browser.py index 10609c8a..436d7291 100644 --- a/test/test_recent_browser.py +++ b/test/test_recent_browser.py @@ -641,6 +641,9 @@ def test_stale_notice_and_empty_state(isolated_page: Page, tmp_path: Path) -> No # It sits in the top bar, beside the theme toggle, so showing or # hiding it doesn't move the list. expect(page.locator(".rc-nav #rc-stale")).to_have_count(1) + box = notice.bounding_box() + toggle = page.locator(".rc-nav .mn-seg").bounding_box() + assert box and toggle and box["height"] == toggle["height"] before = page.locator(".rc-panel").bounding_box() (tmp_path / "recent-data.js").write_text(recent_data_js(fresh), encoding="utf-8") page.clock.run_for(16000) From 2031d2f5aec7d775bbda318b1113501c7b54f5d1 Mon Sep 17 00:00:00 2001 From: Daniel Demmel Date: Tue, 3 Nov 2026 14:48:42 +0000 Subject: [PATCH 13/17] Recent sessions: no session link when no session pages are written Under --combined only the index still names each session's file, so the recent page linked every row to a page nothing writes, on the full run and on the watch's per-tick refresh alike. Both now leave the link out, and the title shows as plain text. Review on #367. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/cli.py | 7 +++++- claude_code_log/converter.py | 15 ++++++++++--- claude_code_log/recent.py | 16 ++++++++++++-- dev-docs/application_model.md | 3 +++ docs/recent-sessions.md | 2 +- test/test_recent.py | 22 +++++++++++++++++++ test/test_watch_cli.py | 41 +++++++++++++++++++++++++++++++++++ 7 files changed, 99 insertions(+), 7 deletions(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 3642d8fd..8cf97bfd 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -2602,7 +2602,12 @@ def refresh_recent() -> None: from . import recent try: - recent.write_recent_data(projects_path, projects_path, memo=recent_memo) + recent.write_recent_data( + projects_path, + projects_path, + memo=recent_memo, + session_pages=individual, + ) except Exception as exc: click.echo(f" recent sessions not refreshed: {exc!r}", err=True) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 1fd13294..bc58d747 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -6538,6 +6538,7 @@ def _write_recent_page( projects_path: Path, project_summaries: List[Dict[str, Any]], theme: str, + session_pages: bool = True, ) -> None: """Write ``recent.html`` and its ``recent-data.js`` next to the index. @@ -6545,13 +6546,15 @@ def _write_recent_page( refreshes itself from ``recent-data.js`` (see ``recent.py``). Session links come from the index's own per-session ``file`` entries, so they follow ``--output`` / ``--expand-paths`` / variant filenames exactly - as the index does. A failure here must never fail the conversion: + as the index does. Without ``session_pages`` (``--combined only``) + there are none: the index names each session's file all the same, + but nothing writes it. A failure here must never fail the conversion: the page is a convenience next to the index, not part of it. """ from .recent import RECENT_DATA, RECENT_PAGE, build_recent_payload, recent_data_js links: Dict[str, str] = {} - for summary in project_summaries: + for summary in project_summaries if session_pages else []: for session in summary.get("sessions", []): if session.get("id") and session.get("file"): links[str(session["id"])] = str(session["file"]) @@ -7363,7 +7366,13 @@ def _convert_plan_inline( atomic_write_text( index_path.parent / "search.html", generate_archive_search_html(theme) ) - _write_recent_page(index_path.parent, projects_path, project_summaries, theme) + _write_recent_page( + index_path.parent, + projects_path, + project_summaries, + theme, + session_pages=generate_individual_sessions, + ) # Count total sessions from project summaries for summary in project_summaries: diff --git a/claude_code_log/recent.py b/claude_code_log/recent.py index 2c78db73..b52d68bd 100644 --- a/claude_code_log/recent.py +++ b/claude_code_log/recent.py @@ -692,6 +692,11 @@ def _default_href(project_dir: Path, session_id: str) -> str: return f"{project_dir.name}/session-{session_id}.html" +def _no_href(_project_dir: Path, _session_id: str) -> None: + """No session page was written, so there is nothing to link to.""" + return None + + def _session_rows(db_path: Path, cutoff_iso: str, limit: int) -> list[sqlite3.Row]: """The newest visible session rows across every project. @@ -869,18 +874,25 @@ def write_recent_data( payload: Optional[dict[str, Any]] = None, now: Optional[datetime] = None, memo: Optional[RecentMemo] = None, + session_pages: bool = True, ) -> Optional[dict[str, Any]]: """Rewrite ``recent-data.js`` next to ``recent.html``, if that page exists. Returns the payload written, or None when there is no page to feed (a single-file conversion, or an output tree without the index). ``memo`` carries session summaries over from the previous call (see - :class:`RecentMemo`). + :class:`RecentMemo`). Without ``session_pages`` (``--combined only``) + the rows link nowhere, since there is no session page to link to. """ from .utils import atomic_write_text if not (index_dir / RECENT_PAGE).exists(): return None - payload = payload or build_recent_payload(projects_dir, now=now, memo=memo) + payload = payload or build_recent_payload( + projects_dir, + now=now, + href_for=None if session_pages else _no_href, + memo=memo, + ) atomic_write_text(index_dir / RECENT_DATA, recent_data_js(payload)) return payload diff --git a/dev-docs/application_model.md b/dev-docs/application_model.md index 0172756e..93146237 100644 --- a/dev-docs/application_model.md +++ b/dev-docs/application_model.md @@ -1683,6 +1683,9 @@ HTML hierarchy run. It takes each session's link from the index's own `sessions[].file` entries, so `--output`, `--expand-paths` and variant filenames resolve as the index's do, and writes `recent-data.js` then `recent.html`. A failure there is reported and never fails the run. +Under `--combined only` the rows get no link: the index still names each +session's file, but nothing writes it. The watch's per-tick refresh +(`recent.write_recent_data`) follows the same rule with `session_pages`. **Self-refresh on `file://`.** A page opened as a file cannot `fetch` (see `live_update.js`), but it can add a `