Skip to content

Comments

Fix content block array rendering when content is Python list#13

Closed
ShlomoStept wants to merge 8 commits intomainfrom
evaluation/markdown-json-rendering-fixes
Closed

Fix content block array rendering when content is Python list#13
ShlomoStept wants to merge 8 commits intomainfrom
evaluation/markdown-json-rendering-fixes

Conversation

@ShlomoStept
Copy link
Owner

Summary

Fixes a critical bug where content block arrays were not rendering as Markdown when the content was a Python list instead of a JSON string.

Root Cause

Bug Location: Lines 1244-1245 in __init__.py

elif isinstance(content, list) or is_json_like(content):
    content_markdown_html = format_json(content)

When tool_result content was a Python list (e.g., [{"type": "text", "text": "## Report..."}]), the code:

  1. Skipped the isinstance(content, str) block (which correctly handles JSON string arrays)
  2. Fell through to line 1244 where it just formatted the list as JSON for the markdown view
  3. Never called render_content_block_array() to properly render the content blocks

The Fix

  1. Added is_content_block_list() helper function to detect if a Python list contains content blocks

  2. Updated tool_result handling to check for content block lists and render them properly:

elif isinstance(content, list):
    if is_content_block_list(content):
        rendered = render_content_block_array(content)
        if rendered:
            content_markdown_html = rendered
        else:
            content_markdown_html = format_json(content)
    else:
        content_markdown_html = format_json(content)
  1. Added comprehensive tests for this exact scenario

Before/After

Before (bug): Both Markdown and JSON views showed raw JSON:

[{"type": "text", "text": "## Report\n\n### Findings\n..."}]

After (fixed):

  • Markdown view: <h2>Report</h2><h3>Findings</h3>... (rendered HTML)
  • JSON view: Formatted JSON in code block

Test plan

  • All 127 tests in test_generate_html.py pass
  • All 32 tests in test_all.py pass
  • New tests added for content block list handling

🤖 Generated with Claude Code

ShlomoStept and others added 8 commits January 6, 2026 02:53
Add visual indicators and metadata extraction for Task and Agent tool calls
that spawn subagent processes. This enhancement helps users understand when
and why Claude Code spawns subagents during session processing.

Features:
- Detect Task and Agent tool calls as subagent spawning operations
- Extract subagent_type (Explore, Plan, code-reviewer, etc.)
- Show visual badge with subagent type in purple gradient
- Display truncated prompt preview for context
- Indicate resume operations when resuming existing agents
- Find related agent session files in the same directory
- Extract agent IDs from tool results

Visual improvements:
- Purple left border for subagent tools (distinct from other tools)
- Gradient badge with lightning bolt icon
- Prompt preview with styled container
- Resume indicator for continued sessions

Tests:
- 16 new tests covering all subagent detection functionality
- Tests for is_subagent_tool, extract_subagent_info
- Tests for extract_agent_id_from_result with strict patterns
- Tests for find_related_agent_sessions
- Tests for render_subagent_tool rendering
- CSS presence verification tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Document proposed enhancements to improve usability, maintainability, and
scalability of claude-code-transcripts:

1. Session Timeline View (Priority P1, Medium effort)
   - Interactive timeline visualization at top of session pages
   - Color-coded segments for different activity types
   - Click-to-navigate functionality
   - Temporal flow understanding for long sessions

2. Diff View for Edit Operations (Priority P1, Medium effort)
   - Unified diff display for old_string/new_string comparisons
   - Side-by-side and unified view modes
   - Syntax highlighting preserved
   - Uses standard library difflib

3. Keyboard Navigation (Priority P2, Low-Medium effort)
   - Comprehensive shortcuts for session navigation
   - Cell expansion/collapse with single keys
   - View mode switching (Markdown/JSON)
   - Accessibility improvements (WCAG 2.1)

Each proposal includes:
- Problem statement
- Proposed solution with implementation details
- Files to modify and key code changes
- Estimated effort
- User impact analysis
- Technical considerations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update PLAN.md with comprehensive Phase 3 grading report:

- Overall score: 85.01 (up from 83.24, +1.77)
- Templates (macros.html): 86.20 (+1.0)
- Core (__init__.py): 82.45 (+1.95)
- Tests: 89.45 (+2.3)

