diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d182fd7..19d1a19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,17 @@ jobs: - name: Install dependencies run: | pip install -e . --group dev + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: | + ~/.cache/ms-playwright/ + ~/Library/Caches/ms-playwright/ + ~\AppData\Local\ms-playwright\ + key: ${{ runner.os }}-playwright + - name: Install Playwright browsers + run: | + python -m playwright install chromium - name: Run tests env: PYTHONUTF8: "1" diff --git a/pyproject.toml b/pyproject.toml index afc552b..8ce1bef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,4 +35,5 @@ dev = [ "pytest>=9.0.2", "pytest-httpx>=0.35.0", "syrupy>=5.0.0", + "pytest-playwright>=0.7.0", ] diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 6318120..438e349 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -35,6 +35,16 @@ def get_template(name): return _jinja_env.get_template(name) +def get_css(): + """Load CSS from template file.""" + return get_template("styles.css").render() + + +def get_js(): + """Load JavaScript from template file.""" + return get_template("scripts.js").render() + + # Regex to match git commit output: [branch hash] message COMMIT_PATTERN = re.compile(r"\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)") @@ -401,8 +411,8 @@ def _generate_project_index(project, output_dir): project_name=project["name"], sessions=sessions_data, session_count=len(sessions_data), - css=CSS, - js=JS, + css=get_css(), + js=get_js(), ) output_path = output_dir / "index.html" @@ -440,8 +450,8 @@ def _generate_master_index(projects, output_dir): projects=projects_data, total_projects=len(projects), total_sessions=total_sessions, - css=CSS, - js=JS, + css=get_css(), + js=get_js(), ) output_path = output_dir / "index.html" @@ -852,6 +862,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,178 +915,20 @@ 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) - - -CSS = """ -:root { --bg-color: #f5f5f5; --card-bg: #ffffff; --user-bg: #e3f2fd; --user-border: #1976d2; --assistant-bg: #f5f5f5; --assistant-border: #9e9e9e; --thinking-bg: #fff8e1; --thinking-border: #ffc107; --thinking-text: #666; --tool-bg: #f3e5f5; --tool-border: #9c27b0; --tool-result-bg: #e8f5e9; --tool-error-bg: #ffebee; --text-color: #212121; --text-muted: #757575; --code-bg: #263238; --code-text: #aed581; } -* { box-sizing: border-box; } -body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-color); color: var(--text-color); margin: 0; padding: 16px; line-height: 1.6; } -.container { max-width: 800px; margin: 0 auto; } -h1 { font-size: 1.5rem; margin-bottom: 24px; padding-bottom: 8px; border-bottom: 2px solid var(--user-border); } -.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; border-bottom: 2px solid var(--user-border); padding-bottom: 8px; margin-bottom: 24px; } -.header-row h1 { border-bottom: none; padding-bottom: 0; margin-bottom: 0; flex: 1; min-width: 200px; } -.message { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } -.message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } -.message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } -.message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } -.tool-reply .role-label { color: #e65100; } -.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; } -.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; } -.timestamp-link { color: inherit; text-decoration: none; } -.timestamp-link:hover { text-decoration: underline; } -.message:target { animation: highlight 2s ease-out; } -@keyframes highlight { 0% { background-color: rgba(25, 118, 210, 0.2); } 100% { background-color: transparent; } } -.message-content { padding: 16px; } -.message-content p { margin: 0 0 12px 0; } -.message-content p:last-child { margin-bottom: 0; } -.thinking { background: var(--thinking-bg); border: 1px solid var(--thinking-border); border-radius: 8px; padding: 12px; margin: 12px 0; font-size: 0.9rem; color: var(--thinking-text); } -.thinking-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: #f57c00; margin-bottom: 8px; } -.thinking p { margin: 8px 0; } -.assistant-text { margin: 8px 0; } -.tool-use { background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 8px; padding: 12px; margin: 12px 0; } -.tool-header { font-weight: 600; color: var(--tool-border); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } -.tool-icon { font-size: 1.1rem; } -.tool-description { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; font-style: italic; } -.tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } -.tool-result.tool-error { background: var(--tool-error-bg); } -.file-tool { border-radius: 8px; padding: 12px; margin: 12px 0; } -.write-tool { background: linear-gradient(135deg, #e3f2fd 0%, #e8f5e9 100%); border: 1px solid #4caf50; } -.edit-tool { background: linear-gradient(135deg, #fff3e0 0%, #fce4ec 100%); border: 1px solid #ff9800; } -.file-tool-header { font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; } -.write-header { color: #2e7d32; } -.edit-header { color: #e65100; } -.file-tool-icon { font-size: 1rem; } -.file-tool-path { font-family: monospace; background: rgba(0,0,0,0.08); padding: 2px 8px; border-radius: 4px; } -.file-tool-fullpath { font-family: monospace; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; word-break: break-all; } -.file-content { margin: 0; } -.edit-section { display: flex; margin: 4px 0; border-radius: 4px; overflow: hidden; } -.edit-label { padding: 8px 12px; font-weight: bold; font-family: monospace; display: flex; align-items: flex-start; } -.edit-old { background: #fce4ec; } -.edit-old .edit-label { color: #b71c1c; background: #f8bbd9; } -.edit-old .edit-content { color: #880e4f; } -.edit-new { background: #e8f5e9; } -.edit-new .edit-label { color: #1b5e20; background: #a5d6a7; } -.edit-new .edit-content { color: #1b5e20; } -.edit-content { margin: 0; flex: 1; background: transparent; font-size: 0.85rem; } -.edit-replace-all { font-size: 0.75rem; font-weight: normal; color: var(--text-muted); } -.write-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #e6f4ea); } -.edit-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff0e5); } -.todo-list { background: linear-gradient(135deg, #e8f5e9 0%, #f1f8e9 100%); border: 1px solid #81c784; border-radius: 8px; padding: 12px; margin: 12px 0; } -.todo-header { font-weight: 600; color: #2e7d32; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; } -.todo-items { list-style: none; margin: 0; padding: 0; } -.todo-item { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(0,0,0,0.06); font-size: 0.9rem; } -.todo-item:last-child { border-bottom: none; } -.todo-icon { flex-shrink: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-weight: bold; border-radius: 50%; } -.todo-completed .todo-icon { color: #2e7d32; background: rgba(46, 125, 50, 0.15); } -.todo-completed .todo-content { color: #558b2f; text-decoration: line-through; } -.todo-in-progress .todo-icon { color: #f57c00; background: rgba(245, 124, 0, 0.15); } -.todo-in-progress .todo-content { color: #e65100; font-weight: 500; } -.todo-pending .todo-icon { color: #757575; background: rgba(0,0,0,0.05); } -.todo-pending .todo-content { color: #616161; } -pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } -pre.json { color: #e0e0e0; } -code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } -pre code { background: none; padding: 0; } -.user-content { margin: 0; } -.truncatable { position: relative; } -.truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } -.truncatable.truncated::after { content: ''; position: absolute; bottom: 32px; left: 0; right: 0; height: 60px; background: linear-gradient(to bottom, transparent, var(--card-bg)); pointer-events: none; } -.message.user .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--user-bg)); } -.message.tool-reply .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } -.tool-use .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-bg)); } -.tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-result-bg)); } -.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); } -.expand-btn:hover { background: rgba(0,0,0,0.1); } -.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; } -.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; } -.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; } -.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); } -.pagination a:hover { background: var(--user-bg); } -.pagination .current { background: var(--user-border); color: white; } -.pagination .disabled { color: var(--text-muted); border: 1px solid #ddd; } -.pagination .index-link { background: var(--user-border); color: white; } -details.continuation { margin-bottom: 16px; } -details.continuation summary { cursor: pointer; padding: 12px 16px; background: var(--user-bg); border-left: 4px solid var(--user-border); border-radius: 12px; font-weight: 500; color: var(--text-muted); } -details.continuation summary:hover { background: rgba(25, 118, 210, 0.15); } -details.continuation[open] summary { border-radius: 12px 12px 0 0; margin-bottom: 0; } -.index-item { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); background: var(--user-bg); border-left: 4px solid var(--user-border); } -.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-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); } -.index-item-commit { margin-top: 6px; padding: 4px 8px; background: #fff3e0; border-radius: 4px; font-size: 0.85rem; color: #e65100; } -.index-item-commit code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; margin-right: 6px; } -.commit-card { margin: 8px 0; padding: 10px 14px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 6px; } -.commit-card a { text-decoration: none; color: #5d4037; display: block; } -.commit-card a:hover { color: #e65100; } -.commit-card-hash { font-family: monospace; color: #e65100; font-weight: 600; margin-right: 8px; } -.index-commit { margin-bottom: 12px; padding: 10px 16px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } -.index-commit a { display: block; text-decoration: none; color: inherit; } -.index-commit a:hover { background: rgba(255, 152, 0, 0.1); margin: -10px -16px; padding: 10px 16px; border-radius: 8px; } -.index-commit-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; margin-bottom: 4px; } -.index-commit-hash { font-family: monospace; color: #e65100; font-weight: 600; } -.index-commit-msg { color: #5d4037; } -.index-item-long-text { margin-top: 8px; padding: 12px; background: var(--card-bg); border-radius: 8px; border-left: 3px solid var(--assistant-border); } -.index-item-long-text .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--card-bg)); } -.index-item-long-text-content { color: var(--text-color); } -#search-box { display: none; align-items: center; gap: 8px; } -#search-box input { padding: 6px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; width: 180px; } -#search-box button, #modal-search-btn, #modal-close-btn { background: var(--user-border); color: white; border: none; border-radius: 6px; padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; } -#search-box button:hover, #modal-search-btn:hover { background: #1565c0; } -#modal-close-btn { background: var(--text-muted); margin-left: 8px; } -#modal-close-btn:hover { background: #616161; } -#search-modal[open] { border: none; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.2); padding: 0; width: 90vw; max-width: 900px; height: 80vh; max-height: 80vh; display: flex; flex-direction: column; } -#search-modal::backdrop { background: rgba(0,0,0,0.5); } -.search-modal-header { display: flex; align-items: center; gap: 8px; padding: 16px; border-bottom: 1px solid var(--assistant-border); background: var(--bg-color); border-radius: 12px 12px 0 0; } -.search-modal-header input { flex: 1; padding: 8px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; } -#search-status { padding: 8px 16px; font-size: 0.85rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } -#search-results { flex: 1; overflow-y: auto; padding: 16px; } -.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-content { padding: 12px; } -.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } -@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; } } -""" + 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, + ) -JS = """ -document.querySelectorAll('time[data-timestamp]').forEach(function(el) { - const timestamp = el.getAttribute('data-timestamp'); - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - if (isToday) { el.textContent = timeStr; } - else { el.textContent = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + timeStr; } -}); -document.querySelectorAll('pre.json').forEach(function(el) { - let text = el.textContent; - text = text.replace(/"([^"]+)":/g, '"$1":'); - text = text.replace(/: "([^"]*)"/g, ': "$1"'); - text = text.replace(/: (\\d+)/g, ': $1'); - text = text.replace(/: (true|false|null)/g, ': $1'); - el.innerHTML = text; -}); -document.querySelectorAll('.truncatable').forEach(function(wrapper) { - const content = wrapper.querySelector('.truncatable-content'); - const btn = wrapper.querySelector('.expand-btn'); - if (content.scrollHeight > 250) { - wrapper.classList.add('truncated'); - btn.addEventListener('click', function() { - if (wrapper.classList.contains('truncated')) { wrapper.classList.remove('truncated'); wrapper.classList.add('expanded'); btn.textContent = 'Show less'; } - else { wrapper.classList.remove('expanded'); wrapper.classList.add('truncated'); btn.textContent = 'Show more'; } - }); - } -}); -""" # JavaScript to fix relative URLs when served via gistpreview.github.io GIST_PREVIEW_JS = r""" @@ -1230,8 +1113,8 @@ def generate_html(json_path, output_dir, github_repo=None): pagination_html = generate_pagination_html(page_num, total_pages) page_template = get_template("page.html") page_content = page_template.render( - css=CSS, - js=JS, + css=get_css(), + js=get_js(), page_num=page_num, total_pages=total_pages, pagination_html=pagination_html, @@ -1293,7 +1176,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)) @@ -1311,8 +1199,8 @@ def generate_html(json_path, output_dir, github_repo=None): index_pagination = generate_index_pagination_html(total_pages) index_template = get_template("index.html") index_content = index_template.render( - css=CSS, - js=JS, + css=get_css(), + js=get_js(), pagination_html=index_pagination, prompt_num=prompt_num, total_messages=total_messages, @@ -1645,8 +1533,8 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): pagination_html = generate_pagination_html(page_num, total_pages) page_template = get_template("page.html") page_content = page_template.render( - css=CSS, - js=JS, + css=get_css(), + js=get_js(), page_num=page_num, total_pages=total_pages, pagination_html=pagination_html, @@ -1708,7 +1596,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)) @@ -1726,8 +1619,8 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): index_pagination = generate_index_pagination_html(total_pages) index_template = get_template("index.html") index_content = index_template.render( - css=CSS, - js=JS, + css=get_css(), + js=get_js(), pagination_html=index_pagination, prompt_num=prompt_num, total_messages=total_messages, 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) %} -
#{{ prompt_num }}
{{ rendered_content|safe }}
{{ stats_html|safe }}
+{% macro index_item(prompt_num, link, timestamp, rendered_content, stats_html, copy_content=None) %} +
#{{ prompt_num }}{% if copy_content %}{% endif %}
{{ rendered_content|safe }}
{{ stats_html|safe }}
{%- 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 -%} -
{{ commit_hash[:7] }}
{{ commit_msg }}
+
{{ commit_hash[:7] }}
{{ commit_msg }}
{%- else -%} -
{{ commit_hash[:7] }}
{{ commit_msg }}
+
{{ commit_hash[:7] }}
{{ commit_msg }}
{%- endif %} {%- endmacro %} diff --git a/src/claude_code_transcripts/templates/scripts.js b/src/claude_code_transcripts/templates/scripts.js new file mode 100644 index 0000000..a96ea4e --- /dev/null +++ b/src/claude_code_transcripts/templates/scripts.js @@ -0,0 +1,64 @@ +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); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (isToday) { el.textContent = timeStr; } + else { el.textContent = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + timeStr; } +}); +document.querySelectorAll('pre.json').forEach(function(el) { + let text = el.textContent; + text = text.replace(/"([^"]+)":/g, '"$1":'); + text = text.replace(/: "([^"]*)"/g, ': "$1"'); + text = text.replace(/: (\d+)/g, ': $1'); + text = text.replace(/: (true|false|null)/g, ': $1'); + el.innerHTML = text; +}); +document.querySelectorAll('.truncatable').forEach(function(wrapper) { + const content = wrapper.querySelector('.truncatable-content'); + const btn = wrapper.querySelector('.expand-btn'); + if (content.scrollHeight > 250) { + wrapper.classList.add('truncated'); + btn.addEventListener('click', function() { + if (wrapper.classList.contains('truncated')) { wrapper.classList.remove('truncated'); wrapper.classList.add('expanded'); btn.textContent = 'Show less'; } + else { wrapper.classList.remove('expanded'); wrapper.classList.add('truncated'); btn.textContent = 'Show more'; } + }); + } +}); 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 = '
' + + '' + + '' + escapeHtml(pageFile) + '' + + '
' + + '' + '
' + clone.innerHTML + '
' + '
'; searchResults.appendChild(resultDiv); diff --git a/src/claude_code_transcripts/templates/styles.css b/src/claude_code_transcripts/templates/styles.css new file mode 100644 index 0000000..0244563 --- /dev/null +++ b/src/claude_code_transcripts/templates/styles.css @@ -0,0 +1,144 @@ +:root { --bg-color: #f5f5f5; --card-bg: #ffffff; --user-bg: #e3f2fd; --user-border: #1976d2; --assistant-bg: #f5f5f5; --assistant-border: #9e9e9e; --thinking-bg: #fff8e1; --thinking-border: #ffc107; --thinking-text: #666; --tool-bg: #f3e5f5; --tool-border: #9c27b0; --tool-result-bg: #e8f5e9; --tool-error-bg: #ffebee; --text-color: #212121; --text-muted: #757575; --code-bg: #263238; --code-text: #aed581; } +* { box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-color); color: var(--text-color); margin: 0; padding: 16px; line-height: 1.6; } +.container { max-width: 800px; margin: 0 auto; } +h1 { font-size: 1.5rem; margin-bottom: 24px; padding-bottom: 8px; border-bottom: 2px solid var(--user-border); } +.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; border-bottom: 2px solid var(--user-border); padding-bottom: 8px; margin-bottom: 24px; } +.header-row h1 { border-bottom: none; padding-bottom: 0; margin-bottom: 0; flex: 1; min-width: 200px; } +.message { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +.message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } +.message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } +.message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } +.tool-reply .role-label { color: #e65100; } +.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; } +.timestamp-link { color: inherit; text-decoration: none; } +.timestamp-link:hover { text-decoration: underline; } +.message:target { animation: highlight 2s ease-out; } +@keyframes highlight { 0% { background-color: rgba(25, 118, 210, 0.2); } 100% { background-color: transparent; } } +.message-content { padding: 16px; } +.message-content p { margin: 0 0 12px 0; } +.message-content p:last-child { margin-bottom: 0; } +.thinking { background: var(--thinking-bg); border: 1px solid var(--thinking-border); border-radius: 8px; padding: 12px; margin: 12px 0; font-size: 0.9rem; color: var(--thinking-text); } +.thinking-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: #f57c00; margin-bottom: 8px; } +.thinking p { margin: 8px 0; } +.assistant-text { margin: 8px 0; } +.tool-use { background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 8px; padding: 12px; margin: 12px 0; } +.tool-header { font-weight: 600; color: var(--tool-border); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } +.tool-icon { font-size: 1.1rem; } +.tool-description { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; font-style: italic; } +.tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } +.tool-result.tool-error { background: var(--tool-error-bg); } +.file-tool { border-radius: 8px; padding: 12px; margin: 12px 0; } +.write-tool { background: linear-gradient(135deg, #e3f2fd 0%, #e8f5e9 100%); border: 1px solid #4caf50; } +.edit-tool { background: linear-gradient(135deg, #fff3e0 0%, #fce4ec 100%); border: 1px solid #ff9800; } +.file-tool-header { font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; } +.write-header { color: #2e7d32; } +.edit-header { color: #e65100; } +.file-tool-icon { font-size: 1rem; } +.file-tool-path { font-family: monospace; background: rgba(0,0,0,0.08); padding: 2px 8px; border-radius: 4px; } +.file-tool-fullpath { font-family: monospace; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; word-break: break-all; } +.file-content { margin: 0; } +.edit-section { display: flex; margin: 4px 0; border-radius: 4px; overflow: hidden; } +.edit-label { padding: 8px 12px; font-weight: bold; font-family: monospace; display: flex; align-items: flex-start; } +.edit-old { background: #fce4ec; } +.edit-old .edit-label { color: #b71c1c; background: #f8bbd9; } +.edit-old .edit-content { color: #880e4f; } +.edit-new { background: #e8f5e9; } +.edit-new .edit-label { color: #1b5e20; background: #a5d6a7; } +.edit-new .edit-content { color: #1b5e20; } +.edit-content { margin: 0; flex: 1; background: transparent; font-size: 0.85rem; } +.edit-replace-all { font-size: 0.75rem; font-weight: normal; color: var(--text-muted); } +.write-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #e6f4ea); } +.edit-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff0e5); } +.todo-list { background: linear-gradient(135deg, #e8f5e9 0%, #f1f8e9 100%); border: 1px solid #81c784; border-radius: 8px; padding: 12px; margin: 12px 0; } +.todo-header { font-weight: 600; color: #2e7d32; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; } +.todo-items { list-style: none; margin: 0; padding: 0; } +.todo-item { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(0,0,0,0.06); font-size: 0.9rem; } +.todo-item:last-child { border-bottom: none; } +.todo-icon { flex-shrink: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-weight: bold; border-radius: 50%; } +.todo-completed .todo-icon { color: #2e7d32; background: rgba(46, 125, 50, 0.15); } +.todo-completed .todo-content { color: #558b2f; text-decoration: line-through; } +.todo-in-progress .todo-icon { color: #f57c00; background: rgba(245, 124, 0, 0.15); } +.todo-in-progress .todo-content { color: #e65100; font-weight: 500; } +.todo-pending .todo-icon { color: #757575; background: rgba(0,0,0,0.05); } +.todo-pending .todo-content { color: #616161; } +pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } +pre.json { color: #e0e0e0; } +code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } +pre code { background: none; padding: 0; } +.user-content { margin: 0; } +.truncatable { position: relative; } +.truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } +.truncatable.truncated::after { content: ''; position: absolute; bottom: 32px; left: 0; right: 0; height: 60px; background: linear-gradient(to bottom, transparent, var(--card-bg)); pointer-events: none; } +.message.user .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--user-bg)); } +.message.tool-reply .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } +.tool-use .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-bg)); } +.tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-result-bg)); } +.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); } +.expand-btn:hover { background: rgba(0,0,0,0.1); } +.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; } +.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; } +.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; } +.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); } +.pagination a:hover { background: var(--user-bg); } +.pagination .current { background: var(--user-border); color: white; } +.pagination .disabled { color: var(--text-muted); border: 1px solid #ddd; } +.pagination .index-link { background: var(--user-border); color: white; } +details.continuation { margin-bottom: 16px; } +details.continuation summary { cursor: pointer; padding: 12px 16px; background: var(--user-bg); border-left: 4px solid var(--user-border); border-radius: 12px; font-weight: 500; color: var(--text-muted); } +details.continuation summary:hover { background: rgba(25, 118, 210, 0.15); } +details.continuation[open] summary { border-radius: 12px 12px 0 0; margin-bottom: 0; } +.index-item { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); background: var(--user-bg); border-left: 4px solid var(--user-border); } +.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); } +.index-item-commit { margin-top: 6px; padding: 4px 8px; background: #fff3e0; border-radius: 4px; font-size: 0.85rem; color: #e65100; } +.index-item-commit code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; margin-right: 6px; } +.commit-card { margin: 8px 0; padding: 10px 14px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 6px; } +.commit-card a { text-decoration: none; color: #5d4037; display: block; } +.commit-card a:hover { color: #e65100; } +.commit-card-hash { font-family: monospace; color: #e65100; font-weight: 600; margin-right: 8px; } +.index-commit { margin-bottom: 12px; padding: 10px 16px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } +.index-commit a { display: block; text-decoration: none; color: inherit; } +.index-commit a:hover { background: rgba(255, 152, 0, 0.1); margin: -10px -16px; padding: 10px 16px; border-radius: 8px; } +.index-commit-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; margin-bottom: 4px; } +.index-commit-hash { font-family: monospace; color: #e65100; font-weight: 600; } +.index-commit-msg { color: #5d4037; } +.index-item-long-text { margin-top: 8px; padding: 12px; background: var(--card-bg); border-radius: 8px; border-left: 3px solid var(--assistant-border); } +.index-item-long-text .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--card-bg)); } +.index-item-long-text-content { color: var(--text-color); } +#search-box { display: none; align-items: center; gap: 8px; } +#search-box input { padding: 6px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; width: 180px; } +#search-box button, #modal-search-btn, #modal-close-btn { background: var(--user-border); color: white; border: none; border-radius: 6px; padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; } +#search-box button:hover, #modal-search-btn:hover { background: #1565c0; } +#modal-close-btn { background: var(--text-muted); margin-left: 8px; } +#modal-close-btn:hover { background: #616161; } +#search-modal[open] { border: none; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.2); padding: 0; width: 90vw; max-width: 900px; height: 80vh; max-height: 80vh; display: flex; flex-direction: column; } +#search-modal::backdrop { background: rgba(0,0,0,0.5); } +.search-modal-header { display: flex; align-items: center; gap: 8px; padding: 16px; border-bottom: 1px solid var(--assistant-border); background: var(--bg-color); border-radius: 12px 12px 0 0; } +.search-modal-header input { flex: 1; padding: 8px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; } +#search-status { padding: 8px 16px; font-size: 0.85rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } +#search-results { flex: 1; overflow-y: auto; padding: 16px; } +.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-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; } } 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..4976bdb 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 @@ -4,8 +4,7 @@ Claude Code transcript - Index - +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 +172,15 @@

Claude Code transcript

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

-
#1

Create a simple Python function to add two numbers

3 bash · 1 write · 1 todo -
abc1234
Add math_utils with add function
-
#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 +372,11 @@

Claude Code transcript

var resultDiv = document.createElement('div'); resultDiv.className = 'search-result'; - resultDiv.innerHTML = '' + - '
' + escapeHtml(pageFile) + '
' + + resultDiv.innerHTML = '
' + + '' + '
' + clone.innerHTML + '
' + '
'; searchResults.appendChild(resultDiv); @@ -480,7 +489,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 cdc794b..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 - +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; } }
@@ -157,9 +163,9 @@

Claude C

-
+

Create a simple Python function to add two numbers

-
+
Thinking

The user wants a simple addition function. I should: 1. Create the function 2. Add a basic test

@@ -172,51 +178,51 @@

Claude C return a + b

-
File written successfully
-
+
File written successfully
+

I've created the add function. Now let me run the tests.

$ Bash
Run pytest on tests directory
python -m pytest tests/
-
===== test session starts =====
+
===== test session starts =====
 collected 2 items
 
 tests/test_math.py ..  [100%]
 
 ===== 2 passed in 0.05s =====
-
+

All tests pass. Let me update the todo list and make a commit.

Task List
  • Create add function
  • Write tests
  • Run tests
  • Commit changes
  • Push to remote
-
Todos updated
-
+
Todos updated
+
$ Bash
Commit changes
git add . && git commit -m 'Add math_utils with add function'
-
1 file changed, 5 insertions(+)
-
+
1 file changed, 5 insertions(+)
+

Committed! Now pushing to remote.

$ Bash
Push to remote
git push -u origin main
-
remote: 
+
remote: 
 remote: Create a pull request for 'main' on GitHub by visiting:
 remote:      https://github.com/example/project/pull/new/main
 remote: 
 To github.com:example/project.git
    def5678..abc1234  main -> main
-
+

Now edit the file to add a subtract function

-
+
Glob
{
   "pattern": "**/*.py",
   "path": "/project"
 }
-
/project/math_utils.py
+
/project/math_utils.py
 /project/tests/test_math.py
-
+
✏️ Edit math_utils.py
/project/math_utils.py
@@ -231,26 +237,36 @@

Claude C

-
File edited successfully
-
+
File edited successfully
+

Added the subtract function. Let me verify with grep.

Grep
{
   "pattern": "def subtract",
   "path": "/project",
   "output_mode": "content"
 }
-
/project/math_utils.py:6:def subtract(a: int, b: int) -> int:
-
+
/project/math_utils.py:6:def subtract(a: int, b: int) -> int:
+

Run the tests again

-
+
$ Bash
Run tests with verbose output
python -m pytest tests/ -v
-
Exit code 1
+
Exit code 1
 ===== FAILURES =====
 test_subtract - AssertionError: expected 5 but got None
-
+

There's a test failure. I need to check the test file and fix it.

Here's some markdown content with: - A bullet list @@ -259,9 +275,9 @@

Claude C
def example():
     return 42
 

-
+

Fix the issue and commit

-
+
✏️ Edit test_math.py (replace all)
/project/tests/test_math.py
-
File edited successfully
-
+
File edited successfully
+
$ Bash
Commit the fix
git add . && git commit -m 'Add subtract function and fix tests'
-
2 files changed, 10 insertions(+), 1 deletion(-)
-
+
2 files changed, 10 insertions(+), 1 deletion(-)
+

Done! The subtract function is now working and committed.

Session continuation summary -
+

This is a session continuation summary from a previous context. The user was working on a math utilities library.

@@ -291,7 +307,42 @@

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 2d46a78..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 - +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; } } -
+

Add a multiply function too

-
+
✏️ Edit math_utils.py
/project/math_utils.py
@@ -174,8 +180,8 @@

Claude C

-
File edited successfully
-
+
File edited successfully
+

Added multiply function!

@@ -188,7 +194,42 @@

