Skip to content

Keep fork points visible when their host message is detail-filtered#240

Merged
cboos merged 1 commit into
mainfrom
dev/fork-point-survives-ghost
Jun 25, 2026
Merged

Keep fork points visible when their host message is detail-filtered#240
cboos merged 1 commit into
mainfrom
dev/fork-point-survives-ghost

Conversation

@cboos

@cboos cboos commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

A follow-up to #233. A fork point is navigational structure, not message content — the branches it connects (session headers) always survive --detail filtering, so the fork point should too. Previously the fork-point box was a rider on its host message, and when _ghost_template_by_detail ghosted that host to None at reduced detail, the box vanished — leaving branches with no visible fork point above them and (per #233's belt-and-suspenders) plain-text back-links.

Change

Instead of ghosting a slot that carries junction_forward_links, keep it as a fork_only landmark: a non-None slot with its .content intact, body suppressed at render time only. A new TemplateMessage.fork_only flag is set in the ghost pass; a template branch in render_message (checked first, before is_session_header / should_render) emits just the ⟂ fork-point box — message-node > children > fork-point, with the anchor id on the box — no message card.

Because the slot stays non-None, the branch back-links and nav anchor reactivate for free: _repair_stale_anchor_refs only nulls refs to None slots. Linking runs before ghosting, so the box data already exists at ghost time. Non-fork slots still ghost to None, so #233's genuine-ghost sanitize path is preserved for non-fork anchors and degenerate (<2-branch) forks.

Net: fork points are now visible at every detail level, full parity with the branches they connect. Verified at full/high/low/minimal/user-only — FULL shows card+box (unchanged #233); reduced suppresses the body, keeps the box + active back-links, zero dead anchors.

Knob: this implements full parity including user-only. If a user-only floor is preferred (fork points drop in the prompts-only view), it's a one-line guard at the fork_only assignment.

Latent-coupling guard

The no-dead-anchor guarantee rests on an unstated cross-class invariant: branch session headers are always visible (SessionHeaderMessage declares no detail_visibility). If a branch could be ghosted, a 2-branch fork could drop below 2 survivors → box suppressed → the kept slot renders with no id → a sibling back-link dangles. A comment at the fork_only assignment documents this, and test_branch_headers_always_visible_keeps_fork_anchored pins it directly — so a future SessionHeaderMessage.detail_visibility trips a test instead of silently activating a dead anchor.

Testing

Three existing tests pinned the superseded #233 behavior ("fork ghosted → omit/sanitize backlink"); each is updated to the new "fork survives" behavior while preserving the deeper invariant (no dead #msg-d-N, D11 trunk-before-branch):

  • test_ghost_repair: fork anchor kept as a fork_only landmark, back-link live (the "no dead anchor" invariant stays independently pinned by TestHtmlAnchorIntegrity).
  • test_detail_levels: fork_only landmarks are exempt from "only recaps survive among system messages" (they render only the box, no body leaks).
  • test_dag_integration (D11): a filtered fork anchor's back-link is now active (resolves to the kept landmark); the trunk-before-branch invariant is unaffected.

New TestForkSurvivesDetailFiltering: box survives / body suppressed / back-links reactivate / no dead anchors, across all reduced levels; plus the always-visible-branch invariant pin.

just ci green (unit + TUI + browser + snapshots, ruff, pyright, ty); no snapshot churn.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Fork points now remain visible as anchored landmarks even when their full message content is hidden by detail settings.
    • Branch back-links continue to point to the fork landmark, helping preserve navigation in filtered views.
  • Bug Fixes

    • Prevented broken or dead anchors when rendering transcripts at reduced detail.
    • Improved handling of forked threads so visible links stay stable across different display levels.
  • Tests

    • Expanded coverage for fork rendering and detail filtering to verify anchors, back-links, and visibility stay consistent.

…233 follow-up)

A fork point is navigational structure, not message content: the branches
it connects (session headers) always survive --detail filtering, so the
fork point must too. Previously the fork-point box was a rider on its host
message, and when _ghost_template_by_detail ghosted that host to None at
reduced detail, the box vanished — leaving branches with no visible fork
point above them and (per #233's belt-and-suspenders) plain-text back-links.

Now, instead of ghosting a slot that carries junction_forward_links, keep
it as a "fork_only" landmark: non-None slot, message body suppressed, the
template renders only the ⟂ fork-point box at the slot's original position.
Because the slot stays non-None, the branch back-links and nav anchor
reactivate for free (_repair_stale_anchor_refs only nulls refs to None
slots). Net: fork points are now visible at every detail level, full parity
with the branches they connect. Verified at full/high/low/minimal/user-only.

- TemplateMessage.fork_only flag; set in _ghost_template_by_detail.
- transcript.html: a fork_only render branch emitting just the box, with
  the anchor id on the box so back-links / nav resolve to it.

Test updates (the behavior this supersedes was pinned by three tests):
- test_ghost_repair: the fork anchor is now KEPT as a fork_only landmark at
  USER_ONLY (not ghosted); back-link stays live. Deeper "no dead anchor"
  invariant unchanged (also pinned by TestHtmlAnchorIntegrity).
- test_detail_levels: a fork_only landmark is exempt from the "only recaps
  survive among system messages" check — it renders only the box, no body.
- test_dag_integration: a filtered fork anchor's branch back-link is now
  active (resolves to the kept landmark), not omitted.
- New TestForkSurvivesDetailFiltering: box survives / body suppressed /
  back-links reactivate / no dead anchors, across all reduced levels.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The renderer now keeps fork-point messages as fork_only landmarks when detail filtering hides their bodies. The HTML template renders those landmarks and their children without the normal session-header path. Tests update backlink, anchor, and recap assertions for the preserved fork points.

Changes

Fork-only landmarks at reduced detail

Layer / File(s) Summary
Fork-only detail filtering
claude_code_log/renderer.py, claude_code_log/html/templates/transcript.html
TemplateMessage gains fork_only, _ghost_template_by_detail keeps fork-point slots visible during filtering, and render_message emits the fork-point landmark while still rendering children.
Anchor repair regressions
test/test_ghost_repair.py, test/test_dag_integration.py, test/test_detail_levels.py
Regression tests now require branch headers to target the live fork-only anchor at USER_ONLY and MINIMAL detail, and recap checks skip fork-only landmarks.
Fork fixture detail rendering
test/test_fork_invisible_node.py
The fork fixture now renders through detail-aware helpers, and reduced-detail assertions check the fork landmark, backlinks, and session-header anchors remain live.

Sequence Diagram(s)

sequenceDiagram
  participant HtmlRenderer
  participant _ghost_template_by_detail
  participant TemplateMessage
  participant render_message

  HtmlRenderer->>_ghost_template_by_detail: filter hidden slots by detail
  _ghost_template_by_detail->>TemplateMessage: set fork_only=True on fork-point slots
  render_message->>TemplateMessage: read fork_only during rendering
  render_message->>render_message: render message.children
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • daaain/claude-code-log#194: Extends the same ghosting and anchor-repair path that this PR adds fork_only landmarks to.
  • daaain/claude-code-log#193: Modifies ghost-aware ctx.messages and anchor dropping, which is the filtering path this PR adjusts for fork points.
  • daaain/claude-code-log#236: Updates fork/invisible-node handling and related tests around preserving resolvable fork-point backlinks.

Poem

I twitched my nose and took a peek,
A fork-point glowed, so bright and meek.
No dead links lurk where carrots grow, 🥕
The branch-back path is clear to show.
Hop, hop — the anchors all know where to go!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: preserving fork points when their host message is filtered by detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/fork-point-survives-ghost

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/test_detail_levels.py (1)

1528-1537: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

continue also skips children recursion for fork_only landmarks.

The fork_only landmark's own body is suppressed (so skipping its system check is correct), but its children are rendered by the template ({% for child in message.children %} in transcript.html). Early-continue here also bypasses the recursive descent into those children, so a non-recap system message nested under a fork landmark would render in the HTML yet escape this assertion — a false negative in the invariant guard.

Skip only the system-type check, not the subtree:

♻️ Keep recursing into fork-landmark children
     for msg in messages:
-        if getattr(msg, "fork_only", False):
-            continue
-        if msg.type == "system":
+        if not getattr(msg, "fork_only", False) and msg.type == "system":
             cls = type(msg.content).__name__
             assert cls == "AwaySummaryMessage", (
                 f"{label}: non-recap system message survived: {cls}"
             )
         if hasattr(msg, "children"):
             _assert_system_messages_are_recaps(msg.children, label)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_detail_levels.py` around lines 1528 - 1537, The recursive invariant
check in _assert_system_messages_are_recaps is skipping entire fork_only
messages, which also prevents descending into their children and can miss nested
non-recap system messages. Adjust the loop so fork_only only bypasses the
msg.type == "system" assertion, but still recurses into msg.children when
present. Keep the existing recursion and uses of getattr(msg, "fork_only",
False), msg.type, and hasattr(msg, "children") intact while changing the control
flow to avoid early continue.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/test_detail_levels.py`:
- Around line 1528-1537: The recursive invariant check in
_assert_system_messages_are_recaps is skipping entire fork_only messages, which
also prevents descending into their children and can miss nested non-recap
system messages. Adjust the loop so fork_only only bypasses the msg.type ==
"system" assertion, but still recurses into msg.children when present. Keep the
existing recursion and uses of getattr(msg, "fork_only", False), msg.type, and
hasattr(msg, "children") intact while changing the control flow to avoid early
continue.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e857875-6f74-4dc2-94a9-ef327e19b304

📥 Commits

Reviewing files that changed from the base of the PR and between c4fb92f and aea992d.

📒 Files selected for processing (6)
  • claude_code_log/html/templates/transcript.html
  • claude_code_log/renderer.py
  • test/test_dag_integration.py
  • test/test_detail_levels.py
  • test/test_fork_invisible_node.py
  • test/test_ghost_repair.py

@cboos cboos merged commit d7a8174 into main Jun 25, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant