Support teammates (#91): rendering (PR 2 of 3)#122
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughThis 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~35 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🟠 MajorAdd
teammateto the transcript filters.
applyFilter()runs on page load and only expands the existing filter types, so.message.teammateentries getfiltered-hiddenby 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
📒 Files selected for processing (11)
claude_code_log/html/renderer.pyclaude_code_log/html/teammate_formatter.pyclaude_code_log/html/templates/components/teammate_styles.cssclaude_code_log/html/templates/transcript.htmlclaude_code_log/markdown/renderer.pyclaude_code_log/renderer.pytest/__snapshots__/test_snapshot_html.ambrtest/__snapshots__/test_snapshot_markdown.ambrtest/test_snapshot_html.pytest/test_snapshot_markdown.pytest/test_teammates_browser.py
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.
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.
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.
f06f8b3 to
5b3b29a
Compare
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.
5b3b29a to
4097e3b
Compare
…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.
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>
* 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>
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:--cc-<color>palette tokens (blue/cyan/green/yellow/orange/red/pink/purple/gray + system),.teammate-messageblock styles with left-aligned colored borders,.teammate-badgepills, task-board table, light + dark backgrounds.teammate_id → colormap 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/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.Commits (4 on top of
main@d4bcd58)03d7cdb— TeammateMessage + 6 tool cards (HTML)e674860— Color propagation via RenderingContextf721a12— Markdown renderers9ee9502— Snapshot + browser testsEach commit stands on its own; bisectable.
Non-goals (PR 3 scope)
Validation
just ciend-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/teammatesproduces well-rendered HTML with colored teammate cards and a Markdown variant with colored-circle markers.One bug caught during test-writing
teammate_styles.csswas originally wrapping itself in<style>...</style>, buttranscript.htmlalready opens one outer<style>for all component CSS. The nested tag closed the outer block prematurely and browsers dropped everything after it —--cc-colornever resolved, so all card borders ended upcurrentColorgray. The browser tests caught it (test_teammate_messages_render_with_distinct_colored_bordersfailed 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
Tests