diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py
index 8e62f0b8..313d167c 100644
--- a/claude_code_log/html/renderer.py
+++ b/claude_code_log/html/renderer.py
@@ -122,6 +122,7 @@
format_user_memory_content,
format_user_slash_command_content,
format_user_text_model_content,
+ format_workflow_sidechannel_user_content,
)
from .assistant_formatters import (
format_assistant_text_content,
@@ -498,8 +499,15 @@ def format_SessionHeaderMessage(
# -------------------------------------------------------------------------
def format_UserTextMessage(
- self, content: UserTextMessage, _: TemplateMessage
+ self, content: UserTextMessage, message: TemplateMessage
) -> str:
+ # User prompts grafted from a workflow agent's side-channel are large
+ # prose+JSON hybrids: render them as collapsible Markdown with the
+ # embedded JSON blocks extracted into params tables (#174 follow-up).
+ if message.in_workflow_sidechannel:
+ return format_workflow_sidechannel_user_content(
+ content, image_formatter=self._format_image
+ )
return format_user_text_model_content(
content, image_formatter=self._format_image
)
diff --git a/claude_code_log/html/templates/components/global_styles.css b/claude_code_log/html/templates/components/global_styles.css
index a6a06890..3e3d8616 100644
--- a/claude_code_log/html/templates/components/global_styles.css
+++ b/claude_code_log/html/templates/components/global_styles.css
@@ -40,7 +40,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css
index 971b473d..99664e26 100644
--- a/claude_code_log/html/templates/components/message_styles.css
+++ b/claude_code_log/html/templates/components/message_styles.css
@@ -1014,40 +1014,108 @@ pre > code {
word-break: break-all;
}
-/* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
-.tool-param-collapsible-rows[open] > summary::after {
- content: none;
+/* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+.tool-param-fold-controls {
+ margin: 2px 0;
}
-.tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
-}
-
-.tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
-.tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+.tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+}
+
+/* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+.tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
-.tool-param-rows-toggle:hover {
+.tool-param-key-toggle:hover {
color: var(--text-primary);
}
+/* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+.tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+}
+
+/* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+}
+
+.tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+[data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+}
+
+/* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+.tool-param-key-toggle,
+.tool-params-expand-all,
+.tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+}
+
+/* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+.tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+}
+
+.tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+}
+
+/* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+.tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+}
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -1464,14 +1532,16 @@ details summary {
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -1554,3 +1624,15 @@ a.workflow-phase-pill:hover {
color: var(--text-muted);
font-size: 0.9em;
}
+
+/* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+.embedded-json {
+ margin: 0.5em 0;
+}
+
+.embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+}
diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html
index f8c38f16..1c8a38c2 100644
--- a/claude_code_log/html/templates/transcript.html
+++ b/claude_code_log/html/templates/transcript.html
@@ -418,16 +418,22 @@
🔍 Search & Filter
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -440,7 +446,10 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -486,6 +503,20 @@
🔍 Search & Filter
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py
index 3745a962..a82c65ff 100644
--- a/claude_code_log/html/tool_formatters.py
+++ b/claude_code_log/html/tool_formatters.py
@@ -1116,12 +1116,15 @@ def _structured_value_html(value: "dict[Any, Any] | list[Any]", depth: int) -> s
def _table_fold_html(formatted_value: str, table_html: str, kind: str) -> str:
"""Wrap a rendered params table in a collapsed fold.
- When the table has row-level folds, the summary carries an explicit
- collapse hint (instead of the generic ::after one) followed by a
- rows-toggle button that expands/collapses them all at once (wired
- up in transcript.html). The "`` is an accessibility violation Chrome
+ flags: not consistently reachable by keyboard/AT). When the table has
+ row-level folds, the rows-toggle button lives in a controls strip
+ AFTER the summary, inside the details — natively hidden while closed,
+ visible when open (wired up in transcript.html). The " 100:
@@ -1129,7 +1132,8 @@ def _table_fold_html(formatted_value: str, table_html: str, kind: str) -> str:
if "
- {preview}collapse
+ {preview}
+
{table_html}
"""
@@ -1155,7 +1159,9 @@ def _params_root_html(table_html: str) -> str:
"
"
@@ -1173,15 +1179,41 @@ def _param_value_html(value: Any, depth: int) -> str:
def _params_table_html(items: "Iterable[tuple[Any, Any]]", depth: int) -> str:
- """Build one key/value table; nested levels get a marker class."""
+ """Build one key/value table; nested levels get a marker class.
+
+ A fold-valued row hoists its ▶/▼ toggle into the KEY column (wired in
+ transcript.html), so the value summary stays free of per-fold collapse
+ chrome — only the previews (closed) and the rows-toggle buttons (open)
+ remain there. The ``"]
for key, value in items:
escaped_key = escape_html(str(key))
value_html = _param_value_html(value, depth)
+ if value_html.lstrip().startswith(" shows, immune to italic contexts.
+ key_cell = (
+ ""
+ )
+ row_attr = " class='tool-param-row-fold'"
+ else:
+ # Scalar rows reserve the same glyph slot (empty) so every key
+ # text starts at the same x.
+ key_cell = f"{escaped_key}"
+ row_attr = ""
html_parts.append(f"""
-
-
{escaped_key}
+
+
{key_cell}
{value_html}
""")
diff --git a/claude_code_log/html/user_formatters.py b/claude_code_log/html/user_formatters.py
index e1b46cbc..9253fff0 100644
--- a/claude_code_log/html/user_formatters.py
+++ b/claude_code_log/html/user_formatters.py
@@ -8,7 +8,9 @@
- tool_formatters.py: tool use/result content
"""
-from typing import Callable, Optional
+import json
+import uuid
+from typing import Any, Callable, Optional, cast
from .ansi_colors import convert_ansi_to_html
from ..models import (
@@ -33,6 +35,7 @@
render_collapsible_code,
render_markdown_collapsible,
render_user_markdown,
+ render_user_markdown_collapsible,
)
@@ -272,6 +275,156 @@ def format_user_text_model_content(
return "\n".join(parts)
+def _json_placeholder() -> str:
+ """A substitution token that survives Markdown rendering verbatim.
+
+ Each uuid4 group is prefixed with ``z`` so no bare 7–40 char hex run
+ remains — otherwise the SHA→commit-URL linkifier (``SHA_PATTERN`` in
+ ``markdown_plugins.py``) would wrap parts of the placeholder in a link
+ and break the back-substitution.
+ """
+ return "-".join(f"z{part}" for part in str(uuid.uuid4()).split("-"))
+
+
+def extract_embedded_json(text: str) -> "tuple[str, dict[str, Any]]":
+ """Pull pretty-printed JSON blocks out of a prose+JSON prompt.
+
+ Workflow sub-agent prompts routinely embed large JSON objects/arrays in
+ otherwise-Markdown prose. The block shape (the only reliable hint): a
+ lone ``{`` or ``[`` on its own line, through a lone matching closer
+ whose next line is blank (or EOF). A candidate only counts when
+ ``json.loads`` accepts it — anything else is left untouched.
+
+ Returns ``(substituted_text, {placeholder: parsed_value})`` where each
+ block is replaced by a unique placeholder line (see
+ :func:`_json_placeholder`); the caller renders the remaining text as
+ Markdown and then swaps each placeholder for a structured rendering of
+ its parsed value. Multiple blocks are supported.
+ """
+ lines = text.split("\n")
+ out_lines: list[str] = []
+ blocks: dict[str, Any] = {}
+ closer_for = {"{": "}", "[": "]"}
+ i = 0
+ in_fence = False
+ while i < len(lines):
+ stripped = lines[i].strip()
+ # Track fence parity (backtick AND tilde fences — both CommonMark):
+ # a JSON example inside a fenced code block must stay verbatim —
+ # extracting it would substitute the table markup INSIDE the
+ # rendered
. (A fence with no internal blank line is
+ # already rejected by the closer check; this covers fences that
+ # carry commentary after a blank line.) One shared toggle for both
+ # fence kinds is an approximation — CommonMark closes a fence only
+ # with the same character — but for this skip-heuristic's purposes
+ # mixing kinds at worst skips an extraction, never corrupts one.
+ if stripped.startswith(("```", "~~~")):
+ in_fence = not in_fence
+ out_lines.append(lines[i])
+ i += 1
+ continue
+ if not in_fence and stripped in closer_for:
+ # Scan to the next blank line (or EOF) — the candidate block end.
+ j = i + 1
+ while j < len(lines) and lines[j].strip():
+ j += 1
+ if j - 1 > i and lines[j - 1].strip() == closer_for[stripped]:
+ candidate = "\n".join(lines[i:j])
+ try:
+ parsed: Any = json.loads(candidate)
+ except ValueError:
+ parsed = None
+ if isinstance(parsed, (dict, list)) and parsed:
+ placeholder = _json_placeholder()
+ blocks[placeholder] = parsed
+ out_lines.append(placeholder)
+ i = j
+ continue
+ out_lines.append(lines[i])
+ i += 1
+ return "\n".join(out_lines), blocks
+
+
+# Top-level breadth cap for extracted blocks, mirroring the params-table
+# breadth discipline (CodeRabbit, PR #216): a folded table still GENERATES
+# one row per element, so a huge embedded array must not tabulate. The
+# fallback is an escaped
in a fold — proportional to the source text,
+# like the un-extracted prompt would have been (and deliberately not
+# Pygments-highlighted, which is itself generation-heavy at this size).
+_EMBEDDED_JSON_MAX_ITEMS = 200
+
+
+def _embedded_json_html(parsed: Any) -> str:
+ """Structured rendering for an extracted JSON block — the generic tool
+ params table (upgraded to the hybrid JSON/Markdown renderer when that
+ lands), with arrays presented as index→value rows."""
+ if isinstance(parsed, dict):
+ params = {str(k): v for k, v in cast("dict[Any, Any]", parsed).items()}
+ else:
+ params = {str(i): v for i, v in enumerate(cast("list[Any]", parsed))}
+ if len(params) > _EMBEDDED_JSON_MAX_ITEMS:
+ dumped = escape_html(json.dumps(parsed, indent=2, ensure_ascii=False))
+ return (
+ "
"
+ f"{len(params)} items (JSON)"
+ f"
{dumped}
"
+ )
+ return f"
{render_params_table(params)}
"
+
+
+def format_workflow_sidechannel_user_text(text: str) -> str:
+ """Render a workflow sub-agent prompt: collapsible Markdown with embedded
+ JSON blocks extracted into params tables (#174 follow-up).
+
+ The text is preprocessed by :func:`extract_embedded_json`, rendered
+ through the escaping Markdown collapsible (these prompts are large), and
+ the placeholders are then swapped for table renderings. A placeholder
+ that lands in the fold's *preview* (the first few lines) becomes a
+ compact ``{…}`` hint there instead — the table belongs in the body, not
+ the summary.
+ """
+ substituted, blocks = extract_embedded_json(text)
+ html = render_user_markdown_collapsible(
+ substituted,
+ "workflow-sidechannel-user",
+ line_threshold=12,
+ preview_line_count=5,
+ )
+ for placeholder, parsed in blocks.items():
+ table = _embedded_json_html(parsed)
+ if "
" in html:
+ head, tail = html.split("", 1)
+ head = head.replace(
+ placeholder, "{…}"
+ )
+ html = head + "" + tail.replace(placeholder, table)
+ else:
+ html = html.replace(placeholder, table)
+ return html
+
+
+def format_workflow_sidechannel_user_content(
+ content: UserTextMessage,
+ image_formatter: Optional[Callable[[ImageContent], str]] = None,
+) -> str:
+ """Variant of :func:`format_user_text_model_content` for user messages
+ grafted from a workflow agent's side-channel: text items get the
+ collapsible + embedded-JSON treatment; other items render as usual."""
+ from .assistant_formatters import format_image_content
+
+ formatter = image_formatter or format_image_content
+ parts: list[str] = []
+ for item in content.items:
+ if isinstance(item, IdeNotificationContent):
+ parts.extend(format_ide_notification_content(item))
+ elif isinstance(item, ImageContent):
+ parts.append(formatter(item))
+ else: # TextContent
+ if item.text.strip():
+ parts.append(format_workflow_sidechannel_user_text(item.text))
+ return "\n".join(parts)
+
+
def format_compacted_summary_content(content: CompactedSummaryMessage) -> str:
"""Format compacted session summary content as HTML.
diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 7d7e437d..dc5ddd4b 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -262,6 +262,12 @@ def __init__(
# Children for tree-based rendering
self.children: list["TemplateMessage"] = []
+ # Set by _graft_agent_sidechannel (#174): True for every node grafted
+ # from a workflow agent's side-channel transcript. Formatters use it
+ # to render those user prompts as collapsible Markdown with embedded
+ # JSON blocks extracted into params tables.
+ self.in_workflow_sidechannel: 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,
@@ -3073,6 +3079,7 @@ def _graft_agent_sidechannel(
def _reindex(node: TemplateMessage, parent: TemplateMessage) -> None:
old = node.message_index
ctx.register(node)
+ node.in_workflow_sidechannel = True
if old is not None and node.message_index is not None:
old_to_new[old] = node.message_index
if parent.message_index is not None:
diff --git a/dev-docs/css-classes.md b/dev-docs/css-classes.md
index cd34f11d..9daadbfd 100755
--- a/dev-docs/css-classes.md
+++ b/dev-docs/css-classes.md
@@ -54,7 +54,7 @@ This document provides a comprehensive reference for CSS class combinations used
| `pair_first` | Various | First message in a pair |
| `pair_last` | Various | Last message in a pair |
| `pair_middle` | Various | Middle message (never used so far) |
-| `sidechain` | Various | Sub-agent (Task) message. The sidechain block under its spawning tool_result is framed by a single grey group line + 2em indent (same color/concept as a workflow agent's side-channel). |
+| `sidechain` | Various | Sub-agent (Task) message. The sidechain block under its spawning tool_result is framed by a single tool-green group line + 2em indent — the line continues the spawning card's border color (same pairing principle as the workflow phase/agent group lines). |
| `slash-command` | `user` | Expanded slash command prompt |
| `steering` | `user` | User steering via queue operation |
| `system-info` | `system` | System info level |
diff --git a/dev-docs/workflows.md b/dev-docs/workflows.md
index 706efd16..790cd5fd 100644
--- a/dev-docs/workflows.md
+++ b/dev-docs/workflows.md
@@ -152,10 +152,29 @@ on non-workflow rendering would be high). Key mechanics:
- **Side-channel grafting** (`_graft_agent_sidechannel`): each agent's
`entries` are re-rendered through a nested
`generate_template_messages` call, then every produced node is
- re-registered into the main ctx (fresh monotonic indices) and its
- pairing references (`pair_first`/`pair_middle`/`pair_last`) remapped
- into the new index space. The side-channel renders at FULL detail
- regardless of the main render's level (see § 7).
+ re-registered into the main ctx (fresh monotonic indices), tagged
+ `in_workflow_sidechannel`, and its pairing references
+ (`pair_first`/`pair_middle`/`pair_last`) remapped into the new index
+ space. The side-channel renders at FULL detail regardless of the main
+ render's level (see § 7).
+- **Side-channel user prompts** (`format_workflow_sidechannel_user_content`
+ in `html/user_formatters.py`, gated on the graft tag): these prompts
+ are large prose+JSON hybrids, so they render as escaping collapsible
+ Markdown with embedded JSON blocks **extracted** first
+ (`extract_embedded_json`): a lone `{`/`[` on its own line, through a
+ lone matching closer followed by a blank line (or EOF), accepted only
+ when `json.loads` parses it to a **non-empty** dict/list — empty
+ `{}`/`[]` stay inline (nothing to tabulate), as do blocks inside
+ fenced code. Each block is substituted with a
+ z-prefixed UUID placeholder (every uuid group gets a `z` so the
+ SHA→commit-URL linkifier can't match inside it), the remainder renders
+ as Markdown, and the placeholders are swapped for the generic
+ params-table rendering of the parsed value (so hybrid-renderer
+ upgrades apply automatically). Blocks wider than
+ `_EMBEDDED_JSON_MAX_ITEMS` fall back to an escaped `
` fold —
+ generation-side breadth discipline. A placeholder landing in the
+ fold's preview becomes a compact `{…}` hint; the table renders once,
+ in the body.
- **Counts**: `has_children`/`is_paired` are derived properties, and
the stock `_mark_messages_with_children` ran pre-splice, so a
bottom-up helper (`_recount_spliced_children`) computes the synthetic
@@ -203,12 +222,14 @@ Two `MessageContent` subclasses in [`models.py`](../claude_code_log/models.py):
inside the ≤1280px responsive block), so the container's border-left
lands at the exact x of its parent card's border — the group border
reads as the card's border continuing down its subtree. Colors pair
- per level: a phase card + its agents group are dark green
+ per level — the group line continues its parent card's border color: a
+ phase card + its agents group are dark green
(`--workflow-phase-color`), an agent card + its side-channel group
- are grey (`--workflow-agent-color`). The Workflow-level phases group
- keeps its indent but draws no line (suppressed at 0px — two levels of
- lines already distinguish a workflow from a standard sub-agent's
- single grey sidechain line, which uses the same grey). Depth
+ are grey (`--workflow-agent-color`), and a standard sub-agent's
+ sidechain line is tool-green (continuing the spawning tool_result
+ card's border). The Workflow-level phases group keeps its indent but
+ draws no line (suppressed at 0px — two levels of lines already
+ distinguish a workflow from a standard sub-agent's single line). Depth
accumulates through DOM nesting, so arbitrarily deep future nests (a
sub-agent spawning its own sub-agents) indent with no new rules.
- **Timeline** (`components/timeline.html`): dedicated
diff --git a/scripts/gen_workflow_fixture.py b/scripts/gen_workflow_fixture.py
index 4d72c603..d85cb731 100644
--- a/scripts/gen_workflow_fixture.py
+++ b/scripts/gen_workflow_fixture.py
@@ -47,6 +47,23 @@
"phaseIndex": 1,
"phaseTitle": "Map",
"model": "claude-sonnet-4-6",
+ # Realistic prose+JSON prompt shape: workflow agent prompts routinely
+ # embed pretty-printed JSON blocks (a lone `{`/`[` line through a lone
+ # closer + blank line). Exercises the embedded-JSON extraction in the
+ # side-channel user rendering.
+ "prompt": (
+ "You are a focused reviewer. Map the **loader** area.\n"
+ "\n"
+ "A prior analysis proposed this opportunity:\n"
+ "\n"
+ "{\n"
+ ' "id": "loader-glob",\n'
+ ' "title": "Extend the discovery glob",\n'
+ ' "touches": ["converter.py"]\n'
+ "}\n"
+ "\n"
+ "Verify it against the current code and report findings."
+ ),
"result": {
"area": "loader",
"summary": "Discovery glob misses subagents/workflows.",
@@ -283,7 +300,7 @@ def _agent_transcript(agent: dict) -> list[dict]:
f"{aid}_u1",
None,
sid,
- [{"type": "text", "text": agent["label"]}],
+ [{"type": "text", "text": agent.get("prompt", agent["label"])}],
sidechain=True,
agent_id=aid,
),
diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr
index 43a99b29..982e6b2c 100644
--- a/test/__snapshots__/test_snapshot_html.ambr
+++ b/test/__snapshots__/test_snapshot_html.ambr
@@ -52,7 +52,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -1373,40 +1373,108 @@
word-break: break-all;
}
- /* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
- .tool-param-collapsible-rows[open] > summary::after {
- content: none;
+ /* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+ .tool-param-fold-controls {
+ margin: 2px 0;
}
- .tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
- }
-
- .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
- .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+ .tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+ }
+
+ /* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+ .tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
- .tool-param-rows-toggle:hover {
+ .tool-param-key-toggle:hover {
color: var(--text-primary);
}
+ /* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+ .tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+ }
+
+ /* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+ button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+ }
+
+ .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+ [data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+ }
+
+ /* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+ .tool-param-key-toggle,
+ .tool-params-expand-all,
+ .tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ }
+
+ /* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+ .tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+ }
+
+ .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+ .tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+ }
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -1823,14 +1891,16 @@
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -1913,6 +1983,18 @@
color: var(--text-muted);
font-size: 0.9em;
}
+
+ /* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+ .embedded-json {
+ margin: 0.5em 0;
+ }
+
+ .embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+ }
/* Session navigation styles */
.navigation {
background-color: var(--bg-neutral);
@@ -4941,16 +5023,22 @@
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -4963,7 +5051,10 @@
const allOpen = folds.length > 0 &&
Array.from(folds).every(fold => fold.open);
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen ? '▼ collapse all' : '▶ expand all';
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = allOpen ? 'collapse all' : 'expand all';
+ }
}
document.addEventListener('click', function (event) {
@@ -4977,12 +5068,20 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -5009,6 +5108,20 @@
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
@@ -6248,7 +6361,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -7569,40 +7682,108 @@
word-break: break-all;
}
- /* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
- .tool-param-collapsible-rows[open] > summary::after {
- content: none;
+ /* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+ .tool-param-fold-controls {
+ margin: 2px 0;
}
- .tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
- }
-
- .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
- .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+ .tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+ }
+
+ /* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+ .tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
- .tool-param-rows-toggle:hover {
+ .tool-param-key-toggle:hover {
color: var(--text-primary);
}
+ /* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+ .tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+ }
+
+ /* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+ button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+ }
+
+ .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+ [data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+ }
+
+ /* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+ .tool-param-key-toggle,
+ .tool-params-expand-all,
+ .tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ }
+
+ /* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+ .tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+ }
+
+ .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+ .tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+ }
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -8019,14 +8200,16 @@
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -8109,6 +8292,18 @@
color: var(--text-muted);
font-size: 0.9em;
}
+
+ /* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+ .embedded-json {
+ margin: 0.5em 0;
+ }
+
+ .embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+ }
/* Session navigation styles */
.navigation {
background-color: var(--bg-neutral);
@@ -11036,16 +11231,22 @@
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -11058,7 +11259,10 @@
const allOpen = folds.length > 0 &&
Array.from(folds).every(fold => fold.open);
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen ? '▼ collapse all' : '▶ expand all';
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = allOpen ? 'collapse all' : 'expand all';
+ }
}
document.addEventListener('click', function (event) {
@@ -11072,12 +11276,20 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -11104,6 +11316,20 @@
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
@@ -12343,7 +12569,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -14531,7 +14757,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -15852,40 +16078,108 @@
word-break: break-all;
}
- /* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
- .tool-param-collapsible-rows[open] > summary::after {
- content: none;
+ /* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+ .tool-param-fold-controls {
+ margin: 2px 0;
}
- .tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
- }
-
- .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
- .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+ .tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+ }
+
+ /* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+ .tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
- .tool-param-rows-toggle:hover {
+ .tool-param-key-toggle:hover {
color: var(--text-primary);
}
+ /* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+ .tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+ }
+
+ /* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+ button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+ }
+
+ .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+ [data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+ }
+
+ /* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+ .tool-param-key-toggle,
+ .tool-params-expand-all,
+ .tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ }
+
+ /* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+ .tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+ }
+
+ .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+ .tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+ }
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -16302,14 +16596,16 @@
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -16392,6 +16688,18 @@
color: var(--text-muted);
font-size: 0.9em;
}
+
+ /* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+ .embedded-json {
+ margin: 0.5em 0;
+ }
+
+ .embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+ }
/* Session navigation styles */
.navigation {
background-color: var(--bg-neutral);
@@ -19535,16 +19843,22 @@
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -19557,7 +19871,10 @@
const allOpen = folds.length > 0 &&
Array.from(folds).every(fold => fold.open);
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen ? '▼ collapse all' : '▶ expand all';
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = allOpen ? 'collapse all' : 'expand all';
+ }
}
document.addEventListener('click', function (event) {
@@ -19571,12 +19888,20 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -19603,6 +19928,20 @@
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
@@ -20842,7 +21181,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -22163,40 +22502,108 @@
word-break: break-all;
}
- /* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
- .tool-param-collapsible-rows[open] > summary::after {
- content: none;
+ /* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+ .tool-param-fold-controls {
+ margin: 2px 0;
}
- .tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
- }
-
- .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
- .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+ .tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+ }
+
+ /* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+ .tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
- .tool-param-rows-toggle:hover {
+ .tool-param-key-toggle:hover {
color: var(--text-primary);
}
+ /* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+ .tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+ }
+
+ /* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+ button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+ }
+
+ .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+ [data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+ }
+
+ /* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+ .tool-param-key-toggle,
+ .tool-params-expand-all,
+ .tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ }
+
+ /* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+ .tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+ }
+
+ .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+ .tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+ }
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -22613,14 +23020,16 @@
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -22703,6 +23112,18 @@
color: var(--text-muted);
font-size: 0.9em;
}
+
+ /* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+ .embedded-json {
+ margin: 0.5em 0;
+ }
+
+ .embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+ }
/* Session navigation styles */
.navigation {
background-color: var(--bg-neutral);
@@ -26113,16 +26534,22 @@
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -26135,7 +26562,10 @@
const allOpen = folds.length > 0 &&
Array.from(folds).every(fold => fold.open);
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen ? '▼ collapse all' : '▶ expand all';
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = allOpen ? 'collapse all' : 'expand all';
+ }
}
document.addEventListener('click', function (event) {
@@ -26149,12 +26579,20 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -26181,6 +26619,20 @@
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
@@ -27420,7 +27872,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -28741,40 +29193,108 @@
word-break: break-all;
}
- /* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
- .tool-param-collapsible-rows[open] > summary::after {
- content: none;
+ /* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+ .tool-param-fold-controls {
+ margin: 2px 0;
}
- .tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
- }
-
- .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
- .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+ .tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+ }
+
+ /* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+ .tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
- .tool-param-rows-toggle:hover {
+ .tool-param-key-toggle:hover {
color: var(--text-primary);
}
+ /* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+ .tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+ }
+
+ /* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+ button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+ }
+
+ .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+ [data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+ }
+
+ /* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+ .tool-param-key-toggle,
+ .tool-params-expand-all,
+ .tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ }
+
+ /* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+ .tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+ }
+
+ .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+ .tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+ }
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -29191,14 +29711,16 @@
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -29281,6 +29803,18 @@
color: var(--text-muted);
font-size: 0.9em;
}
+
+ /* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+ .embedded-json {
+ margin: 0.5em 0;
+ }
+
+ .embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+ }
/* Session navigation styles */
.navigation {
background-color: var(--bg-neutral);
@@ -31923,7 +32457,7 @@
@@ -32653,16 +33188,22 @@
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -32675,7 +33216,10 @@
const allOpen = folds.length > 0 &&
Array.from(folds).every(fold => fold.open);
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen ? '▼ collapse all' : '▶ expand all';
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = allOpen ? 'collapse all' : 'expand all';
+ }
}
document.addEventListener('click', function (event) {
@@ -32689,12 +33233,20 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -32721,6 +33273,20 @@
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
@@ -33960,7 +34526,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -35281,40 +35847,108 @@
word-break: break-all;
}
- /* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
- .tool-param-collapsible-rows[open] > summary::after {
- content: none;
+ /* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+ .tool-param-fold-controls {
+ margin: 2px 0;
}
- .tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
- }
-
- .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
- .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+ .tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+ }
+
+ /* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+ .tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
- .tool-param-rows-toggle:hover {
+ .tool-param-key-toggle:hover {
color: var(--text-primary);
}
+ /* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+ .tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+ }
+
+ /* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+ button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+ }
+
+ .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+ [data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+ }
+
+ /* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+ .tool-param-key-toggle,
+ .tool-params-expand-all,
+ .tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ }
+
+ /* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+ .tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+ }
+
+ .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+ .tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+ }
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -35731,14 +36365,16 @@
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -35821,6 +36457,18 @@
color: var(--text-muted);
font-size: 0.9em;
}
+
+ /* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+ .embedded-json {
+ margin: 0.5em 0;
+ }
+
+ .embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+ }
/* Session navigation styles */
.navigation {
background-color: var(--bg-neutral);
@@ -39137,16 +39785,22 @@
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -39159,7 +39813,10 @@
const allOpen = folds.length > 0 &&
Array.from(folds).every(fold => fold.open);
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen ? '▼ collapse all' : '▶ expand all';
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = allOpen ? 'collapse all' : 'expand all';
+ }
}
document.addEventListener('click', function (event) {
@@ -39173,12 +39830,20 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -39205,6 +39870,20 @@
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
@@ -40444,7 +41123,7 @@
/* Dynamic-workflow tree colors (#174): each level's card border and its
group border (the line framing the card's subtree) share a color. */
- --workflow-phase-color: #1b5e20;
+ --workflow-phase-color: #3a7d3c;
--workflow-agent-color: #9e9e9e;
/* Fork/branch structural colors */
@@ -41765,40 +42444,108 @@
word-break: break-all;
}
- /* Structured-table folds carry an explicit collapse hint plus a
- rows-toggle button in the summary, so suppress the generic ::after
- "collapse" hint for them and show the real elements only when open. */
- .tool-param-collapsible-rows[open] > summary::after {
- content: none;
+ /* The rows-toggle lives in a controls strip AFTER the summary — never
+ inside it (an interactive element within is an accessibility
+ violation: not consistently reachable by keyboard/AT). The strip is
+ details content, so it's natively hidden while the fold is closed. */
+ .tool-param-fold-controls {
+ margin: 2px 0;
}
- .tool-param-collapse-hint,
.tool-param-rows-toggle {
- display: none;
- }
-
- .tool-param-collapsible-rows[open] > summary > .tool-param-collapse-hint {
- display: inline;
+ padding: 0;
+ background: none;
+ border: none;
+ font: inherit;
color: var(--text-muted);
font-style: italic;
+ cursor: pointer;
}
- .tool-param-collapsible-rows[open] > summary > .tool-param-rows-toggle {
- display: inline;
- margin-left: 1.5em;
+ .tool-param-rows-toggle:hover {
+ color: var(--text-primary);
+ }
+
+ /* Fold-valued rows hoist their toggle into the KEY column: the key cell's
+ ▶/▼ button (wired in transcript.html, state derived from toggle events)
+ replaces the per-fold in-summary collapse chrome, which only remained
+ useful en masse — every row showing "▼ collapse" drowned the content.
+ Scoped to keyed rows: the top-level root fold has no key column and
+ keeps its summary affordances (the pure-CSS ::after "collapse" hint —
+ no interactive child). */
+ .tool-param-key-toggle {
+ /* The whole key (glyph + text) is the click target — inherit the key
+ cell's look so fold keys read exactly like scalar keys. */
padding: 0;
background: none;
border: none;
font: inherit;
- color: var(--text-muted);
- font-style: italic;
+ color: inherit;
cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
}
- .tool-param-rows-toggle:hover {
+ .tool-param-key-toggle:hover {
color: var(--text-primary);
}
+ /* The shared fold glyph: the BROWSER'S native disclosure marker (the same
+ UA-drawn icon a shows) via display:list-item — geometrically
+ symmetric open/closed, and immune to the italic style of the button
+ labels (font glyphs like ▶/▼ skew in italics; the marker icon doesn't).
+ The empty span doubles as the key-column alignment slot: every key cell
+ renders one (markerless on scalar rows), so all key texts start at the
+ same x. */
+ .tool-param-fold-glyph {
+ display: inline-block;
+ width: 1.3em;
+ color: var(--text-muted);
+ font-style: normal;
+ }
+
+ /* Marker only inside the toggle buttons — the scalar-row spacer stays
+ empty. list-style-type string values are the fallback where the
+ disclosure-* keywords are unsupported. */
+ button > .tool-param-fold-glyph {
+ display: list-item;
+ list-style-position: inside;
+ list-style-type: "\25B8";
+ list-style-type: disclosure-closed;
+ }
+
+ .tool-param-key-toggle[aria-expanded='true'] > .tool-param-fold-glyph,
+ [data-state='expanded'] > .tool-param-fold-glyph {
+ list-style-type: "\25BE";
+ list-style-type: disclosure-open;
+ }
+
+ /* display:list-item is block-level — lay the buttons out as inline-flex so
+ the marker slot and the label sit on one line. */
+ .tool-param-key-toggle,
+ .tool-params-expand-all,
+ .tool-param-rows-toggle {
+ display: inline-flex;
+ align-items: baseline;
+ }
+
+ /* The key glyph is THE marker for these rows — drop the duplicate native
+ one from the value summary (preview text stays clickable). */
+ .tool-param-row-fold > .tool-param-value > details > summary {
+ list-style: none;
+ }
+
+ .tool-param-row-fold > .tool-param-value > details > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ /* A keyed fold's open summary carries nothing (the key glyph shows ▼ and
+ the rows-toggle sits in the controls strip below) — drop the line it
+ would occupy. This also suppresses the generic ::after "collapse". */
+ .tool-param-row-fold > .tool-param-value > details[open] > summary {
+ display: none;
+ }
+
/* Top-level expand/collapse-all control above a params/result table —
same quiet look as the in-summary rows-toggle. */
.tool-params-controls {
@@ -42215,14 +42962,16 @@
/* A standard sub-agent's sidechain group (Task-spawned agents — sync,
async, teammates): the agent's transcript hangs under the spawning
- tool_result, framed by ONE grey line (the same color/concept as a
- workflow agent's side-channel — an agent transcript is an agent
- transcript). The :not(.sidechain) parent filter keeps the line at the
- block's top level only (containers deeper inside the sidechain hang
- under .sidechain-flagged cards). */
+ tool_result, framed by ONE line in tool-green — per the color-pairing
+ principle, the group line CONTINUES its parent card's border, and the
+ spawning tool_result card's border is tool-green. (Workflow agents use
+ the same principle with their own grey: grey workflow_agent card →
+ grey side-channel line.) The :not(.sidechain) parent filter keeps the
+ line at the block's top level only (containers deeper inside the
+ sidechain hang under .sidechain-flagged cards). */
.message-node:has(> .message.tool_result:not(.sidechain)) > .children:has(> .message-node > .message.sidechain) {
margin-left: 2em;
- border-left: 2px solid var(--workflow-agent-color);
+ border-left: 2px solid var(--tool-use-color);
}
/* A phase's agents group — continues the phase card's dark green. */
@@ -42305,6 +43054,18 @@
color: var(--text-muted);
font-size: 0.9em;
}
+
+ /* Embedded JSON blocks extracted from workflow side-channel user prompts
+ (#174 follow-up): the block renders as a params table in place of the raw
+ JSON text; the hint marks a block whose table lives below the fold. */
+ .embedded-json {
+ margin: 0.5em 0;
+ }
+
+ .embedded-json-hint {
+ color: var(--text-muted);
+ font-family: var(--font-monospace);
+ }
/* Session navigation styles */
.navigation {
background-color: var(--bg-neutral);
@@ -45448,16 +46209,22 @@
// it stays in sync whatever opened the rows (button click,
// toggle-all, manual clicks).
function syncRowsToggle(details) {
- const button = details.querySelector(':scope > summary .tool-param-rows-toggle');
+ // The button lives in a controls strip AFTER the summary —
+ // interactive elements inside are an accessibility
+ // violation (unreachable by keyboard/AT in some browsers).
+ const button = details.querySelector(':scope > .tool-param-fold-controls > .tool-param-rows-toggle');
if (!button) return;
const rows = details.querySelectorAll(rowDetailsSelector);
const allOpen = rows.length > 0 &&
Array.from(rows).every(row => row.open);
const kind = button.dataset.kind || 'rows';
+ // data-state drives the CSS rotation of the constant ▸ glyph;
+ // only the label text changes.
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen
- ? '▼ collapse all ' + kind
- : '▶ expand all ' + kind;
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = (allOpen ? 'collapse all ' : 'expand all ') + kind;
+ }
}
// Top-level expand-all for a whole params/result renderer:
@@ -45470,7 +46237,10 @@
const allOpen = folds.length > 0 &&
Array.from(folds).every(fold => fold.open);
button.dataset.state = allOpen ? 'expanded' : 'collapsed';
- button.textContent = allOpen ? '▼ collapse all' : '▶ expand all';
+ const label = button.querySelector('.tool-param-fold-label');
+ if (label) {
+ label.textContent = allOpen ? 'collapse all' : 'expand all';
+ }
}
document.addEventListener('click', function (event) {
@@ -45484,12 +46254,20 @@
syncExpandAll(root);
return;
}
+ // Key-column fold toggle (▶/▼): drives the value cell's
+ // details from the key cell, keeping the summary free of
+ // per-fold collapse chrome. Glyph state is derived in the
+ // toggle listener below, so any other opener stays in sync.
+ const keyToggle = event.target.closest('.tool-param-key-toggle');
+ if (keyToggle) {
+ const row = keyToggle.closest('tr');
+ const details = row &&
+ row.querySelector(':scope > .tool-param-value > details');
+ if (details) details.open = !details.open;
+ return;
+ }
const button = event.target.closest('.tool-param-rows-toggle');
if (!button) return;
- // The button sits inside a : keep the click from
- // toggling the enclosing details.
- event.preventDefault();
- event.stopPropagation();
const details = button.closest('details');
const expand = button.dataset.state !== 'expanded';
details.querySelectorAll(rowDetailsSelector).forEach(row => {
@@ -45516,6 +46294,20 @@
const cell = details.parentElement;
const parent = cell ? cell.closest('details.tool-param-collapsible-rows') : null;
if (parent && parent !== details) syncRowsToggle(parent);
+ // Sync the key-column glyph of a fold-valued row — derived
+ // from the actual open state, so summary clicks, rows-toggle
+ // bulk opens, and expand-all all keep it truthful.
+ if (cell && cell.classList && cell.classList.contains('tool-param-value')) {
+ const row = cell.closest('tr');
+ const keyBtn = row && row.querySelector(
+ ':scope > .tool-param-key > .tool-param-key-toggle');
+ if (keyBtn) {
+ // CSS rotates the single ▸ glyph on aria-expanded —
+ // symmetric open/closed states by construction.
+ keyBtn.setAttribute('aria-expanded',
+ details.open ? 'true' : 'false');
+ }
+ }
// Keep the renderer's top-level expand-all button in step
// with whatever opened/closed this fold (rows-toggle,
// manual click, global toggle-all).
diff --git a/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl b/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl
index dd84c606..92cdcba2 100644
--- a/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl
+++ b/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01/agent-ag000001.jsonl
@@ -1,3 +1,3 @@
-{"type": "user", "uuid": "ag000001_u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"role": "user", "content": [{"type": "text", "text": "review:loader"}]}}
+{"type": "user", "uuid": "ag000001_u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"role": "user", "content": [{"type": "text", "text": "You are a focused reviewer. Map the **loader** area.\n\nA prior analysis proposed this opportunity:\n\n{\n \"id\": \"loader-glob\",\n \"title\": \"Extend the discovery glob\",\n \"touches\": [\"converter.py\"]\n}\n\nVerify it against the current code and report findings."}]}}
{"type": "assistant", "uuid": "ag000001_a1", "parentUuid": "ag000001_u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"id": "msg_ag000001_a1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Working on review:loader."}, {"type": "tool_use", "id": "toolu_ag000001_so", "name": "StructuredOutput", "input": {"area": "loader", "summary": "Discovery glob misses subagents/workflows.", "key_functions": ["_scan_sidechain_uuids", "load_directory_transcripts"], "opportunities": ["extend glob to subagents/workflows/"]}}], "usage": {"input_tokens": 5, "output_tokens": 5}}}
{"type": "assistant", "uuid": "ag000001_a2", "parentUuid": "ag000001_a1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001#agent-ag000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "agentId": "ag000001", "message": {"id": "msg_ag000001_a2", "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Returning structured output."}], "usage": {"input_tokens": 5, "output_tokens": 5}}}
diff --git a/test/test_params_rows_toggle_browser.py b/test/test_params_rows_toggle_browser.py
index a9acdfe3..47beb2bd 100644
--- a/test/test_params_rows_toggle_browser.py
+++ b/test/test_params_rows_toggle_browser.py
@@ -1,9 +1,12 @@
-"""Playwright tests for the params-table rows-toggle button.
-
-An open structured-value fold shows "▶ expand rows" after the collapse
-hint; pressing it opens every row-level fold of that table and turns
-into "▼ collapse rows"; closing the outer fold restores the initial
-state (rows collapsed, button reset).
+"""Playwright tests for the params-table fold controls.
+
+An open structured-value fold shows "▶ expand all rows" in a controls
+strip after the summary (never inside it — interactive elements within
+ are an accessibility violation); pressing it opens every
+row-level fold of that table and turns into "▼ collapse all rows";
+closing the outer fold restores the initial state. Fold-valued rows
+carry their ▶/▼ toggle in the KEY column, derived from the actual open
+state.
"""
from __future__ import annotations
@@ -102,7 +105,9 @@ def test_rows_toggle_cycle(self, page: Page) -> None:
page.goto(f"file://{html}")
outer = page.locator("details.tool-param-collapsible-rows").first
- button = outer.locator("summary .tool-param-rows-toggle").first
+ button = outer.locator(
+ ".tool-param-fold-controls .tool-param-rows-toggle"
+ ).first
# Collapsed fold: the button is hidden.
assert not button.is_visible()
@@ -134,16 +139,23 @@ def test_closing_fold_restores_initial_state(self, page: Page) -> None:
page.goto(f"file://{html}")
outer = page.locator("details.tool-param-collapsible-rows").first
- button = outer.locator("summary .tool-param-rows-toggle").first
+ button = outer.locator(
+ ".tool-param-fold-controls .tool-param-rows-toggle"
+ ).first
outer.locator("summary").first.click()
button.click()
assert all(outer.evaluate(ROW_DETAILS_JS))
- # Close the outer fold via its summary, then reopen.
- outer.locator("summary").first.click()
+ # Close the outer fold via its key-column toggle (the open summary
+ # is hidden for keyed rows — the ▼ in the key cell is the collapse
+ # control), then reopen the same way.
+ key_toggle = page.locator(
+ "tr.tool-param-row-fold > td.tool-param-key > .tool-param-key-toggle"
+ ).first
+ key_toggle.click()
assert not outer.evaluate("el => el.open")
- outer.locator("summary").first.click()
+ key_toggle.click()
row_states = outer.evaluate(ROW_DETAILS_JS)
assert not any(row_states), "reopened fold must show rows collapsed"
@@ -158,7 +170,9 @@ def test_toggle_all_keeps_button_in_sync(self, page: Page) -> None:
page.goto(f"file://{html}")
outer = page.locator("details.tool-param-collapsible-rows").first
- button = outer.locator("summary .tool-param-rows-toggle").first
+ button = outer.locator(
+ ".tool-param-fold-controls .tool-param-rows-toggle"
+ ).first
page.locator("#toggleDetails").click()
assert outer.evaluate("el => el.open")
@@ -202,9 +216,13 @@ def test_expand_all_control_cascades(self, page: Page) -> None:
)
# Selectively close one row: mixed state → top offers expand again.
+ # (Open keyed summaries are hidden — close via the key-column toggle.)
outer = root.locator("details.tool-param-collapsible-rows").first
- row = outer.locator(":scope > table > tbody > tr > td > details").first
- row.locator("summary").first.click()
+ row_toggle = outer.locator(
+ ":scope > table > tbody > tr.tool-param-row-fold"
+ " > td.tool-param-key > .tool-param-key-toggle"
+ ).first
+ row_toggle.click()
expect(expand_all).to_contain_text("expand all")
# Re-expand, then collapse all back to the initial state.
@@ -229,3 +247,45 @@ def test_global_toggle_all_activates_expand_all(self, page: Page) -> None:
"el => Array.from(el.querySelectorAll('details')).every(d => d.open)"
)
expect(expand_all).to_contain_text("collapse all")
+
+ @pytest.mark.browser
+ def test_key_column_toggle_cycle_and_glyph_sync(self, page: Page) -> None:
+ """The whole-key button drives the row's fold; its state
+ (aria-expanded → native disclosure marker) is derived from the actual
+ open state, so the expand-all path flips it too, not just direct
+ clicks."""
+ html = self._render(_entries_with_structured_list())
+ page.goto(f"file://{html}")
+
+ outer = page.locator("details.tool-param-collapsible-rows").first
+ key_toggle = page.locator(
+ "tr.tool-param-row-fold > td.tool-param-key > .tool-param-key-toggle"
+ ).first
+
+ # The glyph is the native disclosure marker (a ::marker pseudo-
+ # element, not text); open/closed state is carried by aria-expanded.
+ expect(key_toggle).to_have_attribute("aria-expanded", "false")
+ key_toggle.click()
+ assert outer.evaluate("el => el.open")
+ # State updates arrive via the queued toggle event — poll.
+ expect(key_toggle).to_have_attribute("aria-expanded", "true")
+ key_toggle.click()
+ assert not outer.evaluate("el => el.open")
+ expect(key_toggle).to_have_attribute("aria-expanded", "false")
+
+ # Externally-driven open (expand-all) must flip the state too.
+ page.locator(".tool-params-expand-all").first.click()
+ expect(key_toggle).to_have_attribute("aria-expanded", "true")
+
+ @pytest.mark.browser
+ def test_no_interactive_elements_inside_summaries(self, page: Page) -> None:
+ """Accessibility contract (Chrome DevTools issue): no button/link/
+ input may live inside any on the rendered page."""
+ html = self._render(_entries_with_structured_list())
+ page.goto(f"file://{html}")
+ offenders = page.evaluate(
+ "() => document.querySelectorAll("
+ "'summary button, summary a, summary input, summary select'"
+ ").length"
+ )
+ assert offenders == 0
diff --git a/test/test_params_table_hybrid.py b/test/test_params_table_hybrid.py
index a0bfcb56..2a6f5d00 100644
--- a/test/test_params_table_hybrid.py
+++ b/test/test_params_table_hybrid.py
@@ -88,8 +88,9 @@ def test_nested_dict_renders_table(self):
def test_list_renders_indexed_rows(self):
html = render_params_table({"items": ["alpha", "beta"]})
assert "tool-params-nested" in html
- assert "
0
" in html
- assert "
1
" in html
+ # Scalar rows: glyph spacer + index in the key cell.
+ assert "0
" in html
+ assert "1" in html
assert "alpha" in html and "beta" in html
def test_structures_always_fold_regardless_of_size(self):
@@ -141,15 +142,19 @@ def test_long_structure_folds_with_json_preview(self):
assert "tool-params-nested" in html
def test_table_fold_carries_rows_toggle(self):
- """Structured-table folds with foldable rows get the explicit
- hint + rows-toggle button; plain string folds and the JSON
- fallback do not."""
+ """Structured-table folds with foldable rows get the rows-toggle
+ button in a controls strip AFTER the summary (interactive elements
+ inside are an accessibility violation); plain string
+ folds and the JSON fallback do not."""
value = {f"key_{i}": {"nested": i} for i in range(5)}
html = render_params_table({"cfg": value})
assert "tool-param-collapsible-rows" in html
- assert "tool-param-collapse-hint" in html
+ assert "tool-param-fold-controls" in html
assert "tool-param-rows-toggle" in html
assert "expand all properties" in html
+ # The summary holds only the preview — no interactive children.
+ summary = html.split("", 1)[1].split("", 1)[0]
+ assert "" in html
+ assert "1" in html
+ assert "tool-param-key-toggle" in html
def test_invalid_json_stays_text(self):
result = _tool_result('{"status": "ok", trailing')
diff --git a/test/test_workflow_browser.py b/test/test_workflow_browser.py
index 1466d181..08c5e8eb 100644
--- a/test/test_workflow_browser.py
+++ b/test/test_workflow_browser.py
@@ -82,3 +82,106 @@ def test_phase_fold_control_toggles_agents(
# restores it — the fold machine operates on the synthetic phase node.
assert result["d1"] != result["d0"]
assert result["d2"] == result["d0"]
+
+
+class TestWorkflowRuntimeCss:
+ """Runtime contract for the workflow group borders (CR on the polish PR):
+ snapshots embed the CSS *text*, so a DOM-structure change that silently
+ breaks the ``:has()`` selectors would not fail them — only computed-style
+ assertions pin the selector↔DOM contract."""
+
+ @pytest.mark.browser
+ def test_group_borders_and_alignment(self, page: Page, tmp_path: Path) -> None:
+ page.goto(_render(tmp_path))
+ page.wait_for_timeout(300)
+
+ result = page.evaluate(
+ """() => {
+ const phase = document.querySelector('.message.workflow_phase');
+ if (!phase) return { found: false };
+ const phaseNode = phase.parentElement; // .message-node
+ const phasesGroup = phaseNode.parentElement; // .children (of the pair)
+ const agentsGroup = phaseNode.querySelector(':scope > .children');
+ const agentNode = agentsGroup &&
+ agentsGroup.querySelector(':scope > .message-node');
+ const agent = agentNode &&
+ agentNode.querySelector(':scope > .message.workflow_agent');
+ const scGroup = agentNode &&
+ agentNode.querySelector(':scope > .children');
+ if (!agent || !scGroup) return { found: false };
+ // Reveal folded containers so geometry is honest.
+ for (const c of [phasesGroup, agentsGroup, scGroup]) {
+ c.style.display = '';
+ }
+ const cs = (el) => {
+ const s = getComputedStyle(el);
+ return { bw: s.borderLeftWidth, bc: s.borderLeftColor };
+ };
+ const x = (el) => el.getBoundingClientRect().left;
+ return {
+ found: true,
+ phasesGroup: cs(phasesGroup), // suppressed: 0px
+ agentsGroup: cs(agentsGroup), // dark green, 2px
+ scGroup: cs(scGroup), // grey, 2px
+ phaseAligned: Math.abs(x(phase) - x(agentsGroup)) < 1,
+ agentAligned: Math.abs(x(agent) - x(scGroup)) < 1,
+ };
+ }"""
+ )
+
+ assert result.get("found"), "workflow phase/agent structure not found"
+ # Workflow-level group line is suppressed; phase + agent lines drawn.
+ assert result["phasesGroup"]["bw"] == "0px"
+ assert result["agentsGroup"]["bw"] == "2px"
+ assert result["agentsGroup"]["bc"] == "rgb(58, 125, 60)" # #3a7d3c
+ assert result["scGroup"]["bw"] == "2px"
+ assert result["scGroup"]["bc"] == "rgb(158, 158, 158)" # #9e9e9e
+ # Each group border continues its parent card's border (same x).
+ assert result["phaseAligned"] is True
+ assert result["agentAligned"] is True
+
+
+class TestPhasePillNavigation:
+ """Clicking a phase pill in the Workflow header navigates to the phase
+ card: the hash updates and the ``hashchange`` handler unfolds the folded
+ ancestors so the target becomes visible (CR on the polish PR)."""
+
+ @pytest.mark.browser
+ def test_pill_click_navigates_and_unfolds(self, page: Page, tmp_path: Path) -> None:
+ page.goto(_render(tmp_path))
+ page.wait_for_timeout(300)
+
+ target_id = page.evaluate(
+ """() => {
+ const pill = document.querySelector('a.workflow-phase-pill');
+ return pill ? pill.getAttribute('href').slice(1) : null;
+ }"""
+ )
+ assert target_id, "no linked phase pill found"
+
+ hidden_before = page.evaluate(
+ f"() => document.getElementById('{target_id}').offsetParent === null"
+ )
+ assert hidden_before, "phase card should start folded away"
+
+ # The Workflow card itself starts inside folded ancestors — reveal it
+ # the way a user arriving from the session nav would: jump to its
+ # anchor and let the built-in hashchange unfold expose it.
+ page.evaluate(
+ """() => {
+ const card = document.querySelector(
+ '.message.tool_use:has(.workflow-meta)');
+ window.location.hash = '#' + card.id;
+ }"""
+ )
+ page.wait_for_function(
+ "() => document.querySelector('a.workflow-phase-pill')"
+ ".offsetParent !== null"
+ )
+
+ page.click("a.workflow-phase-pill")
+ # The hashchange handler runs asynchronously — poll, don't assume.
+ page.wait_for_function(f"() => window.location.hash === '#{target_id}'")
+ page.wait_for_function(
+ f"() => document.getElementById('{target_id}').offsetParent !== null"
+ )
diff --git a/test/test_workflow_sidechannel_user.py b/test/test_workflow_sidechannel_user.py
new file mode 100644
index 00000000..64d0bd07
--- /dev/null
+++ b/test/test_workflow_sidechannel_user.py
@@ -0,0 +1,228 @@
+"""Workflow side-channel user prompts: collapsible Markdown with embedded
+JSON blocks extracted into params tables (#174 follow-up).
+
+The prompts that drive workflow sub-agents are large prose+JSON hybrids.
+``extract_embedded_json`` pulls out pretty-printed JSON blocks (a lone
+``{``/``[`` line through a lone matching closer followed by a blank line),
+substituting z-prefixed UUID placeholders that survive Markdown rendering;
+``format_workflow_sidechannel_user_text`` renders the remainder as escaping
+collapsible Markdown and swaps each placeholder for a params-table rendering.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from claude_code_log.html.user_formatters import (
+ extract_embedded_json,
+ format_workflow_sidechannel_user_text,
+)
+
+TRUNK = (
+ Path(__file__).parent
+ / "test_data"
+ / "workflow_basic"
+ / "11110000-0000-4000-8000-000000000001.jsonl"
+)
+
+_OBJECT_BLOCK = '{\n "id": "x",\n "touches": ["a.py"]\n}'
+_ARRAY_BLOCK = '[\n {"area": "loader"},\n {"area": "tree"}\n]'
+
+
+class TestExtractEmbeddedJson:
+ def test_object_block_extracted(self) -> None:
+ text = f"Intro prose.\n\n{_OBJECT_BLOCK}\n\nOutro."
+ substituted, blocks = extract_embedded_json(text)
+ assert len(blocks) == 1
+ placeholder, parsed = next(iter(blocks.items()))
+ assert parsed == {"id": "x", "touches": ["a.py"]}
+ assert placeholder in substituted
+ assert '"touches"' not in substituted
+
+ def test_array_block_extracted(self) -> None:
+ substituted, blocks = extract_embedded_json(f"Head:\n\n{_ARRAY_BLOCK}\n\nTail.")
+ assert list(blocks.values()) == [[{"area": "loader"}, {"area": "tree"}]]
+ assert "loader" not in substituted
+
+ def test_no_blank_line_before_opener_still_matches(self) -> None:
+ # The opener needs no preceding blank line — only the closer needs a
+ # following one.
+ text = f"INPUT (maps):\n{_ARRAY_BLOCK}\n\nDone."
+ _substituted, blocks = extract_embedded_json(text)
+ assert len(blocks) == 1
+
+ def test_block_at_eof_matches(self) -> None:
+ # EOF counts as the blank line after the closer.
+ _substituted, blocks = extract_embedded_json(f"Intro.\n\n{_OBJECT_BLOCK}")
+ assert len(blocks) == 1
+
+ def test_multiple_blocks(self) -> None:
+ text = f"A:\n\n{_OBJECT_BLOCK}\n\nB:\n\n{_ARRAY_BLOCK}\n\nC."
+ substituted, blocks = extract_embedded_json(text)
+ assert len(blocks) == 2
+ for placeholder in blocks:
+ assert placeholder in substituted
+
+ def test_invalid_json_left_untouched(self) -> None:
+ bad = "{\nnot json at all\n}"
+ substituted, blocks = extract_embedded_json(f"X.\n\n{bad}\n\nY.")
+ assert blocks == {}
+ assert "not json at all" in substituted
+
+ def test_blank_line_inside_block_breaks_the_candidate(self) -> None:
+ # The scan stops at the first blank line; the truncated candidate
+ # fails to parse and the text stays untouched.
+ gappy = '{\n "a": 1,\n\n "b": 2\n}'
+ substituted, blocks = extract_embedded_json(f"X.\n\n{gappy}\n\nY.")
+ assert blocks == {}
+ assert '"b": 2' in substituted
+
+ def test_closer_not_alone_left_untouched(self) -> None:
+ inline_close = '{\n "a": 1 }'
+ _substituted, blocks = extract_embedded_json(f"X.\n\n{inline_close}\n\nY.")
+ assert blocks == {}
+
+ def test_prose_brace_paragraph_left_untouched(self) -> None:
+ # A lone `{` opening a prose paragraph (no matching lone closer
+ # before the next blank line) must not be eaten.
+ text = "X.\n\n{\nthis is prose, not JSON\n\nY."
+ substituted, blocks = extract_embedded_json(text)
+ assert blocks == {}
+ assert "this is prose" in substituted
+
+ def test_fenced_json_with_internal_blank_line_left_untouched(self) -> None:
+ # Reviewer repro: a fenced example whose commentary follows a blank
+ # line INSIDE the fence — without fence tracking, the scan terminates
+ # at the lone closer, json.loads accepts, and the table markup would
+ # be substituted inside the rendered
.
+ text = (
+ "Spec:\n\n```\n{\n"
+ ' "a": 1\n'
+ "}\n\nplus commentary in the same fence\n```\n\nend"
+ )
+ substituted, blocks = extract_embedded_json(text)
+ assert blocks == {}
+ assert substituted == text
+
+ def test_tilde_fenced_json_with_internal_blank_line_left_untouched(self) -> None:
+ # Tilde fences are CommonMark too — same false-accept shape.
+ text = (
+ "Spec:\n\n~~~\n{\n"
+ ' "a": 1\n'
+ "}\n\nplus commentary in the same fence\n~~~\n\nend"
+ )
+ substituted, blocks = extract_embedded_json(text)
+ assert blocks == {}
+ assert substituted == text
+
+ def test_json_after_closed_fence_still_extracts(self) -> None:
+ # Fence parity resets: a block AFTER a properly closed fence is fair
+ # game again.
+ text = f"```\ncode example\n```\n\nReal data:\n\n{_OBJECT_BLOCK}\n\nend"
+ _substituted, blocks = extract_embedded_json(text)
+ assert len(blocks) == 1
+
+ def test_placeholder_has_no_bare_hex_run(self) -> None:
+ # Every uuid group is z-prefixed so the SHA→commit-URL linkifier
+ # (\b[0-9a-f]{7,40}\b) can never match inside a placeholder.
+ import re
+
+ substituted, blocks = extract_embedded_json(f"P.\n\n{_OBJECT_BLOCK}\n\nQ.")
+ placeholder = next(iter(blocks))
+ assert re.search(r"\b[0-9a-f]{7,40}\b", placeholder) is None
+ assert placeholder in substituted
+
+
+class TestSidechannelUserRendering:
+ def test_json_block_renders_as_params_table(self) -> None:
+ html = format_workflow_sidechannel_user_text(
+ f"Check this:\n\n{_OBJECT_BLOCK}\n\nThanks."
+ )
+ assert "tool-params-table" in html
+ assert "embedded-json" in html
+ # Raw JSON text is gone; values appear in table cells.
+ assert '"touches"' not in html
+ assert "a.py" in html
+
+ def test_long_prompt_is_collapsible(self) -> None:
+ filler = "\n".join(f"Line {i} of prose." for i in range(20))
+ html = format_workflow_sidechannel_user_text(
+ f"{filler}\n\n{_OBJECT_BLOCK}\n\nEnd."
+ )
+ assert " None:
+ # Block within the first preview lines of a long prompt: the summary
+ # shows the compact hint; the table renders once, in the body.
+ filler = "\n".join(f"Line {i}." for i in range(20))
+ html = format_workflow_sidechannel_user_text(
+ f"Top:\n\n{_OBJECT_BLOCK}\n\n{filler}"
+ )
+ head, tail = html.split("
", 1)
+ assert "embedded-json-hint" in head
+ assert "tool-params-table" not in head
+ assert "tool-params-table" in tail
+
+ def test_markdown_prose_still_renders(self) -> None:
+ html = format_workflow_sidechannel_user_text(
+ f"You are an **ADVERSARIAL** verifier.\n\n{_OBJECT_BLOCK}\n\nGo."
+ )
+ assert "ADVERSARIAL" in html
+
+ def test_breadth_cap_boundary(self) -> None:
+ # Generation-side discipline (CodeRabbit, PR #216): at the cap the
+ # block tabulates; past it, an escaped JSON fold — no one-
-per-
+ # element generation for huge embedded arrays.
+ import json as _json
+
+ def prompt(n: int) -> str:
+ block = _json.dumps(list(range(n)), indent=2)
+ return f"Data:\n\n{block}\n\nEnd."
+
+ at_cap = format_workflow_sidechannel_user_text(prompt(200))
+ assert "tool-params-table" in at_cap
+
+ past_cap = format_workflow_sidechannel_user_text(prompt(201))
+ assert "tool-params-table" not in past_cap
+ assert "201 items (JSON)" in past_cap
+ assert "embedded-json" in past_cap
+
+ def test_user_content_stays_escaped(self) -> None:
+ html = format_workflow_sidechannel_user_text(
+ 'Try :\n\n{\n "x": ""\n}\n\nEnd.'
+ )
+ assert "