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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,22 @@ def load_directory_transcripts(
)
]

return dag_ordered + non_dag_entries, tree
# Discover + parse any dynamic-workflow runs under this directory and stash
# them on the tree, keyed by runId, for the renderer to splice in (#174 PR3).
from .workflow import load_workflow_runs, map_workflow_runs_by_tool_use

tree.workflow_runs = {
run.run_id: run for run in load_workflow_runs(directory_path, silent=silent)
}
# Resolve {tool_use_id: run} once, at full-session scope (BEFORE the renderer
# paginates), so a Workflow tool_use links to its run even when its
# tool_result lands on a different page (#174 PR3, pagination-boundary fix).
all_entries = dag_ordered + non_dag_entries
tree.workflow_links = map_workflow_runs_by_tool_use(
all_entries, list(tree.workflow_runs.values())
)

return all_entries, tree


# =============================================================================
Expand Down Expand Up @@ -1582,6 +1597,25 @@ def convert_jsonl_to(
_integrate_agent_entries(messages)
title = f"Claude Transcript - {input_path.stem}"
cache_was_updated = False # No cache in single file mode

# Single-file workflow support (#174 PR3): a lone ``<SID>.jsonl`` still
# has its run data in the sibling ``<SID>/subagents/workflows/`` dir, so
# discover + link it exactly like directory mode and splice the tree.
# Only build a SessionTree when runs exist — otherwise leave
# ``session_tree=None`` so the no-workflow single-file path (the common
# case) is byte-identical to before.
from .workflow import (
load_session_workflow_runs,
map_workflow_runs_by_tool_use,
)

single_file_runs = load_session_workflow_runs(input_path, silent=silent)
if single_file_runs:
session_tree = build_dag_from_entries(messages)
session_tree.workflow_runs = {r.run_id: r for r in single_file_runs}
session_tree.workflow_links = map_workflow_runs_by_tool_use(
messages, single_file_runs
)
else:
# Directory mode - Cache-First Approach
# `output_root` (#151) decouples the output destination from
Expand Down
17 changes: 16 additions & 1 deletion claude_code_log/dag.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

import logging
from dataclasses import dataclass, field
from typing import Optional
from typing import Optional, TYPE_CHECKING

if TYPE_CHECKING:
from .workflow import WorkflowRun

from .models import (
AiTitleTranscriptEntry,
Expand Down Expand Up @@ -93,6 +96,18 @@ class SessionTree:
sessions: dict[str, SessionDAGLine]
roots: list[str] # Root session IDs (no parent session)
junction_points: dict[str, JunctionPoint]
# Parsed dynamic-workflow runs keyed by runId (issue #174 PR3), populated
# by load_directory_transcripts. Empty for single-file / non-workflow loads.
workflow_runs: dict[str, "WorkflowRun"] = field( # pyright: ignore[reportUnknownVariableType]
default_factory=dict
)
# {Workflow tool_use_id: WorkflowRun}, resolved at full-session scope BEFORE
# pagination splits messages into pages (#174 PR3). Lets the per-page linker
# attach a run to its Workflow tool_use even when the tool_use and its
# tool_result land on different pages. Empty for non-workflow loads.
workflow_links: dict[str, "WorkflowRun"] = field( # pyright: ignore[reportUnknownVariableType]
default_factory=dict
)


# =============================================================================
Expand Down
16 changes: 16 additions & 0 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
UserMemoryMessage,
UserSlashCommandMessage,
UserTextMessage,
WorkflowAgentMessage,
WorkflowPhaseMessage,
# Tool input types
AskUserQuestionInput,
AskUserQuestionItem,
Expand Down Expand Up @@ -170,6 +172,8 @@
format_websearch_output,
format_webfetch_input,
format_workflow_input,
format_workflow_phase_content,
format_workflow_agent_content,
format_webfetch_output,
format_monitor_input,
format_monitor_output,
Expand Down Expand Up @@ -583,6 +587,18 @@ def format_UnknownMessage(self, content: UnknownMessage, _: TemplateMessage) ->
"""Format → <pre class='unknown'>JSON dump</pre>."""
return format_unknown_content(content)

def format_WorkflowPhaseMessage(
self, content: WorkflowPhaseMessage, _: TemplateMessage
) -> str:
"""Format → spliced workflow phase card body (detail + agent count)."""
return format_workflow_phase_content(content)

def format_WorkflowAgentMessage(
self, content: WorkflowAgentMessage, _: TemplateMessage
) -> str:
"""Format → spliced workflow agent card body (meta line + result)."""
return format_workflow_agent_content(content)

# -------------------------------------------------------------------------
# Tool Input Formatters
# -------------------------------------------------------------------------
Expand Down
42 changes: 42 additions & 0 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1344,3 +1344,45 @@ details summary {
.workflow-script {
margin-top: 4px;
}

/* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */
.workflow-phase-meta,
.workflow-agent-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
font-size: 0.85em;
color: var(--text-muted);
}

.workflow-phase-detail {
color: var(--text-secondary);
}

.workflow-phase-count,
.workflow-agent-tokens,
.workflow-agent-tools {
font-size: 0.95em;
color: var(--text-muted);
}

.workflow-agent-model {
font-family: var(--font-mono, monospace);
color: var(--text-secondary);
}

.workflow-agent-state {
font-style: italic;
}

.workflow-agent-result,
.workflow-agent-result-preview {
display: block;
margin-top: 6px;
}

.workflow-agent-result-preview {
color: var(--text-muted);
font-size: 0.9em;
}
13 changes: 12 additions & 1 deletion claude_code_log/html/templates/components/timeline.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
'bash-input': { id: 'bash-input', content: '💻 Bash Input', style: 'background-color: #e8eaf6;' },
'bash-output': { id: 'bash-output', content: '📄 Bash Output', style: 'background-color: #efebe9;' },
'teammate': { id: 'teammate', content: '👥 Teammate', style: 'background-color: #e8f2fd;' },
'task-notification': { id: 'task-notification', content: '🔄 Async result', style: 'background-color: #e8f2fd;' }
'task-notification': { id: 'task-notification', content: '🔄 Async result', style: 'background-color: #e8f2fd;' },
'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' },
'workflow_agent': { id: 'workflow_agent', content: '🤖 Workflow Agent', style: 'background-color: #f3e5f5;' }
};

