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.*?)", + 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–
+
+
+ +
+
+ + + + +
+
+ + +
+ +
+ +

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

+
+
+ + + + {# The data, loaded the same way every refresh reloads it. Kept out of + this page so the page is the same on every run (only the data file + changes) and no transcript text is ever inlined into it. #} + + + + diff --git a/claude_code_log/recent.py b/claude_code_log/recent.py 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: