Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,25 @@ def title_ToolUseMessage(
return ""
return super().title_ToolUseMessage(content, message)

def title_ToolResultMessage(
self, content: ToolResultMessage, message: TemplateMessage
) -> str:
"""Tool-result title, plus the fully-collapsed-transcript marker
(#213 visual layer): when a nested Task/Agent spawn's entire
transcript deduped away (the sub-agent answered directly, no tool
calls), tag the result so it reads as 'this IS the whole transcript'
rather than a spawn that produced nothing."""
base = super().title_ToolResultMessage(content, message)
if message.spawns_collapsed_transcript:
marker = (
"<span class='spawn-collapsed-marker'"
" title='The sub-agent answered directly (no tool calls); its"
" full transcript was just the prompt and this result'>"
"≡ full transcript</span>"
)
return f"{base} {marker}" if base else marker
return base

def title_TaskInput(self, input: TaskInput, message: TemplateMessage) -> str:
"""Title → '🔧 Task <desc> (subagent_type) [async #<id>]'.

Expand Down Expand Up @@ -968,21 +987,42 @@ def title_TaskInput(self, input: TaskInput, message: TemplateMessage) -> str:
if input.run_in_background
else ""
)
suffix = async_hint + self._agent_depth_badge(message)
if input.description and input.subagent_type:
escaped_desc = escape_html(input.description)
return (
f"🔧 {escaped_name} <span class='tool-summary'>{escaped_desc}</span>"
f" <span class='tool-subagent'>({escaped_subagent})</span>"
f"{async_hint}"
f"{suffix}"
)
elif input.description:
return self._tool_title(message, "🔧", input.description) + async_hint
return self._tool_title(message, "🔧", input.description) + suffix
elif input.subagent_type:
return (
f"🔧 {escaped_name} <span class='tool-subagent'>({escaped_subagent})</span>"
f"{async_hint}"
f"{suffix}"
)
return f"🔧 {escaped_name}{async_hint}"
return f"🔧 {escaped_name}{suffix}"

def _agent_depth_badge(self, message: TemplateMessage) -> str:
"""Depth badge for a nested spawn card (#213 visual layer).

Shows the depth of the sub-agent this Task/Agent call opens — the
card's own ``agent_depth`` + 1, since the spawned agent sits exactly
one level deeper. Empty for top-level spawns (which open a depth-1
transcript) so the common shallow case stays uncluttered. The badge's
ring class colour-matches the group line that frames the transcript
directly below it.
"""
spawned_depth = message.agent_depth + 1
if spawned_depth < 2:
return ""
ring = ((spawned_depth - 1) % 5) + 1
return (
f" <span class='agent-depth-badge agent-ring-{ring}'"
f" title='Opens a depth-{spawned_depth} sub-agent transcript'>"
f"Depth {spawned_depth}</span>"
)

def _async_id_suffix(
self,
Expand Down
10 changes: 10 additions & 0 deletions claude_code_log/html/templates/components/global_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@
--workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;

/* Nested-agent depth ramp (#213 visual layer): the group line framing a
depth-d sub-agent transcript cycles through these 5 colors via
((d-1) mod 5). Ring 1 is tool-green so a plain depth-1 sub-agent keeps
its existing look; deeper rings stay distinct against the cream bg. */
--agent-ring-1: #4caf50;
--agent-ring-2: #1e88e5;
--agent-ring-3: #8e44ad;
--agent-ring-4: #e67e22;
--agent-ring-5: #00897b;

/* Fork/branch structural colors */
--fork-point-color: #adb5bd;
--branch-point-color: #adb5bd;
Expand Down
93 changes: 91 additions & 2 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1544,14 +1544,97 @@ details summary {
to the tool_use when the spawn has no result yet (running /
interrupted). The :not(.workflow_agent) keeps this higher-specificity
rule off workflow agents' side-channel groups (their card is ALSO
tool_use-classed; their group line is grey, see below). Per-depth
line colors + depth badges: PR2 of #213. */
tool_use-classed; their group line is grey, see below). This is the
BASE: indent 2em + tool-green; the per-depth ramp below recolours
rings 2-5 and the deep-indent rule below compresses the step. */
.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.sidechain),
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
border-left: 2px solid var(--tool-use-color);
}

/* Per-depth colour ramp (#213 visual layer): the group line takes the colour
of the depth it frames, cycling every 5 levels. Ring 1 = tool-green is the
base above (no override needed). Rings 2-5 override border-left-color only;
same selector shape as the base → equal specificity, so source order (these
come after) wins the tie while margin-left is inherited from the base. */
.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.agent-ring-2),
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.agent-ring-2) {
border-left-color: var(--agent-ring-2);
}

.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.agent-ring-3),
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.agent-ring-3) {
border-left-color: var(--agent-ring-3);
}

.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.agent-ring-4),
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.agent-ring-4) {
border-left-color: var(--agent-ring-4);
}

.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.agent-ring-5),
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.agent-ring-5) {
border-left-color: var(--agent-ring-5);
}

/* Deep-chain indent compression (#213 visual layer): the cumulative 2em step
marches very deep chains off-screen (observed 79 levels in the wild). Once
the depth badge carries the absolute depth, levels 6+ (cards tagged
.agent-deep) need only a token step — depth stays legible via the badge and
the cycling line colour, not the indent. Steps 1-5 keep the comfortable 2em. */
.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.agent-deep),
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.agent-deep) {
margin-left: 0.5em;
}

/* Depth badge on a nested spawn card's title (#213 visual layer): a small
pill reading e.g. "d3" for "opens a depth-3 sub-agent". Its ring class
colour-matches the group line framing the transcript directly below. */
.agent-depth-badge {
display: inline-block;
font-size: 0.7em;
font-weight: 700;
line-height: 1.4;
padding: 0 0.45em;
border-radius: 0.8em;
vertical-align: middle;
color: #fff;
background-color: var(--agent-ring-1);
}

.agent-depth-badge.agent-ring-1 {
background-color: var(--agent-ring-1);
}

.agent-depth-badge.agent-ring-2 {
background-color: var(--agent-ring-2);
}

.agent-depth-badge.agent-ring-3 {
background-color: var(--agent-ring-3);
}

.agent-depth-badge.agent-ring-4 {
background-color: var(--agent-ring-4);
}

.agent-depth-badge.agent-ring-5 {
background-color: var(--agent-ring-5);
}

/* "≡ full transcript" marker on a fully-collapsed nested spawn's result
(#213 visual layer): the sub-agent answered directly, so its whole
transcript was just the prompt + this result — nothing was hidden. Muted
so it reads as reassurance, not a warning. */
.spawn-collapsed-marker {
font-size: 0.8em;
font-weight: 500;
color: var(--text-muted, #888);
font-style: italic;
white-space: nowrap;
}

/* A phase's agents group — continues the phase card's dark green. */
.message-node:has(> .message.workflow_phase) > .children {
margin-left: 2em;
Expand Down Expand Up @@ -1580,6 +1663,12 @@ details summary {
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2%;
}

/* Deep-chain compression mirrored for the percentage scale. */
.message-node:has(> .message.tool_result) > .children:has(> .message-node > .message.agent-deep),
.message-node:has(> .message.tool_use:not(.workflow_agent)) > .children:has(> .message-node > .message.agent-deep) {
margin-left: 0.6%;
}
}

/* Phase pills double as anchor links to their phase card when the splice
Expand Down
14 changes: 14 additions & 0 deletions claude_code_log/html/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,20 @@ def css_class_from_message(msg: "TemplateMessage") -> str:
if msg.is_sidechain:
parts.append("sidechain")

# Agent-nesting depth (#213 visual layer). A message at depth d (d >= 1)
# carries:
# - ``agent-depth-{d}`` exact depth (data / tests / timeline)
# - ``agent-ring-{1..5}`` 5-colour cycle bucket for the group line
# - ``agent-deep`` d >= 6 → compress the indent step
# All three are derivable from d, but CSS can't compute them, so we emit
# the classes. Depth 0 (trunk / non-agent) adds nothing.
if msg.agent_depth >= 1:
d = msg.agent_depth
parts.append(f"agent-depth-{d}")
parts.append(f"agent-ring-{((d - 1) % 5) + 1}")
if d >= 6:
parts.append("agent-deep")

return " ".join(parts)


Expand Down
32 changes: 29 additions & 3 deletions claude_code_log/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,22 @@ def __init__(
# JSON blocks extracted into params tables.
self.in_workflow_sidechannel: bool = False

# Agent-nesting depth of this message's session line (#213 visual
# layer): 0 for the trunk / non-agent messages, 1 for a directly
# spawned sub-agent, 2 for a sub-agent of a sub-agent, … Set by
# _build_message_hierarchy (chasing spawned_agent_id links); drives
# the per-depth group-line colour ramp, the spawn-card depth badge,
# and the deep-chain indent compression.
self.agent_depth: int = 0

# Set by _cleanup_sidechain_duplicates on a Task/Agent spawn
# tool_result whose sub-agent transcript collapsed ENTIRELY into the
# prompt + result already shown (the agent answered directly, with no
# surviving tool calls or thinking). Lets the renderer mark it so a
# fully-elided transcript reads as "nothing hidden" rather than as a
# spawn that produced no transcript at all (#213 visual layer).
self.spawns_collapsed_transcript: bool = False

# Per-render annotations populated by the HTML renderer's tree walk
# (HtmlRenderer._annotate_tree_for_render). The recursive template
# macro reads these instead of receiving a flat (msg, title, html,
Expand Down Expand Up @@ -2422,9 +2438,9 @@ def _agent_depth(sid: str) -> int:
# Determine level from message type and modifiers, shifted by
# the agent-nesting depth of the message's session line.
current_level = _get_message_hierarchy_level(message)
agent_depth = _agent_depth(message.meta.session_id or "")
if agent_depth > 1:
current_level += 2 * (agent_depth - 1)
message.agent_depth = _agent_depth(message.meta.session_id or "")
if message.agent_depth > 1:
current_level += 2 * (message.agent_depth - 1)

# Pop stack until we find the appropriate parent level
while hierarchy_stack and hierarchy_stack[-1][0] >= current_level:
Expand Down Expand Up @@ -3581,6 +3597,16 @@ def process_message(message: TemplateMessage) -> None:
del children[i]
break

# Fully-collapsed nested spawn (#213 visual layer): the sub-agent's
# whole transcript was just prompt → answer (both already shown), so
# dedup emptied it. Mark it — but only for a NESTED spawn (the result
# card itself sits inside an agent transcript, ``agent_depth >= 1``);
# trunk-level direct sub-agents keep their pre-#213 rendering. The
# marker distinguishes "transcript identical to result" from "spawn
# with no transcript at all", which otherwise look the same.
if not children and message.agent_depth >= 1:
message.spawns_collapsed_transcript = True

for root in root_messages:
process_message(root)

Expand Down
42 changes: 40 additions & 2 deletions dev-docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,47 @@ round-trip verbatim through its spawn pair collapses entirely — a
trivial leaf shows nothing beyond its parent's tool_use/tool_result
pair, exactly like depth-1 sync agents.

### 5.4 Fixture
### 5.4 Visual layer

`_build_message_hierarchy` stores the chased depth on every message as
`TemplateMessage.agent_depth` (0 = trunk). `css_class_from_message`
turns `agent_depth >= 1` into three classes on the card:
`agent-depth-{d}` (exact, for the badge/tests), `agent-ring-{1..5}`
(the `((d-1) mod 5)+1` colour-cycle bucket), and `agent-deep` for
`d >= 6`. The CSS (`message_styles.css`) then keys off the inner
sidechain card's class so the group line framing a depth-`d`
transcript:

- takes the **ring colour** for that depth (depth 1 = tool-green, the
pre-existing look; 2 blue, 3 purple, 4 orange, 5 teal — see the
`--agent-ring-*` vars), and
- **compresses its indent step** at `d >= 6` (2em for depths 1-5, then
0.5em) so very deep chains (79 levels seen in the wild) stay
on-screen — depth is carried by the badge + colour, not the indent.

Two card-level annotations complete the layer:

- **Depth badge** (`_agent_depth_badge`, `html/renderer.py`): a "Depth
N" pill on a spawn card showing the depth of the sub-agent it *opens*
(`agent_depth + 1`), suppressed for top-level spawns (depth 1) to
keep shallow transcripts clean; its ring colour matches the group
line below.
- **Collapsed marker** (`spawns_collapsed_transcript`, set in
`_cleanup_sidechain_duplicates` when a NESTED spawn's transcript
dedups to nothing): `title_ToolResultMessage` tags the result
"≡ full transcript" — the sub-agent answered directly, so what's
shown is its whole transcript, distinct from a spawn that produced
none. Nested-only (`agent_depth >= 1`); trunk-level direct
sub-agents keep their pre-#213 rendering.

### 5.5 Fixture

`test/test_data/nested_agents/` (generated by
`scripts/gen_nested_agents_fixture.py`): a 2×2 fan-out, a 3-deep chain,
and an interrupted spawn linkable only via its sidecar. Pinned by
`test/test_nested_agents.py`.
`test/test_nested_agents.py` (structure + visual-layer logic) and
`test/test_nested_agents_browser.py` (runtime CSS: ramp colours,
indent, badge, marker). Note the fixture's agents answer directly with
no thinking blocks; real sub-agents usually *think* before spawning,
so an agent's own thinking→spawn nesting renders as an invisible
0-width passthrough group — only true agent boundaries draw a line.
Loading
Loading