Claude C

- +}); \ 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 318283c..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 - +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; } }
@@ -165,9 +171,9 @@

Claude Code transcript

2 prompts · 7 messages · 2 tool calls · 1 commits · 1 pages

-
abc1234
Add hello function
- +
abc1234
Add hello function
+ - +}); \ No newline at end of file diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index b79542b..2aae8af 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1537,3 +1537,60 @@ def test_search_total_pages_available(self, output_dir): # Total pages should be embedded for JS to know how many pages to fetch assert "totalPages" in index_html or "total_pages" in index_html + + +class TestCopyButton: + """Tests for the copy button web component feature.""" + + def test_copy_button_component_defined(self, output_dir): + """Test that the copy-button web component is defined in JavaScript.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # The custom element should be defined + assert "customElements.define" in page_html + assert "copy-button" in page_html + + def test_page_messages_have_copy_buttons(self, output_dir): + """Test that messages in page HTML have copy buttons with data-content.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # Messages should contain copy-button elements + assert " 0, "Copy buttons should be present in messages" + + def test_copy_button_visible_in_index(self, page): + """Test that copy buttons are visible in index items.""" + page_obj, output_dir = page + page_obj.goto(f"file://{output_dir}/index.html") + + # Wait for copy buttons to be rendered + copy_buttons = page_obj.locator("copy-button") + assert copy_buttons.count() > 0, "Copy buttons should be present in index" + + def test_copy_button_click_changes_text(self, page): + """Test that clicking a copy button changes the text to 'Copied!'.""" + page_obj, output_dir = page + page_obj.goto(f"file://{output_dir}/page-001.html") + + # Grant clipboard permissions + page_obj.context.grant_permissions(["clipboard-write", "clipboard-read"]) + + # Find first copy button and click it + copy_button = page_obj.locator("copy-button").first + copy_button.wait_for(state="attached") + + # Get the shadow DOM button + button = copy_button.locator("button") + original_text = button.inner_text() + + # Click the button + button.click() + + # Check that text changed to "Copied!" + assert ( + button.inner_text() == "Copied!" + ), "Button text should change to 'Copied!' after click" + + # Wait 2.5 seconds and check it reverts + page_obj.wait_for_timeout(2500) + assert ( + button.inner_text() == original_text + ), "Button text should revert after 2 seconds" + + def test_copy_button_copies_markdown_content(self, page): + """Test that clicking a copy button actually copies content.""" + page_obj, output_dir = page + page_obj.goto(f"file://{output_dir}/page-001.html") + + # Grant clipboard permissions + page_obj.context.grant_permissions(["clipboard-write", "clipboard-read"]) + + # Find a copy button with data-content (markdown) + copy_button = page_obj.locator("copy-button[data-content]").first + copy_button.wait_for(state="attached") + + # Get the content that should be copied + expected_content = copy_button.get_attribute("data-content") + + # Click the button + button = copy_button.locator("button") + button.click() + + # Read from clipboard + clipboard_content = page_obj.evaluate("navigator.clipboard.readText()") + + assert ( + clipboard_content == expected_content + ), "Clipboard should contain the data-content value" + + +class TestSearchPlaywright: + """Playwright tests for the search feature.""" + + def test_search_box_hidden_by_default(self, page): + """Test that search box is hidden by default (progressive enhancement).""" + page_obj, output_dir = page + # Load the page with JS disabled to test progressive enhancement + page_obj.context.set_offline(False) + page_obj.goto(f"file://{output_dir}/index.html", wait_until="domcontentloaded") + + # Note: With file:// protocol, search is hidden + # This test verifies the file:// protocol behavior + search_box = page_obj.locator("#search-box") + # On file:// protocol, search box should remain hidden + assert not search_box.is_visible() or search_box.evaluate( + "el => getComputedStyle(el).display === 'none'" + ) + + def test_search_modal_exists(self, page): + """Test that the search modal element exists.""" + page_obj, output_dir = page + page_obj.goto(f"file://{output_dir}/index.html") + + # The modal should exist in the DOM + modal = page_obj.locator("#search-modal") + assert modal.count() == 1, "Search modal should exist" + + +class TestTimestampFormatting: + """Test that timestamps are formatted correctly by JavaScript.""" + + def test_timestamps_formatted(self, page): + """Test that timestamps are formatted by JavaScript.""" + page_obj, output_dir = page + page_obj.goto(f"file://{output_dir}/page-001.html") + + # Wait for JS to run + page_obj.wait_for_load_state("networkidle") + + # Get a timestamp element + time_element = page_obj.locator("time[data-timestamp]").first + time_element.wait_for(state="attached") + + # The text should be formatted (not the raw ISO timestamp) + text = time_element.inner_text() + # Raw format is like "2025-01-01T12:00:00.000Z" + # Formatted should be like "Jan 1 12:00" or just "12:00" + assert "T" not in text, "Timestamp should be formatted, not raw ISO format" + assert "Z" not in text, "Timestamp should be formatted, not raw ISO format"