diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c2fc0663..4dc676b6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -519,6 +519,19 @@ supercut and the walkthrough. The data is synthetic and committed
the videos re-record identically anywhere. To show a new feature, add a
`@scene` and list it in `CHAPTERS` or `SUPERCUT`.
+The recent sessions chapter and beat are driven by
+`test/recent_live_fixture.py`: sessions in four projects, written as real
+JSONL and moved on stage by stage, each stage converted by the real full
+run. The same stages are an end-to-end browser test
+(`test/test_recent_live_browser.py`), so a change that breaks the demo's
+story fails in `just test-browser` first, and a play mode for judging the
+page by eye in your own browser:
+
+```bash
+just recent-play # a sandbox page; the next stage every 20 s
+just recent-play --step # Enter moves on a stage
+```
+
The README embeds the supercut as a GitHub upload: after `--publish`, drag
`docs/assets/demos/supercut.mp4` into the README editor on GitHub and
replace the old attachment link.
diff --git a/claude_code_log/background_work.py b/claude_code_log/background_work.py
new file mode 100644
index 00000000..5b8a5359
--- /dev/null
+++ b/claude_code_log/background_work.py
@@ -0,0 +1,364 @@
+"""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. When it
+ then reports by ``SubagentHandback``, the notification that follows
+ keeps that interim summary; only its result says the report was
+ delivered, and that is the 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|result)>(?P.*?)(?P=tag)>",
+ 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"
+_HANDED_BACK = "report was delivered to you"
+
+
+@dataclass
+class Notification:
+ """One ```` block's fields."""
+
+ task_id: Optional[str]
+ status: Optional[str]
+ summary: Optional[str]
+ event: Optional[str]
+ result: Optional[str] = None
+
+ @property
+ def label(self) -> Optional[str]:
+ """What it says happened: the summary, else the event."""
+ return self.summary or self.event
+
+ @property
+ def interim(self) -> bool:
+ """An agent saying it is waiting on its own work, not ending.
+ After a hand-back the summary still says so, but the result
+ says the report was delivered: that one is the end."""
+ return _INTERIM in (self.summary or "") and _HANDED_BACK not in (
+ self.result or ""
+ )
+
+
+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,
+ result=fields.get("result") 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 note.interim:
+ 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, exited_at: Optional[int] = None
+ ) -> list[list[Any]]:
+ """``[start, end, kind, status, label, expiresAt]`` for the work
+ not over before ``horizon``, oldest first, at most ``limit``.
+
+ After the session's process exited (``exited_at``), work with no
+ end on record ended with it: Claude Code stops its background
+ work on the way out, and says so only by leaving no end."""
+ rows: list[list[Any]] = []
+ for w in sorted(self._work.values(), key=lambda w: w.start):
+ end, status = w.end, w.status
+ if end is None and exited_at is not None:
+ end, status = exited_at, "exited"
+ if end is None or end >= horizon:
+ rows.append([w.start, end, w.kind, status, w.label, w.expires])
+ return rows[-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/cli.py b/claude_code_log/cli.py
index cd0159d6..8cf97bfd 100644
--- a/claude_code_log/cli.py
+++ b/claude_code_log/cli.py
@@ -2577,6 +2577,39 @@ def watch(
from .entry_store import ParsedEntryStore, entry_store_enabled
store = ParsedEntryStore() if (entry_store_enabled() and not all_projects) else None
+ # Likewise one memo of recent-session summaries for the whole watch:
+ # each tick rewrites recent-data.js, re-reading only the sessions that
+ # changed since the last (recent.RecentMemo).
+ from .recent import RecentMemo
+
+ recent_memo = RecentMemo()
+
+ 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 entries of the
+ recent sessions that changed since the last tick: ``recent_memo``
+ keeps the rest), 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 . import recent
+
+ try:
+ 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)
def convert(_changed: set[Path]) -> None:
started = time.monotonic()
@@ -2604,6 +2637,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..bc58d747 100644
--- a/claude_code_log/converter.py
+++ b/claude_code_log/converter.py
@@ -132,6 +132,10 @@ def _dag_warnings_suppressed(silent: bool) -> Iterator[None]:
# claude.ai page. No uuid; fully redundant with the Artifact
# tool_result (same path and URL), which is rendered.
"frame-link",
+ # Written as the process exits: {startTime, totalDuration,
+ # totalCostUSD, modelUsage, ...}, no uuid or timestamp. The recent
+ # sessions page reads it from the file's tail (recent.session_exit).
+ "cost-state",
}
)
@@ -6529,6 +6533,43 @@ def process_projects_hierarchy(
)
+def _write_recent_page(
+ index_dir: Path,
+ 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.
+
+ 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. 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 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"])
+ 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(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 +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,
+ session_pages=generate_individual_sessions,
+ )
# Count total sessions from project summaries
for summary in project_summaries:
diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py
index 210c5a0e..8b33a7ea 100644
--- a/claude_code_log/html/renderer.py
+++ b/claude_code_log/html/renderer.py
@@ -2132,3 +2132,22 @@ def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str:
],
)
)
+
+
+def generate_recent_html(theme: str = DEFAULT_THEME) -> str:
+ """Render the recent sessions page (``recent.html``).
+
+ The page holds no data: it loads the sibling ``recent-data.js``
+ (:func:`claude_code_log.recent.recent_data_js`) on open and again on
+ every refresh, so it is identical from run to run and no transcript
+ text is ever inlined into it. It has one look in both themes (the
+ minimal theme's tokens); ``theme`` is accepted for symmetry with the
+ other page generators and recorded in the stamp.
+ """
+ env = get_template_environment()
+ return str(
+ env.get_template("recent.html").render(
+ library_version=get_library_version(),
+ theme=normalize_theme(theme),
+ )
+ )
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/components/recent/recent.css b/claude_code_log/html/templates/components/recent/recent.css
new file mode 100644
index 00000000..2f82a6f7
--- /dev/null
+++ b/claude_code_log/html/templates/components/recent/recent.css
@@ -0,0 +1,1304 @@
+/* The recent sessions page (recent.html): one look in both themes. The
+ colours are the minimal theme's tokens (tokens.css, included before this
+ file), plus the few the page needs of its own, below. Every rule is
+ scoped under body.rc-page. */
+
+:root {
+ --rc-canvas: #f6f7f8; /* the page behind the panel (the panel is --bg) */
+ --rc-bar: #c9cdd2; /* a turn's bar on the strip */
+ --rc-ask: var(--user); /* asked you: orange */
+ --rc-cache: var(--note);
+ --rc-cachebg: #e3ecfb;
+ --rc-future: var(--code);
+ --rc-gridline: var(--rule);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --rc-canvas: #0e1013;
+ --rc-bar: #3d434c;
+ --rc-cachebg: #132238;
+ }
+}
+
+:root[data-theme="dark"] {
+ --rc-canvas: #0e1013;
+ --rc-bar: #3d434c;
+ --rc-cachebg: #132238;
+}
+
+body.rc-page {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--rc-canvas);
+ color: var(--fg);
+ font-family: var(--sans);
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.rc-page *,
+.rc-page *::before,
+.rc-page *::after {
+ box-sizing: border-box;
+}
+
+.rc-page a {
+ color: var(--note);
+}
+
+.rc-page a:hover {
+ color: var(--fg);
+}
+
+.rc-page button {
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+
+.rc-page :focus-visible {
+ outline: 2px solid var(--note);
+ outline-offset: 2px;
+}
+
+.rc-page code {
+ padding: 0 3px;
+ border-radius: 3px;
+ background: var(--code);
+ font: 0.92em var(--mono);
+}
+
+.rc-wrap {
+ max-width: 1240px;
+ margin: 0 auto;
+ padding: 20px 24px 56px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+/* ---------------------------------------------------------- header */
+
+.rc-nav {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 12px;
+ font-size: 13px;
+}
+
+.rc-nav > a,
+.rc-nav .mn-seg {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.rc-sp {
+ flex: 1;
+}
+
+/* The Auto / Light / Dark toggle (components/minimal/scheme_toggle.html),
+ styled here rather than through the minimal pages' chrome.css. */
+.rc-page .mn-seg {
+ display: inline-flex;
+ border: 1px solid var(--rule);
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--bg);
+}
+
+.rc-page .mn-seg button {
+ width: 32px;
+ min-height: 30px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.rc-page .mn-seg button + button {
+ border-left: 1px solid var(--rule);
+}
+
+.rc-page .mn-seg button.on {
+ background: var(--fg);
+ color: var(--bg);
+}
+
+.rc-page .mn-seg button:not(.on):hover {
+ color: var(--fg);
+}
+
+.rc-page .mn-seg svg {
+ display: block;
+}
+
+.rc-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px 32px;
+}
+
+.rc-head h1 {
+ margin: 0;
+ font-size: 34px;
+ line-height: 40px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+}
+
+.rc-stats {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+ gap: 6px;
+ font-family: var(--mono);
+}
+
+.rc-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.rc-filter {
+ min-height: 56px;
+ padding: 6px 14px;
+ border: 0;
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ text-align: left;
+ background: transparent;
+ box-shadow: inset 0 0 0 1px var(--rule);
+ --rc-c: var(--fg);
+ color: var(--muted);
+}
+
+.rc-filter[data-filter="urgent"] {
+ --rc-c: var(--rc-ask);
+ color: var(--rc-c);
+}
+
+.rc-filter[data-filter="cache"] {
+ --rc-c: var(--rc-cache);
+ color: var(--rc-c);
+}
+
+.rc-filter[aria-pressed="true"] {
+ background: var(--rc-c);
+ color: var(--bg);
+ box-shadow: none;
+}
+
+.rc-k {
+ font-size: 11px;
+ line-height: 14px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.rc-filter .rc-n {
+ font-size: 28px;
+ line-height: 34px;
+ font-variant-numeric: tabular-nums;
+ font-weight: 300;
+ color: var(--rc-c);
+}
+
+.rc-filter[data-filter="urgent"] .rc-n,
+.rc-filter[data-filter="cache"] .rc-n {
+ font-weight: 500;
+}
+
+.rc-filter[aria-pressed="true"] .rc-n {
+ color: var(--bg);
+ font-weight: 500;
+}
+
+.rc-updated {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 6px 0 6px 18px;
+ margin-left: 6px;
+ border-left: 1px solid var(--rule);
+ color: var(--muted);
+}
+
+.rc-updated .rc-n {
+ font-size: 28px;
+ line-height: 34px;
+ font-weight: 300;
+ color: var(--fg);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Old data, said in the top bar beside the theme toggle: there, showing
+ or hiding it doesn't move the list. */
+.rc-stale {
+ margin: 0;
+ min-width: 0;
+ /* 18px of line plus 14px: the theme toggle's height. */
+ padding: 7px 10px;
+ border-radius: 4px;
+ background: var(--errbg);
+ color: var(--err);
+ font-weight: 600;
+ line-height: 18px;
+ text-align: right;
+}
+
+.rc-stale[hidden] {
+ display: none;
+}
+
+/* ----------------------------------------------------------- panel */
+
+.rc-panel {
+ --rc-surface: var(--bg); /* what a row is drawn on (see .rc-row) */
+ display: flex;
+ flex-direction: column;
+ border-radius: 8px;
+ background: var(--rc-surface);
+ border: 1px solid var(--rule);
+ overflow: hidden;
+}
+
+.rc-thead {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 0 16px;
+ padding: 6px 16px;
+ border-bottom: 1px solid var(--rule);
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 14px;
+ color: var(--muted);
+}
+
+.rc-th {
+ padding-bottom: 2px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.rc-th-since {
+ flex: 0 0 128px;
+ margin-left: 12px; /* over the rows' block, past their chevron */
+}
+
+.rc-th-session {
+ flex: 1 1 290px;
+}
+
+.rc-axis {
+ flex: 1 1 520px;
+ min-width: 0;
+ position: relative;
+ height: 14px;
+ margin-bottom: 2px;
+}
+
+.rc-axis span {
+ position: absolute;
+ transform: translateX(-50%);
+ white-space: nowrap;
+}
+
+.rc-axis .rc-axis-now {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+.rc-group-toggle {
+ flex: 0 0 104px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 32px;
+ padding: 0 6px;
+ border-radius: 4px;
+ box-shadow: inset 0 0 0 1px var(--rule);
+ color: var(--fg);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.rc-group-toggle input {
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ accent-color: var(--fg);
+}
+
+.rc-group-toggle:focus-within {
+ outline: 2px solid var(--note);
+ outline-offset: 2px;
+}
+
+.rc-group-toggle input:focus-visible {
+ outline: none;
+}
+
+.rc-rows,
+.rc-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.rc-group-h {
+ margin: 0;
+ padding: 14px 16px 6px;
+ border-bottom: 1px solid var(--hair);
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 16px;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ background: var(--rc-canvas);
+}
+
+.rc-group-h .rc-group-n {
+ margin-left: 8px;
+ font-weight: 400;
+}
+
+.rc-empty {
+ margin: 0;
+ padding: 28px 16px;
+ color: var(--muted);
+}
+
+.rc-empty[hidden] {
+ display: none;
+}
+
+/* ------------------------------------------------------------- row */
+
+.rc-row {
+ position: relative; /* holds a closing panel's fading copy (.rc-ghost) */
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0 16px;
+ padding: 6px 16px 6px 6px; /* the chevron sits close to the edge */
+ border-bottom: 1px solid var(--hair);
+ /* Opaque, so a row sliding past others (recent.js "motion") covers them. */
+ background: var(--rc-surface);
+}
+
+/* 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;
+ min-height: 68px;
+ margin: 0;
+ padding: 8px 12px;
+ border: 0;
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 2px;
+ text-align: left;
+ background: transparent;
+}
+
+.rc-block-when {
+ display: flex;
+ align-items: baseline;
+ gap: 4px;
+ font-family: var(--mono);
+}
+
+.rc-block-n {
+ font-size: 28px;
+ line-height: 32px;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ font-weight: 500;
+}
+
+.rc-block-u {
+ font-size: 12px;
+}
+
+/* A weekday and a time ("Fri" "21:14"): smaller, to fit the block. */
+.rc-block-long .rc-block-n {
+ font-size: 22px;
+ letter-spacing: -0.04em;
+}
+
+.rc-block-status {
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 14px;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.rc-state-asked .rc-block {
+ background: var(--rc-ask);
+ color: var(--bg);
+}
+
+.rc-state-error .rc-block {
+ background: var(--err);
+ color: var(--bg);
+}
+
+.rc-state-done .rc-block {
+ color: var(--fg);
+ box-shadow: inset 0 0 0 1.5px var(--fg);
+}
+
+.rc-state-busy .rc-block {
+ 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;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 6px 0;
+}
+
+.rc-title {
+ display: block;
+ text-decoration: none;
+ color: var(--fg);
+ font-size: 17px;
+ line-height: 23px;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rc-page a.rc-title {
+ color: var(--fg);
+}
+
+.rc-page a.rc-title:hover {
+ text-decoration: underline;
+}
+
+.rc-state-asked .rc-title,
+.rc-state-error .rc-title {
+ font-weight: 600;
+}
+
+.rc-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 10px;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 16px;
+ color: var(--muted);
+}
+
+.rc-meta-project {
+ color: var(--fg);
+}
+
+.rc-meta-branch {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.rc-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 6px;
+ border-radius: 3px;
+ background: var(--rc-cachebg);
+ color: var(--rc-cache);
+ font-weight: 600;
+}
+
+/* ----------------------------------------------------- time strip */
+
+.rc-strip {
+ flex: 1 1 520px;
+ min-width: 0;
+ position: relative;
+ height: 64px; /* markers 16-36, background lanes 44-58 */
+ overflow: hidden;
+}
+
+.rc-strip > span,
+.rc-marks > span {
+ position: absolute;
+}
+
+.rc-future {
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background: var(--rc-future);
+}
+
+.rc-grid {
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: var(--rc-gridline);
+}
+
+.rc-band {
+ top: 8px;
+ height: 36px;
+ background: var(--rc-cachebg);
+}
+
+.rc-band-risk {
+ box-shadow: inset 0 0 0 1px var(--rc-cache);
+}
+
+.rc-bar {
+ top: 24px;
+ height: 4px;
+ min-width: 2px;
+ background: var(--rc-bar);
+}
+
+.rc-tick {
+ top: 19px;
+ height: 14px;
+ width: 1px;
+ background: var(--tool);
+}
+
+.rc-marks {
+ position: absolute;
+ inset: 0;
+}
+
+.rc-mk {
+ top: 16px;
+ width: 20px;
+ height: 20px;
+ margin-left: -10px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg);
+ --rc-c: var(--fg);
+ color: var(--rc-c);
+ box-shadow: inset 0 0 0 1.5px var(--rc-c);
+}
+
+.rc-mk svg {
+ display: block;
+}
+
+.rc-dot {
+ top: 23px;
+ width: 6px;
+ height: 6px;
+ margin-left: -3px;
+ border-radius: 1px;
+ --rc-c: var(--fg);
+ background: var(--rc-c);
+}
+
+.rc-k-asst {
+ --rc-c: var(--asst);
+}
+
+.rc-k-ask {
+ --rc-c: var(--rc-ask);
+}
+
+.rc-k-err {
+ --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);
+ color: var(--bg);
+ box-shadow: none;
+}
+
+.rc-now {
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ margin-left: -1px;
+ background: var(--fg);
+}
+
+.rc-strip-note {
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 14px;
+ color: var(--muted);
+ white-space: nowrap;
+ background: var(--bg);
+ padding: 0 2px;
+}
+
+.rc-earlier {
+ left: 2px;
+ 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);
+}
+
+/* Ended with the session (its process exited): a hollow bar. */
+.rc-bg-end-exited {
+ top: calc(var(--rc-lane) - 3px);
+ width: 3px;
+ height: 8px;
+ margin-left: -1.5px;
+ box-sizing: border-box;
+ border: 1px solid 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 {
+ left: 6px;
+ top: 22px;
+}
+
+/* ----------------------------------------------------- copy button */
+
+.rc-copy {
+ flex: 0 0 104px;
+ min-height: 44px;
+ padding: 0 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ border-radius: 4px;
+ border: 1px solid var(--rule);
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.rc-page .rc-copy:hover {
+ color: var(--fg);
+ border-color: var(--muted);
+}
+
+.rc-copy.rc-copied {
+ color: var(--tool);
+}
+
+/* ---------------------------------------------------- expanded row */
+
+.rc-x,
+.rc-ghost {
+ flex-basis: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ padding: 10px 0 20px 22px; /* under the block, past the chevron */
+}
+
+.rc-x[hidden] {
+ display: none;
+}
+
+/* A closing panel's copy, drawn where the panel was while it fades and the
+ rows below slide up over it (recent.js sets its top, left and width). */
+.rc-ghost {
+ position: absolute;
+ margin: 0;
+ pointer-events: none;
+ background: var(--rc-surface);
+}
+
+.rc-x-side {
+ flex: 0 0 128px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 18px;
+}
+
+.rc-x-side dl {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ color: var(--muted);
+}
+
+.rc-x-side dl div {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.rc-x-side dd {
+ margin: 0;
+ color: var(--fg);
+ font-variant-numeric: tabular-nums;
+}
+
+.rc-page .rc-x-link {
+ min-height: 40px;
+ padding: 0 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ border: 1px solid var(--rule);
+ text-decoration: none;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ font-size: 11px;
+}
+
+.rc-x-main {
+ flex: 1 1 480px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding-top: 1px;
+}
+
+/* The last prompt: the time on a line of its own, the Markdown below. */
+.rc-prompt {
+ max-width: 88ch;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-family: var(--mono);
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
+.rc-prompt-at {
+ color: var(--fg);
+}
+
+.rc-last {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.rc-who {
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 14px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.rc-last-text {
+ margin: 0;
+ max-width: 72ch;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-size: 17px;
+ line-height: 27px;
+ color: var(--fg);
+}
+
+.rc-who-tool .rc-last-text,
+.rc-who-error .rc-last-text {
+ font-family: var(--mono);
+ font-size: 14px;
+ line-height: 22px;
+}
+
+.rc-who-error .rc-last-text {
+ 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);
+}
+
+/* ---------------------------------------------------------- footer */
+
+.rc-foot {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 18px;
+ align-items: center;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.rc-legend {
+ display: contents;
+}
+
+.rc-lg {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.rc-lg .rc-mk,
+.rc-lg .rc-tick,
+.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;
+}
+
+.rc-lg .rc-band {
+ display: inline-block;
+ width: 18px;
+ height: 14px;
+}
+
+.rc-foot p {
+ margin: 0;
+}
+
+/* ---------------------------------------------------------- narrow */
+
+@media (max-width: 640px) {
+ .rc-wrap {
+ padding: 16px 16px 40px;
+ }
+
+ /* Too narrow to share the bar: the old-data notice takes a line
+ of its own under it. */
+ .rc-stale {
+ order: 1;
+ flex-basis: 100%;
+ text-align: left;
+ }
+
+ .rc-head h1 {
+ font-size: 26px;
+ line-height: 32px;
+ }
+
+ .rc-filter {
+ min-height: 48px;
+ padding: 4px 10px;
+ }
+
+ .rc-filter .rc-n,
+ .rc-updated .rc-n {
+ font-size: 22px;
+ 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: 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 {
+ --rc-surface: var(--rc-canvas);
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ overflow: visible;
+ }
+
+ .rc-row,
+ .rc-thead,
+ .rc-group-h {
+ padding-left: 0;
+ padding-right: 0;
+ }
+
+ .rc-row {
+ gap: 0 10px;
+ }
+
+ .rc-th-session {
+ display: none;
+ }
+
+ .rc-block {
+ flex-basis: 104px;
+ padding: 6px 10px;
+ }
+
+ .rc-block-n {
+ font-size: 22px;
+ line-height: 28px;
+ }
+
+ .rc-info {
+ flex-basis: 150px;
+ }
+
+ .rc-strip,
+ .rc-axis {
+ 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
new file mode 100644
index 00000000..7c739aae
--- /dev/null
+++ b/claude_code_log/html/templates/components/recent/recent.js
@@ -0,0 +1,1397 @@
+// The recent sessions page (recent.html): draws the rows from a payload
+// (see claude_code_log/recent.py for its shape) and keeps them current.
+//
+// The page is opened from disk, where nothing can be fetched; a
+
+
+
+
+
+
+
+
Sessions, last update first
+
+
+
+
+
+
+
+
updated–
+
+
+
+
+
+ since · status
+ session
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# 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
new file mode 100644
index 00000000..b52d68bd
--- /dev/null
+++ b/claude_code_log/recent.py
@@ -0,0 +1,898 @@
+"""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 ``
+
+## What a row shows
+
+- **How long ago it last changed, and whose move it is.** The block at the
+ start of the row says *asked you* (the last reply ends on a question,
+ or Claude is waiting on an answer to its questions or on your approval
+ of its plan), *error* (an API error was the last entry), *finished* (the
+ turn ended with nothing asked), *background* (it finished, but work it
+ started in the background is still running or a wake-up is due) or
+ *mid-turn* (the last entry is a tool call or result, or a prompt with
+ no reply yet).
+- **The title, project, worktree and branch**, with a link to the session's
+ own page (none with `--combined only`, which writes no session pages).
+- **A strip of the last four hours and the next one.** Each turn is a bar
+ from your prompt to the agent's last entry, with its tool calls as thin
+ ticks and a marker for how it ended. Where markers would crowd, the
+ earlier ones shrink to dots. Hover a marker for its time and the start
+ of that turn's prompt, or of how it ended. Turns from before the
+ strip's window are counted at its left edge. A turn Claude started by
+ itself (background work reporting back, or a wake-up firing) begins
+ with a bell or an alarm clock instead of the prompt marker.
+- **Background work.** Background commands, agents and monitors are thin
+ lines under the turns, from launch to their end: a tick when they
+ completed, a cross when they failed, a bar when they were stopped, a
+ hollow bar when they ended with the session (you quit Claude Code while
+ they ran), and an arrow at *now* while they are still running. Work
+ that never reported its end is drawn dashed. Lines that don't overlap share a lane;
+ past three lanes, the rest fold into the third as *+N more*. A pending
+ wake-up is an alarm clock at the time it is due, often in the hour
+ ahead.
+- **The cache countdown.** The prompt cache lasts an hour from the last
+ update, drawn as a pale band. From 45 minutes on, the row says how many
+ minutes are left: reply soon and the next turn still reads the cached
+ context.
+
+Press the block (or the chevron beside it) to open the row: the turn,
+tool-call and message counts on the left, and your last prompt and the
+agent's last message on the right, rendered as Markdown. When Claude is
+waiting on you, the last message is what it is waiting on: its questions
+with their options, or the plan it wants approved. Below it, a
+*Background* list names every piece of background work with its times
+and how it ended, running ones first.
+
+The numbers at the top filter the list (*your move* is everything that
+asked you, errored or finished); *by project* groups the rows.
+
+!!! note "From the transcripts only"
+ A transcript cannot tell an agent that stopped from one waiting
+ quietly, so these states are about whose move it is, not about whether
+ anything is still running. A *mid-turn* session may have died; one
+ that *finished* may still be open in a terminal. The same goes for
+ background work: work with no end recorded counts as running for an
+ hour after the session's last entry (a monitor stops sooner if its
+ timeout says so), and after that it is shown as *no end recorded*.
+ The exception is quitting: when Claude Code exits, it leaves one last
+ line in the transcript, so a session you quit is never *mid-turn* or
+ *waiting*, and the work it left running *ended with the session*.
+
+## Keeping it current
+
+The page refreshes itself. A page opened as a file can't fetch anything,
+but it can load a script, so the data lives in `recent-data.js` beside
+it, and the page reloads that file every 15 seconds and redraws its rows
+without losing what you have open. Times and the cache countdown keep
+moving between reloads. A row that moves up the list slides to its new
+place, an open row grows or shrinks smoothly as its last message changes,
+and a newly arrived message fades in (unless your system asks for reduced
+motion).
+
+To have something to reload, keep the data file current:
+
+```bash
+claude-code-log watch # the project you're in; also refreshes recent-data.js
+claude-code-log watch --all-projects # the whole archive
+```
+
+If the data stops changing, the page says how old it is.
+
+The watch refreshes `recent-data.js` when the watched project sits in the
+default projects directory and no `--output` is given; with a custom
+output tree, a full run (or `watch --all-projects`) keeps it current.
+
+## With foldyard
+
+When agents run in [foldyard](https://github.com/twistco/foldyard) boxes,
+the supervisor copies their transcripts into `~/.claude/projects` every
+`[claude] transcript_sync_seconds`. A `claude-code-log watch` on top of that
+keeps this page current, and the id each row copies is the handle
+`fy agent attach` and `claude --resume` take. Open the page as a file
+rather than through `claude-code-log serve`: foldyard's
+[agents guide](https://twistco.github.io/foldyard/agents/) explains why.
diff --git a/foldyard.toml b/foldyard.toml
index 97f5fcc1..de05d193 100644
--- a/foldyard.toml
+++ b/foldyard.toml
@@ -26,43 +26,49 @@ prefix = "claude-code-log"
# app = "app" # the app service (fy shell target)
# app_port = "WEB_PORT" # [ports] key shown in the TUI + opened by `fy open`
# compose = ["compose.yml"] # compose files in -f order, relative to this dir
+# A floor, not a pin: `fy` refuses to run in this checkout below it. Raise it in the same
+# commit as a setting that needs it — an older `fy` silently ignores keys it doesn't know.
+min_foldyard_version = "0.5.0"
+
+[project.foldyard_version_reasons]
+"0.5.0" = "[box].packages: system packages baked into a cached image layer instead of installed by every fresh box"
+"0.4.0" = "egress-proxy security fixes (0.3.2); renamed [machine] firewall / [proxy] enforce keys; box shows host timezone"
[machine]
# A rootless VM that mounts ONLY this repo. Default here is the Lima backend + an in-VM
-# egress WALL: fail-closed, so the box's only way out is the host's allowlisting proxy
+# egress FIREWALL: fail-closed, so the box's only way out is the host's allowlisting proxy
# (below). Sizing applies at FIRST creation; to resize later: `foldyard machine recreate`.
# backend = "podman" # the single shared podman machine — no concurrent per-project
-# # VMs, and no in-VM wall (egress is the host-side proxy + gvproxy)
-# backend = "native" # host podman directly (no VM) — weakest isolation, shared kernel
-# wall = false # lima but COOPERATIVE-only: the box uses the proxy, but nothing
-# # stops a process that ignores it (the wall makes it fail-closed)
+# # VMs, and no in-VM firewall (egress is the host-side proxy + gvproxy)
+# firewall = false # lima but COOPERATIVE-only: the box uses the proxy, but nothing
+# # stops a process that ignores it (the firewall makes it fail-closed)
backend = "lima"
-wall = true
+firewall = true
name = "claude-code-log"
cpus = 8
memory_mib = 16384 # MiB — any whole number
disk_gib = 60
# Egress proxy. Declaring [proxy] routes the box's egress through the host proxy — REQUIRED
-# by the wall (which otherwise leaves the box with no way out). ENFORCING from the start:
+# by the firewall (which otherwise leaves the box with no way out). ENFORCING from the start:
# a host you haven't granted is refused, and the recommendations below are how the box
# still builds anyway (you're offered them, one yes each, before the first build). To watch
# rather than block while you discover a new dependency's egress:
-# fy allow wall off # observe only — everything through, all logged
-# fy allow wall on # back to enforcing
+# fy allow enforce off # observe only — everything through, all logged
+# fy allow enforce on # back to enforcing
# Grants are deliberately NOT config: they live in the host-side allow-store, outside the
-# repo mount, so the box can't widen its own wall by editing a file it can write. Blocked
-# hosts show live in `fy tui`, where one keypress grants them (and its Network Log's wall
-# pane manages grants + these offers). `default_deny` here only SEEDS the first answer —
-# `fy allow wall` is the switch from then on. Any keyless host (below) is ALWAYS allowed
+# repo mount, so the box can't widen its own allowlist by editing a file it can write. Blocked
+# hosts show live in `fy tui`, where one keypress grants them (and its Network Log's allowlist
+# pane manages grants + these offers). `enforce` here only SEEDS the first answer —
+# `fy allow enforce` is the switch from then on. Any keyless host (below) is ALWAYS allowed
# implicitly — never list it.
[proxy]
-default_deny = true
+enforce = true
# The repo can RECOMMEND hosts (this is how a team SHARES its allowlist): each entry is
# OFFERED per host at `fy up`/`fy box up`/`fy allow sync`, and nothing is granted without
# that yes — repo config asks, the host answers. (`fy allow sync --yes` takes them all at
# once, for an unattended setup with no terminal to answer on.) These cover box bootstrap,
-# so the wall above comes up working after one round of consented yeses; the agents below
+# so the firewall above comes up working after one round of consented yeses; the agents below
# add their own install hosts to the same offer when you uncomment one. Add your project's
# own hosts WITH a why (the why is shown at the decision moment).
recommend = [
@@ -72,7 +78,7 @@ recommend = [
{ host = "pypi.org", why = "uv/pip — box bootstrap + deps" },
{ host = "*.pypi.org", why = "uv/pip mirrors" },
{ host = "files.pythonhosted.org", why = "uv/pip package downloads" },
- { host = "deb.debian.org", why = "apt — [[box.tools]] system packages" },
+ { host = "deb.debian.org", why = "apt — the [box].packages layer's system packages" },
{ host = "registry-1.docker.io", why = "box image build — base image manifest/blobs" },
{ host = "auth.docker.io", why = "box image build — Docker Hub auth token" },
{ host = "production.cloudfront.docker.com", why = "box image build — Docker Hub blob CDN" },
@@ -82,7 +88,10 @@ recommend = [
{ host = "unpkg.com", why = "vis-timeline, loaded by generated transcript pages (the browser test suite renders them)" },
{ host = "downloads.claude.ai", why = "claude --upgrade / claude plugin — public CDN of signed release artefacts (NOT the keyless host; api.anthropic.com is implicit)" },
]
-# passthrough = ["@all"] # hosts tunnelled UN-decrypted under capture=on (the toolchain)
+# passthrough = ["@jvm", "@linux"] # the default since 0.4.0: hosts tunnelled UN-decrypted;
+# # everything else (uv, npm, git, playwright…) is decrypted
+# # + logged. A cert error naming `mitmproxy` means a tool
+# # needs its bundle here (or `["@all"]` for the old behaviour)
# ── Agents — uncomment ONE, then `fy box up` (step 2) ────────────────────────────────────
# Claude Code (`fy claude`). Declaring [claude] installs it on box-up + mounts its persisted
@@ -170,77 +179,90 @@ tui = { raw_output_mode = false }
[box]
# Shared named volumes mounted into the box at a $HOME-relative path, so they survive box
# recreation. Playwright's browser bundle is ~900 MB — without this, every fresh box
-# re-downloads it through the proxy. The apt volume pairs with the apt-keepcache step
-# below: downloaded .debs land in ~/.cache/apt, so a rebuilt box installs its tools from
-# cache instead of re-downloading from deb.debian.org. The uv volumes spare the wheel
-# downloads AND the uv-managed Python (the Debian slim base ships none — uv provisions its
-# own from python-build-standalone) on every rebuild.
+# re-downloads it through the proxy. The uv volumes spare the wheel downloads AND the
+# uv-managed Python (the Debian slim base ships none — uv provisions its own from
+# python-build-standalone) on every rebuild. No apt cache: system packages are a baked image
+# layer (`packages` below), so a rebuilt box doesn't fetch them at all.
caches = [
{ volume = "devbox_playwright", path = ".cache/ms-playwright" },
- { volume = "devbox_apt", path = ".cache/apt" },
{ volume = "devbox_uv", path = ".cache/uv" },
{ volume = "devbox_uv_python", path = ".local/share/uv" },
]
shadow_volumes = [".venv"] # masks the host bind mount at that subpath
-warmup = [{ dir = ".", run = "uv sync" }] # populate it — it starts empty
-env = { UV_LINK_MODE = "copy" } # venv volume ≠ cache volume ⇒ no hardlinks
-
-# Consumer tools installed once per fresh box, as MONITORED steps (reported ✓/✗). They run
-# as root, in the checkout, before the deps warm-up. The box is Debian (foldyard's generic
-# base, debian:trixie-slim), so apt pulls everything from the one allowlisted
-# deb.debian.org.
-
-# Keep downloaded .debs (apt normally discards them, and the slim image ships a docker-clean
-# hook that deletes them on sight) in ~/.cache/apt, where the devbox_apt volume in [box]
-# caches above persists them across box recreations. A rebuilt box then installs its tools
-# from cache, with only index revalidation touching the (allowlisted) mirror. Ends with the
-# `apt-get update` the install steps below rely on — the slim image ships no package index.
-# Must stay ABOVE the apt install steps — [[box.tools]] run in order.
-[[box.tools]]
-name = "apt-keepcache"
-check = "grep -qs 'fy-keepcache' /etc/apt/apt.conf.d/90fy-keepcache"
-install = "mkdir -p /root/.cache/apt/archives/partial && rm -f /etc/apt/apt.conf.d/docker-clean && printf '// fy-keepcache\\nDir::Cache::Archives \"/root/.cache/apt/archives\";\\nAPT::Keep-Downloaded-Packages \"true\";\\n' > /etc/apt/apt.conf.d/90fy-keepcache && apt-get update"
-
-# `just` is this project's documented entry point: CLAUDE.md and CONTRIBUTING.md route every
-# workflow through it (`just ci` before pushing, `just test`, `just docs-build`), so without
-# it the box can't follow its own instructions — and the recipes below name it too. Debian
-# trixie packages it, so this is a plain apt install: no cargo build, no prebuilt binary
-# fetched past the proxy.
-[[box.tools]]
-name = "just"
-check = "command -v just"
-install = "apt-get install -y --no-install-recommends just"
-
-# `uv run pyright` (the `just ci` typecheck leg) needs a Node runtime: the pyright PyPI
-# package is a thin wrapper that BUNDLES the pyright JS inside its wheel and prefers a
-# global `node` from PATH (PYRIGHT_PYTHON_GLOBAL_NODE defaults on). Without one it falls
-# back to nodeenv, which fetches a node tarball from nodejs.org — denied by the wall.
-# Debian's nodejs (v20) satisfies it outright, so this is the whole fix: no npm, no
-# registry.npmjs.org grant (only the PYRIGHT_PYTHON_FORCE_VERSION escape hatch would npm-
-# install a non-bundled version), and the wrapper's update check uses the already-granted
-# pypi.org JSON API with a 1s timeout that fails silent. Verified in-box: apt nodejs alone
-# takes `uv run pyright` from the nodeenv RuntimeError to a clean run.
-[[box.tools]]
-name = "nodejs-for-pyright"
-check = "command -v node"
-install = "apt-get install -y --no-install-recommends nodejs"
-
-# Browser tests (`just test-browser`, `uv run pytest -m browser`) need Chromium's shared
-# libraries in the box. On Debian, `playwright install-deps` is the supported path: playwright
-# itself owns the apt list for the browser build the lockfile pins, so a Chromium bump can't
-# silently outgrow a hand-maintained library list. The check names a few marker
-# libraries so a warm box skips the apt round-trip.
-[[box.tools]]
-name = "chromium-system-libs"
-check = "dpkg -s libnspr4 libnss3 libgbm1 libatomic1 > /dev/null 2>&1"
-install = "uv run --frozen playwright install-deps chromium"
+# Populate it — it starts empty. FROZEN: the lockfile is on the shared mount, and a resolving
+# install would rewrite the host's tree as root.
+warmup = [{ dir = ".", run = "uv sync --frozen" }]
+# UV_LINK_MODE: the venv volume is a different filesystem from the cache volume, so uv's
+# default hardlink import fails cross-device. UV_FROZEN: a bare `uv run` (or `uv run --group
+# demos`, which `just demos` is) would otherwise re-resolve and REWRITE uv.lock against this
+# box's interpreter — marker churn that lands in whatever commit follows. The box can't push,
+# so it has no business re-locking; when a dep really changes, `uv lock` deliberately.
+env = { UV_LINK_MODE = "copy", UV_FROZEN = "1" }
+
+# System packages, baked ONCE into a cached layer on the box image (`-packages`) rather
+# than reinstalled by every fresh box's bootstrap; rebuilt when this list or the base image
+# changes, or by `fy box build`. The box is Debian trixie (foldyard's packaged generic base),
+# so these are Debian's names, all from the one allowlisted deb.debian.org. Each, and why:
+# • just — this project's documented entry point: CLAUDE.md and CONTRIBUTING.md route every
+# workflow through it (`just ci` before pushing, `just test`, `just docs-build`, `just
+# demos`), so without it the box can't follow its own instructions.
+# • nodejs — `uv run pyright` (the `just ci` typecheck leg) needs a Node runtime: the
+# pyright PyPI package BUNDLES the pyright JS in its wheel and prefers a global `node`
+# from PATH. Without one it falls back to nodeenv, which fetches a node tarball from
+# nodejs.org — denied by the allowlist. Debian's nodejs (v20) satisfies it outright.
+# • the rest — what `playwright install-deps chromium` would install on Debian 13: its
+# `tools` list (xvfb and the fonts pages and the demo videos render with) and its
+# `chromium` list (the browser's shared libraries), as Playwright's nativeDeps declares
+# them for the version uv.lock pins. Listed here so they are baked, not apt-installed on
+# every box; test/test_foldyard_config.py fails when a Playwright bump needs one that is
+# missing, which is what calling install-deps used to guarantee.
+packages = [
+ "just",
+ "nodejs",
+ # playwright install-deps chromium — tools
+ "xvfb",
+ "fonts-noto-color-emoji",
+ "fonts-unifont",
+ "libfontconfig1",
+ "libfreetype6",
+ "xfonts-scalable",
+ "fonts-liberation",
+ "fonts-ipafont-gothic",
+ "fonts-wqy-zenhei",
+ "fonts-tlwg-loma-otf",
+ "fonts-freefont-ttf",
+ # playwright install-deps chromium — chromium
+ "libasound2t64",
+ "libatk-bridge2.0-0t64",
+ "libatk1.0-0t64",
+ "libatspi2.0-0t64",
+ "libcairo2",
+ "libcups2t64",
+ "libdbus-1-3",
+ "libdrm2",
+ "libgbm1",
+ "libglib2.0-0t64",
+ "libnspr4",
+ "libnss3",
+ "libpango-1.0-0",
+ "libx11-6",
+ "libxcb1",
+ "libxcomposite1",
+ "libxdamage1",
+ "libxext6",
+ "libxfixes3",
+ "libxkbcommon0",
+ "libxrandr2",
+]
+
+# Tools installed once per fresh box, as MONITORED steps (reported ✓/✗). They run as root, in
+# the checkout, before the deps warm-up. A distro package belongs in `packages` above instead.
# Browser binaries, from the version of playwright uv.lock pins (NOT a floating `uvx
# playwright` — the browser build number is tied to the library version). `--frozen` keeps
-# uv from rewriting the lockfile on the host bind mount, and the sync it does here doubles
-# as the venv warm-up. `check = "false"` means always run: `playwright install` is idempotent
-# and cheap once the cache volume above is warm, and re-running is what picks up a new
-# browser build when the playwright pin moves.
+# uv from rewriting the lockfile on the host bind mount. `check = "false"` means always run:
+# `playwright install` is idempotent and cheap once the devbox_playwright cache volume above
+# is warm, and re-running is what picks up a new browser build when the playwright pin moves.
[[box.tools]]
name = "playwright-chromium"
check = "false"
diff --git a/justfile b/justfile
index 736c59aa..7797156d 100644
--- a/justfile
+++ b/justfile
@@ -347,6 +347,11 @@ clear-cache:
regen-all: backup render-test-data style-guide cli example
+# Play the recent sessions demo's stages against a sandbox page you open
+# yourself (scripts/demos/recent_play.py); e.g. `just recent-play --step`
+recent-play *ARGS:
+ uv run python scripts/demos/recent_play.py {{ARGS}}
+
# Re-record the demo videos (scripts/demos/record.py) into .demos/;
# e.g. `just demos --only branches`, or `just demos --publish` to update the docs
demos *ARGS:
diff --git a/mkdocs.yml b/mkdocs.yml
index 16f6be85..0d265253 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -77,6 +77,7 @@ nav:
- User Guide:
- Themes: themes.md
- Searching the whole archive: archive-search.md
+ - Recent sessions across all projects: recent-sessions.md
- Watching a session as it runs: live-updates.md
- Restoring archived sessions: restoring-archived-sessions.md
- Reference:
diff --git a/scripts/demos/recent_play.py b/scripts/demos/recent_play.py
new file mode 100644
index 00000000..fbbc6a4b
--- /dev/null
+++ b/scripts/demos/recent_play.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""Play the recent sessions demo's stages against a page you open yourself.
+
+ just recent-play # the next stage every 20 s
+ just recent-play --step # press Enter for the next stage
+ just recent-play --every 30 --no-open
+
+The same stages the demo records and ``test/test_recent_live_browser.py``
+checks (``test/recent_live_fixture.py``): sessions across four projects,
+written as real JSONL into a sandbox projects directory and converted by
+the real full run after each stage. Open the printed ``recent.html`` (it
+opens in your browser by default) and watch the rows move, judge the
+motion, try the filters while it runs.
+
+The page reloads its data file every 15 s, as it does for real, so a stage
+can take that long to show; switching to another tab and back reloads it
+at once. In a dev box (foldyard) there is no browser: the default sandbox
+is inside the repo, so open the printed repo-relative path on the host.
+"""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import io
+import shutil
+import sys
+import time
+import webbrowser
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(REPO))
+
+from test.recent_live_fixture import DESCRIBE, STAGES, RecentLive # noqa: E402
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0])
+ parser.add_argument(
+ "--every", type=float, default=20, help="seconds between stages (default 20)"
+ )
+ parser.add_argument(
+ "--step", action="store_true", help="wait for Enter between stages"
+ )
+ parser.add_argument(
+ "--no-open", action="store_true", help="don't open the page in a browser"
+ )
+ parser.add_argument(
+ "--out",
+ type=Path,
+ default=REPO / ".demos" / "recent-play",
+ help="the sandbox directory (emptied first; default .demos/recent-play)",
+ )
+ args = parser.parse_args()
+
+ if args.out.exists():
+ shutil.rmtree(args.out)
+ live = RecentLive(args.out / "projects")
+
+ def advance(stage: str) -> None:
+ with contextlib.redirect_stdout(io.StringIO()): # the run's progress
+ live.advance(stage)
+ print(f"{time.strftime('%H:%M:%S')} {stage:12} {DESCRIBE[stage]}")
+
+ advance(STAGES[0])
+ print(f"\n {live.url}")
+ if live.page.is_relative_to(REPO):
+ # In a dev box the URL is the box's path; this one is the same on
+ # the host, which is where a browser is.
+ print(f" (in the repo: {live.page.relative_to(REPO)})")
+ print()
+ if not args.no_open and not webbrowser.open(live.url):
+ print(" No browser here: open the page above on your own machine.\n")
+ try:
+ for stage in STAGES[1:]:
+ if args.step:
+ input(" Enter for the next stage… ")
+ else:
+ time.sleep(args.every)
+ advance(stage)
+ except (KeyboardInterrupt, EOFError):
+ print()
+ return
+ print("\nThat's every stage; the page stays where it is. Run again to replay.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/demos/record.py b/scripts/demos/record.py
index 91fc8fc9..82835491 100644
--- a/scripts/demos/record.py
+++ b/scripts/demos/record.py
@@ -143,6 +143,7 @@ def __init__(self, workdir: Path) -> None:
self.server = _Server(self.projects, workdir / "cache.db")
self.server.wait()
self._live: Optional[tuple[_Server, object]] = None
+ self._recent = 0
def page(self, session: str = "") -> str:
base = f"{self.server.url}/{PROJECT}"
@@ -185,6 +186,23 @@ def _append(self, path: Path, entries: list[dict]) -> None:
self._live = (server, script)
return f"{server.url}/{PROJECT}/session-{LIVE_SESSION}.html", script.advance
+ def recent(self) -> Any:
+ """Recent sessions in four projects, at their ``start`` stage, for
+ ``recent.html`` opened from disk (``test/recent_live_fixture``);
+ each scene gets them afresh."""
+ if REPO.as_posix() not in sys.path:
+ sys.path.insert(0, str(REPO))
+ from test.recent_live_fixture import RecentLive
+
+ # A directory of its own each time, not one emptied and reused: this
+ # process remembers each cache path it has set up, so a cache
+ # deleted and recreated under it would be taken as ready.
+ self._recent += 1
+ projects = self.workdir / f"recent-{self._recent}"
+ live = RecentLive(projects)
+ live.advance("start")
+ return live
+
def close(self) -> None:
self.server.close()
if self._live:
@@ -404,6 +422,56 @@ def chapter_live(d: Director, stage: Stage) -> None:
d.caption(None, hold=0.5)
+def _recent_step(d: Director, live: Any, name: str) -> None:
+ """Move the recent sessions on to stage ``name`` and wait for the page
+ to show it."""
+ from test.recent_live_fixture import wait_for_stage
+
+ live.advance(name)
+ wait_for_stage(d.page, name)
+
+
+@scene(
+ "chapter",
+ "Recent sessions",
+ "Every session in every project on one page, newest update first, kept current.",
+ WIDE,
+)
+def chapter_recent(d: Director, stage: Stage) -> None:
+ from test.recent_live_fixture import ROWS
+
+ live = stage.recent()
+ _open(
+ d,
+ live.url,
+ "Recent sessions",
+ "Whose move is it, in every project?",
+ "Chapter 7",
+ )
+ d.caption("Every session in every project, newest update first", hold=2.4)
+ d.spot(".rc-stats", hold=0.2)
+ d.caption("Whose move it is, at a glance: the counts are filters", hold=2.4)
+ d.spot(None)
+ d.caption("Open a row for its last prompt and message", hold=0.3)
+ d.click(f".rc-row[data-id='{ROWS}'] .rc-block", after=1.4)
+ _recent_step(d, live, "tool_runs")
+ d.caption("Sessions move up as they work, and an open row follows", hold=2.6)
+ _recent_step(d, live, "finishes")
+ d.caption("When Claude finishes, its reply appears in place", hold=2.8)
+ _recent_step(d, live, "asks_you")
+ d.caption("A question for you comes to the top: your move", hold=2.8)
+ _recent_step(d, live, "new_session")
+ d.caption("New sessions arrive as they start", hold=2.2)
+ _recent_step(d, live, "bg_done")
+ d.caption("Background work reports back, and its session is done", hold=2.6)
+ d.click(f".rc-row[data-id='{ROWS}'] .rc-block", after=0.6)
+ d.click("[data-filter='move']", after=0.3)
+ d.caption("`your move` shows only what is waiting on you", hold=2.2)
+ d.click("[data-filter='all']", after=0.4)
+ d.click("#rc-group", after=0.3)
+ d.caption("Or group them by project", hold=2.6)
+
+
# Beats (the supercut) ----------------------------------------------------------
@@ -467,8 +535,29 @@ def beat_live(d: Director, stage: Stage) -> None:
d.wait(2)
-CHAPTERS = ["archive", "reading", "toolbar", "branches", "search", "live"]
-SUPERCUT = ["archive", "reading", "depth", "branches", "search", "dark", "live"]
+@scene("beat", "Recent sessions", "", WIDE)
+def beat_recent(d: Director, stage: Stage) -> None:
+ live = stage.recent()
+ live.run_to("finishes")
+ _open(d, live.url)
+ d.caption("Every session in every project, as it happens", hold=0.8)
+ _recent_step(d, live, "asks_you")
+ d.wait(1.8)
+ _recent_step(d, live, "new_session")
+ d.wait(1.8)
+
+
+CHAPTERS = ["archive", "reading", "toolbar", "branches", "search", "live", "recent"]
+SUPERCUT = [
+ "archive",
+ "reading",
+ "depth",
+ "branches",
+ "search",
+ "dark",
+ "live",
+ "recent",
+]
@scene("beat", "Opening card", "")
@@ -658,6 +747,9 @@ def _video(src: str) -> str:
def _docs_page(manifest: dict) -> str:
"""``docs/demos.md``: the supercut, then each chapter with its blurb."""
minutes, seconds = divmod(round(manifest["walkthrough"]), 60)
+ count = len(manifest["chapters"])
+ words = "Zero One Two Three Four Five Six Seven Eight Nine Ten".split()
+ chapters = words[count] if count < len(words) else str(count)
parts = [
"\n",
@@ -666,7 +758,7 @@ def _docs_page(manifest: dict) -> str:
" `scripts/demos/record.py` (`just demos` re-records them).\n",
"## Overview\n",
f"{_video('supercut.mp4')}\n",
- f"## Walkthrough\n\nSix short chapters, {minutes}:{seconds:02d} in all.\n",
+ f"## Walkthrough\n\n{chapters} short chapters, {minutes}:{seconds:02d} in all.\n",
]
for chapter in manifest["chapters"]:
parts.append(
diff --git a/scripts/lint_js.py b/scripts/lint_js.py
index af179eb1..8d1f46bb 100644
--- a/scripts/lint_js.py
+++ b/scripts/lint_js.py
@@ -85,6 +85,7 @@ def _rendered_pages() -> dict[str, str]:
generate_archive_search_html,
generate_html,
generate_projects_index_html,
+ generate_recent_html,
)
from claude_code_log.html.utils import get_template_environment
@@ -100,6 +101,7 @@ def _rendered_pages() -> dict[str, str]:
pages[f"transcript-{theme}"] = generate_html(entries, "Lint", theme=theme)
pages[f"index-{theme}"] = generate_projects_index_html([], theme=theme)
pages[f"archive_search-{theme}"] = generate_archive_search_html(theme)
+ pages[f"recent-{theme}"] = generate_recent_html(theme)
return pages
diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr
index 390a2ea6..3c900a23 100644
--- a/test/__snapshots__/test_snapshot_html.ambr
+++ b/test/__snapshots__/test_snapshot_html.ambr
@@ -19861,6 +19861,7 @@
@@ -21834,6 +21835,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. */
@@ -57250,6 +57255,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. */
@@ -59042,6 +59051,7 @@
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/conftest.py b/test/conftest.py
index e6a94bfc..30ace78a 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -276,6 +276,34 @@ def page(context):
page.close()
+@pytest.fixture(scope="session")
+def _isolated_browser(playwright, browser_type_launch_args, _browser_expect_timeout):
+ """A plain (non-persistent) browser per worker, for ``isolated_page``."""
+ browser = playwright.chromium.launch(**browser_type_launch_args)
+ yield browser
+ browser.close()
+
+
+@pytest.fixture
+def isolated_page(_isolated_browser):
+ """A page in a fresh context of its own, closed with the test.
+
+ Use it instead of ``page`` for anything that changes the *context*
+ rather than the page — above all ``page.clock.install()``: Playwright's
+ clock belongs to the context, and ``page``'s context is shared by every
+ browser test on the worker, so a fake clock installed there stays
+ installed for the tests that follow. (It did: a recent-sessions test's
+ clock froze ``requestAnimationFrame`` timing for a later search test,
+ which then failed whenever xdist put the two on one worker.)
+ """
+ context = _isolated_browser.new_context(
+ viewport={"width": 1280, "height": 720}, ignore_https_errors=True
+ )
+ page = context.new_page()
+ yield page
+ context.close()
+
+
# ========== Deterministic mtime changes ==========
# The cache treats a file as unchanged when its mtime is within 1.0 s of the
# recorded one (``_cache_row_is_fresh``). Tests used to force a change with
diff --git a/test/recent_live_fixture.py b/test/recent_live_fixture.py
new file mode 100644
index 00000000..c9f29a5f
--- /dev/null
+++ b/test/recent_live_fixture.py
@@ -0,0 +1,439 @@
+"""Recent sessions that change on disk the way live ones do.
+
+``RecentLive(projects)`` writes a projects directory whose sessions, across
+four projects, are in every state the recent sessions page tells apart, then
+moves them on stage by stage, appending JSONL lines exactly as Claude Code
+does while it runs. Each stage is converted by the real pipeline
+(:func:`refresh`: the full run, which writes the cache, the session pages,
+``recent.html`` and its ``recent-data.js``), so what the page shows is what
+``recent.py`` reads from real transcripts.
+
+``start``
+ *pin* (foldyard) and *rows* (claude-code-log) are mid-turn, *bench*
+ (wenmode) has finished a turn but its background benchmark is still
+ running, *zsh* (dotfiles) ended on an API error, and two older
+ sessions finished hours ago.
+``tool_runs``
+ *rows* edits a file and starts ``just ci``: it moves to the top, still
+ mid-turn, its last message that tool call.
+``finishes``
+ ``just ci`` passes and *rows* answers in Markdown: finished.
+``asks_you``
+ *pin* asks a question (an unanswered ``AskUserQuestion``): asked you.
+``new_session``
+ A new session starts in wenmode, mid-turn.
+``bg_done``
+ *bench*'s benchmark reports back (a ````) and Claude
+ sums it up: finished.
+
+Timestamps are the clock's when each stage is applied (the setup is placed
+minutes to hours before it), so the page, on its live clock, reads them as
+just happened. Each write moves its file's mtime on by at least 2 s, since
+the cache treats a file whose mtime is within 1 s of the recorded one as
+unchanged (``test/conftest.bump_mtime``).
+
+Used by ``test/test_recent_live_browser.py`` (end to end), the recent
+sessions demo scenes in ``scripts/demos/record.py`` and the play mode,
+``scripts/demos/recent_play.py``. Not a test module itself.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+import uuid as uuid_lib
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Callable, Optional
+
+# Distinct in their first 8 characters: the page shows those (the copy
+# button), and they are what tells rows apart at a glance.
+ROWS = "7f3a9c21-5d0e-4b6a-9e1f-2c8d4a6b0e11"
+PIN = "b81e4f07-a2c3-4d95-8f60-1e7c3b9a5d22"
+BENCH = "3c9d2e8a-71f4-4c0b-a6d5-9b2e0f4c8a33"
+ZSH = "d4507b19-e8a2-4f63-b1c7-6a3d9e2f0b44"
+CI_KEY = "5ae1c6d3-0b9f-4e27-8d41-7c5a2b6e9f55"
+ATTACH = "9e6f0a4b-3c2d-4a18-b7e9-4d1f8c3a6b66"
+TABLES = "1b2c8f5e-6d4a-4f39-a0c2-8e7b5d1a3c77"
+
+STAGES = ("start", "tool_runs", "finishes", "asks_you", "new_session", "bg_done")
+
+#: What each stage is about, for the play mode's log.
+DESCRIBE = {
+ "start": "six sessions across four projects, in every state",
+ "tool_runs": "claude-code-log runs `just ci`: it moves to the top",
+ "finishes": "claude-code-log finishes: its reply grows in place",
+ "asks_you": "foldyard asks you a question: it slides to the top",
+ "new_session": "a new session starts in wenmode: it fades in",
+ "bg_done": "wenmode's background benchmark reports back: finished",
+}
+
+#: After each stage, the session at the top of the list and its status.
+TOP = {
+ "start": (PIN, "mid-turn"),
+ "tool_runs": (ROWS, "mid-turn"),
+ "finishes": (ROWS, "finished"),
+ "asks_you": (PIN, "asked you"),
+ "new_session": (TABLES, "mid-turn"),
+ "bg_done": (BENCH, "finished"),
+}
+
+ROWS_REPLY = """\
+Rows now slide instead of jumping. Each change is measured, applied, then
+played back from where it was:
+
+- a row that moves up the list slides past the others, drawn above them;
+- an open row grows by revealing its new content, and the rows below move
+ down to make room;
+- a closed row's panel fades while the rows below slide up over it.
+
+`just ci` is green, and with reduced motion nothing moves."""
+
+PIN_QUESTION = {
+ "questions": [
+ {
+ "question": "Which cache mode should the 9p mount use?",
+ "header": "Cache mode",
+ "multiSelect": False,
+ "options": [
+ {
+ "label": "mmap",
+ "description": "Fastest reads; needs the newer kernel.",
+ },
+ {"label": "loose", "description": "Works everywhere, slower."},
+ ],
+ }
+ ]
+}
+
+
+def _before(at: datetime, seconds: float) -> datetime:
+ return at - timedelta(seconds=seconds)
+
+
+def _project_dir(name: str) -> str:
+ return f"-home-dev-{name}"
+
+
+class _Session:
+ """One session's JSONL, appended to entry by entry."""
+
+ def __init__(
+ self, projects: Path, project: str, session_id: str, branch: str
+ ) -> None:
+ self.cwd = f"/home/dev/{project}"
+ self.path = projects / _project_dir(project) / f"{session_id}.jsonl"
+ self.session_id = session_id
+ self.branch = branch
+ self.last_uuid: Optional[str] = None
+ self.last_at: Optional[datetime] = None
+ self.count = 0
+ self.tools = 0
+ self.pending: list[dict[str, Any]] = []
+
+ def _stamp(self, at: datetime) -> str:
+ # Strictly increasing within the session, whatever the clock does.
+ if self.last_at is not None and at <= self.last_at:
+ at = self.last_at + timedelta(milliseconds=1)
+ self.last_at = at
+ utc = at.astimezone(timezone.utc)
+ return utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{utc.microsecond // 1000:03d}Z"
+
+ def _entry(self, kind: str, at: datetime, **extra: Any) -> dict[str, Any]:
+ self.count += 1
+ uuid = str(
+ uuid_lib.uuid5(uuid_lib.NAMESPACE_URL, f"{self.session_id}/{self.count}")
+ )
+ entry: dict[str, Any] = {
+ "parentUuid": self.last_uuid,
+ "isSidechain": False,
+ "userType": "external",
+ "cwd": self.cwd,
+ "sessionId": self.session_id,
+ "version": "2.1.180",
+ "gitBranch": self.branch,
+ "type": kind,
+ "uuid": uuid,
+ "timestamp": self._stamp(at),
+ }
+ entry.update(extra)
+ self.last_uuid = uuid
+ self.pending.append(entry)
+ return entry
+
+ def title(self, text: str) -> None:
+ self.pending.append(
+ {"type": "ai-title", "aiTitle": text, "sessionId": self.session_id}
+ )
+
+ def prompt(self, at: datetime, text: Any) -> None:
+ self._entry("user", at, message={"role": "user", "content": text})
+
+ def assistant(
+ self, at: datetime, content: list[dict[str, Any]], model: str = ""
+ ) -> None:
+ self._entry(
+ "assistant",
+ at,
+ message={
+ "id": f"msg_{self.session_id[:8]}{self.count:04d}",
+ "type": "message",
+ "role": "assistant",
+ "model": model or "claude-opus-4-7",
+ "stop_reason": None,
+ "content": content,
+ "usage": {
+ "input_tokens": 6,
+ "cache_creation_input_tokens": 1800,
+ "cache_read_input_tokens": 30000 + 700 * self.count,
+ "output_tokens": 220,
+ },
+ },
+ )
+
+ def say(self, at: datetime, text: str) -> None:
+ self.assistant(at, [{"type": "text", "text": text}])
+
+ def error(self, at: datetime, text: str) -> None:
+ self.assistant(at, [{"type": "text", "text": text}], model="")
+
+ def call(self, at: datetime, name: str, inp: dict[str, Any]) -> str:
+ """A tool call with no result yet; returns its id."""
+ self.tools += 1
+ tool_id = f"toolu_{self.session_id[:8]}{self.tools:04d}"
+ self.assistant(
+ at, [{"type": "tool_use", "id": tool_id, "name": name, "input": inp}]
+ )
+ return tool_id
+
+ def result(
+ self, at: datetime, tool_id: str, text: str, structured: Any = None
+ ) -> None:
+ extra = {"toolUseResult": structured} if structured is not None else {}
+ self._entry(
+ "user",
+ at,
+ message={
+ "role": "user",
+ "content": [
+ {"type": "tool_result", "tool_use_id": tool_id, "content": text}
+ ],
+ },
+ **extra,
+ )
+
+ def tool(
+ self,
+ at: datetime,
+ name: str,
+ inp: dict[str, Any],
+ text: str,
+ took: float = 4,
+ structured: Any = None,
+ ) -> None:
+ tool_id = self.call(at, name, inp)
+ self.result(at + timedelta(seconds=took), tool_id, text, structured)
+
+ def flush(self) -> None:
+ """Append what was written since the last flush, moving the mtime on."""
+ if not self.pending:
+ return
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ before = self.path.stat().st_mtime if self.path.exists() else 0.0
+ with self.path.open("a", encoding="utf-8") as out:
+ out.writelines(json.dumps(e) + "\n" for e in self.pending)
+ self.pending.clear()
+ mtime = max(time.time(), before + 2.0)
+ os.utime(self.path, (mtime, mtime))
+
+
+class RecentLive:
+ """The sessions at ``start``, moved on by :meth:`advance`."""
+
+ def __init__(
+ self,
+ projects: Path,
+ clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
+ ) -> None:
+ self.projects = projects
+ self.clock = clock
+ self.stage: Optional[str] = None
+ self.sessions: dict[str, _Session] = {}
+ self.ci = "" # the pending `just ci` call's tool id
+
+ def _session(
+ self, session_id: str, project: str = "", branch: str = "main"
+ ) -> _Session:
+ if session_id not in self.sessions:
+ self.sessions[session_id] = _Session(
+ self.projects, project, session_id, branch
+ )
+ return self.sessions[session_id]
+
+ @property
+ def page(self) -> Path:
+ return self.projects / "recent.html"
+
+ @property
+ def url(self) -> str:
+ return self.page.as_uri()
+
+ # ---- stages -------------------------------------------------------------
+
+ def start(self) -> None:
+ now = self.clock()
+
+ def ago(minutes: float) -> datetime:
+ return now - timedelta(minutes=minutes)
+
+ rows = self._session(ROWS, "claude-code-log", "recent-motion")
+ rows.title("Animate the recent sessions rows")
+ rows.prompt(ago(96), "Sketch how the recent sessions rows could animate.")
+ rows.tool(ago(95), "Read", {"file_path": "recent.js"}, "(the file)")
+ rows.say(ago(88), "FLIP fits: measure, change, then play each row back.")
+ rows.prompt(
+ ago(9),
+ "Rows jump when the list reorders. Make them slide instead, "
+ "and keep it still for reduced motion.",
+ )
+ rows.tool(ago(8.5), "Read", {"file_path": "recent.js"}, "(the file)")
+ rows.tool(ago(6), "Edit", {"file_path": "recent.js"}, "Edited recent.js")
+ rows.tool(
+ ago(2),
+ "Bash",
+ {"command": "just test-browser -k motion"},
+ "5 passed in 2.1s",
+ took=30,
+ )
+
+ pin = self._session(PIN, "foldyard", "lima-mounts")
+ pin.title("Pin the 9p cache to mmap")
+ pin.prompt(ago(70), "Why is the to-box mount so slow on reads?")
+ pin.say(ago(64), "The 9p mount has no cache, so every read is a round trip.")
+ pin.prompt(ago(4), "Try pinning the 9p cache to mmap.")
+ pin.tool(ago(3.5), "Read", {"file_path": "lima.yaml"}, "(the file)")
+ pin.tool(ago(1.2), "Bash", {"command": "uname -r"}, "6.8.0-45-generic")
+
+ bench = self._session(BENCH, "wenmode", "main")
+ bench.title("Benchmark the tokenizer")
+ bench.prompt(ago(15), "Benchmark the new tokenizer against main.")
+ bench.tool(
+ ago(14),
+ "Bash",
+ {"command": "just bench --rounds 20", "run_in_background": True},
+ "Command running in background with ID: bench1",
+ took=1,
+ structured={"stdout": "", "backgroundTaskId": "bench1"},
+ )
+ bench.say(ago(13), "The benchmark is running; I'll report when it finishes.")
+
+ zsh = self._session(ZSH, "dotfiles", "main")
+ zsh.title("Tidy the zsh prompt")
+ zsh.prompt(ago(31), "Make the zsh prompt show the git branch.")
+ zsh.tool(ago(30), "Read", {"file_path": ".zshrc"}, "(the file)")
+ zsh.error(ago(26), "API Error: 529 Overloaded. Please try again.")
+
+ ci_key = self._session(CI_KEY, "claude-code-log", "main")
+ ci_key.title("Fix the Windows CI cache key")
+ ci_key.prompt(ago(170), "The Windows CI job misses the cache every run.")
+ ci_key.tool(ago(168), "Bash", {"command": "gh run view"}, "(the log)")
+ ci_key.say(ago(160), "The key hashed a path with backslashes; fixed.")
+
+ attach = self._session(ATTACH, "foldyard", "main")
+ attach.title("Speed up fy agent attach")
+ attach.prompt(ago(300), "fy agent attach takes four seconds. Why?")
+ attach.say(ago(290), "It waited on a DNS lookup; now it doesn't.")
+
+ # Each stage ends at the clock's now, its earlier steps just before it:
+ # an entry stamped ahead of the clock would outrank the next stage's.
+
+ def tool_runs(self) -> None:
+ rows = self.sessions[ROWS]
+ now = self.clock()
+ rows.tool(_before(now, 6), "Edit", {"file_path": "recent.css"}, "Edited", 1)
+ self.ci = rows.call(now, "Bash", {"command": "just ci"})
+
+ def finishes(self) -> None:
+ rows = self.sessions[ROWS]
+ now = self.clock()
+ rows.result(_before(now, 2), self.ci, "All checks passed!")
+ rows.say(now, ROWS_REPLY)
+
+ def asks_you(self) -> None:
+ self.sessions[PIN].call(self.clock(), "AskUserQuestion", PIN_QUESTION)
+
+ def new_session(self) -> None:
+ tables = self._session(TABLES, "wenmode", "docs-tables")
+ now = self.clock()
+ tables.title("Render tables in the docs")
+ tables.prompt(_before(now, 8), "The docs site drops Markdown tables. Why?")
+ tables.tool(_before(now, 6), "Read", {"file_path": "mkdocs.yml"}, "(it)", 1)
+ tables.call(now, "Grep", {"pattern": "tables", "path": "docs"})
+
+ def bg_done(self) -> None:
+ bench = self.sessions[BENCH]
+ now = self.clock()
+ bench.prompt(
+ _before(now, 3),
+ "\nbench1\n"
+ "completed\nBackground command "
+ '"just bench --rounds 20" completed (exit code 0)\n'
+ "",
+ )
+ bench.say(
+ now,
+ "The new tokenizer runs at **41 MB/s**, 18% faster than main, "
+ "with the same output on every fixture.",
+ )
+
+ # ---- running ------------------------------------------------------------
+
+ def advance(self, stage: str) -> None:
+ """Apply ``stage`` (in ``STAGES`` order) and convert the result."""
+ expected = STAGES[STAGES.index(self.stage) + 1] if self.stage else STAGES[0]
+ if stage != expected:
+ raise ValueError(f"next stage is {expected!r}, not {stage!r}")
+ getattr(self, stage)()
+ for session in self.sessions.values():
+ session.flush()
+ self.stage = stage
+ refresh(self.projects)
+
+ def run_to(self, stage: str) -> None:
+ """Apply every stage up to and including ``stage``."""
+ while self.stage != stage:
+ nxt = STAGES[STAGES.index(self.stage) + 1] if self.stage else STAGES[0]
+ self.advance(nxt)
+
+
+def refresh(projects: Path) -> None:
+ """Convert ``projects`` as a full run does: the cache, the session pages,
+ the index, ``recent.html`` and ``recent-data.js``."""
+ from claude_code_log.converter import process_projects_hierarchy
+
+ process_projects_hierarchy(projects, silent=True, theme="minimal", jobs=1)
+
+
+#: Waits for the top row to be ``[session id, status]``, prompting the page to reload
+#: its data file meanwhile (a ``visibilitychange`` while visible reloads it
+#: now rather than at its next 15 s tick).
+UNTIL_TOP_JS = """([id, status]) => {
+ const row = document.querySelector('.rc-row');
+ const ok = !!row && row.dataset.id === id
+ && row.querySelector('.rc-block-status').textContent === status;
+ if (!ok && Date.now() - (window.__recentNudge || 0) > 400) {
+ window.__recentNudge = Date.now();
+ document.dispatchEvent(new Event('visibilitychange'));
+ }
+ return ok;
+}"""
+
+
+def wait_for_stage(page: Any, stage: str, timeout: float = 30_000) -> None:
+ """Wait until ``page`` (a Playwright page on ``recent.html``) shows
+ ``stage``'s top row."""
+ session_id, status = TOP[stage]
+ page.wait_for_function(
+ UNTIL_TOP_JS, arg=[session_id, status], polling=100, timeout=timeout
+ )
diff --git a/test/test_browser_isolation.py b/test/test_browser_isolation.py
new file mode 100644
index 00000000..0f28edf7
--- /dev/null
+++ b/test/test_browser_isolation.py
@@ -0,0 +1,51 @@
+"""Browser tests that change their browser *context* must not share it.
+
+``page`` lives in a persistent context shared by every browser test on an
+xdist worker (conftest.py). Playwright's clock belongs to the context, so
+``page.clock.install()`` there leaks a fake clock into the tests that
+follow; ``isolated_page`` gives a context of the test's own. This checks
+statically that every test touching the clock asks for it.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+TEST_DIR = Path(__file__).parent
+
+
+def _tests_using_the_clock() -> list[tuple[str, ast.FunctionDef]]:
+ found: list[tuple[str, ast.FunctionDef]] = []
+ for path in sorted(TEST_DIR.glob("test_*.py")):
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.FunctionDef) or not node.name.startswith(
+ "test"
+ ):
+ continue
+ uses_clock = any(
+ isinstance(sub, ast.Attribute) and sub.attr == "clock"
+ for sub in ast.walk(node)
+ )
+ if uses_clock:
+ found.append((path.name, node))
+ return found
+
+
+def test_clock_users_take_an_isolated_page() -> None:
+ offenders = [
+ f"{name}::{node.name}"
+ for name, node in _tests_using_the_clock()
+ if "isolated_page" not in {arg.arg for arg in node.args.args}
+ ]
+ assert not offenders, (
+ "these install a Playwright clock on the shared context; "
+ f"take `isolated_page` instead of `page`: {offenders}"
+ )
+
+
+def test_the_check_finds_the_clock_users() -> None:
+ """The check above is only as good as its search."""
+ names = {node.name for _, node in _tests_using_the_clock()}
+ assert "test_reload_picks_up_new_sessions_in_place" in names
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_foldyard_config.py b/test/test_foldyard_config.py
new file mode 100644
index 00000000..23113132
--- /dev/null
+++ b/test/test_foldyard_config.py
@@ -0,0 +1,68 @@
+"""The dev box's config (foldyard.toml) against what the code needs of it.
+
+The box's system packages are baked into an image layer from
+``[box].packages`` instead of being installed by ``playwright install-deps
+chromium`` on every fresh box. That call kept the list in step with the
+Playwright version uv.lock pins by itself; this test is what keeps it in step
+now: when a Playwright bump adds a library Chromium needs on Debian 13 (the
+box's base), it fails, naming the packages to add.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import playwright
+import pytest
+
+# Python 3.11+ (the CI matrix's newer legs run it); 3.10 has no tomllib.
+tomllib = pytest.importorskip("tomllib")
+
+REPO = Path(__file__).resolve().parents[1]
+NATIVE_DEPS = (
+ Path(playwright.__path__[0]) / "driver/package/lib/server/registry/nativeDeps.js"
+)
+#: The box's base: foldyard's packaged generic box is Debian trixie.
+DISTRO = "debian13"
+
+
+def _box_packages() -> set[str]:
+ config = tomllib.loads((REPO / "foldyard.toml").read_text(encoding="utf-8"))
+ return set(config["box"]["packages"])
+
+
+def _block(source: str, start: str) -> str:
+ """The text of the object literal that opens at ``start``."""
+ at = source.index(start)
+ depth = 0
+ for i in range(source.index("{", at), len(source)):
+ depth += {"{": 1, "}": -1}.get(source[i], 0)
+ if depth == 0:
+ return source[at : i + 1]
+ raise AssertionError(f"unterminated block at {start!r}")
+
+
+def _list(block: str, key: str) -> list[str]:
+ match = re.search(rf"\b{key}: \[(.*?)\]", block, re.DOTALL)
+ assert match, f"no {key} list"
+ return re.findall(r'"([^"]+)"', match.group(1))
+
+
+def test_box_packages_cover_playwrights_chromium_deps() -> None:
+ source = NATIVE_DEPS.read_text(encoding="utf-8")
+ # The box runs on either architecture; arm64 must still borrow x64's
+ # lists, or this test reads the wrong ones.
+ arm = _block(source, f'deps["{DISTRO}-arm64"] =')
+ for key in ("tools", "chromium"):
+ assert f'...deps["{DISTRO}-x64"].{key}' in arm, (
+ f"Playwright now lists {DISTRO}-arm64 {key} separately: read those too"
+ )
+ x64 = _block(source, f'"{DISTRO}-x64": {{')
+ # What `playwright install-deps chromium` installs: tools + chromium.
+ needed = set(_list(x64, "tools")) | set(_list(x64, "chromium"))
+ assert len(needed) > 20, needed # the parse found the lists
+ missing = sorted(needed - _box_packages())
+ assert not missing, (
+ f"add to [box].packages in foldyard.toml (Playwright's {DISTRO} deps): {missing}"
+ )
diff --git a/test/test_minimal_pages_browser.py b/test/test_minimal_pages_browser.py
index 01f7395d..4962fe1c 100644
--- a/test/test_minimal_pages_browser.py
+++ b/test/test_minimal_pages_browser.py
@@ -210,7 +210,7 @@ def test_index_to_session_and_search_when_served(
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
page.goto(f"{server.url}/index.html")
- page.locator(".mn-archive-link").click()
+ page.locator(".mn-archive-link[href='search.html']").click()
page.wait_for_load_state()
assert page.url.endswith("/search.html")
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
@@ -260,7 +260,7 @@ def test_session_finder_still_finds(self, clean: Page, archive: Path) -> None:
expect(panel).to_be_visible()
expect(panel.locator(".search-result-item").first).to_be_visible()
# The archive link stays on the finder's line, above the results.
- link = page.locator(".mn-archive-link").bounding_box()
+ link = page.locator(".mn-archive-link[href='search.html']").bounding_box()
results = panel.bounding_box()
assert link is not None and results is not None
assert link["y"] < results["y"]
diff --git a/test/test_recent.py b/test/test_recent.py
new file mode 100644
index 00000000..bef5d4ff
--- /dev/null
+++ b/test/test_recent.py
@@ -0,0 +1,1277 @@
+"""Tests for the recent-sessions data (``claude_code_log/recent.py``).
+
+The page's states come from the transcripts alone, so these pin how a
+session's entries become turns, a last message and whose move it is —
+and that the conversion writes the page and its self-refresh data file.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Optional
+
+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 (
+ RECENT_DATA,
+ RECENT_PAGE,
+ BG_CAP_MINUTES,
+ SNIPPET_CHARS,
+ WAKE_GRACE_MINUTES,
+ TURN_HORIZON_HOURS,
+ WINDOW_DAYS,
+ RecentMemo,
+ asks_you,
+ build_recent_payload,
+ payload_json,
+ read_session,
+ recent_data_js,
+ session_exit,
+ summarise_session,
+ write_recent_data,
+)
+
+NOW = datetime(2026, 10, 31, 16, 42, tzinfo=timezone.utc)
+NOW_MS = int(NOW.timestamp() * 1000)
+
+
+def _iso(minutes_ago: float) -> str:
+ return (NOW - timedelta(minutes=minutes_ago)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
+
+
+class _Session:
+ """Writes a session's JSONL lines, one helper per kind of entry."""
+
+ def __init__(self, session_id: str = "sess-1", cwd: str = "/home/u/proj") -> None:
+ self.session_id = session_id
+ self.cwd = cwd
+ self.lines: list[dict[str, Any]] = []
+ self._n = 0
+
+ def _base(self, kind: str, minutes_ago: float, **extra: Any) -> dict[str, Any]:
+ self._n += 1
+ entry: dict[str, Any] = {
+ "type": kind,
+ "timestamp": _iso(minutes_ago),
+ "parentUuid": next(
+ (line["uuid"] for line in reversed(self.lines) if "uuid" in line),
+ None,
+ ),
+ "isSidechain": False,
+ "userType": "external",
+ "cwd": self.cwd,
+ "sessionId": self.session_id,
+ "version": "2.0.0",
+ "uuid": f"{self.session_id}-{self._n}",
+ "gitBranch": "feature-x",
+ }
+ entry.update(extra)
+ self.lines.append(entry)
+ return entry
+
+ def prompt(self, minutes_ago: float, text: str, **extra: Any) -> None:
+ self._base(
+ "user",
+ minutes_ago,
+ message={"role": "user", "content": [{"type": "text", "text": text}]},
+ **extra,
+ )
+
+ def reply(
+ self, minutes_ago: float, text: str, model: str = "claude-opus", **extra: Any
+ ) -> None:
+ self._base(
+ "assistant",
+ minutes_ago,
+ requestId=f"req-{self._n}",
+ message={
+ "id": f"m-{self._n}",
+ "type": "message",
+ "role": "assistant",
+ "model": model,
+ "content": [{"type": "text", "text": text}],
+ },
+ **extra,
+ )
+
+ def tool(
+ self,
+ minutes_ago: float,
+ name: str = "Bash",
+ result: bool = True,
+ result_text: str = "ok",
+ tool_use_result: Any = None,
+ sidechain: bool = False,
+ **inputs: Any,
+ ) -> str:
+ """A tool call and (unless ``result`` is False) its result; the
+ call's id, for a notification's ``tool-use-id``."""
+ tool_id = f"tool-{self._n}"
+ side: dict[str, Any] = {"isSidechain": True} if sidechain else {}
+ self._base(
+ "assistant",
+ minutes_ago,
+ requestId=f"req-{self._n}",
+ message={
+ "id": f"m-{self._n}",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-opus",
+ "content": [
+ {"type": "tool_use", "id": tool_id, "name": name, "input": inputs}
+ ],
+ },
+ **side,
+ )
+ if not result:
+ return tool_id
+ extra: dict[str, Any] = dict(side)
+ if tool_use_result is not None:
+ extra["toolUseResult"] = tool_use_result
+ self._base(
+ "user",
+ minutes_ago - 0.1,
+ message={
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": tool_id,
+ "content": result_text,
+ }
+ ],
+ },
+ **extra,
+ )
+ return tool_id
+
+ def notify(
+ self,
+ minutes_ago: float,
+ task_id: str,
+ status: Optional[str] = None,
+ summary: str = "",
+ event: Optional[str] = None,
+ result: Optional[str] = None,
+ mid_turn: bool = False,
+ preamble: bool = False,
+ queued_only: bool = False,
+ ) -> None:
+ """A ````: a user entry when it arrives while
+ Claude is idle, a ``queued_command`` attachment mid-turn, or only
+ a ``queue-operation`` (queued, never delivered)."""
+ fields = f"{task_id}"
+ if status:
+ fields += f"\n{status}"
+ if summary:
+ fields += f"\n{summary}"
+ if event:
+ fields += f"\n{event}"
+ if result:
+ fields += f"\n{result}"
+ body = f"\n{fields}\n"
+ if preamble:
+ body = "[SYSTEM NOTIFICATION - NOT USER INPUT]\n" + body
+ if queued_only:
+ self._base(
+ "queue-operation", minutes_ago, operation="enqueue", content=body
+ )
+ elif mid_turn:
+ self._base(
+ "attachment",
+ minutes_ago,
+ attachment={
+ "type": "queued_command",
+ "commandMode": "task-notification",
+ "prompt": body,
+ },
+ )
+ else:
+ self._base("user", minutes_ago, message={"role": "user", "content": body})
+
+ def wake_fire(
+ self, minutes_ago: float, content: str = "Claude resuming /loop wakeup"
+ ) -> None:
+ """A wake-up firing: the system entry, then its meta prompt."""
+ self._base(
+ "system", minutes_ago, content=content, subtype="scheduled_task_fire"
+ )
+ self._base(
+ "user",
+ minutes_ago,
+ isMeta=True,
+ message={"role": "user", "content": "check on the CI run"},
+ )
+
+ def exit(self, minutes_ago: float, ran_minutes: float = 120) -> None:
+ """The ``cost-state`` line Claude Code writes as its process exits
+ (no uuid, no timestamp): its start and duration date the exit."""
+ end = _ms_ago(minutes_ago)
+ self.lines.append(
+ {
+ "type": "cost-state",
+ "sessionId": self.session_id,
+ "totalCostUSD": 1.5,
+ "totalDuration": int(ran_minutes * 60000),
+ "startTime": end - int(ran_minutes * 60000),
+ }
+ )
+
+ def metadata(self) -> None:
+ """Trailing metadata lines, written after an exit as before it."""
+ self.lines.append(
+ {"type": "last-prompt", "lastPrompt": "x", "sessionId": self.session_id}
+ )
+ self.lines.append({"type": "mode", "mode": "normal"})
+
+ def write(self, path: Path) -> Path:
+ path.write_text(
+ "".join(json.dumps(line) + "\n" for line in self.lines), encoding="utf-8"
+ )
+ return path
+
+ def summary(self, tmp_path: Path) -> dict[str, Any]:
+ entries = load_transcript(
+ self.write(tmp_path / f"{self.session_id}.jsonl"), silent=True
+ )
+ return summarise_session(entries, now_ms=NOW_MS)
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("Shall I add it to shard L2?", True),
+ ("Which one should I carry through?**", True),
+ ("Done. Want me to push?\n\n", True),
+ ("Is this right? I went with option 2.", False),
+ ("Pushed to the branch.", False),
+ ("", False),
+ ],
+)
+def test_a_reply_asks_when_its_last_line_ends_on_a_question(
+ text: str, expected: bool
+) -> None:
+ assert asks_you(text) is expected
+
+
+def test_a_reply_that_asks_is_your_move(tmp_path: Path) -> None:
+ s = _Session()
+ s.prompt(30, "Fix the stale cache.")
+ s.tool(28, command="just test")
+ s.reply(25, "Fixed. Shall I add the test to shard L2?")
+ info = s.summary(tmp_path)
+ assert info["state"] == "asked"
+ assert [t[2] for t in info["turns"]] == ["ask"]
+ assert info["last"]["who"] == "claude"
+ assert info["last"]["html"].endswith("shard L2?\n")
+ assert info["lastPrompt"]["html"] == "
Fix the stale cache.
\n"
+ assert info["toolCount"] == 1 and len(info["tools"]) == 1
+ assert info["branch"] == "feature-x"
+
+
+def test_a_finished_reply_is_done(tmp_path: Path) -> None:
+ s = _Session()
+ s.prompt(30, "Push it.")
+ s.reply(29, "Pushed to the branch.")
+ assert s.summary(tmp_path)["state"] == "done"
+
+
+def test_an_api_error_as_the_last_entry_is_an_error(tmp_path: Path) -> None:
+ s = _Session()
+ s.prompt(30, "Carry on.")
+ s.reply(29, "API Error: Request timed out.", model="")
+ info = s.summary(tmp_path)
+ assert info["state"] == "error"
+ assert info["turns"][-1][2] == "err"
+ assert info["last"] == {
+ "at": info["last"]["at"],
+ "who": "error",
+ "text": "API Error: Request timed out.",
+ }
+
+
+def test_a_session_ending_on_a_tool_call_is_mid_turn(tmp_path: Path) -> None:
+ """The transcript can't tell a running agent from one that stopped
+ mid-turn: both are the agent's move, shown by the tool call."""
+ s = _Session()
+ s.prompt(30, "Run the race test.")
+ s.tool(2, command="just test -k race --count 20")
+ info = s.summary(tmp_path)
+ assert info["state"] == "busy"
+ assert info["turns"][-1][2] == "open"
+ assert info["last"]["who"] == "tool"
+ assert info["last"]["text"] == "Bash · just test -k race --count 20"
+
+
+def test_a_prompt_with_no_reply_yet_is_the_agents_move(tmp_path: Path) -> None:
+ s = _Session()
+ s.prompt(30, "First.")
+ s.reply(29, "Done.")
+ s.prompt(1, "Now the second part.")
+ info = s.summary(tmp_path)
+ assert info["state"] == "busy"
+ assert info["last"] is None
+ assert info["lastPrompt"]["html"] == "
Now the second part.
\n"
+
+
+def test_only_the_final_turn_can_be_open(tmp_path: Path) -> None:
+ """An earlier turn that ended on a tool call was interrupted: it is
+ over, not under way."""
+ s = _Session()
+ s.prompt(60, "Start.")
+ s.tool(59, command="sleep 100")
+ s.prompt(40, "Never mind, do this instead.")
+ s.reply(39, "Done.")
+ info = s.summary(tmp_path)
+ assert [t[2] for t in info["turns"]] == ["asst", "asst"]
+ assert info["turnCount"] == 2
+
+
+def test_injected_user_bodies_are_not_prompts(tmp_path: Path) -> None:
+ s = _Session()
+ s.prompt(30, "Real prompt.")
+ s.reply(29, "Started a background agent.")
+ s.prompt(
+ 20,
+ "\na1\n"
+ "completed\n",
+ )
+ s.prompt(19, "ok")
+ s.prompt(18, "Meta body", isMeta=True)
+ info = s.summary(tmp_path)
+ # None is a prompt; the notification (delivered while Claude was
+ # idle) starts a turn of its own, the rest just continue it.
+ assert [t[5] for t in info["turns"]] == ["you", "task"]
+ assert info["lastPrompt"]["html"] == "
Real prompt.
\n"
+
+
+def _compact(s: _Session, minutes_ago: float) -> None:
+ """A compaction as Claude Code writes it: the boundary, then the
+ summary as a user entry, then context attachments."""
+ s._base(
+ "system",
+ minutes_ago,
+ content="Conversation compacted",
+ subtype="compact_boundary",
+ isMeta=False,
+ )
+ s.prompt(
+ minutes_ago,
+ "This session is being continued from a previous conversation that ran"
+ " out of context. The summary below covers the earlier portion of the"
+ " conversation.\n\nSummary: ...",
+ isCompactSummary=True,
+ isVisibleInTranscriptOnly=True,
+ )
+ s._base("attachment", minutes_ago, attachment={"type": "environment"})
+
+
+def test_an_idle_compaction_is_not_a_turn(tmp_path: Path) -> None:
+ """Claude Code can compact a session while it waits for you: the
+ summary it writes is not a prompt, and the session stays finished."""
+ s = _Session()
+ s.prompt(120, "Commit the work.")
+ s.reply(110, "Committed.")
+ _compact(s, 55)
+ info = s.summary(tmp_path)
+ assert info["state"] == "done"
+ assert info["turnCount"] == 1
+ assert info["turns"][0][1] == _ms_ago(110) # not stretched to the compaction
+ assert info["lastPrompt"]["html"] == "
Commit the work.
\n"
+ assert info["last"]["html"] == "
Committed.
\n"
+
+
+def test_a_mid_turn_compaction_continues_the_turn(tmp_path: Path) -> None:
+ s = _Session()
+ s.prompt(60, "Refactor the parser.")
+ s.tool(50, command="just test")
+ _compact(s, 45)
+ s.tool(40, command="just test")
+ s.reply(35, "Refactored; the tests pass.")
+ info = s.summary(tmp_path)
+ assert info["state"] == "done"
+ assert info["turnCount"] == 1
+ assert info["toolCount"] == 2
+ assert info["turns"][0][:2] == [_ms_ago(60), _ms_ago(35)]
+
+
+def test_sidechain_entries_are_left_out(tmp_path: Path) -> None:
+ s = _Session()
+ s.prompt(30, "Delegate it.")
+ s.reply(29, "Delegated.")
+ s.prompt(20, "sub-agent prompt", isSidechain=True)
+ s.reply(19, "sub-agent reply?", isSidechain=True)
+ info = s.summary(tmp_path)
+ assert info["turnCount"] == 1
+ assert info["state"] == "done"
+
+
+def test_turns_older_than_the_horizon_are_counted_not_listed(tmp_path: Path) -> None:
+ old = (TURN_HORIZON_HOURS + 2) * 60
+ s = _Session()
+ s.prompt(old, "Yesterday's work.")
+ s.reply(old - 5, "Done.")
+ s.prompt(10, "Today's work.")
+ s.reply(9, "Done.")
+ info = s.summary(tmp_path)
+ assert info["turnCount"] == 2
+ assert len(info["turns"]) == 1
+ assert info["earlierTurns"] == 1
+
+
+def test_the_last_prompt_and_reply_carry_rendered_markdown(tmp_path: Path) -> None:
+ """Rendered by the session pages' pipeline: Markdown becomes markup,
+ raw HTML in the transcript stays text."""
+ s = _Session()
+ s.prompt(30, "Fix **this** ")
+ s.reply(29, "Done:\n\n- `just test` passes\n\n")
+ info = s.summary(tmp_path)
+ prompt_html = info["lastPrompt"]["html"]
+ assert "this" in prompt_html
+ assert "just test passes" in last_html
+ assert ""}]}
+ text = payload_json(payload)
+ assert "<" not in text
+ assert json.loads(text) == payload
+ assert recent_data_js(payload).startswith("/*")
+
+
+def _project(projects: Path, name: str, session: _Session) -> Path:
+ project = projects / name
+ project.mkdir(parents=True)
+ session.write(project / f"{session.session_id}.jsonl")
+ 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"
+ recent = _Session("aaaa1111-recent", cwd="/home/u/code/foldyard")
+ recent.prompt(30, "Pin the cache mode.")
+ recent.reply(25, "Pinned. Shall I add the test?")
+ _project(projects, "-home-u-code-foldyard", recent)
+ old = _Session("bbbb2222-old", cwd="/home/u/code/other")
+ old.prompt(60 * 24 * 10, "Ancient history.")
+ old.reply(60 * 24 * 10 - 1, "Done.")
+ _project(projects, "-home-u-code-other", old)
+ return projects
+
+
+def test_payload_lists_recent_sessions_across_projects(archive: Path) -> None:
+ process_projects_hierarchy(archive, silent=True)
+ payload = build_recent_payload(archive, now=NOW)
+ assert [s["id"] for s in payload["sessions"]] == ["aaaa1111-recent"]
+ session = payload["sessions"][0]
+ assert session["state"] == "asked"
+ assert session["project"] == "foldyard"
+ assert session["short"] == "aaaa1111"
+ assert session["href"] == "-home-u-code-foldyard/session-aaaa1111-recent.html"
+ assert payload["generatedAt"] == NOW_MS
+ assert payload["v"] == 1
+
+
+def _data_payload(path: Path) -> dict[str, Any]:
+ match = re.search(r"__cclRecent\((\{.*\})\);", path.read_text(encoding="utf-8"))
+ assert match, "recent-data.js should hand the page its payload"
+ return json.loads(match.group(1))
+
+
+def test_conversion_writes_the_page_and_its_data(archive: Path) -> None:
+ process_projects_hierarchy(archive, silent=True)
+ assert (archive / RECENT_PAGE).exists()
+ payload = _data_payload(archive / RECENT_DATA)
+ # The real clock: the fixture's session is 25 minutes old only
+ # relative to NOW, so it may fall outside the window — but the file
+ # must always be a well-formed payload.
+ assert payload["v"] == 1
+ assert isinstance(payload["sessions"], list)
+ index = (archive / "index.html").read_text(encoding="utf-8")
+ assert "href='recent.html'" in index
+
+
+def test_index_links_to_the_page_in_both_themes(archive: Path) -> None:
+ for theme in ("classic", "minimal"):
+ process_projects_hierarchy(archive, silent=True, theme=theme)
+ index = (archive / "index.html").read_text(encoding="utf-8")
+ assert index.count("href='recent.html'") == 1, theme
+
+
+def test_write_recent_data_needs_the_page(tmp_path: Path) -> None:
+ """Nothing to feed without the page (a single-file conversion)."""
+ assert write_recent_data(tmp_path, tmp_path, now=NOW) is None
+ assert not (tmp_path / RECENT_DATA).exists()
+
+
+def test_write_recent_data_refreshes_the_data_file(archive: Path) -> None:
+ process_projects_hierarchy(archive, silent=True)
+ payload = write_recent_data(archive, archive, now=NOW)
+ assert payload is not None
+ assert [s["id"] for s in _data_payload(archive / RECENT_DATA)["sessions"]] == [
+ "aaaa1111-recent"
+ ]
+
+
+def test_without_session_pages_rows_have_no_link(
+ archive: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # `--combined only` writes no session pages, so a row links nowhere
+ # rather than to a page that doesn't exist: on the full run, and on
+ # the watch's own refresh.
+ real = recent_module.build_recent_payload
+
+ def at_now(projects_dir: Path, **kwargs: Any) -> dict[str, Any]:
+ kwargs.setdefault("now", NOW)
+ return real(projects_dir, **kwargs)
+
+ monkeypatch.setattr(recent_module, "build_recent_payload", at_now)
+ process_projects_hierarchy(archive, silent=True, generate_individual_sessions=False)
+ assert [s["href"] for s in _data_payload(archive / RECENT_DATA)["sessions"]] == [
+ None
+ ]
+ payload = write_recent_data(archive, archive, now=NOW, session_pages=False)
+ assert payload is not None
+ assert [s["href"] for s in payload["sessions"]] == [None]
+
+
+# ---- reuse between watch ticks ------------------------------------------
+
+
+@pytest.fixture
+def two_sessions(tmp_path: Path) -> tuple[Path, _Session, _Session]:
+ projects = tmp_path / "projects"
+ first = _Session("aaaa1111-first", cwd="/home/u/code/foldyard")
+ first.prompt(30, "First.")
+ first.reply(29, "Done.")
+ second = _Session("bbbb2222-second", cwd="/home/u/code/other")
+ second.prompt(20, "Second.")
+ second.reply(19, "Done.")
+ _project(projects, "-home-u-code-foldyard", first)
+ _project(projects, "-home-u-code-other", second)
+ process_projects_hierarchy(projects, silent=True)
+ return projects, first, second
+
+
+def _count_loads(monkeypatch: pytest.MonkeyPatch) -> list[str]:
+ loaded: list[str] = []
+ original = CacheManager.load_session_entries
+
+ def counting(self: CacheManager, session_id: str) -> list[Any]:
+ loaded.append(session_id)
+ return original(self, session_id)
+
+ monkeypatch.setattr(CacheManager, "load_session_entries", counting)
+ return loaded
+
+
+def test_a_memo_reuses_unchanged_sessions(
+ two_sessions: tuple[Path, _Session, _Session], monkeypatch: pytest.MonkeyPatch
+) -> None:
+ projects, _first, second = two_sessions
+ loaded = _count_loads(monkeypatch)
+ memo = RecentMemo()
+ cold = build_recent_payload(projects, now=NOW, memo=memo)
+ assert sorted(loaded) == ["aaaa1111-first", "bbbb2222-second"]
+
+ loaded.clear()
+ warm = build_recent_payload(projects, now=NOW, memo=memo)
+ assert loaded == []
+ assert warm == cold
+
+ # One session grows: only it is read again.
+ second.prompt(5, "And another thing.")
+ second.reply(4, "Done too.")
+ second.write(projects / "-home-u-code-other" / f"{second.session_id}.jsonl")
+ process_projects_hierarchy(projects, silent=True)
+ loaded.clear()
+ grown = build_recent_payload(projects, now=NOW, memo=memo)
+ assert loaded == ["bbbb2222-second"]
+ assert grown["sessions"][0]["turnCount"] == 2
+
+
+def test_a_memoised_summary_still_follows_the_clock(
+ two_sessions: tuple[Path, _Session, _Session],
+) -> None:
+ """The memo holds what the transcript says; the clock (the turn
+ horizon) is applied on every build."""
+ projects, _first, _second = two_sessions
+ memo = RecentMemo()
+ now = build_recent_payload(projects, now=NOW, memo=memo)
+ # The horizon lands between the two sessions' last turns (29 and
+ # 19 minutes before NOW).
+ later = build_recent_payload(
+ projects,
+ now=NOW + timedelta(hours=TURN_HORIZON_HOURS) - timedelta(minutes=22),
+ memo=memo,
+ )
+ by_id = {s["id"]: s for s in later["sessions"]}
+ assert len(now["sessions"][1]["turns"]) == 1
+ assert by_id["aaaa1111-first"]["turns"] == []
+ assert by_id["aaaa1111-first"]["earlierTurns"] == 1
+ assert len(by_id["bbbb2222-second"]["turns"]) == 1
+
+
+def test_a_memo_forgets_sessions_that_leave_the_list(
+ two_sessions: tuple[Path, _Session, _Session],
+) -> None:
+ projects, _first, _second = two_sessions
+ memo = RecentMemo()
+ build_recent_payload(projects, now=NOW, memo=memo)
+ assert len(memo) == 2
+ # Past the window, neither session is listed any more.
+ build_recent_payload(projects, now=NOW + timedelta(days=WINDOW_DAYS + 1), memo=memo)
+ assert len(memo) == 0
+
+
+def test_a_memo_sees_an_entry_the_session_row_does_not_count(
+ tmp_path: Path,
+) -> None:
+ """A notification delivered as an attachment ends work, but the
+ session row doesn't count attachments: neither its last update nor
+ its message count moves. The memo must still notice it."""
+ projects = tmp_path / "projects"
+ s = _Session("cccc3333-bg", cwd="/home/u/code/foldyard")
+ s.prompt(40, "Run it in the background.")
+ s.tool(30, command="sleep 99", tool_use_result={"backgroundTaskId": "b1"})
+ s.reply(29, "Started.")
+ project = _project(projects, "-home-u-code-foldyard", s)
+ process_projects_hierarchy(projects, silent=True)
+ memo = RecentMemo()
+ running = build_recent_payload(projects, now=NOW, memo=memo)
+ assert running["sessions"][0]["bg"][0][1] is None
+
+ # Delivered mid-turn, as an attachment: the session row skips those.
+ s.notify(20, "b1", "killed", "was stopped", mid_turn=True)
+ s.write(project / f"{s.session_id}.jsonl")
+ process_projects_hierarchy(projects, silent=True)
+ stopped = build_recent_payload(projects, now=NOW, memo=memo)
+ assert stopped["sessions"][0]["bg"][0][1:4] == [_ms_ago(20), "bash", "stopped"]
+
+
+def test_a_memoised_summary_still_sees_the_exit(
+ two_sessions: tuple[Path, _Session, _Session],
+) -> None:
+ """The exit line isn't cached, so it leaves the memo's key alone:
+ it is read from the file on every build."""
+ projects, _first, second = two_sessions
+ memo = RecentMemo()
+ second.prompt(15, "Watch it.")
+ second.tool(14, command="gh pr checks", result=False)
+ second.write(projects / "-home-u-code-other" / f"{second.session_id}.jsonl")
+ process_projects_hierarchy(projects, silent=True)
+ assert (
+ build_recent_payload(projects, now=NOW, memo=memo)["sessions"][0]["state"]
+ == "busy"
+ )
+ second.exit(5)
+ second.write(projects / "-home-u-code-other" / f"{second.session_id}.jsonl")
+ assert (
+ build_recent_payload(projects, now=NOW, memo=memo)["sessions"][0]["state"]
+ == "done"
+ )
diff --git a/test/test_recent_browser.py b/test/test_recent_browser.py
new file mode 100644
index 00000000..c252b1b0
--- /dev/null
+++ b/test/test_recent_browser.py
@@ -0,0 +1,939 @@
+"""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,
+ "html": render_user_markdown(f"Prompt for {sid}"),
+ },
+ "last": {
+ "at": last_at,
+ "who": "claude",
+ "html": render_markdown(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,
+ "bgCapMinutes": 60,
+ "wakeGraceMinutes": 5,
+ "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}']")
+
+
+def _hhmm(page: Page, ms: int) -> str:
+ """A time of day as the page writes it (the browser's local zone)."""
+ return page.evaluate(
+ "(ms) => { const d = new Date(ms); return String(d.getHours()).padStart(2, '0')"
+ " + ':' + String(d.getMinutes()).padStart(2, '0'); }",
+ ms,
+ )
+
+
+@pytest.mark.browser
+def test_reload_picks_up_new_sessions_in_place(
+ isolated_page: Page, tmp_path: Path
+) -> None:
+ # It installs a clock, which belongs to the context: a fresh one.
+ page = isolated_page
+ 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, "html": render_user_markdown(EVIL)},
+ last={"at": NOW - 2 * MIN, "who": "tool", "text": EVIL},
+ bg=[[NOW - 20 * MIN, None, "bash", None, EVIL, None]],
+ wakes=[[NOW + 10 * MIN, 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)
+ expect(row.locator(".rc-bgl li").first).to_contain_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_rendered_markdown_cannot_run_script(page: Page, tmp_path: Path) -> None:
+ """The html fields go in as markup; the pipeline that rendered them
+ escaped the transcript's raw HTML, so it arrives as text."""
+ evil = _session(
+ "dddd5555",
+ 2 * MIN,
+ lastPrompt={
+ "at": NOW - 5 * MIN,
+ "html": render_user_markdown(EVIL),
+ },
+ last={
+ "at": NOW - 2 * MIN,
+ "who": "claude",
+ "html": render_markdown(EVIL),
+ },
+ )
+ page.goto(_write(tmp_path, _payload([evil])))
+ row = _row(page, "dddd5555")
+ row.locator(".rc-block").click()
+ expect(row.locator(".rc-last-text")).to_contain_text("onerror=window.__pwned=1")
+ expect(row.locator(".rc-prompt .rc-md")).to_contain_text(" None:
+ reply = "Done:\n\n- **bold** item\n\n```python\nx = 1\n```"
+ session = _session(
+ "aaaa7777",
+ 3 * MIN,
+ lastPrompt={
+ "at": NOW - 10 * MIN,
+ "html": render_user_markdown("Fix `the cache`"),
+ },
+ last={
+ "at": NOW - 3 * MIN,
+ "who": "claude",
+ "html": render_markdown(reply),
+ },
+ )
+ page.goto(_write(tmp_path, _payload([session])))
+ row = _row(page, "aaaa7777")
+ row.locator(".rc-block").click()
+ last = row.locator(".rc-last-text.rc-md")
+ expect(last.locator("li strong")).to_have_text("bold")
+ expect(last.locator(".highlight")).to_have_count(1)
+ expect(row.locator(".rc-prompt .rc-md code")).to_have_text("the cache")
+ # A tool line is plain text, not Markdown.
+ tool = _session(
+ "bbbb7777",
+ 4 * MIN,
+ "busy",
+ last={"at": NOW - 4 * MIN, "who": "tool", "text": "Bash · **not bold**"},
+ )
+ page.goto(_write(tmp_path, _payload([session, tool])))
+ _row(page, "bbbb7777").locator(".rc-block").click()
+ tool_last = _row(page, "bbbb7777").locator(".rc-last")
+ expect(tool_last.locator(".rc-last-text")).to_have_text("Bash · **not bold**")
+ assert tool_last.locator(".rc-md").count() == 0
+
+
+@pytest.mark.browser
+def test_a_plan_or_question_says_so(page: Page, tmp_path: Path) -> None:
+ plan = _session(
+ "aaaa8888",
+ 3 * MIN,
+ "asked",
+ last={"at": NOW - 3 * MIN, "who": "plan", "html": render_markdown("1. Split")},
+ )
+ question = _session(
+ "bbbb8888",
+ 5 * MIN,
+ "asked",
+ last={
+ "at": NOW - 5 * MIN,
+ "who": "question",
+ "html": render_markdown("Which?"),
+ },
+ )
+ page.goto(_write(tmp_path, _payload([plan, question])))
+ for sid, label in (("aaaa8888", "Plan to approve"), ("bbbb8888", "Claude asked")):
+ row = _row(page, sid)
+ row.locator(".rc-block").click()
+ expect(row.locator(".rc-who")).to_contain_text(label + " · ")
+ expect(row.locator(".rc-last-text.rc-md")).to_be_visible()
+
+
+@pytest.mark.browser
+def test_the_chevron_opens_the_row(page: Page, tmp_path: Path) -> None:
+ page.goto(_write(tmp_path, _payload([_session("cccc7777", 3 * MIN)])))
+ row = _row(page, "cccc7777")
+ chevron = row.locator(".rc-chev")
+ block = row.locator(".rc-block")
+ # Pointer-only: the block stays the one control for keyboards and
+ # screen readers, so a row doesn't gain a second tab stop.
+ expect(chevron).to_have_attribute("aria-hidden", "true")
+ expect(row).not_to_have_attribute("data-open", "true")
+ chevron.click()
+ expect(block).to_have_attribute("aria-expanded", "true")
+ expect(row).to_have_attribute("data-open", "true")
+ expect(row.locator(".rc-x")).to_be_visible()
+ chevron.click()
+ expect(block).to_have_attribute("aria-expanded", "false")
+ expect(row.locator(".rc-x")).to_be_hidden()
+
+
+@pytest.mark.browser
+def test_markers_show_their_turns_snippets(page: Page, tmp_path: Path) -> None:
+ start, end = NOW - 40 * MIN, NOW - 10 * MIN
+ session = _session(
+ "dddd7777",
+ 10 * MIN,
+ "asked",
+ turns=[[start, end, "ask", "Plan the change", "Which renderer?"]],
+ tools=[],
+ )
+ page.goto(_write(tmp_path, _payload([session])))
+ row = _row(page, "dddd7777")
+ expect(row.locator(".rc-k-prompt")).to_have_attribute(
+ "title", f"You · {_hhmm(page, start)}\nPlan the change"
+ )
+ expect(row.locator(".rc-mk.rc-k-ask")).to_have_attribute(
+ "title", f"Claude asked you · {_hhmm(page, end)}\nWhich renderer?"
+ )
+
+
+@pytest.mark.browser
+def test_the_strip_shows_four_hours_back(page: Page, tmp_path: Path) -> None:
+ turns = [
+ [NOW - 5 * HOUR, NOW - 5 * HOUR + 10 * MIN, "asst"],
+ [NOW - 3 * HOUR, NOW - 3 * HOUR + 10 * MIN, "asst"],
+ ]
+ session = _session("eeee7777", 3 * HOUR - 10 * MIN, turns=turns, tools=[])
+ page.goto(_write(tmp_path, _payload([session])))
+ row = _row(page, "eeee7777")
+ expect(row.locator(".rc-bar")).to_have_count(1)
+ expect(row.locator(".rc-earlier")).to_have_text("◂ +1 earlier turn")
+ # NOW sits at four fifths of the strip (4 h back, 1 h ahead).
+ left = page.evaluate(
+ "parseFloat(document.querySelector('.rc-row .rc-now').style.left)"
+ )
+ assert left == pytest.approx(80)
+
+
+@pytest.mark.browser
+def test_background_work_is_not_your_move(page: Page, tmp_path: Path) -> None:
+ """A finished turn with background work still running, or a wake-up
+ still to come, is waiting on itself; asked/error/busy are never
+ overridden, and work with no end stops counting after the cap."""
+ running = [[NOW - 20 * MIN, None, "agent", None, "Survey", None]]
+ sessions = [
+ _session("aaaa9999", 5 * MIN, "done", bg=running),
+ _session("bbbb9999", 6 * MIN, "done", wakes=[[NOW + 20 * MIN, "CI"]]),
+ # The wake-up was missed (past its time and the grace).
+ _session("cccc9999", 7 * MIN, "done", wakes=[[NOW - 10 * MIN, "CI"]]),
+ # No end recorded, but the session went quiet over an hour ago.
+ _session("dddd9999", 70 * MIN, "done", bg=running),
+ # A monitor past its own expiry.
+ _session(
+ "eeee9999",
+ 8 * MIN,
+ "done",
+ bg=[[NOW - 40 * MIN, None, "monitor", None, "CI", NOW - 10 * MIN]],
+ ),
+ _session("ffff9999", 9 * MIN, "asked", bg=running),
+ _session(
+ "abab9999",
+ 10 * MIN,
+ "done",
+ bg=[[NOW - 40 * MIN, NOW - 30 * MIN, "bash", "completed", "test", None]],
+ ),
+ ]
+ page.goto(_write(tmp_path, _payload(sessions)))
+ status = {
+ sid: _row(page, sid).locator(".rc-block-status").inner_text().lower()
+ for sid in (
+ "aaaa9999",
+ "bbbb9999",
+ "cccc9999",
+ "dddd9999",
+ "eeee9999",
+ "ffff9999",
+ "abab9999",
+ )
+ }
+ assert status == {
+ "aaaa9999": "background",
+ "bbbb9999": "background",
+ "cccc9999": "finished",
+ "dddd9999": "finished",
+ "eeee9999": "finished",
+ "ffff9999": "asked you",
+ "abab9999": "finished",
+ }
+ expect(_row(page, "aaaa9999")).to_have_class(re.compile(r"\brc-state-waiting\b"))
+ # Your move: the five finished-or-asked rows, not the two waiting ones.
+ expect(page.locator("[data-filter='move'] .rc-n")).to_have_text("5")
+ page.get_by_role("button", name="your move").click()
+ expect(page.locator(".rc-row")).to_have_count(5)
+ expect(_row(page, "aaaa9999")).to_have_count(0)
+ page.get_by_role("button", name="sessions").click()
+ # The strip's summary says so.
+ expect(_row(page, "aaaa9999").locator(".rc-strip")).to_have_attribute(
+ "aria-label", re.compile("1 background task running")
+ )
+ expect(_row(page, "bbbb9999").locator(".rc-strip")).to_have_attribute(
+ "aria-label", re.compile("wakes up at " + _hhmm(page, NOW + 20 * MIN))
+ )
+
+
+@pytest.mark.browser
+def test_background_lanes_pack_and_overflow(page: Page, tmp_path: Path) -> None:
+ # Three in a row share one lane.
+ sequential = [
+ [NOW - 90 * MIN, NOW - 80 * MIN, "bash", "completed", "one", None],
+ [NOW - 70 * MIN, NOW - 60 * MIN, "bash", "failed", "two", None],
+ [NOW - 50 * MIN, NOW - 40 * MIN, "bash", "stopped", "three", None],
+ ]
+ # Six at once: two lanes of their own, the other four folded into one.
+ crowded = [
+ [NOW - 60 * MIN + i * MIN, None, "agent", None, f"agent {i}", None]
+ for i in range(6)
+ ]
+ sessions = [
+ _session("aaaa6666", 5 * MIN, bg=sequential),
+ _session("bbbb6666", 5 * MIN, bg=crowded),
+ ]
+ page.goto(_write(tmp_path, _payload(sessions)))
+ seq = _row(page, "aaaa6666")
+ expect(seq.locator(".rc-bg")).to_have_count(3)
+ assert set(
+ seq.locator(".rc-bg").evaluate_all("els => els.map(e => e.dataset.lane)")
+ ) == {"0"}
+ expect(seq.locator(".rc-bg-end-completed")).to_have_count(1)
+ expect(seq.locator(".rc-bg-end-failed")).to_have_count(1)
+ expect(seq.locator(".rc-bg-end-stopped")).to_have_count(1)
+ expect(seq.locator(".rc-bg-failed")).to_have_attribute(
+ "title",
+ f"Bash “two” · {_hhmm(page, NOW - 70 * MIN)}–{_hhmm(page, NOW - 60 * MIN)}"
+ " · failed",
+ )
+
+ crowd = _row(page, "bbbb6666")
+ lanes = crowd.locator(".rc-bg").evaluate_all("els => els.map(e => e.dataset.lane)")
+ assert sorted(set(lanes)) == ["0", "1", "2"]
+ expect(crowd.locator(".rc-bg.rc-bg-more")).to_have_count(1)
+ expect(crowd.locator(".rc-bg-more-n")).to_have_text("+4 more")
+ expect(crowd.locator(".rc-bg-running").first).to_have_attribute(
+ "title", f"Agent “agent 0” · {_hhmm(page, NOW - 60 * MIN)} – running"
+ )
+ # The lanes stay inside the strip.
+ box = crowd.locator(".rc-strip").bounding_box()
+ for lane in crowd.locator(".rc-bg").all():
+ seg = lane.bounding_box()
+ assert box and seg and seg["y"] + seg["height"] <= box["y"] + box["height"]
+
+
+@pytest.mark.browser
+def test_lost_work_and_wake_markers(page: Page, tmp_path: Path) -> None:
+ lost_start = NOW - 3 * HOUR
+ session = _session(
+ "cccc6666",
+ 90 * MIN,
+ "done",
+ bg=[[lost_start, None, "monitor", None, "CI", None]],
+ turns=[
+ [NOW - 3 * HOUR, NOW - 170 * MIN, "asst", "Watch CI", "Watching", "you"],
+ [
+ NOW - 2 * HOUR,
+ NOW - 115 * MIN,
+ "asst",
+ "Monitor “CI” stream ended",
+ "CI is green",
+ "task",
+ ],
+ [
+ NOW - 95 * MIN,
+ NOW - 90 * MIN,
+ "asst",
+ "check the deploy",
+ "Done",
+ "wake",
+ ],
+ ],
+ tools=[],
+ wakes=[],
+ )
+ waiting = _session(
+ "dddd6666",
+ 3 * MIN,
+ "done",
+ wakes=[[NOW + 25 * MIN, "Recheck CI"]],
+ )
+ page.goto(_write(tmp_path, _payload([session, waiting])))
+ row = _row(page, "cccc6666")
+ lost = row.locator(".rc-bg-lost")
+ expect(lost).to_have_attribute(
+ "title", f"Monitor “CI” · {_hhmm(page, lost_start)} – no end recorded"
+ )
+ expect(row.locator(".rc-mk.rc-k-task")).to_have_attribute(
+ "title",
+ f"Background work reported · {_hhmm(page, NOW - 2 * HOUR)}"
+ "\nMonitor “CI” stream ended",
+ )
+ expect(row.locator(".rc-k-wake")).to_have_attribute(
+ "title", f"Woke up · {_hhmm(page, NOW - 95 * MIN)}\ncheck the deploy"
+ )
+ pending = _row(page, "dddd6666").locator(".rc-mk.rc-k-wake.rc-pending")
+ expect(pending).to_have_attribute(
+ "title", f"Wakes up · {_hhmm(page, NOW + 25 * MIN)}\nRecheck CI"
+ )
+ # In the hour ahead: right of NOW (at 80%).
+ assert float(pending.evaluate("e => parseFloat(e.style.left)")) > 80
+
+ # The expanded row lists it all, running first.
+ _row(page, "dddd6666").locator(".rc-block").click()
+ expect(_row(page, "dddd6666").locator(".rc-bgl li")).to_have_text(
+ [f"Wakes up {_hhmm(page, NOW + 25 * MIN)} · Recheck CI"]
+ )
+ row.locator(".rc-block").click()
+ expect(row.locator(".rc-bgl li")).to_have_text(
+ [f"Monitor · CI · {_hhmm(page, lost_start)} – no end recorded"]
+ )
+
+
+@pytest.mark.browser
+def test_work_that_ended_with_the_session_says_so(page: Page, tmp_path: Path) -> None:
+ """The session's process exited with the work still going: it ends
+ there, with a mark of its own, and the row is no longer waiting."""
+ bg = [[NOW - 30 * MIN, NOW - 10 * MIN, "bash", "exited", "Watch PR", None]]
+ page.goto(_write(tmp_path, _payload([_session("ffff6666", 10 * MIN, bg=bg)])))
+ row = _row(page, "ffff6666")
+ expect(row.locator(".rc-bg-end-exited")).to_have_count(1)
+ expect(row.locator(".rc-bg-running")).to_have_count(0)
+ row.locator(".rc-block").click()
+ expect(row.locator(".rc-bgl li")).to_have_text(
+ [
+ f"Bash · Watch PR · {_hhmm(page, NOW - 30 * MIN)}–"
+ f"{_hhmm(page, NOW - 10 * MIN)} · ended with the session"
+ ]
+ )
+
+
+@pytest.mark.browser
+def test_background_list_orders_running_first(page: Page, tmp_path: Path) -> None:
+ bg = [
+ [NOW - 50 * MIN, NOW - 40 * MIN, "bash", "completed", "build", None],
+ [NOW - 45 * MIN, None, "agent", None, "Survey", None],
+ [NOW - 30 * MIN, NOW - 20 * MIN, "bash", "failed", "test", None],
+ ]
+ page.goto(_write(tmp_path, _payload([_session("eeee6666", 5 * MIN, bg=bg)])))
+ row = _row(page, "eeee6666")
+ row.locator(".rc-block").click()
+ expect(row.locator(".rc-bgl li")).to_have_text(
+ [
+ f"Agent · Survey · {_hhmm(page, NOW - 45 * MIN)} – running",
+ f"Bash · build · {_hhmm(page, NOW - 50 * MIN)}–"
+ f"{_hhmm(page, NOW - 40 * MIN)} · completed",
+ f"Bash · test · {_hhmm(page, NOW - 30 * MIN)}–"
+ f"{_hhmm(page, NOW - 20 * MIN)} · failed",
+ ]
+ )
+ # The legend explains the new marks.
+ expect(page.locator("#rc-legend")).to_contain_text("background work")
+ expect(page.locator("#rc-legend")).to_contain_text("wake-up")
+
+
+@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: list[list[Any]] = [
+ [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
+ # No snippets in these turns: just what and when.
+ expect(last).to_have_attribute(
+ "title", "Claude finished · " + _hhmm(page, turns[-1][1])
+ )
+
+
+@pytest.mark.browser
+def test_stale_notice_and_empty_state(isolated_page: Page, tmp_path: Path) -> None:
+ # It installs a clock, which belongs to the context: a fresh one.
+ page = isolated_page
+ 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")
+ # 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)
+ 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()
+
+
+@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})
+
+
+@pytest.mark.browser
+def test_phone_layout_saves_rows(page: Page, tmp_path: Path) -> None:
+ """At phone width: Updated shares the last filter row, right-aligned;
+ the list has no box; an open row puts Transcript beside the copy
+ button and its counts on one line."""
+ page.set_viewport_size({"width": 375, "height": 800})
+ try:
+ page.goto(_write(tmp_path, _payload([_session("abcd1234", 3 * MIN, "asked")])))
+
+ def box(selector: str) -> FloatRect:
+ rect = page.locator(selector).first.bounding_box()
+ assert rect is not None, f"{selector} is not laid out"
+ return rect
+
+ def middle(rect: FloatRect) -> float:
+ return rect["y"] + rect["height"] / 2
+
+ cache, updated, head = (
+ box("[data-filter='cache']"),
+ box(".rc-updated"),
+ box(".rc-head"),
+ )
+ assert abs(updated["y"] - cache["y"]) < 2, (updated, cache)
+ assert abs((updated["x"] + updated["width"]) - (head["x"] + head["width"])) < 2
+
+ panel = page.evaluate(
+ "(() => { const s = getComputedStyle(document.querySelector('.rc-panel'));"
+ " return [s.borderTopWidth, s.borderRadius]; })()"
+ )
+ assert panel == ["0px", "0px"], panel
+
+ row = _row(page, "abcd1234")
+ row.locator(".rc-block").click()
+ copy, link = box(".rc-copy"), box(".rc-x-link")
+ assert abs(middle(copy) - middle(link)) < 2, (copy, link)
+ tops = page.evaluate(
+ "[...document.querySelectorAll('.rc-x-side dl > div')]"
+ ".map(d => Math.round(d.getBoundingClientRect().top))"
+ )
+ assert len(tops) == 3 and len(set(tops)) == 1, tops
+ widths = page.evaluate(
+ "[document.documentElement.scrollWidth, document.documentElement.clientWidth]"
+ )
+ assert widths[0] <= widths[1], widths
+ finally:
+ page.set_viewport_size({"width": 1280, "height": 720})
+
+
+# ---- motion ------------------------------------------------------------
+# Changes animate by FLIP (recent.js "motion"): the layout jumps to its end
+# state and transform / opacity / clip-path play each part back from where
+# it was drawn. window.__cclRecent(payload) redraws synchronously, so a
+# test can hand the page a refresh and read document.getAnimations() in
+# the same task, before any of it has run.
+
+# The page's own animations (CSS transitions, like the chevron's, aside):
+# for each, the row it is in, the target's classes and what it animates.
+_MOTIONS = """() => document.getAnimations()
+ .filter((a) => a.constructor === Animation)
+ .map((a) => {
+ const t = a.effect.target;
+ const row = t.closest('.rc-row');
+ const props = new Set();
+ a.effect.getKeyframes().forEach((k) => Object.keys(k).forEach((p) => props.add(p)));
+ ['offset', 'computedOffset', 'easing', 'composite'].forEach((p) => props.delete(p));
+ return {
+ row: row ? row.dataset.id.slice(0, 8) : null,
+ cls: t.getAttribute('class') || '',
+ props: [...props].sort(),
+ };
+ })"""
+_SETTLE = (
+ "() => Promise.all(document.getAnimations()"
+ ".map((a) => a.finished.catch(() => null)))"
+)
+# Paint-only properties: nothing that needs a layout per frame. The one
+# exception is the list's own height (the card's edge and the legend).
+_PAINT_ONLY = {"transform", "opacity", "clipPath", "zIndex"}
+
+
+def _motions(page: Page, script: str = "", arg: Any = None) -> list[dict[str, Any]]:
+ """Run `script` (a function body taking `arg`), then list the motions."""
+ return page.evaluate(
+ f"(arg) => {{ {script}; return ({_MOTIONS})(); }}",
+ arg,
+ )
+
+
+def _click(sid: str) -> str:
+ return f"document.querySelector('.rc-row[data-id^={sid}] .rc-block').click()"
+
+
+def _settle(page: Page) -> None:
+ page.evaluate(_SETTLE)
+ assert _motions(page) == []
+ expect(page.locator(".rc-ghost")).to_have_count(0)
+
+
+def _on(motions: list[dict[str, Any]], sid: str, cls: str = "rc-row") -> set[str]:
+ """What the motions on `sid`'s element of class `cls` animate."""
+ props: set[str] = set()
+ for m in motions:
+ if m["row"] == sid and cls in m["cls"].split():
+ props.update(m["props"])
+ return props
+
+
+def _only_paint(motions: list[dict[str, Any]]) -> None:
+ for m in motions:
+ allowed = _PAINT_ONLY | ({"height"} if m["cls"] == "rc-rows" else set())
+ assert set(m["props"]) <= allowed, m
+
+
+@pytest.mark.browser
+def test_opening_and_closing_a_row_animate(page: Page, tmp_path: Path) -> None:
+ sessions = [
+ _session("aaaa1111", 2 * MIN),
+ _session("bbbb2222", 5 * MIN),
+ _session("cccc3333", 9 * MIN),
+ ]
+ page.goto(_write(tmp_path, _payload(sessions)))
+ expect(page.locator(".rc-row")).to_have_count(3)
+ # The first draw is not animated.
+ _settle(page)
+
+ opening = _motions(page, _click("aaaa1111"))
+ _only_paint(opening)
+ # The row is revealed by a clip as it grows (it doesn't move), its
+ # panel fades in, and the rows below slide down to make room.
+ assert _on(opening, "aaaa1111") == {"clipPath"}, opening
+ assert _on(opening, "aaaa1111", "rc-x") == {"opacity"}, opening
+ assert "transform" in _on(opening, "bbbb2222"), opening
+ assert "transform" in _on(opening, "cccc3333"), opening
+ # The list's height carries the card's edge and the legend along.
+ assert any(m["cls"] == "rc-rows" for m in opening), opening
+ _settle(page)
+ expect(_row(page, "aaaa1111").locator(".rc-x")).to_be_visible()
+
+ closing = _motions(page, _click("aaaa1111"))
+ _only_paint(closing)
+ # The panel itself goes at once; a copy of it stays drawn, out of the
+ # page's reach, fading while the rows below slide up over it.
+ expect(_row(page, "aaaa1111").locator(".rc-x")).to_be_hidden()
+ ghost = _row(page, "aaaa1111").locator(".rc-ghost")
+ expect(ghost).to_have_count(1)
+ expect(ghost).to_have_attribute("aria-hidden", "true")
+ assert ghost.get_attribute("id") is None
+ assert _on(closing, "aaaa1111", "rc-ghost") == {"opacity"}, closing
+ assert "transform" in _on(closing, "bbbb2222"), closing
+ _settle(page)
+ expect(page.locator(".rc-x:visible")).to_have_count(0)
+
+
+@pytest.mark.browser
+def test_a_refresh_slides_rows_to_their_new_places(page: Page, tmp_path: Path) -> None:
+ sessions = [
+ _session("aaaa1111", 2 * MIN),
+ _session("bbbb2222", 5 * MIN),
+ _session("cccc3333", 9 * MIN),
+ ] + [_session(f"f{i:03d}0000", (20 + i) * MIN) for i in range(20)]
+ page.goto(_write(tmp_path, _payload(sessions)))
+ expect(page.locator(".rc-row")).to_have_count(23)
+ _settle(page)
+
+ # cccc3333 is updated (it jumps to the top) and a new session arrives.
+ moved = [_session("dddd4444", 0), _session("cccc3333", 1 * MIN)] + sessions[:2]
+ refresh = _payload(moved + sessions[3:])
+ motions = _motions(page, "window.__cclRecent(arg)", refresh)
+ _only_paint(motions)
+ rows = page.locator(".rc-row")
+ expect(rows.nth(0)).to_have_attribute("data-id", re.compile("^dddd4444"))
+ expect(rows.nth(1)).to_have_attribute("data-id", re.compile("^cccc3333"))
+ # The new row fades in; the one that jumped slides up past the others,
+ # drawn above them; the ones it passed slide down.
+ assert _on(motions, "dddd4444") == {"opacity"}, motions
+ assert _on(motions, "cccc3333") == {"transform", "zIndex"}, motions
+ assert _on(motions, "aaaa1111") == {"transform"}, motions
+ # Only what is on screen moves: the last rows were never in view.
+ animated = {m["row"] for m in motions}
+ assert "f0190000" not in animated, animated
+ assert len(animated) < 23, animated
+ _settle(page)
+
+ # Choosing a filter or grouping redraws at once.
+ page.get_by_role("button", name="your move").click()
+ assert _motions(page) == []
+ page.locator("#rc-group").check()
+ assert _motions(page) == []
+
+
+@pytest.mark.browser
+def test_a_refresh_fades_in_only_the_new_message(page: Page, tmp_path: Path) -> None:
+ sessions = [_session("aaaa1111", 2 * MIN), _session("bbbb2222", 5 * MIN)]
+ page.goto(_write(tmp_path, _payload(sessions)))
+ _row(page, "aaaa1111").locator(".rc-block").click()
+ _settle(page)
+
+ # Same prompt, a longer reply: the row grows, the reply fades in, the
+ # rest of the panel (prompt, counts) stays as it is.
+ reply = "Reply from aaaa1111\n\n" + "\n\n".join(f"Paragraph {i}" for i in range(6))
+ longer = _session(
+ "aaaa1111",
+ 2 * MIN,
+ last={"at": NOW - 2 * MIN, "who": "claude", "html": render_markdown(reply)},
+ )
+ motions = _motions(page, "window.__cclRecent(arg)", _payload([longer, sessions[1]]))
+ _only_paint(motions)
+ assert _on(motions, "aaaa1111", "rc-last") == {"opacity"}, motions
+ assert _on(motions, "aaaa1111", "rc-prompt") == set(), motions
+ assert _on(motions, "aaaa1111", "rc-x") == set(), motions
+ assert _on(motions, "aaaa1111") == {"clipPath"}, motions
+ assert "transform" in _on(motions, "bbbb2222"), motions
+ _settle(page)
+ expect(_row(page, "aaaa1111").locator(".rc-last-text")).to_contain_text(
+ "Paragraph 5"
+ )
+
+ # A refresh that changes nothing moves nothing.
+ assert (
+ _motions(page, "window.__cclRecent(arg)", _payload([longer, sessions[1]])) == []
+ )
+
+
+@pytest.mark.browser
+def test_a_change_mid_motion_carries_on_and_settles(page: Page, tmp_path: Path) -> None:
+ sessions = [_session("aaaa1111", 2 * MIN), _session("bbbb2222", 5 * MIN)]
+ page.goto(_write(tmp_path, _payload(sessions)))
+ _settle(page)
+ # Open, close and refresh in one go: each takes over from the last.
+ swapped = _payload([_session("bbbb2222", 1 * MIN), sessions[0]])
+ motions = _motions(
+ page,
+ _click("aaaa1111") + ";" + _click("aaaa1111") + "; window.__cclRecent(arg)",
+ swapped,
+ )
+ _only_paint(motions)
+ # The refresh dropped the close's leftover copy of the panel.
+ expect(page.locator(".rc-ghost")).to_have_count(0)
+ _settle(page)
+ expect(_row(page, "aaaa1111").locator(".rc-block")).to_have_attribute(
+ "aria-expanded", "false"
+ )
+ expect(page.locator(".rc-x:visible")).to_have_count(0)
+ # Nothing is left drawn away from where it is laid out.
+ offsets = page.evaluate(
+ "[...document.querySelectorAll('.rc-row')].map((r) =>"
+ " [getComputedStyle(r).transform, getComputedStyle(r).clipPath])"
+ )
+ assert all(o == ["none", "none"] for o in offsets), offsets
+
+
+@pytest.mark.browser
+def test_reduced_motion_moves_nothing(page: Page, tmp_path: Path) -> None:
+ page.emulate_media(reduced_motion="reduce")
+ sessions = [_session("aaaa1111", 2 * MIN), _session("bbbb2222", 5 * MIN)]
+ page.goto(_write(tmp_path, _payload(sessions)))
+ assert _motions(page, _click("aaaa1111")) == []
+ expect(_row(page, "aaaa1111").locator(".rc-x")).to_be_visible()
+ swapped = _payload([_session("bbbb2222", 1 * MIN), sessions[0]])
+ assert _motions(page, "window.__cclRecent(arg)", swapped) == []
+ assert _motions(page, _click("aaaa1111")) == []
+ expect(page.locator(".rc-ghost")).to_have_count(0)
+ expect(_row(page, "aaaa1111").locator(".rc-x")).to_be_hidden()
diff --git a/test/test_recent_live_browser.py b/test/test_recent_live_browser.py
new file mode 100644
index 00000000..ec0808a4
--- /dev/null
+++ b/test/test_recent_live_browser.py
@@ -0,0 +1,124 @@
+"""The recent sessions page under changing transcripts, end to end.
+
+``test/recent_live_fixture.py`` writes sessions across four projects and
+moves them on stage by stage, as real JSONL; each stage goes through the
+real full run (cache, session pages, ``recent.html``, ``recent-data.js``)
+and the page, opened from disk as you would, reloads its data file. The
+same stages are the recent sessions demo (``scripts/demos/record.py``) and
+its play mode (``scripts/demos/recent_play.py``), so a change that breaks
+the demo's story fails here first.
+
+Nothing sleeps: each step waits for the page to show the state it expects.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import Locator, Page, expect
+
+from test.conftest import collect_page_errors
+from test.recent_live_fixture import (
+ ATTACH,
+ BENCH,
+ CI_KEY,
+ PIN,
+ ROWS,
+ TABLES,
+ ZSH,
+ RecentLive,
+ wait_for_stage,
+)
+
+pytestmark = pytest.mark.browser
+
+
+def _ids(page: Page) -> list[str]:
+ return page.evaluate(
+ "[...document.querySelectorAll('.rc-row')].map((r) => r.dataset.id)"
+ )
+
+
+def _statuses(page: Page) -> list[str]:
+ return page.locator(".rc-row .rc-block-status").all_text_contents()
+
+
+def _row(page: Page, session_id: str) -> Locator:
+ return page.locator(f".rc-row[data-id='{session_id}']")
+
+
+def _count(page: Page, name: str) -> Locator:
+ return page.locator(f"[data-filter='{name}'] .rc-n")
+
+
+def test_sessions_move_through_their_states(page: Page, tmp_path: Path) -> None:
+ live = RecentLive(tmp_path / "projects")
+ live.advance("start")
+ errors = collect_page_errors(page, live.url)
+ page.goto(live.url)
+ wait_for_stage(page, "start")
+
+ # Every state the page tells apart, newest update first, each with its
+ # project; the background benchmark makes a finished turn "background".
+ assert _ids(page) == [PIN, ROWS, BENCH, ZSH, CI_KEY, ATTACH]
+ assert _statuses(page) == [
+ "mid-turn",
+ "mid-turn",
+ "background",
+ "error",
+ "finished",
+ "finished",
+ ]
+ expect(_count(page, "all")).to_have_text("6")
+ expect(_count(page, "move")).to_have_text("3")
+ expect(_count(page, "urgent")).to_have_text("1")
+ expect(_row(page, PIN).locator(".rc-meta-project")).to_have_text("foldyard")
+ expect(_row(page, ZSH).locator(".rc-meta-project")).to_have_text("dotfiles")
+ # A title links to its session page, which the run wrote.
+ href = _row(page, ROWS).locator("a.rc-title").get_attribute("href")
+ assert href and (live.projects / href).is_file(), href
+
+ rows = _row(page, ROWS)
+ rows.locator(".rc-block").click()
+ expect(rows.locator(".rc-who")).to_contain_text("Tool call")
+
+ live.advance("tool_runs")
+ wait_for_stage(page, "tool_runs")
+ assert _ids(page)[:2] == [ROWS, PIN]
+ # Still open, its last message the call it is waiting on.
+ expect(rows.locator(".rc-block")).to_have_attribute("aria-expanded", "true")
+ expect(rows.locator(".rc-last-text")).to_contain_text("just ci")
+
+ live.advance("finishes")
+ wait_for_stage(page, "finishes")
+ expect(rows.locator(".rc-who")).to_contain_text("Claude")
+ expect(rows.locator(".rc-last-text li")).to_have_count(3)
+ expect(_count(page, "move")).to_have_text("4")
+
+ live.advance("asks_you")
+ wait_for_stage(page, "asks_you")
+ assert _ids(page)[:2] == [PIN, ROWS]
+ expect(_row(page, PIN).locator(".rc-last-text")).to_contain_text(
+ "Which cache mode should the 9p mount use?"
+ )
+ expect(_count(page, "urgent")).to_have_text("2")
+
+ live.advance("new_session")
+ wait_for_stage(page, "new_session")
+ expect(page.locator(".rc-row")).to_have_count(7)
+ expect(_row(page, TABLES).locator(".rc-meta-project")).to_have_text("wenmode")
+
+ live.advance("bg_done")
+ wait_for_stage(page, "bg_done")
+ assert _ids(page)[:4] == [BENCH, TABLES, PIN, ROWS]
+ assert "background" not in _statuses(page)
+ expect(_row(page, BENCH).locator(".rc-last-text")).to_contain_text("41 MB/s")
+
+ # Grouped, each project's sessions sit under it, newest project first.
+ page.locator("#rc-group").check()
+ expect(page.locator(".rc-group-h")).to_have_count(4)
+ expect(page.locator(".rc-group-h").first).to_contain_text("wenmode")
+ expect(page.locator(".rc-group").first.locator(".rc-row")).to_have_count(2)
+
+ assert errors == [], errors
diff --git a/test/test_recent_page.py b/test/test_recent_page.py
new file mode 100644
index 00000000..e4b260ae
--- /dev/null
+++ b/test/test_recent_page.py
@@ -0,0 +1,121 @@
+"""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 re
+
+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_writes_markup_only_from_rendered_fields() -> None:
+ """Transcript text reaches the page through textContent, except the
+ payload's ``html`` fields (Markdown rendered by the session pages'
+ escaping pipeline), which go in through one helper and nowhere else.
+ """
+ source = (COMPONENTS / "recent.js").read_text(encoding="utf-8")
+ for sink in ("outerHTML", "insertAdjacentHTML", "document.write"):
+ assert sink not in source, sink
+ assert source.count("innerHTML") == 1
+ assert "node.innerHTML = html;" in source
+ # (A bare "renderedMarkdown()" is the header comment naming it.)
+ calls = [c for c in re.findall(r"renderedMarkdown\(([^)]*)\)", source) if c]
+ # The definition, then every call passes a payload html field.
+ assert calls[0] == "cls, html"
+ assert calls[1:], "renderedMarkdown is never called"
+ for args in calls[1:]:
+ assert re.search(r"\.html$", args.strip()), args
diff --git a/test/test_watch_cli.py b/test/test_watch_cli.py
index cec32ba7..674207ab 100644
--- a/test/test_watch_cli.py
+++ b/test/test_watch_cli.py
@@ -8,6 +8,7 @@
import json
from pathlib import Path
+import pytest
from click.testing import CliRunner
from claude_code_log.cli import _resolve_watch_root, main
@@ -122,6 +123,82 @@ def run_once(engine, stop=None):
assert "hello" in produced[0].read_text(encoding="utf-8")
assert "Watching" in result.output
+ def test_ticks_share_one_recent_sessions_memo(
+ self, tmp_path: Path, monkeypatch
+ ) -> None:
+ """Each tick rewrites recent-data.js; the loop owns one memo, so a
+ tick re-reads only the recent sessions that changed."""
+ projects = tmp_path / "projects"
+ proj = projects / "-tmp-demo"
+ proj.mkdir(parents=True)
+ sid = "11111111-2222-3333-4444-555555555555"
+ (proj / f"{sid}.jsonl").write_text(_entry("u0", "hello", sid), encoding="utf-8")
+ (projects / "recent.html").write_text("page", encoding="utf-8")
+
+ import claude_code_log.recent as recent_mod
+ import claude_code_log.watch as watch_mod
+
+ memos: list[object] = []
+
+ def spy(index_dir, projects_dir, **kwargs):
+ memos.append(kwargs.get("memo"))
+
+ def run_twice(engine, stop=None):
+ engine.on_change(set())
+ engine.on_change(set())
+
+ monkeypatch.setattr(recent_mod, "write_recent_data", spy)
+ monkeypatch.setattr(watch_mod.WatchEngine, "run", run_twice)
+ result = CliRunner().invoke(
+ main, ["watch", str(proj), "--projects-dir", str(projects)]
+ )
+
+ assert result.exit_code == 0, result.output
+ # The up-front conversion, then two ticks: one memo throughout.
+ assert len(memos) == 3, result.output
+ assert isinstance(memos[0], recent_mod.RecentMemo)
+ assert all(m is memos[0] for m in memos)
+
+ @pytest.mark.parametrize(("combined", "pages"), [("yes", True), ("only", False)])
+ def test_the_refresh_links_rows_only_to_pages_it_writes(
+ self, tmp_path: Path, monkeypatch, combined: str, pages: bool
+ ) -> None:
+ """`--combined only` writes no session pages, so the recent page's
+ rows get no link to one."""
+ projects = tmp_path / "projects"
+ proj = projects / "-tmp-demo"
+ proj.mkdir(parents=True)
+ sid = "11111111-2222-3333-4444-555555555555"
+ (proj / f"{sid}.jsonl").write_text(_entry("u0", "hello", sid), encoding="utf-8")
+ (projects / "recent.html").write_text("page", encoding="utf-8")
+
+ import claude_code_log.recent as recent_mod
+ import claude_code_log.watch as watch_mod
+
+ seen: list[object] = []
+
+ def spy(index_dir, projects_dir, **kwargs):
+ seen.append(kwargs.get("session_pages"))
+
+ monkeypatch.setattr(recent_mod, "write_recent_data", spy)
+ monkeypatch.setattr(
+ watch_mod.WatchEngine, "run", lambda engine, stop=None: None
+ )
+ result = CliRunner().invoke(
+ main,
+ [
+ "watch",
+ str(proj),
+ "--projects-dir",
+ str(projects),
+ "--combined",
+ combined,
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert seen == [pages], result.output
+
def test_an_unresolvable_target_exits_nonzero(
self, tmp_path: Path, monkeypatch
) -> None: