Skip to content

Support teammates (#91): rendering (PR 2 of 3)#122

Merged
cboos merged 9 commits intomainfrom
dev/teammates-rendering
Apr 24, 2026
Merged

Support teammates (#91): rendering (PR 2 of 3)#122
cboos merged 9 commits intomainfrom
dev/teammates-rendering

Conversation

@cboos
Copy link
Copy Markdown
Collaborator

@cboos cboos commented Apr 19, 2026

Rendering pass for the experimental teammates feature. Builds on the parsing/data-model landed in #117.

Scope

PR 2 of the 3-PR plan at work/teammates-plan.md. Adds:

  • HTML rendering — TeammateMessage formatter (one colored card per block), six teammate tool cards (TeamCreate/Delete, TaskCreate/Update/List, SendMessage), teammate-aware Task extras (spawn fields + parsed metadata tail).
  • CSS — named --cc-<color> palette tokens (blue/cyan/green/yellow/orange/red/pink/purple/gray + system), .teammate-message block styles with left-aligned colored borders, .teammate-badge pills, task-board table, light + dark backgrounds.
  • Color propagation — RenderingContext gains a teammate_id → color map populated from the first-sighting color in <teammate-message> blocks. HtmlRenderer / MarkdownRenderer snapshot it at render time so owner badges, recipient badges, TaskList rows, and TeamDelete active-member lists pick up the right color even when the entry itself doesn't carry one.
  • Markdown mirror — matching formatters in markdown/renderer.py. Markdown can't color, so teammate markers use a colored-circle emoji convention (🔵 alice, 🟢 bob, ⬛ system, …). TaskList becomes a pipe table; TeammateMessage becomes a blockquote with a header line.
  • Tests — 2 new syrupy snapshot tests (HTML + Markdown for the fixture), 3 Playwright browser tests (distinct colored borders, task-list table shape, alice badge stays blue).

Commits (4 on top of main@d4bcd58)

  1. 03d7cdb — TeammateMessage + 6 tool cards (HTML)
  2. e674860 — Color propagation via RenderingContext
  3. f721a12 — Markdown renderers
  4. 9ee9502 — Snapshot + browser tests

Each commit stands on its own; bisectable.

Non-goals (PR 3 scope)

  • Sidechain stitching under Agent/Task tool_use (the "whole-transaction" collapsible view).
  • Subsume the redundant first User sidechain message.
  • Team badge on session headers / teammate badge on subagent headers.
  • Index integration ("Team: N members" annotation).

Validation

  • just ci end-to-end green: 933 pytest passes, 3 browser passes, 1 benchmark pass, pyright 0 errors, ty clean, ruff clean.
  • uv run claude-code-log test/test_data/teammates produces well-rendered HTML with colored teammate cards and a Markdown variant with colored-circle markers.

One bug caught during test-writing

teammate_styles.css was originally wrapping itself in <style>...</style>, but transcript.html already opens one outer <style> for all component CSS. The nested tag closed the outer block prematurely and browsers dropped everything after it — --cc-color never resolved, so all card borders ended up currentColor gray. The browser tests caught it (test_teammate_messages_render_with_distinct_colored_borders failed with all cards same color). Fixed by removing the inner <style> tags in the teammates-tests commit.

Ready for review.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added teammate collaboration support with color-coded teammate identification that persists across sessions
    • Introduced team and task management operations including creation, updates, and listing capabilities
    • Added colored teammate badges and visual styling for teammate messages and task assignments
    • Enabled task list rendering with owner assignment and status tracking
  • Tests

    • Added snapshot tests for teammate feature rendering in both HTML and Markdown formats
    • Added browser tests validating colored teammate displays and task list rendering

cboos added 4 commits April 19, 2026 23:54
Before this commit, TeammateMessage content and the six new teammate
tools (TeamCreate, TeamDelete, TaskCreate, TaskUpdate, TaskList,
SendMessage) fell through the HtmlRenderer dispatch — the body of a
<teammate-message> block was silently dropped, and the tool I/O was
handled only by the generic parameter-table fallback.

This change wires in:

- claude_code_log/html/teammate_formatter.py: format_teammate_content()
  renders one card per TeammateMessageBlock with a colored left border,
  a teammate-id badge, an italicized summary line, and a Markdown body.
  teammate_id="system" blocks pick up a neutral "teammate-system" class
  for terminate notifications.

- Six tool input/output cards: TeamCreate (team card),
  TeamDelete (success/refused notice with active-members list when
  cleanup is blocked), TaskCreate (subject card), TaskUpdate (task +
  updated fields + owner badge), TaskList (task-board table with per-
  row status class), SendMessage (recipient badge + typed message).

- Extension fragments on format_TaskInput / format_TaskOutput that
  surface teammate-spawn fields (name, team_name, mode,
  run_in_background) and the AgentResultMetadata parsed in PR 1
  (agent_id, worktree path/branch, usage totals) as a second card.

- claude_code_log/html/templates/components/teammate_styles.css: the
  --cc-<color> design tokens (blue/cyan/green/yellow/orange/red/pink/
  purple/gray + system), light + dark backgrounds, .teammate-message
  (colored border, left-aligned — teammate messages are not human
  user input), .teammate-badge pill, .teammate-tool-card dl + the
  .task-list table with status color classes.

- HtmlRenderer gains format_TeammateMessage + one dispatch per new
  typed input/output. The template now includes the new CSS bundle.

Color routing uses the inline `--cc-color` CSS variable on each card
so future color-propagation (teammate_id → color map) only has to
pick a token, not re-render markup. Snapshots regenerated for the
new <style> include (diff is purely additive).
Add a teammate_id → color map to RenderingContext and populate it in a
new _populate_teammate_colors pass after the message tree is built.
The source of truth is the `color` attribute on each
<teammate-message> block (parsed by teammate_factory into
TeammateMessageBlock) — first sighting wins.

HtmlRenderer captures the map from the ctx at the start of a render
and stores it as self._teammate_colors. The existing dispatch methods
that render a teammate name (TaskUpdate owner, SendMessage recipient,
TaskList owner, TeamDelete active members, Task teammate-spawn extras)
now pass the map through to the formatter so entries that don't carry
a color inline pick the right color from the session-wide map.

Effect on the fixture: alice's TaskUpdate owner badge picks up blue
from her <teammate-message> block, bob's green propagates to his
SendMessage recipient and TaskList row, etc. — no transcript-level
color field required.
Mirror the HTML rendering in Markdown so teammate transcripts render
cleanly in `--format markdown` output too.

- format_TeammateMessage renders one blockquote per block with a
  colored-circle emoji (🔵/🟢/🟡/…) + bold teammate-id + italic
  summary header, followed by the body as a `> ` blockquote. Markdown
  can't color, so the emoji convention preserves the at-a-glance
  color cue. system blocks get ⬛.

- Six new tool formatters on MarkdownRenderer: TeamCreate (bullet
  list), TeamDelete (✓/✗ + active-members markers), TaskCreate
  (#N — subject), TaskUpdate (#N · status · owner marker), TaskList
  (pipe table with colored-circle owner markers), SendMessage (✓/✗
  + recipient marker + blockquoted message). TaskListInput and
  TeamDeleteInput render empty (no params).

- MarkdownRenderer keeps its own `_teammate_colors` snapshot of the
  ctx map, same pattern as HtmlRenderer, so the color markers pick
  up the right palette even when the entry itself doesn't carry a
  color field.
Lock in the PR 2 rendering with three layers of regression coverage.

## Snapshot tests (syrupy)

- test_teammates_fixture_html: snapshots the fixture rendered as a
  combined transcript so the <teammate-message> cards, six tool
  cards, task-list table, and teammate-aware Task extras are pinned
  at their current shape.
- test_teammates_fixture_markdown: snapshots the Markdown output —
  colored-circle emoji markers, pipe-table TaskList, blockquoted
  TeammateMessage bodies.
- Regenerated the pre-existing HTML/Markdown snapshots to pick up
  the new teammate_styles.css inclusion (diff is purely additive).

## Browser tests (Playwright)

test_teammates_browser.py exercises the rendered-in-browser output:

- test_teammate_messages_render_with_distinct_colored_borders:
  asserts ≥3 .teammate-message cards exist with distinct computed
  border-left colors across alice/bob/system (proves the inline
  --cc-color variable routes correctly to the CSS cascade).
- test_task_list_renders_as_html_table: asserts the TaskList output
  produces a real <table class="task-list"> with rows and the
  Status/Owner header cells.
- test_teammate_badge_color_matches_teammate_id: asserts every
  alice badge computes a blue-dominant backgroundColor (b > r and
  b > g), proving the teammate_id → color map in RenderingContext
  lands in the right cells.

Assertions use DOM-attached checks + computed styles rather than
visibility, so the tests aren't flaky against <details> fold state.

## Bug fix in teammate_styles.css

The component CSS file was wrapping itself in `<style>...</style>`,
but transcript.html already wraps all included CSS components in a
single outer <style> block. The nested tag closed the outer block
prematurely — CSS after it was dropped, so --cc-color never
resolved in the browser (borders came out as default currentColor
rgb(51,51,51)). Dropped the inner <style> tags to match the
convention of the other component CSS files. The browser tests
caught this: before the fix, all cards had identical borders.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b8ed684a-3cb0-4d67-95cc-4640889b3152

📥 Commits

Reviewing files that changed from the base of the PR and between f06f8b3 and 4097e3b.

📒 Files selected for processing (2)
  • claude_code_log/html/templates/components/teammate_styles.css
  • test/__snapshots__/test_snapshot_html.ambr
✅ Files skipped from review due to trivial changes (1)
  • claude_code_log/html/templates/components/teammate_styles.css

📝 Walkthrough

Walkthrough

This PR introduces comprehensive rendering support for the experimental "teammates" feature across HTML and Markdown formats. It adds session-scoped teammate color management, new formatter modules, multiple renderer dispatch methods for teammate message and tool operations, CSS styling with color tokens, and supporting tests.

Changes

Cohort / File(s) Summary
Core Rendering Context
claude_code_log/renderer.py
Added RenderingContext.teammate_colors mapping and _populate_teammate_colors() to track session-scoped teammate ID-to-color associations from TeammateMessage blocks.
HTML Rendering Infrastructure
claude_code_log/html/renderer.py, claude_code_log/html/teammate_formatter.py
Added session-scoped color lookup via _colors_for(), new dispatch methods for TeammateMessage and 12 teammate tool input/output types, and a new 462-line formatter module with HTML rendering functions for teammate cards, badges, task lists, and message blocks.
Markdown Rendering Infrastructure
claude_code_log/markdown/renderer.py
Extended model imports and added color-circle emoji markers (_COLOR_CIRCLE, _teammate_marker()), session-scoped color lookup, and 12 dispatch methods for teammate message and tool formatting with table cell escaping.
Styling and Templates
claude_code_log/html/templates/components/teammate_styles.css, claude_code_log/html/templates/transcript.html, claude_code_log/html/templates/components/timeline.html
Added new CSS stylesheet with color-token variables, teammate message/card styling, task-list table formatting, and status classes; included stylesheet in transcript template; added timeline group for teammate messages.
HTML Utils
claude_code_log/html/utils.py
Registered TeammateMessage in CSS class registry to derive ["user", "teammate"] classes for template rendering.
Snapshot and Browser Tests
test/test_snapshot_html.py, test/test_snapshot_markdown.py, test/__snapshots__/test_snapshot_markdown.ambr, test/test_teammates_browser.py, test/test_teammates_parsing.py
Added snapshot tests for HTML and Markdown rendering of teammates fixture, Playwright browser tests validating colored borders/badges and task-list table rendering, and unit tests for Markdown table escaping and session-scoped color isolation.

Sequence Diagram

sequenceDiagram
    participant Ctx as RenderingContext
    participant Gen as generate_template_messages()
    participant ColorMap as _populate_teammate_colors()
    participant Render as HtmlRenderer/MarkdownRenderer
    participant Fmt as teammate_formatter
    participant CSS as CSS Styles

    Gen->>Ctx: Create context
    Gen->>ColorMap: Call after sidechain cleanup
    ColorMap->>Ctx: Populate teammate_colors by session_id
    Gen->>Render: Snapshot ctx.teammate_colors into _teammate_colors_by_session
    
    loop For each TemplateMessage
        Render->>Render: Dispatch format_TeammateMessage/format_Team*Input/Output
        Render->>Fmt: Call format_* functions with colors_for() lookup
        Fmt->>Fmt: Render HTML/Markdown fragments with color tokens
        Fmt-->>Render: Return formatted content
        Render-->>Ctx: Add rendered content to output
    end
    
    Render-->>CSS: Apply inline color variables (--cc-color, --cc-color-bg)
    CSS->>CSS: Render teammate badges, cards, borders with palette colors
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Possibly related PRs

  • PR #117: Introduces the TeammateMessage and teammate tool input/output types in models/factories that this PR renders into HTML/Markdown format.

Suggested reviewers

  • daaain

Poem

🐰 A fuzzy friend whispers with glee:
"Teammates now render in color, you see!
With badges and borders in hue,
Each session keeps colors both bright and true.
No blending across streams, hooray!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing rendering support for the experimental teammates feature, with the parenthetical indicating this is PR 2 of a 3-part series.

✏️ 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-rendering

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.

@cboos cboos marked this pull request as ready for review April 20, 2026 05:01
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: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
claude_code_log/html/templates/transcript.html (1)

70-80: ⚠️ Potential issue | 🟠 Major

Add teammate to the transcript filters.

applyFilter() runs on page load and only expands the existing filter types, so .message.teammate entries get filtered-hidden by default even though the teammate CSS is now included.

🐛 Proposed fix
                 <button class="filter-toggle active" data-type="tool">🛠️ Tool <span
                         class="count">(0)</span></button>
+                <button class="filter-toggle active" data-type="teammate">👥 Teammates <span
+                        class="count">(0)</span></button>
                 <button class="filter-toggle active" data-type="sidechain">🔗 Sub-assistant <span
                         class="count">(0)</span></button>
@@
-                const messageTypes = ['assistant', 'sidechain', 'system', 'thinking', 'image'];
+                const messageTypes = ['assistant', 'sidechain', 'system', 'thinking', 'image', 'teammate'];
@@
-                const messageTypes = ['assistant', 'sidechain', 'system', 'thinking', 'image'];
+                const messageTypes = ['assistant', 'sidechain', 'system', 'thinking', 'image', 'teammate'];

Also applies to: 319-405, 426-430

🤖 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 70 - 80, The
transcript filters are missing a "teammate" toggle so messages with class
.message.teammate remain filtered-hidden by default; add a filter button like
the others with class "filter-toggle active" and data-type="teammate" (label
e.g., "🧑‍🤝‍🧑 Teammate") wherever the other filter buttons are defined, and
update applyFilter() to recognize "teammate" alongside
"user/system/assistant/thinking/tool/sidechain/image" so .message.teammate
elements are correctly shown/hidden when filters are applied.
🤖 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/html/renderer.py`:
- Around line 287-291: The teammate color snapshot stored on the renderer
(self._teammate_colors) is not being passed into teammate-formatting helpers, so
uncolored teammate-message blocks and send-message outputs can't inherit learned
colors; update the calls to format_teammate_content (in format_TeammateMessage)
and the analogous call(s) around the send-message output path (the other call
site noted) to pass self._teammate_colors as an extra argument, and then update
the helper function signatures in claude_code_log/html/teammate_formatter.py
(and any other referenced helpers) to accept a teammate_colors parameter and use
it as the fallback color source.
- Line 23: The timeline rendering is missing the new "teammate" type: update the
message type detection array that currently checks for
['user','assistant','tool_use','tool_result','thinking','system','image'] to
also include 'teammate'; add a corresponding entry in the messageTypeGroups
mapping (same shape as other entries, keyed 'teammate' and mapping to the
appropriate CSS class/label used by TeammateMessage), and add 'teammate' to the
groupOrder array so the timeline groups include teammate messages in the correct
order; use the existing identifiers messageTypeGroups, groupOrder, and
TeammateMessage to locate where to add these changes.

In `@claude_code_log/html/teammate_formatter.py`:
- Around line 394-396: The code currently chooses a cached teammate color via
_lookup_color(teammate_colors, output.teammate_id) before considering
output.color, which causes inline TaskOutput colors to be overridden; update the
selection logic so color = output.color or _lookup_color(teammate_colors,
output.teammate_id) (i.e., prefer output.color first) and then pass that color
into _teammate_badge when appending the ("Teammate", ...) row.

In `@claude_code_log/html/templates/components/teammate_styles.css`:
- Around line 49-53: Add the missing blank line before the first declaration in
the .teammate-message rule and replace the deprecated declaration word-break:
break-word with the modern equivalent (use overflow-wrap: anywhere or
overflow-wrap: break-word/normal as appropriate to preserve behavior); apply the
same blank-line and word-break → overflow-wrap change to the other
teammate-related CSS rule blocks in this file that showed the same Stylelint
violations.

In `@claude_code_log/markdown/renderer.py`:
- Around line 454-465: _format_teammate_block_markdown currently falls back to
_COLOR_CIRCLE["default"] when block.color is empty, ignoring learned colors;
change it to check self._teammate_colors for an existing color for
block.teammate_id before using the default. Specifically, compute emoji by first
using (block.color or "").lower() if present, otherwise look up
self._teammate_colors.get(block.teammate_id) and map that through _COLOR_CIRCLE
(falling back to "default" only if neither provides a mapped color); keep the
existing system override using block.is_system and preserve the rest of the
function behavior.
- Around line 868-887: format_TaskListOutput currently only escapes pipes in
subject, so status and owner (and newlines in any cell) can break the Markdown
table; add a small cell-escape helper (e.g., normalize_cell) and use it for
every cell derived from untrusted data (apply to subject, status, owner, and
even task.id if IDs can be non-numeric) before building lines; the helper should
escape pipe characters (|) and replace newlines with a safe marker or space
(e.g., replace "\n" with " " or "\\n"), then call _teammate_marker(task.owner,
colors.get(task.owner)) first if needed and pass its result through the helper;
update format_TaskListOutput to use this helper for all cells so rows cannot be
split or injected by transcript content.

In `@claude_code_log/renderer.py`:
- Around line 113-120: The teammate_colors map on RenderingContext is currently
global keyed only by teammate_id, causing color leakage across combined
transcripts; change teammate_colors (the field) to be scoped per render session
(e.g., dict[str, dict[str, str]] mapping render_session_id -> {teammate_id ->
color} or use a composite key render_session_id + teammate_id) and update all
places that read/write it (including the color accumulation logic and the
consumers referenced around the other block) to index by the current
TemplateMessage.render_session_id when storing or looking up colors so each
render session gets its own teammate color map.

---

Outside diff comments:
In `@claude_code_log/html/templates/transcript.html`:
- Around line 70-80: The transcript filters are missing a "teammate" toggle so
messages with class .message.teammate remain filtered-hidden by default; add a
filter button like the others with class "filter-toggle active" and
data-type="teammate" (label e.g., "🧑‍🤝‍🧑 Teammate") wherever the other filter
buttons are defined, and update applyFilter() to recognize "teammate" alongside
"user/system/assistant/thinking/tool/sidechain/image" so .message.teammate
elements are correctly shown/hidden when filters are applied.
🪄 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: d92f698a-2a50-40a6-bc6f-a29aecc1c587

📥 Commits

Reviewing files that changed from the base of the PR and between d4bcd58 and 9ee9502.

📒 Files selected for processing (11)
  • 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/html/templates/transcript.html
  • claude_code_log/markdown/renderer.py
  • claude_code_log/renderer.py
  • test/__snapshots__/test_snapshot_html.ambr
  • test/__snapshots__/test_snapshot_markdown.ambr
  • test/test_snapshot_html.py
  • test/test_snapshot_markdown.py
  • test/test_teammates_browser.py

Comment thread claude_code_log/html/renderer.py
Comment thread claude_code_log/html/renderer.py Outdated
Comment thread claude_code_log/html/teammate_formatter.py
Comment thread claude_code_log/html/templates/components/teammate_styles.css
Comment thread claude_code_log/markdown/renderer.py Outdated
Comment thread claude_code_log/markdown/renderer.py Outdated
Comment thread claude_code_log/renderer.py Outdated
cboos added 4 commits April 20, 2026 10:08
Without the CSS_CLASS_REGISTRY entry, css_class_from_message fell
back to msg.type="user", so the outer .message wrapper around a
TeammateMessage only carried `user` — timeline.html's detection
saw `user` first and grouped teammate entries with plain user
messages. The .teammate-message class that the formatter emits
only exists on *inner* divs, never reaching the outer wrapper
the timeline reads.

Fix both halves atomically (either alone is a no-op):

- claude_code_log/html/utils.py::CSS_CLASS_REGISTRY: add
  `TeammateMessage: ["user", "teammate"]`. Keep "user" for the
  base styling + add "teammate" as a specific modifier.

- claude_code_log/html/templates/components/timeline.html:
  new `teammate` entry in messageTypeGroups, a dedicated
  `classList.includes('teammate')` branch that precedes the
  generic `.find(...)` so it wins over the `user` class in DOM
  order, and insertion of `'teammate'` in groupOrder between
  `user` and `system`.

Verified the outer wrapper now carries `class='message user teammate d-0'`
on rendered teammate-message entries.

Reported by CodeRabbit on PR #122, flagged as Major by monk's
review introspection (thread 2289).
#4)

Three closely-related color-propagation follow-ups from the
CodeRabbit-driven review introspection on PR #122 (thread 2289).

## #2 — Missed teammate_colors pass on 2 HTML dispatches

- `format_TeammateMessage` called `format_teammate_content(content)`
  without the color map, so later <teammate-message> blocks without
  an inline `color=` attribute rendered with the default gray
  instead of inheriting the teammate's learned session color.
- `format_SendMessageOutput` called `_format_sendmessage_output(output)`
  without the color map, so the `target` field rendered as plain
  escaped text instead of a colored badge (breaking symmetry with
  `format_SendMessageInput`'s recipient badge).

Extend both formatter signatures with an optional `teammate_colors`
parameter, thread `self._teammate_colors` through the HtmlRenderer
dispatchers, and pick the color as `block.color or cache[id]` /
`cache[target]` respectively.

## #3 — Inline color wins over cache on TaskOutput

`format_task_output_teammate_extras` used
`_lookup_color(map, id) or output.color`, so the session-wide
cache beat the inline `output.color` field. Inline is specific to
this agent-spawn result and should win over the general map.

Swap the `or` order to `output.color or _lookup_color(map, id)`.

## #4 — Markdown teammate block fallback to cache

Same bug as #2 on the Markdown side:
`_format_teammate_block_markdown` only consulted `block.color`,
jumping to the default-gray circle when absent even though
`self._teammate_colors` was already populated for the session.

Fall back to `self._teammate_colors[block.teammate_id]` when the
inline color is missing — matches the HTML-side precedence.

Snapshot tests refreshed for the new colored SendMessage target
badge rendering.

All three findings originally reported by CodeRabbit on PR #122;
triaged as Major by monk's post-review introspection.
combined_transcripts.html merges multiple Claude Code sessions. The
previous `RenderingContext.teammate_colors: dict[str, str]` was keyed
only by teammate_id, so first-sighting-wins silently cross-contaminated:
session A's `alice=blue` overrode session B's `alice=red` even though
they're independent sessions.

## Changes

- `RenderingContext.teammate_colors` → `dict[session_id, dict[teammate_id, str]]`.
  `_populate_teammate_colors` now keys by `(template_msg.meta.session_id,
  block.teammate_id)`.

- Both renderers snapshot the nested map as
  `self._teammate_colors_by_session` and expose `_colors_for(message)`
  returning the per-session `dict[str, str]`. Every `format_*` method
  that previously used `self._teammate_colors` now calls
  `self._colors_for(message)` / `self._colors_for(_)` so the lookup
  happens in the message's session scope.

- Markdown's internal `_format_teammate_block_markdown` helper now
  takes `session_colors` as a parameter (called by `format_TeammateMessage`
  which has the TemplateMessage to derive scope).

## Regression test

`test_teammate_colors_are_session_scoped`: builds two single-entry
sessions where `<teammate-message teammate_id="alice">` uses `color="blue"`
in session A and `color="red"` in session B, loads them via
`load_directory_transcripts` (the combined-transcript path), then
asserts the renderer's ctx.teammate_colors nested-map has both colors
distinctly keyed by session:

```python
{
    "ssn-a-0001": {"alice": "blue"},
    "ssn-b-0001": {"alice": "red"},
}
```

Without the fix the test fails with either blue or red winning for
both sessions (depending on load order). Snapshots refreshed for the
new SendMessage target badge + per-session lookup paths.

Reported by CodeRabbit on PR #122; flagged by monk's review
introspection as "correctness; lean fix-it" despite the borderline
YAGNI angle. Combined transcripts are a shipped feature so the
bug is real, not hypothetical.
format_TaskListOutput previously escaped `|` only on the subject
cell; id/status/owner were passed through verbatim. A malformed
transcript with `|` in status ("in|progress") or `\n` in a value
would silently split the row into extra cells or terminate it,
shifting every following row's alignment.

Add a `_table_cell` helper at module scope that:
- converts None → empty string (cell renders blank)
- replaces `\n` with `<br>` (GFM preserves `<br>` in cells; the
  visual line break intent survives)
- escapes `|` as `\|`

Apply to id, status, and subject cells. Owner is rendered via
`_teammate_marker` (palette circle + backticked name) which is
pipe-/newline-free by construction, so skip the escape there to
keep the backticks live.

## Regression test

`test_tasklist_markdown_escapes_pipes_and_newlines` feeds rows with
`A | B` subject, `in|progress` status, and a `\n` in the subject.
Asserts:
- `A \| B` and `in\|progress` appear in output
- `multi<br>line` replaces the literal newline
- total newline count matches the 2-row table (header + separator +
  2 data rows = 3 newlines between lines)

Reported by CodeRabbit on PR #122; classified as Minor by monk's
review introspection.
cboos added a commit that referenced this pull request Apr 20, 2026
Two CodeRabbit-flagged Stylelint violations in teammate_styles.css.

1. **`word-break: break-word` is deprecated** (MDN: non-standard,
   `word-break: break-word` is a no-op in spec-compliant renderers).
   The modern equivalent is `overflow-wrap: anywhere` — breaks within
   words when they'd otherwise overflow, preserves normal word
   boundaries elsewhere.

2. **Missing blank line between custom-property declarations and
   regular declarations** (stylelint `declaration-empty-line-before`).
   Four rules had this shape: `--cc-color: …` immediately followed
   by `border-left: … var(--cc-color)`. Insert one blank line before
   the first non-custom-property declaration in each affected block:
   `.teammate-message`, `.teammate-tool-card.team-card`,
   `.teammate-tool-card.task-update-card`,
   `.teammate-tool-card.send-message-card`.

Purely style — no rendering change. Snapshots refreshed to reflect
the whitespace + `overflow-wrap` tokens.

Reported by CodeRabbit on PR #122; classified as Minor by monk's
review introspection.
cboos added a commit that referenced this pull request Apr 20, 2026
Seven-point checklist monk uses to tighten reviews in the territory
CodeRabbit caught issues PR #122 approval had missed. Covers: new
MessageContent registration, context-propagated state audit, precedence
in or-coalesced expressions, HTML/Markdown renderer symmetry, running
every linter the project uses, Markdown table cell escaping, and
session-scoped state in combined transcripts.

Lives in dev-docs/ (not CLAUDE.md) because it's reviewer-specific
tooling — monk reads it when a PR lands in relevant territory.

Written by monk after introspecting on the 7 CodeRabbit findings from
the rendering PR.
@cboos cboos force-pushed the dev/teammates-rendering branch from f06f8b3 to 5b3b29a Compare April 20, 2026 17:20
Two CodeRabbit-flagged Stylelint violations in teammate_styles.css.

1. **`word-break: break-word` is deprecated** (MDN: non-standard,
   `word-break: break-word` is a no-op in spec-compliant renderers).
   The modern equivalent is `overflow-wrap: anywhere` — breaks within
   words when they'd otherwise overflow, preserves normal word
   boundaries elsewhere.

2. **Missing blank line between custom-property declarations and
   regular declarations** (stylelint `declaration-empty-line-before`).
   Four rules had this shape: `--cc-color: …` immediately followed
   by `border-left: … var(--cc-color)`. Insert one blank line before
   the first non-custom-property declaration in each affected block:
   `.teammate-message`, `.teammate-tool-card.team-card`,
   `.teammate-tool-card.task-update-card`,
   `.teammate-tool-card.send-message-card`.

Purely style — no rendering change. Snapshots refreshed to reflect
the whitespace + `overflow-wrap` tokens.

Reported by CodeRabbit on PR #122; classified as Minor by monk's
review introspection.
@cboos cboos force-pushed the dev/teammates-rendering branch from 5b3b29a to 4097e3b Compare April 20, 2026 17:47
@cboos cboos merged commit f6c7a9e into main Apr 24, 2026
11 checks passed
cboos added a commit that referenced this pull request Apr 24, 2026
…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 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>
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.

1 participant