Skip to content
Open
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
24 changes: 23 additions & 1 deletion claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@
report_timing_statistics,
set_timing_var,
)
from ..utils import format_timestamp, split_websearch_queries
from ..utils import (
collect_trunk_session_ids,
format_timestamp,
get_warmup_session_ids,
resume_command_for_session,
split_websearch_queries,
)
from .system_formatters import (
format_away_summary_content,
format_hook_attachment_content,
Expand Down Expand Up @@ -1611,6 +1617,7 @@ def generate(
session_tree=session_tree,
page_info=page_info,
page_stats=page_stats,
repo_cwd=repo_cwd,
)

def _generate_inner(
Expand All @@ -1622,6 +1629,7 @@ def _generate_inner(
session_tree: Optional["SessionTree"] = None,
page_info: Optional[dict[str, Any]] = None,
page_stats: Optional[dict[str, Any]] = None,
repo_cwd: Optional[str] = None,
) -> str:
"""Body of ``generate`` running inside the SHA-resolver context."""
import time
Expand Down Expand Up @@ -1664,6 +1672,19 @@ def _generate_inner(
with log_timing("Content formatting (pre-order)", t_start):
render_roots = self._annotate_tree_for_render(root_messages)

# Resume button: only pages holding a single trunk session get
# one — `claude -r <session-id>` is unambiguous there. Combined
# pages spanning several sessions don't (which session would
# resume?).
resume_command = None
trunk_sids = collect_trunk_session_ids(
messages, get_warmup_session_ids(messages)
)
if len(trunk_sids) == 1:
resume_command = resume_command_for_session(
next(iter(trunk_sids)), repo_cwd
)

# Render template
with log_timing("Template environment setup", t_start):
env = get_template_environment()
Expand All @@ -1684,6 +1705,7 @@ def _generate_inner(
is_session_header=is_session_header,
page_info=page_info,
page_stats=page_stats,
resume_command=resume_command,
)
)

Expand Down
36 changes: 36 additions & 0 deletions claude_code_log/html/templates/components/global_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,42 @@ body.show-raw-user .user-content:not([data-user-view="md"]) .user-raw {
color: #333;
}

/* Resume-session button (single-session pages only): copies the
* `cd … && claude -r <session-id>` command to the clipboard. */
.resume-session.floating-btn {
bottom: 340px;
border-radius: 6px;
width: auto;
height: 28px;
padding: 0 10px;
font-size: 0.65em;
font-family: 'SFMono-Regular', Consolas, monospace;
font-weight: 600;
white-space: nowrap;
Comment on lines +367 to +376

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the Stylelint font-family violation.

Line 374 quotes SFMono-Regular, but the configured Stylelint rule rejects these quotes. Remove them so linting passes.

Proposed fix
-    font-family: 'SFMono-Regular', Consolas, monospace;
+    font-family: SFMono-Regular, Consolas, monospace;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.resume-session.floating-btn {
bottom: 340px;
border-radius: 6px;
width: auto;
height: 28px;
padding: 0 10px;
font-size: 0.65em;
font-family: 'SFMono-Regular', Consolas, monospace;
font-weight: 600;
white-space: nowrap;
.resume-session.floating-btn {
bottom: 340px;
border-radius: 6px;
width: auto;
height: 28px;
padding: 0 10px;
font-size: 0.65em;
font-family: SFMono-Regular, Consolas, monospace;
font-weight: 600;
white-space: nowrap;
🧰 Tools
🪛 Stylelint (17.14.1)

[error] 374-374: Expected no quotes around "SFMono-Regular" (font-family-name-quotes)

(font-family-name-quotes)

🤖 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 `@claude_code_log/html/templates/components/global_styles.css` around lines 367
- 376, Update the font-family declaration in .resume-session.floating-btn to
remove the quotes around SFMono-Regular while preserving the existing fallback
fonts and styling.

Source: Linters/SAST tools

}

/* Transient confirmation shown after the resume command is copied. */
.resume-toast {
position: fixed;
right: 20px;
bottom: 380px;
max-width: 320px;
padding: 8px 12px;
background-color: var(--session-bg-dimmed);
color: var(--text-muted);
border-radius: 6px;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
font-size: 0.8em;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
z-index: 1000;
}

.resume-toast.visible {
opacity: 1;
}

@media (max-width: 1280px) {
.header > span:first-child {
flex: auto;
Expand Down
50 changes: 50 additions & 0 deletions claude_code_log/html/templates/transcript.html
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ <h3>🔍 Search & Filter</h3>

{% for root in roots %}{{ render_message(root) }}{% endfor %}

{% if resume_command %}
<button class="resume-session floating-btn" id="resumeSession" data-command="{{ resume_command }}"
title="Copy the command that resumes this session in Claude Code">▶ Resume Session</button>
{% endif %}
<button class="timeline-toggle floating-btn" id="toggleTimeline" title="Show timeline">📆</button>
<button class="filter-messages floating-btn" id="filterMessages" title="Search & Filter (/)">🔍</button>
<button class="toggle-details floating-btn" id="toggleDetails" title="Toggle all details">📋</button>
Expand Down Expand Up @@ -272,6 +276,52 @@ <h3>🔍 Search & Filter</h3>
debugButton.classList.toggle('active');
});

// Resume session: copy the resume command to the clipboard
// and prompt the user to paste it into a terminal. The
// command is built server-side (data-command) from the
// session's recorded cwd, so its quoting matches the OS
// the transcript was recorded on — which may differ from
// the OS viewing this page.
const resumeButton = document.getElementById('resumeSession');
if (resumeButton) {
let resumeToastTimer = null;
function showResumeToast(message) {
let toast = document.getElementById('resumeToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'resumeToast';
toast.className = 'resume-toast';
// Live-region semantics so screen readers
// announce the copy confirmation.
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.add('visible');
if (resumeToastTimer) clearTimeout(resumeToastTimer);
resumeToastTimer = setTimeout(function () {
toast.classList.remove('visible');
}, 5000);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
resumeButton.addEventListener('click', function () {
const command = resumeButton.dataset.command;
function copied() {
showResumeToast('📋 Copied! Paste into your terminal to resume this session.');
}
function fallback() {
// Clipboard API unavailable or refused: let the
// user copy from a prompt instead.
window.prompt('Copy this command, then paste it into your terminal:', command);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(command).then(copied, fallback);
} else {
fallback();
}
});
}

// User-content view toggle (Markdown / raw).
//
// Two toggles:
Expand Down
47 changes: 47 additions & 0 deletions claude_code_log/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,53 @@ def get_project_display_name(
return best_working_dir(project_dir_name, working_directories)[0]


# The resume command is pasted into a shell, and transcript fields are
# untrusted input (same threat model as the HTML escaping in #245) — so
# both values are held to conservative charsets and the button is
# skipped entirely rather than risk smuggling shell syntax.
_RESUME_SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
# Inside double quotes, cmd still expands %var% / delayed-expansion
# !var!, and PowerShell expands $var and `x escapes; a literal " would
# end the quoting altogether.
_WINDOWS_CWD_UNSAFE_RE = re.compile(r'["%!$`]')


def resume_command_for_session(session_id: str, cwd: Optional[str]) -> Optional[str]:
"""Build a shell one-liner that resumes ``session_id`` in Claude Code.

``cwd`` is the session's recorded working directory; the command
changes there first so ``claude -r`` runs in the right project.
Quoting follows the OS the *transcript* was recorded on (detected
from the path shape, like :func:`path_looks_absolute`), not the
host rendering the HTML — a Windows-recorded session must be
resumed in a Windows shell regardless of where the page is viewed.

Returns a bare ``claude -r`` command when no cwd was recorded, and
``None`` (no button) when the session id or a Windows cwd contains
characters a shell could interpret. Newlines are rejected in every
position: pasting a multi-line clipboard can execute each line
immediately, so quoting alone is no defence.
"""
if not _RESUME_SESSION_ID_RE.fullmatch(session_id):
return None
if not cwd:
return f"claude -r {session_id}"
if "\n" in cwd or "\r" in cwd:
return None
from pathlib import PureWindowsPath

if PureWindowsPath(cwd).drive:
if _WINDOWS_CWD_UNSAFE_RE.search(cwd):
return None
# Windows shells (PowerShell 7+, cmd): double quotes handle
# spaces; backslashes are literal inside them.
return f'cd "{cwd}" && claude -r {session_id}'
Comment on lines +250 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use a command that changes the Windows drive.

cmd.exe keeps its active drive after cd "D:\repo". If the terminal starts on C:, this command starts Claude from the current C: directory instead of the recorded working directory. The comment states that the command supports both cmd and PowerShell.

Generate syntax that works in each declared shell, such as pushd, or explicitly support one shell. Add a regression test for a D: working directory when the terminal starts on C:. Update the command expectations in test/test_resume_session_button.py.

🤖 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 `@claude_code_log/utils.py` around lines 250 - 255, Update the Windows command
generation in the drive-handling branch of the relevant utility to switch to the
recorded drive as well as the directory, using syntax compatible with both
cmd.exe and PowerShell (such as pushd). Add a regression test covering a D:
working directory while the terminal starts on C:, and update the corresponding
expectations in test_resume_session_button.py.

# POSIX shells: shlex protects spaces and metacharacters.
import shlex

return f"cd {shlex.quote(cwd)} && claude -r {session_id}"


def path_looks_absolute(s: str) -> bool:
"""True if ``s`` looks like an absolute path on either POSIX or
Windows. Decoupled from the host OS so JSONL-stored cwds don't
Expand Down
Loading