Skip to content

Support teammates (#91): stitching + headers + index (PR 3 of 3)#125

Merged
cboos merged 28 commits intomainfrom
dev/teammates-stitching
Apr 26, 2026
Merged

Support teammates (#91): stitching + headers + index (PR 3 of 3)#125
cboos merged 28 commits intomainfrom
dev/teammates-stitching

Conversation

@cboos
Copy link
Copy Markdown
Collaborator

@cboos cboos commented Apr 24, 2026

Final piece of the teammates series. Builds on the parsing/data-model from #117 and the rendering from #122.

Scope

Per the PR3 plan main approved (thread 2548/2549/2550):

  1. Team badge on session headers when teamName is set.
  2. Teammate badge on subagent session headers (each subagent session now has its own header so the colored teammate identity has somewhere to land).
  3. Subagent session collapse: subagent session blocks render inside <details> collapsed by default — parent transcript stays scannable; user expands to see the subagent's work inline.
  4. Project-index "Team: …" annotation on the project cards.

Per main's direction: kept subagent sessions as separate entities (just collapsed under the anchor) rather than restructuring the DAG.

Commits (5, bisect-clean)

  1. 2aee20d — Team badge on session headers (PR3 Hide / toogle tool use & result messages #1).
  2. 791753b — Link agent_id → (teammate_name, color) in RenderingContext (PR3 Don't truncate first user message #2). Pure data layer.
  3. e05c0d8 — Teammate badge on subagent session headers (PR3 Create TODOs #3). Subagent sessions now get their own header.
  4. dead054 — Collapse subagent session under spawning tool_use (PR3 Error converting file: list index out of range #4). <details> wrapping via Jinja namespace state.
  5. e9ec955'Team: <name>' annotation on project-index cards (PR3 claude-trace - what would this add? #5). New SQL migration 005, SessionCacheData.team_name, project aggregation, index template stat row.

Validation

  • just ci end-to-end green: 941 pytest + 4 browser + 1 benchmark + pyright 0 errors + ty clean + ruff clean.
  • 4 browser tests pass both -n auto (parallel) and -n 0 (sequential): teammate-message colored borders, task-list table shape, alice badge stays blue, subagent sessions collapsed by default.
  • Snapshots refreshed for the new badges + <details> wrapping + index team-row.

Notable implementation notes

  • Subagent header creation: previously skipped via if not is_agent_session — now removed so subagent sessions get a header. Title is Subagent • <short id>; the colored teammate badge added by _annotate_subagent_session_headers carries the human-readable identity. Trunk-vs-agent branch reset semantics preserved (only trunk sessions reset current_render_session).
  • <details> wrapping: tracked via Jinja namespace(in_collapsed_session=False) across the message-iteration loop. The summary line carries the same teammate badge / title HTML as the inner header. Inner header hidden when collapsed (summary already shows it), revealed when expanded.
  • Cache schema migration: 005_session_team_name.sql adds team_name TEXT column. Backward-compatible (NULL default). Old caches keep working; new sessions get the field populated. Both SessionCacheData reader sites guard "team_name" in row.keys() for the unmigrated-DB transient case.
  • No CodeRabbit findings yet — branch just pushed. Same iteration drill as PR1/PR2 if anything comes up.

Updated test count

  • test_dag_integration.py::test_agent_no_session_header → renamed to test_agent_session_gets_its_own_header. Updated to reflect the new behavior (agent sessions DO get headers now).
  • New tests in test_teammates_parsing.py: prepare_session_team_names, agent_teammates_populated_from_task_pairs, subagent_session_headers_carry_teammate_badge, template_project_carries_team_names, session_cache_data_round_trips_team_name.
  • New browser test test_subagent_sessions_collapsed_by_default.

Ready for review.

Summary by CodeRabbit

  • New Features

    • Sessions show a “Team: …” badge and projects list aggregated team names; subagent sidechain content is preserved and inlined in session exports
    • Task and SendMessage titles now render more compactly; teammate JSON bodies show as compact key/value
  • Style

    • Updated session/team badge and global task-status visual styles
  • Documentation

    • Added comprehensive teammates implementation guide
  • Tests

    • Expanded fixtures and integration tests for teammates/subagent rendering

cboos added 5 commits April 24, 2026 23:59
When the experimental teammates feature is active, every entry in
the active session carries a top-level `teamName`. This commit
surfaces it as a colored "👥 Team: <name>" badge next to the title
on every session header so the reader sees at a glance which session
ran inside a team.

## Changes

- `SessionHeaderMessage.team_name: Optional[str]` (additive,
  defaults None so non-teammate sessions are unaffected).

- `prepare_session_team_names(messages)` in `renderer.py` returns
  `dict[session_id → team_name]` by scanning entries for the first
  non-None `teamName`. Mirror of `prepare_session_summaries`'s
  shape; called once per render in `generate_template_messages`.

- `_render_messages` gains a `session_team_names` parameter; both
  session-header and within-session-fork branch-header construction
  sites populate `team_name`. Branch headers inherit their original
  pre-fork session's team name (a within-session fork doesn't change
  team membership).

- `format_session_header_content` renders a `<span class="session-
  team-badge">` next to the title when the field is set. Uses the
  team-card palette token `--cc-purple` so the badge reads as
  structurally related to the TeamCreate cards from PR #122.

- New CSS rules for `.session-team-badge` + `.session-teammate-badge`
  (the latter reserved for the next commit's subagent header use).

## Tests

- `test_prepare_session_team_names`: confirms first-sighting-wins per
  session; `None` teamName entries are skipped.
- Pre-existing snapshot tests refreshed for the new badge in the
  fixture's "test-coverage" team session header (rendered as
  `👥 Team: test-coverage` next to the session title).
… (PR3 #2)

Pure data layer — no rendering change yet. Sets up the lookup the next
commit's subagent session header needs.

## What it does

- Adds `RenderingContext.agent_teammates: dict[str, dict[str, Optional[str]]]`
  shaped as `agent_id → {"name": str, "color": Optional[str]}`.

- New `_populate_agent_teammates(ctx)` pass walks every Task tool_use
  message in the rendering context, finds its paired tool_result via
  the existing `pair_first` / `pair_last` indexing, and pulls:
  - `agent_id` from `tool_result.output.metadata.agent_id` (PR #117's
    AgentResultMetadata, parsed from the markdown tail).
  - `teammate_name` from `tool_use.input.name` (Claude Code sets this
    when spawning a named teammate).
  - `color` from `tool_result.output.color` if explicit, else falls
    back to `ctx.teammate_colors[spawning_session][teammate_name]`
    (the PR #122 cache learned from `<teammate-message color="...">`
    blocks).

- Runs in `generate_template_messages` after `_populate_teammate_colors`
  so the color fallback can read the populated cache. First-sighting-
  wins per agent_id (defensive against duplicate spawns; doesn't
  happen in practice).

- Skips Tasks without a `name` field (regular sub-agents that aren't
  named teammates) and pairs whose tool_result lacks a parsed
  agent_id (older transcripts without the metadata tail).

## Tests

- `test_agent_teammates_populated_from_task_pairs`: end-to-end on the
  PR #117 fixture. Both alice (blue) and bob (green) have their
  agent_ids mapped to {name, color} via the color-cache fallback path
  (the fixture's TaskOutput.color is unset; color comes from the
  `<teammate-message>` cache).
Subagent (sidechain) sessions now get their own session header so
the teammate identity has somewhere to render. Each header carries
a colored "▎ <teammate>" badge derived from the agent_id mapping
built in PR3 #2.

## Changes

- `SessionHeaderMessage.teammate_id` and `.teammate_color`
  (additive). Filled by a new `_annotate_subagent_session_headers`
  pass that walks all SessionHeaderMessages, detects synthetic
  `{main}#agent-{id}` sessionIds, and looks up the teammate info in
  `ctx.agent_teammates` (PR3 #2).

- `_render_messages` now creates a session header for subagent
  sessions too (previously skipped via `if not is_agent`). Title is
  `Subagent • <short id>`; the teammate badge added by the
  annotator pass carries the human-readable identity. Trunk-vs-agent
  branch reset semantics are preserved (only trunk sessions reset
  current_render_session — agent sessions stay grouped with the
  parent's render flow).

- `format_session_header_content` renders a `<span class="session-
  teammate-badge">` next to the title when `teammate_id` is set,
  using the teammate's palette token (`--cc-blue`/`--cc-green`/…)
  with a gray fallback for unknown colors.

## Tests

- `test_subagent_session_headers_carry_teammate_badge`: end-to-end
  on the fixture via `load_directory_transcripts` (the path that
  triggers `_integrate_agent_entries`). Asserts the main session
  header carries `team_name="test-coverage"` and that alice/bob
  subagent headers exist with the right teammate_id + color.

- `test_agent_session_gets_its_own_header` (renamed from
  `test_agent_no_session_header`): updated to reflect the new
  behavior — agent sessions DO get headers now, and the test pins
  the synthetic `{main}#agent-{id}` sessionId on the second header.

- Snapshots refreshed for the new subagent session-header markup.
Subagent session blocks now render inside a `<details>` collapsed by
default. Parent transcript stays scannable; user expands the
`▶ Subagent • <id>  ▎teammate` summary to see the agent's work.

## Changes

- `SessionHeaderMessage.collapsed_by_default: bool` (additive,
  defaults False). Set to True for synthetic-sessionId headers in
  `_render_messages` (`is_agent` path).

- New `TemplateMessage.is_collapsed_session` property — single source
  of truth for "this header should render inside a `<details>`",
  consumed by the template.

- `transcript.html` tracks an `_state.in_collapsed_session` namespace
  flag across the message-iteration loop. When a collapsed-session
  header appears, opens a `<details class="subagent-session-block">`;
  the next session header (any kind) closes it before opening its
  own. End-of-loop closes any still-open block. The summary line
  carries the session-header HTML (badges + title) so it stays
  visible when collapsed.

- New CSS rules for `.subagent-session-block`: dashed left border for
  visual nesting cue, `▶`/`▼` summary marker via pseudo-element, and
  hide the inner session-header div when collapsed (the summary
  already shows it) but reveal it when expanded for full context.

## Tests

- `test_subagent_sessions_collapsed_by_default` (browser): asserts
  the fixture renders with 2 `details.subagent-session-block`
  elements, neither carrying the `open` attribute.
- Browser-test `_render` switched to `load_directory_transcripts`
  (the path that triggers `_integrate_agent_entries`) — the prior
  `load_transcript` direct call wasn't producing subagent sessions
  in the rendered output, which the new test surfaced.
- Snapshots refreshed for the new `<details>` wrapping.

Implements monk-approved Item 1 of the PR3 plan (thread 2548): keep
subagent session block as a separate entity, just collapsed under
its anchor — not restructured into the DAG as a child of the
spawning tool_use.
Surfaces team membership on the project-listing index page so a user
scanning their projects can see at a glance which ran inside a
teammates team.

## Changes

- New SQL migration `005_session_team_name.sql`: `ALTER TABLE
  sessions ADD COLUMN team_name TEXT`. Backward-compatible — existing
  rows get NULL, and `SessionCacheData.team_name: Optional[str] =
  None` accepts that.

- `SessionCacheData.team_name` field. Cache INSERT and SELECT
  statements updated; archived-session reader updated. Both readers
  guard `"team_name" in row.keys()` so a fresh load against an
  unmigrated DB keeps working until the migration runs.

- Per-session `team_name` populated by the two cache-build paths in
  converter.py (`_build_session_data_from_messages` for the no-cache
  fallback path and the main `convert_to_html` aggregation loop for
  the cached path). First non-None `teamName` per session wins —
  same shape as `prepare_session_team_names`.

- Project summaries gain a `"team_names"` set: distinct `team_name`
  values across the project's sessions. Computed in all three
  project_summaries construction sites (cached, fresh, archived).

- `TemplateProject.team_names: list[str]` exposes the sorted list to
  the index template. Defaults to `[]` for projects with no teams,
  so the no-teammates render is unchanged.

- `index.html` adds a `👥 Team: <code>x</code>` stat row when one
  team is set, and `👥 Teams (N): <code>a</code>, <code>b</code>` when
  multiple. Conditionally rendered, so non-teammate projects look
  identical to before.

## Tests

- `test_template_project_carries_team_names`: project_summaries with
  `team_names` set surfaces sorted list on TemplateProject; missing
  key yields empty list.
- `test_session_cache_data_round_trips_team_name`: building
  SessionCacheData with team_name and round-trip; backward-compat
  case (no team_name field) loads with None.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds end-to-end "teammates" support: parses <teammate-message> blocks and Agent/Task tool flows, persists per-session team_name, synthesizes sessionId#agent-{agentId} for subagent sidechains, anchors and relocates sidechains in the DAG, and threads team metadata through HTML/Markdown renderers, CSS, migrations, tests, and docs.

Changes

Cohort / File(s) Summary
Data model & DB
claude_code_log/models.py, claude_code_log/cache.py, claude_code_log/migrations/005_session_team_name.sql
Add team_name: Optional[str] to session models and cache; add nullable team_name column; read/write logic updated to preserve compatibility with older rows.
Parsing & converter
claude_code_log/converter.py, claude_code_log/factories/teammate_factory.py, claude_code_log/factories/tool_factory.py
Parse and propagate teamName into session metadata; improve <teammate-message> attr capture; alias "Agent" to Task parsing; synthesize and re-parent subagent entries as sessionId#agent-{agentId}.
DAG & stitching
claude_code_log/dag.py
Treat AssistantTranscriptEntries with Task/Agent tool results as anchors; dedupe dead-end UUID collection to avoid duplicate node emission.
Rendering core
claude_code_log/renderer.py, claude_code_log/html/renderer.py
Precompute and thread per-session team_name; add prepare_session_team_names, _relocate_subagent_blocks, _populate_task_metadata; track task subjects/tool_use→task mappings; relocate subagent blocks inline and skip synthetic subagent headers.
HTML formatting & CSS
claude_code_log/html/system_formatters.py, claude_code_log/html/teammate_formatter.py, claude_code_log/html/templates/components/teammate_styles.css, claude_code_log/html/templates/index.html
Render session team badge and icon; render teammate JSON payloads as key/value grids; introduce .session-team-badge styles; add per-project team_names display; compact task/sendmessage titles and status pills.
Markdown renderer
claude_code_log/markdown/renderer.py
Add _inline_code to safely render team names with backticks; append “— Team: …” to session headers; include subagent-rewritten sessionIds in per-session exports.
Tool & teammate formatting
claude_code_log/html/teammate_formatter.py, claude_code_log/html/renderer.py
Refactor TaskCreate/TaskUpdate/SendMessage formatting (titles/body/status/owner badges), suppress empty tool-result cards, and render teammate JSON bodies as <dl>.
Tests & fixtures
test/*, test/test_data/real_projects/.../subagents/*
Add/adjust unit, snapshot, browser, and integration tests for teammates and subagents; add multiple subagent JSONL fixtures; update snapshots.
Docs & diagrams
dev-docs/teammates.md, dev-docs/FOLD_STATE_DIAGRAM.md, work/teammates-plan.md
Add comprehensive teammates developer docs, update fold/state diagram to treat teammate like user, and remove obsolete plan file.
Misc templates/snapshots
test/__snapshots__/*, test/test_teammates_*
Snapshot updates to reflect teammate badge, CSS, reordered/relocated sidechains, and markdown header changes.

Sequence Diagram(s)

sequenceDiagram
    participant JSONL as JSONL Transcripts
    participant Parser as converter.py
    participant DAG as dag.py
    participant Renderer as renderer.py
    participant Output as HTML/Markdown

    JSONL->>Parser: Parse entries (including <teammate-message>, Agent/Task tools)
    Parser->>Parser: Extract `team_name`, synthesize `sessionId#agent-{agentId}`, emit session/tree
    Parser->>DAG: Provide session nodes and anchors
    DAG->>DAG: Stitch anchors and dedupe dead-end UUIDs
    DAG->>Renderer: Deliver ordered messages + session_tree
    Renderer->>Renderer: prepare_session_team_names, collect task metadata, relocate subagent blocks
    Renderer->>Output: Render HTML/Markdown with team badges, compacted titles, and inlined sidechains
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

  • Support "teammates" #91 — Implements the teammates feature (parsing <teammate-message>, session.team_name propagation, subagent anchoring/relocation, rendering badges, and index aggregation).

Possibly related PRs

Suggested labels

enhancement

Poem

🐰
I hopped through transcripts, badges in paw,
Anchored each agent where the trunk once saw;
Team names stitched neat on every session's tree,
Tasks wear tidy titles — hop, joy, and glee! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: completing the teammates implementation with stitching, headers, and index support as the final piece (PR 3 of 3) of a series.
Docstring Coverage ✅ Passed Docstring coverage is 82.80% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/teammates-stitching

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…nt_teammates

Two fixes from monk's review of PR #125 (thread 2562, blocker on #1).

## #1 — Markdown session-header asymmetry (BLOCKER)

`SessionHeaderMessage` gained three fields in this PR (`team_name`,
`teammate_id`, `teammate_color`) but the Markdown side never picked
them up — `title_SessionHeaderMessage` rendered `📋 Session abc12345`
even for subagent sessions, so a Markdown reader had no way to tell
whose work was whose. This is the asymmetry monk's reviewer
heuristic #4 (HTML/Markdown mirror) was meant to catch.

Fix: extend `title_SessionHeaderMessage` to render
`📋 Subagent 🔵 \`alice\`` for subagent sessions (using the existing
`_teammate_marker` colored-circle convention from PR #122) and
append `— Team: \`<name>\`` for any team-active session. Backtick-
escape `team_name` defensively per heuristic #5 (boundary hygiene).

Verified on the fixture:
```
# 📋 Session `ef000000` — Team: `test-coverage`
# 📋 Subagent 🔵 `alice`
# 📋 Subagent 🟢 `bob`
```

Markdown snapshot refreshed; non-teammate sessions unchanged.

## #2 — agent_teammates session-scoping (consistency follow-up)

`RenderingContext.agent_teammates` was keyed by `agent_id` alone.
Combined transcripts merge multiple sessions — an agent_id collision
across sessions would silently cross-contaminate (matches the exact
shape of PR #122's `teammate_colors` issue we fixed there).

Fix: nest by spawning session — `dict[session_id, dict[agent_id,
{name, color}]]`. `_populate_agent_teammates` keys by the Task
tool_use's session; `_annotate_subagent_session_headers` derives the
spawning session from the synthetic
`{spawning_session}#agent-{agent_id}` format and uses it for the
lookup. Color fallback path also refactored to compute spawning_sid
once.

Practical collision risk on random ~64-bit agent_ids: negligible.
Architectural consistency with the precedent from #122: meaningful
— same shape, same correctness story.

## Tests

- Updated `test_agent_teammates_populated_from_task_pairs` for the
  new nested shape (outer key = spawning sid, inner = agent_id).
- New `test_agent_teammates_are_session_scoped` mirrors
  `test_teammate_colors_are_session_scoped` from PR #122: asserts
  the outer key is the main session, inner has both alice and bob,
  and the old top-level-agent_id shape no longer holds.
- Snapshot refresh picks up the new Markdown title shape.

CI green: pytest + browser + pyright + ty + ruff all clean.
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 25, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
test/__snapshots__/test_snapshot_html.ambr (1)

5076-5149: Snapshot updates look consistent with the feature.

This is generated snapshot output and the additions across all five fixtures are consistent: the new CSS rules for .session-team-badge / .session-teammate-badge and details.subagent-session-block match the PR's feature scope, and the rendered badge in hunk 6 (Team: test-coverage) reflects the team-name plumbing through to HTML. Whitespace-only hunks (1, 2, 4, 8, 10, 12) are benign artifacts of the template state-tracking change.

One minor observation worth verifying in the source CSS (not the snapshot): the transition: transform 0.15s ease on summary.subagent-session-summary::before won't actually animate anything, since the [open] rule swaps the content glyph () rather than applying a transform. If a smooth chevron rotation was intended, switching to a single glyph plus transform: rotate(90deg) under [open] would make the transition take effect; otherwise the declaration can be dropped.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/__snapshots__/test_snapshot_html.ambr` around lines 5076 - 5149, The
snapshot shows you added transition: transform 0.15s ease on
summary.subagent-session-summary::before but the open state
(details.subagent-session-block[open] >
summary.subagent-session-summary::before) replaces the content glyph instead of
transforming it, so the transition never runs; either remove the transition
declaration from summary.subagent-session-summary::before or change the approach
to use a single glyph and animate it via transform (e.g., keep
summary.subagent-session-summary::before content as one chevron and apply
transform: rotate(...) in details.subagent-session-block[open] >
summary.subagent-session-summary::before) — update the source CSS where
summary.subagent-session-summary::before and
details.subagent-session-block[open] > summary.subagent-session-summary::before
are defined.
claude_code_log/html/system_formatters.py (1)

97-120: Consolidate the palette constant + normalization with teammate_formatter.

_CC_PALETTE and the inline normalization in _subagent_teammate_badge ((color or "").strip().lower() then membership test) duplicate _PALETTE and _color_token() in claude_code_log/html/teammate_formatter.py. The inline comment already flags the drift risk ("kept in sync with html/teammate_formatter.py::_PALETTE"), and the two constants even use different names (_CC_PALETTE vs _PALETTE).

Consider extracting both into a small shared helper module (e.g. claude_code_log/html/palette.py) exporting the frozenset plus a normalize_color(color, fallback="gray") helper, and importing from both formatters. Also: defining _CC_PALETTE after the function that references it works (lookup is runtime) but reads better when placed above the first use.

♻️ Sketch of a shared helper
# claude_code_log/html/palette.py
from typing import Optional

PALETTE: frozenset[str] = frozenset(
    {"blue", "cyan", "green", "yellow", "orange", "red", "pink", "purple", "gray"}
)


def color_token(color: Optional[str]) -> Optional[str]:
    """Return the normalized palette token, or None if not recognized."""
    if not color:
        return None
    normalised = color.strip().lower()
    return normalised if normalised in PALETTE else None


def color_token_or(color: Optional[str], fallback: str = "gray") -> str:
    """Like color_token() but with a guaranteed-valid fallback for CSS-var injection."""
    return color_token(color) or fallback

Then in system_formatters.py:

-def _subagent_teammate_badge(teammate_id: str, color: Optional[str]) -> str:
-    ...
-    palette = (color or "").strip().lower()
-    palette_var = palette if palette in _CC_PALETTE else "gray"
+def _subagent_teammate_badge(teammate_id: str, color: Optional[str]) -> str:
+    ...
+    palette_var = color_token_or(color)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/system_formatters.py` around lines 97 - 120, The file
duplicates palette logic: extract the shared frozenset and normalization helpers
from teammate_formatter (/_PALETTE and _color_token/_color_token_or) into a new
module (e.g. claude_code_log/html/palette.py) exporting a PALETTE and
color_token/color_token_or functions, then update _subagent_teammate_badge to
import and use the shared color normalizer (instead of `(color or
"").strip().lower()` and _CC_PALETTE) and remove the local _CC_PALETTE; also
move any remaining palette constant definitions so they appear before first use
(ensure _subagent_teammate_badge calls the imported normalizer and uses the
fallback token like "gray").
claude_code_log/renderer.py (1)

2559-2565: Minor: first lookup of branch_sid in _team_names is effectively dead.

prepare_session_team_names keys by getattr(message, "sessionId", "") (line 882), which never produces a branch pseudo-session id of the {sid}@{uuid} form, so _team_names.get(branch_sid) is always None here and the result is determined entirely by the _team_names.get(original_sid or "") fallback. Either drop the first lookup or add a comment that it's defensive against a future change to the keying. Not user-visible — purely a readability nit.

♻️ Optional simplification
-                # Branches inherit the team_name of the original (pre-fork)
-                # session: a within-session fork doesn't change which team is
-                # active.
-                _team_names = session_team_names or {}
-                branch_team_name = _team_names.get(branch_sid) or _team_names.get(
-                    original_sid or ""
-                )
+                # Branches inherit the team_name of the original (pre-fork)
+                # session: a within-session fork doesn't change which team is
+                # active. session_team_names is keyed by raw sessionId, so
+                # look up via original_sid.
+                branch_team_name = (session_team_names or {}).get(original_sid or "")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 2559 - 2565, The first lookup
`_team_names.get(branch_sid)` in the `branch_team_name` computation is
effectively dead because `prepare_session_team_names` keys by `getattr(message,
"sessionId", "")` (via that function), so remove the unnecessary lookup to
simplify logic: change the assignment of `branch_team_name` to use only the
fallback `_team_names.get(original_sid or "")` (or keep the lookup but add a
clarifying comment in `renderer.py` near `prepare_session_team_names`,
referencing `branch_sid` and `original_sid`) and ensure you update the
surrounding comment about branches inheriting team_name to reflect the
simplified lookup; target symbols: `prepare_session_team_names`, `_team_names`,
`branch_sid`, `original_sid`, and the `branch_team_name` assignment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/converter.py`:
- Around line 2601-2608: The fallback path currently builds team_names_set by
scanning raw messages (variable messages) which can include warmup/agent-only
session teamNames and thus diverge from the cached path; update the fallback to
derive team names from the already-filtered sessions_data (or replicate the
per-session first-non-None walk used elsewhere) so both branches use the same
source (e.g., iterate sessions_data or cached_project_data.sessions.values(),
respect kept_session_ids) and populate team_names_set from each session's
selected teamName instead of scanning all messages.

In `@claude_code_log/html/templates/transcript.html`:
- Line 106: The template currently unconditionally closes a subagent collapsed
<details> when rendering any session header; update the conditional so the
collapse is only closed for non-branch session headers — i.e., change the if
that checks _state.in_collapsed_session to also verify the current header is not
a branch (use the existing flag on the render state that indicates branch
headers, e.g. _state.header_is_branch or similar), so only when
_state.in_collapsed_session is true AND the header is not a branch do you emit
the closing </details> and set _state.in_collapsed_session = False.
- Line 108: The new collapsed subagent sections use the class
.subagent-session-block (rendered when message.is_collapsed_session) but the
global "Toggle all details" control doesn't include that selector; update the
toggle-all logic (the script that queries details elements / the toggle-all
selector) to include .subagent-session-block so those <details> blocks are
targeted (e.g., add '.subagent-session-block' alongside existing details
selectors or expand the query selector to include both standard details and
.subagent-session-block, and ensure the toggle handler opens/closes them the
same way as other detail blocks).

In `@claude_code_log/markdown/renderer.py`:
- Around line 367-372: The current escape of backticks in content.team_name is
ineffective for CommonMark code spans; instead, detect the longest consecutive
run of backticks in content.team_name (use content.team_name and safe_team as
the target), choose a fence of backticks one longer than that run, and wrap the
team name with that fence (e.g., fence + content + fence); also add a leading
and/or trailing space inside the fence if the team name begins or ends with a
backtick or space to avoid ambiguity. Replace the existing replace("`", r"\`")
logic in the block that sets title (where safe_team is computed and title =
f"{title} — Team: `{safe_team}`") with this fence-based quoting logic so the
resulting inline code span is valid for any team_name.

---

Nitpick comments:
In `@claude_code_log/html/system_formatters.py`:
- Around line 97-120: The file duplicates palette logic: extract the shared
frozenset and normalization helpers from teammate_formatter (/_PALETTE and
_color_token/_color_token_or) into a new module (e.g.
claude_code_log/html/palette.py) exporting a PALETTE and
color_token/color_token_or functions, then update _subagent_teammate_badge to
import and use the shared color normalizer (instead of `(color or
"").strip().lower()` and _CC_PALETTE) and remove the local _CC_PALETTE; also
move any remaining palette constant definitions so they appear before first use
(ensure _subagent_teammate_badge calls the imported normalizer and uses the
fallback token like "gray").

In `@claude_code_log/renderer.py`:
- Around line 2559-2565: The first lookup `_team_names.get(branch_sid)` in the
`branch_team_name` computation is effectively dead because
`prepare_session_team_names` keys by `getattr(message, "sessionId", "")` (via
that function), so remove the unnecessary lookup to simplify logic: change the
assignment of `branch_team_name` to use only the fallback
`_team_names.get(original_sid or "")` (or keep the lookup but add a clarifying
comment in `renderer.py` near `prepare_session_team_names`, referencing
`branch_sid` and `original_sid`) and ensure you update the surrounding comment
about branches inheriting team_name to reflect the simplified lookup; target
symbols: `prepare_session_team_names`, `_team_names`, `branch_sid`,
`original_sid`, and the `branch_team_name` assignment.

In `@test/__snapshots__/test_snapshot_html.ambr`:
- Around line 5076-5149: The snapshot shows you added transition: transform
0.15s ease on summary.subagent-session-summary::before but the open state
(details.subagent-session-block[open] >
summary.subagent-session-summary::before) replaces the content glyph instead of
transforming it, so the transition never runs; either remove the transition
declaration from summary.subagent-session-summary::before or change the approach
to use a single glyph and animate it via transform (e.g., keep
summary.subagent-session-summary::before content as one chevron and apply
transform: rotate(...) in details.subagent-session-block[open] >
summary.subagent-session-summary::before) — update the source CSS where
summary.subagent-session-summary::before and
details.subagent-session-block[open] > summary.subagent-session-summary::before
are defined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cc050781-fd80-43a9-9c81-0a3f33f3256d

📥 Commits

Reviewing files that changed from the base of the PR and between f6c7a9e and cea8896.

📒 Files selected for processing (15)
  • claude_code_log/cache.py
  • claude_code_log/converter.py
  • claude_code_log/html/system_formatters.py
  • claude_code_log/html/templates/components/teammate_styles.css
  • claude_code_log/html/templates/index.html
  • claude_code_log/html/templates/transcript.html
  • claude_code_log/markdown/renderer.py
  • claude_code_log/migrations/005_session_team_name.sql
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • test/__snapshots__/test_snapshot_html.ambr
  • test/__snapshots__/test_snapshot_markdown.ambr
  • test/test_dag_integration.py
  • test/test_teammates_browser.py
  • test/test_teammates_parsing.py

Comment thread claude_code_log/converter.py Outdated
Comment thread claude_code_log/html/templates/transcript.html Outdated
Comment thread claude_code_log/html/templates/transcript.html Outdated
Comment thread claude_code_log/markdown/renderer.py Outdated
## #2 (Major) — branch headers don't close subagent <details>

The template's `</details>` closer fired on any session header, but
within-session-fork branch headers ARE session headers, so a fork
inside a subagent session would prematurely end the collapsed block.

Gate the close on `not message.is_branch_header`. The branch header
itself stays inside the open subagent block, which is correct: a
fork within the subagent is part of the subagent's work.

## #3 (Minor) — global "Toggle all details" missed subagent blocks

`collapsibleSelector` listed three details classes but not the new
`.subagent-session-block`, so the global toggle button left them
alone. Added to both selectors so the button affects all four.

## #4 (Minor) — CommonMark backtick inside code span

My defensive `replace("`", r"\`")` in `title_SessionHeaderMessage`
was security-theater: CommonMark explicitly does NOT honor backslash
escapes inside code spans (`` `foo\`bar` `` reads as code span `foo\`
+ literal `bar`, prematurely closing). The right recipe is adaptive
fence widening, same as `_code_fence` already does for fenced blocks.

New `_inline_code(value)` helper in markdown/renderer.py:
- counts the longest run of backticks in *value*
- uses a fence one tick longer
- pads with spaces when the value starts/ends with a tick (otherwise
  the leading/trailing tick fuses with the fence)

Also routed `_teammate_marker` through `_inline_code` so teammate
names with literal backticks (unlikely but possible) survive too.

## #1 (Minor) — fallback team_names didn't filter warmup sessions

Cached path reads from `cached_project_data.sessions`, which is
already filtered by `_update_cache_with_session_data` (drops warmup
+ empty/agent-only sessions). The no-cache fallback path iterated
raw `messages`, so a warmup session that happened to carry teamName
would surface in the project card via fallback but not via cache —
same data, same project, divergent annotation depending on cache
state.

Mirror the cached-path filter: skip `SummaryTranscriptEntry`,
coalesce agent synthetic sessionIds via `get_parent_session_id`,
and drop warmup sessions via `get_warmup_session_ids`. Use a
session_id keyed dict so first-sighting-wins per session, then
collect distinct values.

## Tests

- `test_inline_code_widens_fence_for_backticks`: 6 cases covering
  no backticks, single tick, run of 3, leading tick, trailing tick,
  empty value.
- `test_markdown_session_header_handles_backtick_in_team_name`:
  end-to-end — a `team_name="weird`team"` session header renders
  with the inner backtick *inside* a 2+ tick fence.
- `test_fallback_team_names_filters_warmup_sessions`: warmup +
  real session both with teamName; only the real one's team
  surfaces.

Reported by CodeRabbit on PR #125 thread; all 4 findings triaged
by bob and confirmed valid against the code.
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 25, 2026

@CodeRabbit review

Not sure why it decided to skip the review...

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

@cboos: Apologies for the skip — triggering a full review now!

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
claude_code_log/converter.py (1)

2601-2620: ⚠️ Potential issue | 🟡 Minor

No-cache fallback still diverges from cached path for empty/agent-only sessions.

The previous review flagged that this fallback aggregates team_names from raw messages without applying the same filtering used on the cached path. Warmup filtering has been added (good), but the cached path also drops sessions that lack a first_user_message (_update_cache_with_session_data, lines 1812-1814 — empty/agent-only sessions). This fallback still includes those, so a project rendered without cache will surface a Team: … annotation that the cached render won't, and vice-versa. Easy fix: intersect with sessions_data (already built at line 2559).

🛡️ Proposed fix
             warmup_for_teams = get_warmup_session_ids(messages)
             team_name_per_session: dict[str, str] = {}
             for _msg in messages:
                 if isinstance(_msg, SummaryTranscriptEntry):
                     continue
                 if not hasattr(_msg, "sessionId"):
                     continue
                 _sid = get_parent_session_id(getattr(_msg, "sessionId", ""))
                 if not _sid or _sid in warmup_for_teams:
                     continue
                 _tn = getattr(_msg, "teamName", None)
                 if _tn and _sid not in team_name_per_session:
                     team_name_per_session[_sid] = _tn
-            team_names_set: set[str] = set(team_name_per_session.values())
+            # Match cached path: drop empty/agent-only sessions
+            kept_session_ids = {s["id"] for s in sessions_data}
+            team_names_set: set[str] = {
+                tn
+                for sid, tn in team_name_per_session.items()
+                if sid in kept_session_ids
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 2601 - 2620, Fallback collection
of team names includes sessions that the cached path drops (empty/agent-only
sessions); modify the no-cache aggregation (the loop that builds
team_name_per_session using get_warmup_session_ids and get_parent_session_id) to
only consider session IDs present in sessions_data (i.e., intersect _sid with
sessions_data keys) before inserting into team_name_per_session so
team_names_set matches the cached path behavior.
🧹 Nitpick comments (4)
claude_code_log/html/system_formatters.py (2)

132-141: Variable shadowing — minor readability nit.

badges is assigned on line 133, then reassigned on line 141 by concatenating with teammate_badge_html. Functionally fine, but a single assignment reads more clearly.

♻️ Optional cleanup
-    badges = _team_badge(content.team_name) if content.team_name else ""
-    teammate_badge_html = (
-        _subagent_teammate_badge(content.teammate_id, content.teammate_color)
-        if content.teammate_id
-        else ""
-    )
-    # Compose both in display order (team badge first if both present —
-    # team scope is broader than the teammate within it).
-    badges = f"{badges}{teammate_badge_html}"
+    # Compose badges in display order — team scope is broader than the
+    # teammate within it, so team badge comes first.
+    team_html = _team_badge(content.team_name) if content.team_name else ""
+    teammate_html = (
+        _subagent_teammate_badge(content.teammate_id, content.teammate_color)
+        if content.teammate_id
+        else ""
+    )
+    badges = f"{team_html}{teammate_html}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/system_formatters.py` around lines 132 - 141, The
variable badges is assigned then reassigned which shadows the original value;
instead build the final badges in one assignment for clarity: compute
teammate_badge_html via _subagent_teammate_badge(content.teammate_id,
content.teammate_color) (or empty string) and _team_badge(content.team_name) (or
empty string) and then set badges once to the concatenation of those results
(e.g., badges = team_badge + teammate_badge_html or join a list), referencing
escaped_title, _team_badge, _subagent_teammate_badge, badges, and
teammate_badge_html to locate the code to change.

116-120: Duplicated palette constant — risk of drift.

The comment on line 117 explicitly notes _CC_PALETTE must be kept in sync with html/teammate_formatter.py::_PALETTE. Two sources of truth for the same allowlist will eventually diverge (someone adds a color in one place but not the other → silent gray fallback in only one renderer). Consider hoisting to a shared module so both formatters import the same constant.

♻️ Suggested layout

Move the palette to e.g. claude_code_log/html/teammate_palette.py:

# claude_code_log/html/teammate_palette.py
CC_PALETTE: frozenset[str] = frozenset(
    {"blue", "cyan", "green", "yellow", "orange", "red", "pink", "purple", "gray"}
)

Then both system_formatters.py and teammate_formatter.py import CC_PALETTE instead of defining their own copies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/system_formatters.py` around lines 116 - 120, The
duplicated palette constant _CC_PALETTE is a second source of truth for the same
allowlist used by teammate_formatter.py::_PALETTE; extract the palette into a
single shared symbol (e.g., CC_PALETTE) in a new module (e.g.,
claude_code_log/html/teammate_palette.py) and update system_formatters.py and
teammate_formatter.py to import that shared CC_PALETTE instead of defining their
own frozenset so both formatters reference one canonical set.
claude_code_log/html/templates/transcript.html (1)

109-111: Edge case: a header that is both a branch header and a collapsed-session header would nest <details>.

If a single SessionHeaderMessage ever has is_branch_header=True and is_collapsed_session=True simultaneously, line 109 would skip closing the outer <details> (branch guard) and line 111 would open a new one, producing nested subagent blocks. Probably not reachable today (branch pseudo-sessions and subagent sessions are constructed independently), but worth a defensive guard or an explicit invariant note.

🛡️ Optional defensive guard
-    {%- if _state.in_collapsed_session and not message.is_branch_header %}</details>{% set _state.in_collapsed_session = False %}{% endif %}
+    {%- if _state.in_collapsed_session and not message.is_branch_header %}</details>{% set _state.in_collapsed_session = False %}{% endif %}
     {%- if not message.is_branch_header %}<div class="session-divider"></div>{% endif %}
-    {%- if message.is_collapsed_session %}<details class="subagent-session-block"><summary class="subagent-session-summary">{{ html_content|safe }}</summary>{% set _state.in_collapsed_session = True %}{% endif %}
+    {%- if message.is_collapsed_session and not _state.in_collapsed_session %}<details class="subagent-session-block"><summary class="subagent-session-summary">{{ html_content|safe }}</summary>{% set _state.in_collapsed_session = True %}{% endif %}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/templates/transcript.html` around lines 109 - 111, A
SessionHeaderMessage that is both is_branch_header and is_collapsed_session can
cause nested <details> blocks; to prevent this, update the collapsed-session
opening condition to only open when the message is not a branch header (i.e.,
change the check where message.is_collapsed_session is used to require not
message.is_branch_header as well) and keep the existing
_state.in_collapsed_session state update; reference:
_state.in_collapsed_session, message.is_branch_header,
message.is_collapsed_session, SessionHeaderMessage.
test/test_teammates_parsing.py (1)

266-343: Test name implies it covers the prod fallback, but it tests a local copy of the logic; imports are then del'd to silence warnings.

test_fallback_team_names_filters_warmup_sessions imports convert_jsonl_to_html, json, and TemplateProject (lines 274–278) but never calls them — they're del'd at the bottom (339–343) "to suppress the unused-import warning." The test then re-implements the aggregation inline (lines 327–334). If the converter's fallback path ever drifts from this inline copy, this test won't catch it — defeating the regression guard the docstring promises.

Either drive the actual prod path (build a tmp_path project layout and call convert_jsonl_to_html / the no-cache project aggregator), or rename the test (e.g. test_warmup_aggregation_logic) and drop the misleading imports + del block.

♻️ Drop the dead imports and `del` block
-    from claude_code_log.converter import convert_jsonl_to_html
     from claude_code_log.utils import get_warmup_session_ids
     from claude_code_log.models import UserTranscriptEntry, UserMessageModel
-    from claude_code_log.renderer import TemplateProject
-    import json
@@
     assert team_names == ["real-team"]
-    # Suppress the unused-import warning — convert_jsonl_to_html is
-    # the prod entry point this regression covers.
-    del convert_jsonl_to_html
-    del json
-    del TemplateProject
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_teammates_parsing.py` around lines 266 - 343, The test
test_fallback_team_names_filters_warmup_sessions imports prod artifacts
(convert_jsonl_to_html, json, TemplateProject) but re-implements the aggregation
and then deletes those imports; either exercise the real no-cache fallback or
make this a unit test of the helper logic. Quick fix: rename the test to
test_warmup_aggregation_logic (or similar), update its docstring to reflect it
tests the aggregation helper only, and remove the unused imports
(convert_jsonl_to_html, json, TemplateProject) and the trailing del statements;
keep get_warmup_session_ids and the local team_name_per_session logic as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/renderer.py`:
- Around line 1840-1850: The parsing of synthetic session IDs is inconsistent:
change the split logic in the block using sid.find(marker) (marker "#agent-") so
it mirrors the other usage (rsplit("#agent-", 1)); compute spawning_sid and
agent_id by doing parts = sid.rsplit("#agent-", 1), set spawning_sid = parts[0]
and agent_id = parts[1] (or skip if no marker), then use
ctx.agent_teammates.get(spawning_sid, {}).get(agent_id) as before; this aligns
with how _populate_agent_teammates stores entries and fixes nested agent lookups
(alternatively, if nested agents are out-of-scope add a clear TODO comment
referencing _populate_agent_teammates and the asymmetric rsplit usage).

---

Duplicate comments:
In `@claude_code_log/converter.py`:
- Around line 2601-2620: Fallback collection of team names includes sessions
that the cached path drops (empty/agent-only sessions); modify the no-cache
aggregation (the loop that builds team_name_per_session using
get_warmup_session_ids and get_parent_session_id) to only consider session IDs
present in sessions_data (i.e., intersect _sid with sessions_data keys) before
inserting into team_name_per_session so team_names_set matches the cached path
behavior.

---

Nitpick comments:
In `@claude_code_log/html/system_formatters.py`:
- Around line 132-141: The variable badges is assigned then reassigned which
shadows the original value; instead build the final badges in one assignment for
clarity: compute teammate_badge_html via
_subagent_teammate_badge(content.teammate_id, content.teammate_color) (or empty
string) and _team_badge(content.team_name) (or empty string) and then set badges
once to the concatenation of those results (e.g., badges = team_badge +
teammate_badge_html or join a list), referencing escaped_title, _team_badge,
_subagent_teammate_badge, badges, and teammate_badge_html to locate the code to
change.
- Around line 116-120: The duplicated palette constant _CC_PALETTE is a second
source of truth for the same allowlist used by teammate_formatter.py::_PALETTE;
extract the palette into a single shared symbol (e.g., CC_PALETTE) in a new
module (e.g., claude_code_log/html/teammate_palette.py) and update
system_formatters.py and teammate_formatter.py to import that shared CC_PALETTE
instead of defining their own frozenset so both formatters reference one
canonical set.

In `@claude_code_log/html/templates/transcript.html`:
- Around line 109-111: A SessionHeaderMessage that is both is_branch_header and
is_collapsed_session can cause nested <details> blocks; to prevent this, update
the collapsed-session opening condition to only open when the message is not a
branch header (i.e., change the check where message.is_collapsed_session is used
to require not message.is_branch_header as well) and keep the existing
_state.in_collapsed_session state update; reference:
_state.in_collapsed_session, message.is_branch_header,
message.is_collapsed_session, SessionHeaderMessage.

In `@test/test_teammates_parsing.py`:
- Around line 266-343: The test test_fallback_team_names_filters_warmup_sessions
imports prod artifacts (convert_jsonl_to_html, json, TemplateProject) but
re-implements the aggregation and then deletes those imports; either exercise
the real no-cache fallback or make this a unit test of the helper logic. Quick
fix: rename the test to test_warmup_aggregation_logic (or similar), update its
docstring to reflect it tests the aggregation helper only, and remove the unused
imports (convert_jsonl_to_html, json, TemplateProject) and the trailing del
statements; keep get_warmup_session_ids and the local team_name_per_session
logic as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c27704e7-fdb8-4c07-8668-bece21a7349b

📥 Commits

Reviewing files that changed from the base of the PR and between f6c7a9e and 8e576d8.

📒 Files selected for processing (15)
  • claude_code_log/cache.py
  • claude_code_log/converter.py
  • claude_code_log/html/system_formatters.py
  • claude_code_log/html/templates/components/teammate_styles.css
  • claude_code_log/html/templates/index.html
  • claude_code_log/html/templates/transcript.html
  • claude_code_log/markdown/renderer.py
  • claude_code_log/migrations/005_session_team_name.sql
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • test/__snapshots__/test_snapshot_html.ambr
  • test/__snapshots__/test_snapshot_markdown.ambr
  • test/test_dag_integration.py
  • test/test_teammates_browser.py
  • test/test_teammates_parsing.py

Comment thread claude_code_log/renderer.py Outdated
Comment on lines +1840 to +1850
marker_idx = sid.find(marker)
if marker_idx < 0:
continue
# Synthetic format is `{spawning_session}#agent-{agent_id}`;
# split into the two halves to look up the (now session-scoped)
# agent_teammates map.
spawning_sid = sid[:marker_idx]
agent_id = sid[marker_idx + len(marker) :]
info = ctx.agent_teammates.get(spawning_sid, {}).get(agent_id)
if info is None:
continue
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for nested-agent fixtures (a subagent jsonl that itself uses Task to spawn another agent)
fd -t f -e jsonl . | xargs rg -l '"name": "Task"' 2>/dev/null | xargs rg -l 'isSidechain.*true' 2>/dev/null

# Confirm the converter only does a single `#agent-` rewrite per session id
ast-grep --pattern $'msg.sessionId = f"{$_}#agent-{$_}"'

# Look for any test of nested teammate spawning
rg -nP -C3 'nested.*(agent|teammate)|agent.*spawns.*agent'

Repository: daaain/claude-code-log

Length of output: 50382


🏁 Script executed:

# Check converter context around line 659
sed -n '650,670p' claude_code_log/converter.py

# Examine the nested-agent fixture structure
head -50 test/test_data/teammates/ef000000-0000-4000-8000-000000000001.jsonl

Repository: daaain/claude-code-log

Length of output: 15486


🏁 Script executed:

# Check if nested agents are explicitly marked as unsupported or future work
rg -n 'nested.*agent|agent.*spawn|future.*agent' claude_code_log/renderer.py

# Check _populate_agent_teammates to see if it even reaches nested agents
sed -n '1859,1924p' claude_code_log/renderer.py

# Look for any tests that involve nested agents (agent-within-agent)
rg -l 'nested.*agent|spawn.*agent' test/ 2>/dev/null | head -10

Repository: daaain/claude-code-log

Length of output: 4113


🏁 Script executed:

# Examine line 2656 to see the title generation parse behavior
sed -n '2640,2670p' claude_code_log/renderer.py

Repository: daaain/claude-code-log

Length of output: 1504


🏁 Script executed:

# Check converter logic more carefully — does it handle nested agents or prevent them?
sed -n '650,680p' claude_code_log/converter.py

# Look for any TODOs, FIXMEs, or comments about nested agents
rg -i 'todo|fixme|nested.*agent|future.*support' claude_code_log/

# Check phase-c work status
cat work/phase-c-agent-transcripts.md | grep -A5 -B5 'nested agents'

Repository: daaain/claude-code-log

Length of output: 8818


Subagent header lookup uses inconsistent parsing and will miss nested agents — confirm out-of-scope or fix.

The lookup at lines 1840–1850 uses sid.find("#agent-") to split on the first #agent- occurrence, while the title generation at line 2656 uses rsplit("#agent-", 1)[-1] (last segment). For single-agent sidechains today both produce correct results, but the asymmetry is fragile.

For nested agents (A spawns B), the issue becomes concrete:

  • B's synthetic sessionId becomes main#agent-A#agent-B (converter applies a single rewrite to each agent's sidechain entries)
  • The find split yields spawning_sid="main" and agent_id="A#agent-B", so the lookup agent_teammates["main"].get("A#agent-B") misses
  • But _populate_agent_teammates stores B under agent_teammates["main#agent-A"]["B"] because B's spawning session is actually main#agent-A

Phase-c work lists "Test with projects that have nested agents" as future work, so this may be out of scope for this PR. If so, add an explicit comment at line 1840 documenting that nested teammates are not yet supported. Otherwise, align the parse: either both use rsplit, or both use find, and ensure spawning_sid matches where _populate_agent_teammates records the entry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/renderer.py` around lines 1840 - 1850, The parsing of
synthetic session IDs is inconsistent: change the split logic in the block using
sid.find(marker) (marker "#agent-") so it mirrors the other usage
(rsplit("#agent-", 1)); compute spawning_sid and agent_id by doing parts =
sid.rsplit("#agent-", 1), set spawning_sid = parts[0] and agent_id = parts[1]
(or skip if no marker), then use ctx.agent_teammates.get(spawning_sid,
{}).get(agent_id) as before; this aligns with how _populate_agent_teammates
stores entries and fixes nested agent lookups (alternatively, if nested agents
are out-of-scope add a clear TODO comment referencing _populate_agent_teammates
and the asymmetric rsplit usage).

cboos and others added 4 commits April 25, 2026 11:25
CodeRabbit flagged an asymmetry between
`_annotate_subagent_session_headers` (uses `find("#agent-")` →
*first* occurrence) and `_render_messages` title generation (uses
`rsplit("#agent-", 1)` → *last* occurrence). For today's flat
synthetic ids `{trunk}#agent-{id}` both produce the same result;
the asymmetry only matters once nested teammates land. User asked
to fix it now so future support works correctly.

## What's actually wrong

Even today (single-segment ids), the lookup misses for nested
agents. Concretely: B is spawned from inside A's transcript. A's
sidechain entries get rewritten to session_id `main#agent-A`, so
the Task tool_use that spawns B has session_id `main#agent-A`
when `_populate_agent_teammates` records B → that lands at
`agent_teammates["main#agent-A"]["B"]`. But B's own sidechain
entries get rewritten to `main#agent-B` (single-level rewrite —
the converter doesn't know about A's nesting). The annotator
splits `main#agent-B` → spawning_sid="main", agent_id="B" →
looks up `agent_teammates["main"]["B"]` → MISS.

CodeRabbit's hypothesis assumed Claude Code would produce
multi-level synthetic ids like `main#agent-A#agent-B`. That's not
what today's converter produces. So the rsplit-only fix doesn't
suffice for today's scenario — but it's correct for the
multi-level case if/when it arrives.

## Fix

Two-tier lookup:

1. `rpartition("#agent-")` to split the synthetic id (matches the
   convention at line 2660). Direct lookup at the rpartition-
   derived spawning_sid hits for the multi-level future case.

2. Scan-fallback over `ctx.agent_teammates` outer dict if the
   direct lookup misses, finding any session that records this
   agent_id. Handles today's flat-id behavior correctly.
   agent_ids are random ~64-bit hex so the scan is unambiguous in
   practice (and bounded by the number of teammates, typically
   small).

## Test

`test_annotate_subagent_handles_nested_agents` pre-populates
`ctx.agent_teammates` with a nested layout (A spawned from main,
B spawned from A) and verifies three scenarios:

- Future deeply-nested id `main#agent-A#agent-B` → direct lookup
  finds B via rpartition.
- Today's flat id `main#agent-B` (where B is actually nested) →
  scan fallback finds B under `main#agent-A`.
- Sanity: regular non-nested A still resolves directly.

Reported by CodeRabbit on PR #125 thread r3141742134.
…xture fix)

Real-world testing on `ef958aa1-…/.jsonl` (the
experiments/worktrees teammates session) surfaced three fixable
gaps that the synthetic fixture didn't expose:

## 1. Agent tool used generic param-table rendering

Claude Code's experimental teammates feature emits the spawn tool
as `Agent`, not `Task`. `Agent` wasn't in `TOOL_INPUT_MODELS` /
`TOOL_OUTPUT_PARSERS`, so `create_tool_input("Agent", …)` returned
None and the renderer fell back to the generic
`format_ToolUseContent` (param-table key/value list). Likewise for
the result.

Alias `Agent` → `TaskInput` and `parse_task_output`. TaskInput
already carries `name` / `team_name` / `mode`; the additional
`isolation` field that real Agent inputs carry survives via
Pydantic's default `extra="ignore"`. Result: the prompt now renders
as collapsible Markdown via `format_task_input`, the result body as
collapsible Markdown via `format_task_output`, and the existing
`format_task_input_teammate_extras` / `format_task_output_teammate_extras`
surface the teammate badge + agent metadata as before.

## 2 + 3. Sidechain dedup didn't fire for Agent spawns

`_cleanup_sidechain_duplicates` checked
`tool_name == "Task"` only, so Agent-spawned subagents kept their
redundant first User sidechain message ("You are **alice**, …"
duplicate of the spawn prompt) and last Sub-assistant message
(duplicate of the tool result body).

Accept both `Task` and `Agent` via a small frozen set.

## Verified on the real fixture

Before (master at `f6c7a9e` + dev/teammates-stitching @ `d64a489`):
- 0 collapsible Markdown prompt blocks (all generic param tables)
- 18 raw "You are alice/bob/carol" prompt occurrences (every
  subagent's first User entry duplicating the spawn prompt)

After this commit:
- 6 collapsible Markdown prompt blocks (one per Agent tool_use)
- 0 generic param tables on Agent cards
- 4 raw "You are X" occurrences — and those are now legitimate
  body content (the rendered prompt itself), not duplicates.

`just ci` green; existing teammate tests still pass; snapshots
unchanged because the synthetic fixture uses `Task`, not `Agent`.

Naturally-threaded subagent rendering (the deeper issue surfaced
by the same real-world test — synthetic-sessionId machinery
fighting the parentUuid chain) is a separate refactoring still
to land on this branch.
Removes the teammate_id/teammate_color/collapsed_by_default fields on
SessionHeaderMessage and the rendering scaffolding that consumed them
(`<details class="subagent-session-block">` wrap, `_state` namespace,
`_subagent_teammate_badge`, `_populate_agent_teammates`,
`_annotate_subagent_session_headers`, `RenderingContext.agent_teammates`,
`is_collapsed_session`, `.session-teammate-badge` CSS, markdown teammate
branch in `title_SessionHeaderMessage`).

Keeps the synthetic `{trunk}#agent-{agentId}` sessionId rewrite — it is
required for `extract_session_dag_lines` to give each subagent its own
DAG-line attached to its anchor uuid (without it, sidechain entries
stay in the trunk session and `_collect_agent_anchors` scoops them all
up as fake anchors, polluting the trunk DAG-line).

Subagent session headers still appear (with `Subagent • <short_id>`
title), but plain — no `<details>` wrap, no teammate badge.

Verified rendering parity vs baseline on real-world fixture
(ef958aa1-...): same 30 inline sidechain assistants, same wave-1
anchoring, same wave-2 quirk. Render is 3.4 KB smaller.

Tests updates and snapshot refresh pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`_relocate_subagent_blocks` (renderer.py): post-pass after
`_reorder_paired_messages` that walks the message list, picks up each
subagent's chunks (identified by the synthetic
`{trunk}#agent-{agentId}` sessionId stamped by
`_integrate_agent_entries`), and splices each block right after the
trunk Task/Agent tool_result whose `meta.agent_id` matches.

Without it, pair-reordering brought all Task/Agent tool_use ↔
tool_result pairs adjacent and pushed every subagent thread to the
trunk tail. The level-stack then collapsed all subagent content under
whichever anchor sat last in render order — alice/bob/carol all ended
up as children of one tool_result.

After relocation, each subagent's content correctly nests under its
own tool_result, and `_cleanup_sidechain_duplicates` fires per-agent
(elides first-User and last-Sub-assistant duplicates for ALL agents).

Also: `_render_messages` no longer creates an empty SessionHeaderMessage
for synthetic subagent sessions — the chunks are inlined under the
trunk by the relocation pass, so the standalone header was dead weight
parked at the end of the document.

Tests:
- removed obsolete `test_annotate_subagent_handles_nested_agents`,
  `test_agent_teammates_are_session_scoped`,
  `test_subagent_session_headers_carry_teammate_badge`,
  `test_agent_teammates_populated_from_task_pairs` (all targeted
  removed `agent_teammates` / `teammate_id` fields).
- `test_agent_session_gets_its_own_header` →
  `test_agent_session_has_no_separate_header` (asserts the new
  inline-only behavior).
- snapshot refresh for representative_messages, edge_cases,
  multi_session, individual_session, teammates_fixture.

Verified on user fixture (ef958aa1...): all 6 wave-1+wave-2 subagents
correctly nest under their respective tool_results; sidechain User
prompts dropped from 4 → 0; Sub-assistant final responses 32 → 28
(per-agent dedup now fires).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 25, 2026

Well, I had to steer everything back on track. Not sure if we're again seeing a "dumbing down" bug in Claude Code, or if it just painted itself into a corner, or if this was genuinely difficult, but I'd have expected more smarts, as the structure in the sample data I provided him with was quite clear (but there was some interaction with message pairing that it didn't see by itself). Anyway, now it looks good ;-)

Title compaction:
  Before: "🛠️ TaskCreate" + body { Subject, Active form, Description }
          followed by tool_result card "Task ID #1, Subject ...".
  After:  "🛠️ Task #1 Research plan mode (alice) [created]"
          + body { Active form, Description }, no tool_result card.

  Same shape for TaskUpdate ("[updated]"); the input's status renders
  as a small-caps pill ("IN PROGRESS" in blue / "COMPLETED" in green
  / etc.) reusing the TaskList palette for visual consistency.

Implementation:
- `RenderingContext` gains `task_subjects` (sid → task_id → subject)
  and `task_id_for_tool_use` (sid → tool_use_id → task_id), populated
  by `_populate_task_metadata` from TaskCreate tool_results
  (definitive) and TaskList rows (snapshot fallback). Session-scoped
  to mirror `teammate_colors`.
- `HtmlRenderer` snapshots both maps and adds `title_TaskCreateInput`
  / `title_TaskUpdateInput` that compose the new title via a shared
  `_task_title` helper.
- `format_taskcreate_input` drops the now-redundant Subject row;
  `format_taskcreate_output` returns "" (the title carries the id).
- `format_taskupdate_input` drops the Task row, status renders via
  new `_status_pill` helper; `format_taskupdate_output` returns ""
  unless there's a from→to transition (which the title can't show).
- New `.task-action` CSS for the muted `[created]` / `[updated]` tag.
- Generalized `.task-status.status-*` color rules (lifted out of
  `.task-list` selector) so the pills render in both TaskList rows
  and TaskCreate / TaskUpdate cards.

Empty-pair suppression:
  `_flatten_preorder.visit` skips messages whose title + html + children
  are all empty — kills the bare-timestamp tool_result card that
  TaskCreate / TaskUpdate now produce. To avoid stranding the surviving
  tool_use with a flat-bottom `pair_first` border, the skip-path also
  clears the partner's `pair_last` (and `pair_duration`) so it loses
  its pair role.

Snapshot refresh: TaskCreate / TaskUpdate fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@daaain
Copy link
Copy Markdown
Owner

daaain commented Apr 25, 2026

Were you using the team to develop these PRs? I wasn't "brave" enough to try the team, still feels like it's worth doing planning in each session to get good results

cboos and others added 2 commits April 25, 2026 20:56
Title compaction:
  Before: "🛠️ SendMessage" + body { To, Type, Message } where the
          message body was an indented monospaced quote inside the
          orange-bordered teammate-tool-card.
  After:  "✉️ SendMessage to <recipient_badge>"
          + body = the message content rendered directly as markdown
          (collapsible when long).

Implementation:
- New `title_SendMessageInput` in `HtmlRenderer` returns
  "✉️ SendMessage to <badge>" — the leading ✉️ replaces the default
  🛠️ via the template's "title already has an emoji" check.
- `format_sendmessage_input` drops the To/Type rows (To moved to
  title) and renders the message body via `render_markdown_collapsible`
  with a `send-message-body` class. Type is surfaced only when it's
  not the default "message" (e.g. a future "signal" variant).
- Output card (Status / Message footer) unchanged.

Snapshot refresh: teammates fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/teammates-plan.md

The original as-built doc was written against `dev/teammates-stitching`
at d64a489 — before the in-branch refactor that landed as fd993f2,
27e43fb, fdd28ec, 7c364bc, 47bc50e. This update reconciles the doc with
the post-refactor state:

- Frame the trilogy + post-merge refactor in the intro.
- §1.1: note `Agent` aliased to `Task` for the teammates spawn tool;
  `_cleanup_sidechain_duplicates` broadened to {Task, Agent}.
- §2.5: SessionHeaderMessage.{teammate_id, teammate_color,
  collapsed_by_default} documented as dropped (commit 27e43fb).
- §3.3: `Agent` registered in TOOL_INPUT_MODELS / TOOL_OUTPUT_PARSERS.
- §4.3: rewritten — synthetic sessionId is kept (rationale spelled out),
  plus the new `_relocate_subagent_blocks` post-pass (fdd28ec) that
  splices each subagent's chunks under its trunk Task/Agent anchor.
- §5.3: replaced the removed `RenderingContext.agent_teammates` cache
  with the new `task_subjects` / `task_id_for_tool_use` maps from
  `_populate_task_metadata` (7c364bc).
- §5.4: pipeline order updated — `_relocate_subagent_blocks` between
  reorder and build-hierarchy; `_populate_agent_teammates` and
  `_annotate_subagent_session_headers` removed.
- §6.1: added Task title compaction (`title_TaskCreateInput` /
  `title_TaskUpdateInput` / `_task_title` / status pill) and empty-pair
  suppression (with the partner pair_last clearing detail). Replaced
  the two-pill session-header section with the team-only badge, since
  subagent session headers no longer exist. CSS list updated
  (`.task-status.status-*` lifted out of `.task-list`, new
  `.task-action`; `.session-teammate-badge` and `.subagent-session-block`
  removed).
- §6.1: `Agent` aliased to TaskInput notes; SendMessage compaction
  (47bc50e — title carries `to <recipient_badge>`, body collapsible
  Markdown via `render_markdown_collapsible`).
- §6.2: simplified `title_SessionHeaderMessage` (no Subagent variant
  after 27e43fb).
- §8: 4 obsolete tests noted as removed in fdd28ec; 1 renamed.
- §9: coverage table updated for the badge change.
- §10.4: real-world testing pass results captured (the actual driver
  for the refactor); ground-rules: keep this section concrete.
- §11: code references updated to the post-refactor surface.

work/teammates-plan.md removed: the planning doc was the early shape of
this work; the as-built reference now subsumes its purpose, and keeping
both invites drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 25, 2026

No, I used one "traditional" session for the implementation. But I used a sample session with teammates as a reference to exercise the analysis and the rendering tests. Come to think of it, that one is just under 300k, so it wouldn't add much to our test/test_data/real_projects/, and could be useful as a reference.

Oops, forgot the subagents/ ... that makes it more like 2M in total...

@daaain
Copy link
Copy Markdown
Owner

daaain commented Apr 25, 2026

No, I used one "traditional" session for the implementation. But I used a sample session with teammates as a reference to exercise the analysis and the rendering tests. Come to think of it, that one is just under 300k, so it wouldn't add much to our test/test_data/real_projects/, and could be useful as a reference.

Oops, forgot the subagents/ ... that makes it more like 2M in total...

I think it would still be good to add as more recent and quite different test data!

cboos and others added 5 commits April 25, 2026 21:42
- §5.3 / §6.1: drop fictitious ``(alice)`` parenthetical from the
  ``Task #N <subject> [created]`` title example. The ``(alice)``
  appearing in real renders is part of the user's subject string
  ("Research plan mode (alice)"), not a separate formatter field.
- §6.1: remove the misleading ``<STATUS_PILL>`` after ``[updated]`` in
  the title example. The status pill renders in the body card via
  ``format_taskupdate_input`` / ``_status_pill``, not in the title.
  Title generation ends at ``[updated]``.
- §9: update the "Whole transaction visible" coverage row — it still
  said "shown in collapsed ``<details>`` block" but the wrap was
  dropped in commit ``27e43fb``. Now reads "content threaded inline
  under the spawning Task/Agent tool_result via
  ``_relocate_subagent_blocks`` (commit ``fdd28ec``)".
- §5.4 pipeline order: insert ``_build_message_tree`` between
  ``_build_message_hierarchy`` and ``_cleanup_sidechain_duplicates``,
  and clarify that the dedup operates on the tree (not the flat list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a real-world teammates transcript as a test fixture under
``test/test_data/real_projects/-experiments-worktrees/``. The session
``ef958aa1-…`` is the same one that drove the post-merge refactor
(commits ``fd993f2`` → ``47bc50e``): two waves of three
sequentially-spawned subagents, all linked via the structured
``toolUseResult.agentId`` path, with ``Agent`` (not ``Task``) as the
spawn tool name.

The fixture is automatically processed by the existing
``test_process_all_real_projects`` / ``test_cache_creation_all_projects``
sweeps (no per-project plumbing needed).

New ``TestExperimentsWorktreesTeammates`` class with six focused
regression checks:

- ``test_directory_loader_links_all_six_subagents`` — ``load_directory_
  transcripts`` picks up every ``subagents/agent-*.jsonl`` and stamps
  the synthetic ``{trunk}#agent-<id>`` sessionId on each.
- ``test_combined_html_renders_without_errors`` — smoke test, output is
  non-trivial.
- ``test_subagents_relocated_under_their_anchors`` — pre-relocation, all
  sidechain assistants ended in the same anchor d-id; post-relocation
  there must be ≥6 distinct anchors (one per subagent).
- ``test_no_subagent_collapse_markup`` — pins the absence of
  ``subagent-session-block`` and ``session-teammate-badge`` markup
  (dropped in ``27e43fb``).
- ``test_no_orphan_subagent_session_headers`` — pins the absence of
  ``data-session-id='…#agent-…'`` on rendered session-headers (dropped
  in ``fdd28ec``).
- ``test_compact_tool_titles`` — verifies TaskCreate / TaskUpdate use
  the ``[created]`` / ``[updated]`` action tag and SendMessage carries
  the ✉️ emoji.

Bug fix surfaced by the new tests:
``_relocate_subagent_blocks`` was filtering anchors on
``content.tool_name in ("Task", "Agent")``, but two of the six
trunk tool_results in the fixture had ``tool_name=None`` because the
tool_factory's ``tool_use_context`` lookup occasionally fails to
populate it. Loosened the filter to accept any trunk tool_result with
``meta.agent_id`` set — the agent_id alone is sufficient evidence
that this is a Task/Agent anchor. Without this fix, two subagents'
content (carol1 + carol2) got stranded under unrelated trunk messages.

Cleanup:
- Drop unused ``_quote_text_block`` helper from teammate_formatter.py
  (its only call site was removed when SendMessage moved to markdown).
- Suppress pyright private-import warning on the renderer's
  cross-module ``_teammate_badge`` reference.

Test count: 1012 → 1018.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… check

``test_subagent_sessions_collapsed_by_default`` was pinning the
old ``<details class="subagent-session-block">`` wrap that was
dropped in commit ``27e43fb`` (and the standalone subagent session
header dropped in ``fdd28ec``). It was caught by CI on PR #125
after the post-merge refactor pushed.

Replaced with ``test_subagent_content_inlined_under_anchor``:

- asserts the legacy collapse markup is gone
  (``details.subagent-session-block`` and ``.session-teammate-badge``
  counts are 0).
- asserts the subagents' sidechain assistant rows are present
  inline (≥2 for the alice + bob fixture), proving
  ``_relocate_subagent_blocks`` threaded them under their spawning
  anchors without needing an expand action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long Task descriptions routinely include numbered lists, inline code
spans, and multi-paragraph plans. Rendering them through
``escape_html`` flattened them into a single wall of text. Routing the
field through ``render_markdown_collapsible`` (same helper already
used for Task prompts, EnterPlanMode plans, and the SendMessage body)
preserves the structure and collapses long content with a preview.

Snapshot refresh: teammates fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two glitches surfaced by reviewing a richer real-world teammates
session:

1. **Attribute parser truncating on literal ``>``.**

The attribute-run regex was ``[^>]*``, which stopped at the first
``>`` regardless of quoting context. So an opening tag like

    <teammate-message ... summary="coverage 15% -> 96%">

was cut at the ``->`` arrow, leaking the tail of the summary into
the body and rendering the rest of the attribute as garbled DOM.

Per the XML 1.0 spec [10] ``AttValue ::= '"' ([^<&"] | Reference)*
'"'`` — ``>`` is *legal* inside a quoted attribute value; only
``<``, ``&``, and the matching quote are forbidden. The new
attribute-run pattern walks ``\s+name(\s*=\s*"[^"<&]*"|'[^'<&]*')?``
pairs explicitly so quoted values can carry ``>``.

2. **JSON-shaped notification bodies render as a code blob.**

Some teammate notifications come through as compact JSON payloads
(e.g. ``{"type":"idle_notification","from":"alice",
"timestamp":"…","idleReason":"available"}``) rather than Markdown
prose. Routed through ``render_markdown`` they collapsed into a
single inline-code blob.

Added a cheap heuristic in ``format_teammate_content``: if the body
parses as a JSON object (object-only, framed by ``{`` ... ``}``),
render it as a ``<dl class="teammate-json">`` key:value list with
``<code>`` cells for scalars. Anything else (including invalid JSON,
arrays, scalars) still routes through the Markdown renderer.

New CSS rules style ``.teammate-body .teammate-json`` as a compact
two-column grid keeping the existing card styling.

Tests:
- ``test_summary_with_unescaped_gt_is_preserved`` — regression for
  the attribute parser fix.
- ``test_json_body_renders_as_key_value_dl`` — covers the
  notification-payload pathway.
- ``test_non_json_body_falls_back_to_markdown`` — covers the
  fall-through when something starts with ``{`` but isn't valid JSON.

Snapshot refresh: teammates fixture (carries the now-correctly-
parsed mixed-teammates entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 25, 2026

My -experiments-worktrees is not exercising the full "teammates" features (#91). I have another session that does, but it's a bit too large to include as test. Anyway, now that -experiments-worktrees renders fine, I'll continue to polish the support here.

cboos and others added 5 commits April 25, 2026 22:59
The "Active form" and "Description" labeled rows added pure ceremony
when the two fields read naturally as a heading + body. Stitch them
into one Markdown blob — ``activeForm`` becomes a leading
``***bold-italic***`` heading, ``description`` follows as the main
content — and route through ``render_markdown_collapsible`` so long
plans (with numbered lists, code spans, etc.) keep their structure.
Either field on its own degrades naturally.

Snapshot refresh: teammates fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``_get_message_hierarchy_level`` only matched ``msg_type == "user"``,
but ``TeammateMessage.message_type`` returns ``"teammate"``. So a
sidechain Teammate (the team-lead's wrapped prompt to a teammate,
parsed as TeammateMessage from the subagent's first User entry) fell
through to the default level 1.

Level 1 popped the level-stack down past the spawning Task
(level 3). The next Task tool_use then ended up nested as a
*descendant* of the Teammate, swallowing the rest of that Task's
sidechain content into the wrong slot. Visible symptom: each Task
card after the first looked empty (no fold buttons), and folding the
preceding Teammate revealed the swallowed Task underneath.

Fix: dispatch ``msg_type in ("user", "teammate")`` together — both
share the same level (1 for trunk, 4 for sidechain). A
TeammateMessage IS a User entry whose content is a stack of
``<teammate-message>`` blocks; it just gets a distinct
message_type so the renderer can pick a different visual.

The dedup pass intentionally still skips TeammateMessage — the
team-lead's wrapped prompt is what the user wants visible (the
right-aligned "Teammate" card under the Task), not elided like the
plain UserTextMessage duplicate.

Tests:
- ``test_sidechain_teammate_at_level_4`` — direct unit test on
  ``_get_message_hierarchy_level`` for both trunk (level 1) and
  sidechain (level 4) TeammateMessage.

Snapshot refresh: teammates fixture (HTML + Markdown).

Doc: ``dev-docs/FOLD_STATE_DIAGRAM.md`` level table + decision-logic
table updated to call out the ``teammate`` type alongside ``user``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Windows CI run on PR #125 failed with
``UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f in
position 74518``. The combined transcript contains UTF-8 (the ✉️ in
the SendMessage title, em-dashes in the fixture, etc.) and
``Path.read_text()`` defaults to the platform locale — cp1252 on
Windows.

Match the pattern already used elsewhere in the file
(``read_text(encoding="utf-8")``).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ``_LOW_KEEP_TOOLS`` whitelist (WebSearch / WebFetch / Task) was
missing ``Agent`` — the teammates-feature spawn alias for Task
(registered in the tool_factory as ``"Agent": TaskInput``). Real
teammate transcripts emit ``Agent`` rather than ``Task`` for the
spawn, so at ``--detail low`` every teammate spawn-and-result pair
silently dropped out of the rendering.

Adding ``Agent`` to the set keeps Task/Agent symmetric across all
levels and surfaces the teammate spawns at low detail alongside the
existing Web tools. Doc-string for ``_filter_by_detail`` updated to
mention the alias.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests in ``TestExperimentsWorktreesTeammates``:

- ``test_low_detail_preserves_agent_spawns`` — locks in commit
  ``dc134bf``: ``Agent`` is in ``_LOW_KEEP_TOOLS``, so its tool_use
  titles + tool_result cards survive ``--detail low``. Threshold is
  ≥4 (not 6) because the directory loader's DAG walker prunes two of
  the six Agent spawns (both carols) under nested-Task fork
  patterns — pre-existing issue, separate from the LOW filter.
  Negative side: TaskCreate / TaskUpdate (not whitelisted) get
  stripped, so their ``[created]`` / ``[updated]`` action tags are
  absent.
- ``test_minimal_detail_strips_agent_spawns`` — locks in the other
  half of the contract: ``--detail minimal`` strips every tool,
  ``Agent`` included. Web tools (kept at LOW) also gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos
Copy link
Copy Markdown
Collaborator Author

cboos commented Apr 25, 2026

The latest tests uncovered some deeper issues... still WIP.

The directory loader was dropping two of the six ``Agent`` tool_uses
in the ``-experiments-worktrees`` fixture (both carols, in nested
positions wave-1 carol1 + wave-2 carol2). Single-file rendering of the
same JSONL kept all six — the bug only triggered when
``load_directory_transcripts`` ran the integration pass.

Root cause is in ``dag._stitch_tool_results``:

The fixture has each Agent's tool_use chained as parent of the next
Agent's tool_use, with each Agent's tool_result as a sibling of the
next tool_use:

    alice_tu (assistant)
    ├── bob_tu (assistant)        ← nested spawn
    │   ├── carol_tu (assistant)
    │   │   └── carol_tr (user, agentId=carol)
    │   └── bob_tr (user, agentId=bob)
    └── alice_tr (user, agentId=alice)

At alice_tu, the fork stitch pairs ``user_with_cont = [alice_tr]``
(the chain continuation, runs through the trunk attachment into the
next user turn) with the ``bob_tu`` assistant subtree on the
"dead-end" side. ``_collect_agent_anchors`` then surfaced the
agentId-bearing user descendants (carol_tr, bob_tr) so the subagent
sessions stay reachable — but the *spawning assistants* in between
(bob_tu, carol_tu) silently disappeared into ``_collect_descendants``
along with their tool_use blocks.

Fix: extend ``_collect_agent_anchors`` to also surface
``AssistantTranscriptEntry`` whose content carries a ``Task`` /
``Agent`` ``ToolUseContent`` block. Add a dedup pass on the merged
``dead_ends`` list since assistant_children and the new anchor list
can now overlap.

Verified on the experiments-worktrees fixture: all six Agent
tool_uses now appear in the directory-mode rendering (matches the
single-file rendering).

Test ``test_low_detail_preserves_agent_spawns`` threshold raised
from ≥4 to ≥6 to lock in the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboos cboos marked this pull request as ready for review April 25, 2026 22:36
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
claude_code_log/converter.py (1)

2600-2619: ⚠️ Potential issue | 🟡 Minor

Keep fallback team_names scoped to the same kept sessions as the cached path.

sessions_data has already dropped warmup and empty/agent-only sessions, but this branch rebuilds team_name_per_session from raw messages. A teamName on a session that never makes it into sessions_data will still leak into the project-card annotation here, so cached and no-cache indexes can still disagree.

Possible fix
-            warmup_for_teams = get_warmup_session_ids(messages)
+            kept_session_ids = {s["id"] for s in sessions_data}
             team_name_per_session: dict[str, str] = {}
             for _msg in messages:
                 if isinstance(_msg, SummaryTranscriptEntry):
                     continue
                 if not hasattr(_msg, "sessionId"):
                     continue
                 _sid = get_parent_session_id(getattr(_msg, "sessionId", ""))
-                if not _sid or _sid in warmup_for_teams:
+                if not _sid or _sid not in kept_session_ids:
                     continue
                 _tn = getattr(_msg, "teamName", None)
                 if _tn and _sid not in team_name_per_session:
                     team_name_per_session[_sid] = _tn
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 2600 - 2619, The fallback team
name collection from raw messages can include sessions that were dropped
earlier, causing cached and no-cache annotations to differ; modify the loop that
builds team_name_per_session so it only considers parent session IDs that are
present in the already-filtered sessions_data (or its keys) and not in
warmup_for_teams, i.e., derive allowed_session_ids from sessions_data before
iterating messages and skip any _sid not in that set; keep the existing checks
(SummaryTranscriptEntry, hasattr(sessionId), get_parent_session_id,
warmup_for_teams) and only add to team_name_per_session when _sid is in
allowed_session_ids.
🧹 Nitpick comments (2)
test/test_teammates_parsing.py (2)

351-375: Docstring overstates coverage — no serialization actually happens.

The docstring says team_name "survives serialisation", but the test only constructs SessionCacheData and reads the attribute back. There's no model_dump/model_validate (or JSON) round-trip, so a regression in (de)serialization (e.g., team_name dropped from the dump, or aliasing change) wouldn't be caught.

♻️ Make the round-trip real
     s = SessionCacheData(
         session_id="abc",
         first_timestamp="t",
         last_timestamp="t",
         message_count=1,
         first_user_message="hi",
         team_name="my-team",
     )
-    assert s.team_name == "my-team"
+    assert s.team_name == "my-team"
+    # Real round-trip through Pydantic JSON serialization.
+    s_round = SessionCacheData.model_validate_json(s.model_dump_json())
+    assert s_round.team_name == "my-team"

Same treatment for the s2 (default-None) case is worth adding to lock in backward-compat against caches written before the field existed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_teammates_parsing.py` around lines 351 - 375, The docstring is
wrong: the test never serialises/deserialises. Update the test for
SessionCacheData to perform a real round-trip for both cases (s with team_name
and s2 without) by calling the model's dump (e.g., model_dump() or json export)
and then reloading with model_validate() (or equivalent parse/validate method)
and asserting the reloaded instance preserves team_name == "my-team" for s and
team_name is None for s2; keep references to the SessionCacheData constructor, s
and s2, and the model_dump/model_validate (or JSON export/parse) calls so the
test fails if serialization drops or renames the field.

241-318: Test mirrors the aggregation logic instead of exercising it — and dels the production entry point.

The docstring states this is a regression for the no-cache fallback path in convert_jsonl_to_html/converter.py, but the test:

  1. Imports convert_jsonl_to_html, json, and TemplateProject (lines 249–253) only to discard them via del at the end (lines 316–318). The del lines are a code smell that disguises unused imports as "intentional".
  2. Re-implements the warmup-filter aggregation locally (lines 302–309). If converter.py ever regresses by dropping the if sid in warmup_ids: continue guard, this test will still pass because it never calls into production code.

Either invoke the real fallback aggregator (e.g. import the helper that performs the per-session teamName collection in the no-cache path and call it on [warmup_msg, real_msg]), or — if no helper is exposed — drive convert_jsonl_to_html end-to-end on a tmp dir and assert the rendered project card. Otherwise rename the test to make clear it's only documenting the intended shape of the filter, not guarding it.

♻️ Minimal cleanup: drop the dead imports/`del`s
-    from claude_code_log.converter import convert_jsonl_to_html
-    from claude_code_log.utils import get_warmup_session_ids
+    from claude_code_log.utils import get_warmup_session_ids
     from claude_code_log.models import UserTranscriptEntry, UserMessageModel
-    from claude_code_log.renderer import TemplateProject
-    import json
@@
-    assert team_names == ["real-team"]
-    # Suppress the unused-import warning — convert_jsonl_to_html is
-    # the prod entry point this regression covers.
-    del convert_jsonl_to_html
-    del json
-    del TemplateProject
+    assert team_names == ["real-team"]

Better still, replace the local mirror with a call into the actual fallback helper so the test fails if the warmup filter is removed from production.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_teammates_parsing.py` around lines 241 - 318, Test currently
re-implements the no-cache team_name aggregation and imports
convert_jsonl_to_html/json/TemplateProject only to del them; instead wire the
test to exercise production code: remove the dead imports and del statements,
and replace the local aggregation block in
test_fallback_team_names_filters_warmup_sessions with a call to the real
fallback aggregator in converter.py (the helper that collects per-session
teamName values used by convert_jsonl_to_html); if no such helper is exported,
drive convert_jsonl_to_html end-to-end on a temporary input (using the
warmup_msg and real_msg) and assert the rendered project card contains only
"real-team" so the test fails if the warmup filter is removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@claude_code_log/converter.py`:
- Around line 659-661: The per-message sessionId is being rewritten to include
an agent suffix (in the block that assigns msg.sessionId and uses
agent_anchors), which breaks MarkdownRenderer.generate_session()'s exact match
on session_id; preserve the original session id by recording it before mutating
and expose it for per-session exports (e.g., set a new field like
msg.parentSessionId or msg.originalSessionId with the pre-suffix value) so
generate_session() can match parentSessionId == session_id (or update
generate_session() to check parentSessionId when present). Locate the assignment
to msg.sessionId and agent_anchors in the converter and add the extra
preserved-field; then update MarkdownRenderer.generate_session() (the function
using session_id) to prefer msg.parentSessionId == session_id when that field
exists or fall back to msg.sessionId == session_id.

In `@claude_code_log/factories/teammate_factory.py`:
- Around line 36-49: The current _ATTR_RUN regex forbids literal '&' inside
quoted attribute values (patterns like "[^\"<&]*" and '[^\'<&]*'), causing
<teammate-message ...> blocks with values like summary="tests & docs done" to be
missed; update _ATTR_RUN so the quoted-value subpatterns only disallow the
opening angle bracket and the matching quote (e.g. use "[^\"<]*" and '[^\'<]*'
instead), then keep _BLOCK_RE as-is so <teammate-message ...> blocks with
literal '&' in attributes are matched again.

In `@claude_code_log/html/teammate_formatter.py`:
- Around line 352-374: format_taskupdate_output currently returns an empty
string whenever output.status_change is None, which hides failed or no-op
TaskUpdate results; update format_taskupdate_output so that when status_change
is None it still returns a non-empty card (using _render_card and the
"task-update-card" type) that surfaces failure/no-op details from the
TaskUpdateOutput (e.g., an error/message field or a "No status change" row)
instead of returning "", preserving the visible failure signal that complements
the tool_use title.

In `@dev-docs/FOLD_STATE_DIAGRAM.md`:
- Around line 172-175: Update the "Sidechain (Sub-agent) Handling" section to
match the behavior described earlier: state that plain sidechain user messages
are processed by the level dispatch and only pruned afterwards by the
_cleanup_sidechain_duplicates pass (so they are not skipped upfront), while
TeammateMessage-shaped sidechain users (team-lead wrapped prompts) are retained
at Level 4; also reaffirm that system-info/system-warning map to Level 3 and
system (commands/errors) without info/warning map to Level 2 so the doc no
longer contradicts itself.

In `@dev-docs/teammates.md`:
- Around line 845-865: The LOW-detail subsection "10.2 Detail-level interaction"
is out of date and incorrectly claims LOW drops teammate spawn tool pairs;
update the text to reflect the current renderer/tests which already preserve
Agent/Task spawns at LOW when TaskInput.name is set and keep the matching
tool_result (agent_metadata and response), and remove the note about needing a
future teammate-preserving exception or clearly mark it as historical/obsolete;
reference the section title "10.2 Detail-level interaction" and the symbols
`Task`, `TaskInput.name`, and `tool_result` when editing.

---

Duplicate comments:
In `@claude_code_log/converter.py`:
- Around line 2600-2619: The fallback team name collection from raw messages can
include sessions that were dropped earlier, causing cached and no-cache
annotations to differ; modify the loop that builds team_name_per_session so it
only considers parent session IDs that are present in the already-filtered
sessions_data (or its keys) and not in warmup_for_teams, i.e., derive
allowed_session_ids from sessions_data before iterating messages and skip any
_sid not in that set; keep the existing checks (SummaryTranscriptEntry,
hasattr(sessionId), get_parent_session_id, warmup_for_teams) and only add to
team_name_per_session when _sid is in allowed_session_ids.

---

Nitpick comments:
In `@test/test_teammates_parsing.py`:
- Around line 351-375: The docstring is wrong: the test never
serialises/deserialises. Update the test for SessionCacheData to perform a real
round-trip for both cases (s with team_name and s2 without) by calling the
model's dump (e.g., model_dump() or json export) and then reloading with
model_validate() (or equivalent parse/validate method) and asserting the
reloaded instance preserves team_name == "my-team" for s and team_name is None
for s2; keep references to the SessionCacheData constructor, s and s2, and the
model_dump/model_validate (or JSON export/parse) calls so the test fails if
serialization drops or renames the field.
- Around line 241-318: Test currently re-implements the no-cache team_name
aggregation and imports convert_jsonl_to_html/json/TemplateProject only to del
them; instead wire the test to exercise production code: remove the dead imports
and del statements, and replace the local aggregation block in
test_fallback_team_names_filters_warmup_sessions with a call to the real
fallback aggregator in converter.py (the helper that collects per-session
teamName values used by convert_jsonl_to_html); if no such helper is exported,
drive convert_jsonl_to_html end-to-end on a temporary input (using the
warmup_msg and real_msg) and assert the rendered project card contains only
"real-team" so the test fails if the warmup filter is removed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7fd228e6-8e57-4483-a6cd-9ed7d09e1ce0

📥 Commits

Reviewing files that changed from the base of the PR and between 8e576d8 and 0f5c643.

📒 Files selected for processing (33)
  • claude_code_log/converter.py
  • claude_code_log/dag.py
  • claude_code_log/factories/teammate_factory.py
  • claude_code_log/factories/tool_factory.py
  • claude_code_log/html/renderer.py
  • claude_code_log/html/system_formatters.py
  • claude_code_log/html/teammate_formatter.py
  • claude_code_log/html/templates/components/teammate_styles.css
  • claude_code_log/markdown/renderer.py
  • claude_code_log/models.py
  • claude_code_log/renderer.py
  • dev-docs/FOLD_STATE_DIAGRAM.md
  • dev-docs/teammates.md
  • test/__snapshots__/test_snapshot_html.ambr
  • test/__snapshots__/test_snapshot_markdown.ambr
  • test/test_dag_integration.py
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1.jsonl
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a36efb2a1231c41e2.jsonl
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a36efb2a1231c41e2.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a4ca7529859c158c2.jsonl
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a4ca7529859c158c2.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a52886dc180afb356.jsonl
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a52886dc180afb356.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a6aa8ad077d9ea083.jsonl
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a6aa8ad077d9ea083.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-ab1e6e141221a29ab.jsonl
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-ab1e6e141221a29ab.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-aef063ba534d73153.jsonl
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-aef063ba534d73153.meta.json
  • test/test_integration_realistic.py
  • test/test_teammates_browser.py
  • test/test_teammates_parsing.py
  • work/teammates-plan.md
💤 Files with no reviewable changes (1)
  • work/teammates-plan.md
✅ Files skipped from review due to trivial changes (8)
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a36efb2a1231c41e2.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a52886dc180afb356.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a6aa8ad077d9ea083.meta.json
  • claude_code_log/models.py
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-aef063ba534d73153.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a4ca7529859c158c2.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-ab1e6e141221a29ab.meta.json
  • test/test_data/real_projects/-experiments-worktrees/ef958aa1-0e93-49f0-bb71-150f63befcd1/subagents/agent-a52886dc180afb356.jsonl
🚧 Files skipped from review as they are similar to previous changes (1)
  • claude_code_log/renderer.py

Comment on lines 659 to +661
if msg.parentUuid is None and agent_id in agent_anchors:
msg.parentUuid = agent_anchors[agent_id]
msg.sessionId = f"{msg.sessionId}#agent-{agent_id}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Synthetic sidechain IDs now drop subagents from per-session exports.

After Line 661, subagent entries no longer match the exact msg.sessionId == session_id filter still used by MarkdownRenderer.generate_session() on Line 1241. Combined transcripts keep working, but session-*.md will lose the inlined subagent conversation entirely unless the per-session path switches to parent-session matching.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/converter.py` around lines 659 - 661, The per-message
sessionId is being rewritten to include an agent suffix (in the block that
assigns msg.sessionId and uses agent_anchors), which breaks
MarkdownRenderer.generate_session()'s exact match on session_id; preserve the
original session id by recording it before mutating and expose it for
per-session exports (e.g., set a new field like msg.parentSessionId or
msg.originalSessionId with the pre-suffix value) so generate_session() can match
parentSessionId == session_id (or update generate_session() to check
parentSessionId when present). Locate the assignment to msg.sessionId and
agent_anchors in the converter and add the extra preserved-field; then update
MarkdownRenderer.generate_session() (the function using session_id) to prefer
msg.parentSessionId == session_id when that field exists or fall back to
msg.sessionId == session_id.

Comment thread claude_code_log/factories/teammate_factory.py
Comment thread claude_code_log/html/teammate_formatter.py
Comment thread dev-docs/FOLD_STATE_DIAGRAM.md
Comment thread dev-docs/teammates.md Outdated
Five findings:

**(major) Per-session exports lost subagent content.**
``MarkdownRenderer.generate_session`` and
``HtmlRenderer.generate_session`` filtered with
``msg.sessionId == session_id`` (exact match). After
``_integrate_agent_entries`` rewrites subagent entries to
``{trunk}#agent-{agent_id}``, those no longer matched and the
per-session export silently dropped the inlined subagent
conversation (verified: 36 Sub-assistant rows in combined,
10 in per-session). Fix: accept ``msg.sessionId == session_id``
OR ``msg.sessionId.startswith(f"{session_id}#agent-")`` so
subagent threads ride along with their trunk session.

**(major) Don't reject literal ``&`` inside teammate attributes.**
The ``_ATTR_RUN`` regex was tightened to be XML-spec-strict
(``[^"<&]*`` and ``[^'<&]*``) but transcript text isn't strict
XML — real summaries routinely contain ``&`` ("tests & docs
done") which would now make the whole block disappear. Loosened
to ``[^"<]*`` and ``[^'<]*``: only ``<`` and the matching quote
are forbidden, ``>`` and ``&`` ride through.

**(major) Don't hide failed ``TaskUpdate`` results.**
``format_taskupdate_output`` returned "" when there was no
``status_change``, including on failure. Combined with the
title's ``[updated]`` tag, that made the UI claim success even
when the update failed. Now: ``not output.success`` produces a
``Status: failed`` row (red, via new ``.task-update-failed``
CSS) plus a ``Detail`` row carrying the parsed ``raw_text``
when present, so the reader can see what went wrong.

**(minor) FOLD_STATE_DIAGRAM.md contradicted itself.**
The "Sidechain (Sub-agent) Handling" section still said
"Sidechain user messages are skipped (duplicate Task input)"
without the qualifier already in the rest of the doc. Aligned
with the level-table updates and the new behavior:
``UserTextMessage`` duplicates pruned post-build,
``TeammateMessage`` sidechain prompts intentionally kept,
last-response dedup remains.

**(minor) ``dev-docs/teammates.md`` §10.2 contradicted the code.**
The "Detail-level interaction" subsection still claimed LOW
drops teammate spawn pairs and needed a future exception, but
``_LOW_KEEP_TOOLS`` already includes ``Task`` AND ``Agent``
since commit ``dc134bf``. Rewrote §10.2 to describe the
current behaviour (whitelist exempts the spawn pair, sidechain
content still strips at LOW, MINIMAL still strips everything),
linked to the regression tests.

Tests:
- ``test_summary_with_unescaped_amp_is_preserved`` (regression
  for the parser fix).
- ``test_per_session_export_includes_subagent_content``
  (regression for the per-session filter fix; asserts the
  per-session export carries ≥ as many ``Sub-assistant`` rows
  as the combined transcript).

Test count: 1024 → 1026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
claude_code_log/html/teammate_formatter.py (2)

298-318: activeForm is interpolated into a markdown bold-italic wrapper without escaping.

f"***{input_.activeForm}***" passes activeForm straight into the Markdown renderer. If a value happens to contain *, _, [, or backticks, mistune will interpret them and may produce surprising output (broken emphasis, unintended links, mid-string code spans). Per the docstring activeForm is typically a short verb phrase, so this is a low-likelihood issue, but escaping the markdown specials would harden the path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/teammate_formatter.py` around lines 298 - 318,
format_taskcreate_input currently injects input_.activeForm directly into the
markdown wrapper via f"***{input_.activeForm}***", which can let markdown
metacharacters (* _ [ ] `) change rendering; update format_taskcreate_input to
escape markdown-special characters in input_.activeForm before wrapping it in
the bold-italic markers (or call an existing markdown-escape utility if one
exists), then continue to join with input_.description and call
render_markdown_collapsible as before so the activeForm is rendered literally
and cannot break emphasis or create unintended links/code spans.

361-370: Failure surfacing fix looks correct, but multi-line raw_text won't render line breaks.

escape_html(output.raw_text) is dropped into a <dd> with no <br> substitution, so any newlines in a captured failure message collapse to whitespace in the rendered card. If failed TaskUpdate parses sometimes carry multi-line stderr/stack traces, consider wrapping the detail in <pre> (or substituting \n with <br>) so the structure is preserved. Otherwise this is a clean resolution of the prior "hidden failure" concern.

♻️ Optional refinement
         if output.raw_text:
-            rows.append(("Detail", escape_html(output.raw_text)))
+            rows.append(("Detail", f"<pre class='task-update-detail'>{escape_html(output.raw_text)}</pre>"))
         return _render_card("task-update-card", rows)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@claude_code_log/html/teammate_formatter.py` around lines 361 - 370, The
rendered failure detail currently injects escape_html(output.raw_text) into the
rows passed to _render_card, which collapses newlines; update the rows.append
call for ("Detail", ...) so multi-line content preserves line breaks — for
example wrap the escaped text in a <pre> (e.g. "<pre
class='task-update-detail'>"+escape_html(output.raw_text)+"</pre>") or replace
"\n" with "<br>" on the escaped string
(escape_html(output.raw_text).replace("\n","<br>")); keep the escaping via
escape_html, and ensure the altered string is what gets appended to rows before
calling _render_card("task-update-card", rows).
test/test_integration_realistic.py (1)

1825-1834: Quote-style coupling in HTML regex.

The regex pattern only matches single-quoted attributes ('... data-message-id). If the renderer ever switches to double quotes (e.g. via Jinja autoescape changes), anchors will be empty and the assertion will fail loudly — which is safe — but the test will need updating. If you're open to it, parsing the HTML with html.parser / BeautifulSoup would make this resilient. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_integration_realistic.py` around lines 1825 - 1834, The current
regex in the test (the pattern variable that looks for "message assistant
sidechain ((?:d-\d+ ?)+)' data-message-id") is brittle because it only matches
single-quoted attributes; update the test to be resilient by either (a)
replacing the ad-hoc regex with an HTML parser (html.parser or BeautifulSoup) to
locate elements with class "message assistant sidechain" and read their
data-message-id attribute, or (b) modify the regex to accept either single or
double quotes around data-message-id (e.g., allow ['"] before the space and ['"]
after), and keep using anchors (the anchors set and the logic that adds
ancestry[-1]) unchanged so the assertion on len(anchors) still holds.
test/test_teammates_parsing.py (1)

249-318: Drop the unused imports instead of del-ing them.

convert_jsonl_to_html, json, and TemplateProject are imported only to be del'd at the bottom of the test (lines 314‑318). The imports aren't exercised — the test mirrors the fallback aggregation logic locally with get_warmup_session_ids. Removing the dead imports keeps the test focused and avoids the unusual "import-then-del" idiom, which can look confusing to future readers and tooling.

♻️ Proposed cleanup
-    from claude_code_log.converter import convert_jsonl_to_html
     from claude_code_log.utils import get_warmup_session_ids
     from claude_code_log.models import UserTranscriptEntry, UserMessageModel
-    from claude_code_log.renderer import TemplateProject
-    import json
@@
-    # Only "real-team" should surface — "leak-team" gets filtered out
-    # because warmup-session is in warmup_ids.
-    assert team_names == ["real-team"]
-    # Suppress the unused-import warning — convert_jsonl_to_html is
-    # the prod entry point this regression covers.
-    del convert_jsonl_to_html
-    del json
-    del TemplateProject
+    # Only "real-team" should surface — "leak-team" gets filtered out
+    # because warmup-session is in warmup_ids.
+    assert team_names == ["real-team"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/test_teammates_parsing.py` around lines 249 - 318, Remove the unused
imports instead of deleting them at the end of the test: delete
convert_jsonl_to_html, json, and TemplateProject from the top import list and
remove the trailing del convert_jsonl_to_html / del json / del TemplateProject
statements; keep imports for get_warmup_session_ids, UserTranscriptEntry,
UserMessageModel, and any other symbols actually used (e.g.,
get_warmup_session_ids, UserTranscriptEntry, UserMessageModel) so the test
mirrors the fallback aggregation logic without the import-then-del idiom.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@dev-docs/teammates.md`:
- Around line 668-674: The doc examples in teammates.md are using
backslash-escaped backticks inside inline code (e.g., the examples for
title_SessionHeaderMessage and the _inline_code helper) which CommonMark doesn't
support; update those examples to use the widened-fence inline-code pattern
instead of backslash escapes (i.e., replace occurrences like `📋 Session
\`abc12345\`` and the team-name variant with an inline-code fence long enough to
contain the inner backtick so you can remove the backslashes), and ensure there
is no leading space inside the code span to avoid MD038.

---

Nitpick comments:
In `@claude_code_log/html/teammate_formatter.py`:
- Around line 298-318: format_taskcreate_input currently injects
input_.activeForm directly into the markdown wrapper via
f"***{input_.activeForm}***", which can let markdown metacharacters (* _ [ ] `)
change rendering; update format_taskcreate_input to escape markdown-special
characters in input_.activeForm before wrapping it in the bold-italic markers
(or call an existing markdown-escape utility if one exists), then continue to
join with input_.description and call render_markdown_collapsible as before so
the activeForm is rendered literally and cannot break emphasis or create
unintended links/code spans.
- Around line 361-370: The rendered failure detail currently injects
escape_html(output.raw_text) into the rows passed to _render_card, which
collapses newlines; update the rows.append call for ("Detail", ...) so
multi-line content preserves line breaks — for example wrap the escaped text in
a <pre> (e.g. "<pre
class='task-update-detail'>"+escape_html(output.raw_text)+"</pre>") or replace
"\n" with "<br>" on the escaped string
(escape_html(output.raw_text).replace("\n","<br>")); keep the escaping via
escape_html, and ensure the altered string is what gets appended to rows before
calling _render_card("task-update-card", rows).

In `@test/test_integration_realistic.py`:
- Around line 1825-1834: The current regex in the test (the pattern variable
that looks for "message assistant sidechain ((?:d-\d+ ?)+)' data-message-id") is
brittle because it only matches single-quoted attributes; update the test to be
resilient by either (a) replacing the ad-hoc regex with an HTML parser
(html.parser or BeautifulSoup) to locate elements with class "message assistant
sidechain" and read their data-message-id attribute, or (b) modify the regex to
accept either single or double quotes around data-message-id (e.g., allow ['"]
before the space and ['"] after), and keep using anchors (the anchors set and
the logic that adds ancestry[-1]) unchanged so the assertion on len(anchors)
still holds.

In `@test/test_teammates_parsing.py`:
- Around line 249-318: Remove the unused imports instead of deleting them at the
end of the test: delete convert_jsonl_to_html, json, and TemplateProject from
the top import list and remove the trailing del convert_jsonl_to_html / del json
/ del TemplateProject statements; keep imports for get_warmup_session_ids,
UserTranscriptEntry, UserMessageModel, and any other symbols actually used
(e.g., get_warmup_session_ids, UserTranscriptEntry, UserMessageModel) so the
test mirrors the fallback aggregation logic without the import-then-del idiom.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 152f450a-8358-4b76-b005-9cd83fd19197

📥 Commits

Reviewing files that changed from the base of the PR and between 0f5c643 and d33c426.

📒 Files selected for processing (10)
  • claude_code_log/factories/teammate_factory.py
  • claude_code_log/html/renderer.py
  • claude_code_log/html/teammate_formatter.py
  • claude_code_log/html/templates/components/teammate_styles.css
  • claude_code_log/markdown/renderer.py
  • dev-docs/FOLD_STATE_DIAGRAM.md
  • dev-docs/teammates.md
  • test/__snapshots__/test_snapshot_html.ambr
  • test/test_integration_realistic.py
  • test/test_teammates_parsing.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • claude_code_log/html/templates/components/teammate_styles.css

Comment thread dev-docs/teammates.md Outdated
CodeRabbit on PR #125 caught the doc examples for
``title_SessionHeaderMessage`` using the very pattern the surrounding
text warns against — backslash-escaping inner backticks inside
single-backtick code spans:

    ``\`📋 Session \\\`abc12345\\\`\`\``

CommonMark doesn't honor backslash escapes inside code spans, so the
inner backslash + backtick parses as a literal ``\\`` followed by an
unescaped ``\``` that prematurely closes the span. The surrounding
section even teaches the fence-widening recipe (``_inline_code``);
the docs just weren't following it.

Switched the four affected examples (plain trunk, with-summary,
team-active, and the stale ``Subagent`` variant) to double-backtick
fences with the canonical leading/trailing space when needed. Also
silences the markdownlint MD038 false-positive on the
``trailing-space-inside-code-span`` line.

The actual code (``title_SessionHeaderMessage`` in
``markdown/renderer.py:387``) routes through ``_inline_code`` and is
already correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
dev-docs/teammates.md (1)

52-54: Minor clarification: distinguish new vs. repurposed fields.

The phrasing "gains five teammate-spawn fields" followed by listing team_name, name, mode, plus "the existing run_in_background and subagent_type" is slightly ambiguous—it's actually 3 new fields plus 2 existing ones now used in the teammate context. Line 139 repeats this pattern. Consider: "gains three new teammate-spawn fields (team_name, name, mode) in addition to the existing run_in_background and subagent_type" for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dev-docs/teammates.md` around lines 52 - 54, The wording is ambiguous about
which fields are new versus repurposed; update the sentences that describe
Task's teammate-spawn fields so they explicitly state that Task "gains three new
teammate-spawn fields (team_name, name, mode) in addition to the existing
run_in_background and subagent_type" — make the same clarification wherever the
pattern repeats (e.g., the later paragraph that currently mirrors this phrasing)
so readers know which fields are newly added versus already existing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@dev-docs/teammates.md`:
- Around line 52-54: The wording is ambiguous about which fields are new versus
repurposed; update the sentences that describe Task's teammate-spawn fields so
they explicitly state that Task "gains three new teammate-spawn fields
(team_name, name, mode) in addition to the existing run_in_background and
subagent_type" — make the same clarification wherever the pattern repeats (e.g.,
the later paragraph that currently mirrors this phrasing) so readers know which
fields are newly added versus already existing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa069486-c430-4631-be9f-634b6f48b3a3

📥 Commits

Reviewing files that changed from the base of the PR and between d33c426 and 74a12d1.

📒 Files selected for processing (1)
  • dev-docs/teammates.md

@cboos cboos merged commit 6b7e188 into main Apr 26, 2026
12 checks passed
cboos added a commit that referenced this pull request Apr 26, 2026
The teammates fixture (added in PR #122 / merged via #125) contains
user messages that the user-markdown rendering now displays
differently:

- HTML: emits a `.user-content` wrapper with markdown/raw toggle UI;
  CSS for `.user-md`, `.user-raw`, `.user-view-toggle`, and the
  per-message + body-class precedence rules.
- Markdown: drops the triple-backtick fence around user text, since
  user content is now rendered as Markdown directly.

Snapshot churn is purely additive (HTML) or the intended shape change
(Markdown). No behavioral regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cboos added a commit that referenced this pull request Apr 26, 2026
* Render user text as Markdown with a toggle back to raw

Default user-text rendering now tries Markdown via mistune. When the
rendered HTML is well-formed (balanced tags, no parser errors),
both views are emitted so the reader can toggle between them; when
the HTML is ill-formed — strong evidence the source wasn't actually
Markdown — only the bare raw `<pre>` is emitted and the toggle is
suppressed. Markdown is the default view.

Mechanism:

- `html/utils.py`:
  - `render_user_markdown(text)` — mistune with `escape=True`. Unlike
    the shared `render_markdown` used for assistant output, user
    content must escape raw HTML so a user typing `<script>` sees
    the literal characters instead of injecting a tag.
  - `is_well_formed_html(fragment)` — walks the rendered output with
    stdlib `html.parser.HTMLParser`, tracks an open-tag stack (void
    elements excluded), and returns False on any unexpected close,
    unclosed tag at end of input, or parser exception.

- `html/user_formatters.py::format_user_text_content(text)`:
  - Well-formed: emits a `.user-content[data-user-view="md"]` wrapper
    with `.user-md` (rendered), `.user-raw` (escaped `<pre>`), and a
    small `.user-view-toggle` button.
  - Ill-formed: returns the bare `<pre>{escaped}</pre>` as before.

UI:

- Template `transcript.html` gains a floating `md`/`raw` button next
  to the existing uuid/details toggles. Clicking flips a body class
  (`show-raw-user`) and persists the choice in localStorage.
- Delegated click handler toggles `data-user-view` on individual
  `.user-content` wrappers for per-message control.
- CSS precedence: per-message `data-user-view="md"` beats the global
  class, so a user who explicitly chose Markdown on one message isn't
  surprised when they flip the global toggle.

Tests:

- `test_is_well_formed_html_unit` — balanced/self-closing/mismatched/
  unclosed coverage.
- `test_render_user_markdown_escapes_html` — raw `<script>` escaped.
- `test_user_ill_formed_markdown_falls_back_to_raw` — monkey-patches
  the renderer to emit unclosed HTML and asserts the bare `<pre>`
  fallback.
- `test_user_message_markdown_rendered_with_raw_preserved` —
  replaces the former "user messages are never markdown-rendered"
  test (directly contradicting the new policy) and asserts the
  dual view is present with raw preserved.

Updated tests that asserted the old `<pre>` shape. The queue-operation
test now expects the message text to appear twice (once per view)
rather than once.

Snapshot updates absorb the dual-view HTML + CSS/JS additions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Accept XHTML self-closing tags in is_well_formed_html

Monk's review caught a real bug: mistune emits XHTML self-closing
syntax for void elements (``<br />``, ``<hr />``, ``<img />``) but
stdlib `HTMLParser`'s default `handle_startendtag` calls
`handle_starttag` followed by `handle_endtag`. My checker then saw the
endtag, found an empty stack (because void tags don't push), and
logged "unexpected </br>" — rejecting every user message with a
newline as ill-formed and silently demoting the dual view to bare
`<pre>`.

Override `handle_startendtag` as a no-op for tracking purposes.
`<tag />` opens and closes in a single token — neither push nor pop
is needed whether the tag is void or not.

Regression tests:
- `is_well_formed_html` unit cases now cover `<br />`, `<hr />`, and
  `<img src="x" alt="y" />`.
- `test_user_markdown_with_newline_keeps_dual_view` asserts
  `format_user_text_content("line1\nline2")` keeps the dual-view
  wrapper (the exact failure mode monk surfaced).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Emit user-content without data-user-view so global toggle works

Monk's second review caught that every `.user-content` wrapper was
emitted with `data-user-view="md"` baked in. The global raw-toggle CSS
uses `:not([data-user-view="md"])` to skip messages the user
explicitly chose to keep as Markdown — but with the attribute present
on every untouched message, the selector never matched and the global
toggle had no visible effect.

Drop the baked attribute. The per-message toggle JS already sets
`data-user-view` after a click, and the CSS selectors interpret a
missing attribute as "untouched" — which is what we want the global
raw-view to flip. A user who explicitly locks a message to Markdown
still gets the attribute set to "md" after their first round-trip
through the toggle, so per-message overrides continue to win over
global.

Verified end-to-end with Playwright:
- Default: md visible.
- Global toggle (no prior per-message click): md hidden, raw visible.
- Global toggle again: md visible.

Updated `test_format_user_text_content` to assert the attribute is
absent on fresh output. Snapshots refreshed — purely the removed
attribute, no other structural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add browser regression tests for user-view toggle

Covers the scenarios monk's review flagged as unexercised:
- Default load: markdown visible, raw hidden
- Global toggle flips untouched messages (regression guard for the
  bake-in bug where data-user-view='md' broke the global selector)
- Per-message toggle affects only that message
- Per-message explicit 'md' overrides global 'raw'
- Global choice persists across reload via localStorage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Type-clean the ill-formed fallback test's monkey-patch

Swap the untyped lambda for unittest.mock.patch. ty (stricter than
pyright) rejected `uf.render_user_markdown = lambda ...` as an
invalid-assignment against a typed module attribute. `mock.patch`
sidesteps that check and also restores the original automatically,
so the try/finally goes away.

Reported by monk in #2208.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Isolate browser tests from file:// localStorage leak

Chromium treats every file:// URL as the same null origin, so
localStorage persists across Playwright tests in a session even
though each test gets a fresh BrowserContext. That made the test
order sensitive:
- If a prior test left 'raw' in localStorage, the next default-md
  test started in raw mode and the initial assertion failed.
- If a prior test left 'md', the reload test's click toggled
  out of raw instead of into it.

Fix: every test now goes through `_goto_clean`, which clears
localStorage and reloads once before the test body runs. Verified
deterministic across 2 sequential runs + a parallel -n auto run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update CSS doc-comment to match post-fix emitter contract

The wrapper no longer ships with `data-user-view='md'` — the
attribute is absent until the per-message toggle is clicked.
Reported by monk in #2220.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Render user text as Markdown in Markdown output too

The HTML renderer runs user text through mistune and falls back to
raw only when the output is ill-formed; the Markdown renderer was
still wrapping every user message in an unconditional code fence.
That erased headings, bold, lists, and links from the Markdown
output for no good reason — the same well-formed-HTML gate is
just as informative about "is this actually Markdown?" regardless
of the final format.

format_UserTextMessage now:
- Tries render_user_markdown + is_well_formed_html.
- On pass, emits the user's text as raw Markdown so downstream
  viewers (GitHub, IDEs, etc.) render it naturally.
- On fail, keeps the current code-fence fallback — literal content
  is preserved when mistune can't cleanly make sense of it.

Raw HTML/XML tags in otherwise-clean text get wrapped in inline
backticks by `_protect_html_tags`, mirroring the HTML path's
`escape=True` safety posture. Without that, a permissive viewer
would interpret user-typed `<script>` or `<iframe>` as markup.
The wrapper skips fenced code blocks (tags are already literal
there) and existing inline code spans (via negative lookaround).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Switch _protect_html_tags to mistune round-trip

The regex-based approach mangled text the user had already taken
care to quote themselves — ``use `x <br> y` here`` would get the
``<br>`` rewritten a second time, making the situation worse for
anyone who'd already done the right thing. Handling inline-code
spans, fenced blocks, and indented code blocks via regex gets
hairy fast (coderabbit flagged this on #119).

Switch to the approach mistune already supports: subclass
``MarkdownRenderer`` (the stock re-emitter that parses Markdown
and writes it back out), override ``inline_html`` to backtick-wrap
and ``block_html`` to fence-wrap. The parser does the hard work of
distinguishing raw HTML from content inside code spans / fences /
indented blocks, so we only ever transform the tokens that would
actually let a downstream viewer interpret markup.

Net effect for the user's stated concern:
  before: ``use `x <br> y` here`` → ``use `x `<br>` y` here``  ← breaks it
  after:  ``use `x <br> y` here`` → ``use `x <br> y` here``   ← preserved

Acceptable round-trip quirks (mistune normalisation, not
regressions):
- Standalone HTML on its own line (``<script>…`` alone) becomes a
  fenced code block instead of backtick-wrapped inline. Stronger
  protection, same semantics.
- Indented HTML becomes fenced. Equivalent literal rendering.
- ``\`\`<br>\`\`` (double-backtick inline code) renders with a
  single-backtick delimiter when single suffices. Semantically
  the same inline code span.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make per-message toggle respect the global raw view

When the global raw toggle was active and a user clicked a
per-message toggle, the first click changed nothing visible:

1. Container has no `data-user-view` attribute (default state).
2. JS read `current = attr || 'md'`, setting `next = 'raw'`.
3. CSS `[data-user-view="raw"]` already matched the global-raw
   display → no visual change, just a button-label flip.
4. User had to click a second time to actually reach markdown.

The default-to-'md' fallback was only correct while the global
toggle was off. Fix: derive the *effective* current view from the
explicit attribute first, then fall back to the global state.

Also correct the per-message button labels on load and after
global toggles — every server-rendered button ships with text
"raw", which is wrong when the effective current view is already
raw (i.e. the user would flip TO md, not to raw). New helper
`applyPerMessageToggleLabels()` runs on init and after the
global toggle so the label always names the view the user would
switch to.

Added browser test `test_per_message_click_under_global_raw_flips_in_one_click`
as the regression guard. Flagged by coderabbit on PR #119.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Use adaptive backtick delimiters in _TagProtectingMarkdownRenderer

Fixed-length delimiters (1 backtick for inline, 3 for block) can
be broken by backticks the user embedded in the HTML token itself:

  input : <span title="`">x</span>
  wrap  : `<span title="`">`x`</span>`
             ^^^^^^^^^^^^^^
  problem: the inner ` closes the inline code span early, so
           `">x` leaks back to the Markdown renderer as live text.

Same class of break for block HTML that happens to contain a
triple fence:

  input : <div>\n```\n</div>
  wrap  : ```\n<div>\n```\n</div>\n```
  problem: the inner ``` closes the outer fence, `</div>` leaks.

Mirrors the existing `_code_fence` pattern: compute an adaptive
delimiter that's one backtick longer than the longest run in the
token (or at least the minimum — 1 for inline spans, 3 for
fences). Verified that the resulting Markdown, when re-rendered,
emits the tag as entity-escaped text inside <code>, with no live
markup leakage.

Flagged by coderabbit on PR #119.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Escape HTML tokens to entities instead of wrapping in backticks

Adaptive backtick delimiters still leaked the moment a user typed
a stray backtick adjacent to a tag — ``x `<br> y`` ended up as
``x ``<br>` y``, where the opening two-backtick run never matched
the closing one-backtick and ``<br>`` reached the downstream
renderer as live markup.

The earlier switch to mistune's round-trip was supposed to
sidestep this class of edge cases by leaning on the parser; the
point got lost when we then hand-built a new class of edge cases
in the wrapper itself.

Simpler and robust: feed the raw HTML token through
``html.escape`` in both ``inline_html`` and ``block_html``. The
output is plain Markdown text — no delimiter to merge with, no
surrounding-context interactions, no precedence for the user to
outsmart us on. Permissive downstream renderers (GitHub, VS Code
previews) display the tag text; strict ones show entity-encoded
text. Either way the tag never reaches the HTML output live.

Dropped ``_backtick_delimiter``, the adaptive-delim tests, and
the ``<code>``-wrapping assumptions. Added three "does not leak"
tests for the breakage cases we previously handled (stray
backtick adjacent to tag, backtick in attribute, block HTML
carrying a fence) plus covers for entity-escape semantics across
the rest of the scenarios.

Flagged by coderabbit on PR #119.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Refresh teammates snapshots for user-content markdown rendering

The teammates fixture (added in PR #122 / merged via #125) contains
user messages that the user-markdown rendering now displays
differently:

- HTML: emits a `.user-content` wrapper with markdown/raw toggle UI;
  CSS for `.user-md`, `.user-raw`, `.user-view-toggle`, and the
  per-message + body-class precedence rules.
- Markdown: drops the triple-backtick fence around user text, since
  user content is now rendered as Markdown directly.

Snapshot churn is purely additive (HTML) or the intended shape change
(Markdown). No behavioral regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai coderabbitai Bot mentioned this pull request Apr 27, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants