Skip to content

Commit bcf8af2

Browse files
committed
Add copy buttons to all message boxes and index items
Add a <copy-button> 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.
1 parent b7669be commit bcf8af2

File tree

8 files changed

+412
-63
lines changed

8 files changed

+412
-63
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,37 @@ def is_tool_result_message(message_data):
852852
)
853853

854854

855+
def extract_markdown_content(message_data, log_type):
856+
"""Extract markdown/text content from a message for copying."""
857+
content = message_data.get("content", "")
858+
if log_type == "user":
859+
if isinstance(content, str):
860+
if not is_json_like(content):
861+
return content, "Copy Markdown", None
862+
return None, "Copy JSON", ".message-content pre"
863+
elif isinstance(content, list):
864+
# Check for tool results (use selector) or text (use content)
865+
for block in content:
866+
if isinstance(block, dict):
867+
if block.get("type") == "tool_result":
868+
return None, "Copy text", ".message-content"
869+
if block.get("type") == "text":
870+
text = block.get("text", "")
871+
if text and not is_json_like(text):
872+
return text, "Copy Markdown", None
873+
return None, "Copy text", ".message-content"
874+
elif log_type == "assistant":
875+
# For assistant messages, extract text blocks
876+
if isinstance(content, list):
877+
texts = []
878+
for block in content:
879+
if isinstance(block, dict) and block.get("type") == "text":
880+
texts.append(block.get("text", ""))
881+
if texts:
882+
return "\n\n".join(texts), "Copy Markdown", None
883+
return None, "Copy text", ".message-content"
884+
885+
855886
def render_message(log_type, message_json, timestamp):
856887
if not message_json:
857888
return ""
@@ -874,7 +905,19 @@ def render_message(log_type, message_json, timestamp):
874905
if not content_html.strip():
875906
return ""
876907
msg_id = make_msg_id(timestamp)
877-
return _macros.message(role_class, role_label, msg_id, timestamp, content_html)
908+
copy_content, copy_label, copy_from = extract_markdown_content(
909+
message_data, log_type
910+
)
911+
return _macros.message(
912+
role_class,
913+
role_label,
914+
msg_id,
915+
timestamp,
916+
content_html,
917+
copy_label=copy_label,
918+
copy_content=copy_content,
919+
copy_from=copy_from,
920+
)
878921

879922

880923
CSS = """
@@ -893,6 +936,7 @@ def render_message(log_type, message_json, timestamp):
893936
.tool-reply .tool-result { background: transparent; padding: 0; margin: 0; }
894937
.tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); }
895938
.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; }
939+
.header-actions { display: flex; align-items: center; gap: 8px; }
896940
.role-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
897941
.user .role-label { color: var(--user-border); }
898942
time { color: var(--text-muted); font-size: 0.8rem; }
@@ -977,6 +1021,7 @@ def render_message(log_type, message_json, timestamp):
9771021
.index-item a { display: block; text-decoration: none; color: inherit; }
9781022
.index-item a:hover { background: rgba(25, 118, 210, 0.1); }
9791023
.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; }
1024+
.index-item-header .header-actions { display: flex; align-items: center; gap: 8px; }
9801025
.index-item-number { font-weight: 600; color: var(--user-border); }
9811026
.index-item-content { padding: 16px; }
9821027
.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):
10101055
.search-result { margin-bottom: 16px; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
10111056
.search-result a { display: block; text-decoration: none; color: inherit; }
10121057
.search-result a:hover { background: rgba(25, 118, 210, 0.05); }
1013-
.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); }
1058+
.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); }
1059+
.search-result-page { font-size: 0.8rem; color: var(--text-muted); text-decoration: none; }
1060+
.search-result-page:hover { text-decoration: underline; }
10141061
.search-result-content { padding: 12px; }
10151062
.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; }
1063+
copy-button { display: inline-block; margin-right: 8px; }
1064+
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; }
1065+
copy-button button:hover { background: rgba(0,0,0,0.05); border-color: var(--user-border); color: var(--user-border); }
1066+
copy-button button.copied { background: #e8f5e9; border-color: #4caf50; color: #2e7d32; }
10161067
@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; } }
10171068
"""
10181069

