diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 2c6e3afc..2433744e 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -506,6 +506,11 @@ def _clear_output_files(input_path: Path, all_projects: bool, file_ext: str) -> default=2000, help="Maximum messages per page for combined transcript (default: 2000). Sessions are never split across pages.", ) +@click.option( + "--shallow", + is_flag=True, + help="Render only user and assistant text messages (no tools, system, or thinking)", +) @click.option( "--debug", is_flag=True, @@ -528,6 +533,7 @@ def main( output_format: str, image_export_mode: Optional[str], page_size: int, + shallow: bool, debug: bool, ) -> None: """Convert Claude transcript JSONL files to HTML or Markdown. @@ -685,6 +691,7 @@ def main( output_format, image_export_mode, page_size=page_size, + shallow=shallow, ) # Count processed projects @@ -737,6 +744,7 @@ def main( not no_cache, image_export_mode=image_export_mode, page_size=page_size, + shallow=shallow, ) if input_path.is_file(): click.echo(f"Successfully converted {input_path} to {output_path}") diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 2a673080..a09d0bea 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -15,7 +15,9 @@ from .utils import ( format_timestamp_range, + get_parent_session_id, get_project_display_name, + is_agent_session, should_use_as_session_starter, create_session_preview, get_warmup_session_ids, @@ -29,13 +31,17 @@ from .parser import parse_timestamp from .factories import create_transcript_entry from .models import ( + BaseTranscriptEntry, + PassthroughTranscriptEntry, TranscriptEntry, AssistantTranscriptEntry, + QueueOperationTranscriptEntry, SummaryTranscriptEntry, SystemTranscriptEntry, UserTranscriptEntry, ToolResultContent, ) +from .dag import SessionTree, build_dag_from_entries, traverse_session_tree from .renderer import get_renderer, is_html_outdated @@ -47,6 +53,118 @@ def get_file_extension(format: str) -> str: return "md" if format in ("md", "markdown") else format +# ============================================================================= +# Progress Chain Repair +# ============================================================================= + + +def _scan_file_progress(path: Path, chain: dict[str, Optional[str]]) -> None: + """Extract progress entry uuid->parentUuid from a single JSONL file.""" + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + if "progress" not in line: # Fast pre-filter + continue + line = line.strip() + if not line: + continue + try: + raw = json.loads(line) + if not isinstance(raw, dict): + continue + d = cast(dict[str, Any], raw) + if d.get("type") == "progress": + uuid = d.get("uuid") + if isinstance(uuid, str): + chain[uuid] = d.get("parentUuid") + except json.JSONDecodeError: + continue + except FileNotFoundError: + pass # Race condition: file may have been deleted + + +def _scan_progress_chains(*paths: Path) -> dict[str, Optional[str]]: + """Fast scan of JSONL files for progress entry uuid->parentUuid mappings.""" + chain: dict[str, Optional[str]] = {} + for path in paths: + if path.is_file(): + _scan_file_progress(path, chain) + elif path.is_dir(): + for f in path.glob("*.jsonl"): + _scan_file_progress(f, chain) + # Also scan subagent directories + for f in path.glob("*/subagents/*.jsonl"): + _scan_file_progress(f, chain) + return chain + + +def _scan_sidechain_uuids(directory: Path) -> set[str]: + """Collect UUIDs from sidechain/subagent files not loaded into the DAG. + + Some subagent files (e.g. aprompt_suggestion) are never referenced + via agentId in the main session, so they aren't loaded by + load_transcript(). Their UUIDs are needed to suppress false orphan + warnings when main-chain entries reference sidechain parents. + """ + uuids: set[str] = set() + for f in directory.glob("*/subagents/*.jsonl"): + try: + with open(f, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + raw = json.loads(line) + if isinstance(raw, dict): + uuid = cast(dict[str, Any], raw).get("uuid") + if isinstance(uuid, str): + uuids.add(uuid) + except json.JSONDecodeError: + continue + except FileNotFoundError: + pass + return uuids + + +def _repair_parent_chains( + messages: list[TranscriptEntry], + progress_chain: dict[str, Optional[str]], +) -> None: + """Repair parentUuid fields that point to dropped progress entries. + + Walks the progress chain to find the nearest non-progress ancestor. + Only repairs links to progress entries that are NOT in the messages + list (i.e. those that were truly dropped, not preserved as + PassthroughTranscriptEntry). + Mutates entries in place (Pydantic v2 models are mutable by default). + """ + if not progress_chain: + return + # Filter out progress UUIDs that are present as parsed entries — + # those are PassthroughTranscriptEntry nodes in the DAG and valid parents. + present_uuids = {getattr(m, "uuid", None) for m in messages} + dropped_progress = { + uuid: parent + for uuid, parent in progress_chain.items() + if uuid not in present_uuids + } + if not dropped_progress: + return + for msg in messages: + parent = getattr(msg, "parentUuid", None) + if parent and parent in dropped_progress: + current: Optional[str] = parent + seen: set[str] = set() + while current is not None and current in dropped_progress: + if current in seen: + current = None + break + seen.add(current) + current = dropped_progress[current] + msg.parentUuid = current # type: ignore[union-attr] + + # ============================================================================= # Transcript Loading Functions # ============================================================================= @@ -208,19 +326,20 @@ def load_transcript( # Parse using Pydantic models entry = create_transcript_entry(entry_dict) messages.append(entry) - elif ( - entry_type - in [ - "file-history-snapshot", # Internal Claude Code file backup metadata - "progress", # Real-time progress updates (hook_progress, bash_progress) - ] - ): - # Silently skip internal message types we don't render - pass - else: - display_line = line[:1000] + "..." if len(line) > 1000 else line - print( - f"Line {line_no} of {jsonl_path} is not a recognised message type: {display_line}" + elif entry_dict.get("uuid") and entry_dict.get("sessionId"): + # Unknown type with DAG-relevant fields — create a + # PassthroughTranscriptEntry to preserve DAG chain + # continuity (e.g. "attachment", "permission-mode"). + messages.append( + PassthroughTranscriptEntry( + uuid=entry_dict["uuid"], + parentUuid=entry_dict.get("parentUuid"), + sessionId=entry_dict["sessionId"], + timestamp=entry_dict.get("timestamp", ""), + type=entry_type, + isSidechain=entry_dict.get("isSidechain", False), + agentId=entry_dict.get("agentId"), + ) ) except json.JSONDecodeError as e: print( @@ -309,14 +428,70 @@ def load_transcript( return messages +def _integrate_agent_entries(messages: list[TranscriptEntry]) -> None: + """Parent agent entries and assign synthetic session IDs. + + Agent (sidechain) entries share sessionId with their parent session + but form separate conversation threads. This function: + + 1. Builds a map of agentId -> anchor UUID (the main-session User entry + whose agentId matches, i.e. the tool_result that references the agent) + 2. For each agent's root entry (parentUuid=None, isSidechain=True), + sets parentUuid to the anchor UUID + 3. Assigns a synthetic sessionId ("{sessionId}#agent-{agentId}") to all + agent entries so they form separate DAG-lines + + Mutates entries in place (Pydantic v2 models are mutable by default). + """ + # Build agentId -> anchor UUID map. + # An anchor is any entry whose agentId references a sidechain transcript. + # Prefer non-sidechain anchors (main session), but also accept sidechain + # anchors (nested agents: agent A spawns agent B, so B's anchor lives + # inside A's sidechain). + agent_anchors: dict[str, str] = {} + agent_anchors_from_sidechain: dict[str, str] = {} + for msg in messages: + if not isinstance(msg, (BaseTranscriptEntry, PassthroughTranscriptEntry)): + continue + if not msg.agentId: + continue + if msg.isSidechain: + agent_anchors_from_sidechain.setdefault(msg.agentId, msg.uuid) + else: + agent_anchors[msg.agentId] = msg.uuid + # Merge: non-sidechain anchors take priority + for agent_id, uuid in agent_anchors_from_sidechain.items(): + agent_anchors.setdefault(agent_id, uuid) + + if not agent_anchors: + return + + # Process sidechain entries: parent roots and assign synthetic sessionIds + for msg in messages: + if not isinstance(msg, (BaseTranscriptEntry, PassthroughTranscriptEntry)): + continue + if not msg.isSidechain or not msg.agentId: + continue + agent_id = msg.agentId + # Assign synthetic session ID to separate from main session + msg.sessionId = f"{msg.sessionId}#agent-{agent_id}" + # Parent the root entry to the anchor + if msg.parentUuid is None and agent_id in agent_anchors: + msg.parentUuid = agent_anchors[agent_id] + + def load_directory_transcripts( directory_path: Path, cache_manager: Optional["CacheManager"] = None, from_date: Optional[str] = None, to_date: Optional[str] = None, silent: bool = False, -) -> list[TranscriptEntry]: - """Load all JSONL transcript files from a directory and combine them.""" +) -> tuple[list[TranscriptEntry], SessionTree]: + """Load all JSONL transcript files from a directory and combine them. + + Returns (messages, session_tree) — the tree is reused by the renderer + to avoid rebuilding the DAG. + """ all_messages: list[TranscriptEntry] = [] # Find all .jsonl files, excluding agent files (they are loaded via load_transcript @@ -331,14 +506,32 @@ def load_directory_transcripts( ) all_messages.extend(messages) - # Sort all messages chronologically - def get_timestamp(entry: TranscriptEntry) -> str: - if hasattr(entry, "timestamp"): - return entry.timestamp # type: ignore - return "" + # Repair parent chains: progress entries create UUID gaps + progress_chain = _scan_progress_chains(directory_path) + _repair_parent_chains(all_messages, progress_chain) + + # Parent agent entries and assign synthetic session IDs so they + # form separate DAG-lines spliced at their anchor points. + _integrate_agent_entries(all_messages) + + # Collect UUIDs from unloaded subagent files (e.g. aprompt_suggestion + # agents never referenced via agentId) to suppress orphan warnings + unloaded_sidechain_uuids = _scan_sidechain_uuids(directory_path) + + # Build DAG and traverse (entries grouped by session, depth-first) + tree = build_dag_from_entries( + all_messages, sidechain_uuids=unloaded_sidechain_uuids + ) + dag_ordered = traverse_session_tree(tree) + + # Re-add summaries/queue-ops (excluded from DAG since they lack uuid) + non_dag_entries: list[TranscriptEntry] = [ + e + for e in all_messages + if isinstance(e, (SummaryTranscriptEntry, QueueOperationTranscriptEntry)) + ] - all_messages.sort(key=get_timestamp) - return all_messages + return dag_ordered + non_dag_entries, tree # ============================================================================= @@ -406,12 +599,18 @@ def deduplicate_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntr content_key = item.tool_use_id break else: - # No tool result found - this is a user text message + # No tool result found - this is a user text message. + # Use uuid to keep distinct messages (even at same timestamp) + # so DAG parent references remain valid. is_user_text = True - # content_key stays empty (dedupe by timestamp alone) + content_key = message.uuid elif isinstance(message, SummaryTranscriptEntry): # Summaries have no timestamp or uuid - use leafUuid to keep them distinct content_key = message.leafUuid + elif isinstance(message, PassthroughTranscriptEntry): + # Passthrough entries are DAG chain nodes — use uuid to prevent + # false dedup of entries with the same timestamp (e.g. attachments) + content_key = message.uuid # Create deduplication key dedup_key = (message_type, timestamp, is_meta, session_id, content_key) @@ -614,11 +813,11 @@ def _build_session_data_from_messages( sessions: Dict[str, Dict[str, Any]] = {} for message in messages: if not hasattr(message, "sessionId") or isinstance( - message, SummaryTranscriptEntry + message, (SummaryTranscriptEntry, PassthroughTranscriptEntry) ): continue - session_id = getattr(message, "sessionId", "") + session_id = get_parent_session_id(getattr(message, "sessionId", "")) if not session_id or session_id in warmup_session_ids: continue @@ -698,6 +897,8 @@ def _generate_paginated_html( session_data: Dict[str, SessionCacheData], working_directories: List[str], silent: bool = False, + session_tree: Optional[SessionTree] = None, + shallow: bool = False, ) -> Path: """Generate paginated HTML files for combined transcript. @@ -714,7 +915,7 @@ def _generate_paginated_html( Returns: Path to the first page (combined_transcripts.html) """ - from .html.renderer import generate_html + from .html.renderer import HtmlRenderer from .utils import format_timestamp # Check if page size changed - if so, invalidate all pages @@ -747,14 +948,16 @@ def _generate_paginated_html( if orphan_path.exists(): orphan_path.unlink() - # Group messages by session for fast lookup + # Group messages by session for fast lookup (agent messages grouped + # under their parent session since they don't have their own pages) messages_by_session: Dict[str, List[TranscriptEntry]] = {} for msg in messages: session_id = getattr(msg, "sessionId", None) if session_id: - if session_id not in messages_by_session: - messages_by_session[session_id] = [] - messages_by_session[session_id].append(msg) + key = get_parent_session_id(session_id) + if key not in messages_by_session: + messages_by_session[key] = [] + messages_by_session[key].append(msg) first_page_path = output_dir / _get_page_html_path(1) @@ -851,11 +1054,14 @@ def _generate_paginated_html( # Generate HTML for this page page_title = f"{title} - Page {page_num}" if page_num > 1 else title - html_content = generate_html( + page_renderer = HtmlRenderer() + page_renderer.shallow = shallow + html_content = page_renderer.generate( page_messages, page_title, page_info=page_info, page_stats=page_stats, + session_tree=session_tree, ) page_file.write_text(html_content, encoding="utf-8") @@ -915,6 +1121,7 @@ def convert_jsonl_to( silent: bool = False, image_export_mode: Optional[str] = None, page_size: int = 2000, + shallow: bool = False, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -930,6 +1137,7 @@ def convert_jsonl_to( image_export_mode: Image export mode ("placeholder", "embedded", "referenced"). page_size: Maximum messages per page for combined transcript pagination. If None, uses format default (embedded for HTML, referenced for Markdown). + shallow: If True, render only user and assistant text messages. """ if not input_path.exists(): raise FileNotFoundError(f"Input path not found: {input_path}") @@ -948,11 +1156,21 @@ def convert_jsonl_to( # Initialize working_directories for both branches (used by pagination in directory mode) working_directories: List[str] = [] + # session_tree is populated in directory mode (DAG already built); + # None in single-file mode (renderer builds it on demand) + session_tree: Optional[SessionTree] = None + if input_path.is_file(): # Single file mode - cache only available for directory mode if output_path is None: output_path = input_path.with_suffix(f".{ext}") messages = load_transcript(input_path, silent=silent) + # Repair progress chain gaps for single-file mode + progress_chain = _scan_progress_chains(input_path) + _repair_parent_chains(messages, progress_chain) + # Parent agent entries and assign synthetic session IDs (same as + # directory mode) so DAG-based ordering handles sidechain placement. + _integrate_agent_entries(messages) title = f"Claude Transcript - {input_path.stem}" cache_was_updated = False # No cache in single file mode else: @@ -988,7 +1206,7 @@ def convert_jsonl_to( return output_path # Phase 2: Load messages (will use fresh cache when available) - messages = load_directory_transcripts( + messages, session_tree = load_directory_transcripts( input_path, cache_manager, from_date, to_date, silent ) @@ -1018,7 +1236,7 @@ def convert_jsonl_to( # Generate combined output file (check if regeneration needed) assert output_path is not None - renderer = get_renderer(format, image_export_mode) + renderer = get_renderer(format, image_export_mode, shallow=shallow) # Decide whether to use pagination (HTML only, directory mode, no date filter) use_pagination = False @@ -1047,7 +1265,11 @@ def convert_jsonl_to( current_session_ids: set[str] = set() for message in messages: session_id = getattr(message, "sessionId", "") - if session_id and session_id not in warmup_session_ids: + if ( + session_id + and session_id not in warmup_session_ids + and not is_agent_session(session_id) + ): current_session_ids.add(session_id) session_data = { session_id: session_cache @@ -1065,6 +1287,8 @@ def convert_jsonl_to( session_data, working_directories, silent=silent, + session_tree=session_tree, + shallow=shallow, ) else: # Use single-file generation for small projects or filtered views @@ -1091,7 +1315,9 @@ def convert_jsonl_to( if should_regenerate: # For referenced images, pass the output directory output_dir = output_path.parent - content = renderer.generate(messages, title, output_dir=output_dir) + content = renderer.generate( + messages, title, output_dir=output_dir, session_tree=session_tree + ) assert content is not None output_path.write_text(content, encoding="utf-8") @@ -1117,44 +1343,13 @@ def convert_jsonl_to( cache_was_updated, image_export_mode, silent=silent, + session_tree=session_tree, + shallow=shallow, ) return output_path -def has_cache_changes( - project_dir: Path, - cache_manager: Optional[CacheManager], - from_date: Optional[str] = None, - to_date: Optional[str] = None, -) -> bool: - """Check if cache needs updating (fast mtime comparison only). - - Returns True if there are modified files or cache is stale. - Does NOT load any messages - that's deferred to ensure_fresh_cache. - """ - if cache_manager is None: - return True # No cache means we need to process - - jsonl_files = list(project_dir.glob("*.jsonl")) - if not jsonl_files: - return False - - # Get cached project data - cached_project_data = cache_manager.get_cached_project_data() - - # Check various invalidation conditions - modified_files = cache_manager.get_modified_files(jsonl_files) - - return ( - cached_project_data is None - or from_date is not None - or to_date is not None - or bool(modified_files) - or (cached_project_data.total_message_count == 0 and bool(jsonl_files)) - ) - - def ensure_fresh_cache( project_dir: Path, cache_manager: Optional[CacheManager], @@ -1165,7 +1360,6 @@ def ensure_fresh_cache( """Ensure cache is fresh and populated. Returns True if cache was updated. This does the heavy lifting of loading and parsing files. - Call has_cache_changes() first for a fast check. """ if cache_manager is None: return False @@ -1201,7 +1395,7 @@ def ensure_fresh_cache( # Load and process messages to populate cache if not silent: print(f"Updating cache for {project_dir.name}...") - messages = load_directory_transcripts( + messages, _tree = load_directory_transcripts( project_dir, cache_manager, from_date, to_date, silent ) @@ -1271,7 +1465,7 @@ def _update_cache_with_session_data( if hasattr(message, "sessionId") and not isinstance( message, SummaryTranscriptEntry ): - session_id = getattr(message, "sessionId", "") + session_id = get_parent_session_id(getattr(message, "sessionId", "")) if not session_id: continue @@ -1308,7 +1502,7 @@ def _update_cache_with_session_data( if message.type == "assistant" and hasattr(message, "message"): assistant_message = getattr(message, "message") request_id = getattr(message, "requestId", None) - session_id = getattr(message, "sessionId", "") + session_id = get_parent_session_id(getattr(message, "sessionId", "")) if ( hasattr(assistant_message, "usage") @@ -1405,13 +1599,14 @@ def _collect_project_sessions(messages: list[TranscriptEntry]) -> list[dict[str, ): session_summaries[uuid_to_session_backup[leaf_uuid]] = message.summary - # Group messages by session (excluding warmup-only sessions) + # Group messages by session (excluding warmup-only sessions, + # coalescing agent sessions into their parent) sessions: dict[str, dict[str, Any]] = {} for message in messages: if hasattr(message, "sessionId") and not isinstance( message, SummaryTranscriptEntry ): - session_id = getattr(message, "sessionId", "") + session_id = get_parent_session_id(getattr(message, "sessionId", "")) if not session_id or session_id in warmup_session_ids: continue @@ -1479,6 +1674,8 @@ def _generate_individual_session_files( cache_was_updated: bool = False, image_export_mode: Optional[str] = None, silent: bool = False, + session_tree: Optional[SessionTree] = None, + shallow: bool = False, ) -> int: """Generate individual files for each session in the specified format. @@ -1489,12 +1686,16 @@ def _generate_individual_session_files( # Pre-compute warmup sessions to exclude them warmup_session_ids = get_warmup_session_ids(messages) - # Find all unique session IDs (excluding warmup sessions) + # Find all unique session IDs (excluding warmup and agent sessions) session_ids: set[str] = set() for message in messages: if hasattr(message, "sessionId"): session_id: str = getattr(message, "sessionId") - if session_id and session_id not in warmup_session_ids: + if ( + session_id + and session_id not in warmup_session_ids + and not is_agent_session(session_id) + ): session_ids.add(session_id) # Get session data from cache for better titles @@ -1514,7 +1715,7 @@ def _generate_individual_session_files( project_title = get_project_display_name(output_dir.name, working_directories) # Get renderer once outside the loop - renderer = get_renderer(format, image_export_mode) + renderer = get_renderer(format, image_export_mode, shallow=shallow) regenerated_count = 0 # Generate HTML file for each session @@ -1577,7 +1778,12 @@ def _generate_individual_session_files( if should_regenerate_session: # Generate session content session_content = renderer.generate_session( - messages, session_id, session_title, cache_manager, output_dir + messages, + session_id, + session_title, + cache_manager, + output_dir, + session_tree=session_tree, ) assert session_content is not None # Write session file @@ -1662,6 +1868,7 @@ def process_projects_hierarchy( image_export_mode: Optional[str] = None, silent: bool = True, page_size: int = 2000, + shallow: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -1818,6 +2025,7 @@ def process_projects_hierarchy( silent=silent, image_export_mode=image_export_mode, page_size=page_size, + shallow=shallow, ) # Track timing @@ -1897,7 +2105,7 @@ def process_projects_hierarchy( print( f"Warning: No cached data available for {project_dir.name}, using fallback processing" ) - messages = load_directory_transcripts( + messages, _tree = load_directory_transcripts( project_dir, cache_manager, from_date, to_date, silent=silent ) # Ensure cache is populated with session data (including working directories) diff --git a/claude_code_log/dag.py b/claude_code_log/dag.py new file mode 100644 index 00000000..cf90aa46 --- /dev/null +++ b/claude_code_log/dag.py @@ -0,0 +1,650 @@ +"""DAG-based message ordering for Claude Code transcripts. + +Replaces timestamp-based ordering with parentUuid → uuid graph traversal. +Works at the TranscriptEntry level (before factory/rendering). + +See dev-docs/dag.md for the full architecture spec. +""" + +import logging +from dataclasses import dataclass, field +from typing import Optional + +from .models import ( + TranscriptEntry, + SummaryTranscriptEntry, + QueueOperationTranscriptEntry, + UserTranscriptEntry, + AssistantTranscriptEntry, +) + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Data Structures +# ============================================================================= + + +@dataclass +class MessageNode: + """A deduplicated message in the DAG.""" + + uuid: str + parent_uuid: Optional[str] + session_id: str + timestamp: str + entry: TranscriptEntry + children_uuids: list[str] = field(default_factory=lambda: []) + + +@dataclass +class SessionDAGLine: + """A session's ordered chain of unique messages.""" + + session_id: str + uuids: list[str] # Ordered by parent→child chain traversal + first_timestamp: str + parent_session_id: Optional[str] = None + 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 +class JunctionPoint: + """A message where other sessions fork or continue.""" + + uuid: str + session_id: str # The session this message belongs to + target_sessions: list[str] = field(default_factory=lambda: []) + + +@dataclass +class SessionTree: + """The complete session hierarchy for a project.""" + + nodes: dict[str, MessageNode] + sessions: dict[str, SessionDAGLine] + roots: list[str] # Root session IDs (no parent session) + junction_points: dict[str, JunctionPoint] + + +# ============================================================================= +# Step 1: Load and Index +# ============================================================================= + + +def build_message_index( + entries: list[TranscriptEntry], +) -> dict[str, MessageNode]: + """Build a deduplicated message index from transcript entries. + + Skips SummaryTranscriptEntry (no uuid/sessionId) and + QueueOperationTranscriptEntry (no uuid). For duplicate uuids, + keeps the entry from the earliest session (by first entry timestamp). + """ + # First pass: determine earliest timestamp per session + session_first_ts: dict[str, str] = {} + for entry in entries: + if isinstance(entry, (SummaryTranscriptEntry, QueueOperationTranscriptEntry)): + continue + sid = entry.sessionId + ts = entry.timestamp + if sid not in session_first_ts or ts < session_first_ts[sid]: + session_first_ts[sid] = ts + + # Second pass: build nodes, deduplicating by uuid (earliest session wins) + nodes: dict[str, MessageNode] = {} + for entry in entries: + if isinstance(entry, (SummaryTranscriptEntry, QueueOperationTranscriptEntry)): + continue + uuid = entry.uuid + sid = entry.sessionId + if uuid in nodes: + existing = nodes[uuid] + existing_session_ts = session_first_ts.get(existing.session_id, "") + new_session_ts = session_first_ts.get(sid, "") + if new_session_ts < existing_session_ts: + # Replace with entry from earlier session + nodes[uuid] = MessageNode( + uuid=uuid, + parent_uuid=entry.parentUuid, + session_id=sid, + timestamp=entry.timestamp, + entry=entry, + ) + else: + nodes[uuid] = MessageNode( + uuid=uuid, + parent_uuid=entry.parentUuid, + session_id=sid, + timestamp=entry.timestamp, + entry=entry, + ) + + return nodes + + +# ============================================================================= +# Step 2: Build DAG (parent→children links) +# ============================================================================= + + +def build_dag( + nodes: dict[str, MessageNode], + sidechain_uuids: set[str] | None = None, +) -> None: + """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 unloaded sidechain + data (e.g. aprompt_suggestion agents) are silently promoted to root + without warning. + """ + _sidechain_uuids = sidechain_uuids or set() + + # Clear existing children + for node in nodes.values(): + node.children_uuids = [] + + # Build parent→children links + for node in nodes.values(): + if node.parent_uuid is not None: + parent = nodes.get(node.parent_uuid) + if parent is not None: + parent.children_uuids.append(node.uuid) + else: + if node.parent_uuid not in _sidechain_uuids: + logger.warning( + "Orphan node %s: parentUuid %s not found in loaded" + " data (promoting to root)", + node.uuid, + node.parent_uuid, + ) + # Clear the dangling parent so this node becomes a root + # and can participate in DAG walks + node.parent_uuid = None + + # Validate: no cycles (walk parent chain for each node) + for node in nodes.values(): + visited: set[str] = set() + current: Optional[str] = node.uuid + while current is not None: + if current in visited: + logger.warning("Cycle detected in parent chain at uuid %s", current) + nodes[current].parent_uuid = None + break + visited.add(current) + parent = nodes.get(current) + if parent is None: + break + current = parent.parent_uuid + + +# ============================================================================= +# Step 3: Extract Session DAG-lines +# ============================================================================= + + +def _collect_descendants( + uuid: str, + session_uuids: set[str], + nodes: dict[str, MessageNode], + result: set[str], +) -> None: + """Recursively collect a node and all its same-session descendants.""" + if uuid in result: + return + result.add(uuid) + node = nodes.get(uuid) + if node is None: + return + for child in node.children_uuids: + if child in session_uuids: + _collect_descendants(child, session_uuids, nodes, result) + + +def _is_subtree_dead_end( + uuid: str, + session_uuids: set[str], + nodes: dict[str, MessageNode], + max_depth: int = 20, +) -> bool: + """Check if a node's subtree eventually terminates (no continuation). + + A subtree is a dead end if every leaf within the session has no + same-session children. Walks depth-first with a depth limit to + avoid runaway traversals. + """ + stack: list[tuple[str, int]] = [(uuid, 0)] + while stack: + current, depth = stack.pop() + children = [c for c in nodes[current].children_uuids if c in session_uuids] + if not children: + continue # Leaf — dead end, keep checking siblings + if depth >= max_depth: + return False # Too deep to tell — assume not dead end + for c in children: + stack.append((c, depth + 1)) + return True + + +def _stitch_tool_results( + children: list[str], + session_uuids: set[str], + nodes: dict[str, MessageNode], +) -> Optional[list[str]]: + """Detect and stitch tool-result side-branches into a linear chain. + + When the assistant makes multiple tool calls in one turn, the JSONL + records both the next tool_use and the tool_result as children of the + current tool_use entry, creating a false fork. Two variants: + + Variant 1 — User child is immediate dead end: + A(tool_use) → U(tool_result) [dead-end side-branch] + → A(next tool_use) [main chain continues] + + Variant 2 — User child continues, Assistant subtree dead-ends: + A(tool_use) → U(tool_result) → A(response) → ... [main chain] + → A(tool_use) → ... → dead ends [progress artifact] + + Returns a stitched ordering placing dead-end children first, then + the single continuation child. Returns None if the pattern doesn't + match. + """ + # Separate into user (tool_result) and assistant (continuation) children + user_children = [ + c for c in children if isinstance(nodes[c].entry, UserTranscriptEntry) + ] + assistant_children = [ + c for c in children if isinstance(nodes[c].entry, AssistantTranscriptEntry) + ] + + if not user_children or not assistant_children: + return None # Not the tool_result pattern + + # Check variant 1: all user children are immediate dead ends + user_all_dead = all( + not any(c in session_uuids for c in nodes[uc].children_uuids) + for uc in user_children + ) + + if user_all_dead: + # Variant 1: user dead ends + single assistant continuation + if len(assistant_children) != 1: + return None + user_children.sort(key=lambda c: nodes[c].timestamp) + return user_children + assistant_children + + # Check variant 2: assistant subtrees are dead ends, + # exactly one user child continues + user_with_cont = [ + uc + for uc in user_children + if any(c in session_uuids for c in nodes[uc].children_uuids) + ] + if len(user_with_cont) != 1: + return None # Ambiguous — multiple user continuations + + # Verify all assistant children's subtrees are dead ends + for ac in assistant_children: + if not _is_subtree_dead_end(ac, session_uuids, nodes): + return None + + # Verify remaining user children (without continuation) are dead ends + user_dead = [uc for uc in user_children if uc not in user_with_cont] + for uc in user_dead: + if not _is_subtree_dead_end(uc, session_uuids, nodes): + return None + + # Stitch: dead-end children first, then the continuing user child + dead_ends = user_dead + assistant_children + dead_ends.sort(key=lambda c: nodes[c].timestamp) + return dead_ends + user_with_cont + + +def _walk_session_with_forks( + root: MessageNode, + session_id: str, + session_uuids: set[str], + nodes: dict[str, MessageNode], +) -> tuple[list[SessionDAGLine], set[str]]: + """Walk a session's DAG from root, splitting into separate DAG-lines at fork points. + + Uses a queue-based approach to handle nested forks: + 1. Start with (root_uuid, session_id, None) in the queue + 2. Walk chain following single same-session children + 3. On fork (multiple same-session children): stop chain at fork point, + push each child as a new branch + 4. Update MessageNode.session_id for branch nodes + + Returns: + Tuple of (DAG-line list, set of UUIDs intentionally skipped as + compaction replays). + """ + # Queue entries: (start_uuid, dag_line_id, parent_dag_line_id) + queue: list[tuple[str, str, Optional[str]]] = [(root.uuid, session_id, None)] + result: list[SessionDAGLine] = [] + skipped: set[str] = set() # Compaction replay UUIDs + + while queue: + start_uuid, line_id, parent_line_id = queue.pop(0) + chain: list[str] = [] + current: Optional[MessageNode] = nodes[start_uuid] + is_branch = line_id != session_id + + while current is not None: + chain.append(current.uuid) + # Update session_id for branch nodes (needed for build_session_tree) + if is_branch: + current.session_id = line_id + + # Find children in the original session + same_session_children = [ + c for c in current.children_uuids if c in session_uuids + ] + if len(same_session_children) == 0: + current = None + elif len(same_session_children) == 1: + current = nodes[same_session_children[0]] + else: + # Multiple same-session children. Distinguish real forks + # from artifacts (see dev-docs/dag.md caveats). + same_session_children.sort(key=lambda c: nodes[c].timestamp) + + stitched = _stitch_tool_results( + same_session_children, session_uuids, nodes + ) + if stitched is not None: + # Tool-result side-branches were stitched into the + # chain. The last element is the continuation; all + # others are dead-end nodes whose subtree descendants + # must be skipped. + for su in stitched[:-1]: + if is_branch: + nodes[su].session_id = line_id + _collect_descendants(su, session_uuids, nodes, skipped) + chain.extend(stitched[:-1]) + current = nodes[stitched[-1]] + else: + unique_timestamps = { + nodes[c].timestamp for c in same_session_children + } + if len(unique_timestamps) == 1: + # Same timestamp = compaction replay: follow only + # the first child (original chain), skip replays + # and all their descendants. + current = nodes[same_session_children[0]] + for sc in same_session_children[1:]: + _collect_descendants(sc, session_uuids, nodes, skipped) + else: + # Different timestamps = real fork (rewind). + # Stop chain here, push each child as a branch. + for child_uuid in same_session_children: + branch_id = f"{line_id}@{child_uuid[:12]}" + queue.append((child_uuid, branch_id, line_id)) + current = None + + if chain: + first_ts = nodes[chain[0]].timestamp + dag_line = SessionDAGLine( + session_id=line_id, + uuids=chain, + first_timestamp=first_ts, + is_branch=is_branch, + original_session_id=session_id if is_branch else None, + ) + # Set parent/attachment for branches + if is_branch and parent_line_id is not None: + parent_uuid = nodes[chain[0]].parent_uuid + dag_line.parent_session_id = parent_line_id + dag_line.attachment_uuid = parent_uuid + result.append(dag_line) + + return result, skipped + + +def extract_session_dag_lines( + nodes: dict[str, MessageNode], +) -> dict[str, SessionDAGLine]: + """Extract per-session ordered chains from the DAG. + + For each session, finds the root node (parent_uuid is null or points + to a different session), then walks forward via children_uuids filtering + to same-session children. + + Within-session forks (multiple same-session children) produce additional + DAG-lines with synthetic IDs (e.g., "s1@child_uuid12"). + Falls back to timestamp sort only when no root is found. + """ + # Group nodes by session + session_nodes: dict[str, list[MessageNode]] = {} + for node in nodes.values(): + session_nodes.setdefault(node.session_id, []).append(node) + + sessions: dict[str, SessionDAGLine] = {} + for session_id, snodes in session_nodes.items(): + session_uuids = {n.uuid for n in snodes} + + # Find root(s): nodes whose parent_uuid is null or outside this session + roots = [ + n + for n in snodes + if n.parent_uuid is None or n.parent_uuid not in session_uuids + ] + + if not roots: + logger.warning( + "Session %s: no root found, falling back to timestamp sort", + session_id, + ) + sorted_nodes = sorted(snodes, key=lambda n: n.timestamp) + sessions[session_id] = SessionDAGLine( + session_id=session_id, + uuids=[n.uuid for n in sorted_nodes], + first_timestamp=sorted_nodes[0].timestamp, + ) + continue + + # Sort roots by timestamp (earliest first = primary root) + roots.sort(key=lambda n: n.timestamp) + if len(roots) > 1: + logger.warning( + "Session %s: %d roots found, walking all from earliest (%s)", + session_id, + len(roots), + roots[0].uuid, + ) + + # Walk from ALL roots to maximize coverage (orphan-promoted roots + # create disconnected subtrees that must each be walked) + dag_lines: list[SessionDAGLine] = [] + walked_uuids: set[str] = set() + skipped_uuids: set[str] = set() + for root in roots: + if root.uuid in walked_uuids: + continue + root_lines, root_skipped = _walk_session_with_forks( + root, session_id, session_uuids, nodes + ) + for dl in root_lines: + walked_uuids.update(dl.uuids) + skipped_uuids.update(root_skipped) + dag_lines.extend(root_lines) + + # Check coverage: walked + intentionally skipped (compaction replays) + covered = len(walked_uuids | skipped_uuids) + if covered < len(snodes): + logger.warning( + "Session %s: DAG walk covers %d of %d nodes, " + "falling back to timestamp sort", + session_id, + covered, + len(snodes), + ) + sorted_nodes = sorted(snodes, key=lambda n: n.timestamp) + sessions[session_id] = SessionDAGLine( + session_id=session_id, + uuids=[n.uuid for n in sorted_nodes], + first_timestamp=sorted_nodes[0].timestamp, + ) + else: + # Merge non-branch DAG-lines that share the same session_id + # (happens when multiple roots exist due to orphan promotion) + trunk_lines = [dl for dl in dag_lines if dl.session_id == session_id] + branch_lines = [dl for dl in dag_lines if dl.session_id != session_id] + if trunk_lines: + # Merge all trunk lines into one, ordered by first_timestamp + trunk_lines.sort(key=lambda dl: dl.first_timestamp) + merged_uuids: list[str] = [] + for tl in trunk_lines: + merged_uuids.extend(tl.uuids) + sessions[session_id] = SessionDAGLine( + session_id=session_id, + uuids=merged_uuids, + first_timestamp=trunk_lines[0].first_timestamp, + ) + for dag_line in branch_lines: + sessions[dag_line.session_id] = dag_line + + return sessions + + +# ============================================================================= +# Step 4: Build Session Tree +# ============================================================================= + + +def build_session_tree( + nodes: dict[str, MessageNode], + sessions: dict[str, SessionDAGLine], +) -> SessionTree: + """Build the session hierarchy and identify junction points. + + For each session's DAG-line, the first message's parent_uuid determines + the parent session: + - null → root session + - points to node in different session → child of that session + """ + roots: list[str] = [] + junction_points: dict[str, JunctionPoint] = {} + + for session_id, dag_line in sessions.items(): + if not dag_line.uuids: + roots.append(session_id) + continue + + first_uuid = dag_line.uuids[0] + first_node = nodes[first_uuid] + parent_uuid = first_node.parent_uuid + + if parent_uuid is None or parent_uuid not in nodes: + # Root session (or orphan parent) + roots.append(session_id) + dag_line.parent_session_id = None + dag_line.attachment_uuid = None + else: + parent_node = nodes[parent_uuid] + if parent_node.session_id == session_id: + # Parent is in same session - this is a root + roots.append(session_id) + dag_line.parent_session_id = None + dag_line.attachment_uuid = None + else: + # Child session: attaches to parent session at parent_uuid + dag_line.parent_session_id = parent_node.session_id + dag_line.attachment_uuid = parent_uuid + + # Record junction point + if parent_uuid not in junction_points: + junction_points[parent_uuid] = JunctionPoint( + uuid=parent_uuid, + session_id=parent_node.session_id, + ) + junction_points[parent_uuid].target_sessions.append(session_id) + + # Order roots chronologically + roots.sort(key=lambda sid: sessions[sid].first_timestamp) + + # Order junction point target_sessions chronologically + for jp in junction_points.values(): + jp.target_sessions.sort(key=lambda sid: sessions[sid].first_timestamp) + + return SessionTree( + nodes=nodes, + sessions=sessions, + roots=roots, + junction_points=junction_points, + ) + + +# ============================================================================= +# Step 5: Ordered Traversal +# ============================================================================= + + +def traverse_session_tree(tree: SessionTree) -> list[TranscriptEntry]: + """Depth-first traversal of session tree producing rendering order. + + For each session: yields its DAG-line's entries in chain order. + Children are visited in chronological order (by first_timestamp). + """ + result: list[TranscriptEntry] = [] + visited_sessions: set[str] = set() + + def _visit_session(session_id: str) -> None: + if session_id in visited_sessions: + return + visited_sessions.add(session_id) + + dag_line = tree.sessions.get(session_id) + if dag_line is None: + return + + # Build map: attachment_uuid → [child session IDs] for this session + children_at: dict[str, list[str]] = {} + for sid, sline in tree.sessions.items(): + if sline.parent_session_id == session_id and sline.attachment_uuid: + children_at.setdefault(sline.attachment_uuid, []).append(sid) + for child_sids in children_at.values(): + child_sids.sort(key=lambda sid: tree.sessions[sid].first_timestamp) + + # Emit entries, visiting child sessions at junction points + for uuid in dag_line.uuids: + node = tree.nodes[uuid] + result.append(node.entry) + # After emitting this message, visit any child sessions + # that attach here (in chronological order) + if uuid in children_at: + for child_sid in children_at[uuid]: + _visit_session(child_sid) + + # Visit root sessions in chronological order + for root_sid in tree.roots: + _visit_session(root_sid) + + return result + + +# ============================================================================= +# Convenience: Full Pipeline +# ============================================================================= + + +def build_dag_from_entries( + entries: list[TranscriptEntry], + sidechain_uuids: set[str] | None = None, +) -> SessionTree: + """Build a complete SessionTree from raw transcript entries. + + Convenience function that runs Steps 1-4 in sequence. + ``sidechain_uuids`` suppresses orphan warnings for parents known + 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) + sessions = extract_session_dag_lines(nodes) + return build_session_tree(nodes, sessions) diff --git a/claude_code_log/factories/transcript_factory.py b/claude_code_log/factories/transcript_factory.py index ae3a22d7..f7a8d539 100644 --- a/claude_code_log/factories/transcript_factory.py +++ b/claude_code_log/factories/transcript_factory.py @@ -24,6 +24,7 @@ # Transcript entry types AssistantTranscriptEntry, MessageType, + PassthroughTranscriptEntry, QueueOperationTranscriptEntry, SummaryTranscriptEntry, SystemTranscriptEntry, @@ -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}") diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 8d22a374..2a48bdd5 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -5,6 +5,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Tuple, cast +if TYPE_CHECKING: + from ..dag import SessionTree + from ..cache import get_library_version from ..models import ( AssistantTextMessage, @@ -516,6 +519,7 @@ def generate( title: Optional[str] = None, combined_transcript_link: Optional[str] = None, output_dir: Optional[Path] = None, + session_tree: Optional["SessionTree"] = None, page_info: Optional[dict[str, Any]] = None, page_stats: Optional[dict[str, Any]] = None, ) -> str: @@ -528,6 +532,7 @@ def generate( output_dir: Optional output directory for referenced images. page_info: Optional pagination info (page_number, prev_link, next_link). page_stats: Optional page statistics (message_count, date_range, token_summary). + session_tree: Optional pre-built SessionTree (avoids rebuilding DAG). """ import time @@ -541,7 +546,9 @@ def generate( title = "Claude Transcript" # Get root messages (tree) and session navigation from format-neutral renderer - root_messages, session_nav, _ = generate_template_messages(messages) + root_messages, session_nav, _ = generate_template_messages( + messages, session_tree=session_tree, shallow=self.shallow + ) # Flatten tree via pre-order traversal, formatting content along the way with log_timing("Content formatting (pre-order)", t_start): @@ -579,6 +586,7 @@ def generate_session( title: Optional[str] = None, cache_manager: Optional["CacheManager"] = None, output_dir: Optional[Path] = None, + session_tree: Optional["SessionTree"] = None, ) -> str: """Generate HTML for a single session.""" # Filter messages for this session (SummaryTranscriptEntry.sessionId is always None) @@ -599,6 +607,7 @@ def generate_session( title or f"Session {session_id[:8]}", combined_transcript_link=combined_link, output_dir=output_dir, + session_tree=session_tree, ) def generate_projects_index( @@ -645,6 +654,7 @@ def generate_html( combined_transcript_link: Optional[str] = None, page_info: Optional[dict[str, Any]] = None, page_stats: Optional[dict[str, Any]] = None, + session_tree: Optional["SessionTree"] = None, ) -> str: """Generate HTML from transcript messages using Jinja2 templates. @@ -656,6 +666,7 @@ def generate_html( combined_transcript_link: Optional link to combined transcript. page_info: Optional pagination info (page_number, prev_link, next_link). page_stats: Optional page statistics (message_count, date_range, token_summary). + session_tree: Optional pre-built SessionTree (avoids rebuilding DAG). """ return HtmlRenderer().generate( messages, @@ -663,6 +674,7 @@ def generate_html( combined_transcript_link, page_info=page_info, page_stats=page_stats, + session_tree=session_tree, ) diff --git a/claude_code_log/html/system_formatters.py b/claude_code_log/html/system_formatters.py index 08807033..fd3a5468 100644 --- a/claude_code_log/html/system_formatters.py +++ b/claude_code_log/html/system_formatters.py @@ -88,6 +88,49 @@ def format_session_header_content(content: SessionHeaderMessage) -> str: HTML for the session header display """ escaped_title = html.escape(content.title) + if content.is_branch and content.parent_message_index is not None: + # 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' (in Session {esc_sid})' + ) + fork_backref = "" + if content.parent_session_summary: + escaped_fork = html.escape(content.parent_session_summary) + fork_backref = ( + f'
' + f'from ' + f"⑂ Fork point • {escaped_fork}
" + ) + else: + fork_backref = ( + f'
' + f'from ' + f"⑂ Fork point
" + ) + 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) + if content.parent_message_index is not None: + link = ( + f'↳ continues from ' + f"{escaped_parent}" + ) + else: + link = ( + f'↳ continues from ' + f"{escaped_parent}" + ) + return f"{link}{escaped_title}" return escaped_title diff --git a/claude_code_log/html/templates/components/global_styles.css b/claude_code_log/html/templates/components/global_styles.css index eed4503c..c9bcbb75 100644 --- a/claude_code_log/html/templates/components/global_styles.css +++ b/claude_code_log/html/templates/components/global_styles.css @@ -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; @@ -230,6 +234,21 @@ pre { bottom: 200px; } +.debug-toggle.floating-btn { + bottom: 260px; + border-radius: 6px; + width: 38px; + height: 28px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; +} + +.debug-toggle.floating-btn.active { + background-color: #d4e8f7; + color: #333; +} + @media (max-width: 1280px) { .header > span:first-child { flex: auto; diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index abdd30c4..966bf3b9 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -565,6 +565,26 @@ font-size: 1.2em; } +/* 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; color: var(--text-muted); @@ -892,6 +912,20 @@ details summary { .ansi-bg-cyan { background-color: #11a8cd; } .ansi-bg-white { background-color: #e5e5e5; } +/* Debug UUID info */ +.debug-info { + display: none; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.7em; + color: #999; + padding: 2px 0; + letter-spacing: 0.02em; +} + +.show-debug-info .debug-info { + display: block; +} + /* Bright background colors */ .ansi-bg-bright-black { background-color: #666666; } .ansi-bg-bright-red { background-color: #f14c4c; } diff --git a/claude_code_log/html/templates/components/session_nav.html b/claude_code_log/html/templates/components/session_nav.html index 143d4a89..4316b5bd 100644 --- a/claude_code_log/html/templates/components/session_nav.html +++ b/claude_code_log/html/templates/components/session_nav.html @@ -12,26 +12,50 @@

Session Navigation

{% for session in sessions %} - - - - {% if session.first_user_message %} -
-                {{- session.first_user_message|e -}}
-            
+ {% if session.is_fork_point is defined and session.is_fork_point %} +
+ + ⑂ {{ session.first_user_message }} + +
+ {% elif session.is_branch is defined and session.is_branch %} +
+ + ↳ {{ session.first_user_message }} + +
+ {% else %} +
0 %}style='margin-left: {{ session.depth * 24 }}px'{% endif %}> + {% if session.parent_session_id and mode == "toc" and session.parent_message_index is defined and session.parent_message_index is not none %} + ↳ continues from {{ session.parent_session_id[:8] }} + {% elif session.parent_session_id %} + ↳ continues from {{ session.parent_session_id[:8] }} {% endif %} - + + + + {% if session.first_user_message %} +
+                    {{- session.first_user_message|e -}}
+                
+ {% endif %} +
+
+ {% endif %} {% endfor %}
diff --git a/claude_code_log/html/templates/components/session_nav_styles.css b/claude_code_log/html/templates/components/session_nav_styles.css index a69c4d35..2216b535 100644 --- a/claude_code_log/html/templates/components/session_nav_styles.css +++ b/claude_code_log/html/templates/components/session_nav_styles.css @@ -24,6 +24,7 @@ } .session-link { + display: block; padding: 8px 12px; background-color: var(--white-dimmed); border: 1px solid #dee2e6; @@ -48,6 +49,25 @@ margin-top: 2px; } +/* Child session hierarchy */ +.session-nav-item.session-child { + border-left: 3px solid var(--accent-color, #6c757d); + padding-left: 8px; +} + +.session-backlink { + font-size: 0.75em; + color: #6c757d; + display: block; + margin-bottom: 2px; + text-decoration: none; +} + +a.session-backlink:hover { + color: var(--text-secondary); + text-decoration: underline; +} + /* Project-specific session navigation */ .project-sessions { margin-top: 15px; @@ -103,4 +123,84 @@ .session-preview { font-size: 0.75em; line-height: 1.3; +} + +/* 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; + flex-direction: column; + gap: 2px; + padding-left: 12px; +} + +.fork-point-branch { + display: block; + text-decoration: none; + color: var(--text-muted, #6c757d); + padding: 2px 0; + transition: color 0.2s; +} + +.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 { + 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 */ +.session-fork-point, +.session-branch { + padding-left: 8px; +} + +.session-fork-point { + border-left: 2px dashed #888; +} + +.session-branch { + border-left: 2px dashed #666; +} + +.fork-link, +.branch-link { + display: block; + text-decoration: none; + color: #6c757d; + padding: 2px 0; + font-size: 0.85em; +} + +.fork-link:hover, +.branch-link:hover { + color: var(--text-secondary); } \ No newline at end of file diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 5863b50c..9a1564cf 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -98,9 +98,9 @@

🔍 Search & Filter

{% for message, message_title, html_content, formatted_timestamp in messages %} {% if is_session_header(message) %} -
-
-
Session: {{ html_content }}
+ {% if not message.is_branch_header %}
{% endif %} +
+
{% if message.is_branch_header %}↳ {% else %}Session: {% endif %}{{ html_content|safe }}
{% if message.has_children %}
{% if message.immediate_children_count == message.total_descendants_count %} @@ -141,6 +141,7 @@

🔍 Search & Filter

{% endif %}
+ {% if message.meta %}
{{ message.meta.uuid[:12] }}{% if message.meta.parent_uuid %} → {{ message.meta.parent_uuid[:12] }}{% endif %}
{% endif %}
{{ html_content | safe }}
{% if message.has_children %}
@@ -165,11 +166,23 @@

🔍 Search & Filter

{% endif %}
{% endif %} + {% if message.junction_forward_links %} + {# Fork point element — rendered OUTSIDE the message box as a structural navigation element #} +
+
⑂ Fork point{% if message.fork_point_preview %} • {{ message.fork_point_preview }}{% endif %}
+
+ {% for branch_sid, branch_idx, branch_preview in message.junction_forward_links %} + ↳ Branch{% if branch_preview %} • {{ branch_preview }}{% endif %} + {% endfor %} +
+
+ {% endif %} {% endfor %} + 🔝