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
186 changes: 135 additions & 51 deletions claude_code_log/converter.py

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions claude_code_log/dag.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class SessionDAGLine:
attachment_uuid: Optional[str] = None # UUID in parent where this attaches
is_branch: bool = False # True for within-session fork branches
original_session_id: Optional[str] = None # Original session_id before fork split
is_sidechain: bool = False # True for agent transcript sessions


@dataclass
Expand Down Expand Up @@ -138,8 +139,9 @@ def build_dag(
"""Populate children_uuids on each node. Mutates nodes in place.

Warns about orphan nodes (parentUuid points outside loaded data)
and validates acyclicity. Parents known to be in sidechain data
(Phase C scope) are silently promoted to root without warning.
and validates acyclicity. Parents known to be in unloaded sidechain
data (e.g. aprompt_suggestion agents) are silently promoted to root
without warning.
"""
_sidechain_uuids = sidechain_uuids or set()

Expand Down Expand Up @@ -639,7 +641,8 @@ def build_dag_from_entries(

Convenience function that runs Steps 1-4 in sequence.
``sidechain_uuids`` suppresses orphan warnings for parents known
to be in sidechain data (not yet integrated, Phase C scope).
to be in unloaded sidechain data (e.g. aprompt_suggestion agents
that are never referenced via agentId in the main session).
"""
nodes = build_message_index(entries)
build_dag(nodes, sidechain_uuids=sidechain_uuids)
Expand Down
2 changes: 1 addition & 1 deletion claude_code_log/factories/system_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,5 @@ def create_system_message(

# Create structured system content
meta = create_meta(transcript)
level = getattr(transcript, "level", "info")
level = transcript.level or "info"
return SystemMessage(level=level, text=transcript.content, meta=meta)
18 changes: 15 additions & 3 deletions claude_code_log/factories/transcript_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
# Transcript entry types
AssistantTranscriptEntry,
MessageType,
PassthroughTranscriptEntry,
QueueOperationTranscriptEntry,
SummaryTranscriptEntry,
SystemTranscriptEntry,
Expand Down Expand Up @@ -233,6 +234,17 @@ def create_transcript_entry(data: dict[str, Any]) -> TranscriptEntry:
"""
entry_type = data.get("type")
creator = ENTRY_CREATORS.get(entry_type) # type: ignore[arg-type]
if creator is None:
raise ValueError(f"Unknown transcript entry type: {entry_type}")
return creator(data)
if creator is not None:
return creator(data)
# Fall back to PassthroughTranscriptEntry for unknown types with DAG fields
if data.get("uuid") and data.get("sessionId"):
return PassthroughTranscriptEntry(
uuid=data["uuid"],
parentUuid=data.get("parentUuid"),
sessionId=data["sessionId"],
timestamp=data.get("timestamp", ""),
type=entry_type,
isSidechain=data.get("isSidechain", False),
agentId=data.get("agentId"),
)
raise ValueError(f"Unknown transcript entry type: {entry_type}")
42 changes: 26 additions & 16 deletions claude_code_log/html/system_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,23 +89,33 @@ def format_session_header_content(content: SessionHeaderMessage) -> str:
"""
escaped_title = html.escape(content.title)
if content.is_branch and content.parent_message_index is not None:
# Branch header: backlink to fork point with context
fork_label = "fork point"
# Branch header: compact with back-reference to fork point
# Show session info for cross-session branches (different real session)
session_info = ""
if content.original_session_id and content.parent_session_id:
parent_real_sid = content.parent_session_id.split("@")[0]
if content.original_session_id != parent_real_sid:
esc_sid = html.escape(content.original_session_id[:8])
session_info = (
f' <span class="branch-session">(in Session {esc_sid})</span>'
)
fork_backref = ""
if content.parent_session_summary:
escaped_summary = html.escape(content.parent_session_summary)
fork_label = escaped_summary
# Show original session ID for context
orig_id = ""
if content.original_session_id:
orig_id = html.escape(content.original_session_id[:8])
link = (
f'<a href="#msg-d-{content.parent_message_index}" '
f'class="session-backlink branch-backlink">'
f"&#x21b3; branched from {fork_label}</a>"
)
return (
f"{orig_id} {link}{escaped_title}" if orig_id else f"{link}{escaped_title}"
)
escaped_fork = html.escape(content.parent_session_summary)
fork_backref = (
f'<div class="branch-from">'
f'from <a href="#msg-d-{content.parent_message_index}" '
f'class="branch-backlink">'
f"&#x2442; Fork point &bull; {escaped_fork}</a></div>"
)
else:
fork_backref = (
f'<div class="branch-from">'
f'from <a href="#msg-d-{content.parent_message_index}" '
f'class="branch-backlink">'
f"&#x2442; Fork point</a></div>"
)
return f"{escaped_title}{session_info}{fork_backref}"
if content.parent_session_id:
parent_label = content.parent_session_summary or content.parent_session_id[:8]
escaped_parent = html.escape(parent_label)
Expand Down
4 changes: 4 additions & 0 deletions claude_code_log/html/templates/components/global_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
--system-error-color: #f44336;
--tool-use-color: #4caf50;

/* Fork/branch structural colors */
--fork-point-color: #adb5bd;
--branch-point-color: #adb5bd;

/* Question/answer tool colors */
--question-accent: #f5a623;
--question-bg: #fffbf0;
Expand Down
17 changes: 17 additions & 0 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,23 @@

/* Branch headers (within-session forks) — indent set via inline
style based on tree depth (margin-left: depth*2em). */
.branch-header {
background-color: transparent;
box-shadow: none;
border: none;
border-left: 3px solid var(--branch-point-color);
padding: 8px 14px;
margin: 2em 0 1em 0;
}

.branch-header .header {
font-size: 1em;
margin-bottom: 0;
}

.branch-header .fold-bar[data-border-color="session-header"] .fold-bar-section.folded {
border-bottom-color: var(--branch-point-color);
}

.session-subtitle {
font-size: 0.9em;
Expand Down
58 changes: 41 additions & 17 deletions claude_code_log/html/templates/components/session_nav_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,32 +125,56 @@ a.session-backlink:hover {
line-height: 1.3;
}

/* Within-session fork styles */
.junction-forward-links {
/* Fork point element — standalone structural element after a fork message */
.fork-point {
margin: 8px 0 2em 0;
padding: 8px 14px;
font-size: 0.85em;
color: var(--text-muted, #6c757d);
border-left: 3px solid var(--fork-point-color);
border-radius: 8px;
}

.fork-point-header {
font-weight: 600;
margin-bottom: 4px;
color: var(--text-secondary, #495057);
}

.fork-point-branches {
display: flex;
gap: 8px;
margin-top: 6px;
padding: 4px 8px;
font-size: 0.8em;
flex-direction: column;
gap: 2px;
padding-left: 12px;
}

.junction-link {
color: #6c757d;
.fork-point-branch {
display: block;
text-decoration: none;
padding: 2px 6px;
border: 1px dashed #adb5bd;
border-radius: 3px;
transition: background-color 0.2s;
color: var(--text-muted, #6c757d);
padding: 2px 0;
transition: color 0.2s;
}

.junction-link:hover {
background-color: #e9ecef;
color: var(--text-secondary);
.fork-point-branch:hover {
color: var(--text-secondary, #495057);
}

/* Branch header back-reference to fork point */
.branch-from {
font-size: 0.85em;
color: var(--text-muted, #6c757d);
margin-top: 2px;
}

.branch-backlink {
border-left: 2px dashed #888;
padding-left: 6px;
text-decoration: none;
color: var(--text-muted, #6c757d);
transition: color 0.2s;
}

.branch-backlink:hover {
color: var(--text-secondary, #495057);
}

/* Fork point and branch nav items — lightweight text links, not cards */
Expand Down
22 changes: 13 additions & 9 deletions claude_code_log/html/templates/transcript.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ <h3>🔍 Search & Filter</h3>

{% for message, message_title, html_content, formatted_timestamp in messages %}
{% if is_session_header(message) %}
<div class="session-divider"></div>
{% if not message.is_branch_header %}<div class="session-divider"></div>{% endif %}
<div class='message session-header{% if message.is_branch_header %} branch-header{% endif %}' data-message-id='{{ message.message_id }}' data-session-id='{{ message.session_id }}' id='msg-{{ message.message_id }}'{% if message.branch_depth %} style='margin-left: {{ message.branch_depth * 2 }}em'{% endif %}>
<div class='header'>Session: {{ html_content|safe }}</div>
<div class='header'>{% if message.is_branch_header %}&#x21b3; {% else %}Session: {% endif %}{{ html_content|safe }}</div>
{% if message.has_children %}
<div class='fold-bar' data-message-id='{{ message.message_id }}' data-border-color='session-header'>
{% if message.immediate_children_count == message.total_descendants_count %}
Expand Down Expand Up @@ -143,13 +143,6 @@ <h3>🔍 Search & Filter</h3>
</div>
{% if message.meta %}<div class='debug-info'>{{ message.meta.uuid[:12] }}{% if message.meta.parent_uuid %} &rarr; {{ message.meta.parent_uuid[:12] }}{% endif %}</div>{% endif %}
<div class='content{% if markdown %} markdown{% endif %}'>{{ html_content | safe }}</div>
{% if message.junction_forward_links %}
<div class='junction-forward-links'>
{% for branch_sid, branch_idx in message.junction_forward_links %}
<a href='#msg-d-{{ branch_idx }}' class='junction-link'>&#x2192; {{ branch_sid.split('@')[-1][:8] }}</a>
{% endfor %}
</div>
{% endif %}
{% if message.has_children %}
<div class='fold-bar' data-message-id='{{ message.message_id }}' data-border-color='{{ msg_css_class }}'>
{% if message.immediate_children_count == message.total_descendants_count %}
Expand All @@ -173,6 +166,17 @@ <h3>🔍 Search & Filter</h3>
{% endif %}
</div>
{% endif %}
{% if message.junction_forward_links %}
{# Fork point element — rendered OUTSIDE the message box as a structural navigation element #}
<div class='fork-point{% for ancestor_index in message.ancestry %} d-{{ ancestor_index }}{% endfor %}'>
<div class='fork-point-header'>&#x2442; Fork point{% if message.fork_point_preview %} &bull; {{ message.fork_point_preview }}{% endif %}</div>
<div class='fork-point-branches'>
{% for branch_sid, branch_idx, branch_preview in message.junction_forward_links %}
<a href='#msg-d-{{ branch_idx }}' class='fork-point-branch'>&#x21b3; Branch{% if branch_preview %} &bull; {{ branch_preview }}{% endif %}</a>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}

<button class="timeline-toggle floating-btn" id="toggleTimeline" title="Show timeline">📆</button>
Expand Down
19 changes: 19 additions & 0 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,31 @@ class QueueOperationTranscriptEntry(BaseModel):
)


class PassthroughTranscriptEntry(BaseModel):
"""Structural-only entry for DAG chain continuity.

Captures entries like "attachment", "permission-mode", etc. that have
uuid/parentUuid and participate in the DAG chain but are not rendered.
Without these, messages whose parentUuid points to a dropped entry
become false roots in the DAG.
"""

uuid: str
parentUuid: Optional[str] = None
sessionId: str
timestamp: str
type: Optional[str] = None # Original type (e.g. "attachment")
isSidechain: bool = False
agentId: Optional[str] = None


TranscriptEntry = Union[
UserTranscriptEntry,
AssistantTranscriptEntry,
SummaryTranscriptEntry,
SystemTranscriptEntry,
QueueOperationTranscriptEntry,
PassthroughTranscriptEntry,
]


Expand Down
Loading