// Build timeline data from messages
Expand Down Expand Up @@ -95,6 +97,15 @@
// they'd land in the `user` group rather than getting
// their own 🔄 Async result row in the timeline.
messageType = 'task-notification';
} else if (classList.includes('workflow_phase')) {
// Spliced dynamic-workflow phase/agent cards (#174 PR3)
// carry the `tool_use` class for filter visibility, so —
// like `teammate`/`task-notification` — they need an
// explicit branch before the generic `.find` below, else
// they'd be swept into the 🛠️ Tool Use lane.
messageType = 'workflow_phase';
} else if (classList.includes('workflow_agent')) {
messageType = 'workflow_agent';
} else {
// Look for standard message types
messageType = classList.find(cls =>
Expand Down
87 changes: 85 additions & 2 deletions claude_code_log/html/tool_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
resolve_memory_body_links,
)
from ..utils import strip_error_tags
from ..workflow import parse_workflow_meta
from ..workflow import resolve_workflow_header
from ..models import (
AskUserQuestionInput,
AskUserQuestionItem,
Expand Down Expand Up @@ -68,6 +68,8 @@
WebSearchOutput,
WebFetchInput,
WebFetchOutput,
WorkflowAgentMessage,
WorkflowPhaseMessage,
WorkflowToolInput,
WriteInput,
WriteOutput,
Expand Down Expand Up @@ -1190,7 +1192,9 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str:
``meta`` block (name / description / phase pills) above the JavaScript
orchestrator source, syntax-highlighted and collapsible when long."""
script = workflow_input.script or ""
name, description, phases = parse_workflow_meta(script)
name, description, phases = resolve_workflow_header(
workflow_input.workflow_run, script
)

header_parts: list[str] = []
if name:
Expand Down Expand Up @@ -1223,6 +1227,83 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str:
return f"{header}{body}"


# -- Workflow run tree: phase + agent cards (issue #174 PR3) -------------------


def format_workflow_phase_content(content: WorkflowPhaseMessage) -> str:
"""Format a spliced workflow *phase* card body: the phase ``detail`` plus
its agent count. The phase title is the card heading (``title_content``)."""
parts: list[str] = []
if content.detail:
parts.append(
f"<span class='workflow-phase-detail'>{escape_html(content.detail)}</span>"
)
if content.agent_count:
unit = "agent" if content.agent_count == 1 else "agents"
parts.append(
f"<span class='workflow-phase-count'>{content.agent_count} {unit}</span>"
)
if not parts:
return ""
return f"<div class='workflow-phase-meta'>{''.join(parts)}</div>"


def format_workflow_agent_content(content: WorkflowAgentMessage) -> str:
"""Format a spliced workflow *agent* card body: a metadata chrome line
(model / state / tokens / tool calls) above the agent's result — a
``StructuredOutput`` dict pretty-printed + highlighted as JSON, a plain
string rendered as collapsible Markdown. The agent's side-channel
transcript renders separately as this node's ``.children``."""
meta_bits: list[str] = []
if content.model:
meta_bits.append(
f"<span class='workflow-agent-model'>{escape_html(content.model)}</span>"
)
if content.state:
meta_bits.append(
f"<span class='workflow-agent-state'>{escape_html(content.state)}</span>"
)
if content.tokens is not None:
meta_bits.append(
f"<span class='workflow-agent-tokens'>{content.tokens} tokens</span>"
)
if content.tool_calls is not None:
unit = "call" if content.tool_calls == 1 else "calls"
meta_bits.append(
f"<span class='workflow-agent-tools'>{content.tool_calls} tool {unit}</span>"
)
parts: list[str] = []
if meta_bits:
parts.append(f"<div class='workflow-agent-meta'>{''.join(meta_bits)}</div>")

result = content.result
if isinstance(result, (dict, list)):
# Pretty-print + JSON-highlight directly. NOT via render_async_result_body
# — its JSON heuristic only fires on `{"`-shaped text, so a list-shaped
# StructuredOutput result (``[...]``) would fall through to the markdown
# path and lose JSON highlighting (and diverge from the Markdown renderer,
# which fences both dict and list as JSON). A real dict/list always
# serializes to valid JSON, so highlight it unconditionally (CR #210).
pretty = json.dumps(result, indent=2, ensure_ascii=False)
parts.append(
render_file_content_collapsible(
pretty,
"result.json",
"workflow-agent-result",
line_threshold=10,
preview_line_count=6,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
elif isinstance(result, str) and result.strip():
parts.append(render_markdown_collapsible(result, "workflow-agent-result"))
elif content.result_preview:
parts.append(
f"<span class='workflow-agent-result-preview'>"
f"{escape_html(content.result_preview)}</span>"
)
return "".join(parts)


# -- Public Exports -----------------------------------------------------------

__all__ = [
Expand All @@ -1241,6 +1322,8 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str:
"format_websearch_input",
"format_webfetch_input",
"format_workflow_input",
"format_workflow_phase_content",
"format_workflow_agent_content",
"format_monitor_input",
"format_schedulewakeup_input",
"format_croncreate_input",
Expand Down
13 changes: 13 additions & 0 deletions claude_code_log/html/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
UserSlashCommandMessage,
UserSteeringMessage,
UserTextMessage,
WorkflowAgentMessage,
WorkflowPhaseMessage,
)
from ..renderer_timings import timing_stat

Expand Down Expand Up @@ -190,6 +192,13 @@ def _rewrite(m: "re.Match[str]") -> str:
BashInputMessage: ["bash-input"],
BashOutputMessage: ["bash-output"],
UnknownMessage: ["unknown"],
# Dynamic-workflow synthetic nodes (#174 PR3): phase/agent cards spliced
# under a Workflow tool_use. The `tool_use` class keeps them visible under
# the runtime "Tool Use" filter toggle (the splice lives inside a tool_use
# subtree); the `workflow_phase`/`workflow_agent` modifiers drive styling
# and the timeline's dedicated detection branch.
WorkflowPhaseMessage: ["tool_use", "workflow_phase"],
WorkflowAgentMessage: ["tool_use", "workflow_agent"],
}


Expand Down Expand Up @@ -320,6 +329,10 @@ def get_message_emoji(msg: "TemplateMessage") -> str:
return "💭"
elif msg_type == "image":
return "🖼️"
elif msg_type == "workflow_phase":
return "🧩"
elif msg_type == "workflow_agent":
return "🤖"
return ""


Expand Down
Loading
Loading