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