From bcf8af26476273bed964c34752c36d55b34e509a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 16:13:06 +0000 Subject: [PATCH 1/4] Add copy buttons to all message boxes and index items Add a WebComponent that copies content to clipboard: - Uses data-content attribute for markdown content (stored at render time) - Uses data-content-from attribute with CSS selector for JSON/text content - Button shows "Copied!" for 2 seconds after successful copy - Different button labels: "Copy Markdown", "Copy JSON", "Copy text" Copy buttons added to: - Messages in page-X.html (left of timestamp) - Index items in index.html (left of timestamp) - Index commits in index.html (left of timestamp) - Search results in search modal Includes CSS styling, WebComponent JavaScript, and test coverage. --- src/claude_code_transcripts/__init__.py | 105 +++++++++++++++- .../templates/macros.html | 12 +- .../templates/search.js | 7 +- ...enerateHtml.test_generates_index_html.html | 67 ++++++++-- ...rateHtml.test_generates_page_001_html.html | 114 +++++++++++++----- ...rateHtml.test_generates_page_002_html.html | 54 ++++++++- ...SessionFile.test_jsonl_generates_html.html | 59 ++++++++- tests/test_generate_html.py | 57 +++++++++ 8 files changed, 412 insertions(+), 63 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 6318120..af2247c 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -852,6 +852,37 @@ def is_tool_result_message(message_data): ) +def extract_markdown_content(message_data, log_type): + """Extract markdown/text content from a message for copying.""" + content = message_data.get("content", "") + if log_type == "user": + if isinstance(content, str): + if not is_json_like(content): + return content, "Copy Markdown", None + return None, "Copy JSON", ".message-content pre" + elif isinstance(content, list): + # Check for tool results (use selector) or text (use content) + for block in content: + if isinstance(block, dict): + if block.get("type") == "tool_result": + return None, "Copy text", ".message-content" + if block.get("type") == "text": + text = block.get("text", "") + if text and not is_json_like(text): + return text, "Copy Markdown", None + return None, "Copy text", ".message-content" + elif log_type == "assistant": + # For assistant messages, extract text blocks + if isinstance(content, list): + texts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + texts.append(block.get("text", "")) + if texts: + return "\n\n".join(texts), "Copy Markdown", None + return None, "Copy text", ".message-content" + + def render_message(log_type, message_json, timestamp): if not message_json: return "" @@ -874,7 +905,19 @@ def render_message(log_type, message_json, timestamp): if not content_html.strip(): return "" msg_id = make_msg_id(timestamp) - return _macros.message(role_class, role_label, msg_id, timestamp, content_html) + copy_content, copy_label, copy_from = extract_markdown_content( + message_data, log_type + ) + return _macros.message( + role_class, + role_label, + msg_id, + timestamp, + content_html, + copy_label=copy_label, + copy_content=copy_content, + copy_from=copy_from, + ) CSS = """ @@ -893,6 +936,7 @@ def render_message(log_type, message_json, timestamp): .tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } .tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } .message-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; } +.header-actions { display: flex; align-items: center; gap: 8px; } .role-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .user .role-label { color: var(--user-border); } time { color: var(--text-muted); font-size: 0.8rem; } @@ -977,6 +1021,7 @@ def render_message(log_type, message_json, timestamp): .index-item a { display: block; text-decoration: none; color: inherit; } .index-item a:hover { background: rgba(25, 118, 210, 0.1); } .index-item-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; } +.index-item-header .header-actions { display: flex; align-items: center; gap: 8px; } .index-item-number { font-weight: 600; color: var(--user-border); } .index-item-content { padding: 16px; } .index-item-stats { padding: 8px 16px 12px 32px; font-size: 0.85rem; color: var(--text-muted); border-top: 1px solid rgba(0,0,0,0.06); } @@ -1010,13 +1055,55 @@ def render_message(log_type, message_json, timestamp): .search-result { margin-bottom: 16px; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .search-result a { display: block; text-decoration: none; color: inherit; } .search-result a:hover { background: rgba(25, 118, 210, 0.05); } -.search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } +.search-result-header { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: rgba(0,0,0,0.03); border-bottom: 1px solid rgba(0,0,0,0.06); } +.search-result-page { font-size: 0.8rem; color: var(--text-muted); text-decoration: none; } +.search-result-page:hover { text-decoration: underline; } .search-result-content { padding: 12px; } .search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } +copy-button { display: inline-block; margin-right: 8px; } +copy-button button { background: transparent; border: 1px solid var(--text-muted); border-radius: 4px; padding: 2px 6px; font-size: 0.7rem; color: var(--text-muted); cursor: pointer; white-space: nowrap; } +copy-button button:hover { background: rgba(0,0,0,0.05); border-color: var(--user-border); color: var(--user-border); } +copy-button button.copied { background: #e8f5e9; border-color: #4caf50; color: #2e7d32; } @media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } """ JS = """ +class CopyButton extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + connectedCallback() { + const label = this.getAttribute('label') || 'Copy'; + const style = document.createElement('style'); + style.textContent = 'button { background: transparent; border: 1px solid #757575; border-radius: 4px; padding: 2px 6px; font-size: 0.7rem; color: #757575; cursor: pointer; white-space: nowrap; font-family: inherit; } button:hover { background: rgba(0,0,0,0.05); border-color: #1976d2; color: #1976d2; } button.copied { background: #e8f5e9; border-color: #4caf50; color: #2e7d32; }'; + const btn = document.createElement('button'); + btn.textContent = label; + btn.addEventListener('click', (e) => this.handleClick(e, btn, label)); + this.shadowRoot.appendChild(style); + this.shadowRoot.appendChild(btn); + } + handleClick(e, btn, label) { + e.preventDefault(); + e.stopPropagation(); + let content = this.getAttribute('data-content'); + if (!content) { + const selector = this.getAttribute('data-content-from'); + if (selector) { + const el = this.closest('.message, .index-item, .index-commit, .search-result')?.querySelector(selector) || document.querySelector(selector); + if (el) content = el.innerText; + } + } + if (content) { + navigator.clipboard.writeText(content).then(() => { + btn.textContent = 'Copied!'; + btn.classList.add('copied'); + setTimeout(() => { btn.textContent = label; btn.classList.remove('copied'); }, 2000); + }); + } + } +} +customElements.define('copy-button', CopyButton); document.querySelectorAll('time[data-timestamp]').forEach(function(el) { const timestamp = el.getAttribute('data-timestamp'); const date = new Date(timestamp); @@ -1293,7 +1380,12 @@ def generate_html(json_path, output_dir, github_repo=None): stats_html = _macros.index_stats(tool_stats_str, long_texts_html) item_html = _macros.index_item( - prompt_num, link, conv["timestamp"], rendered_content, stats_html + prompt_num, + link, + conv["timestamp"], + rendered_content, + stats_html, + copy_content=conv["user_text"], ) timeline_items.append((conv["timestamp"], "prompt", item_html)) @@ -1708,7 +1800,12 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): stats_html = _macros.index_stats(tool_stats_str, long_texts_html) item_html = _macros.index_item( - prompt_num, link, conv["timestamp"], rendered_content, stats_html + prompt_num, + link, + conv["timestamp"], + rendered_content, + stats_html, + copy_content=conv["user_text"], ) timeline_items.append((conv["timestamp"], "prompt", item_html)) diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index 66866e5..c3289fe 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -147,8 +147,8 @@ {%- endmacro %} {# Message wrapper - content_html is pre-rendered so needs |safe #} -{% macro message(role_class, role_label, msg_id, timestamp, content_html) %} -
{{ role_label }}
{{ content_html|safe }}
+{% macro message(role_class, role_label, msg_id, timestamp, content_html, copy_label='Copy Markdown', copy_content=None, copy_from=None) %} +
{{ role_label }}{% if copy_content %}{% elif copy_from %}{% endif %}
{{ content_html|safe }}
{%- endmacro %} {# Continuation wrapper - content_html is pre-rendered so needs |safe #} @@ -157,17 +157,17 @@ {%- endmacro %} {# Index item (prompt) - rendered_content and stats_html are pre-rendered so need |safe #} -{% macro index_item(prompt_num, link, timestamp, rendered_content, stats_html) %} - +{% macro index_item(prompt_num, link, timestamp, rendered_content, stats_html, copy_content=None) %} + {%- endmacro %} {# Index commit #} {% macro index_commit(commit_hash, commit_msg, timestamp, github_repo) %} {%- if github_repo -%} {%- set github_link = 'https://github.com/' ~ github_repo ~ '/commit/' ~ commit_hash -%} - + {%- else -%} -
{{ commit_hash[:7] }}
{{ commit_msg }}
+
{{ commit_hash[:7] }}
{{ commit_msg }}
{%- endif %} {%- endmacro %} diff --git a/src/claude_code_transcripts/templates/search.js b/src/claude_code_transcripts/templates/search.js index 48a6e1d..eedc83e 100644 --- a/src/claude_code_transcripts/templates/search.js +++ b/src/claude_code_transcripts/templates/search.js @@ -163,8 +163,11 @@ var resultDiv = document.createElement('div'); resultDiv.className = 'search-result'; - resultDiv.innerHTML = '' + - '
' + escapeHtml(pageFile) + '
' + + resultDiv.innerHTML = '
' + + '' + '
' + clone.innerHTML + '
' + '
'; searchResults.appendChild(resultDiv); diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index c2b6116..af14ae7 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -20,6 +20,7 @@ .tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } .tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } .message-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; } +.header-actions { display: flex; align-items: center; gap: 8px; } .role-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .user .role-label { color: var(--user-border); } time { color: var(--text-muted); font-size: 0.8rem; } @@ -104,6 +105,7 @@ .index-item a { display: block; text-decoration: none; color: inherit; } .index-item a:hover { background: rgba(25, 118, 210, 0.1); } .index-item-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; } +.index-item-header .header-actions { display: flex; align-items: center; gap: 8px; } .index-item-number { font-weight: 600; color: var(--user-border); } .index-item-content { padding: 16px; } .index-item-stats { padding: 8px 16px 12px 32px; font-size: 0.85rem; color: var(--text-muted); border-top: 1px solid rgba(0,0,0,0.06); } @@ -137,9 +139,15 @@ .search-result { margin-bottom: 16px; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .search-result a { display: block; text-decoration: none; color: inherit; } .search-result a:hover { background: rgba(25, 118, 210, 0.05); } -.search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } +.search-result-header { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: rgba(0,0,0,0.03); border-bottom: 1px solid rgba(0,0,0,0.06); } +.search-result-page { font-size: 0.8rem; color: var(--text-muted); text-decoration: none; } +.search-result-page:hover { text-decoration: underline; } .search-result-content { padding: 12px; } .search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } +copy-button { display: inline-block; margin-right: 8px; } +copy-button button { background: transparent; border: 1px solid var(--text-muted); border-radius: 4px; padding: 2px 6px; font-size: 0.7rem; color: var(--text-muted); cursor: pointer; white-space: nowrap; } +copy-button button:hover { background: rgba(0,0,0,0.05); border-color: var(--user-border); color: var(--user-border); } +copy-button button.copied { background: #e8f5e9; border-color: #4caf50; color: #2e7d32; } @media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } @@ -166,15 +174,15 @@

Claude Code transcript

5 prompts · 33 messages · 12 tool calls · 2 commits · 2 pages

- -
#2

Now edit the file to add a subtract function

1 glob · 1 edit · 1 grep + + -
#3

Run the tests again

1 bash + - -
#5

Add a multiply function too

1 edit + + @@ -366,8 +374,11 @@

Claude Code transcript

var resultDiv = document.createElement('div'); resultDiv.className = 'search-result'; - resultDiv.innerHTML = '' + - '
' + escapeHtml(pageFile) + '
' + + resultDiv.innerHTML = '
' + + '' + '
' + clone.innerHTML + '
' + '
'; searchResults.appendChild(resultDiv); @@ -481,6 +492,42 @@

Claude Code transcript

- +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index 06edd6b..9ffc938 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -4,8 +4,7 @@ Claude Code transcript - page 1 - +@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } }
@@ -309,8 +307,7 @@

Claude C

- +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 4ac7ca8..93f2be1 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -4,8 +4,7 @@ Claude Code transcript - page 2 - +@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } }
- +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index 2de9e0e..3fb3605 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -4,8 +4,7 @@ Claude Code transcript - Index - +@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } }
@@ -482,8 +480,7 @@

Claude Code transcript

})();
- +}); \ No newline at end of file