diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index a2463f43..93ccb86e 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -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, @@ -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( @@ -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 @@ -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 ` 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() @@ -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, ) ) diff --git a/claude_code_log/html/templates/components/global_styles.css b/claude_code_log/html/templates/components/global_styles.css index e94fe948..248d3212 100644 --- a/claude_code_log/html/templates/components/global_styles.css +++ b/claude_code_log/html/templates/components/global_styles.css @@ -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 ` 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; +} + +/* 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; diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 89a11203..3f2470c9 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -244,6 +244,10 @@

🔍 Search & Filter

{% for root in roots %}{{ render_message(root) }}{% endfor %} + {% if resume_command %} + + {% endif %} @@ -272,6 +276,52 @@

🔍 Search & Filter

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); + } + 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: diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index 34d16653..12070e83 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -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}' + # 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 diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 9d0a15a6..833730d2 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -374,6 +374,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -5020,6 +5056,10 @@ + + + @@ -5162,6 +5202,52 @@ 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); + } + 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: @@ -7196,6 +7282,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -11737,6 +11859,10 @@ + + + @@ -11879,6 +12005,52 @@ 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); + } + 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: @@ -13913,6 +14085,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -16342,6 +16550,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -21106,6 +21350,10 @@ + + + @@ -21248,6 +21496,52 @@ 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); + } + 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: @@ -23282,6 +23576,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -28326,6 +28656,10 @@ + + + @@ -28468,6 +28802,52 @@ 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); + } + 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: @@ -30502,6 +30882,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -35497,6 +35913,7 @@ + @@ -35639,6 +36056,52 @@ 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); + } + 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: @@ -37673,6 +38136,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -42613,6 +43112,7 @@ + @@ -42755,6 +43255,52 @@ 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); + } + 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: @@ -44789,6 +45335,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -49553,6 +50135,10 @@ + + + @@ -49695,6 +50281,52 @@ 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); + } + 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: @@ -51729,6 +52361,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -56298,6 +56966,10 @@ + + + @@ -56440,6 +57112,52 @@ 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); + } + 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: @@ -58474,6 +59192,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` 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; + } + + /* 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; @@ -62992,6 +63746,10 @@ + + + @@ -63134,6 +63892,52 @@ 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); + } + 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: diff --git a/test/test_resume_session_browser.py b/test/test_resume_session_browser.py new file mode 100644 index 00000000..9bf014fe --- /dev/null +++ b/test/test_resume_session_browser.py @@ -0,0 +1,138 @@ +"""Playwright tests for the Resume Session floating button. + +Covers the live-JS behavior the server-side unit tests can't reach: +clicking the button writes the resume command to the clipboard and +shows the paste-into-terminal toast. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import List, Optional + +import pytest +from playwright.sync_api import Page, expect + +from claude_code_log.converter import load_transcript +from claude_code_log.html.renderer import generate_html +from claude_code_log.models import TranscriptEntry + + +def _user_entry( + uuid: str, + text: str, + session_id: str = "test_session", + cwd: str = "/tmp/project", + ts: str = "2026-01-01T10:00:00.000Z", +) -> dict: + """Build a raw user-entry dict for a JSONL fixture.""" + return { + "type": "user", + "timestamp": ts, + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": cwd, + "sessionId": session_id, + "version": "1.0.0", + "uuid": uuid, + "message": { + "role": "user", + "content": [{"type": "text", "text": text}], + }, + } + + +class TestResumeSessionBrowser: + """Live-browser tests for the Resume Session button.""" + + def setup_method(self) -> None: + """Track temp files created by the test for cleanup.""" + self.temp_files: List[Path] = [] + + def teardown_method(self) -> None: + """Remove the temp files created during the test.""" + for f in self.temp_files: + try: + f.unlink() + except FileNotFoundError: + pass + + def _render(self, entries: List[dict], title: str = "Resume Test") -> Path: + """Write entries to a JSONL, render to HTML, return the HTML path.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".jsonl", delete=False, encoding="utf-8" + ) as f: + for e in entries: + f.write(json.dumps(e) + "\n") + jsonl_path = Path(f.name) + self.temp_files.append(jsonl_path) + + messages: List[TranscriptEntry] = load_transcript(jsonl_path) + html_content = generate_html(messages, title) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".html", delete=False, encoding="utf-8" + ) as f: + f.write(html_content) + html_path = Path(f.name) + self.temp_files.append(html_path) + return html_path + + def _goto_with_clipboard_stub(self, page: Page, html: Path) -> None: + """Navigate to the rendered HTML with ``navigator.clipboard`` + stubbed to record writes into ``window.__copied``. file:// pages + can't reliably get real clipboard permission in the test + browser, and a stub also lets the test assert the exact text.""" + page.add_init_script( + """ + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: function (text) { + window.__copied = text; + return Promise.resolve(); + } + }, + configurable: true + }); + """ + ) + page.goto(html.as_uri()) + + @pytest.mark.browser + def test_click_copies_command_and_shows_toast(self, page: Page) -> None: + """Clicking the button copies the exact command and shows the toast.""" + html = self._render( + [_user_entry("u1", "hello", session_id="ab12cd34", cwd="/tmp/project")] + ) + self._goto_with_clipboard_stub(page, html) + + button = page.locator("#resumeSession") + expect(button).to_be_visible() + expect(button).to_have_text("▶ Resume Session") + + button.click() + copied: Optional[str] = page.evaluate("() => window.__copied") + assert copied == "cd /tmp/project && claude -r ab12cd34" + + toast = page.locator("#resumeToast") + expect(toast).to_be_visible() + expect(toast).to_contain_text("Paste into your terminal") + + @pytest.mark.browser + def test_no_button_on_multi_session_page(self, page: Page) -> None: + """Combined pages spanning several sessions render no button.""" + html = self._render( + [ + _user_entry("u1", "first", session_id="session_a"), + _user_entry( + "u2", + "second", + session_id="session_b", + ts="2026-01-01T10:01:00.000Z", + ), + ] + ) + page.goto(html.as_uri()) + expect(page.locator("#resumeSession")).to_have_count(0) diff --git a/test/test_resume_session_button.py b/test/test_resume_session_button.py new file mode 100644 index 00000000..17e39996 --- /dev/null +++ b/test/test_resume_session_button.py @@ -0,0 +1,140 @@ +"""Tests for the Resume Session floating button and its command builder.""" + +from typing import Optional + +from claude_code_log.html.renderer import generate_html +from claude_code_log.models import ( + TextContent, + UserMessageModel, + UserTranscriptEntry, +) +from claude_code_log.utils import resume_command_for_session + +SESSION_A = "c2688f20-2ca1-410d-a82b-1a7f11761315" +SESSION_B = "37f83ec9-f2ea-42a9-925e-0d5c105cb6e8" + + +def _user_entry(session_id: str, cwd: Optional[str], uuid: str) -> UserTranscriptEntry: + """Build a minimal user entry for rendering tests.""" + return UserTranscriptEntry( + type="user", + timestamp="2025-06-14T10:00:00.000Z", + parentUuid=None, + isSidechain=False, + userType="human", + cwd=cwd or "", + sessionId=session_id, + version="1.0.0", + uuid=uuid, + message=UserMessageModel( + role="user", + content=[TextContent(type="text", text=f"Hello from {uuid}")], + ), + ) + + +class TestResumeCommandForSession: + """Command shape follows the OS the transcript was recorded on.""" + + def test_windows_cwd_uses_double_quotes(self): + """A drive-lettered cwd gets Windows double-quote quoting.""" + assert ( + resume_command_for_session(SESSION_A, "C:\\Users\\maxno") + == f'cd "C:\\Users\\maxno" && claude -r {SESSION_A}' + ) + + def test_windows_cwd_with_spaces(self): + """Spaces in a Windows cwd stay inside the double quotes.""" + assert ( + resume_command_for_session(SESSION_A, "C:\\My Projects\\app") + == f'cd "C:\\My Projects\\app" && claude -r {SESSION_A}' + ) + + def test_posix_cwd(self): + """A plain POSIX cwd needs no quoting at all.""" + assert ( + resume_command_for_session(SESSION_A, "/Users/dain/workspace") + == f"cd /Users/dain/workspace && claude -r {SESSION_A}" + ) + + def test_posix_cwd_with_spaces_is_quoted(self): + """Spaces in a POSIX cwd get shlex single-quoting.""" + assert ( + resume_command_for_session(SESSION_A, "/home/u/my project") + == f"cd '/home/u/my project' && claude -r {SESSION_A}" + ) + + def test_no_cwd_falls_back_to_bare_resume(self): + """Without a recorded cwd the command is a bare claude -r.""" + assert resume_command_for_session(SESSION_A, None) == f"claude -r {SESSION_A}" + + def test_posix_cwd_with_single_quote_is_escaped(self): + """shlex neutralizes an embedded single quote in a POSIX cwd.""" + command = resume_command_for_session(SESSION_A, "/home/u/it's") + assert command == f"""cd '/home/u/it'"'"'s' && claude -r {SESSION_A}""" + + def test_session_id_with_shell_metacharacters_is_rejected(self): + """A session id carrying shell syntax yields no command at all.""" + assert resume_command_for_session("evil; rm -rf ~", "/tmp") is None + assert resume_command_for_session("$(whoami)", "/tmp") is None + assert resume_command_for_session("", "/tmp") is None + + def test_windows_cwd_with_embedded_quote_is_rejected(self): + """A double quote in a Windows cwd would break out of the quoting.""" + assert resume_command_for_session(SESSION_A, 'C:\\evil" & calc & "') is None + + def test_windows_cwd_with_expansion_characters_is_rejected(self): + """cmd %var%/!var! and PowerShell $var/`x expand inside double quotes.""" + assert resume_command_for_session(SESSION_A, "C:\\x%TEMP%") is None + assert resume_command_for_session(SESSION_A, "C:\\x$(calc)") is None + assert resume_command_for_session(SESSION_A, "C:\\x`n") is None + assert resume_command_for_session(SESSION_A, "C:\\x!var!") is None + + def test_cwd_with_newline_is_rejected(self): + """Pasting multi-line text can execute each line — reject outright.""" + assert resume_command_for_session(SESSION_A, "/tmp/a\nrm -rf ~") is None + assert resume_command_for_session(SESSION_A, "C:\\a\r\ncalc") is None + + +class TestResumeButtonInHtml: + """The button renders only on single-session pages.""" + + def test_single_session_page_has_button_with_command(self): + """A single-session page renders the button with its command.""" + html = generate_html( + [_user_entry(SESSION_A, "/Users/dain/workspace", "uuid-1")], + "Test Resume", + ) + assert 'id="resumeSession"' in html + assert "▶ Resume Session" in html + assert ( + f'data-command="cd /Users/dain/workspace && claude -r {SESSION_A}"' + in html + ) + + def test_windows_session_command_is_escaped_into_attribute(self): + """The Windows command lands HTML-escaped in the data attribute.""" + html = generate_html( + [_user_entry(SESSION_A, "C:\\Users\\maxno", "uuid-1")], + "Test Resume Windows", + ) + assert f"cd "C:\\Users\\maxno" && claude -r {SESSION_A}" in html + + def test_multi_session_page_has_no_button(self): + """Combined pages spanning several sessions render no button.""" + html = generate_html( + [ + _user_entry(SESSION_A, "/Users/dain/workspace", "uuid-1"), + _user_entry(SESSION_B, "/Users/dain/workspace", "uuid-2"), + ], + "Test Combined", + ) + assert 'id="resumeSession"' not in html + + def test_unsafe_session_id_renders_no_button(self): + """A transcript with a shell-unsafe session id gets no button.""" + html = generate_html( + [_user_entry("evil; rm -rf ~", "/tmp", "uuid-1")], + "Test Unsafe", + ) + assert 'id="resumeSession"' not in html