Key improvements:
- C.1 Subagent Detection fully implemented
- 16 new tests (157 total), all passing
- FEATURE_PROPOSALS.md with 3 detailed proposals
- New functions follow established patterns

Cumulative improvement from Phase 2 initial: +3.73 points

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
## Investigation Plan

This branch addresses the following issues:

### Issue 1: Subagent Content Display
- Subagent content is not being provided in full

### Issue 2: Markdown/JSON Rendering in Tool Calls
- Markdown content duplicated above JSON in tool call area
- Long markdown content hidden and not scrollable
- JSON not wrapped in code block in Markdown mode
- No visual difference when switching to JSON mode for tool results
- Subcells (tool call/reply sections) not collapsible

## Approach
1. Analyze template files (macros.html, page.html) for rendering logic
2. Identify JavaScript handling of markdown/JSON toggle
3. Locate CSS styling for tool call containers
4. Implement fixes with proper separation of concerns
5. Add collapsible functionality to subcells

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix content duplication: Restructure view-markdown and view-json to have
  independent truncatable wrappers instead of sharing one container
- Fix hidden content: Each view now calculates height independently
- Fix JSON code block: Wrap render_json_with_markdown output in <pre> tag
- Add visual differentiation: Markdown and JSON modes now show distinct content
- Make tool pairs collapsible: Convert tool_pair macro to use details/summary
- Update CSS with collapsible tool pair styles and json-markdown class
- Update test for new tool-pair-collapsible class name
- Update 11 snapshots to reflect structural changes

Addresses issues:
- Duplication of markdown/JSON content in tool call area
- Long content becoming hidden and non-scrollable
- JSON not wrapped in code block in Markdown mode
- No visual difference between modes for tool results
- Tool call/result sections not collapsible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
## Purpose
Evaluate whether the fixes from PR #12 (fix/markdown-json-rendering-v2) properly address
all reported issues.

## Issues to Verify

### Issue 1: Subagent Content Display
- Verify subagents are provided in full (not truncated)

### Issue 2: Markdown/JSON Rendering
- [ ] Content duplication: Only ONE view (markdown OR JSON) should display at a time
- [ ] Scrollability: Long markdown content should be scrollable, not hidden
- [ ] Code block wrapping: JSON should be wrapped in code blocks in BOTH modes
- [ ] Mode differentiation: Visual difference when switching markdown/JSON modes
- [ ] Collapsible sections: Tool call/reply should be collapsible

## Evaluation Method
1. Analyze the template changes in macros.html
2. Review CSS/JS changes in __init__.py
3. Generate test HTML output and verify behavior
4. Run existing tests to ensure no regressions
5. Provide comprehensive grading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When tool_result content is a Python list (not a JSON string), the markdown
view was incorrectly showing raw JSON instead of rendering the content blocks.

Root cause: Lines 1244-1245 were falling through to format_json() for all
list content, bypassing the render_content_block_array() logic that properly
renders content blocks like [{"type": "text", "text": "## Report..."}].

Changes:
- Add is_content_block_list() helper to check if a Python list contains
  content blocks (dicts with 'type' key)
- Update tool_result handling in render_content_block() to check for content
  block lists and render them properly instead of just formatting as JSON
- Add tests for Python list content rendering with markdown headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings January 7, 2026 04:02
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR claims to fix a critical bug where content block arrays weren't rendering as Markdown when content is a Python list. However, the actual changes include extensive additional functionality far beyond what's described in the title.

Key changes:

  • Added is_content_block_list() helper to detect Python list content blocks (the stated fix)
  • Restructured HTML templates to separate view-markdown and view-json into independent truncatable containers
  • Added collapsible tool sections using <details> elements
  • Implemented comprehensive subagent detection system with 110+ lines of new code and 200+ new test lines
  • Added JSON code block wrapping for markdown mode rendering

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/claude_code_transcripts/__init__.py Added is_content_block_list() function and updated tool_result handling; added extensive subagent detection functions (331-446); added new CSS for collapsible tool pairs and subagent styling
src/claude_code_transcripts/templates/macros.html Restructured tool_use, subagent_tool, and tool_result macros; converted tool_pair to collapsible <details> element
tests/test_generate_html.py Added 2 tests for content block list bug fix; added 200+ lines of subagent detection tests (lines 2015-2217)
tests/__snapshots__/*.ambr Updated 11 snapshot files to reflect new HTML structure
walkthrough.md Rewritten to describe broader "Markdown/JSON Rendering Fixes" rather than the specific bug
task.md Updated to track broader rendering issues, not the content block bug
PLAN.md Added Phase 3 completion details for subagent detection
FEATURE_PROPOSALS.md New file with 197 lines proposing future features
implementation_plan.md Completely rewritten to describe rendering fixes instead of original plan

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +623 to +682
def test_tool_result_content_block_array_as_list(self, snapshot_html):
"""Test that tool_result with content as a Python list renders properly.

This is a critical bug fix test: when content is a Python list (not a JSON
string), the markdown view should still render the content blocks, not show
raw JSON.
"""
block = {
"type": "tool_result",
"content": [
{
"type": "text",
"text": "## Deployment Report\n\n### Findings\n\n- Issue 1\n- Issue 2",
},
{
"type": "text",
"text": "agentId: abc1234 (for resuming)",
},
],
"is_error": False,
}
result = render_content_block(block)
# Should render markdown content, not raw JSON
assert "<h2>" in result or "Deployment Report</p>" in result
assert "Findings" in result
assert "Issue 1" in result
# Should NOT show raw JSON structure in the markdown view
# The view-markdown div should contain rendered HTML, not JSON
assert 'class="view-markdown"' in result
# Check that the markdown view doesn't contain escaped JSON type field
import re

md_view_match = re.search(
r'class="view-markdown"[^>]*>(.*?)class="view-json"', result, re.DOTALL
)
if md_view_match:
md_view_content = md_view_match.group(1)
# Markdown view should NOT contain the raw JSON structure
assert "&quot;type&quot;: &quot;text&quot;" not in md_view_content
assert result == snapshot_html

def test_tool_result_content_block_array_list_with_multiple_blocks(self):
"""Test that multiple content blocks in a Python list all render."""
block = {
"type": "tool_result",
"content": [
{"type": "text", "text": "First block"},
{"type": "text", "text": "Second block"},
{"type": "text", "text": "Third block"},
],
"is_error": False,
}
result = render_content_block(block)
# All three blocks should be rendered
assert "First block" in result
assert "Second block" in result
assert "Third block" in result
# Each should be in its own assistant-text div
assert result.count("assistant-text") >= 3

Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The test test_tool_result_content_block_array_as_list doesn't test what happens with edge cases for the new is_content_block_list() function:

Missing test cases:

  1. Mixed list with content blocks and non-content-block dictionaries: [{"type": "text", "text": "hello"}, {"foo": "bar"}]
  2. List with dict that has 'type' key but invalid content block structure: [{"type": "unknown_type", "invalid": "data"}]
  3. Empty list: []
  4. List with null/None values: [None, {"type": "text", "text": "hi"}]
  5. List with non-dict items: ["string", 123, {"type": "text", "text": "hi"}]

These edge cases would help ensure the fix handles all scenarios correctly and doesn't have unexpected behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +348 to +374
def extract_subagent_info(tool_input):
"""Extract subagent metadata from a Task or Agent tool input.

Args:
tool_input: The tool's input dict.

Returns:
Dict with subagent_type, description, prompt (truncated), and agent_id if available.
Returns None if no subagent info can be extracted.
"""
if not isinstance(tool_input, dict):
return None

subagent_type = tool_input.get("subagent_type", "")
description = tool_input.get("description", "")
prompt = tool_input.get("prompt", "")
resume_agent_id = tool_input.get("resume", "")

# For Task tool, extract key info
if subagent_type or description or prompt:
return {
"subagent_type": subagent_type or "Unknown",
"description": description,
"prompt_preview": prompt[:200] + "..." if len(prompt) > 200 else prompt,
"agent_id": resume_agent_id,
"is_resume": bool(resume_agent_id),
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The extract_subagent_info() function returns a dict with agent_id set to the resume_agent_id value (line 372), but this value is only used when is_resume is True. When there's no resume ID, agent_id will be an empty string, which might be confusing.

Consider either:

  1. Not including agent_id in the returned dict when resume_agent_id is empty
  2. Setting agent_id to None instead of an empty string when not present
  3. Clarifying in the docstring that agent_id may be present but empty

This would make the API clearer about when agent_id is meaningful.

Copilot uses AI. Check for mistakes.
Comment on lines +391 to +407
if isinstance(tool_result_content, str):
# Look for common patterns - agent IDs typically look like UUIDs or
# have formats like "agent-{timestamp}" with alphanumeric characters
patterns = [
# "agent ID: abc123def456" or "Agent ID: abc-123-def"
r"agent\s+ID[:\s]+([a-zA-Z0-9_-]{8,})",
# "agentId": "abc123" in JSON-like output
r'agentId["\']?\s*[:\s]\s*["\']?([a-zA-Z0-9_-]{8,})',
# "agent_id": "abc123"
r'agent_id["\']?\s*[:\s]\s*["\']?([a-zA-Z0-9_-]{8,})',
# "Started agent abc123def456789012"
r"Started\s+agent\s+(?:with\s+ID[:\s]+)?([a-zA-Z0-9_-]{8,})",
]
for pattern in patterns:
match = re.search(pattern, tool_result_content, re.IGNORECASE)
if match:
return match.group(1)
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The regex patterns in extract_agent_id_from_result() for matching agent IDs have a minimum length requirement of 8 characters ({8,}). However, the comment on line 393 mentions "agent IDs typically look like UUIDs", which are 36 characters (with hyphens) or 32 characters (without hyphens).

If the minimum of 8 characters is intentional to catch shorter agent IDs, this could lead to false positives matching on common words. For example:

  • "Started agent myagent1" would match "myagent1" (8 chars)
  • "agent ID: shortone" would match "shortone" (8 chars)

Consider either:

  1. Increasing the minimum length to be more conservative (e.g., 12 or 16 characters)
  2. Adding additional validation that the captured ID matches expected format patterns (e.g., UUIDs, specific prefixes)
  3. Documenting in the docstring what the expected minimum and format of agent IDs are

Copilot uses AI. Check for mistakes.
Comment on lines +205 to +206
{% macro tool_pair(tool_use_html, tool_result_html, tool_name="Tool", is_collapsed=false) %}
<details class="tool-pair-collapsible"{% if not is_collapsed %} open{% endif %}>
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The tool_pair macro has a parameter is_collapsed=false with a default value, but the HTML rendering uses {% if not is_collapsed %} open{% endif %}. This creates a double negative that can be confusing.

When is_collapsed=false (default), the condition not is_collapsed is True, so it renders open, meaning the section is expanded by default.

Consider renaming the parameter to open or open_by_default to be more explicit and avoid the double negative. This would make the template code more readable:

{% macro tool_pair(tool_use_html, tool_result_html, tool_name="Tool", open=true) %}
<details class="tool-pair-collapsible"{% if open %} open{% endif %}>

This pattern already exists in the cell macro on line 213, where the parameter is named open_by_default.

Suggested change
{% macro tool_pair(tool_use_html, tool_result_html, tool_name="Tool", is_collapsed=false) %}
<details class="tool-pair-collapsible"{% if not is_collapsed %} open{% endif %}>
{% macro tool_pair(tool_use_html, tool_result_html, tool_name="Tool", open_by_default=true, is_collapsed=None) %}
{%- if is_collapsed is not none -%}
{%- set _open = not is_collapsed -%}
{%- else -%}
{%- set _open = open_by_default -%}
{%- endif -%}
<details class="tool-pair-collapsible"{% if _open %} open{% endif %}>

Copilot uses AI. Check for mistakes.
## Summary of Changes

This document summarizes the technical debt fixes implemented on the `fix/technical-debt` branch.
This document details the fixes implemented on `fix/markdown-rendering-issues` branch to resolve tool call rendering issues in the claude-code-transcripts project.
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The PR title and description focus on a specific bug fix for content block array rendering when content is a Python list. However, the actual changes in this PR include many additional modifications that are not mentioned in the PR title or description:

  1. Restructuring of HTML templates to separate view-markdown and view-json into independent truncatable containers (macros.html)
  2. Adding collapsible tool sections using details/summary elements
  3. Wrapping JSON output in code blocks for markdown mode
  4. Adding extensive subagent detection functionality (lines 331-446 in init.py)
  5. Adding 200+ new tests for subagent detection (lines 2015-2217 in test_generate_html.py)
  6. Documentation updates describing these broader changes (walkthrough.md, task.md, PLAN.md, FEATURE_PROPOSALS.md)

The PR description's "Summary" section claims this is fixing "a critical bug where content block arrays were not rendering as Markdown when the content was a Python list", but this specific fix (lines 140-156 and 1262-1271 in init.py) represents only a small fraction of the total changes in this PR.

This creates confusion about the scope and purpose of the PR. The actual changes appear to be a much broader set of rendering improvements and new features, not just the single bug fix described.

Suggested change
This document details the fixes implemented on `fix/markdown-rendering-issues` branch to resolve tool call rendering issues in the claude-code-transcripts project.
This document focuses on the rendering-related fixes implemented on the `fix/markdown-rendering-issues` branch (tool call and transcript display issues) and does not cover other feature or testing changes made in the same PR.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +155
def is_content_block_list(content):
"""Check if a Python list is a content block array.

Args:
content: Python list to check.

Returns:
True if the list contains content blocks (dicts with 'type' key).
"""
if not isinstance(content, list) or not content:
return False
# Check if at least one item looks like a content block
for item in content:
if isinstance(item, dict) and "type" in item:
return True
return False
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The is_content_block_list() function checks if at least one item in the list has a 'type' key and returns True immediately upon finding such an item. However, this could lead to false positives if the list contains a mix of content blocks and non-content-block dictionaries with a 'type' key.

For example, a list like [{"type": "text", "text": "hello"}, {"type": "error", "code": 500}] where the second item is not a valid content block would still be treated as a content block list.

Consider either:

  1. Checking that ALL items with 'type' keys are valid content blocks (have the expected structure)
  2. Checking that a majority of items are content blocks
  3. Adding validation for known content block types ('text', 'image', 'thinking', 'tool_use', etc.)

Copilot uses AI. Check for mistakes.
Comment on lines +1263 to +1271
# Check if this list is a content block array (e.g., [{"type": "text", ...}])
if is_content_block_list(content):
rendered = render_content_block_array(content)
if rendered:
content_markdown_html = rendered
else:
content_markdown_html = format_json(content)
else:
content_markdown_html = format_json(content)
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The handling for empty lists may not be ideal. When content is an empty list [], is_content_block_list() returns False (line 149), so the code falls through to line 1271 and calls format_json(content) which will display [].

However, an empty content block array is still semantically a content block array - it just happens to be empty. Consider whether empty lists should:

  1. Be rendered as empty markdown (no visible output)
  2. Display a message like "No content"
  3. Be handled the same as non-empty content block arrays

The current behavior treats empty lists differently from non-empty content block arrays, which may be inconsistent.

Suggested change
# Check if this list is a content block array (e.g., [{"type": "text", ...}])
if is_content_block_list(content):
rendered = render_content_block_array(content)
if rendered:
content_markdown_html = rendered
else:
content_markdown_html = format_json(content)
else:
content_markdown_html = format_json(content)
# Treat empty lists as empty markdown (no visible content), while still
# showing the raw JSON in the JSON view.
if not content:
content_markdown_html = ""
else:
# Check if this list is a content block array (e.g., [{"type": "text", ...}])
if is_content_block_list(content):
rendered = render_content_block_array(content)
if rendered:
content_markdown_html = rendered
else:
content_markdown_html = format_json(content)
else:
content_markdown_html = format_json(content)

Copilot uses AI. Check for mistakes.
@ShlomoStept
Copy link
Owner Author

Closing - changes consolidated for upstream PR submission

@ShlomoStept ShlomoStept closed this Jan 9, 2026
ShlomoStept pushed a commit that referenced this pull request Jan 9, 2026
> The gistpreview mechanis has a bug: fragment links like https://gistpreview.github.io/?edbd5ddcb39d1edc9e175f1bf7b9ef9a/page-001.html#msg-2025-12-26T15-30-45-910Z do not naught the user to the right point, presumably because the content has not loaded in time
>
> Fix that with more JavaScript in the existing gistpreview JavaScript

When accessing a gistpreview URL with a fragment (e.g. #msg-2025-12-26T15-30-45-910Z), the browser's native fragment navigation fails because gistpreview loads content dynamically. By the time the browser tries to scroll to the element, it doesn't exist yet.

Added JavaScript that:
- Checks for fragment in window.location.hash
- Uses scrollIntoView to navigate to the target element
- Retries with increasing delays (100ms, 300ms, 500ms, 1s) to handle dynamic content loading

https://gistpreview.github.io/?883ee001b0d63a2045f1e2c7c2598a9f/index.html
ShlomoStept pushed a commit that referenced this pull request Jan 9, 2026
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