10191070
JS = """
1071+
class CopyButton extends HTMLElement {
1072+
constructor() {
1073+
super();
1074+
this.attachShadow({ mode: 'open' });
1075+
}
1076+
connectedCallback() {
1077+
const label = this.getAttribute('label') || 'Copy';
1078+
const style = document.createElement('style');
1079+
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; }';
1080+
const btn = document.createElement('button');
1081+
btn.textContent = label;
1082+
btn.addEventListener('click', (e) => this.handleClick(e, btn, label));
1083+
this.shadowRoot.appendChild(style);
1084+
this.shadowRoot.appendChild(btn);
1085+
}
1086+
handleClick(e, btn, label) {
1087+
e.preventDefault();
1088+
e.stopPropagation();
1089+
let content = this.getAttribute('data-content');
1090+
if (!content) {
1091+
const selector = this.getAttribute('data-content-from');
1092+
if (selector) {
1093+
const el = this.closest('.message, .index-item, .index-commit, .search-result')?.querySelector(selector) || document.querySelector(selector);
1094+
if (el) content = el.innerText;
1095+
}
1096+
}
1097+
if (content) {
1098+
navigator.clipboard.writeText(content).then(() => {
1099+
btn.textContent = 'Copied!';
1100+
btn.classList.add('copied');
1101+
setTimeout(() => { btn.textContent = label; btn.classList.remove('copied'); }, 2000);
1102+
});
1103+
}
1104+
}
1105+
}
1106+
customElements.define('copy-button', CopyButton);
10201107
document.querySelectorAll('time[data-timestamp]').forEach(function(el) {
10211108
const timestamp = el.getAttribute('data-timestamp');
10221109
const date = new Date(timestamp);
@@ -1293,7 +1380,12 @@ def generate_html(json_path, output_dir, github_repo=None):
12931380
stats_html = _macros.index_stats(tool_stats_str, long_texts_html)
12941381

12951382
item_html = _macros.index_item(
1296-
prompt_num, link, conv["timestamp"], rendered_content, stats_html
1383+
prompt_num,
1384+
link,
1385+
conv["timestamp"],
1386+
rendered_content,
1387+
stats_html,
1388+
copy_content=conv["user_text"],
12971389
)
12981390
timeline_items.append((conv["timestamp"], "prompt", item_html))
12991391

@@ -1708,7 +1800,12 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None):
17081800
stats_html = _macros.index_stats(tool_stats_str, long_texts_html)
17091801

17101802
item_html = _macros.index_item(
1711-
prompt_num, link, conv["timestamp"], rendered_content, stats_html
1803+
prompt_num,
1804+
link,
1805+
conv["timestamp"],
1806+
rendered_content,
1807+
stats_html,
1808+
copy_content=conv["user_text"],
17121809
)
17131810
timeline_items.append((conv["timestamp"], "prompt", item_html))
17141811

src/claude_code_transcripts/templates/macros.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@
147147
{%- endmacro %}
148148

149149
{# Message wrapper - content_html is pre-rendered so needs |safe #}
150-
{% macro message(role_class, role_label, msg_id, timestamp, content_html) %}
151-
<div class="message {{ role_class }}" id="{{ msg_id }}"><div class="message-header"><span class="role-label">{{ role_label }}</span><a href="#{{ msg_id }}" class="timestamp-link"><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></a></div><div class="message-content">{{ content_html|safe }}</div></div>
150+
{% macro message(role_class, role_label, msg_id, timestamp, content_html, copy_label='Copy Markdown', copy_content=None, copy_from=None) %}
151+
<div class="message {{ role_class }}" id="{{ msg_id }}"><div class="message-header"><span class="role-label">{{ role_label }}</span><span class="header-actions">{% if copy_content %}<copy-button label="{{ copy_label }}" data-content="{{ copy_content|e }}"></copy-button>{% elif copy_from %}<copy-button label="{{ copy_label }}" data-content-from="{{ copy_from }}"></copy-button>{% endif %}<a href="#{{ msg_id }}" class="timestamp-link"><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></a></span></div><div class="message-content">{{ content_html|safe }}</div></div>
152152
{%- endmacro %}
153153

154154
{# Continuation wrapper - content_html is pre-rendered so needs |safe #}
@@ -157,17 +157,17 @@
157157
{%- endmacro %}
158158

159159
{# Index item (prompt) - rendered_content and stats_html are pre-rendered so need |safe #}
160-
{% macro index_item(prompt_num, link, timestamp, rendered_content, stats_html) %}
161-
<div class="index-item"><a href="{{ link }}"><div class="index-item-header"><span class="index-item-number">#{{ prompt_num }}</span><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></div><div class="index-item-content">{{ rendered_content|safe }}</div></a>{{ stats_html|safe }}</div>
160+
{% macro index_item(prompt_num, link, timestamp, rendered_content, stats_html, copy_content=None) %}
161+
<div class="index-item"><a href="{{ link }}"><div class="index-item-header"><span class="index-item-number">#{{ prompt_num }}</span><span class="header-actions">{% if copy_content %}<copy-button label="Copy Markdown" data-content="{{ copy_content|e }}"></copy-button>{% endif %}<time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></span></div><div class="index-item-content">{{ rendered_content|safe }}</div></a>{{ stats_html|safe }}</div>
162162
{%- endmacro %}
163163

164164
{# Index commit #}
165165
{% macro index_commit(commit_hash, commit_msg, timestamp, github_repo) %}
166166
{%- if github_repo -%}
167167
{%- set github_link = 'https://github.com/' ~ github_repo ~ '/commit/' ~ commit_hash -%}
168-
<div class="index-commit"><a href="{{ github_link }}"><div class="index-commit-header"><span class="index-commit-hash">{{ commit_hash[:7] }}</span><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></div><div class="index-commit-msg">{{ commit_msg }}</div></a></div>
168+
<div class="index-commit"><a href="{{ github_link }}"><div class="index-commit-header"><span class="index-commit-hash">{{ commit_hash[:7] }}</span><span class="header-actions"><copy-button label="Copy text" data-content-from=".index-commit-msg"></copy-button><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></span></div><div class="index-commit-msg">{{ commit_msg }}</div></a></div>
169169
{%- else -%}
170-
<div class="index-commit"><div class="index-commit-header"><span class="index-commit-hash">{{ commit_hash[:7] }}</span><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></div><div class="index-commit-msg">{{ commit_msg }}</div></div>
170+
<div class="index-commit"><div class="index-commit-header"><span class="index-commit-hash">{{ commit_hash[:7] }}</span><span class="header-actions"><copy-button label="Copy text" data-content-from=".index-commit-msg"></copy-button><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></span></div><div class="index-commit-msg">{{ commit_msg }}</div></div>
171171
{%- endif %}
172172
{%- endmacro %}
173173

src/claude_code_transcripts/templates/search.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,11 @@
163163

164164
var resultDiv = document.createElement('div');
165165
resultDiv.className = 'search-result';
166-
resultDiv.innerHTML = '<a href="' + link + '">' +
167-
'<div class="search-result-page">' + escapeHtml(pageFile) + '</div>' +
166+
resultDiv.innerHTML = '<div class="search-result-header">' +
167+
'<copy-button label="Copy text" data-content-from=".search-result-content"></copy-button>' +
168+
'<a href="' + link + '" class="search-result-page">' + escapeHtml(pageFile) + '</a>' +
169+
'</div>' +
170+
'<a href="' + link + '">' +
168171
'<div class="search-result-content">' + clone.innerHTML + '</div>' +
169172
'</a>';
170173
searchResults.appendChild(resultDiv);

0 commit comments

Comments
 (0)